From b18e6215bfd06392cfd991dc95711fb18020f270 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Thu, 31 Jul 2025 09:54:57 +0700 Subject: [PATCH 001/106] feat: wallet unlock on wallet screen (#415) --- src/ui/wallets/wallets_screen/mod.rs | 141 +++++++++++++++++++-------- 1 file changed, 98 insertions(+), 43 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 5723a70af..edfa21969 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -6,6 +6,7 @@ use crate::model::wallet::Wallet; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike, ScreenType}; use chrono::{DateTime, Utc}; @@ -45,6 +46,9 @@ pub struct WalletsBalancesScreen { refreshing: bool, show_rename_dialog: bool, rename_input: String, + wallet_password: String, + show_password: bool, + error_message: Option, } pub trait DerivationPathHelpers { @@ -134,6 +138,9 @@ impl WalletsBalancesScreen { refreshing: false, show_rename_dialog: false, rename_input: String::new(), + wallet_password: String::new(), + show_password: false, + error_message: None, } } @@ -144,10 +151,17 @@ impl WalletsBalancesScreen { wallet.receive_address(self.app_context.network, true, Some(&self.app_context)) }; - // Now the immutable borrow of `wallet` is dropped, and we can use `self` mutably - if let Err(e) = result { - self.display_message(&e, MessageType::Error); + match result { + Ok(address) => { + let message = format!("Added new receiving address: {}", address); + self.display_message(&message, MessageType::Success); + } + Err(e) => { + self.display_message(&e, MessageType::Error); + } } + } else { + self.display_message("No wallet selected", MessageType::Error); } } @@ -553,14 +567,27 @@ impl WalletsBalancesScreen { fn render_bottom_options(&mut self, ui: &mut Ui) { if self.selected_filters.contains("Funds") { ui.add_space(10.0); - ui.horizontal(|ui| { - if ui - .button(RichText::new("➕ Add Receiving Address").size(14.0)) - .clicked() - { - self.add_receiving_address(); - } - }); + + // Check if wallet is unlocked + let wallet_is_open = if let Some(wallet_guard) = &self.selected_wallet { + wallet_guard.read().unwrap().is_open() + } else { + false + }; + + if wallet_is_open { + ui.horizontal(|ui| { + if ui + .button(RichText::new("➕ Add Receiving Address").size(14.0)) + .clicked() + { + self.add_receiving_address(); + } + }); + } else { + // Show wallet unlock UI + self.render_wallet_unlock_if_needed(ui); + } } } @@ -725,15 +752,7 @@ impl WalletsBalancesScreen { } fn check_message_expiration(&mut self) { - if let Some((_, _, timestamp)) = &self.message { - let now = Utc::now(); - let elapsed = now.signed_duration_since(*timestamp); - - // Automatically dismiss the message after 10 seconds - if elapsed.num_seconds() >= 10 { - self.dismiss_message(); - } - } + // Messages no longer auto-expire, they must be dismissed manually } } @@ -799,6 +818,35 @@ impl ScreenLike for WalletsBalancesScreen { let mut inner_action = AppAction::None; let dark_mode = ui.ctx().style().visuals.dark_mode; + // Display messages at the top, outside of scroll area + let message = self.message.clone(); + if let Some((message, message_type, _timestamp)) = message { + let message_color = match message_type { + MessageType::Error => egui::Color32::from_rgb(255, 100, 100), + MessageType::Info => DashColors::text_primary(dark_mode), + MessageType::Success => egui::Color32::from_rgb(100, 255, 100), + }; + + // Display message in a prominent frame + ui.horizontal(|ui| { + Frame::new() + .fill(message_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, message_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(message).color(message_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.dismiss_message(); + } + }); + }); + }); + ui.add_space(10.0); + } + egui::ScrollArea::vertical() .auto_shrink([false; 2]) .show(ui, |ui| { @@ -877,29 +925,6 @@ impl ScreenLike for WalletsBalancesScreen { } }); - let message = self.message.clone(); - if let Some((message, message_type, timestamp)) = message { - let message_color = match message_type { - MessageType::Error => egui::Color32::DARK_RED, - MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => egui::Color32::DARK_GREEN, - }; - - ui.add_space(25.0); // Same space as refreshing indicator - ui.horizontal(|ui| { - ui.add_space(10.0); - - // Calculate remaining seconds - let now = Utc::now(); - let elapsed = now.signed_duration_since(timestamp); - let remaining = (10 - elapsed.num_seconds()).max(0); - - // Add the message with auto-dismiss countdown - let full_msg = format!("{} ({}s)", message, remaining); - ui.label(egui::RichText::new(full_msg).color(message_color)); - }); - ui.add_space(2.0); // Same space below as refreshing indicator - } inner_action }); @@ -985,3 +1010,33 @@ impl ScreenLike for WalletsBalancesScreen { fn refresh(&mut self) {} } + +impl ScreenWithWalletUnlock for WalletsBalancesScreen { + fn selected_wallet_ref(&self) -> &Option>> { + &self.selected_wallet + } + + fn wallet_password_ref(&self) -> &String { + &self.wallet_password + } + + fn wallet_password_mut(&mut self) -> &mut String { + &mut self.wallet_password + } + + fn show_password(&self) -> bool { + self.show_password + } + + fn show_password_mut(&mut self) -> &mut bool { + &mut self.show_password + } + + fn set_error_message(&mut self, error_message: Option) { + self.error_message = error_message; + } + + fn error_message(&self) -> Option<&String> { + self.error_message.as_ref() + } +} From 2ce10eeb0fc8e63b97ea4d65911f2620f5145f10 Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Thu, 31 Jul 2025 09:07:44 +0200 Subject: [PATCH 002/106] refactor: Amount type and AmountInput component (#417) * feat: amount input, first building version * test(amount): fixed tests * chore: I think final * chore: my_tokens display correct amount * chore: transfer tokens update * chore: hide unit on rewards estimate column * chore: two new helper methods * chore: I think finals * cargo fmt * feat: component trait * impl Component for AmountInput * chore: updated component trait * chore: update for egui enabled state mgmt * doc: component design pattern doc * chore: component design pattern continued * chore: amount improvements * chore: copilot review * chore: amount improvements * backport: amount component from * chore: fix imports * chore: refactor * chore: futher refactor * chore: further refactor based on feedback * doc: simplified component design pattern description * chore: peer review * doc: update docs * chore: amount input --- doc/COMPONENT_DESIGN_PATTERN.md | 99 ++++ src/model/amount.rs | 692 +++++++++++++++++++++++ src/model/mod.rs | 1 + src/ui/components/amount_input.rs | 512 +++++++++++++++++ src/ui/components/component_trait.rs | 95 ++++ src/ui/components/mod.rs | 5 + src/ui/helpers.rs | 4 + src/ui/identities/transfer_screen.rs | 86 ++- src/ui/identities/withdraw_screen.rs | 84 +-- src/ui/tokens/tokens_screen/groups.rs | 2 +- src/ui/tokens/tokens_screen/my_tokens.rs | 31 +- src/ui/tokens/transfer_tokens_screen.rs | 219 +++---- src/utils/path.rs | 2 +- 13 files changed, 1617 insertions(+), 215 deletions(-) create mode 100644 doc/COMPONENT_DESIGN_PATTERN.md create mode 100644 src/model/amount.rs create mode 100644 src/ui/components/amount_input.rs create mode 100644 src/ui/components/component_trait.rs diff --git a/doc/COMPONENT_DESIGN_PATTERN.md b/doc/COMPONENT_DESIGN_PATTERN.md new file mode 100644 index 000000000..a81d3c94b --- /dev/null +++ b/doc/COMPONENT_DESIGN_PATTERN.md @@ -0,0 +1,99 @@ +# UI Component Design Pattern + +## Vision + +Imagine a library of ready-to-use widgets where you, as a developer, simply pick what you need. + +Need wallet selection? Grab `WalletChooserWidget`. It handles wallet selection, prompts for passwords when needed, validates user choices, and more. + +Need password entry? Use `PasswordWidget`. It manages passwords securely, masks input, validates complexity rules, and zeros memory after use. + +All widgets follow the same simple pattern: add 2 fields to your screen struct, lazy-load the widget, then bind it to your data with the `update()` method. + +## Quick Start: Using Components + +In this section, you will see how to use an existing component. + +### 1. Add fields to your screen struct +```rust +struct MyScreen { + amount: Option, // Domain data + amount_widget: Option, // UI component +} +``` + +### 2. Lazily initialize the component + +Inside your screen's `show()` method or simiar: + +```rust +let amount_widget = self.amount_widget.get_or_insert_with(|| { + AmountInput::new(amount_type) + .with_label("Amount:") +}); +``` + +### 3. Show component and handle updates + +After initialization above, use `update()` to bind your screen's field with the component: + +```rust +let response = amount_widget.show(ui); +response.inner.update(&mut self.amount); +``` + +### 4. Use the domain data +When `self.amount.is_some()`, the user has entered a valid amount. Use it for whatever you need. + +--- + +## Implementation Guidelines: Creating New Components + +In this screen, you will see generalized guidelines for creating a new component. + +### ✅ Component Structure Checklist +- [ ] Struct with private fields only +- [ ] `new()` constructor taking domain configuration +- [ ] Builder methods (`with_label()`, `with_max_amount()`, `with_hint_text()`, etc.) +- [ ] Response struct with `response`, `changed`, `error_message`, and domain-specific data fields + +### ✅ Trait Implementation Checklist +- [ ] Implement `Component` trait with `show()` method +- [ ] Implement `ComponentResponse` for response struct + +### ✅ Response Pattern +```rust +pub struct MyComponentResponse { + pub response: Response, + pub changed: bool, + pub error_message: Option, + // Add any component-specific fields as needed + pub parsed_data: Option, +} + +impl ComponentResponse for MyComponentResponse { + type DomainType = YourType; + + fn has_changed(&self) -> bool { self.changed } + fn is_valid(&self) -> bool { self.error_message.is_none() } + fn changed_value(&self) -> &Option { &self.parsed_data } + fn error_message(&self) -> Option<&str> { self.error_message.as_deref() } +} +``` + +### ✅ Best Practices +- [ ] Use lazy initialization (`Option`) +- [ ] Use egui's `add_enabled_ui()` for enabled/disabled state +- [ ] Set data to `None` when input changes but is invalid +- [ ] Provide fluent builder API for configuration +- [ ] Keep internal state private +- [ ] **Be self-contained**: Handle validation, error display, hints, and formatting internally (preferably with configurable error display) +- [ ] **Own your UX**: Component should manage its complete user experience + +### ❌ Anti-Patterns to Avoid +- Public mutable fields +- Managing enabled state in component +- Eager initialization +- Not clearing invalid data + +See `AmountInput` in `src/ui/components/amount_input.rs` for a complete example. diff --git a/src/model/amount.rs b/src/model/amount.rs new file mode 100644 index 000000000..dfa8b26bb --- /dev/null +++ b/src/model/amount.rs @@ -0,0 +1,692 @@ +use bincode::{Decode, Encode}; +use dash_sdk::dpp::balances::credits::{CREDITS_PER_DUFF, Duffs, TokenAmount}; +use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dash_sdk::dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; +use serde::{Deserialize, Serialize}; +use std::fmt::{Debug, Display}; + +/// How many decimal places are used for DASH amounts. +/// +/// This value is used to convert between DASH and credits. 1 DASH = 10.pow(DASH_DECIMAL_PLACES) +/// +/// 1 dash == 10e11 credits +pub const DASH_DECIMAL_PLACES: u8 = 11; + +/// Represents an amount of a token or cryptocurrency. +/// +/// As we cannot use floats to represent token amounts due to precision issues, we represent amounts as integers (u64) +/// with a specified number of decimal places. `Amount` is a generic type to handle these types of values. +/// +/// Internally, the value is stored as an integer (u64) representing the smallest unit of the +/// token (e.g., [Credits] for DASH), and the number of decimal places that is used to format it correctly. +#[derive(Serialize, Deserialize, Encode, Decode, Clone, PartialEq, Eq, Default)] +pub struct Amount { + /// Number of smallest units available for this token. + /// For example, for token value of `12.3450` with 4 decimal places, the stored value is `123450`. + value: u64, + /// Number of decimal places used for this token. + /// For example, for token value of `12.3450` that allows 4 decimal places, decimal_places is `4`. + decimal_places: u8, + unit_name: Option, +} + +impl PartialOrd for Amount { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.value.cmp(&other.value)) + } +} + +impl PartialEq for Amount { + fn eq(&self, other: &TokenAmount) -> bool { + self.value == *other + } +} + +impl PartialEq for &Amount { + fn eq(&self, other: &TokenAmount) -> bool { + self.value == *other + } +} + +impl Display for Amount { + /// Formats the TokenValue as a user-friendly string with optional unit name. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let amount_str = self.to_string_without_unit(); + + match &self.unit_name { + Some(unit) => write!(f, "{} {}", amount_str, unit), + None => write!(f, "{}", amount_str), + } + } +} + +impl Debug for Amount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Amount") + .field("value", &self.value) + .field("decimal_places", &self.decimal_places) + .field("unit_name", &self.unit_name) + .field("formatted", &self.to_string_without_unit()) + .finish() + } +} + +impl Amount { + /// Creates a new Amount. + /// + /// To set an unit name, use [Amount::with_unit_name]. + pub const fn new(value: TokenAmount, decimal_places: u8) -> Self { + Self { + value, + decimal_places, + unit_name: None, + } + } + + /// Creates a new Amount configured for a specific token. + /// + /// This extracts the decimal places and token alias from the token configuration + /// and creates an Amount with the specified value. + pub fn from_token( + value: TokenAmount, + token_info: &crate::ui::tokens::tokens_screen::IdentityTokenInfo, + ) -> Self { + let decimal_places = token_info.token_config.conventions().decimals(); + Self::new(value, decimal_places).with_unit_name(&token_info.token_alias) + } + + /// Creates a new Amount based on a floating-point value. + /// + /// Note that this is imprecise due to floating-point representation. Prefer using [Amount::new]. + pub fn try_from_f64(value: f64, decimal_places: u8) -> Result { + let value = checked_round(value * 10f64.powi(decimal_places as i32)) + .map_err(|e| format!("Invalid amount: {}", e))?; + Ok(Self::new(value, decimal_places)) + } + + /// Creates a new Amount from a string input with specified decimal places. + /// If the input string contains a unit suffix (e.g., "123.45 USD"), the unit name will be preserved. + pub fn parse(input: &str, decimal_places: u8) -> Result { + let (value, unit_name) = Self::parse_amount_string_with_unit(input, decimal_places)?; + match unit_name { + Some(unit) => Ok(Self::new(value, decimal_places).with_unit_name(&unit)), + None => Ok(Self::new(value, decimal_places)), + } + } + + /// Parses a string amount into the internal u64 representation. + /// Returns a tuple of (value, optional_unit_name). + /// Automatically extracts any unit suffix from the input string. + fn parse_amount_string_with_unit( + input: &str, + decimal_places: u8, + ) -> Result<(u64, Option), String> { + let input = input.trim(); + if input.is_empty() { + return Err("Invalid amount: cannot be empty".to_string()); + } + + // Split by whitespace to separate numeric part from potential unit + let parts: Vec<&str> = input.split_whitespace().collect(); + let numeric_part = parts.first().unwrap_or(&input); + let unit_name = if parts.len() > 1 { + Some(parts[1..].join(" ")) // Join remaining parts as unit name (handles multi-word units) + } else { + None + }; + + let value = Self::parse_numeric_part(numeric_part, decimal_places)?; + Ok((value, unit_name)) + } + + /// Parses the numeric part of an amount string. + fn parse_numeric_part(numeric_part: &str, decimal_places: u8) -> Result { + if decimal_places == 0 { + return numeric_part + .parse::() + .map_err(|e| format!("Invalid amount: {}", e)); + } + + let parts: Vec<&str> = numeric_part.split('.').collect(); + match parts.len() { + 1 => { + // No decimal point, parse as whole number + let whole = parts[0] + .parse::() + .map_err(|_| "Invalid amount: must be a number".to_string())?; + let multiplier = 10u64.pow(decimal_places as u32); + whole + .checked_mul(multiplier) + .ok_or_else(|| "Amount too large".to_string()) + } + 2 => { + // Has decimal point + let whole = if parts[0].is_empty() { + 0 + } else { + parts[0] + .parse::() + .map_err(|_| "Invalid amount: whole part must be a number".to_string())? + }; + + let fraction_str = parts[1]; + if fraction_str.len() > decimal_places as usize { + return Err(format!( + "Too many decimal places. Maximum allowed: {}", + decimal_places + )); + } + + // Pad with zeros if needed + let padded_fraction = + format!("{:0() + .map_err(|_| "Invalid amount: decimal part must be a number".to_string())?; + + let multiplier = 10u64.pow(decimal_places as u32); + let whole_part = whole + .checked_mul(multiplier) + .ok_or_else(|| "Amount too large".to_string())?; + + whole_part + .checked_add(fraction) + .ok_or_else(|| "Amount too large".to_string()) + } + _ => Err("Invalid amount: too many decimal points".to_string()), + } + } + + /// Converts the Amount to a f64 representation with the specified decimal places. + /// + /// Note this is a non-precise conversion, as f64 cannot represent all decimal values exactly. + pub fn to_f64(&self) -> f64 { + (self.value as f64) / 10u64.pow(self.decimal_places as u32) as f64 + } + + /// Returns the number of decimal places. + pub fn decimal_places(&self) -> u8 { + self.decimal_places + } + + /// Returns the value as the smallest unit (without decimal conversion). + pub fn value(&self) -> u64 { + self.value + } + + /// Returns the unit name if set. + pub fn unit_name(&self) -> Option<&str> { + self.unit_name.as_deref() + } + + /// Sets the unit name. + pub fn with_unit_name(mut self, unit_name: &str) -> Self { + self.unit_name = Some(unit_name.to_string()); + self + } + + /// Clears the unit name. + pub fn without_unit_name(mut self) -> Self { + self.unit_name = None; + self + } + + /// Returns the numeric string representation without the unit name. + /// This is useful for text input fields where only the number should be shown. + pub fn to_string_without_unit(&self) -> String { + if self.decimal_places == 0 { + self.value.to_string() + } else { + let divisor = 10u64.pow(self.decimal_places as u32); + let whole = self.value / divisor; + let fraction = self.value % divisor; + + if fraction == 0 { + whole.to_string() + } else { + // Format with the appropriate number of decimal places, removing trailing zeros + let fraction_str = + format!("{:0width$}", fraction, width = self.decimal_places as usize); + let trimmed = fraction_str.trim_end_matches('0'); + format!("{}.{}", whole, trimmed) + } + } + } + + /// Creates a new Amount with the specified value in TokenAmount. + pub fn with_value(mut self, value: TokenAmount) -> Self { + self.value = value; + self + } + + /// Updates the decimal places for this amount. + /// This adjusts the internal value to maintain the same displayed amount. + /// + /// If new decimal places are equal to the current ones, it does nothing. + pub fn recalculate_decimal_places(mut self, new_decimal_places: u8) -> Self { + if self.decimal_places != new_decimal_places { + let current_decimals = self.decimal_places; + + if new_decimal_places > current_decimals { + // More decimal places - multiply value + let factor = 10u64.pow((new_decimal_places - current_decimals) as u32); + self.value = self.value.saturating_mul(factor); + } else if new_decimal_places < current_decimals { + // Fewer decimal places - divide value + let factor = 10u64.pow((current_decimals - new_decimal_places) as u32); + self.value /= factor; + } + + self.decimal_places = new_decimal_places; + } + self + } + + /// Checks if the amount is for the same token as the other amount. + /// + /// This is determined by comparing the unit names and decimal places. + pub fn is_same_token(&self, other: &Self) -> bool { + self.unit_name == other.unit_name && self.decimal_places == other.decimal_places + } +} + +/// Dash-specific amount handling +impl Amount { + /// Create a new [Amount] representing some value in DASH. + /// + /// Create [Amount] representation of some value in DASH cryptocurrency (eg. `1.5`). + /// + /// Note: Due to use of float, this may not be precise. Use [Amount::new()] for exact values. + pub fn new_dash(dash_value: f64) -> Self { + const MULTIPLIER: f64 = 10u64.pow(DASH_DECIMAL_PLACES as u32) as f64; + // internally we store DASH as [Credits] in the Amount.value field + let credits = dash_value * MULTIPLIER; + Self::new( + checked_round(credits).expect("DASH value overflow"), + DASH_DECIMAL_PLACES, + ) + .with_unit_name("DASH") + } + + /// Return Amount representing Dash currency equal to the given duffs. + /// + /// This is a special case where we get Duffs (eg. from Core) and want to convert it to an Amount representing DASH. + pub fn dash_from_duffs(duffs: Duffs) -> Self { + let credits = duffs * CREDITS_PER_DUFF; + Self::new(credits, DASH_DECIMAL_PLACES).with_unit_name("DASH") + } + + /// Returns the DASH amount as duffs, rounded down to the nearest integer. + /// + /// ## Returns + /// + /// Returns error if the token is not DASH, eg. decimals != DASH_DECIMAL_PLACES or token name is neither `DASH` nor empty. + pub fn dash_to_duffs(&self) -> Result { + if self.unit_name.as_ref().is_some_and(|name| name != "DASH") { + return Err("Amount is not in DASH".into()); + } + if self.decimal_places != DASH_DECIMAL_PLACES { + return Err("Amount is not in DASH, decimal places mismatch".into()); + } + + self.value + .checked_div(CREDITS_PER_DUFF) + .ok_or("Division by zero in DASH to duffs conversion".to_string()) + } +} + +impl AsRef for Amount { + /// Returns a reference to the Amount. + fn as_ref(&self) -> &Self { + self + } +} + +/// Conversion implementations for token types +impl From<&crate::ui::tokens::tokens_screen::IdentityTokenBalance> for Amount { + /// Converts an IdentityTokenBalance to an Amount. + /// + /// The decimal places are automatically determined from the token configuration, + /// and the token alias is used as the unit name. + fn from(token_balance: &crate::ui::tokens::tokens_screen::IdentityTokenBalance) -> Self { + let decimal_places = token_balance.token_config.conventions().decimals(); + Self::new(token_balance.balance, decimal_places).with_unit_name(&token_balance.token_alias) + } +} + +impl From for Amount { + /// Converts an owned IdentityTokenBalance to an Amount. + fn from(token_balance: crate::ui::tokens::tokens_screen::IdentityTokenBalance) -> Self { + Self::from(&token_balance) + } +} + +impl From<&crate::ui::tokens::tokens_screen::IdentityTokenBalanceWithActions> for Amount { + /// Converts an IdentityTokenBalanceWithActions to an Amount. + /// + /// The decimal places are automatically determined from the token configuration, + /// and the token alias is used as the unit name. + fn from( + token_balance: &crate::ui::tokens::tokens_screen::IdentityTokenBalanceWithActions, + ) -> Self { + let decimal_places = token_balance.token_config.conventions().decimals(); + Self::new(token_balance.balance, decimal_places).with_unit_name(&token_balance.token_alias) + } +} + +impl From for Amount { + /// Converts an owned IdentityTokenBalanceWithActions to an Amount. + fn from( + token_balance: crate::ui::tokens::tokens_screen::IdentityTokenBalanceWithActions, + ) -> Self { + Self::from(&token_balance) + } +} + +/// Helper function to convert f64 to u64, with checks for overflow. +/// It rounds the value to the nearest u64, ensuring it is within bounds. +fn checked_round(value: f64) -> Result { + let rounded = value.round(); + if rounded < u64::MIN as f64 || rounded > u64::MAX as f64 { + return Err("Overflow: value outside of bounds".to_string()); + } + + Ok(rounded as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_token_amount_formatting() { + // Test 0 decimal places + assert_eq!(Amount::new(100, 0).to_string(), "100"); + + // Test 2 decimal places + assert_eq!(Amount::new(12345, 2).to_string(), "123.45"); + assert_eq!(Amount::new(12300, 2).to_string(), "123"); + assert_eq!(Amount::new(12340, 2).to_string(), "123.4"); + + // Test 8 decimal places + assert_eq!(Amount::new(100_000_000, 8).to_string(), "1"); + assert_eq!(Amount::new(150_000_000, 8).to_string(), "1.5"); + assert_eq!(Amount::new(123_456_789, 8).to_string(), "1.23456789"); + } + + #[test] + fn test_token_amount_parsing() { + // Test 0 decimal places + assert_eq!(Amount::parse("100", 0).unwrap(), 100); + + // Test 2 decimal places + assert_eq!(Amount::parse("123.45", 2).unwrap(), 12345); + assert_eq!(Amount::parse("123", 2).unwrap(), 12300); + assert_eq!(Amount::parse("123.4", 2).unwrap(), 12340); + + // Test 8 decimal places + assert_eq!(Amount::parse("1", 8).unwrap(), 100000000); + assert_eq!(Amount::parse("1.5", 8).unwrap(), 150000000); + assert_eq!(Amount::parse("1.23456789", 8).unwrap(), 123456789); + + assert_eq!(Amount::parse("1.5 DASH", 8).unwrap(), 150000000); + + // Test parsing amounts with unit suffixes + assert_eq!(Amount::parse("123.45 USD", 2).unwrap(), 12345); + assert_eq!(Amount::parse("1.0 BTC", 8).unwrap(), 100000000); + assert_eq!(Amount::parse("50 TOKEN", 0).unwrap(), 50); + } + + #[test] + fn test_dash_amounts() { + // Test Dash parsing + let dash_amount = Amount::parse("1.5", DASH_DECIMAL_PLACES).unwrap(); + assert_eq!(dash_amount.value(), 150_000_000_000); + assert_eq!(dash_amount.decimal_places(), DASH_DECIMAL_PLACES); + assert_eq!(dash_amount.unit_name(), None); // No unit name when not specified in input + + // Test Dash parsing with unit suffix + let dash_amount_with_unit = Amount::parse("1.5 DASH", DASH_DECIMAL_PLACES).unwrap(); + assert_eq!(dash_amount_with_unit.value(), 150_000_000_000); + assert_eq!(dash_amount_with_unit.decimal_places(), DASH_DECIMAL_PLACES); + assert_eq!(dash_amount_with_unit.unit_name(), Some("DASH")); + } + + #[test] + fn test_duffs_method() { + // Test creating DASH amounts from duffs + // 1 DASH = 100,000,000 duffs = 10^8 duffs + // 1 duff = 1000 credits (CREDITS_PER_DUFF) + // So 1 DASH = 10^8 * 10^3 = 10^11 credits + + let zero_duffs = Amount::dash_from_duffs(0); + assert_eq!(zero_duffs.value(), 0); + assert_eq!(zero_duffs.unit_name(), Some("DASH")); + assert_eq!(format!("{}", zero_duffs), "0 DASH"); + + let one_duff = Amount::dash_from_duffs(1); + assert_eq!(one_duff.value(), 1000); // 1 duff = 1000 credits + assert_eq!(one_duff.unit_name(), Some("DASH")); + assert_eq!(format!("{}", one_duff), "0.00000001 DASH"); + + let hundred_million_duffs = Amount::dash_from_duffs(100_000_000); // 1 DASH + assert_eq!(hundred_million_duffs.value(), 100_000_000_000); + assert_eq!(format!("{}", hundred_million_duffs), "1 DASH"); + + let one_and_half_dash_in_duffs = Amount::dash_from_duffs(150_000_000); // 1.5 DASH + assert_eq!(one_and_half_dash_in_duffs.value(), 150_000_000_000); + assert_eq!(format!("{}", one_and_half_dash_in_duffs), "1.5 DASH"); + } + + #[test] + fn test_to_duffs_method() { + // Test converting DASH amounts back to duffs + let one_dash = Amount::new_dash(1.0); + assert_eq!(one_dash.dash_to_duffs().unwrap(), 100_000_000); // 1 DASH = 10^8 duffs + + let half_dash = Amount::new_dash(0.5); + assert_eq!(half_dash.dash_to_duffs().unwrap(), 50_000_000); // 0.5 DASH = 5*10^7 duffs + + let one_and_half_dash = Amount::new_dash(1.5); + assert_eq!(one_and_half_dash.dash_to_duffs().unwrap(), 150_000_000); // 1.5 DASH = 1.5*10^8 duffs + + // Test with very small amounts + let one_credit = Amount::new(1, DASH_DECIMAL_PLACES).with_unit_name("DASH"); + assert_eq!(one_credit.dash_to_duffs().unwrap(), 0); // 1 credit = 0 duffs (rounded down) + + let thousand_credits = Amount::new(1000, DASH_DECIMAL_PLACES).with_unit_name("DASH"); + assert_eq!(thousand_credits.dash_to_duffs().unwrap(), 1); // 1000 credits = 1 duff + + // Test with amount without unit name (should work) + let dash_no_unit = Amount::new(100_000_000_000, DASH_DECIMAL_PLACES); + assert_eq!(dash_no_unit.dash_to_duffs().unwrap(), 100_000_000); + } + + #[test] + #[should_panic(expected = "Amount is not in DASH")] + fn test_to_duffs_panics_with_wrong_unit() { + let btc_amount = Amount::new(100_000_000, 8).with_unit_name("BTC"); + btc_amount.dash_to_duffs().unwrap(); // Should panic + } + + #[test] + #[should_panic(expected = "Amount is not in DASH, decimal places mismatch")] + fn test_to_duffs_panics_with_wrong_decimals() { + let wrong_decimals = Amount::new(100_000_000, 8).with_unit_name("DASH"); + wrong_decimals.dash_to_duffs().unwrap(); // Should panic + } + + #[test] + fn test_dash_duffs_roundtrip() { + // Test that duffs -> DASH -> duffs preserves the value + let original_duffs = 123_456_789u64; + let dash_amount = Amount::dash_from_duffs(original_duffs); + let converted_back = dash_amount.dash_to_duffs().unwrap(); + assert_eq!(original_duffs, converted_back); + + // Test edge cases + let zero_duffs = 0u64; + let zero_dash = Amount::dash_from_duffs(zero_duffs); + assert_eq!(zero_duffs, zero_dash.dash_to_duffs().unwrap()); + + let max_reasonable_duffs = 2_100_000_000_000_000u64; // 21M DASH * 10^8 + let max_dash = Amount::dash_from_duffs(max_reasonable_duffs); + assert_eq!(max_reasonable_duffs, max_dash.dash_to_duffs().unwrap()); + assert_eq!(max_reasonable_duffs * CREDITS_PER_DUFF, max_dash.value()); + assert_eq!(21_000_000.0, max_dash.to_f64()); + } + + #[test] + fn test_dash_precision() { + // Test that the dash() method handles precision correctly + // Note: Due to f64 limitations, very precise decimals might have rounding issues + + // Test values that should be exact in f64 + let exact_values = [0.0, 0.5, 1.0, 1.5, 2.0, 10.0, 100.0]; + for &value in &exact_values { + let dash_amount = Amount::new_dash(value); + let expected_credits = (value * 100_000_000_000.0).round() as u64; + assert_eq!(dash_amount.value(), expected_credits); + } + + // Test a value with 11 decimal places (max precision for DASH) + let precise_dash = Amount::new_dash(1.23456789012); // This might lose precision due to f64 + // We mainly test that it doesn't panic and creates a valid amount + assert!(precise_dash.value() > 0); + assert_eq!(precise_dash.unit_name(), Some("DASH")); + } + + #[test] + fn test_amount_display() { + let amount = Amount::new(12_345, 2); + assert_eq!(format!("{}", amount), "123.45"); + + let dash_amount = Amount::new_dash(1.5); + assert_eq!(format!("{}", dash_amount), "1.5 DASH"); + + // Test amount with custom unit name + let amount_with_unit = Amount::new(54321, 2).with_unit_name("USD"); + assert_eq!(format!("{}", amount_with_unit), "543.21 USD"); + } + + #[test] + fn test_unit_name_functionality() { + // Test creating amount with unit name + let amount = Amount::new(12345, 2).with_unit_name("USD"); + assert_eq!(amount.unit_name(), Some("USD")); + assert_eq!(amount.value(), 12345); + assert_eq!(amount.decimal_places(), 2); + assert_eq!(format!("{}", amount), "123.45 USD"); + + // Test adding unit name to existing amount + let amount = Amount::new(54321, 8).with_unit_name("BTC"); + assert_eq!(amount.unit_name(), Some("BTC")); + + // Test removing unit name + let amount = amount.without_unit_name(); + assert_eq!(amount.unit_name(), None); + + // Test Dash amounts include unit name + let dash_amount = Amount::new_dash(1.0); + assert_eq!(dash_amount.unit_name(), Some("DASH")); + + // Test parsing with_unit_name + let parsed = Amount::parse("123.45", 2).unwrap().with_unit_name("TOKEN"); + assert_eq!(parsed.unit_name(), Some("TOKEN")); + assert_eq!(parsed.value(), 12345); + } + + #[test] + fn test_parsing_errors() { + // Empty input + assert!(Amount::parse("", 2).is_err()); + + // Too many decimal places + assert!(Amount::parse("1.123", 2).is_err()); + + // Invalid characters + assert!(Amount::parse("abc", 2).is_err()); + + // Multiple decimal points + assert!(Amount::parse("1.2.3", 2).is_err()); + } + + #[test] + fn test_simplified_parsing_with_units() { + // Test the simplified API pattern: parse_with_decimals now preserves unit names automatically + let token_amount = Amount::parse("123.45 TOKEN", 2).unwrap(); + assert_eq!(token_amount.value(), 12345); + assert_eq!(token_amount.unit_name(), Some("TOKEN")); + assert_eq!(format!("{}", token_amount), "123.45 TOKEN"); + + // Test parsing with unit suffix automatically preserves the unit + let btc_amount = Amount::parse("0.5 BTC", 8).unwrap(); + assert_eq!(btc_amount.value(), 50000000); + assert_eq!(btc_amount.unit_name(), Some("BTC")); + assert_eq!(format!("{}", btc_amount), "0.5 BTC"); + + // Test parsing without unit in string results in no unit name + let no_unit_amount = Amount::parse("1.5", 11).unwrap(); + assert_eq!(no_unit_amount.value(), 150_000_000_000); + assert_eq!(no_unit_amount.unit_name(), None); + assert_eq!(format!("{}", no_unit_amount), "1.5"); + + // Test adding unit name manually when not present in string + let dash_amount = Amount::parse("1.5", 11).unwrap().with_unit_name("DASH"); + assert_eq!(dash_amount.value(), 150_000_000_000); + assert_eq!(dash_amount.unit_name(), Some("DASH")); + assert_eq!(format!("{}", dash_amount), "1.5 DASH"); + + // Test multi-word unit names + let multi_word_unit = Amount::parse("100 US Dollar", 2).unwrap(); + assert_eq!(multi_word_unit.value(), 10000); + assert_eq!(multi_word_unit.unit_name(), Some("US Dollar")); + assert_eq!(format!("{}", multi_word_unit), "100 US Dollar"); + } + + #[test] + fn test_to_string_without_unit() { + // Test amount without unit + let amount = Amount::new(12345, 2); + assert_eq!(amount.to_string_without_unit(), "123.45"); + assert_eq!(format!("{}", amount), "123.45"); // Display should be the same + + // Test amount with unit + let amount_with_unit = Amount::new(12345, 2).with_unit_name("USD"); + assert_eq!(amount_with_unit.to_string_without_unit(), "123.45"); // Without unit + assert_eq!(format!("{}", amount_with_unit), "123.45 USD"); // Display includes unit + + // Test Dash amount + let dash_amount = Amount::new_dash(1.5); // 1.5 DASH + assert_eq!(dash_amount.to_string_without_unit(), "1.5"); + assert_eq!(format!("{}", dash_amount), "1.5 DASH"); + assert_eq!(dash_amount.dash_to_duffs().unwrap(), 150_000_000); // 1.5 DASH in duffs + + // Test zero amount + let zero_amount = Amount::new(0, 8); + assert_eq!(zero_amount.to_string_without_unit(), "0"); + } + + #[test] + fn test_decimal_places_conversion() { + // Test converting from 2 decimal places to 8 decimal places + let amount = Amount::new(12345, 2); // 123.45 + let converted = amount.recalculate_decimal_places(8); + assert_eq!(converted.value(), 12345000000); // 123.45 with 8 decimals + assert_eq!(converted.decimal_places(), 8); + assert_eq!(format!("{}", converted), "123.45"); + + // Test converting from 8 decimal places to 2 decimal places + let amount = Amount::new(12345000000, 8); // 123.45 + let converted = amount.recalculate_decimal_places(2); + assert_eq!(converted.value(), 12345); // 123.45 with 2 decimals + assert_eq!(converted.decimal_places(), 2); + assert_eq!(format!("{}", converted), "123.45"); + + // Test no conversion (same decimal places) + let amount = Amount::new(12345, 2); + let same = amount.clone().recalculate_decimal_places(2); + assert_eq!(same.value(), 12345); + assert_eq!(same.decimal_places(), 2); + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index 4e3b91988..b04309945 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,3 +1,4 @@ +pub mod amount; pub mod contested_name; pub mod password_info; pub mod proof_log_item; diff --git a/src/ui/components/amount_input.rs b/src/ui/components/amount_input.rs new file mode 100644 index 000000000..8ef235d44 --- /dev/null +++ b/src/ui/components/amount_input.rs @@ -0,0 +1,512 @@ +use crate::model::amount::Amount; +use crate::ui::components::{Component, ComponentResponse}; +use dash_sdk::dpp::fee::Credits; +use egui::{InnerResponse, Response, TextEdit, Ui, Vec2, WidgetText}; + +/// Response from the amount input widget +#[derive(Clone)] +pub struct AmountInputResponse { + /// The response from the text edit widget + pub response: Response, + /// Whether the input text has changed + pub changed: bool, + /// The error message if the input is invalid + pub error_message: Option, + /// Whether the max button was clicked + pub max_clicked: bool, + /// The parsed amount if the input is valid (None for empty input or validation errors) + pub parsed_amount: Option, +} + +impl AmountInputResponse { + /// Returns whether the input is valid (no error message) + pub fn is_valid(&self) -> bool { + self.error_message.is_none() + } + + /// Returns whether the input has changed + pub fn has_changed(&self) -> bool { + self.changed + } +} + +impl ComponentResponse for AmountInputResponse { + type DomainType = Amount; + fn has_changed(&self) -> bool { + self.changed + } + + fn changed_value(&self) -> &Option { + &self.parsed_amount + } + + fn is_valid(&self) -> bool { + self.error_message.is_none() + } + + fn error_message(&self) -> Option<&str> { + self.error_message.as_deref() + } +} + +/// A reusable amount input widget that handles decimal parsing and validation. +/// This widget can be used for any type of amount input (tokens, Dash, etc.). +/// +/// The widget validates the input in real-time and shows error messages when +/// the input is invalid. It follows the component design pattern with lazy +/// initialization and response-based communication. +/// +/// # Usage +/// +/// Store the component as `Option` in your screen struct for lazy +/// initialization, then use the fluent builder API to configure it: +/// +/// ```rust,ignore +/// let amount_input = self.amount_input.get_or_insert_with(|| { +/// AmountInput::new(Amount::new_dash(0.0)) +/// .label("Amount:") +/// .hint_text("Enter amount") +/// .max_amount(Some(1000000)) +/// .min_amount(Some(1000)) +/// .max_button(true) +/// }); +/// +/// let response = amount_input.show(ui); +/// response.inner.update(&mut self.amount); +/// ``` +/// +/// See the tests for complete usage examples. +pub struct AmountInput { + // Raw data, as entered by the user + amount_str: String, + decimal_places: u8, + unit_name: Option, + label: Option, + hint_text: Option, + max_amount: Option, + min_amount: Option, + show_max_button: bool, + desired_width: Option, + show_validation_errors: bool, + // When true, we enforce that the input was changed, even if text edit didn't change. + changed: bool, +} + +impl AmountInput { + /// Creates a new amount input widget from an Amount. + /// + /// # Arguments + /// * `amount` - The initial amount to display (determines decimal places automatically) + /// + /// The decimal places are automatically set based on the Amount object. + /// Amount entered by the user will be available through [`AmountInputResponse`]. + pub fn new>(amount: T) -> Self { + let amount = amount.as_ref(); + let amount_str = if amount.value() == 0 { + String::new() + } else { + amount.to_string_without_unit() + }; + Self { + amount_str, + decimal_places: amount.decimal_places(), + unit_name: amount.unit_name().map(|s| s.to_string()), + label: None, + hint_text: None, + max_amount: None, + min_amount: Some(1), // Default minimum is 1 (greater than zero) + show_max_button: false, + desired_width: None, + show_validation_errors: true, // Default to showing validation errors + changed: false, + } + } + + /// Sets whether the input has changed. + /// This is useful for cases where you want to force the component to treat the input as changed, + /// even if the text edit widget itself did not register a change. + pub fn set_changed(&mut self, changed: bool) -> &mut Self { + self.changed = changed; + self + } + + /// Gets the number of decimal places this input is configured for. + pub fn decimal_places(&self) -> u8 { + self.decimal_places + } + + /// Gets the unit name this input is configured for. + pub fn unit_name(&self) -> Option<&str> { + self.unit_name.as_deref() + } + + /// Sets the label for the input field. + pub fn label>(mut self, label: T) -> Self { + self.label = Some(label.into()); + self + } + + /// Sets the label for the input field (mutable reference version). + /// Use this for dynamic configuration when the label needs to change after initialization. + pub fn set_label>(&mut self, label: T) -> &mut Self { + self.label = Some(label.into()); + self + } + + /// Sets the hint text for the input field. + pub fn hint_text>(mut self, hint_text: T) -> Self { + self.hint_text = Some(hint_text.into()); + self + } + + /// Sets the hint text for the input field (mutable reference version). + pub fn set_hint_text>(&mut self, hint_text: T) -> &mut Self { + self.hint_text = Some(hint_text.into()); + self + } + + /// Sets the maximum amount allowed. If provided, a "Max" button will be shown + /// when `show_max_button` is true. + pub fn max_amount(mut self, max_amount: Option) -> Self { + self.max_amount = max_amount; + self + } + + /// Sets the maximum amount allowed (mutable reference version). + /// Use this for dynamic configuration when the max amount changes at runtime (e.g., balance updates). + pub fn set_max_amount(&mut self, max_amount: Option) -> &mut Self { + self.max_amount = max_amount; + self + } + + /// Sets the minimum amount allowed. Defaults to 1 (must be greater than zero). + /// Set to Some(0) to allow zero amounts, or None to disable minimum validation. + pub fn min_amount(mut self, min_amount: Option) -> Self { + self.min_amount = min_amount; + self + } + + /// Sets the minimum amount allowed (mutable reference version). + pub fn set_min_amount(&mut self, min_amount: Option) -> &mut Self { + self.min_amount = min_amount; + self + } + + /// Whether to show a "Max" button that sets the amount to the maximum. + pub fn max_button(mut self, show: bool) -> Self { + self.show_max_button = show; + self + } + + /// Whether to show a "Max" button (mutable reference version). + pub fn set_show_max_button(&mut self, show: bool) -> &mut Self { + self.show_max_button = show; + self + } + + /// Sets the desired width of the input field. + pub fn desired_width(mut self, width: f32) -> Self { + self.desired_width = Some(width); + self + } + + /// Sets the desired width of the input field (mutable reference version). + pub fn set_desired_width(&mut self, width: f32) -> &mut Self { + self.desired_width = Some(width); + self + } + + /// Controls whether validation errors are displayed as a label within the component. + pub fn show_validation_errors(mut self, show: bool) -> Self { + self.show_validation_errors = show; + self + } + + /// Validates the current amount string and returns validation results. + /// + /// Returns `Ok(Some(Amount))` for valid input, `Ok(None)` for empty input, + /// or `Err(String)` with error message if validation fails. + fn validate_amount(&self) -> Result, String> { + if self.amount_str.trim().is_empty() { + return Ok(None); + } + + match Amount::parse(&self.amount_str, self.decimal_places) { + Ok(mut amount) => { + // Apply the unit name if we have one + if let Some(ref unit_name) = self.unit_name { + amount = amount.with_unit_name(unit_name); + } + + // Check if amount exceeds maximum + if let Some(max_amount) = self.max_amount { + if amount.value() > max_amount { + return Err(format!( + "Amount {} exceeds allowed maximum {}", + amount, + Amount::new(max_amount, self.decimal_places) + )); + } + } + + // Check if amount is below minimum + if let Some(min_amount) = self.min_amount { + if amount.value() < min_amount { + return Err(format!( + "Amount must be at least {}", + Amount::new(min_amount, self.decimal_places) + )); + } + } + + Ok(Some(amount)) + } + Err(error) => Err(error), + } + } + + /// Renders the amount input widget and returns an `InnerResponse` for use with `show()`. + fn show_internal(&mut self, ui: &mut Ui) -> InnerResponse { + ui.horizontal(|ui| { + if self.show_max_button { + // ensure we have height predefined to correctly vertically align the input field; + // see StyledButton::show() to see how y is calculated + ui.allocate_space(Vec2::new(0.0, 30.0)); + } + // Show label if provided + if let Some(label) = &self.label { + ui.label(label.clone()); + } + // Create the text edit widget + let mut text_edit = TextEdit::singleline(&mut self.amount_str); + + if let Some(hint) = &self.hint_text { + text_edit = text_edit.hint_text(hint.clone()); + } + + if let Some(width) = self.desired_width { + text_edit = text_edit.desired_width(width); + } + + let text_response = ui.add(text_edit); + + let mut changed = text_response.changed() && ui.is_enabled(); + + // Show max button if max amount is available + let mut max_clicked = false; + if self.show_max_button { + if let Some(max_amount) = self.max_amount { + if ui.button("Max").clicked() { + self.amount_str = Amount::new(max_amount, self.decimal_places).to_string(); + max_clicked = true; + changed = true; + } + } else if ui.button("Max").clicked() { + // Max button clicked but no max amount set - still report the click + max_clicked = true; + } + } + + // Validate the amount + let (error_message, parsed_amount) = match self.validate_amount() { + Ok(amount) => (None, amount), + Err(error) => (Some(error), None), + }; + + // Show validation error if enabled and error exists + if self.show_validation_errors + && let Some(error_msg) = &error_message + { + ui.colored_label(ui.visuals().error_fg_color, error_msg); + } + + if self.changed { + changed = true; // Force changed if set + self.changed = false; // Reset after use + } + + AmountInputResponse { + response: text_response, + changed, + error_message, + max_clicked, + parsed_amount, + } + }) + } +} + +impl Component for AmountInput { + type DomainType = Amount; + type Response = AmountInputResponse; + + fn show(&mut self, ui: &mut Ui) -> InnerResponse { + AmountInput::show_internal(self, ui) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_initialization_with_non_zero_amount_and_unit() { + // Test that AmountInput correctly initializes from an existing amount + let amount = Amount::new_dash(1.5); // 1.5 DASH + + assert_eq!(amount.unit_name(), Some("DASH")); + assert_eq!(format!("{}", amount), "1.5 DASH"); + + let amount_input = AmountInput::new(amount); + + // The amount_str should be initialized with the numeric part, not the unit + assert_eq!(amount_input.amount_str, "1.5"); + assert_eq!(amount_input.decimal_places, 11); + } + + #[test] + fn test_initialization_with_zero_amount() { + // Test that zero amounts initialize with empty string + let amount = Amount::new_dash(0.0); + let amount_input = AmountInput::new(amount); + assert_eq!(amount_input.amount_str, ""); + assert_eq!(amount_input.decimal_places, 11); + } + + #[test] + fn test_minimum_amount_settings() { + let amount = Amount::new(0, 8); // Generic amount with 8 decimal places + + // Default minimum should be 1 + let input = AmountInput::new(amount); + assert_eq!(input.min_amount, Some(1)); + + // Custom minimum + let input = AmountInput::new(Amount::new(0, 8)).min_amount(Some(1000)); + assert_eq!(input.min_amount, Some(1000)); + + // Allow zero + let input = AmountInput::new(Amount::new(0, 8)).min_amount(Some(0)); + assert_eq!(input.min_amount, Some(0)); + + // No minimum + let input = AmountInput::new(Amount::new(0, 8)).min_amount(None); + assert_eq!(input.min_amount, None); + } + + #[test] + fn test_unit_name_preservation() { + let amount = Amount::new(150_000_000_000, 11).with_unit_name("DASH"); // 1.5 DASH + let mut input = AmountInput::new(amount); + + // Check that unit name is preserved + assert_eq!(input.unit_name(), Some("DASH")); + + // Test that get_current_amount preserves unit name + input.amount_str = "2.5".to_string(); + let current = input.validate_amount().unwrap().unwrap(); + assert_eq!(current.unit_name(), Some("DASH")); + assert_eq!(format!("{}", current), "2.5 DASH"); + + // Test validation also preserves unit name + let validation_result = input.validate_amount(); + assert!(validation_result.is_ok()); + let parsed = validation_result.unwrap().unwrap(); + assert_eq!(parsed.unit_name(), Some("DASH")); + assert_eq!(format!("{}", parsed), "2.5 DASH"); + } + + #[test] + fn test_token_unit_name_preservation() { + let amount = Amount::new(1000000, 6).with_unit_name("MYTOKEN"); // 1.0 MYTOKEN + let mut input = AmountInput::new(amount); + + // Check that token unit name is preserved + assert_eq!(input.unit_name(), Some("MYTOKEN")); + + // Test with different amount + input.amount_str = "5.5".to_string(); + let current = input.validate_amount().unwrap().unwrap(); + assert_eq!(current.unit_name(), Some("MYTOKEN")); + assert_eq!(format!("{}", current), "5.5 MYTOKEN"); + } + + #[test] + fn test_validation_states() { + let amount = Amount::new(0, 2); // 2 decimal places for simple testing + let mut input = AmountInput::new(amount); + + // Test empty input (valid) + input.amount_str = "".to_string(); + let validation_result = input.validate_amount(); + assert!(validation_result.is_ok(), "Empty input should be valid"); + assert!( + validation_result.unwrap().is_none(), + "Empty input should have no parsed amount" + ); + + // Test valid input + input.amount_str = "10.50".to_string(); + let validation_result = input.validate_amount(); + assert!( + validation_result.is_ok(), + "Valid input should have no error" + ); + assert!( + validation_result.unwrap().is_some(), + "Valid input should have parsed amount" + ); + + // Test invalid input (too many decimals) + input.amount_str = "10.555".to_string(); + let validation_result = input.validate_amount(); + assert!( + validation_result.is_err(), + "Invalid input should have error" + ); + + // Test invalid input (non-numeric) + input.amount_str = "abc".to_string(); + let validation_result = input.validate_amount(); + assert!( + validation_result.is_err(), + "Non-numeric input should have error" + ); + } + + #[test] + fn test_min_max_validation() { + let amount = Amount::new(0, 2); + let mut input = AmountInput::new(amount) + .min_amount(Some(100)) // Minimum 1.00 + .max_amount(Some(10000)); // Maximum 100.00 + + // Test amount below minimum + input.amount_str = "0.50".to_string(); // 50 (below min of 100) + let validation_result = input.validate_amount(); + assert!( + validation_result.is_err(), + "Amount below minimum should have error" + ); + + // Test amount above maximum + input.amount_str = "150.00".to_string(); // 15000 (above max of 10000) + let validation_result = input.validate_amount(); + assert!( + validation_result.is_err(), + "Amount above maximum should have error" + ); + + // Test valid amount within range + input.amount_str = "50.00".to_string(); // 5000 (within range) + let validation_result = input.validate_amount(); + assert!( + validation_result.is_ok(), + "Amount within range should have no error" + ); + assert!( + validation_result.unwrap().is_some(), + "Amount within range should have parsed amount" + ); + } +} diff --git a/src/ui/components/component_trait.rs b/src/ui/components/component_trait.rs new file mode 100644 index 000000000..199d87e2c --- /dev/null +++ b/src/ui/components/component_trait.rs @@ -0,0 +1,95 @@ +use egui::{InnerResponse, Ui}; + +/// Generic response trait for all UI components following the design pattern. +/// +/// All component responses should implement this trait to provide consistent +/// access to basic response properties. +pub trait ComponentResponse: Clone { + /// The domain object type that this response represents. + /// This type represents the data this component is designed to handle, + /// such as Amount, Identity, etc. + /// + /// It must be equal to the `DomainType` of the component that produced this response. + type DomainType; + + /// Returns whether the component input/state has changed + fn has_changed(&self) -> bool; + + /// Returns whether the component is in a valid state (no error) + fn is_valid(&self) -> bool; + + /// Returns the changed value of the component, if any; otherwise, `None`. + /// It is Some() only if `has_changed()` is true. + /// + /// Note that only valid values should be returned here. + /// If the component value is invalid, this should return `None`. + fn changed_value(&self) -> &Option; + + /// Returns any error message from the component + fn error_message(&self) -> Option<&str>; + + /// Binds the response to a mutable value, updating it if the component state has changed. + /// + /// Provided `value` will be updated whenever the user changes the component state. + /// It will be set to `None` if the component state is invalid (eg. user entered value that didn't pass the validation). + /// + /// # Returns + /// + /// * `true` if the value was updated (including change to `None`), + /// * `false` if it was not changed (eg. `self.has_changed() == false`). + fn update(&self, value: &mut Option) -> bool + where + Self::DomainType: Clone, + { + if self.has_changed() { + if let Some(inner) = self.changed_value() { + value.replace(inner.clone()); + true + } else { + value.take(); + true + } + } else { + false + } + } +} + +/// Core trait that all UI components following the design pattern should implement. +/// +/// This trait provides a standardized interface for components that follow the +/// established patterns of lazy initialization, dual configuration APIs, and +/// response-based communication. +/// +/// # Type Parameters +/// +/// * `DomainType` - The domain object type that this component is designed to handle. +/// This represents the conceptual data type the component works with (e.g., Amount, Identity). +/// * `Response` - The specific response type returned by the component's `show()` method +/// +/// # See also +/// +/// See `doc/COMPONENT_DESIGN_PATTERN.md` for detailed design pattern documentation. +pub trait Component { + /// The domain object type that this component is designed to handle. + /// This type represents the data this component is designed to handle, + /// such as Amount, Identity, etc. + type DomainType; + + /// The response type returned by the component's `show()` method. + /// This type should implement `ComponentResponse` and contain all + /// information about the component's current state and any changes. + type Response: ComponentResponse; + + /// Renders the component and returns a response with interaction results. + /// + /// This method should handle both rendering the component and processing + /// any user interactions, including validation, error display, hints, + /// and formatting. + /// + /// # Returns + /// + /// An [`InnerResponse`] containing the component's response data in [`InnerResponse::inner`] field. + /// [`InnerResponse::inner`] should implement [`ComponentResponse`] trait. + fn show(&mut self, ui: &mut Ui) -> InnerResponse; +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 13bd00efb..94c579e59 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -1,3 +1,5 @@ +pub mod amount_input; +pub mod component_trait; pub mod contract_chooser_panel; pub mod dpns_subscreen_chooser_panel; pub mod entropy_grid; @@ -9,3 +11,6 @@ pub mod tokens_subscreen_chooser_panel; pub mod tools_subscreen_chooser_panel; pub mod top_panel; pub mod wallet_unlock; + +// Re-export the main traits for easy access +pub use component_trait::{Component, ComponentResponse}; diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 91b497800..a3a303096 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -24,6 +24,10 @@ use egui::{Color32, ComboBox, Response, Ui}; use super::tokens::tokens_screen::IdentityTokenInfo; +/// Layout of labels and buttons in the UI fails to vertically align properly containers that contain buttons and other items (labels, text fields, etc.). +/// This constant provides a constant padding to be used in such cases to ensure proper alignment. +pub const BUTTON_ADJUSTMENT_PADDING_TOP: f32 = 15.0; + /// Helper function to create a styled info icon button pub fn info_icon_button(ui: &mut egui::Ui, hover_text: &str) -> Response { let (rect, response) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::click()); diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index 9f6ca327b..4240337b1 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -2,12 +2,15 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::identity::IdentityTask; use crate::context::AppContext; +use crate::model::amount::Amount; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::amount_input::AmountInput; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::{Component, ComponentResponse}; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::fee::Credits; @@ -41,7 +44,8 @@ pub struct TransferScreen { selected_key: Option, known_identities: Vec, receiver_identity_id: String, - amount: String, + amount: Option, + amount_input: Option, transfer_credits_status: TransferCreditsStatus, error_message: Option, max_amount: u64, @@ -74,7 +78,8 @@ impl TransferScreen { selected_key: selected_key.cloned(), known_identities, receiver_identity_id: String::new(), - amount: String::new(), + amount: Some(Amount::new_dash(0.0)), + amount_input: None, transfer_credits_status: TransferCreditsStatus::NotStarted, error_message: None, max_amount, @@ -99,16 +104,38 @@ impl TransferScreen { } fn render_amount_input(&mut self, ui: &mut Ui) { - ui.horizontal(|ui| { - ui.label("Amount in Dash:"); - - ui.text_edit_singleline(&mut self.amount); + // Show available balance + let balance_in_dash = self.max_amount as f64 / 100_000_000_000.0; + ui.label(format!("Available balance: {:.8} DASH", balance_in_dash)); + ui.add_space(5.0); + + // Calculate max amount minus fee for the "Max" button + let max_amount_minus_fee = (self.max_amount as f64 / 100_000_000_000.0 - 0.0001).max(0.0); + let max_amount_credits = (max_amount_minus_fee * 100_000_000_000.0) as u64; + + let amount_input = self.amount_input.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .label("Amount:") + .max_button(true) + .max_amount(Some(max_amount_credits)) + }); - if ui.button("Max").clicked() { - let amount_in_dash = self.max_amount as f64 / 100_000_000_000.0 - 0.0001; // Subtract a small amount to cover gas fee which is usually around 0.00002 Dash - self.amount = format!("{:.8}", amount_in_dash); + // Check if input should be disabled when operation is in progress + let enabled = match self.transfer_credits_status { + TransferCreditsStatus::WaitingForResult(_) | TransferCreditsStatus::Complete => false, + TransferCreditsStatus::NotStarted | TransferCreditsStatus::ErrorMessage(_) => { + amount_input.set_max_amount(Some(max_amount_credits)); + true } - }); + }; + + let response = ui.add_enabled_ui(enabled, |ui| amount_input.show(ui)).inner; + + response.inner.update(&mut self.amount); + + if let Some(error) = &response.inner.error_message { + ui.colored_label(egui::Color32::DARK_RED, error); + } } fn render_to_identity_input(&mut self, ui: &mut Ui) { @@ -163,26 +190,20 @@ impl TransferScreen { }; ui.label(format!( - "Are you sure you want to transfer {} Dash to {}", - self.amount, self.receiver_identity_id + "Are you sure you want to transfer {} to {}", + self.amount.as_ref().expect("Amount should be present"), + self.receiver_identity_id )); - let parts: Vec<&str> = self.amount.split('.').collect(); - let mut credits: u128 = 0; - - // Process the whole number part if it exists. - if let Some(whole) = parts.first() { - if let Ok(whole_number) = whole.parse::() { - credits += whole_number * 100_000_000_000; // Whole Dash amount to credits - } - } - // Process the fractional part if it exists. - if let Some(fraction) = parts.get(1) { - let fraction_length = fraction.len(); - let fraction_number = fraction.parse::().unwrap_or(0); - // Calculate the multiplier based on the number of digits in the fraction. - let multiplier = 10u128.pow(11 - fraction_length as u32); - credits += fraction_number * multiplier; // Fractional Dash to credits + // Use the amount directly since it's already an Amount struct + let credits = self.amount.as_ref().map(|v| v.value()).unwrap_or_default() as u128; + if credits == 0 { + self.error_message = Some("Amount must be greater than 0".to_string()); + self.transfer_credits_status = TransferCreditsStatus::ErrorMessage( + "Amount must be greater than 0".to_string(), + ); + self.confirmation_popup = false; + return; } if ui.button("Confirm").clicked() { @@ -381,6 +402,9 @@ impl ScreenLike for TransferScreen { ui.add_space(10.0); // Transfer button + let ready = self.amount.is_some() + && !self.receiver_identity_id.is_empty() + && self.selected_key.is_some(); let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); ui.set_style(new_style); @@ -388,7 +412,11 @@ impl ScreenLike for TransferScreen { .fill(Color32::from_rgb(0, 128, 255)) .frame(true) .corner_radius(3.0); - if ui.add(button).clicked() { + if ui + .add_enabled(ready, button) + .on_disabled_hover_text("Please ensure all fields are filled correctly") + .clicked() + { self.confirmation_popup = true; } diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index 88acc78a3..041daa70e 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -2,13 +2,16 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::identity::IdentityTask; use crate::context::AppContext; +use crate::model::amount::Amount; use crate::model::qualified_identity::encrypted_key_storage::PrivateKeyData; use crate::model::qualified_identity::{IdentityType, PrivateKeyTarget, QualifiedIdentity}; use crate::model::wallet::Wallet; +use crate::ui::components::amount_input::AmountInput; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::{Component, ComponentResponse}; use crate::ui::helpers::{TransactionType, add_identity_key_chooser}; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; @@ -41,7 +44,8 @@ pub struct WithdrawalScreen { pub identity: QualifiedIdentity, selected_key: Option, withdrawal_address: String, - withdrawal_amount: String, + withdrawal_amount: Option, + withdrawal_amount_input: Option, max_amount: u64, pub app_context: Arc, confirmation_popup: bool, @@ -69,7 +73,8 @@ impl WithdrawalScreen { identity, selected_key: selected_key.cloned(), withdrawal_address: String::new(), - withdrawal_amount: String::new(), + withdrawal_amount: None, + withdrawal_amount_input: None, max_amount, app_context: app_context.clone(), confirmation_popup: false, @@ -94,21 +99,33 @@ impl WithdrawalScreen { } fn render_amount_input(&mut self, ui: &mut Ui) { - ui.horizontal(|ui| { - ui.label("Amount (dash):"); - - ui.text_edit_singleline(&mut self.withdrawal_amount); + let max_amount_minus_fee = (self.max_amount as f64 / 100_000_000_000.0 - 0.0001).max(0.0); + let max_amount_credits = (max_amount_minus_fee * 100_000_000_000.0) as u64; + + // Lazy initialization with basic configuration + let amount_input = self.withdrawal_amount_input.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .label("Amount:") + .max_button(true) + }); - if ui.button("Max").clicked() { - let expected_max_amount = self.max_amount.saturating_sub(500000000) as f64 * 1e-11; + // Check if input should be disabled when operation is in progress + let enabled = match self.withdraw_from_identity_status { + WithdrawFromIdentityStatus::WaitingForResult(_) + | WithdrawFromIdentityStatus::Complete => false, + WithdrawFromIdentityStatus::NotStarted + | WithdrawFromIdentityStatus::ErrorMessage(_) => { + amount_input.set_max_amount(Some(max_amount_credits)); + true + } + }; - // Use flooring and format the result with 4 decimal places - let floored_amount = (expected_max_amount * 10_000.0).floor() / 10_000.0; + let response = ui.add_enabled_ui(enabled, |ui| amount_input.show(ui)).inner; - // Set the withdrawal amount to the floored value formatted as a string - self.withdrawal_amount = format!("{:.4}", floored_amount); - } - }); + response.inner.update(&mut self.withdrawal_amount); + if let Some(error) = &response.inner.error_message { + ui.colored_label(egui::Color32::DARK_RED, error); + } } fn render_address_input(&mut self, ui: &mut Ui) { @@ -182,27 +199,19 @@ impl WithdrawalScreen { }; ui.label(format!( - "Are you sure you want to withdraw {} Dash to {}", - self.withdrawal_amount, message_address + "Are you sure you want to withdraw {} to {}", + self.withdrawal_amount + .as_ref() + .expect("Withdrawal amount should be present"), + message_address )); - let parts: Vec<&str> = self.withdrawal_amount.split('.').collect(); - let mut credits: u128 = 0; - // Process the whole number part if it exists. - if let Some(whole) = parts.first() { - if let Ok(whole_number) = whole.parse::() { - credits += whole_number * 100_000_000_000; // Whole Dash amount to credits - } - } - - // Process the fractional part if it exists. - if let Some(fraction) = parts.get(1) { - let fraction_length = fraction.len(); - let fraction_number = fraction.parse::().unwrap_or(0); - // Calculate the multiplier based on the number of digits in the fraction. - let multiplier = 10u128.pow(11 - fraction_length as u32); - credits += fraction_number * multiplier; // Fractional Dash to credits - } + // Use the amount directly from the stored amount + let credits = self + .withdrawal_amount + .as_ref() + .expect("Withdrawal amount should be present") + .value() as u128; if ui.button("Confirm").clicked() { self.confirmation_popup = false; @@ -449,13 +458,20 @@ impl ScreenLike for WithdrawalScreen { ui.add_space(10.0); // Withdraw button + let button = egui::Button::new(RichText::new("Withdraw").color(Color32::WHITE)) .fill(Color32::from_rgb(0, 128, 255)) .frame(true) .corner_radius(3.0) .min_size(egui::vec2(60.0, 30.0)); - if ui.add(button).clicked() { + let ready = self.withdrawal_amount.as_ref().is_some(); + + if ui + .add_enabled(ready, button) + .on_disabled_hover_text("Please enter a valid amount to withdraw") + .clicked() + { self.confirmation_popup = true; } diff --git a/src/ui/tokens/tokens_screen/groups.rs b/src/ui/tokens/tokens_screen/groups.rs index 191572984..6414cd930 100644 --- a/src/ui/tokens/tokens_screen/groups.rs +++ b/src/ui/tokens/tokens_screen/groups.rs @@ -164,7 +164,7 @@ impl TokensScreen { .members .iter() .enumerate() - .filter_map(|(i, m)| if i != j && !m.identity_str.is_empty() { + .filter_map(|(i, m)| if i != j && !m.identity_str.is_empty() { let identifier = Identifier::from_string(&m.identity_str, Encoding::Base58).ok()?; Some(identifier) } else { diff --git a/src/ui/tokens/tokens_screen/my_tokens.rs b/src/ui/tokens/tokens_screen/my_tokens.rs index 630505fa9..6b6db3fd3 100644 --- a/src/ui/tokens/tokens_screen/my_tokens.rs +++ b/src/ui/tokens/tokens_screen/my_tokens.rs @@ -1,6 +1,7 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; +use crate::model::amount::Amount; use crate::ui::Screen; use crate::ui::components::styled::StyledButton; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; @@ -419,8 +420,10 @@ impl TokensScreen { }); row.col(|ui| { if let Some(balance) = itb.balance { - let formatted_balance = balance.to_string(); - ui.label(formatted_balance); + // Create an amount using the token's decimal places and alias + let decimals = itb.token_config.conventions().decimals(); + let amount = Amount::new(balance, decimals).with_unit_name(&itb.token_alias); + ui.label(amount.to_string_without_unit()); } else if ui.button("Check").clicked() { action = AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::QueryIdentityTokenBalance(itb.clone().into())))); } @@ -430,8 +433,10 @@ impl TokensScreen { if itb.available_actions.can_estimate { if let Some(known_rewards) = itb.estimated_unclaimed_rewards { ui.horizontal(|ui| { - let formatted_rewards = known_rewards.to_string(); - ui.label(formatted_rewards); + // Create an amount for rewards using the token's decimal places and alias + let decimals = itb.token_config.conventions().decimals(); + let rewards_amount = Amount::new(known_rewards, decimals); + ui.label(rewards_amount.to_string()); // Info button to show explanation let identity_token_id = IdentityTokenIdentifier { @@ -512,12 +517,18 @@ impl TokensScreen { egui::ScrollArea::vertical().show(ui, |ui| { ui.heading("Reward Estimation Details"); ui.separator(); - - let formatted_total = explanation.total_amount.to_string(); - ui.label(format!( - "Total Estimated Rewards: {} tokens", - formatted_total - )); + let decimal_places = + token_info.token_configuration.conventions().decimals(); + let unit_name = token_info + .token_configuration + .conventions() + .plural_form_by_language_code_or_default("en"); + let reward_amount = Amount::new( + explanation.total_amount, + decimal_places, + ).with_unit_name(unit_name); + + ui.label(format!("Total Estimated Rewards: {}", reward_amount)); ui.separator(); ui.collapsing("Basic Explanation", |ui| { diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 1e89685a2..e35d5b736 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -2,14 +2,17 @@ use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; +use crate::model::amount::Amount; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::amount_input::AmountInput; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::{Component, ComponentResponse}; use crate::ui::helpers::{TransactionType, add_identity_key_chooser}; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; @@ -29,83 +32,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::ui::identities::get_selected_wallet; use super::tokens_screen::IdentityTokenBalance; -use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; -use dash_sdk::dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; - -fn format_token_amount(amount: u64, decimals: u8) -> String { - if decimals == 0 { - return amount.to_string(); - } - - let divisor = 10u64.pow(decimals as u32); - let whole = amount / divisor; - let fraction = amount % divisor; - - if fraction == 0 { - whole.to_string() - } else { - // Format with the appropriate number of decimal places, removing trailing zeros - let fraction_str = format!("{:0width$}", fraction, width = decimals as usize); - let trimmed = fraction_str.trim_end_matches('0'); - format!("{}.{}", whole, trimmed) - } -} - -fn parse_token_amount(input: &str, decimals: u8) -> Result { - if decimals == 0 { - return input - .parse::() - .map_err(|_| "Invalid amount: must be a whole number".to_string()); - } - - let parts: Vec<&str> = input.split('.').collect(); - match parts.len() { - 1 => { - // No decimal point, parse as whole number - let whole = parts[0] - .parse::() - .map_err(|_| "Invalid amount: must be a number".to_string())?; - let multiplier = 10u64.pow(decimals as u32); - whole - .checked_mul(multiplier) - .ok_or_else(|| "Amount too large".to_string()) - } - 2 => { - // Has decimal point - let whole = if parts[0].is_empty() { - 0 - } else { - parts[0] - .parse::() - .map_err(|_| "Invalid amount: whole part must be a number".to_string())? - }; - - let fraction_str = parts[1]; - if fraction_str.len() > decimals as usize { - return Err(format!( - "Too many decimal places. Maximum allowed: {}", - decimals - )); - } - - // Pad with zeros if needed - let padded_fraction = format!("{:0() - .map_err(|_| "Invalid amount: decimal part must be a number".to_string())?; - - let multiplier = 10u64.pow(decimals as u32); - let whole_part = whole - .checked_mul(multiplier) - .ok_or_else(|| "Amount too large".to_string())?; - - whole_part - .checked_add(fraction) - .ok_or_else(|| "Amount too large".to_string()) - } - _ => Err("Invalid amount: too many decimal points".to_string()), - } -} #[derive(PartialEq)] pub enum TransferTokensStatus { @@ -122,9 +48,10 @@ pub struct TransferTokensScreen { selected_key: Option, pub public_note: Option, pub receiver_identity_id: String, - pub amount: String, + pub amount: Option, + pub amount_input: Option, transfer_tokens_status: TransferTokensStatus, - max_amount: u64, + max_amount: Amount, pub app_context: Arc, confirmation_popup: bool, selected_wallet: Option>>, @@ -146,7 +73,7 @@ impl TransferTokensScreen { .find(|identity| identity.identity.id() == identity_token_balance.identity_id) .expect("Identity not found") .clone(); - let max_amount = identity_token_balance.balance; + let max_amount = Amount::from(&identity_token_balance); let identity_clone = identity.identity.clone(); let selected_key = identity_clone.get_first_public_key_matching( Purpose::AUTHENTICATION, @@ -158,6 +85,8 @@ impl TransferTokensScreen { let selected_wallet = get_selected_wallet(&identity, None, selected_key, &mut error_message); + let amount = Some(Amount::from(&identity_token_balance).with_value(0)); + Self { identity, identity_token_balance, @@ -165,7 +94,8 @@ impl TransferTokensScreen { selected_key: selected_key.cloned(), public_note: None, receiver_identity_id: String::new(), - amount: String::new(), + amount, + amount_input: None, transfer_tokens_status: TransferTokensStatus::NotStarted, max_amount, app_context: app_context.clone(), @@ -177,20 +107,48 @@ impl TransferTokensScreen { } fn render_amount_input(&mut self, ui: &mut Ui) { - ui.horizontal(|ui| { - ui.label("Amount:"); - - ui.text_edit_singleline(&mut self.amount); - - if ui.button("Max").clicked() { - let decimals = self - .identity_token_balance - .token_config - .conventions() - .decimals(); - self.amount = format_token_amount(self.max_amount, decimals); + ui.label(format!("Available balance: {}", self.max_amount)); + ui.add_space(5.0); + + // Lazy initialization with proper decimal places + let amount_input = match self.amount_input.as_mut() { + Some(input) => input, + _ => { + self.amount_input = Some( + AmountInput::new( + self.amount + .as_ref() + .unwrap_or(&Amount::from(&self.identity_token_balance)), + ) + .label("Amount:") + .max_button(true), + ); + + self.amount_input + .as_mut() + .expect("AmountInput should be initialized above") } - }); + }; + + // Check if input should be disabled when operation is in progress + let enabled = match self.transfer_tokens_status { + TransferTokensStatus::WaitingForResult(_) | TransferTokensStatus::Complete => false, + TransferTokensStatus::NotStarted | TransferTokensStatus::ErrorMessage(_) => { + amount_input.set_max_amount(Some(self.max_amount.value())); + true + } + }; + + let response = ui.add_enabled_ui(enabled, |ui| amount_input.show(ui)).inner; + + response.inner.update(&mut self.amount); + + if let Some(error) = &response.inner.error_message { + ui.colored_label( + DashColors::error_color(ui.ctx().style().visuals.dark_mode), + error, + ); + } } fn render_to_identity_input(&mut self, ui: &mut Ui) { @@ -243,7 +201,9 @@ impl TransferTokensScreen { ui.label(format!( "Are you sure you want to transfer {} {} to {}?", - self.amount, self.identity_token_balance.token_alias, self.receiver_identity_id + self.amount.as_ref().expect("Amount should be set"), + self.identity_token_balance.token_alias, + self.receiver_identity_id )); if ui.button("Confirm").clicked() { @@ -267,13 +227,8 @@ impl TransferTokensScreen { sending_identity: self.identity.clone(), recipient_id: identifier, amount: { - let decimals = self - .identity_token_balance - .token_config - .conventions() - .decimals(); - parse_token_amount(&self.amount, decimals) - .expect("Amount should be valid at this point") + // Use the amount value directly + self.amount.as_ref().expect("Amount should be set").value() }, data_contract, token_position: self.identity_token_balance.token_position, @@ -352,8 +307,8 @@ impl ScreenLike for TransferTokensScreen { self.max_amount = token_balances .values() .find(|balance| balance.identity_id == self.identity.identity.id()) - .map(|balance| balance.balance) - .unwrap_or(0); + .map(Amount::from) + .unwrap_or_default(); } /// Renders the UI components for the withdrawal screen @@ -471,19 +426,6 @@ impl ScreenLike for TransferTokensScreen { ui.heading("2. Input the amount to transfer"); ui.add_space(5.0); - // Show available balance - let decimals = self - .identity_token_balance - .token_config - .conventions() - .decimals(); - let formatted_balance = format_token_amount(self.max_amount, decimals); - ui.label(format!( - "Available balance: {} {}", - formatted_balance, self.identity_token_balance.token_alias - )); - ui.add_space(5.0); - self.render_amount_input(ui); ui.add_space(10.0); @@ -519,6 +461,10 @@ impl ScreenLike for TransferTokensScreen { ui.add_space(10.0); // Transfer button + + let ready = self.amount.is_some() + && !self.receiver_identity_id.is_empty() + && self.selected_key.is_some(); let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); ui.set_style(new_style); @@ -526,29 +472,22 @@ impl ScreenLike for TransferTokensScreen { .fill(Color32::from_rgb(0, 128, 255)) .frame(true) .corner_radius(3.0); - if ui.add(button).clicked() { - let decimals = self - .identity_token_balance - .token_config - .conventions() - .decimals(); - match parse_token_amount(&self.amount, decimals) { - Ok(parsed_amount) => { - if parsed_amount > self.max_amount { - self.transfer_tokens_status = TransferTokensStatus::ErrorMessage( - "Amount exceeds available balance".to_string(), - ); - } else if parsed_amount == 0 { - self.transfer_tokens_status = TransferTokensStatus::ErrorMessage( - "Amount must be greater than zero".to_string(), - ); - } else { - self.confirmation_popup = true; - } - } - Err(e) => { - self.transfer_tokens_status = TransferTokensStatus::ErrorMessage(e); - } + if ui + .add_enabled(ready, button) + .on_disabled_hover_text("Please ensure all fields are filled correctly") + .clicked() + { + // Use the amount value directly since it's already parsed + if self.amount.as_ref().is_some_and(|v| v > &self.max_amount) { + self.transfer_tokens_status = TransferTokensStatus::ErrorMessage( + "Amount exceeds available balance".to_string(), + ); + } else if self.amount.as_ref().is_none_or(|a| a.value() == 0) { + self.transfer_tokens_status = TransferTokensStatus::ErrorMessage( + "Amount must be greater than zero".to_string(), + ); + } else { + self.confirmation_popup = true; } } diff --git a/src/utils/path.rs b/src/utils/path.rs index fed87451a..5bd443bdf 100644 --- a/src/utils/path.rs +++ b/src/utils/path.rs @@ -7,7 +7,7 @@ use std::path::Path; /// - Otherwise, it displays the full path /// /// # Examples -/// ``` +/// ```no_run /// "/Applications/Dash-Qt.app/Contents/MacOS/Dash-Qt" -> "Dash-Qt.app" /// "/usr/local/bin/dash-qt" -> "/usr/local/bin/dash-qt" /// ``` From 73a7f711c455e07c1c416e9269883db264d814cd Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 Aug 2025 12:04:52 +0200 Subject: [PATCH 003/106] test: run tests using github actions and fix failing tests (#422) * fix: failing tests * gha: run tests * fix: tests fail due to lack of .env file --- .github/workflows/tests.yml | 64 ++++++++++++++++++++ src/ui/tokens/tokens_screen/distributions.rs | 1 + src/ui/tokens/tokens_screen/mod.rs | 53 ++++++++-------- src/utils/path.rs | 25 ++++---- 4 files changed, 105 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..383340727 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,64 @@ +name: Tests + +on: + push: + branches: + - main + - "v*-dev" + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test Suite + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-test- + ${{ runner.os }}-cargo- + + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config clang cmake libsqlite3-dev + + - name: Install protoc + run: | + curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v25.2/protoc-25.2-linux-x86_64.zip + sudo unzip -o protoc-25.2-linux-x86_64.zip -d /usr/local bin/protoc + sudo unzip -o protoc-25.2-linux-x86_64.zip -d /usr/local 'include/*' + rm -f protoc-25.2-linux-x86_64.zip + env: + PROTOC: /usr/local/bin/protoc + + - name: Run tests + uses: actions-rs/cargo@v1 + with: + command: test + args: --all-features --workspace + + - name: Run doc tests + uses: actions-rs/cargo@v1 + with: + command: test + args: --doc --all-features --workspace diff --git a/src/ui/tokens/tokens_screen/distributions.rs b/src/ui/tokens/tokens_screen/distributions.rs index 483152f59..e87b666fc 100644 --- a/src/ui/tokens/tokens_screen/distributions.rs +++ b/src/ui/tokens/tokens_screen/distributions.rs @@ -172,6 +172,7 @@ impl TokensScreen { DistributionFunctionUI::InvertedLogarithmic, "InvertedLogarithmic", ); + // DistributionFunctionUI::Random is not supported }); let response = crate::ui::helpers::info_icon_button(ui, "Info about distribution types"); diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index d7a8cc691..d3ae8b65f 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -1061,9 +1061,9 @@ pub struct TokensScreen { // --- FixedAmount --- pub fixed_amount_input: String, - // --- Random --- - pub random_min_input: String, - pub random_max_input: String, + // --- Random --- - not supported + // pub random_min_input: String, + // pub random_max_input: String, // --- StepDecreasingAmount --- pub step_count_input: String, @@ -1406,8 +1406,8 @@ impl TokensScreen { perpetual_dist_interval_unit: IntervalTimeUnit::Day, perpetual_dist_function: DistributionFunctionUI::FixedAmount, fixed_amount_input: String::new(), - random_min_input: String::new(), - random_max_input: String::new(), + // random_min_input: String::new(), + // random_max_input: String::new(), step_count_input: String::new(), decrease_per_interval_numerator_input: String::new(), decrease_per_interval_denominator_input: String::new(), @@ -2133,8 +2133,8 @@ impl TokensScreen { self.perpetual_dist_type = PerpetualDistributionIntervalTypeUI::None; self.perpetual_dist_interval_input = "".to_string(); self.fixed_amount_input = "".to_string(); - self.random_min_input = "".to_string(); - self.random_max_input = "".to_string(); + // self.random_min_input = "".to_string(); + // self.random_max_input = "".to_string(); self.step_count_input = "".to_string(); self.decrease_per_interval_numerator_input = "".to_string(); self.decrease_per_interval_denominator_input = "".to_string(); @@ -2854,6 +2854,7 @@ impl ScreenWithWalletUnlock for TokensScreen { mod tests { use std::path::Path; + use crate::app_dir::copy_env_file_if_not_exists; use crate::database::Database; use crate::model::qualified_identity::IdentityStatus; use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; @@ -2892,6 +2893,7 @@ mod tests { let db = Arc::new(Database::new(db_file_path).unwrap()); db.initialize(Path::new(&db_file_path)).unwrap(); + copy_env_file_if_not_exists(); // Required by AppContext::new() let app_context = AppContext::new(Network::Regtest, db, None, Default::default()) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); @@ -3005,7 +3007,7 @@ mod tests { // ------------------------------------------------- // Groups // ------------------------------------------------- - // We'll define 2 groups for testing: positions 2 (main) and 7 + // We'll define 2 groups for testing: positions 0 (main) and 1 token_creator_ui.groups_ui = vec![ GroupConfigUI { required_power_str: "2".to_string(), @@ -3165,25 +3167,26 @@ mod tests { }; assert_eq!( new_dest_id.to_string(Encoding::Base58), - "GCMnPwQZcH3RP9atgkmvtmN45QrVcYvh5cmUYARHBTu9" + "BCMnPwQZcH3RP9atgkmvtmN45QrVcYvh5cmUYARHBTu9" ); assert!(dist_rules_v0.minting_allow_choosing_destination); // F) Check the Groups - // (Positions 2 and 7, from above) + // (Positions 0 and 1, from above) assert_eq!(contract_v1.groups.len(), 2, "We added two groups in the UI"); - let group2 = contract_v1.groups.get(&2).expect("Expected group pos=2"); + + let group0 = contract_v1.groups.get(&0).expect("Expected group pos=0"); assert_eq!( - group2.required_power(), + group0.required_power(), 2, - "Group #2 required_power mismatch" + "Group #0 required_power mismatch" ); - let members = &group2.members(); + let members = &group0.members(); assert_eq!(members.len(), 2); - let group7 = contract_v1.groups.get(&7).expect("Expected group pos=7"); - assert_eq!(group7.required_power(), 1); - assert_eq!(group7.members().len(), 0); + let group1 = contract_v1.groups.get(&1).expect("Expected group pos=1"); + assert_eq!(group1.required_power(), 1); + assert_eq!(group1.members().len(), 0); } #[test] @@ -3192,6 +3195,7 @@ mod tests { let db = Arc::new(Database::new(db_file_path).unwrap()); db.initialize(Path::new(&db_file_path)).unwrap(); + copy_env_file_if_not_exists(); // required by AppContext::new() let app_context = AppContext::new(Network::Regtest, db, None, Default::default()) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); @@ -3238,9 +3242,10 @@ mod tests { // Enable perpetual distribution, select Random token_creator_ui.enable_perpetual_distribution = true; token_creator_ui.perpetual_dist_type = PerpetualDistributionIntervalTypeUI::TimeBased; - token_creator_ui.perpetual_dist_interval_input = "60000".to_string(); - token_creator_ui.random_min_input = "100".to_string(); - token_creator_ui.random_max_input = "200".to_string(); + token_creator_ui.perpetual_dist_function = DistributionFunctionUI::FixedAmount; + token_creator_ui.perpetual_dist_interval_input = "60".to_string(); + token_creator_ui.perpetual_dist_interval_unit = IntervalTimeUnit::Second; + token_creator_ui.fixed_amount_input = "100".to_string(); // Parse + build let build_args = token_creator_ui @@ -3289,11 +3294,10 @@ mod tests { RewardDistributionType::TimeBasedDistribution { interval, function } => { assert_eq!(*interval, 60000, "Expected 60s (in ms)"); match function { - DistributionFunction::Random { min, max } => { - assert_eq!(*min, 100); - assert_eq!(*max, 200); + DistributionFunction::FixedAmount { amount } => { + assert_eq!(*amount, 100); } - _ => panic!("Expected DistributionFunction::Random"), + _ => panic!("Expected DistributionFunction::FixedAmount"), } } _ => panic!("Expected TimeBasedDistribution"), @@ -3306,6 +3310,7 @@ mod tests { let db = Arc::new(Database::new(db_file_path).unwrap()); db.initialize(Path::new(&db_file_path)).unwrap(); + copy_env_file_if_not_exists(); // required by AppContext::new() let app_context = AppContext::new(Network::Regtest, db, None, Default::default()) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); diff --git a/src/utils/path.rs b/src/utils/path.rs index 5bd443bdf..dbea509d0 100644 --- a/src/utils/path.rs +++ b/src/utils/path.rs @@ -6,24 +6,21 @@ use std::path::Path; /// - If the path ends with `.app/Contents/MacOS/Dash-Qt`, it displays as `Dash-Qt.app` /// - Otherwise, it displays the full path /// -/// # Examples -/// ```no_run -/// "/Applications/Dash-Qt.app/Contents/MacOS/Dash-Qt" -> "Dash-Qt.app" -/// "/usr/local/bin/dash-qt" -> "/usr/local/bin/dash-qt" -/// ``` +/// # Examples: +/// +/// * `"/Applications/Dash-Qt.app/Contents/MacOS/Dash-Qt" -> "Dash-Qt.app"` +/// * `"/usr/local/bin/dash-qt" -> "/usr/local/bin/dash-qt"` pub fn format_path_for_display(path: &Path) -> String { let path_str = path.to_string_lossy(); // Check if this is a macOS app bundle executable path - if cfg!(target_os = "macos") { - // Check if the path matches the pattern for an app bundle executable - if let Some(app_start) = path_str.rfind(".app/Contents/MacOS/") { - // Find the start of the app name by looking backwards for a path separator - let before_app = &path_str[..app_start]; - let app_name_start = before_app.rfind('/').map(|i| i + 1).unwrap_or(0); - let app_name = &path_str[app_name_start..app_start + 4]; // Include ".app" - return app_name.to_string(); - } + // Check if the path matches the pattern for an app bundle executable + if let Some(app_start) = path_str.rfind(".app/Contents/MacOS/") { + // Find the start of the app name by looking backwards for a path separator + let before_app = &path_str[..app_start]; + let app_name_start = before_app.rfind('/').map(|i| i + 1).unwrap_or(0); + let app_name = &path_str[app_name_start..app_start + 4]; // Include ".app" + return app_name.to_string(); } // For all other cases, return the full path From 0296abc0a6dc01156837e4c2f7b484668e5edc0d Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Tue, 5 Aug 2025 12:38:42 +0200 Subject: [PATCH 004/106] fix: incorrect decimals handling on token create, mint and burn screens (#419) * feat: amount input, first building version * test(amount): fixed tests * chore: I think final * chore: my_tokens display correct amount * chore: transfer tokens update * chore: hide unit on rewards estimate column * chore: two new helper methods * chore: I think finals * cargo fmt * feat: component trait * impl Component for AmountInput * chore: updated component trait * chore: update for egui enabled state mgmt * doc: component design pattern doc * chore: component design pattern continued * chore: amount improvements * chore: copilot review * chore: amount improvements * refactor: mint and burn token screens use AmountInput * chore: use AmountInput on token creator screen * fix: burn error handling * feat: errors displayed in the AmountInput component * fix: vertical align of amount input * backport: amount component from * chore: fix imports * chore: refactor * chore: futher refactor * chore: further refactor based on feedback * doc: simplified component design pattern description * chore: peer review * doc: update docs * chore: amount input * chore: fixes after merge * chore: self-review * feat: amout set decimal places + rename label => with_label * refactor: amount input init on token screen * chore: fix token creator layout * chore: format base amount with leading zeros in confirmation * chore: base supply 0 by default --- src/backend_task/system_task/mod.rs | 2 +- src/model/amount.rs | 192 ++++++++++++------ src/ui/components/amount_input.rs | 56 +++-- src/ui/components/component_trait.rs | 8 + .../group_actions_screen.rs | 13 +- src/ui/identities/transfer_screen.rs | 11 +- src/ui/identities/withdraw_screen.rs | 8 +- src/ui/tokens/burn_tokens_screen.rs | 77 +++++-- src/ui/tokens/mint_tokens_screen.rs | 50 +++-- src/ui/tokens/tokens_screen/mod.rs | 79 +++++-- src/ui/tokens/tokens_screen/my_tokens.rs | 7 +- src/ui/tokens/tokens_screen/token_creator.rs | 51 +++-- src/ui/tokens/transfer_tokens_screen.rs | 12 +- 13 files changed, 389 insertions(+), 177 deletions(-) diff --git a/src/backend_task/system_task/mod.rs b/src/backend_task/system_task/mod.rs index d7a6383d2..2999b43fb 100644 --- a/src/backend_task/system_task/mod.rs +++ b/src/backend_task/system_task/mod.rs @@ -49,7 +49,7 @@ impl AppContext { theme_mode: ThemeMode, ) -> Result { let _guard = self.invalidate_settings_cache(); - + self.db .update_theme_preference(theme_mode) .map_err(|e| e.to_string())?; diff --git a/src/model/amount.rs b/src/model/amount.rs index dfa8b26bb..8230f7c9b 100644 --- a/src/model/amount.rs +++ b/src/model/amount.rs @@ -50,13 +50,11 @@ impl PartialEq for &Amount { impl Display for Amount { /// Formats the TokenValue as a user-friendly string with optional unit name. + /// + /// See [`Amount::to_string_opts()`] for more formatting options. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let amount_str = self.to_string_without_unit(); - - match &self.unit_name { - Some(unit) => write!(f, "{} {}", amount_str, unit), - None => write!(f, "{}", amount_str), - } + let amount_str = self.to_string_opts(true, true); + write!(f, "{}", amount_str) } } @@ -88,8 +86,8 @@ impl Amount { /// This extracts the decimal places and token alias from the token configuration /// and creates an Amount with the specified value. pub fn from_token( - value: TokenAmount, token_info: &crate::ui::tokens::tokens_screen::IdentityTokenInfo, + value: TokenAmount, ) -> Self { let decimal_places = token_info.token_config.conventions().decimals(); Self::new(value, decimal_places).with_unit_name(&token_info.token_alias) @@ -221,7 +219,12 @@ impl Amount { /// Sets the unit name. pub fn with_unit_name(mut self, unit_name: &str) -> Self { - self.unit_name = Some(unit_name.to_string()); + if unit_name.is_empty() { + self.unit_name = None; + } else { + self.unit_name = Some(unit_name.to_string()); + } + self } @@ -232,25 +235,53 @@ impl Amount { } /// Returns the numeric string representation without the unit name. + /// Trailing zeroes are trimmed by default. /// This is useful for text input fields where only the number should be shown. + /// + /// ## See also + /// + /// [`Amount::to_string_opts()`] for more formatting options. pub fn to_string_without_unit(&self) -> String { - if self.decimal_places == 0 { - self.value.to_string() - } else { - let divisor = 10u64.pow(self.decimal_places as u32); - let whole = self.value / divisor; - let fraction = self.value % divisor; - - if fraction == 0 { - whole.to_string() - } else { - // Format with the appropriate number of decimal places, removing trailing zeros - let fraction_str = - format!("{:0width$}", fraction, width = self.decimal_places as usize); - let trimmed = fraction_str.trim_end_matches('0'); - format!("{}.{}", whole, trimmed) + self.to_string_opts(false, true) + } + + /// Formats the Amount as a string with options for unit display and trailing zeroes. + pub fn to_string_opts(&self, show_unit: bool, trim_trailing_zeroes: bool) -> String { + let mut result = String::new(); + + let divisor = 10u64.pow(self.decimal_places as u32); + let whole = self.value / divisor; + let fraction = self.value % divisor; + + // "123" + result.push_str(&whole.to_string()); + + if self.decimal_places != 0 { + // "123.0000" + result.push_str(&format!( + ".{:0width$}", + fraction, + width = self.decimal_places as usize + )); + + if trim_trailing_zeroes { + // Remove trailing zeros + // "123." + result = result.trim_end_matches('0').to_string(); } + // "123" + result = result.trim_end_matches('.').to_string(); + }; + + if show_unit + && let Some(unit_name) = self.unit_name.as_ref() + && !unit_name.is_empty() + { + result.push(' '); + result.push_str(unit_name); } + + result } /// Creates a new Amount with the specified value in TokenAmount. @@ -259,29 +290,6 @@ impl Amount { self } - /// Updates the decimal places for this amount. - /// This adjusts the internal value to maintain the same displayed amount. - /// - /// If new decimal places are equal to the current ones, it does nothing. - pub fn recalculate_decimal_places(mut self, new_decimal_places: u8) -> Self { - if self.decimal_places != new_decimal_places { - let current_decimals = self.decimal_places; - - if new_decimal_places > current_decimals { - // More decimal places - multiply value - let factor = 10u64.pow((new_decimal_places - current_decimals) as u32); - self.value = self.value.saturating_mul(factor); - } else if new_decimal_places < current_decimals { - // Fewer decimal places - divide value - let factor = 10u64.pow((current_decimals - new_decimal_places) as u32); - self.value /= factor; - } - - self.decimal_places = new_decimal_places; - } - self - } - /// Checks if the amount is for the same token as the other amount. /// /// This is determined by comparing the unit names and decimal places. @@ -668,25 +676,77 @@ mod tests { } #[test] - fn test_decimal_places_conversion() { - // Test converting from 2 decimal places to 8 decimal places - let amount = Amount::new(12345, 2); // 123.45 - let converted = amount.recalculate_decimal_places(8); - assert_eq!(converted.value(), 12345000000); // 123.45 with 8 decimals - assert_eq!(converted.decimal_places(), 8); - assert_eq!(format!("{}", converted), "123.45"); - - // Test converting from 8 decimal places to 2 decimal places - let amount = Amount::new(12345000000, 8); // 123.45 - let converted = amount.recalculate_decimal_places(2); - assert_eq!(converted.value(), 12345); // 123.45 with 2 decimals - assert_eq!(converted.decimal_places(), 2); - assert_eq!(format!("{}", converted), "123.45"); - - // Test no conversion (same decimal places) - let amount = Amount::new(12345, 2); - let same = amount.clone().recalculate_decimal_places(2); - assert_eq!(same.value(), 12345); - assert_eq!(same.decimal_places(), 2); + fn test_to_string_opts() { + // Test basic formatting options with 2 decimal places + let amount = Amount::new(12345, 2).with_unit_name("USD"); + + // Test all combinations of show_unit and trim_trailing_zeroes + assert_eq!(amount.to_string_opts(true, true), "123.45 USD"); // show unit, trim zeros + assert_eq!(amount.to_string_opts(false, true), "123.45"); // no unit, trim zeros + assert_eq!(amount.to_string_opts(true, false), "123.45 USD"); // show unit, no trim (same as above since no trailing zeros) + assert_eq!(amount.to_string_opts(false, false), "123.45"); // no unit, no trim (same as above since no trailing zeros) + + // Test with trailing zeros + let amount_with_zeros = Amount::new(12300, 2).with_unit_name("USD"); + assert_eq!(amount_with_zeros.to_string_opts(true, true), "123 USD"); // show unit, trim zeros + assert_eq!(amount_with_zeros.to_string_opts(false, true), "123"); // no unit, trim zeros + assert_eq!(amount_with_zeros.to_string_opts(true, false), "123.00 USD"); // show unit, no trim + assert_eq!(amount_with_zeros.to_string_opts(false, false), "123.00"); // no unit, no trim + + // Test with partial trailing zeros + let amount_partial_zeros = Amount::new(12340, 2).with_unit_name("USD"); + assert_eq!(amount_partial_zeros.to_string_opts(true, true), "123.4 USD"); // show unit, trim zeros + assert_eq!(amount_partial_zeros.to_string_opts(false, true), "123.4"); // no unit, trim zeros + assert_eq!( + amount_partial_zeros.to_string_opts(true, false), + "123.40 USD" + ); // show unit, no trim + assert_eq!(amount_partial_zeros.to_string_opts(false, false), "123.40"); // no unit, no trim + + // Test with 0 decimal places + let whole_amount = Amount::new(123, 0).with_unit_name("WHOLE"); + assert_eq!(whole_amount.to_string_opts(true, true), "123 WHOLE"); + assert_eq!(whole_amount.to_string_opts(false, true), "123"); + assert_eq!(whole_amount.to_string_opts(true, false), "123 WHOLE"); + assert_eq!(whole_amount.to_string_opts(false, false), "123"); + + // Test with high decimal places + let high_precision = Amount::new(123456789, 8).with_unit_name("BTC"); + assert_eq!(high_precision.to_string_opts(true, true), "1.23456789 BTC"); // trim zeros + assert_eq!(high_precision.to_string_opts(false, true), "1.23456789"); // trim zeros + assert_eq!(high_precision.to_string_opts(true, false), "1.23456789 BTC"); // no trim (same as above since no trailing zeros) + assert_eq!(high_precision.to_string_opts(false, false), "1.23456789"); // no trim (same as above since no trailing zeros) + + // Test with high decimal places and trailing zeros + let high_precision_zeros = Amount::new(100000000, 8).with_unit_name("BTC"); + assert_eq!(high_precision_zeros.to_string_opts(true, true), "1 BTC"); // trim zeros + assert_eq!(high_precision_zeros.to_string_opts(false, true), "1"); // trim zeros + assert_eq!( + high_precision_zeros.to_string_opts(true, false), + "1.00000000 BTC" + ); // no trim + assert_eq!( + high_precision_zeros.to_string_opts(false, false), + "1.00000000" + ); // no trim + + // Test zero amount + let zero_amount = Amount::new(0, 4).with_unit_name("TOKEN"); + assert_eq!(zero_amount.to_string_opts(true, true), "0 TOKEN"); + assert_eq!(zero_amount.to_string_opts(false, true), "0"); + assert_eq!(zero_amount.to_string_opts(true, false), "0.0000 TOKEN"); + assert_eq!(zero_amount.to_string_opts(false, false), "0.0000"); + + // Test amount without unit name + let no_unit = Amount::new(12345, 3); + assert_eq!(no_unit.to_string_opts(true, true), "12.345"); // show_unit=true but no unit name + assert_eq!(no_unit.to_string_opts(false, true), "12.345"); // show_unit=false + assert_eq!(no_unit.to_string_opts(true, false), "12.345"); // show_unit=true but no unit name, no trim + assert_eq!(no_unit.to_string_opts(false, false), "12.345"); // show_unit=false, no trim + + // Test amount with empty unit name (should be treated as no unit) + let empty_unit = Amount::new(12345, 2).with_unit_name(""); + assert_eq!(empty_unit.to_string_opts(true, true), "123.45"); // empty unit name should not show + assert_eq!(empty_unit.to_string_opts(false, true), "123.45"); } } diff --git a/src/ui/components/amount_input.rs b/src/ui/components/amount_input.rs index 8ef235d44..da497541f 100644 --- a/src/ui/components/amount_input.rs +++ b/src/ui/components/amount_input.rs @@ -118,7 +118,7 @@ impl AmountInput { show_max_button: false, desired_width: None, show_validation_errors: true, // Default to showing validation errors - changed: false, + changed: true, // Start as changed to force initial validation } } @@ -135,13 +135,36 @@ impl AmountInput { self.decimal_places } + /// Update decimal places used to render values. + /// + /// Value displayed in the input is not changed, but the actual [Amount] + /// will be multiplied or divided by 10^(difference of decimal places). + /// + /// ## Example + /// + /// The input contains `12.34` and decimal places is set to 3. + /// It will be interpreted as `12.340` when parsed (credits value `12_340`). + /// + /// + /// If you change the decimal places from 3 to 5: + /// + /// * The input will still display `12.34` (unchanged) + /// * The next time the input is parsed, it will generate `12.34000` + /// (credits value `1_234_000`). + pub fn set_decimal_places(&mut self, decimal_places: u8) -> &mut Self { + self.decimal_places = decimal_places; + self.changed = true; + + self + } + /// Gets the unit name this input is configured for. pub fn unit_name(&self) -> Option<&str> { self.unit_name.as_deref() } /// Sets the label for the input field. - pub fn label>(mut self, label: T) -> Self { + pub fn with_label>(mut self, label: T) -> Self { self.label = Some(label.into()); self } @@ -154,7 +177,7 @@ impl AmountInput { } /// Sets the hint text for the input field. - pub fn hint_text>(mut self, hint_text: T) -> Self { + pub fn with_hint_text>(mut self, hint_text: T) -> Self { self.hint_text = Some(hint_text.into()); self } @@ -167,7 +190,7 @@ impl AmountInput { /// Sets the maximum amount allowed. If provided, a "Max" button will be shown /// when `show_max_button` is true. - pub fn max_amount(mut self, max_amount: Option) -> Self { + pub fn with_max_amount(mut self, max_amount: Option) -> Self { self.max_amount = max_amount; self } @@ -181,7 +204,7 @@ impl AmountInput { /// Sets the minimum amount allowed. Defaults to 1 (must be greater than zero). /// Set to Some(0) to allow zero amounts, or None to disable minimum validation. - pub fn min_amount(mut self, min_amount: Option) -> Self { + pub fn with_min_amount(mut self, min_amount: Option) -> Self { self.min_amount = min_amount; self } @@ -193,7 +216,7 @@ impl AmountInput { } /// Whether to show a "Max" button that sets the amount to the maximum. - pub fn max_button(mut self, show: bool) -> Self { + pub fn with_max_button(mut self, show: bool) -> Self { self.show_max_button = show; self } @@ -205,7 +228,7 @@ impl AmountInput { } /// Sets the desired width of the input field. - pub fn desired_width(mut self, width: f32) -> Self { + pub fn with_desired_width(mut self, width: f32) -> Self { self.desired_width = Some(width); self } @@ -343,6 +366,15 @@ impl Component for AmountInput { fn show(&mut self, ui: &mut Ui) -> InnerResponse { AmountInput::show_internal(self, ui) } + + fn current_value(&self) -> Option { + // Validate the current amount string and return the parsed amount + match self.validate_amount() { + Ok(Some(amount)) => Some(amount), + Ok(None) => None, // Empty input + Err(_) => None, // Invalid input returns None + } + } } #[cfg(test)] @@ -382,15 +414,15 @@ mod tests { assert_eq!(input.min_amount, Some(1)); // Custom minimum - let input = AmountInput::new(Amount::new(0, 8)).min_amount(Some(1000)); + let input = AmountInput::new(Amount::new(0, 8)).with_min_amount(Some(1000)); assert_eq!(input.min_amount, Some(1000)); // Allow zero - let input = AmountInput::new(Amount::new(0, 8)).min_amount(Some(0)); + let input = AmountInput::new(Amount::new(0, 8)).with_min_amount(Some(0)); assert_eq!(input.min_amount, Some(0)); // No minimum - let input = AmountInput::new(Amount::new(0, 8)).min_amount(None); + let input = AmountInput::new(Amount::new(0, 8)).with_min_amount(None); assert_eq!(input.min_amount, None); } @@ -478,8 +510,8 @@ mod tests { fn test_min_max_validation() { let amount = Amount::new(0, 2); let mut input = AmountInput::new(amount) - .min_amount(Some(100)) // Minimum 1.00 - .max_amount(Some(10000)); // Maximum 100.00 + .with_min_amount(Some(100)) // Minimum 1.00 + .with_max_amount(Some(10000)); // Maximum 100.00 // Test amount below minimum input.amount_str = "0.50".to_string(); // 50 (below min of 100) diff --git a/src/ui/components/component_trait.rs b/src/ui/components/component_trait.rs index 199d87e2c..6a5feb28e 100644 --- a/src/ui/components/component_trait.rs +++ b/src/ui/components/component_trait.rs @@ -92,4 +92,12 @@ pub trait Component { /// An [`InnerResponse`] containing the component's response data in [`InnerResponse::inner`] field. /// [`InnerResponse::inner`] should implement [`ComponentResponse`] trait. fn show(&mut self, ui: &mut Ui) -> InnerResponse; + + /// Returns the current value of the component. + /// + /// Note that only valid values should be returned here. + /// If the component value is invalid, this should return `None`. + /// + /// See [`ComponentResponse::current_value`] for more details. + fn current_value(&self) -> Option; } diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index 74aea7cc8..3418eb419 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -12,6 +12,7 @@ use crate::app::AppAction; use crate::backend_task::contract::ContractTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::model::amount::Amount; use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::QualifiedIdentity; use crate::ui::components::identity_selector::IdentitySelector; @@ -355,14 +356,22 @@ impl GroupActionsScreen { TokenEvent::Mint(amount, _identifier, note_opt) => { let mut mint_screen = MintTokensScreen::new(identity_token_info, &self.app_context); mint_screen.group_action_id = Some(action_id); - mint_screen.amount_to_mint = amount.to_string(); + // Convert amount to Amount struct using the token configuration + mint_screen.amount = Some(Amount::from_token( + &mint_screen.identity_token_info, + *amount, + )); mint_screen.public_note = note_opt.clone(); *action |= AppAction::AddScreen(Screen::MintTokensScreen(mint_screen)); } TokenEvent::Burn(amount, _burn_from, note_opt) => { let mut burn_screen = BurnTokensScreen::new(identity_token_info, &self.app_context); burn_screen.group_action_id = Some(action_id); - burn_screen.amount_to_burn = amount.to_string(); + // Convert amount to Amount struct using the token configuration + burn_screen.amount = Some(Amount::from_token( + &burn_screen.identity_token_info, + *amount, + )); burn_screen.public_note = note_opt.clone(); *action |= AppAction::AddScreen(Screen::BurnTokensScreen(burn_screen)); } diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index 4240337b1..31d13e60f 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -115,9 +115,9 @@ impl TransferScreen { let amount_input = self.amount_input.get_or_insert_with(|| { AmountInput::new(Amount::new_dash(0.0)) - .label("Amount:") - .max_button(true) - .max_amount(Some(max_amount_credits)) + .with_label("Amount:") + .with_max_button(true) + .with_max_amount(Some(max_amount_credits)) }); // Check if input should be disabled when operation is in progress @@ -132,10 +132,7 @@ impl TransferScreen { let response = ui.add_enabled_ui(enabled, |ui| amount_input.show(ui)).inner; response.inner.update(&mut self.amount); - - if let Some(error) = &response.inner.error_message { - ui.colored_label(egui::Color32::DARK_RED, error); - } + // errors are handled inside AmountInput } fn render_to_identity_input(&mut self, ui: &mut Ui) { diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index 041daa70e..720b6c404 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -105,8 +105,8 @@ impl WithdrawalScreen { // Lazy initialization with basic configuration let amount_input = self.withdrawal_amount_input.get_or_insert_with(|| { AmountInput::new(Amount::new_dash(0.0)) - .label("Amount:") - .max_button(true) + .with_label("Amount:") + .with_max_button(true) }); // Check if input should be disabled when operation is in progress @@ -123,9 +123,7 @@ impl WithdrawalScreen { let response = ui.add_enabled_ui(enabled, |ui| amount_input.show(ui)).inner; response.inner.update(&mut self.withdrawal_amount); - if let Some(error) = &response.inner.error_message { - ui.colored_label(egui::Color32::DARK_RED, error); - } + // errors are handled inside AmountInput } fn render_address_input(&mut self, ui: &mut Ui) { diff --git a/src/ui/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs index 96a3ac708..8292cccf9 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -1,9 +1,12 @@ +use crate::ui::components::amount_input::AmountInput; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; +use crate::ui::components::{Component, ComponentResponse}; use crate::ui::contracts_documents::group_actions_screen::GroupActionsScreen; use crate::ui::helpers::{TransactionType, add_identity_key_chooser, render_group_action_text}; use crate::ui::theme::DashColors; +use crate::ui::tokens::tokens_screen::IdentityTokenIdentifier; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; @@ -25,6 +28,7 @@ use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; +use crate::model::amount::Amount; use crate::model::wallet::Wallet; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; @@ -52,7 +56,9 @@ pub struct BurnTokensScreen { pub group_action_id: Option, // The user chooses how many tokens to burn - pub amount_to_burn: String, + pub amount: Option, + pub amount_input: Option, + pub max_amount: Option, // Maximum amount the user can burn based on their balance pub public_note: Option, status: BurnTokensStatus, @@ -72,6 +78,18 @@ pub struct BurnTokensScreen { impl BurnTokensScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { + let token_balance = match app_context.identity_token_balances() { + Ok(identity_token_balances) => { + let itb = identity_token_balances; + let key = IdentityTokenIdentifier { + identity_id: identity_token_info.identity.identity.id(), + token_id: identity_token_info.token_id, + }; + itb.get(&key).map(|itb| itb.balance) + } + Err(_) => None, + }; + let possible_key = identity_token_info .identity .identity @@ -179,7 +197,9 @@ impl BurnTokensScreen { group, is_unilateral_group_member, group_action_id: None, - amount_to_burn: String::new(), + amount: None, + amount_input: None, + max_amount: token_balance, public_note: None, status: BurnTokensStatus::NotStarted, error_message, @@ -192,11 +212,23 @@ impl BurnTokensScreen { } /// Renders a text input for the user to specify an amount to burn - fn render_amount_input(&mut self, ui: &mut Ui) { - ui.horizontal(|ui| { - ui.label("Amount to Burn:"); - ui.text_edit_singleline(&mut self.amount_to_burn); + fn render_amount_input(&mut self, ui: &mut egui::Ui) { + let amount_input = self.amount_input.get_or_insert_with(|| { + let token_amount = Amount::from_token(&self.identity_token_info, 0); + let mut input = AmountInput::new(token_amount).with_label("Amount:"); + + if self.max_amount.is_some() { + input.set_show_max_button(self.max_amount.is_some()); + input.set_max_amount(self.max_amount); + } + + input }); + + let amount_response = amount_input.show(ui).inner; + // Update the amount based on user input + amount_response.update(&mut self.amount); + // errors are handled inside AmountInput } /// Renders a confirm popup with the final "Are you sure?" step @@ -207,19 +239,18 @@ impl BurnTokensScreen { .collapsible(false) .open(&mut is_open) .show(ui.ctx(), |ui| { - // Validate user input - let amount_ok = self.amount_to_burn.parse::().ok(); - if amount_ok.is_none() { - self.error_message = Some("Please enter a valid integer amount.".into()); - self.status = BurnTokensStatus::ErrorMessage("Invalid amount".into()); - self.show_confirmation_popup = false; - return; - } + let amount = match self.amount.as_ref() { + Some(amount) if amount.value() > 0 => amount, + _ => { + self.error_message = + Some("Please enter a valid amount greater than 0.".into()); + self.status = BurnTokensStatus::ErrorMessage("Invalid amount".into()); + self.show_confirmation_popup = false; + return; + } + }; - ui.label(format!( - "Are you sure you want to burn {} tokens?", - self.amount_to_burn - )); + ui.label(format!("Are you sure you want to burn {}?", amount)); ui.add_space(10.0); @@ -265,7 +296,7 @@ impl BurnTokensScreen { } else { self.public_note.clone() }, - amount: amount_ok.unwrap(), + amount: amount.value(), group_info, })), BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), @@ -504,7 +535,13 @@ impl ScreenLike for BurnTokensScreen { "You are signing an existing group Burn so you are not allowed to choose the amount.", ); ui.add_space(5.0); - ui.label(format!("Amount: {}", self.amount_to_burn)); + ui.label(format!( + "Amount: {}", + self.amount + .as_ref() + .map(|a| a.to_string()) + .unwrap_or_default() + )); } else { self.render_amount_input(ui); } diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index ab15559df..4a90e8aff 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -3,14 +3,17 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; +use crate::model::amount::Amount; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::amount_input::AmountInput; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::{Component, ComponentResponse}; use crate::ui::contracts_documents::group_actions_screen::GroupActionsScreen; use crate::ui::helpers::{TransactionType, add_identity_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; @@ -58,7 +61,8 @@ pub struct MintTokensScreen { pub recipient_identity_id: String, - pub amount_to_mint: String, + pub amount: Option, + pub amount_input: Option, status: MintTokensStatus, error_message: Option, @@ -190,7 +194,8 @@ impl MintTokensScreen { group_action_id: None, known_identities, recipient_identity_id: "".to_string(), - amount_to_mint: "".to_string(), + amount: None, + amount_input: None, status: MintTokensStatus::NotStarted, error_message, app_context: app_context.clone(), @@ -201,15 +206,25 @@ impl MintTokensScreen { } } - /// Renders a text input for the user to specify an amount to mint + /// Renders an amount input for the user to specify an amount to mint fn render_amount_input(&mut self, ui: &mut Ui) { - ui.horizontal(|ui| { - ui.label("Amount to Mint:"); - ui.text_edit_singleline(&mut self.amount_to_mint); - - // Since it's minting, we often don't do "Max." - // But you could show a help text or put constraints if needed. + // Lazy initialization with proper token configuration + let amount_input = self.amount_input.get_or_insert_with(|| { + // Create appropriate Amount based on token configuration + let token_amount = Amount::from_token(&self.identity_token_info, 0); + AmountInput::new(token_amount).with_label("Amount to Mint:") }); + + // Check if input should be disabled when operation is in progress + let enabled = match self.status { + MintTokensStatus::WaitingForResult(_) | MintTokensStatus::Complete => false, + MintTokensStatus::NotStarted | MintTokensStatus::ErrorMessage(_) => true, + }; + + let response = ui.add_enabled_ui(enabled, |ui| amount_input.show(ui)).inner; + + response.inner.update(&mut self.amount); + // errors are handled inside AmountInput } /// Renders an optional text input for the user to specify a "Recipient Identity" @@ -237,13 +252,12 @@ impl MintTokensScreen { .open(&mut is_open) .show(ui.ctx(), |ui| { // Validate user input - let amount_ok = self.amount_to_mint.parse::().ok(); - if amount_ok.is_none() { + let Some(amount) = &self.amount else { self.error_message = Some("Please enter a valid amount.".into()); self.status = MintTokensStatus::ErrorMessage("Invalid amount".into()); self.show_confirmation_popup = false; return; - } + }; let maybe_identifier = if self.recipient_identity_id.trim().is_empty() { None @@ -266,7 +280,7 @@ impl MintTokensScreen { ui.label(format!( "Are you sure you want to mint {} token(s)?", - self.amount_to_mint + amount )); // If user provided a recipient: @@ -320,7 +334,7 @@ impl MintTokensScreen { } else { self.public_note.clone() }, - amount: amount_ok.unwrap(), + amount: amount.value(), recipient_id: maybe_identifier, group_info, }, @@ -557,7 +571,13 @@ impl ScreenLike for MintTokensScreen { "You are signing an existing group Mint so you are not allowed to choose the amount.", ); ui.add_space(5.0); - ui.label(format!("Amount: {}", self.amount_to_mint)); + ui.label(format!( + "Amount: {}", + self.amount + .as_ref() + .map(|a| a.to_string()) + .unwrap_or_default() + )); } else { self.render_amount_input(ui); } diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index d3ae8b65f..a934e08ed 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex, RwLock}; use serde_json; use chrono::{DateTime, Duration, Utc}; -use dash_sdk::dpp::balances::credits::TokenAmount; +use dash_sdk::dpp::balances::credits::{TokenAmount}; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::v0::{TokenConfigurationPresetFeatures, TokenConfigurationV0}; use dash_sdk::dpp::data_contract::associated_token::token_distribution_rules::v0::TokenDistributionRulesV0; @@ -56,13 +56,16 @@ use crate::backend_task::{BackendTask, NO_IDENTITIES_FOUND}; use crate::app::{AppAction, DesiredAppAction}; use crate::context::AppContext; +use crate::model::amount::Amount; use crate::model::qualified_identity::{IdentityType, QualifiedIdentity}; use crate::model::wallet::Wallet; +use crate::ui::components::amount_input::AmountInput; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::{Component, ComponentResponse}; use crate::ui::{BackendTaskSuccessResult, MessageType, RootScreenType, ScreenLike, ScreenType}; const EXP_FORMULA_PNG: &[u8] = include_bytes!("../../../../assets/exp_function.png"); @@ -71,6 +74,8 @@ const LOG_FORMULA_PNG: &[u8] = include_bytes!("../../../../assets/log_function.p const LINEAR_FORMULA_PNG: &[u8] = include_bytes!("../../../../assets/linear_function.png"); const POLYNOMIAL_FORMULA_PNG: &[u8] = include_bytes!("../../../../assets/polynomial_function.png"); +const DEFAULT_DECIMALS: u8 = 8; + pub fn load_formula_image(bytes: &[u8]) -> ColorImage { let image = ImageReader::new(std::io::Cursor::new(bytes)) .with_guessed_format() @@ -1001,8 +1006,10 @@ pub struct TokensScreen { token_description_input: String, should_capitalize_input: bool, decimals_input: String, - base_supply_input: String, - max_supply_input: String, + base_supply_amount: Option, + base_supply_input: Option, + max_supply_amount: Option, + max_supply_input: Option, start_as_paused_input: bool, main_control_group_input: String, show_token_creator_confirmation_popup: bool, @@ -1355,11 +1362,11 @@ impl TokensScreen { contract_keywords_input: String::new(), token_description_input: String::new(), should_capitalize_input: true, - decimals_input: 0.to_string(), - base_supply_input: TokenConfigurationV0::default_most_restrictive() - .base_supply() - .to_string(), - max_supply_input: String::new(), + decimals_input: DEFAULT_DECIMALS.to_string(), + base_supply_amount: None, + base_supply_input: None, + max_supply_amount: None, + max_supply_input: None, start_as_paused_input: false, show_advanced_keeps_history: false, token_advanced_keeps_history: TokenKeepsHistoryRulesV0::default_for_keeping_all_history( @@ -2104,9 +2111,11 @@ impl TokensScreen { )]; self.contract_keywords_input = "".to_string(); self.token_description_input = "".to_string(); - self.decimals_input = "8".to_string(); - self.base_supply_input = "100000".to_string(); - self.max_supply_input = "".to_string(); + self.decimals_input = DEFAULT_DECIMALS.to_string(); // + self.base_supply_input = None; + self.base_supply_amount = None; + self.max_supply_input = None; + self.max_supply_amount = None; self.start_as_paused_input = false; self.should_capitalize_input = true; self.token_advanced_keeps_history = @@ -2397,6 +2406,46 @@ impl TokensScreen { self.token_to_remove = None; } } + + /// Renders the base supply amount input using AmountInput component + fn render_base_supply_input(&mut self, ui: &mut egui::Ui) { + let decimals = self.decimals_input.parse::().unwrap_or(0); + let input = self + .base_supply_input + .get_or_insert_with(|| AmountInput::new(Amount::new(0, decimals))); + + if decimals != input.decimal_places() { + // Update decimals; it will change actual value but I guess this is what user expects + input.set_decimal_places(decimals); + } + + let response = input.show(ui); + response.inner.update(&mut self.base_supply_amount); + } + + /// Renders the max supply amount input using AmountInput component + fn render_max_supply_input(&mut self, ui: &mut egui::Ui) { + let decimals = self.decimals_input.parse::().unwrap_or(0); + + let input = self.max_supply_input.get_or_insert_with(|| { + let initial_amount = Amount::new( + TokenConfigurationV0::default_most_restrictive() + .max_supply() + .unwrap_or(0), + decimals, + ); + + AmountInput::new(initial_amount) + }); + + if decimals != input.decimal_places() { + // Update decimals; it will change actual value but I guess this is what user expects + input.set_decimal_places(decimals); + } + + let response = input.show(ui); + response.inner.update(&mut self.max_supply_amount); + } } // ───────────────────────────────────────────────────────────────── @@ -2937,9 +2986,11 @@ mod tests { TokenNameLanguage::English, true, )]; - token_creator_ui.base_supply_input = "5000000".to_string(); - token_creator_ui.max_supply_input = "10000000".to_string(); - token_creator_ui.decimals_input = "8".to_string(); + token_creator_ui.base_supply_input = None; + token_creator_ui.base_supply_amount = Some(Amount::new(5000000, 8)); + token_creator_ui.max_supply_input = None; + token_creator_ui.max_supply_amount = Some(Amount::new(10000000, 8)); + token_creator_ui.decimals_input = DEFAULT_DECIMALS.to_string(); token_creator_ui.start_as_paused_input = true; token_creator_ui.token_advanced_keeps_history = TokenKeepsHistoryRulesV0::default_for_keeping_all_history(true); diff --git a/src/ui/tokens/tokens_screen/my_tokens.rs b/src/ui/tokens/tokens_screen/my_tokens.rs index 6b6db3fd3..a7e3a02b2 100644 --- a/src/ui/tokens/tokens_screen/my_tokens.rs +++ b/src/ui/tokens/tokens_screen/my_tokens.rs @@ -523,10 +523,9 @@ impl TokensScreen { .token_configuration .conventions() .plural_form_by_language_code_or_default("en"); - let reward_amount = Amount::new( - explanation.total_amount, - decimal_places, - ).with_unit_name(unit_name); + let reward_amount = + Amount::new(explanation.total_amount, decimal_places) + .with_unit_name(unit_name); ui.label(format!("Total Estimated Rewards: {}", reward_amount)); ui.separator(); diff --git a/src/ui/tokens/tokens_screen/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index c1f7f640f..e10ef0e25 100644 --- a/src/ui/tokens/tokens_screen/token_creator.rs +++ b/src/ui/tokens/tokens_screen/token_creator.rs @@ -11,7 +11,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; use eframe::epaint::Color32; -use egui::{ComboBox, Context, RichText, TextEdit, Ui}; +use egui::{ComboBox, Context, RichText, TextEdit, Ui}; use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; @@ -245,13 +245,15 @@ impl TokensScreen { } // Row 2: Base Supply + // We put label manually to comply with grid layout; + // errors will be rendered in second column ui.label("Base Supply*:"); - ui.text_edit_singleline(&mut self.base_supply_input); + self.render_base_supply_input(ui); ui.end_row(); // Row 3: Max Supply ui.label("Max Supply:"); - ui.text_edit_singleline(&mut self.max_supply_input); + self.render_max_supply_input(ui); ui.end_row(); // Row 4: Contract Keywords @@ -807,19 +809,18 @@ impl TokensScreen { .parse::() .map_err(|_| "Invalid decimal places amount".to_string())?; let base_supply = self - .base_supply_input - .parse::() - .map_err(|_| "Invalid base supply amount".to_string())?; - let max_supply = if self.max_supply_input.is_empty() { - None - } else { - // If parse fails, error out - Some( - self.max_supply_input - .parse::() - .map_err(|_| "Invalid Max Supply".to_string())?, - ) - }; + .base_supply_amount + .as_ref() + .map(|amount| amount.value()) + .ok_or_else(|| "Please enter a valid base supply amount".to_string())?; + let max_supply = self + .max_supply_amount + .as_ref() + .map(|amount| { + let value = amount.value(); + if value > 0 { Some(value) } else { None } + }) + .unwrap_or(None); let start_paused = self.start_as_paused_input; let allow_transfers_to_frozen_identities = self.allow_transfers_to_frozen_identities; @@ -1017,14 +1018,20 @@ impl TokensScreen { ui.label( "Are you sure you want to register a new token contract with these settings?\n", ); - let max_supply_display = if self.max_supply_input.is_empty() { - "None".to_string() - } else { - self.max_supply_input.clone() - }; + let base_supply_display = self + .base_supply_amount + .as_ref() + .map(|amount| amount.to_string_opts(true, false)) + .unwrap_or_else(|| "0".to_string()); + let max_supply_display = self + .max_supply_amount + .as_ref() + .filter(|amount| amount.value() > 0) + .map(|amount| amount.to_string_opts(true, false)) + .unwrap_or_else(|| "None".to_string()); ui.label(format!( "Name: {}\nBase Supply: {}\nMax Supply: {}", - self.token_names_input[0].0, self.base_supply_input, max_supply_display, + self.token_names_input[0].0, base_supply_display, max_supply_display, )); ui.add_space(10.0); diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index e35d5b736..3426ce36e 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -120,8 +120,8 @@ impl TransferTokensScreen { .as_ref() .unwrap_or(&Amount::from(&self.identity_token_balance)), ) - .label("Amount:") - .max_button(true), + .with_label("Amount:") + .with_max_button(true), ); self.amount_input @@ -142,13 +142,7 @@ impl TransferTokensScreen { let response = ui.add_enabled_ui(enabled, |ui| amount_input.show(ui)).inner; response.inner.update(&mut self.amount); - - if let Some(error) = &response.inner.error_message { - ui.colored_label( - DashColors::error_color(ui.ctx().style().visuals.dark_mode), - error, - ); - } + // errors are handled inside AmountInput } fn render_to_identity_input(&mut self, ui: &mut Ui) { From 11771ee31f8d081c478e9454bafadbcf806c31dd Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 5 Aug 2025 17:49:57 +0700 Subject: [PATCH 005/106] feat: add network field to identity structures (#423) * feat: add network field to identity structures * fix --- src/backend_task/identity/load_identity.rs | 1 + .../identity/load_identity_from_wallet.rs | 1 + .../identity/register_identity.rs | 1 + src/database/identities.rs | 9 ++++++-- src/database/wallet.rs | 1 + .../encrypted_key_storage.rs | 3 +++ src/model/qualified_identity/mod.rs | 21 +++---------------- src/model/wallet/mod.rs | 6 ++++-- src/ui/identities/keys/key_info_screen.rs | 2 ++ src/ui/tokens/tokens_screen/mod.rs | 3 +++ 10 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index 0947ca1e0..e2d646d8a 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -304,6 +304,7 @@ impl AppContext { wallet_index: None, //todo top_ups: Default::default(), status: IdentityStatus::Active, + network: self.network, }; let wallet_info = qualified_identity.determine_wallet_info()?; diff --git a/src/backend_task/identity/load_identity_from_wallet.rs b/src/backend_task/identity/load_identity_from_wallet.rs index bbe630e21..bad0699f3 100644 --- a/src/backend_task/identity/load_identity_from_wallet.rs +++ b/src/backend_task/identity/load_identity_from_wallet.rs @@ -142,6 +142,7 @@ impl AppContext { wallet_index: Some(identity_index), top_ups: Default::default(), status: IdentityStatus::Active, + network: self.network, }; // Insert qualified identity into the database diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index 57dc94c04..837460c2f 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -348,6 +348,7 @@ impl AppContext { wallet_index: Some(wallet_identity_index), top_ups: Default::default(), status: IdentityStatus::PendingCreation, + network: self.network, }; if !alias_input.is_empty() { diff --git a/src/database/identities.rs b/src/database/identities.rs index b0635d067..ac5c86f2c 100644 --- a/src/database/identities.rs +++ b/src/database/identities.rs @@ -177,6 +177,7 @@ impl Database { identity.wallet_index = wallet_index; identity.status = IdentityStatus::from_u8(status); + identity.network = app_context.network; // Associate wallets identity.associated_wallets = wallets.clone(); //todo: use less wallets @@ -232,6 +233,7 @@ impl Database { let mut identity: QualifiedIdentity = QualifiedIdentity::from_bytes(&data); identity.alias = alias; identity.wallet_index = wallet_index; + identity.network = app_context.network; // Associate wallets identity.associated_wallets = wallets.clone(); //todo: use less wallets @@ -287,6 +289,7 @@ impl Database { let mut identity: QualifiedIdentity = QualifiedIdentity::from_bytes(&data); identity.alias = alias; identity.wallet_index = wallet_index; + identity.network = app_context.network; // Associate wallets identity.associated_wallets = wallets.clone(); //todo: use less wallets @@ -326,7 +329,8 @@ impl Database { )?; let identity_iter = stmt.query_map(params![network], |row| { let data: Vec = row.get(0)?; - let identity: QualifiedIdentity = QualifiedIdentity::from_bytes(&data); + let mut identity: QualifiedIdentity = QualifiedIdentity::from_bytes(&data); + identity.network = app_context.network; Ok(identity) })?; @@ -353,7 +357,8 @@ impl Database { stmt.query_map(params![network], |row| { let data: Vec = row.get(0)?; let wallet_id: Option = row.get(1)?; - let identity: QualifiedIdentity = QualifiedIdentity::from_bytes(&data); + let mut identity: QualifiedIdentity = QualifiedIdentity::from_bytes(&data); + identity.network = app_context.network; Ok((identity, wallet_id)) })? diff --git a/src/database/wallet.rs b/src/database/wallet.rs index c27ae0d06..7ad878bbc 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -467,6 +467,7 @@ impl Database { if let Some(wallet) = wallets_map.get_mut(&wallet_seed_hash_array) { let mut identity: QualifiedIdentity = QualifiedIdentity::from_bytes(&identity_data); identity.wallet_index = Some(wallet_index); + identity.network = *network; tracing::trace!( wallet_seed = ?wallet_seed_hash_array, diff --git a/src/model/qualified_identity/encrypted_key_storage.rs b/src/model/qualified_identity/encrypted_key_storage.rs index 94a48af6f..c5fb77ec7 100644 --- a/src/model/qualified_identity/encrypted_key_storage.rs +++ b/src/model/qualified_identity/encrypted_key_storage.rs @@ -5,6 +5,7 @@ use bincode::de::{BorrowDecoder, Decoder}; use bincode::enc::Encoder; use bincode::error::{DecodeError, EncodeError}; use bincode::{BorrowDecode, Decode, Encode}; +use dash_sdk::dashcore_rpc::dashcore::Network; use dash_sdk::dashcore_rpc::dashcore::bip32::DerivationPath; use dash_sdk::dpp::dashcore::bip32::ChildNumber; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -277,6 +278,7 @@ impl KeyStorage { &self, key: &(PrivateKeyTarget, KeyID), wallets: &[Arc>], + network: Network, ) -> Result, String> { self.private_keys .get(key) @@ -296,6 +298,7 @@ impl KeyStorage { wallets, *wallet_seed_hash, derivation_path, + network, )? .ok_or(format!( "Wallet for key at derivation path {} not present, we have {} wallets", diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 1d57c299b..7f7e16b10 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -223,6 +223,7 @@ pub struct QualifiedIdentity { pub wallet_index: Option, pub top_ups: BTreeMap, pub status: IdentityStatus, + pub network: Network, } impl AsRef for QualifiedIdentity { @@ -286,6 +287,7 @@ impl Decode for QualifiedIdentity { wallet_index: None, top_ups: Default::default(), status: IdentityStatus::Unknown, // Loaded from the database, not encoded + network: Network::Dash, // Loaded from the database, not encoded }) } } @@ -308,6 +310,7 @@ impl Signer for QualifiedIdentity { .cloned() .collect::>() .as_slice(), + self.network, ) .map_err(ProtocolError::Generic)? .ok_or(ProtocolError::Generic(format!( @@ -616,21 +619,3 @@ impl QualifiedIdentity { Ok(wallet_info) } } -impl From for QualifiedIdentity { - fn from(value: Identity) -> Self { - QualifiedIdentity { - identity: value, - associated_voter_identity: None, - associated_operator_identity: None, - associated_owner_key_id: None, - identity_type: IdentityType::User, - alias: None, - private_keys: Default::default(), - dpns_names: vec![], - associated_wallets: BTreeMap::new(), - wallet_index: None, - top_ups: Default::default(), - status: IdentityStatus::Unknown, - } - } -} diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 5e3e5b912..c7d880490 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -336,6 +336,7 @@ impl Wallet { slice: &[Arc>], wallet_seed_hash: WalletSeedHash, derivation_path: &DerivationPath, + network: Network, ) -> Result, String> { for wallet in slice { // Attempt to read the wallet from the RwLock @@ -344,7 +345,7 @@ impl Wallet { if wallet_ref.seed_hash() == wallet_seed_hash { // Attempt to derive the private key using the provided derivation path let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(wallet_ref.seed_bytes()?, Network::Dash) + .derive_priv_ecdsa_for_master_seed(wallet_ref.seed_bytes()?, network) .map_err(|e| e.to_string())?; return Ok(Some(extended_private_key.private_key.secret_bytes())); } @@ -356,9 +357,10 @@ impl Wallet { pub fn private_key_at_derivation_path( &self, derivation_path: &DerivationPath, + network: Network, ) -> Result { let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(self.seed_bytes()?, Network::Dash) + .derive_priv_ecdsa_for_master_seed(self.seed_bytes()?, network) .map_err(|e| e.to_string())?; Ok(extended_private_key.to_priv()) } diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index 4a7166ced..523ecbc32 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -334,6 +334,7 @@ impl ScreenLike for KeyInfoScreen { self.selected_wallet.as_ref().unwrap().read().unwrap(); match wallet.private_key_at_derivation_path( &derivation_path.derivation_path, + self.app_context.network, ) { Ok(private_key) => { let private_key_wif = private_key.to_wif(); @@ -365,6 +366,7 @@ impl ScreenLike for KeyInfoScreen { self.selected_wallet.as_ref().unwrap().read().unwrap(); match wallet.private_key_at_derivation_path( &derivation_path.derivation_path, + self.app_context.network, ) { Ok(private_key) => { let private_key_wif = private_key.to_wif(); diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index a934e08ed..372e7550a 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -2971,6 +2971,7 @@ mod tests { wallet_index: None, top_ups: BTreeMap::new(), status: IdentityStatus::Active, + network: Network::Dash, }; token_creator_ui.selected_identity = Some(mock_identity); @@ -3275,6 +3276,7 @@ mod tests { wallet_index: None, top_ups: BTreeMap::new(), status: IdentityStatus::Active, + network: Network::Dash, }; token_creator_ui.selected_identity = Some(mock_identity); @@ -3390,6 +3392,7 @@ mod tests { wallet_index: None, top_ups: BTreeMap::new(), status: IdentityStatus::Active, + network: Network::Dash, }; token_creator_ui.selected_identity = Some(mock_identity); From 51509ff428a27e41e7351704483801f3cc8e123a Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 5 Aug 2025 17:50:34 +0700 Subject: [PATCH 006/106] feat: add display for private key in both WIF and Hex formats (#424) * feat: add display for private key in both WIF and Hex formats * fix: display private keys as normal text and depend on color theme --------- Co-authored-by: pauldelucia --- src/ui/identities/keys/key_info_screen.rs | 81 +++++++++++++++-------- 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index 523ecbc32..03d8da0dc 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -26,7 +26,7 @@ use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBound use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::IdentityPublicKey; use eframe::egui::{self, Context}; -use egui::{Color32, RichText, ScrollArea, TextEdit}; +use egui::{Color32, RichText, ScrollArea}; use std::sync::{Arc, RwLock}; pub struct KeyInfoScreen { @@ -299,11 +299,15 @@ impl ScreenLike for KeyInfoScreen { match private_key { PrivateKeyData::Clear(clear) | PrivateKeyData::AlwaysClear(clear) => { - let private_key_hex = hex::encode(clear); - ui.add( - TextEdit::singleline(&mut private_key_hex.as_str().to_owned()) - .desired_width(f32::INFINITY), - ); + egui::Grid::new("private_key_grid") + .num_columns(2) + .spacing([10.0, 10.0]) + .show(ui, |ui| { + ui.label(RichText::new("Private Key (Hex):").strong().color(ui.visuals().text_color())); + let private_key_hex = hex::encode(clear); + ui.label(RichText::new(private_key_hex).color(ui.visuals().text_color())); + ui.end_row(); + }); ui.add_space(10.0); if ui.button("Remove private key from DET").clicked() { self.show_confirm_remove_private_key = true; @@ -322,13 +326,20 @@ impl ScreenLike for KeyInfoScreen { && self.selected_wallet.is_some() { if let Some(private_key) = self.decrypted_private_key { - let private_key_wif = private_key.to_wif(); - ui.add( - TextEdit::multiline( - &mut private_key_wif.as_str().to_owned(), - ) - .desired_width(f32::INFINITY), - ); + egui::Grid::new("private_key_grid_wallet") + .num_columns(2) + .spacing([10.0, 10.0]) + .show(ui, |ui| { + ui.label(RichText::new("Private Key (WIF):").strong().color(ui.visuals().text_color())); + let private_key_wif = private_key.to_wif(); + ui.label(RichText::new(private_key_wif).color(ui.visuals().text_color())); + ui.end_row(); + + ui.label(RichText::new("Private Key (Hex):").strong().color(ui.visuals().text_color())); + let private_key_hex = hex::encode(private_key.inner.secret_bytes()); + ui.label(RichText::new(private_key_hex).color(ui.visuals().text_color())); + ui.end_row(); + }); } else { let wallet = self.selected_wallet.as_ref().unwrap().read().unwrap(); @@ -337,13 +348,21 @@ impl ScreenLike for KeyInfoScreen { self.app_context.network, ) { Ok(private_key) => { - let private_key_wif = private_key.to_wif(); - ui.add( - TextEdit::multiline( - &mut private_key_wif.as_str().to_owned(), - ) - .desired_width(f32::INFINITY), - ); + egui::Grid::new("private_key_grid_wallet2") + .num_columns(2) + .spacing([10.0, 10.0]) + .show(ui, |ui| { + ui.label(RichText::new("Private Key (WIF):").strong().color(ui.visuals().text_color())); + let private_key_wif = private_key.to_wif(); + ui.label(RichText::new(private_key_wif).color(ui.visuals().text_color())); + ui.end_row(); + + ui.label(RichText::new("Private Key (Hex):").strong().color(ui.visuals().text_color())); + let private_key_hex = hex::encode(private_key.inner.secret_bytes()); + ui.label(RichText::new(private_key_hex).color(ui.visuals().text_color())); + ui.end_row(); + }); + self.decrypted_private_key = Some(private_key); } Err(e) => { @@ -369,13 +388,21 @@ impl ScreenLike for KeyInfoScreen { self.app_context.network, ) { Ok(private_key) => { - let private_key_wif = private_key.to_wif(); - ui.add( - TextEdit::multiline( - &mut private_key_wif.as_str().to_owned(), - ) - .desired_width(f32::INFINITY), - ); + egui::Grid::new("private_key_grid_wallet2") + .num_columns(2) + .spacing([10.0, 10.0]) + .show(ui, |ui| { + ui.label(RichText::new("Private Key (WIF):").strong().color(ui.visuals().text_color())); + let private_key_wif = private_key.to_wif(); + ui.label(RichText::new(private_key_wif).color(ui.visuals().text_color())); + ui.end_row(); + + ui.label(RichText::new("Private Key (Hex):").strong().color(ui.visuals().text_color())); + let private_key_hex = hex::encode(private_key.inner.secret_bytes()); + ui.label(RichText::new(private_key_hex).color(ui.visuals().text_color())); + ui.end_row(); + }); + self.decrypted_private_key = Some(private_key); } Err(e) => { From 554bc6bbac4489f30cbbb3739d1e7c6391daa177 Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Wed, 6 Aug 2025 12:09:19 +0200 Subject: [PATCH 007/106] feat: allow setting zmq uri in .env (#425) * feat: allow setting ZMQ URI * chore: rename zmq_endpoint to core_zmq_endpoint --- .env.example | 4 ++++ src/app.rs | 27 +++++++++++++++++++++++---- src/config.rs | 11 +++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index f3527862f..0a9bd218b 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ MAINNET_core_rpc_port=9998 MAINNET_core_rpc_user=dashrpc MAINNET_core_rpc_password=password MAINNET_insight_api_url=https://insight.dash.org/insight-api +MAINNET_core_zmq_endpoint=tcp://127.0.0.1:23708 MAINNET_show_in_ui=true MAINNET_developer_mode=true @@ -13,6 +14,7 @@ TESTNET_core_rpc_port=19998 TESTNET_core_rpc_user=dashrpc TESTNET_core_rpc_password=password TESTNET_insight_api_url=https://testnet-insight.dash.org/insight-api +TESTNET_core_zmq_endpoint=tcp://127.0.0.1:23709 TESTNET_show_in_ui=true TESTNET_developer_mode=false @@ -22,6 +24,7 @@ DEVNET_core_rpc_port=29998 DEVNET_core_rpc_user=dashrpc DEVNET_core_rpc_password=password DEVNET_insight_api_url= +DEVNET_core_zmq_endpoint=tcp://127.0.0.1:23710 DEVNET_show_in_ui=true DEVNET_developer_mode=false @@ -31,4 +34,5 @@ LOCAL_core_rpc_port=20302 LOCAL_core_rpc_user=dashmate LOCAL_core_rpc_password=password LOCAL_insight_api_url=http://localhost:3001/insight-api +LOCAL_core_zmq_endpoint=tcp://127.0.0.1:20302 LOCAL_show_in_ui=true \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 2116b1723..6f50bb50f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -319,9 +319,16 @@ impl AppState { let (core_message_sender, core_message_receiver) = mpsc::channel().with_egui_ctx(ctx.clone()); + let mainnet_core_zmq_endpoint = mainnet_app_context + .config + .read() + .unwrap() + .core_zmq_endpoint + .clone() + .unwrap_or_else(|| "tcp://127.0.0.1:23708".to_string()); let mainnet_core_zmq_listener = CoreZMQListener::spawn_listener( Network::Dash, - "tcp://127.0.0.1:23708", + &mainnet_core_zmq_endpoint, core_message_sender.clone(), // Clone the sender for each listener Some(mainnet_app_context.sx_zmq_status.clone()), ) @@ -331,9 +338,13 @@ impl AppState { .as_ref() .map(|context| context.sx_zmq_status.clone()); + let testnet_core_zmq_endpoint = testnet_app_context + .as_ref() + .and_then(|ctx| ctx.config.read().unwrap().core_zmq_endpoint.clone()) + .unwrap_or_else(|| "tcp://127.0.0.1:23709".to_string()); let testnet_core_zmq_listener = CoreZMQListener::spawn_listener( Network::Testnet, - "tcp://127.0.0.1:23709", + &testnet_core_zmq_endpoint, core_message_sender.clone(), // Use the original sender or create a new one if needed testnet_tx_zmq_status_option, ) @@ -343,9 +354,13 @@ impl AppState { .as_ref() .map(|context| context.sx_zmq_status.clone()); + let devnet_core_zmq_endpoint = devnet_app_context + .as_ref() + .and_then(|ctx| ctx.config.read().unwrap().core_zmq_endpoint.clone()) + .unwrap_or_else(|| "tcp://127.0.0.1:23710".to_string()); let devnet_core_zmq_listener = CoreZMQListener::spawn_listener( Network::Devnet, - "tcp://127.0.0.1:23710", + &devnet_core_zmq_endpoint, core_message_sender.clone(), devnet_tx_zmq_status_option, ) @@ -355,9 +370,13 @@ impl AppState { .as_ref() .map(|context| context.sx_zmq_status.clone()); + let local_core_zmq_endpoint = local_app_context + .as_ref() + .and_then(|ctx| ctx.config.read().unwrap().core_zmq_endpoint.clone()) + .unwrap_or_else(|| "tcp://127.0.0.1:20302".to_string()); let local_core_zmq_listener = CoreZMQListener::spawn_listener( Network::Regtest, - "tcp://127.0.0.1:20302", + &local_core_zmq_endpoint, core_message_sender, local_tx_zmq_status_option, ) diff --git a/src/config.rs b/src/config.rs index 1d0a7686a..bb3452bb9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,6 +40,8 @@ pub struct NetworkConfig { pub core_rpc_password: String, /// URL of the Insight API pub insight_api_url: String, + /// ZMQ endpoint for Core blockchain events (e.g., tcp://127.0.0.1:23708) + pub core_zmq_endpoint: Option, /// Devnet network name if one exists pub devnet_name: Option, /// Optional wallet private key to instantiate the wallet @@ -103,6 +105,15 @@ impl Config { ) .map_err(|e| ConfigError::LoadError(e.to_string()))?; + if let Some(core_zmq_endpoint) = &config.core_zmq_endpoint { + writeln!( + env_file, + "{}core_zmq_endpoint={}", + prefix, core_zmq_endpoint + ) + .map_err(|e| ConfigError::LoadError(e.to_string()))?; + } + if let Some(devnet_name) = &config.devnet_name { // Only write devnet name if it exists writeln!(env_file, "{}devnet_name={}", prefix, devnet_name) From 7669b42582d5e630e91174bfd0df2d699c581445 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Wed, 13 Aug 2025 17:54:41 +0700 Subject: [PATCH 008/106] fix: failing test in token screen needed update --- src/ui/tokens/tokens_screen/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 372e7550a..9a85b6072 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -3292,6 +3292,9 @@ mod tests { true, )]; + // Set base supply + token_creator_ui.base_supply_amount = Some(Amount::new(1000000, 8)); + // Enable perpetual distribution, select Random token_creator_ui.enable_perpetual_distribution = true; token_creator_ui.perpetual_dist_type = PerpetualDistributionIntervalTypeUI::TimeBased; From 2288337bc891c4d8f458d9f6ca8f32405b159108 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Wed, 13 Aug 2025 18:01:49 +0700 Subject: [PATCH 009/106] fix: update CI to use rust version 1.88 to match rust-toolchain --- .github/workflows/clippy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index b200d4e4e..ba81213f4 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -34,7 +34,7 @@ jobs: - name: Install Rust toolchain uses: actions-rs/toolchain@v1 with: - toolchain: stable + toolchain: 1.88 components: clippy override: true From 4d782da323d04cd324c6ed88e0010c04429ba3f7 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Wed, 13 Aug 2025 18:09:40 +0700 Subject: [PATCH 010/106] feat: better display in key selector (#429) --- src/ui/helpers.rs | 19 +++-- src/ui/identities/keys/key_info_screen.rs | 98 ++++++++++++++++++----- 2 files changed, 88 insertions(+), 29 deletions(-) diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index a3a303096..d6b72eada 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -251,10 +251,11 @@ pub fn add_identity_key_chooser_with_doc_type<'a, T>( .as_ref() .map(|k| { format!( - "Key {} Type {} Security {}", + "Key {} | {} | {} | {}", k.id(), - k.key_type(), - k.security_level() + k.purpose(), + k.security_level(), + k.key_type() ) }) .unwrap_or_else(|| "Select Key…".into()), @@ -305,15 +306,19 @@ pub fn add_identity_key_chooser_with_doc_type<'a, T>( { // In dev mode, mark keys that wouldn't normally be allowed format!( - "Key {} Security {} [DEV]", + "Key {} | {} | {} | {} [DEV]", key.id(), - key.security_level() + key.purpose(), + key.security_level(), + key.key_type() ) } else { format!( - "Key {} Security {}", + "Key {} | {} | {} | {}", key.id(), - key.security_level() + key.purpose(), + key.security_level(), + key.key_type() ) }; diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index 03d8da0dc..1c12968d8 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -303,9 +303,16 @@ impl ScreenLike for KeyInfoScreen { .num_columns(2) .spacing([10.0, 10.0]) .show(ui, |ui| { - ui.label(RichText::new("Private Key (Hex):").strong().color(ui.visuals().text_color())); + ui.label( + RichText::new("Private Key (Hex):") + .strong() + .color(ui.visuals().text_color()), + ); let private_key_hex = hex::encode(clear); - ui.label(RichText::new(private_key_hex).color(ui.visuals().text_color())); + ui.label( + RichText::new(private_key_hex) + .color(ui.visuals().text_color()), + ); ui.end_row(); }); ui.add_space(10.0); @@ -330,14 +337,29 @@ impl ScreenLike for KeyInfoScreen { .num_columns(2) .spacing([10.0, 10.0]) .show(ui, |ui| { - ui.label(RichText::new("Private Key (WIF):").strong().color(ui.visuals().text_color())); + ui.label( + RichText::new("Private Key (WIF):") + .strong() + .color(ui.visuals().text_color()), + ); let private_key_wif = private_key.to_wif(); - ui.label(RichText::new(private_key_wif).color(ui.visuals().text_color())); + ui.label( + RichText::new(private_key_wif) + .color(ui.visuals().text_color()), + ); ui.end_row(); - - ui.label(RichText::new("Private Key (Hex):").strong().color(ui.visuals().text_color())); - let private_key_hex = hex::encode(private_key.inner.secret_bytes()); - ui.label(RichText::new(private_key_hex).color(ui.visuals().text_color())); + + ui.label( + RichText::new("Private Key (Hex):") + .strong() + .color(ui.visuals().text_color()), + ); + let private_key_hex = + hex::encode(private_key.inner.secret_bytes()); + ui.label( + RichText::new(private_key_hex) + .color(ui.visuals().text_color()), + ); ui.end_row(); }); } else { @@ -352,17 +374,33 @@ impl ScreenLike for KeyInfoScreen { .num_columns(2) .spacing([10.0, 10.0]) .show(ui, |ui| { - ui.label(RichText::new("Private Key (WIF):").strong().color(ui.visuals().text_color())); + ui.label( + RichText::new("Private Key (WIF):") + .strong() + .color(ui.visuals().text_color()), + ); let private_key_wif = private_key.to_wif(); - ui.label(RichText::new(private_key_wif).color(ui.visuals().text_color())); + ui.label( + RichText::new(private_key_wif) + .color(ui.visuals().text_color()), + ); ui.end_row(); - - ui.label(RichText::new("Private Key (Hex):").strong().color(ui.visuals().text_color())); - let private_key_hex = hex::encode(private_key.inner.secret_bytes()); - ui.label(RichText::new(private_key_hex).color(ui.visuals().text_color())); + + ui.label( + RichText::new("Private Key (Hex):") + .strong() + .color(ui.visuals().text_color()), + ); + let private_key_hex = hex::encode( + private_key.inner.secret_bytes(), + ); + ui.label( + RichText::new(private_key_hex) + .color(ui.visuals().text_color()), + ); ui.end_row(); }); - + self.decrypted_private_key = Some(private_key); } Err(e) => { @@ -392,17 +430,33 @@ impl ScreenLike for KeyInfoScreen { .num_columns(2) .spacing([10.0, 10.0]) .show(ui, |ui| { - ui.label(RichText::new("Private Key (WIF):").strong().color(ui.visuals().text_color())); + ui.label( + RichText::new("Private Key (WIF):") + .strong() + .color(ui.visuals().text_color()), + ); let private_key_wif = private_key.to_wif(); - ui.label(RichText::new(private_key_wif).color(ui.visuals().text_color())); + ui.label( + RichText::new(private_key_wif) + .color(ui.visuals().text_color()), + ); ui.end_row(); - - ui.label(RichText::new("Private Key (Hex):").strong().color(ui.visuals().text_color())); - let private_key_hex = hex::encode(private_key.inner.secret_bytes()); - ui.label(RichText::new(private_key_hex).color(ui.visuals().text_color())); + + ui.label( + RichText::new("Private Key (Hex):") + .strong() + .color(ui.visuals().text_color()), + ); + let private_key_hex = hex::encode( + private_key.inner.secret_bytes(), + ); + ui.label( + RichText::new(private_key_hex) + .color(ui.visuals().text_color()), + ); ui.end_row(); }); - + self.decrypted_private_key = Some(private_key); } Err(e) => { From c1c6294528941cd0f8e0960319b0c5cedaab876a Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Wed, 13 Aug 2025 13:10:18 +0200 Subject: [PATCH 011/106] refactor: unified confirmation dialog (#413) * refactor: unified alert window * chore: update CLAUDE to create reusable components whenever appropriate * feat: correct confirm dialog on set token price screen * chore: remove callbacks which are overkill * chore: cargo fmt * chore: doctest fix * chore: impl Component for ConfirmationDialog * chore: use WidgetText * feat: Add Escape key handling for confirmation dialog * chore: button inactive when in progress * chore: fixes after merge * chore: some theme improvements * fmt and visual appeal --------- Co-authored-by: pauldelucia --- CLAUDE.md | 2 + doc/COMPONENT_DESIGN_PATTERN.md | 1 + src/ui/components/confirmation_dialog.rs | 359 +++++++++++++++++++++++ src/ui/components/mod.rs | 1 + src/ui/components/styled.rs | 3 + src/ui/identities/transfer_screen.rs | 186 +++++++----- src/ui/theme.rs | 4 + src/ui/tokens/set_token_price_screen.rs | 319 +++++++++++--------- 8 files changed, 667 insertions(+), 208 deletions(-) create mode 100644 src/ui/components/confirmation_dialog.rs diff --git a/CLAUDE.md b/CLAUDE.md index 0bfa8aa2b..799f3a55b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,8 @@ cross build --target x86_64-pc-windows-gnu --release - **Async Backend Tasks**: Communication via crossbeam channels with result handling - **Network Isolation**: Separate app contexts per network with independent databases - **Real-time Updates**: ZMQ listeners for core blockchain events on network-specific ports +- **Custom UI components**: we build a library of reusable widgets in `ui/components` whenever we need similar + widget displayed in more than 2 places ### Critical Dependencies - **dash-sdk**: Core Dash Platform SDK (git dependency, specific revision) diff --git a/doc/COMPONENT_DESIGN_PATTERN.md b/doc/COMPONENT_DESIGN_PATTERN.md index a81d3c94b..d1a6d73df 100644 --- a/doc/COMPONENT_DESIGN_PATTERN.md +++ b/doc/COMPONENT_DESIGN_PATTERN.md @@ -89,6 +89,7 @@ impl ComponentResponse for MyComponentResponse { - [ ] Keep internal state private - [ ] **Be self-contained**: Handle validation, error display, hints, and formatting internally (preferably with configurable error display) - [ ] **Own your UX**: Component should manage its complete user experience +- [ ] Colors should be defined in `ComponentStyles` and optimized for light and dark mode ### ❌ Anti-Patterns to Avoid - Public mutable fields diff --git a/src/ui/components/confirmation_dialog.rs b/src/ui/components/confirmation_dialog.rs new file mode 100644 index 000000000..6f04f2a2b --- /dev/null +++ b/src/ui/components/confirmation_dialog.rs @@ -0,0 +1,359 @@ +use std::sync::Arc; + +use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::theme::{ComponentStyles, DashColors, Shape}; +use egui::{InnerResponse, Ui, WidgetText}; + +/// Response from showing a confirmation dialog +#[derive(Debug, Clone, PartialEq)] +pub enum ConfirmationStatus { + /// User clicked confirm button + Confirmed, + /// User clicked cancel button or closed dialog + Canceled, +} + +pub const NOTHING: Option<&str> = None; +/// Response struct for the ConfirmationDialog component following the Component trait pattern +#[derive(Debug, Clone)] +pub struct ConfirmationDialogComponentResponse { + pub response: egui::Response, + pub changed: bool, + pub error_message: Option, + pub dialog_response: Option, +} + +impl ComponentResponse for ConfirmationDialogComponentResponse { + type DomainType = ConfirmationStatus; + + fn has_changed(&self) -> bool { + self.changed + } + + fn is_valid(&self) -> bool { + self.error_message.is_none() + } + + fn changed_value(&self) -> &Option { + if self.has_changed() { + &self.dialog_response + } else { + &None + } + } + + fn error_message(&self) -> Option<&str> { + self.error_message.as_deref() + } +} +/// A reusable confirmation dialog component that implements the Component trait +/// +/// This component provides a consistent modal dialog for confirming user actions +/// across the application. It supports customizable titles, messages, button text +/// with rich formatting (using WidgetText for styling), danger mode for destructive +/// actions, and optional buttons (confirm and cancel buttons can be hidden independently). +/// The dialog can be dismissed by pressing Escape (treated as cancel) or clicking the X button. +pub struct ConfirmationDialog { + title: WidgetText, + message: WidgetText, + status: Option, + confirm_text: Option, + cancel_text: Option, + danger_mode: bool, + is_open: bool, +} + +impl Component for ConfirmationDialog { + type DomainType = ConfirmationStatus; + type Response = ConfirmationDialogComponentResponse; + + fn show(&mut self, ui: &mut Ui) -> InnerResponse { + let inner_response = self.show_dialog(ui); + let changed = inner_response.inner.is_some(); + let response = inner_response.response; + + InnerResponse::new( + ConfirmationDialogComponentResponse { + response: response.clone(), + changed, + error_message: None, // Confirmation dialogs don't have validation errors + dialog_response: inner_response.inner, + }, + response, + ) + } + + fn current_value(&self) -> Option { + // Return the current dialog state - None if still open, Some(status) if closed + if self.is_open { + None + } else { + Some(ConfirmationStatus::Canceled) // If dialog is closed, it was canceled + } + } +} + +impl ConfirmationDialog { + /// Create a new confirmation dialog with the given title and message + pub fn new(title: impl Into, message: impl Into) -> Self { + Self { + title: title.into(), + message: message.into(), + confirm_text: Some("Confirm".into()), + cancel_text: Some("Cancel".into()), + danger_mode: false, + is_open: true, + status: None, // No action taken yet + } + } + + /// Set the text for the confirm button, or None to hide it + pub fn confirm_text(mut self, text: Option>) -> Self { + self.confirm_text = text.map(|t| t.into()); + self + } + + /// Set the text for the cancel button, or None to hide it + pub fn cancel_text(mut self, text: Option>) -> Self { + self.cancel_text = text.map(|t| t.into()); + self + } + + /// Enable danger mode (red confirm button) for destructive actions + pub fn danger_mode(mut self, enabled: bool) -> Self { + self.danger_mode = enabled; + self + } + + /// Set whether the dialog is open + pub fn open(mut self, open: bool) -> Self { + self.is_open = open; + self + } +} + +impl ConfirmationDialog { + /// Show the dialog and return the user's response + fn show_dialog(&mut self, ui: &mut Ui) -> InnerResponse> { + let mut is_open = self.is_open; + + if !is_open { + return InnerResponse::new( + None, // no change + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), + ); + } + + // Draw dark overlay behind the dialog for better visibility + let screen_rect = ui.ctx().screen_rect(); + let painter = ui.ctx().layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("confirmation_dialog_overlay"), + )); + painter.rect_filled( + screen_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), // Semi-transparent black overlay + ); + + let mut final_response = None; + let window_response = egui::Window::new(self.title.clone()) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .open(&mut is_open) + .frame(egui::Frame { + inner_margin: egui::Margin::same(16), + outer_margin: egui::Margin::same(0), + corner_radius: egui::CornerRadius::same(8), + shadow: egui::epaint::Shadow { + offset: [0, 8], + blur: 16, + spread: 0, + color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), + }, + fill: ui.style().visuals.window_fill, + stroke: egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), + ), + }) + .show(ui.ctx(), |ui| { + // Set minimum width for the dialog + ui.set_min_width(300.0); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Message content with bold text and proper color + ui.add_space(10.0); + ui.label( + egui::RichText::new(self.message.text()) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(20.0); + + // Buttons + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Confirm button (only if text is provided) + if let Some(confirm_text) = &self.confirm_text { + let (fill_color, text_color) = if self.danger_mode { + ( + ComponentStyles::danger_button_fill(), + ComponentStyles::danger_button_text(), + ) + } else { + ( + ComponentStyles::primary_button_fill(), + ComponentStyles::primary_button_text(), + ) + }; + let confirm_label = if let WidgetText::RichText(rich_text) = + confirm_text + { + // preserve rich text formatting + rich_text.clone() + } else { + Arc::new(egui::RichText::new(confirm_text.text()).color(text_color)) + }; + + let confirm_button = egui::Button::new(confirm_label) + .fill(fill_color) + .stroke(if self.danger_mode { + egui::Stroke::NONE + } else { + ComponentStyles::primary_button_stroke() + }) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_SM)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui + .add(confirm_button) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + final_response = Some(ConfirmationStatus::Confirmed); + } + } + + // Cancel button (only if text is provided) + if let Some(cancel_text) = &self.cancel_text { + let cancel_label = if let WidgetText::RichText(rich_text) = cancel_text + { + // preserve rich text formatting + rich_text.clone() + } else { + egui::RichText::new(cancel_text.text()) + .color(ComponentStyles::secondary_button_text()) + .into() + }; + + let cancel_button = egui::Button::new(cancel_label) + .fill(ComponentStyles::secondary_button_fill()) + .stroke(ComponentStyles::secondary_button_stroke()) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_SM)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui + .add(cancel_button) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + final_response = Some(ConfirmationStatus::Canceled); + } + + ui.add_space(8.0); // Add spacing between buttons + } + }); + }); + }); + + // Handle window being closed via X button - treat as cancel + if !is_open && final_response.is_none() { + final_response = Some(ConfirmationStatus::Canceled); + } + + // Handle Escape key press - always treat as cancel + if final_response.is_none() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + final_response = Some(ConfirmationStatus::Canceled); + } + + // Update the dialog's state + self.is_open = is_open; + // if user actually did something, update the status + if final_response.is_some() { + self.status = final_response.clone(); + } + + if let Some(window_response) = window_response { + InnerResponse::new(final_response, window_response.response) + } else { + InnerResponse::new( + final_response, + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), + ) + } + } +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_confirmation_dialog_creation() { + let dialog = ConfirmationDialog::new("Test Title", "Test Message") + .confirm_text(Some("Yes")) + .cancel_text(Some("No")) + .danger_mode(true); + + assert_eq!(dialog.title.text(), "Test Title"); + assert_eq!(dialog.message.text(), "Test Message"); + assert!(dialog.confirm_text.is_some_and(|t| t.text() == "Yes")); + assert!(dialog.cancel_text.is_some_and(|t| t.text() == "No")); + assert!(dialog.danger_mode); + assert!(dialog.is_open); + } + + #[test] + fn test_confirmation_dialog_no_buttons() { + let dialog = ConfirmationDialog::new("Test Title", "Test Message") + .confirm_text(NOTHING) + .cancel_text(NOTHING); + + assert_eq!(dialog.title.text(), "Test Title"); + assert_eq!(dialog.message.text(), "Test Message"); + assert!(dialog.confirm_text.is_none()); + assert!(dialog.cancel_text.is_none()); + assert!(!dialog.danger_mode); + assert!(dialog.is_open); + } + + #[test] + fn test_confirmation_dialog_only_confirm_button() { + let dialog = ConfirmationDialog::new("Test Title", "Test Message") + .confirm_text(Some("OK")) + .cancel_text(NOTHING); + + assert_eq!(dialog.title.text(), "Test Title"); + assert_eq!(dialog.message.text(), "Test Message"); + assert!(dialog.confirm_text.is_some()); + assert!(dialog.cancel_text.is_none()); + assert!(!dialog.danger_mode); + assert!(dialog.is_open); + } + + #[test] + fn test_confirmation_dialog_only_cancel_button() { + let dialog = ConfirmationDialog::new("Test Title", "Test Message") + .confirm_text(NOTHING) + .cancel_text(Some("Close")); + + assert_eq!(dialog.title.text(), "Test Title"); + assert_eq!(dialog.message.text(), "Test Message"); + assert!(dialog.confirm_text.is_none()); + assert!(dialog.cancel_text.is_some()); + assert!(!dialog.danger_mode); + assert!(dialog.is_open); + } +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 94c579e59..e728d66a6 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -1,5 +1,6 @@ pub mod amount_input; pub mod component_trait; +pub mod confirmation_dialog; pub mod contract_chooser_panel; pub mod dpns_subscreen_chooser_panel; pub mod entropy_grid; diff --git a/src/ui/components/styled.rs b/src/ui/components/styled.rs index a9f5ac01e..ad83e5799 100644 --- a/src/ui/components/styled.rs +++ b/src/ui/components/styled.rs @@ -9,6 +9,9 @@ use egui::{ Ui, Vec2, }; +// Re-export commonly used components +pub use super::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; + /// Styled button variants #[allow(dead_code)] pub(crate) enum ButtonVariant { diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index 31d13e60f..5596766e2 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -6,11 +6,12 @@ use crate::model::amount::Amount; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::{Component, ComponentResponse}; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::fee::Credits; @@ -51,6 +52,7 @@ pub struct TransferScreen { max_amount: u64, pub app_context: Arc, confirmation_popup: bool, + confirmation_dialog: Option, selected_wallet: Option>>, wallet_password: String, show_password: bool, @@ -85,6 +87,7 @@ impl TransferScreen { max_amount, app_context: app_context.clone(), confirmation_popup: false, + confirmation_dialog: None, selected_wallet, wallet_password: String::new(), show_password: false, @@ -148,84 +151,109 @@ impl TransferScreen { ); } - fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let mut app_action = AppAction::None; - let mut is_open = true; - egui::Window::new("Confirm Transfer") - .collapsible(false) - .open(&mut is_open) - .show(ui.ctx(), |ui| { - let identifier = if self.receiver_identity_id.is_empty() { - self.error_message = Some("Invalid identifier".to_string()); - self.transfer_credits_status = - TransferCreditsStatus::ErrorMessage("Invalid identifier".to_string()); - self.confirmation_popup = false; - return; - } else { - match Identifier::from_string_try_encodings( - &self.receiver_identity_id, - &[Encoding::Base58, Encoding::Hex], - ) { - Ok(identifier) => identifier, - Err(_) => { - self.error_message = Some("Invalid identifier".to_string()); - self.transfer_credits_status = TransferCreditsStatus::ErrorMessage( - "Invalid identifier".to_string(), - ); - self.confirmation_popup = false; - return; - } - } - }; - - let Some(selected_key) = self.selected_key.as_ref() else { - self.error_message = Some("No selected key".to_string()); - self.transfer_credits_status = - TransferCreditsStatus::ErrorMessage("No selected key".to_string()); - self.confirmation_popup = false; - return; - }; - - ui.label(format!( - "Are you sure you want to transfer {} to {}", - self.amount.as_ref().expect("Amount should be present"), - self.receiver_identity_id - )); - - // Use the amount directly since it's already an Amount struct - let credits = self.amount.as_ref().map(|v| v.value()).unwrap_or_default() as u128; - if credits == 0 { - self.error_message = Some("Amount must be greater than 0".to_string()); - self.transfer_credits_status = TransferCreditsStatus::ErrorMessage( - "Amount must be greater than 0".to_string(), - ); - self.confirmation_popup = false; - return; - } + /// Handle the confirmation action when user clicks OK + fn confirmation_ok(&mut self) -> AppAction { + self.confirmation_popup = false; + self.confirmation_dialog = None; // Reset the dialog for next use + + // Validate identifier + let identifier = match self.validate_receiver_identifier() { + Ok(id) => id, + Err(error) => { + self.set_error_state(error); + return AppAction::None; + } + }; - if ui.button("Confirm").clicked() { - self.confirmation_popup = false; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.transfer_credits_status = TransferCreditsStatus::WaitingForResult(now); - app_action = - AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::Transfer( - self.identity.clone(), - identifier, - credits as Credits, - Some(selected_key.id()), - ))); - } - if ui.button("Cancel").clicked() { - self.confirmation_popup = false; - } - }); - if !is_open { + // Validate selected key + let selected_key = match self.selected_key.as_ref() { + Some(key) => key, + None => { + self.set_error_state("No selected key".to_string()); + return AppAction::None; + } + }; + + // Use the amount directly since it's already an Amount struct + let credits = self.amount.as_ref().map(|v| v.value()).unwrap_or_default() as u128; + if credits == 0 { + self.error_message = Some("Amount must be greater than 0".to_string()); + self.transfer_credits_status = + TransferCreditsStatus::ErrorMessage("Amount must be greater than 0".to_string()); self.confirmation_popup = false; + return AppAction::None; + } + + // Set waiting state and create backend task + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.transfer_credits_status = TransferCreditsStatus::WaitingForResult(now); + + AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::Transfer( + self.identity.clone(), + identifier, + credits as Credits, + Some(selected_key.id()), + ))) + } + + /// Handle the cancel action when user clicks Cancel or closes dialog + fn confirmation_cancel(&mut self) -> AppAction { + self.confirmation_popup = false; + self.confirmation_dialog = None; // Reset the dialog for next use + AppAction::None + } + + /// Validate the receiver identity identifier + fn validate_receiver_identifier(&self) -> Result { + if self.receiver_identity_id.is_empty() { + return Err("Invalid identifier".to_string()); + } + + Identifier::from_string_try_encodings( + &self.receiver_identity_id, + &[Encoding::Base58, Encoding::Hex], + ) + .map_err(|_| "Invalid identifier".to_string()) + } + + /// Set error state with the given message + fn set_error_state(&mut self, error: String) { + self.error_message = Some(error.clone()); + self.transfer_credits_status = TransferCreditsStatus::ErrorMessage(error); + } + + fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { + // Prepare values before borrowing + let Some(amount) = &self.amount else { + self.set_error_state("Incorrect or empty amount".to_string()); + return AppAction::None; + }; + + let receiver_id = self.receiver_identity_id.clone(); + + let msg = format!( + "Are you sure you want to transfer {} to {}?", + amount, receiver_id + ); + + // Lazy initialization of the confirmation dialog + let confirmation_dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new("Confirm Transfer", msg) + .confirm_text(Some("Confirm")) + .cancel_text(Some("Cancel")) + }); + + let response = confirmation_dialog.show(ui); + + // Handle the response using the Component pattern + match response.inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => self.confirmation_ok(), + Some(ConfirmationStatus::Canceled) => self.confirmation_cancel(), + None => AppAction::None, } - app_action } pub fn show_success(&self, ui: &mut Ui) -> AppAction { @@ -401,7 +429,11 @@ impl ScreenLike for TransferScreen { // Transfer button let ready = self.amount.is_some() && !self.receiver_identity_id.is_empty() - && self.selected_key.is_some(); + && self.selected_key.is_some() + && !matches!( + self.transfer_credits_status, + TransferCreditsStatus::WaitingForResult(_), + ); let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); ui.set_style(new_style); diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 4b1485f99..4b2be45d8 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -453,6 +453,10 @@ impl ComponentStyles { DashColors::WHITE } + pub fn primary_button_stroke() -> Stroke { + Stroke::new(1.0, DashColors::DASH_BLUE) + } + pub fn secondary_button_fill() -> Color32 { DashColors::WHITE } diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 374071c9b..f3e42a24d 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -4,6 +4,8 @@ use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; use crate::model::wallet::Wallet; +use crate::ui::components::component_trait::Component; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; @@ -74,6 +76,7 @@ pub struct SetTokenPriceScreen { /// Confirmation popup show_confirmation_popup: bool, + confirmation_dialog: Option, // If needed for password-based wallet unlocking: selected_wallet: Option>>, @@ -206,6 +209,7 @@ impl SetTokenPriceScreen { error_message: None, app_context: app_context.clone(), show_confirmation_popup: false, + confirmation_dialog: None, selected_wallet, wallet_password: String::new(), show_password: false, @@ -489,127 +493,195 @@ impl SetTokenPriceScreen { } } - /// Renders a confirm popup with the final "Are you sure?" step - fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - let mut is_open = true; - egui::Window::new("Confirm SetPricingSchedule") - .collapsible(false) - .open(&mut is_open) - .frame( - egui::Frame::default() - .fill(Color32::from_rgb(245, 245, 245)) - .stroke(egui::Stroke::new(1.0, Color32::from_rgb(200, 200, 200))) - .shadow(egui::epaint::Shadow::default()) - .inner_margin(egui::Margin::same(20)) - .corner_radius(egui::CornerRadius::same(8)), - ) - .show(ui.ctx(), |ui| { - // Validate user input - let token_pricing_schedule_opt = match self.create_pricing_schedule() { - Ok(schedule) => schedule, - Err(error) => { - self.error_message = Some(error.clone()); - self.status = SetTokenPriceStatus::ErrorMessage(error); - self.show_confirmation_popup = false; - return; + /// Validate the current pricing configuration before showing confirmation dialog + fn validate_pricing_configuration(&self) -> Result<(), String> { + match self.pricing_type { + PricingType::RemovePricing => Ok(()), + PricingType::SinglePrice => { + if self.single_price.trim().is_empty() { + return Err("Please enter a price".to_string()); + } + match self.single_price.trim().parse::() { + Ok(price) if price > 0.0 => Ok(()), + Ok(_) => Err("Price must be greater than 0".to_string()), + Err(_) => Err("Invalid price format - must be a positive number".to_string()), + } + } + PricingType::TieredPricing => { + let mut valid_tiers = 0; + + for (amount_str, price_str) in &self.tiered_prices { + if amount_str.trim().is_empty() || price_str.trim().is_empty() { + continue; } - }; - // Show confirmation message based on pricing type - match &self.pricing_type { - PricingType::RemovePricing => { - ui.colored_label( - Color32::from_rgb(180, 100, 0), - "WARNING: Are you sure you want to remove the pricing schedule?", - ); - ui.label("This will make the token unavailable for direct purchase."); + let _amount = amount_str.trim().parse::().map_err(|_| { + format!( + "Invalid amount '{}' - must be a whole number", + amount_str.trim() + ) + })?; + + let price = price_str.trim().parse::().map_err(|_| { + format!( + "Invalid price '{}' - must be a positive number", + price_str.trim() + ) + })?; + + if price <= 0.0 { + return Err(format!( + "Price '{}' must be greater than 0", + price_str.trim() + )); } - PricingType::SinglePrice => { - if let Ok(dash_price) = self.single_price.trim().parse::() { - ui.label(format!( - "Are you sure you want to set a fixed price of {} Dash per token?", - dash_price - )); - } + + valid_tiers += 1; + } + + if valid_tiers == 0 { + return Err("Please add at least one valid pricing tier".to_string()); + } + + Ok(()) + } + } + } + + /// Generate the confirmation message for the set price dialog + /// + /// ## Panics + /// + /// Panics if the pricing type is not set correctly or if the single price is not a valid number. + fn confirmation_message(&self) -> String { + match &self.pricing_type { + PricingType::RemovePricing => { + "WARNING: Are you sure you want to remove the pricing schedule? This will make the token unavailable for direct purchase.".to_string() + } + PricingType::SinglePrice => { + if let Ok(dash_price) = self.single_price.trim().parse::() { + format!( + "Are you sure you want to set a fixed price of {} Dash per token?", + dash_price + ) + } else { + "Are you sure you want to set the pricing schedule?".to_string() + } + } + PricingType::TieredPricing => { + let mut message = "Are you sure you want to set the following tiered pricing?".to_string(); + for (amount_str, price_str) in &self.tiered_prices { + if amount_str.trim().is_empty() || price_str.trim().is_empty() { + continue; } - PricingType::TieredPricing => { - ui.label("Are you sure you want to set the following tiered pricing?"); - ui.add_space(5.0); - for (amount_str, price_str) in &self.tiered_prices { - if amount_str.trim().is_empty() || price_str.trim().is_empty() { - continue; - } - if let (Ok(amount), Ok(dash_price)) = ( - amount_str.trim().parse::(), - price_str.trim().parse::(), - ) { - ui.label(format!( - " - {} or more tokens: {} Dash each", - amount, dash_price - )); - } - } + if let (Ok(amount), Ok(dash_price)) = ( + amount_str.trim().parse::(), + price_str.trim().parse::(), + ) { + message.push_str(&format!( + " + - {} or more tokens: {} Dash each", + amount, dash_price + )); } } + message + } + } + } - ui.add_space(10.0); + /// Handle the confirmation action when user clicks OK + fn confirmation_ok(&mut self) -> AppAction { + self.show_confirmation_popup = false; + self.confirmation_dialog = None; // Reset the dialog for next use + + // Validate user input and create pricing schedule + let token_pricing_schedule_opt = match self.create_pricing_schedule() { + Ok(schedule) => schedule, + Err(error) => { + // This should not happen if validation was done before opening dialog, + // but we handle it as a safety net + self.set_error_state(format!("Validation error: {}", error)); + return AppAction::None; + } + }; - // Confirm button - if ui.button("Confirm").clicked() { - self.show_confirmation_popup = false; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.status = SetTokenPriceStatus::WaitingForResult(now); - - let group_info = if self.group_action_id.is_some() { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( - GroupStateTransitionInfo { - group_contract_position: *pos, - action_id: self.group_action_id.unwrap(), - action_is_proposer: false, - }, - ) - }) - } else { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) - }) - }; - - // Dispatch the actual backend mint action - action = AppAction::BackendTask(BackendTask::TokenTask(Box::new( - TokenTask::SetDirectPurchasePrice { - identity: self.identity_token_info.identity.clone(), - data_contract: Arc::new( - self.identity_token_info.data_contract.contract.clone(), - ), - token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("Expected a key"), - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() - }, - token_pricing_schedule: token_pricing_schedule_opt, - group_info, - }, - ))); - } + // Set waiting state + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = SetTokenPriceStatus::WaitingForResult(now); + + // Prepare group info + let group_info = if self.group_action_id.is_some() { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: *pos, + action_id: self.group_action_id.unwrap(), + action_is_proposer: false, + }, + ) + }) + } else { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) + }) + }; - // Cancel button - if ui.button("Cancel").clicked() { - self.show_confirmation_popup = false; - } - }); + // Create and return the backend task + AppAction::BackendTask(BackendTask::TokenTask(Box::new( + TokenTask::SetDirectPurchasePrice { + identity: self.identity_token_info.identity.clone(), + data_contract: Arc::new(self.identity_token_info.data_contract.contract.clone()), + token_position: self.identity_token_info.token_position, + signing_key: self.selected_key.clone().expect("Expected a key"), + public_note: if self.group_action_id.is_some() { + None + } else { + self.public_note.clone() + }, + token_pricing_schedule: token_pricing_schedule_opt, + group_info, + }, + ))) + } + + /// Handle the cancel action when user clicks Cancel or closes dialog + fn confirmation_cancel(&mut self) -> AppAction { + self.show_confirmation_popup = false; + self.confirmation_dialog = None; // Reset the dialog for next use + AppAction::None + } + + /// Set error state with the given message + fn set_error_state(&mut self, error: String) { + self.error_message = Some(error.clone()); + self.status = SetTokenPriceStatus::ErrorMessage(error); + } - if !is_open { - self.show_confirmation_popup = false; + /// Renders a confirm popup with the final "Are you sure?" step + fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { + // Prepare values before borrowing + let confirmation_message = self.confirmation_message(); + let is_danger_mode = self.pricing_type == PricingType::RemovePricing; + + // Lazy initialization of the confirmation dialog + let confirmation_dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new("Confirm pricing schedule update", confirmation_message) + .confirm_text(Some("Confirm")) + .cancel_text(Some("Cancel")) + .danger_mode(is_danger_mode) + }); + + let response = confirmation_dialog.show(ui); + + match response.inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => self.confirmation_ok(), + Some(ConfirmationStatus::Canceled) => self.confirmation_cancel(), + None => AppAction::None, } - action } /// Renders a simple "Success!" screen after completion @@ -911,25 +983,10 @@ impl ScreenLike for SetTokenPriceScreen { }; // Set price button - let can_proceed = match self.pricing_type { - PricingType::RemovePricing => true, - PricingType::SinglePrice => { - if let Ok(price) = self.single_price.trim().parse::() { - price > 0.0 - } else { - false - } - }, - PricingType::TieredPricing => { - self.tiered_prices.iter().any(|(amount, price)| { - !amount.trim().is_empty() && !price.trim().is_empty() && - amount.trim().parse::().is_ok() && - if let Ok(p) = price.trim().parse::() { p > 0.0 } else { false } - }) - } - }; + let validation_result = self.validate_pricing_configuration(); + let button_active = validation_result.is_ok() && !matches!(self.status, SetTokenPriceStatus::WaitingForResult(_)); - let button_color = if can_proceed { + let button_color = if validation_result.is_ok() { Color32::from_rgb(0, 128, 255) } else { Color32::from_rgb(100, 100, 100) @@ -939,10 +996,10 @@ impl ScreenLike for SetTokenPriceScreen { .fill(button_color) .corner_radius(3.0); - let button_response = ui.add_enabled(can_proceed, button); + let button_response = ui.add_enabled(button_active, button); - if !can_proceed { - button_response.on_hover_text("Please enter valid pricing information"); + if let Err(hover_message) = validation_result { + button_response.on_disabled_hover_text(hover_message); } else if button_response.clicked() { self.show_confirmation_popup = true; } From 80f288ad18c0743d657a8d727afc0cdf72c2ca87 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Wed, 13 Aug 2025 18:10:33 +0700 Subject: [PATCH 012/106] feat: nicer contract chooser panel (#426) * feat: nicer contract chooser panel * fmt * clippy --- src/ui/components/contract_chooser_panel.rs | 721 +++++++++++--------- src/ui/dpns/dpns_contested_names_screen.rs | 4 +- 2 files changed, 393 insertions(+), 332 deletions(-) diff --git a/src/ui/components/contract_chooser_panel.rs b/src/ui/components/contract_chooser_panel.rs index 039cbb565..ea24d24e8 100644 --- a/src/ui/components/contract_chooser_panel.rs +++ b/src/ui/components/contract_chooser_panel.rs @@ -23,6 +23,11 @@ pub struct ContractChooserState { pub right_click_contract_id: Option, pub show_context_menu: bool, pub context_menu_position: egui::Pos2, + pub expanded_contracts: std::collections::HashSet, + pub expanded_sections: std::collections::HashMap>, + pub expanded_doc_types: std::collections::HashMap>, + pub expanded_indexes: std::collections::HashMap>, + pub expanded_tokens: std::collections::HashMap>, } impl Default for ContractChooserState { @@ -31,10 +36,112 @@ impl Default for ContractChooserState { right_click_contract_id: None, show_context_menu: false, context_menu_position: egui::Pos2::ZERO, + expanded_contracts: std::collections::HashSet::new(), + expanded_sections: std::collections::HashMap::new(), + expanded_doc_types: std::collections::HashMap::new(), + expanded_indexes: std::collections::HashMap::new(), + expanded_tokens: std::collections::HashMap::new(), } } } +// Helper function to render a custom collapsing header with +/- button +fn render_collapsing_header( + ui: &mut egui::Ui, + text: impl Into, + is_expanded: bool, + is_selected: bool, + indent_level: usize, +) -> bool { + let text = text.into(); + let dark_mode = ui.ctx().style().visuals.dark_mode; + let indent = indent_level as f32 * 16.0; + + let mut clicked = false; + + ui.horizontal(|ui| { + ui.add_space(indent); + + // +/- button + let button_text = if is_expanded { "−" } else { "+" }; + let button_response = ui.add( + egui::Button::new( + RichText::new(button_text) + .size(20.0) + .color(DashColors::DASH_BLUE), + ) + .fill(Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE), + ); + + if button_response.clicked() { + clicked = true; + } + + // Label - make contract names (level 0) larger + let label_text = if indent_level == 0 { + // Contract names - make them the largest with heading font + if is_selected { + RichText::new(text) + .size(16.0) + .heading() + .color(DashColors::DASH_BLUE) + } else { + RichText::new(text) + .size(16.0) + .heading() + .color(DashColors::text_primary(dark_mode)) + } + } else if indent_level == 1 { + // Section headers (Document Types, Tokens, Contract JSON) - medium size + if is_selected { + RichText::new(text) + .size(14.0) + .heading() + .color(DashColors::DASH_BLUE) + } else { + RichText::new(text) + .size(14.0) + .heading() + .color(DashColors::text_primary(dark_mode)) + } + } else if indent_level == 2 { + // Document type names - smaller + if is_selected { + RichText::new(text) + .size(13.0) + .heading() + .color(DashColors::DASH_BLUE) + } else { + RichText::new(text) + .size(13.0) + .heading() + .color(DashColors::text_primary(dark_mode)) + } + } else { + // Indexes and other sub-items - smallest + if is_selected { + RichText::new(text) + .size(12.0) + .heading() + .color(DashColors::DASH_BLUE) + } else { + RichText::new(text) + .size(12.0) + .heading() + .color(DashColors::text_primary(dark_mode)) + } + }; + + let label_response = ui.add(egui::Label::new(label_text).sense(egui::Sense::click())); + if label_response.clicked() { + clicked = true; + } + }); + + clicked +} + #[allow(clippy::too_many_arguments)] pub fn add_contract_chooser_panel( ctx: &EguiContext, @@ -105,373 +212,327 @@ pub fn add_contract_chooser_panel( }); // List out each matching contract - ui.vertical(|ui| { + ui.vertical_centered(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; // Remove vertical spacing between contracts + for contract in filtered_contracts { - ui.push_id( - contract.contract.id().to_string(Encoding::Base58), - |ui| { - ui.horizontal(|ui| { - let is_selected_contract = - *selected_data_contract == *contract; - - let name_or_id = contract.alias.clone().unwrap_or( - contract.contract.id().to_string(Encoding::Base58), - ); - - // Highlight the contract if selected - let contract_header_text = if is_selected_contract { - RichText::new(name_or_id) - .color(Color32::from_rgb(21, 101, 192)) - } else { - RichText::new(name_or_id) - }; - - // Expand/collapse the contract info - let collapsing_response = - ui.collapsing(contract_header_text, |ui| { - // - // ===== Document Types Section ===== - // - ui.collapsing("Document Types", |ui| { - for (doc_name, doc_type) in - contract.contract.document_types() - { - let is_selected_doc_type = - *selected_document_type - == *doc_type; - - let doc_type_header_text = - if is_selected_doc_type { - RichText::new(doc_name.clone()) - .color(Color32::from_rgb( - 21, 101, 192, - )) - } else { - RichText::new(doc_name.clone()) - }; - - let doc_resp = - ui.collapsing(doc_type_header_text, |ui| { - // Show the indexes - if doc_type.indexes().is_empty() { - ui.label("No indexes defined"); + let contract_id = contract.contract.id().to_string(Encoding::Base58); + let is_selected_contract = *selected_data_contract == *contract; + + // Format built-in contract names nicely + let display_name = match contract.alias.as_deref() { + Some("dpns") => "DPNS".to_string(), + Some("keyword_search") => "Keyword Search".to_string(), + Some("token_history") => "Token History".to_string(), + Some("withdrawals") => "Withdrawals".to_string(), + Some(alias) => alias.to_string(), + None => contract_id.clone(), + }; + + // Check if this contract is expanded + let is_expanded = chooser_state.expanded_contracts.contains(&contract_id); + + // Render the custom collapsing header for the contract + if render_collapsing_header(ui, &display_name, is_expanded, is_selected_contract, 0) { + if is_expanded { + chooser_state.expanded_contracts.remove(&contract_id); + } else { + chooser_state.expanded_contracts.insert(contract_id.clone()); + } + } + + // Show contract content if expanded + if is_expanded { + ui.push_id(&contract_id, |ui| { + ui.vertical(|ui| { + // + // ===== Document Types Section ===== + // + // Only show Document Types section if there are document types + if !contract.contract.document_types().is_empty() { + let doc_types_key = format!("{}_doc_types", contract_id); + let doc_types_expanded = chooser_state.expanded_sections + .get(&contract_id) + .map(|s| s.contains(&doc_types_key)) + .unwrap_or(false); + + if render_collapsing_header(ui, "Document Types", doc_types_expanded, false, 1) { + let sections = chooser_state.expanded_sections + .entry(contract_id.clone()) + .or_default(); + if doc_types_expanded { + sections.remove(&doc_types_key); } else { - for (index_name, index) in - doc_type.indexes() - { - let is_selected_index = *selected_index - == Some(index.clone()); - - let index_header_text = - if is_selected_index { - RichText::new(format!( - "Index: {}", - index_name - )) - .color(Color32::from_rgb( - 21, 101, 192, - )) - } else { - RichText::new(format!( - "Index: {}", - index_name - )) - }; - - let index_resp = ui.collapsing( - index_header_text, - |ui| { - // Show index properties if expanded - for prop in &index.properties { - ui.label(format!( - "{:?}", - prop - )); - } - }, - ); - - // If index was just clicked (opened) - if index_resp.header_response.clicked() - && index_resp - .body_response - .is_some() - { - *selected_index = - Some(index.clone()); - if let Ok(new_doc_type) = contract - .contract - .document_type_cloned_for_name( - doc_name, - ) - { - *selected_document_type = - new_doc_type; - *selected_data_contract = - contract.clone(); - - // Build the WHERE clause using all property names - let conditions: Vec = - index - .property_names() - .iter() - .map(|property_name| { - format!( - "`{}` = '___'", - property_name - ) - }) - .collect(); - - let where_clause = - if conditions.is_empty() { - String::new() - } else { - format!( - " WHERE {}", - conditions - .join(" AND ") - ) - }; - - *document_query = format!( - "SELECT * FROM {}{}", - selected_document_type - .name(), - where_clause - ); - } - } - // If index was just collapsed - else if index_resp - .header_response - .clicked() - && index_resp - .body_response - .is_none() - { - *selected_index = None; - *document_query = format!( - "SELECT * FROM {}", - selected_document_type.name() - ); - } - } + sections.insert(doc_types_key.clone()); } - }); - - // Document Type clicked - if doc_resp.header_response.clicked() - && doc_resp.body_response.is_some() - { - // Expand doc type - if let Ok(new_doc_type) = contract - .contract - .document_type_cloned_for_name( - doc_name, - ) - { - *pending_document_type = - new_doc_type.clone(); - *selected_document_type = - new_doc_type.clone(); - *selected_data_contract = - contract.clone(); + } + + if doc_types_expanded { + ui.vertical(|ui| { + for (doc_name, doc_type) in contract.contract.document_types() { + let is_selected_doc_type = *selected_document_type == *doc_type; + let doc_type_key = format!("{}_{}", contract_id, doc_name); + + let doc_expanded = chooser_state.expanded_doc_types + .get(&contract_id) + .map(|s| s.contains(&doc_type_key)) + .unwrap_or(false); + + if render_collapsing_header(ui, doc_name, doc_expanded, is_selected_doc_type, 2) { + let doc_types = chooser_state.expanded_doc_types + .entry(contract_id.clone()) + .or_default(); + if doc_expanded { + doc_types.remove(&doc_type_key); + // Document Type collapsed + *selected_index = None; + *document_query = format!("SELECT * FROM {}", selected_document_type.name()); + } else { + doc_types.insert(doc_type_key.clone()); + // Document Type expanded + if let Ok(new_doc_type) = contract.contract.document_type_cloned_for_name(doc_name) { + *pending_document_type = new_doc_type.clone(); + *selected_document_type = new_doc_type.clone(); + *selected_data_contract = contract.clone(); *selected_index = None; - *document_query = format!( - "SELECT * FROM {}", - selected_document_type - .name() - ); + *document_query = format!("SELECT * FROM {}", selected_document_type.name()); // Reinitialize field selection - pending_fields_selection - .clear(); + pending_fields_selection.clear(); // Mark doc-defined fields - for (field_name, _schema) in - new_doc_type - .properties() - .iter() - { - pending_fields_selection - .insert( - field_name.clone(), - true, - ); + for (field_name, _schema) in new_doc_type.properties().iter() { + pending_fields_selection.insert(field_name.clone(), true); } // Show "internal" fields as unchecked by default, // except for $ownerId and $id, which are checked - for dash_field in - DOCUMENT_PRIVATE_FIELDS - { - let checked = *dash_field - == "$ownerId" - || *dash_field == "$id"; - pending_fields_selection - .insert( - dash_field - .to_string(), - checked, - ); + for dash_field in DOCUMENT_PRIVATE_FIELDS { + let checked = *dash_field == "$ownerId" || *dash_field == "$id"; + pending_fields_selection.insert(dash_field.to_string(), checked); } } } - // Document Type collapsed - else if doc_resp - .header_response - .clicked() - && doc_resp.body_response.is_none() - { - *selected_index = None; - *document_query = format!( - "SELECT * FROM {}", - selected_document_type.name() - ); - } } - }); - // - // ===== Tokens Section ===== - // - ui.collapsing("Tokens", |ui| { - let tokens_map = contract.contract.tokens(); - if tokens_map.is_empty() { - ui.label( - "No tokens defined for this contract.", - ); - } else { - for (token_name, token) in tokens_map { - // Each token is its own collapsible - ui.collapsing( - token_name.to_string(), - |ui| { - // Now you can display base supply, max supply, etc. - ui.label(format!( - "Base Supply: {}", - token.base_supply() - )); - if let Some(max_supply) = - token.max_supply() - { - ui.label(format!( - "Max Supply: {}", - max_supply - )); - } else { - ui.label( - "Max Supply: None", - ); + if doc_expanded { + ui.vertical(|ui| { + // Show the indexes + if doc_type.indexes().is_empty() { + ui.add_space(4.0); + ui.label("No indexes defined"); + } else { + for (index_name, index) in doc_type.indexes() { + let is_selected_index = *selected_index == Some(index.clone()); + let index_key = format!("{}_{}_{}", contract_id, doc_name, index_name); + + let index_expanded = chooser_state.expanded_indexes + .get(&contract_id) + .map(|s| s.contains(&index_key)) + .unwrap_or(false); + + let index_label = format!("Index: {}", index_name); + if render_collapsing_header(ui, &index_label, index_expanded, is_selected_index, 3) { + let indexes = chooser_state.expanded_indexes + .entry(contract_id.clone()) + .or_default(); + if index_expanded { + indexes.remove(&index_key); + // Index collapsed + *selected_index = None; + *document_query = format!("SELECT * FROM {}", selected_document_type.name()); + } else { + indexes.insert(index_key.clone()); + // Index expanded + *selected_index = Some(index.clone()); + if let Ok(new_doc_type) = contract.contract.document_type_cloned_for_name(doc_name) { + *selected_document_type = new_doc_type; + *selected_data_contract = contract.clone(); + + // Build the WHERE clause using all property names + let conditions: Vec = index + .property_names() + .iter() + .map(|property_name| { + format!("`{}` = '___'", property_name) + }) + .collect(); + + let where_clause = if conditions.is_empty() { + String::new() + } else { + format!(" WHERE {}", conditions.join(" AND ")) + }; + + *document_query = format!( + "SELECT * FROM {}{}", + selected_document_type.name(), + where_clause + ); + } + } + } + + if index_expanded { + ui.vertical(|ui| { + ui.add_space(4.0); + for prop in &index.properties { + ui.horizontal(|ui| { + ui.add_space(64.0); + ui.label(format!("{:?}", prop)); + }); + } + }); } + } + } + }); + } + } + }); + } + } - // Add more details here - }, - ); + // + // ===== Tokens Section ===== + // + // Only show Tokens section if there are tokens + let tokens_map = contract.contract.tokens(); + if !tokens_map.is_empty() { + let tokens_key = format!("{}_tokens", contract_id); + let tokens_expanded = chooser_state.expanded_sections + .get(&contract_id) + .map(|s| s.contains(&tokens_key)) + .unwrap_or(false); + + if render_collapsing_header(ui, "Tokens", tokens_expanded, false, 1) { + let sections = chooser_state.expanded_sections + .entry(contract_id.clone()) + .or_default(); + if tokens_expanded { + sections.remove(&tokens_key); + } else { + sections.insert(tokens_key.clone()); + } + } + + if tokens_expanded { + ui.vertical(|ui| { + for (token_name, token) in tokens_map { + let token_key = format!("{}_token_{}", contract_id, token_name); + let token_expanded = chooser_state.expanded_tokens + .get(&contract_id) + .map(|s| s.contains(&token_key)) + .unwrap_or(false); + + if render_collapsing_header(ui, token_name.to_string(), token_expanded, false, 2) { + let tokens = chooser_state.expanded_tokens + .entry(contract_id.clone()) + .or_default(); + if token_expanded { + tokens.remove(&token_key); + } else { + tokens.insert(token_key.clone()); + } + } + + if token_expanded { + ui.vertical(|ui| { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.add_space(32.0); + ui.label(format!("Base Supply: {}", token.base_supply())); + }); + ui.horizontal(|ui| { + ui.add_space(32.0); + if let Some(max_supply) = token.max_supply() { + ui.label(format!("Max Supply: {}", max_supply)); + } else { + ui.label("Max Supply: None"); + } + }); + }); } } }); + } + } - // - // ===== Entire Contract JSON ===== - // - ui.collapsing("Contract JSON", |ui| { - match contract - .contract - .to_json(app_context.platform_version()) - { - Ok(json_value) => { - let pretty_str = - serde_json::to_string_pretty( - &json_value, - ) - .unwrap_or_else(|_| { - "Error formatting JSON" - .to_string() - }); + // + // ===== Entire Contract JSON ===== + // + let json_key = format!("{}_json", contract_id); + let json_expanded = chooser_state.expanded_sections + .get(&contract_id) + .map(|s| s.contains(&json_key)) + .unwrap_or(false); + + if render_collapsing_header(ui, "Contract JSON", json_expanded, false, 1) { + let sections = chooser_state.expanded_sections + .entry(contract_id.clone()) + .or_default(); + if json_expanded { + sections.remove(&json_key); + } else { + sections.insert(json_key.clone()); + } + } + + if json_expanded { + ui.vertical(|ui| { + match contract.contract.to_json(app_context.platform_version()) { + Ok(json_value) => { + let pretty_str = serde_json::to_string_pretty(&json_value) + .unwrap_or_else(|_| "Error formatting JSON".to_string()); - ui.add_space(2.0); + ui.add_space(2.0); - // A resizable region that the user can drag to expand/shrink - egui::Resize::default() - .id_salt( - "json_resize_area_for_contract", - ) - .default_size([400.0, 400.0]) // initial w,h + // A resizable region that the user can drag to expand/shrink + egui::Resize::default() + .id_salt(format!("json_resize_{}", contract_id)) + .default_size([400.0, 400.0]) .show(ui, |ui| { egui::ScrollArea::vertical() .auto_shrink([false; 2]) .show(ui, |ui| { - ui.monospace( - pretty_str, - ); + ui.monospace(pretty_str); }); }); - ui.add_space(3.0); - } - Err(e) => { - ui.label(format!( - "Error converting contract to JSON: {e}" - )); - } + ui.add_space(3.0); } - }); + Err(e) => { + ui.label(format!("Error converting contract to JSON: {e}")); + } + } }); + } + }); + + // Check for right-click on the contract header + // TODO: Add right-click support to custom header if needed - // Check for right-click on the contract header - if collapsing_response - .header_response - .secondary_clicked() + // Right‐aligned Remove button + ui.horizontal(|ui| { + ui.add_space(8.0); + if contract.alias != Some("dpns".to_string()) + && contract.alias != Some("token_history".to_string()) + && contract.alias != Some("withdrawals".to_string()) + && contract.alias != Some("keyword_search".to_string()) + && ui.add( + egui::Button::new("Remove") + .min_size(egui::Vec2::new(60.0, 20.0)) + .small() + ).clicked() { - let contract_id = contract - .contract - .id() - .to_string(Encoding::Base58); - chooser_state.right_click_contract_id = - Some(contract_id); - chooser_state.show_context_menu = true; - chooser_state.context_menu_position = ui - .ctx() - .pointer_interact_pos() - .unwrap_or(egui::Pos2::ZERO); + action |= AppAction::BackendTask( + BackendTask::ContractTask(Box::new( + ContractTask::RemoveContract(contract.contract.id()), + )), + ); } - - // Right‐aligned Remove button - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - ui.add_space(2.0); // Push down a few pixels - if contract.alias != Some("dpns".to_string()) - && contract.alias - != Some("token_history".to_string()) - && contract.alias - != Some("withdrawals".to_string()) - && contract.alias - != Some("keyword_search".to_string()) - && ui - .add( - egui::Button::new("X") - .min_size(egui::Vec2::new( - 20.0, 20.0, - )) - .small(), - ) - .clicked() - { - action |= AppAction::BackendTask( - BackendTask::ContractTask(Box::new( - ContractTask::RemoveContract( - contract.contract.id(), - ), - )), - ); - } - }, - ); }); - }, - ); + }); + } } }); }); diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index b7fb9266f..36dbae643 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -19,7 +19,7 @@ use crate::model::contested_name::{ContestState, ContestedName}; use crate::model::qualified_identity::{DPNSNameInfo, QualifiedIdentity}; use crate::ui::components::dpns_subscreen_chooser_panel::add_dpns_subscreen_chooser_panel; use crate::ui::components::left_panel::add_left_panel; -use crate::ui::components::styled::island_central_panel; +use crate::ui::components::styled::{StyledButton, island_central_panel}; use crate::ui::components::top_panel::add_top_panel; use crate::ui::theme::DashColors; use crate::ui::{BackendTaskSuccessResult, MessageType, RootScreenType, ScreenLike, ScreenType}; @@ -304,7 +304,7 @@ impl DPNSScreen { let dark_mode = ui.ctx().style().visuals.dark_mode; ui.label(RichText::new("Please check back later or try refreshing the list.").color(DashColors::text_primary(dark_mode))); ui.add_space(20.0); - if ui.button("Refresh").clicked() { + if StyledButton::primary("Refresh").show(ui).clicked() { if let RefreshingStatus::Refreshing(_) = self.refreshing_status { app_action = AppAction::None; } else { From acc79ac13c851e9d467b0505afdbe7d89f9051cf Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Wed, 13 Aug 2025 21:53:26 +0700 Subject: [PATCH 013/106] fix: token purchasability was not refreshing unless app restart (#432) --- src/ui/tokens/tokens_screen/mod.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 9a85b6072..d36e5a95d 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -2467,6 +2467,11 @@ impl ScreenLike for TokensScreen { .map(|qi| (qi.identity.id(), qi)) .collect(); + // Clear pricing data to force re-fetching when tokens are selected + // This ensures we get updated pricing after changes like SetPrice + self.token_pricing_data.clear(); + self.pricing_loading_state.clear(); + self.my_tokens = my_tokens( &self.app_context, &self.identities, @@ -2503,6 +2508,11 @@ impl ScreenLike for TokensScreen { .map(|qi| (qi.identity.id(), qi)) .collect(); + // Clear pricing data to force re-fetching when tokens are selected + // This ensures we get updated pricing after changes like SetPrice + self.token_pricing_data.clear(); + self.pricing_loading_state.clear(); + self.my_tokens = my_tokens( &self.app_context, &self.identities, From 54c62194985b8d1b21fa8a9f33b9e2de619cad6b Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Wed, 13 Aug 2025 23:47:03 +0700 Subject: [PATCH 014/106] fix: token action buttons alignment --- src/ui/tokens/tokens_screen/my_tokens.rs | 74 ++++++++++-------------- 1 file changed, 29 insertions(+), 45 deletions(-) diff --git a/src/ui/tokens/tokens_screen/my_tokens.rs b/src/ui/tokens/tokens_screen/my_tokens.rs index a7e3a02b2..cad02080c 100644 --- a/src/ui/tokens/tokens_screen/my_tokens.rs +++ b/src/ui/tokens/tokens_screen/my_tokens.rs @@ -474,29 +474,27 @@ impl TokensScreen { }); } row.col(|ui| { - ui.horizontal(|ui| { - if itb.available_actions.shown_buttons() < 6 { - action |= self.render_actions(itb, &token_info, 0..10, ui); - } else { - action |= self.render_actions(itb, &token_info, 0..3, ui); - // Expandable advanced actions menu - ui.menu_button("...", |ui| { - action |= self.render_actions(itb, &token_info, 3..128, ui); - }); - } + if itb.available_actions.shown_buttons() < 3 { + action |= self.render_actions(itb, &token_info, 0..10, ui); + } else { + action |= self.render_actions(itb, &token_info, 0..3, ui); + // Expandable advanced actions menu + ui.menu_button("...", |ui| { + action |= self.render_actions(itb, &token_info, 3..128, ui); + }); + } - // Remove - if ui - .button("X") - .on_hover_text( - "Remove identity token balance from DET", - ) - .clicked() - { - self.confirm_remove_identity_token_balance_popup = true; - self.identity_token_balance_to_remove = Some(itb.into()); - } - }); + // Remove + if ui + .button("X") + .on_hover_text( + "Remove identity token balance from DET", + ) + .clicked() + { + self.confirm_remove_identity_token_balance_popup = true; + self.identity_token_balance_to_remove = Some(itb.into()); + } }); }); } @@ -591,10 +589,6 @@ impl TokensScreen { ) -> AppAction { let mut pos = 0; let mut action = AppAction::None; - - ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { - ui.add_space(-9.0); - ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 5.0; if range.contains(&pos) { @@ -924,10 +918,6 @@ impl TokensScreen { ui.close_kind(egui::UiKind::Menu); } } - }); - - }); - action } @@ -1006,21 +996,15 @@ impl TokensScreen { self.show_token_info_popup = Some(*token_id); } - ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { - ui.add_space(-1.0); - - ui.horizontal(|ui| { - // Remove button - if ui - .button("X") - .on_hover_text("Remove token from DET") - .clicked() - { - self.confirm_remove_token_popup = true; - self.token_to_remove = Some(*token_id); - } - }); - }); + // Remove button + if ui + .button("X") + .on_hover_text("Remove token from DET") + .clicked() + { + self.confirm_remove_token_popup = true; + self.token_to_remove = Some(*token_id); + } }); }); } From 562332c9df23de6840e575d6c109dd8693a5ad13 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:47:20 +0700 Subject: [PATCH 015/106] feat: nicer expanding tabs in token creator (#431) * feat: nicer expanding tabs in token creator * fix --- src/ui/tokens/tokens_screen/distributions.rs | 199 ++++++--- src/ui/tokens/tokens_screen/groups.rs | 106 +++-- src/ui/tokens/tokens_screen/mod.rs | 118 +++++- src/ui/tokens/tokens_screen/token_creator.rs | 403 +++++++++++-------- 4 files changed, 533 insertions(+), 293 deletions(-) diff --git a/src/ui/tokens/tokens_screen/distributions.rs b/src/ui/tokens/tokens_screen/distributions.rs index e87b666fc..9085dffd1 100644 --- a/src/ui/tokens/tokens_screen/distributions.rs +++ b/src/ui/tokens/tokens_screen/distributions.rs @@ -1,3 +1,4 @@ +use crate::ui::theme::DashColors; use crate::ui::tokens::tokens_screen::{ DistributionEntry, DistributionFunctionUI, IntervalTimeUnit, PerpetualDistributionIntervalTypeUI, TokenDistributionRecipientUI, TokensScreen, sanitize_i64, @@ -10,31 +11,41 @@ impl TokensScreen { pub(super) fn render_distributions(&mut self, context: &Context, ui: &mut egui::Ui) { ui.add_space(5.0); - let mut distribution_state = - egui::collapsing_header::CollapsingState::load_with_default_open( - ui.ctx(), - ui.make_persistent_id("token_creator_distribution"), - false, + ui.horizontal(|ui| { + // +/- button + let button_text = if self.token_creator_distribution_expanded { + "−" + } else { + "+" + }; + let button_response = ui.add( + egui::Button::new( + RichText::new(button_text) + .size(20.0) + .color(DashColors::DASH_BLUE), + ) + .fill(Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE), ); - - // Force close if we need to reset - if self.should_reset_collapsing_states { - distribution_state.set_open(false); - } - - distribution_state.store(ui.ctx()); - - distribution_state.show_header(ui, |ui| { + if button_response.clicked() { + self.token_creator_distribution_expanded = + !self.token_creator_distribution_expanded; + } ui.label("Distribution"); - }) - .body(|ui| { + }); + + if self.token_creator_distribution_expanded { ui.add_space(3.0); - // PERPETUAL DISTRIBUTION SETTINGS - if ui.checkbox( - &mut self.enable_perpetual_distribution, - "Enable Perpetual Distribution", - ).clicked() { + ui.indent("distribution_section", |ui| { + // PERPETUAL DISTRIBUTION SETTINGS + if ui + .checkbox( + &mut self.enable_perpetual_distribution, + "Enable Perpetual Distribution", + ) + .clicked() + { self.perpetual_dist_type = PerpetualDistributionIntervalTypeUI::TimeBased; }; if self.enable_perpetual_distribution { @@ -72,13 +83,14 @@ impl TokensScreen { ui.label(" - Distributes every "); // Restrict input to digits only - let response = ui.add( - TextEdit::singleline(&mut self.perpetual_dist_interval_input) - ); + let response = ui.add(TextEdit::singleline( + &mut self.perpetual_dist_interval_input, + )); // Optionally filter out non-digit input if response.changed() { - self.perpetual_dist_interval_input.retain(|c| c.is_ascii_digit()); + self.perpetual_dist_interval_input + .retain(|c| c.is_ascii_digit()); } // Dropdown for selecting unit @@ -99,7 +111,9 @@ impl TokensScreen { ui.selectable_value( &mut self.perpetual_dist_interval_unit, unit.clone(), - unit.label_for_amount(&self.perpetual_dist_interval_input), + unit.label_for_amount( + &self.perpetual_dist_interval_input, + ), ); } }); @@ -331,9 +345,15 @@ Emits tokens in fixed amounts for specific intervals. ui.image(texture); }); ui.add_space(10.0); - } else if let Some(image) = self.function_images.get(&self.perpetual_dist_function) { - let texture = context.load_texture(self.perpetual_dist_function.name(), image.clone(), Default::default()); - self.function_textures.insert(self.perpetual_dist_function.clone(), texture.clone()); + } else if let Some(image) = self.function_images.get(&self.perpetual_dist_function) + { + let texture = context.load_texture( + self.perpetual_dist_function.name(), + image.clone(), + Default::default(), + ); + self.function_textures + .insert(self.perpetual_dist_function.clone(), texture.clone()); ui.add_space(10.0); ui.horizontal(|ui| { ui.add_space(50.0); // Shift image right @@ -359,11 +379,22 @@ Emits tokens in fixed amounts for specific intervals. sanitize_u64(&mut self.step_count_input); } if !self.step_count_input.is_empty() { - if let Ok((perpetual_dist_interval_input, step_count_input)) = self.perpetual_dist_interval_input.parse::().and_then(|perpetual_dist_interval_input| self.step_count_input.parse::().map(|step_count_input| (perpetual_dist_interval_input, step_count_input))) { + if let Ok((perpetual_dist_interval_input, step_count_input)) = self + .perpetual_dist_interval_input + .parse::() + .and_then(|perpetual_dist_interval_input| { + self.step_count_input.parse::().map( + |step_count_input| { + (perpetual_dist_interval_input, step_count_input) + }, + ) + }) + { let text = match self.perpetual_dist_type { PerpetualDistributionIntervalTypeUI::None => "".to_string(), PerpetualDistributionIntervalTypeUI::BlockBased => { - let amount = perpetual_dist_interval_input * step_count_input; + let amount = + perpetual_dist_interval_input * step_count_input; if amount == 1 { "Every Block".to_string() } else { @@ -371,11 +402,18 @@ Emits tokens in fixed amounts for specific intervals. } } PerpetualDistributionIntervalTypeUI::TimeBased => { - let amount = perpetual_dist_interval_input * step_count_input; - format!("Every {} {}", amount, self.perpetual_dist_interval_unit.capitalized_label_for_num_amount(amount)) + let amount = + perpetual_dist_interval_input * step_count_input; + format!( + "Every {} {}", + amount, + self.perpetual_dist_interval_unit + .capitalized_label_for_num_amount(amount) + ) } PerpetualDistributionIntervalTypeUI::EpochBased => { - let amount = perpetual_dist_interval_input * step_count_input; + let amount = + perpetual_dist_interval_input * step_count_input; if amount == 1 { "Every Epoch Change".to_string() } else { @@ -391,7 +429,9 @@ Emits tokens in fixed amounts for specific intervals. ui.horizontal(|ui| { ui.label(" - Decrease per Interval Numerator (n < 65,536):"); - let response = ui.add(TextEdit::singleline(&mut self.decrease_per_interval_numerator_input)); + let response = ui.add(TextEdit::singleline( + &mut self.decrease_per_interval_numerator_input, + )); if response.changed() { sanitize_u64(&mut self.decrease_per_interval_numerator_input); self.decrease_per_interval_numerator_input.truncate(5); @@ -400,7 +440,9 @@ Emits tokens in fixed amounts for specific intervals. ui.horizontal(|ui| { ui.label(" - Decrease per Interval Denominator (d < 65,536):"); - let response = ui.add(TextEdit::singleline(&mut self.decrease_per_interval_denominator_input)); + let response = ui.add(TextEdit::singleline( + &mut self.decrease_per_interval_denominator_input, + )); if response.changed() { sanitize_u64(&mut self.decrease_per_interval_denominator_input); self.decrease_per_interval_denominator_input.truncate(5); @@ -410,8 +452,10 @@ Emits tokens in fixed amounts for specific intervals. ui.horizontal(|ui| { ui.label(" - Start Period Offset (i64, optional):"); let response = ui.add( - TextEdit::singleline(&mut self.step_decreasing_start_period_offset_input) - .hint_text("None"), + TextEdit::singleline( + &mut self.step_decreasing_start_period_offset_input, + ) + .hint_text("None"), ); if response.changed() { sanitize_i64(&mut self.step_decreasing_start_period_offset_input); @@ -420,7 +464,9 @@ Emits tokens in fixed amounts for specific intervals. ui.horizontal(|ui| { ui.label(" - Initial Token Emission Amount:"); - let response = ui.add(TextEdit::singleline(&mut self.step_decreasing_initial_emission_input)); + let response = ui.add(TextEdit::singleline( + &mut self.step_decreasing_initial_emission_input, + )); if response.changed() { sanitize_u64(&mut self.step_decreasing_initial_emission_input); } @@ -440,8 +486,10 @@ Emits tokens in fixed amounts for specific intervals. ui.horizontal(|ui| { ui.label(" - Maximum Interval Count (optional):"); let response = ui.add( - TextEdit::singleline(&mut self.step_decreasing_max_interval_count_input) - .hint_text("None"), + TextEdit::singleline( + &mut self.step_decreasing_max_interval_count_input, + ) + .hint_text("None"), ); if response.changed() { sanitize_u64(&mut self.step_decreasing_max_interval_count_input); @@ -581,14 +629,16 @@ Emits tokens in fixed amounts for specific intervals. DistributionFunctionUI::Linear => { ui.horizontal(|ui| { ui.label(" - Slope Numerator (a, { -255 ≤ a ≤ 256 }):"); - let response = ui.add(TextEdit::singleline(&mut self.linear_int_a_input)); + let response = + ui.add(TextEdit::singleline(&mut self.linear_int_a_input)); if response.changed() { sanitize_i64(&mut self.linear_int_a_input); } }); ui.horizontal(|ui| { ui.label(" - Slope Divisor (d, u64):"); - let response = ui.add(TextEdit::singleline(&mut self.linear_int_d_input)); + let response = + ui.add(TextEdit::singleline(&mut self.linear_int_d_input)); if response.changed() { sanitize_u64(&mut self.linear_int_d_input); } @@ -605,7 +655,9 @@ Emits tokens in fixed amounts for specific intervals. }); ui.horizontal(|ui| { ui.label(" - Starting Amount (b, i64):"); - let response = ui.add(TextEdit::singleline(&mut self.linear_int_starting_amount_input)); + let response = ui.add(TextEdit::singleline( + &mut self.linear_int_starting_amount_input, + )); if response.changed() { sanitize_i64(&mut self.linear_int_starting_amount_input); } @@ -660,8 +712,7 @@ Emits tokens in fixed amounts for specific intervals. ui.horizontal(|ui| { ui.label(" - Start Period Offset (s, optional, u64):"); let response = ui.add( - TextEdit::singleline(&mut self.poly_int_s_input) - .hint_text("None"), + TextEdit::singleline(&mut self.poly_int_s_input).hint_text("None"), ); if response.changed() && !self.poly_int_s_input.trim().is_empty() { sanitize_u64(&mut self.poly_int_s_input); @@ -686,7 +737,9 @@ Emits tokens in fixed amounts for specific intervals. TextEdit::singleline(&mut self.poly_int_min_value_input) .hint_text("None"), ); - if response.changed() && !self.poly_int_min_value_input.trim().is_empty() { + if response.changed() + && !self.poly_int_min_value_input.trim().is_empty() + { sanitize_u64(&mut self.poly_int_min_value_input); } }); @@ -697,7 +750,9 @@ Emits tokens in fixed amounts for specific intervals. TextEdit::singleline(&mut self.poly_int_max_value_input) .hint_text("None"), ); - if response.changed() && !self.poly_int_max_value_input.trim().is_empty() { + if response.changed() + && !self.poly_int_max_value_input.trim().is_empty() + { sanitize_u64(&mut self.poly_int_max_value_input); } }); @@ -710,7 +765,9 @@ Emits tokens in fixed amounts for specific intervals. sanitize_u64(&mut self.exp_a_input); }); ui.horizontal(|ui| { - ui.label(" - Exponent Rate Numerator (m, { -8 ≤ m ≤ 8 ; m ≠ 0 }):"); + ui.label( + " - Exponent Rate Numerator (m, { -8 ≤ m ≤ 8 ; m ≠ 0 }):", + ); ui.text_edit_singleline(&mut self.exp_m_input); sanitize_i64(&mut self.exp_m_input); }); @@ -726,10 +783,8 @@ Emits tokens in fixed amounts for specific intervals. }); ui.horizontal(|ui| { ui.label(" - Start Period Offset (s, optional, u64):"); - let response = ui.add( - TextEdit::singleline(&mut self.exp_s_input) - .hint_text("None"), - ); + let response = ui + .add(TextEdit::singleline(&mut self.exp_s_input).hint_text("None")); if response.changed() && !self.exp_s_input.trim().is_empty() { sanitize_u64(&mut self.exp_s_input); } @@ -769,7 +824,9 @@ Emits tokens in fixed amounts for specific intervals. DistributionFunctionUI::Logarithmic => { ui.horizontal(|ui| { - ui.label(" - Scaling Factor (a, i64, { -32_766 ≤ a ≤ 32_767 }):"); + ui.label( + " - Scaling Factor (a, i64, { -32_766 ≤ a ≤ 32_767 }):", + ); ui.text_edit_singleline(&mut self.log_a_input); sanitize_i64(&mut self.log_a_input); }); @@ -794,10 +851,8 @@ Emits tokens in fixed amounts for specific intervals. ui.horizontal(|ui| { ui.label(" - Start Period Offset (s, optional, u64):"); - let response = ui.add( - TextEdit::singleline(&mut self.log_s_input) - .hint_text("None"), - ); + let response = ui + .add(TextEdit::singleline(&mut self.log_s_input).hint_text("None")); if response.changed() && !self.log_s_input.trim().is_empty() { sanitize_u64(&mut self.log_s_input); } @@ -840,7 +895,9 @@ Emits tokens in fixed amounts for specific intervals. DistributionFunctionUI::InvertedLogarithmic => { ui.horizontal(|ui| { - ui.label(" - Scaling Factor (a, i64, { -32_766 ≤ a ≤ 32_767 }):"); + ui.label( + " - Scaling Factor (a, i64, { -32_766 ≤ a ≤ 32_767 }):", + ); ui.text_edit_singleline(&mut self.inv_log_a_input); sanitize_i64(&mut self.inv_log_a_input); }); @@ -866,8 +923,7 @@ Emits tokens in fixed amounts for specific intervals. ui.horizontal(|ui| { ui.label(" - Start Period Offset (s, optional, u64):"); let response = ui.add( - TextEdit::singleline(&mut self.inv_log_s_input) - .hint_text("None"), + TextEdit::singleline(&mut self.inv_log_s_input).hint_text("None"), ); if response.changed() && !self.inv_log_s_input.trim().is_empty() { sanitize_u64(&mut self.inv_log_s_input); @@ -892,7 +948,8 @@ Emits tokens in fixed amounts for specific intervals. TextEdit::singleline(&mut self.inv_log_min_value_input) .hint_text("None"), ); - if response.changed() && !self.inv_log_min_value_input.trim().is_empty() { + if response.changed() && !self.inv_log_min_value_input.trim().is_empty() + { sanitize_u64(&mut self.inv_log_min_value_input); } }); @@ -903,7 +960,8 @@ Emits tokens in fixed amounts for specific intervals. TextEdit::singleline(&mut self.inv_log_max_value_input) .hint_text("None"), ); - if response.changed() && !self.inv_log_max_value_input.trim().is_empty() { + if response.changed() && !self.inv_log_max_value_input.trim().is_empty() + { sanitize_u64(&mut self.inv_log_max_value_input); } }); @@ -950,7 +1008,14 @@ Emits tokens in fixed amounts for specific intervals. ui.horizontal(|ui| { ui.label(" "); - self.perpetual_distribution_rules.render_control_change_rules_ui(ui, &self.groups_ui,"Perpetual Distribution Rules", None); + self.perpetual_distribution_rules + .render_control_change_rules_ui( + ui, + &self.groups_ui, + "Perpetual Distribution Rules", + None, + &mut self.token_creator_perpetual_distribution_rules_expanded, + ); }); ui.add_space(5.0); @@ -1018,10 +1083,12 @@ Emits tokens in fixed amounts for specific intervals. ui.horizontal(|ui| { ui.label(" "); if ui.button("Add New Distribution Entry").clicked() { - self.pre_programmed_distributions.push(DistributionEntry::default()); + self.pre_programmed_distributions + .push(DistributionEntry::default()); } }); } - }); + }); + } } } diff --git a/src/ui/tokens/tokens_screen/groups.rs b/src/ui/tokens/tokens_screen/groups.rs index 6414cd930..adc4a4e70 100644 --- a/src/ui/tokens/tokens_screen/groups.rs +++ b/src/ui/tokens/tokens_screen/groups.rs @@ -1,5 +1,6 @@ use crate::model::qualified_identity::QualifiedIdentity; use crate::ui::components::identity_selector::IdentitySelector; +use crate::ui::theme::DashColors; use crate::ui::tokens::tokens_screen::TokensScreen; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::group::v0::GroupV0; @@ -8,6 +9,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; use eframe::epaint::Color32; +use egui::RichText; use std::collections::BTreeMap; #[derive(Default, Clone)] @@ -98,25 +100,33 @@ impl TokensScreen { pub fn render_groups(&mut self, ui: &mut egui::Ui) { ui.add_space(5.0); - let mut groups_state = egui::collapsing_header::CollapsingState::load_with_default_open( - ui.ctx(), - ui.make_persistent_id("token_creator_groups"), - false, - ); - - // Force close if we need to reset - if self.should_reset_collapsing_states { - groups_state.set_open(false); - } - - groups_state.store(ui.ctx()); - - groups_state.show_header(ui, |ui| { + ui.horizontal(|ui| { + // +/- button + let button_text = if self.token_creator_groups_expanded { + "−" + } else { + "+" + }; + let button_response = ui.add( + egui::Button::new( + RichText::new(button_text) + .size(20.0) + .color(DashColors::DASH_BLUE), + ) + .fill(Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE), + ); + if button_response.clicked() { + self.token_creator_groups_expanded = !self.token_creator_groups_expanded; + } ui.label("Groups"); - }) - .body(|ui| { + }); + + if self.token_creator_groups_expanded { ui.add_space(3.0); - ui.label("Define one or more groups for multi-party control of the contract."); + + ui.indent("groups_section", |ui| { + ui.label("Define one or more groups for multi-party control of the contract."); ui.add_space(2.0); // Add main group selection input @@ -132,15 +142,31 @@ impl TokensScreen { let last_group_position = self.groups_ui.len().saturating_sub(1); for (group_position, group_ui) in self.groups_ui.iter_mut().enumerate() { - egui::collapsing_header::CollapsingState::load_with_default_open( - ui.ctx(), - format!("group_header_{}", group_position).into(), - true, - ) - .show_header(ui, |ui| { - ui.label(format!("Group {}", group_position)); - }) - .body(|ui| { + ui.horizontal(|ui| { + // +/- button for individual groups + let group_key = format!("group_{}", group_position); + let is_expanded = self.token_creator_groups_items_expanded.contains(&group_key); + let button_text = if is_expanded { "−" } else { "+" }; + let button_response = ui.add( + egui::Button::new( + RichText::new(button_text) + .size(20.0) + .color(DashColors::DASH_BLUE), + ) + .fill(Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE), + ); + if button_response.clicked() { + if is_expanded { + self.token_creator_groups_items_expanded.remove(&group_key); + } else { + self.token_creator_groups_items_expanded.insert(group_key.clone()); + } + } + ui.label(format!("Group {}", group_position)); + }); + + if self.token_creator_groups_items_expanded.contains(&format!("group_{}", group_position)) { ui.add_space(3.0); ui.horizontal(|ui| { @@ -229,10 +255,10 @@ impl TokensScreen { group_to_remove = Some(group_position); } } - }); + } } - if let Some(group_to_remove) = group_to_remove{ + if let Some(group_to_remove) = group_to_remove { self.groups_ui.remove(group_to_remove); } @@ -240,15 +266,23 @@ impl TokensScreen { if ui.button("Add New Group").clicked() { self.groups_ui.push(GroupConfigUI { required_power_str: "2".to_owned(), - members: vec![GroupMemberUI { - identity_str: self.selected_identity.as_ref().map(|q| q.identity.id().to_string(Encoding::Base58)).unwrap_or_default(), - power_str: "1".to_string(), - }, GroupMemberUI { - identity_str: "".to_string(), - power_str: "1".to_string(), - }], + members: vec![ + GroupMemberUI { + identity_str: self + .selected_identity + .as_ref() + .map(|q| q.identity.id().to_string(Encoding::Base58)) + .unwrap_or_default(), + power_str: "1".to_string(), + }, + GroupMemberUI { + identity_str: "".to_string(), + power_str: "1".to_string(), + }, + ], }); } - }); + }); + } } } diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index d36e5a95d..62c293789 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -247,12 +247,32 @@ impl ChangeControlRulesUI { current_groups: &[GroupConfigUI], action_name: &str, special_case_option: Option<&mut bool>, + is_expanded: &mut bool, ) { - ui.collapsing(action_name, |ui| { - egui::Grid::new("basic_token_info_grid") - .num_columns(2) - .spacing([16.0, 8.0]) // Horizontal, vertical spacing - .show(ui, |ui| { + ui.horizontal(|ui| { + // +/- button + let button_text = if *is_expanded { "−" } else { "+" }; + let button_response = ui.add( + egui::Button::new( + RichText::new(button_text) + .size(20.0) + .color(crate::ui::theme::DashColors::DASH_BLUE), + ) + .fill(Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE), + ); + if button_response.clicked() { + *is_expanded = !*is_expanded; + } + ui.label(action_name); + }); + + if *is_expanded { + ui.indent(format!("{}_content", action_name), |ui| { + egui::Grid::new(format!("{}_grid", action_name)) + .num_columns(2) + .spacing([16.0, 8.0]) // Horizontal, vertical spacing + .show(ui, |ui| { // Authorized action takers ui.horizontal(|ui| { ui.label("Authorized to perform action:"); @@ -460,7 +480,8 @@ impl ChangeControlRulesUI { } } }); - }); + }); + } } #[allow(clippy::too_many_arguments)] @@ -474,12 +495,34 @@ impl ChangeControlRulesUI { new_tokens_destination_identity_rules: &mut ChangeControlRulesUI, new_tokens_destination_identity: &mut String, minting_allow_choosing_destination_rules: &mut ChangeControlRulesUI, + is_expanded: &mut bool, + new_tokens_destination_expanded: &mut bool, + minting_allow_choosing_expanded: &mut bool, ) { - ui.collapsing("Manual Mint", |ui| { - egui::Grid::new("basic_token_info_grid") - .num_columns(2) - .spacing([16.0, 8.0]) // Horizontal, vertical spacing - .show(ui, |ui| { + ui.horizontal(|ui| { + // +/- button + let button_text = if *is_expanded { "−" } else { "+" }; + let button_response = ui.add( + egui::Button::new( + RichText::new(button_text) + .size(20.0) + .color(crate::ui::theme::DashColors::DASH_BLUE), + ) + .fill(Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE), + ); + if button_response.clicked() { + *is_expanded = !*is_expanded; + } + ui.label("Manual Mint"); + }); + + if *is_expanded { + ui.indent("manual_mint_content", |ui| { + egui::Grid::new("manual_mint_grid") + .num_columns(2) + .spacing([16.0, 8.0]) // Horizontal, vertical spacing + .show(ui, |ui| { // Authorized action takers ui.horizontal(|ui| { ui.label("Authorized to perform action:"); @@ -709,7 +752,7 @@ impl ChangeControlRulesUI { ui.text_edit_singleline(new_tokens_destination_identity); ui.end_row(); - new_tokens_destination_identity_rules.render_control_change_rules_ui(ui, current_groups,"New Tokens Destination Identity Rules", None); + new_tokens_destination_identity_rules.render_control_change_rules_ui(ui, current_groups,"New Tokens Destination Identity Rules", None, new_tokens_destination_expanded); } ui.end_row(); @@ -723,7 +766,7 @@ impl ChangeControlRulesUI { if *minting_allow_choosing_destination { ui.end_row(); - minting_allow_choosing_destination_rules.render_control_change_rules_ui(ui, current_groups, "Minting Allow Choosing Destination Rules", None); + minting_allow_choosing_destination_rules.render_control_change_rules_ui(ui, current_groups, "Minting Allow Choosing Destination Rules", None, minting_allow_choosing_expanded); } ui.end_row(); @@ -740,7 +783,8 @@ impl ChangeControlRulesUI { } } }); - }); + }); + } } pub fn extract_change_control_rules( @@ -957,6 +1001,29 @@ pub struct TokensScreen { pending_backend_task: Option, refreshing_status: RefreshingStatus, should_reset_collapsing_states: bool, + // Token Creator expanded sections + token_creator_advanced_expanded: bool, + token_creator_action_rules_expanded: bool, + token_creator_main_control_expanded: bool, + token_creator_distribution_expanded: bool, + token_creator_groups_expanded: bool, + token_creator_groups_items_expanded: std::collections::HashSet, + token_creator_document_schemas_expanded: bool, + // Individual action rules expanded states + token_creator_manual_mint_expanded: bool, + token_creator_manual_burn_expanded: bool, + token_creator_freeze_expanded: bool, + token_creator_unfreeze_expanded: bool, + token_creator_destroy_frozen_expanded: bool, + token_creator_emergency_action_expanded: bool, + token_creator_max_supply_change_expanded: bool, + token_creator_conventions_change_expanded: bool, + token_creator_marketplace_expanded: bool, + token_creator_direct_purchase_pricing_expanded: bool, + // Nested rules expanded states + token_creator_new_tokens_destination_expanded: bool, + token_creator_minting_allow_choosing_expanded: bool, + token_creator_perpetual_distribution_rules_expanded: bool, // Contract Search pub selected_contract_id: Option, @@ -1489,6 +1556,29 @@ impl TokensScreen { function_images, function_textures: BTreeMap::default(), should_reset_collapsing_states: false, + // Token Creator expanded sections + token_creator_advanced_expanded: false, + token_creator_action_rules_expanded: false, + token_creator_main_control_expanded: false, + token_creator_distribution_expanded: false, + token_creator_groups_expanded: false, + token_creator_groups_items_expanded: std::collections::HashSet::new(), + token_creator_document_schemas_expanded: false, + // Individual action rules expanded states + token_creator_manual_mint_expanded: false, + token_creator_manual_burn_expanded: false, + token_creator_freeze_expanded: false, + token_creator_unfreeze_expanded: false, + token_creator_destroy_frozen_expanded: false, + token_creator_emergency_action_expanded: false, + token_creator_max_supply_change_expanded: false, + token_creator_conventions_change_expanded: false, + token_creator_marketplace_expanded: false, + token_creator_direct_purchase_pricing_expanded: false, + // Nested rules expanded states + token_creator_new_tokens_destination_expanded: false, + token_creator_minting_allow_choosing_expanded: false, + token_creator_perpetual_distribution_rules_expanded: false, // Token adding status adding_token_start_time: None, diff --git a/src/ui/tokens/tokens_screen/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index e10ef0e25..b6937ae9d 100644 --- a/src/ui/tokens/tokens_screen/token_creator.rs +++ b/src/ui/tokens/tokens_screen/token_creator.rs @@ -12,6 +12,7 @@ use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; use eframe::epaint::Color32; use egui::{ComboBox, Context, RichText, TextEdit, Ui}; +use crate::ui::theme::DashColors; use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; @@ -294,128 +295,133 @@ impl TokensScreen { ui.add_space(10.0); // 5) Advanced settings toggle - let mut advanced_state = egui::collapsing_header::CollapsingState::load_with_default_open( - ui.ctx(), - ui.make_persistent_id("token_creator_advanced"), - false, - ); - - // Force close if we need to reset - if self.should_reset_collapsing_states { - advanced_state.set_open(false); - } - - advanced_state.store(ui.ctx()); - - advanced_state.show_header(ui, |ui| { + ui.horizontal(|ui| { + // +/- button + let button_text = if self.token_creator_advanced_expanded { "−" } else { "+" }; + let button_response = ui.add( + egui::Button::new( + RichText::new(button_text) + .size(20.0) + .color(DashColors::DASH_BLUE), + ) + .fill(Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE), + ); + if button_response.clicked() { + self.token_creator_advanced_expanded = !self.token_creator_advanced_expanded; + } ui.label("Advanced"); - }) - .body(|ui| { - ui.add_space(3.0); - - // Use `Grid` to align labels and text edits - egui::Grid::new("advanced_token_info_grid") - .num_columns(2) - .spacing([16.0, 8.0]) // Horizontal, vertical spacing - .show(ui, |ui| { - - // Start as paused - ui.horizontal(|ui| { - StyledCheckbox::new(&mut self.start_as_paused_input, "Start as paused").show(ui); - - crate::ui::helpers::info_icon_button(ui, "When enabled, the token will be created in a paused state, meaning transfers will be disabled by default. All other token features—such as distributions and manual minting—remain fully functional. To allow transfers in the future, the token must be unpaused via an emergency action. It is strongly recommended to enable emergency actions if this option is selected, unless the intention is to permanently disable transfers."); - }); - ui.end_row(); - - self.history_row(ui); - ui.end_row(); - - // Name should be capitalized - ui.horizontal(|ui| { - StyledCheckbox::new(&mut self.should_capitalize_input, "Name should be capitalized").show(ui); + }); - crate::ui::helpers::info_icon_button(ui, "This is used only as helper information to client applications that will use token. This informs them on whether to capitalize the token name or not by default."); - }); - ui.end_row(); + if self.token_creator_advanced_expanded { + ui.add_space(3.0); - // Decimals - ui.horizontal(|ui| { - ui.label("Max Decimals:"); - // Restrict input to digits only - let response = ui.add( - TextEdit::singleline(&mut self.decimals_input).desired_width(50.0) - ); + ui.indent("advanced_section", |ui| { + // Use `Grid` to align labels and text edits + egui::Grid::new("advanced_token_info_grid") + .num_columns(2) + .spacing([16.0, 8.0]) // Horizontal, vertical spacing + .show(ui, |ui| { + // Start as paused + ui.horizontal(|ui| { + StyledCheckbox::new(&mut self.start_as_paused_input, "Start as paused").show(ui); + crate::ui::helpers::info_icon_button(ui, "When enabled, the token will be created in a paused state, meaning transfers will be disabled by default. All other token features—such as distributions and manual minting—remain fully functional. To allow transfers in the future, the token must be unpaused via an emergency action. It is strongly recommended to enable emergency actions if this option is selected, unless the intention is to permanently disable transfers."); + }); + ui.end_row(); - // Optionally filter out non-digit input - if response.changed() { - self.decimals_input.retain(|c| c.is_ascii_digit()); - self.decimals_input.truncate(2); - } + self.history_row(ui); + ui.end_row(); - let token_name = self.token_names_input - .first() - .as_ref() - .and_then(|(_, name, _, _)| if name.is_empty() { None} else { Some(name.as_str())}) - .unwrap_or(""); + // Name should be capitalized + ui.horizontal(|ui| { + StyledCheckbox::new(&mut self.should_capitalize_input, "Name should be capitalized").show(ui); + crate::ui::helpers::info_icon_button(ui, "This is used only as helper information to client applications that will use token. This informs them on whether to capitalize the token name or not by default."); + }); + ui.end_row(); + + // Decimals + ui.horizontal(|ui| { + ui.label("Max Decimals:"); + // Restrict input to digits only + let response = ui.add( + TextEdit::singleline(&mut self.decimals_input).desired_width(50.0) + ); - let message = if self.decimals_input == "0" { - format!("Non Fractional Token (i.e. 0, 1, 2 or 10 {})", token_name) - } else { - format!("Fractional Token (i.e. 0.2 {})", token_name) - }; + // Optionally filter out non-digit input + if response.changed() { + self.decimals_input.retain(|c| c.is_ascii_digit()); + self.decimals_input.truncate(2); + } - ui.label(RichText::new(message).color(Color32::GRAY)); + let token_name = self.token_names_input + .first() + .as_ref() + .and_then(|(_, name, _, _)| if name.is_empty() { None} else { Some(name.as_str())}) + .unwrap_or(""); - crate::ui::helpers::info_icon_button(ui, "The decimal places of the token, for example Dash and Bitcoin use 8. The minimum indivisible amount is a Duff or a Satoshi respectively. If you put a value greater than 0 this means that it is indicated that the consensus is that 10^(number entered) is what represents 1 full unit of the token."); - }); - ui.end_row(); + let message = if self.decimals_input == "0" { + format!("Non Fractional Token (i.e. 0, 1, 2 or 10 {})", token_name) + } else { + format!("Fractional Token (i.e. 0.2 {})", token_name) + }; - // Marketplace Trade Mode - ui.horizontal(|ui| { - ui.label("Marketplace Trade Mode:"); - ComboBox::from_id_salt("marketplace_trade_mode_selector") - .selected_text("Not Tradeable") - .show_ui(ui, |ui| { - ui.selectable_value( - &mut self.marketplace_trade_mode, - 0, - "Not Tradeable", - ); - // Future trade modes can be added here when SDK supports them - }); + ui.label(RichText::new(message).color(Color32::GRAY)); + crate::ui::helpers::info_icon_button(ui, "The decimal places of the token, for example Dash and Bitcoin use 8. The minimum indivisible amount is a Duff or a Satoshi respectively. If you put a value greater than 0 this means that it is indicated that the consensus is that 10^(number entered) is what represents 1 full unit of the token."); + }); + ui.end_row(); + + // Marketplace Trade Mode + ui.horizontal(|ui| { + ui.label("Marketplace Trade Mode:"); + ComboBox::from_id_salt("marketplace_trade_mode_selector") + .selected_text("Not Tradeable") + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.marketplace_trade_mode, + 0, + "Not Tradeable", + ); + // Future trade modes can be added here when SDK supports them + }); - crate::ui::helpers::info_icon_button(ui, - "Currently, all tokens are created as 'Not Tradeable'. \ - Future updates will add more trade mode options.\n\n\ - IMPORTANT: If you want to enable marketplace trading in the future, \ - make sure to set the 'Marketplace Trade Mode Change' rules in the Action Rules \ - section to something other than 'No One'. Otherwise, trading can never be enabled." - ); + crate::ui::helpers::info_icon_button(ui, + "Currently, all tokens are created as 'Not Tradeable'. \ + Future updates will add more trade mode options.\n\n\ + IMPORTANT: If you want to enable marketplace trading in the future, \ + make sure to set the 'Marketplace Trade Mode Change' rules in the Action Rules \ + section to something other than 'No One'. Otherwise, trading can never be enabled." + ); + }); + ui.end_row(); }); - ui.end_row(); - }); - }); + }); + } ui.add_space(5.0); - let mut action_rules_state = egui::collapsing_header::CollapsingState::load_with_default_open( - ui.ctx(), - ui.make_persistent_id("token_creator_action_rules"), - false, - ); - - // Force close if we need to reset - if self.should_reset_collapsing_states { - action_rules_state.set_open(false); - } + ui.horizontal(|ui| { + // +/- button + let button_text = if self.token_creator_action_rules_expanded { "−" } else { "+" }; + let button_response = ui.add( + egui::Button::new( + RichText::new(button_text) + .size(20.0) + .color(DashColors::DASH_BLUE), + ) + .fill(Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE), + ); + if button_response.clicked() { + self.token_creator_action_rules_expanded = !self.token_creator_action_rules_expanded; + } + ui.label("Action Rules"); + }); - action_rules_state.store(ui.ctx()); + if self.token_creator_action_rules_expanded { + ui.add_space(3.0); - action_rules_state.show_header(ui, |ui| { - ui.label("Action Rules"); - }) - .body(|ui| { ui.horizontal(|ui| { + ui.add_space(40.0); // Indentation ui.label("Preset:"); ComboBox::from_id_salt("preset_selector") @@ -467,36 +473,48 @@ impl TokensScreen { }); }); - self.manual_minting_rules.render_mint_control_change_rules_ui(ui, &self.groups_ui, &mut self.new_tokens_destination_identity_should_default_to_contract_owner, &mut self.new_tokens_destination_other_identity_enabled, &mut self.minting_allow_choosing_destination, &mut self.new_tokens_destination_identity_rules, &mut self.new_tokens_destination_other_identity, &mut self.minting_allow_choosing_destination_rules); - self.manual_burning_rules.render_control_change_rules_ui(ui, &self.groups_ui,"Manual Burn", None); - self.freeze_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Freeze", Some(&mut self.allow_transfers_to_frozen_identities)); - self.unfreeze_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Unfreeze", None); - self.destroy_frozen_funds_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Destroy Frozen Funds", None); - self.emergency_action_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Emergency Action", None); - self.max_supply_change_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Max Supply Change", None); - self.conventions_change_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Conventions Change", None); - self.marketplace_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Marketplace Trade Mode Change", None); - self.change_direct_purchase_pricing_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Direct Purchase Pricing Change", None); + ui.add_space(5.0); + + ui.horizontal(|ui| { + ui.add_space(20.0); // Indentation for action rules + ui.vertical(|ui| { + self.manual_minting_rules.render_mint_control_change_rules_ui(ui, &self.groups_ui, &mut self.new_tokens_destination_identity_should_default_to_contract_owner, &mut self.new_tokens_destination_other_identity_enabled, &mut self.minting_allow_choosing_destination, &mut self.new_tokens_destination_identity_rules, &mut self.new_tokens_destination_other_identity, &mut self.minting_allow_choosing_destination_rules, &mut self.token_creator_manual_mint_expanded, &mut self.token_creator_new_tokens_destination_expanded, &mut self.token_creator_minting_allow_choosing_expanded); + self.manual_burning_rules.render_control_change_rules_ui(ui, &self.groups_ui,"Manual Burn", None, &mut self.token_creator_manual_burn_expanded); + self.freeze_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Freeze", Some(&mut self.allow_transfers_to_frozen_identities), &mut self.token_creator_freeze_expanded); + self.unfreeze_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Unfreeze", None, &mut self.token_creator_unfreeze_expanded); + self.destroy_frozen_funds_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Destroy Frozen Funds", None, &mut self.token_creator_destroy_frozen_expanded); + self.emergency_action_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Emergency Action", None, &mut self.token_creator_emergency_action_expanded); + self.max_supply_change_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Max Supply Change", None, &mut self.token_creator_max_supply_change_expanded); + self.conventions_change_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Conventions Change", None, &mut self.token_creator_conventions_change_expanded); + self.marketplace_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Marketplace Trade Mode Change", None, &mut self.token_creator_marketplace_expanded); + self.change_direct_purchase_pricing_rules.render_control_change_rules_ui(ui, &self.groups_ui, "Direct Purchase Pricing Change", None, &mut self.token_creator_direct_purchase_pricing_expanded); + }); + }); // Main control group change is slightly different so do this one manually. ui.add_space(6.0); - let mut main_control_state = egui::collapsing_header::CollapsingState::load_with_default_open( - ui.ctx(), - ui.make_persistent_id("token_creator_main_control_group"), - false, - ); - - // Force close if we need to reset - if self.should_reset_collapsing_states { - main_control_state.set_open(false); - } - - main_control_state.store(ui.ctx()); + ui.horizontal(|ui| { + ui.add_space(20.0); // Indentation for main control group change + ui.vertical(|ui| { + ui.horizontal(|ui| { + // +/- button + let button_text = if self.token_creator_main_control_expanded { "−" } else { "+" }; + let button_response = ui.add( + egui::Button::new( + RichText::new(button_text) + .size(20.0) + .color(DashColors::DASH_BLUE), + ) + .fill(Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE), + ); + if button_response.clicked() { + self.token_creator_main_control_expanded = !self.token_creator_main_control_expanded; + } + ui.label("Main Control Group Change"); + }); - main_control_state.show_header(ui, |ui| { - ui.label("Main Control Group Change"); - }) - .body(|ui| { + if self.token_creator_main_control_expanded { ui.add_space(3.0); // A) authorized_to_make_change @@ -554,8 +572,10 @@ impl TokensScreen { _ => {} } }); + } + }); }); - }); + } self.render_distributions(context, ui); self.render_groups(ui); @@ -633,10 +653,30 @@ impl TokensScreen { } } }); - }); - // Reset the flag after processing all collapsing headers + // Reset the expanded states after processing if self.should_reset_collapsing_states { + self.token_creator_advanced_expanded = false; + self.token_creator_action_rules_expanded = false; + self.token_creator_main_control_expanded = false; + self.token_creator_distribution_expanded = false; + self.token_creator_groups_expanded = false; + self.token_creator_document_schemas_expanded = false; + // Individual action rules + self.token_creator_manual_mint_expanded = false; + self.token_creator_manual_burn_expanded = false; + self.token_creator_freeze_expanded = false; + self.token_creator_unfreeze_expanded = false; + self.token_creator_destroy_frozen_expanded = false; + self.token_creator_emergency_action_expanded = false; + self.token_creator_max_supply_change_expanded = false; + self.token_creator_conventions_change_expanded = false; + self.token_creator_marketplace_expanded = false; + self.token_creator_direct_purchase_pricing_expanded = false; + // Nested rules + self.token_creator_new_tokens_destination_expanded = false; + self.token_creator_minting_allow_choosing_expanded = false; + self.token_creator_perpetual_distribution_rules_expanded = false; self.should_reset_collapsing_states = false; } @@ -670,6 +710,8 @@ impl TokensScreen { ui.add_space(10.0); } + }); // Close the ScrollArea from line 40 + action } @@ -1146,29 +1188,35 @@ impl TokensScreen { fn render_document_schemas(&mut self, ui: &mut Ui) { ui.add_space(5.0); - let mut document_schemas_state = - egui::collapsing_header::CollapsingState::load_with_default_open( - ui.ctx(), - ui.make_persistent_id("token_creator_document_schemas"), - false, + ui.horizontal(|ui| { + // +/- button + let button_text = if self.token_creator_document_schemas_expanded { + "−" + } else { + "+" + }; + let button_response = ui.add( + egui::Button::new( + RichText::new(button_text) + .size(20.0) + .color(DashColors::DASH_BLUE), + ) + .fill(Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE), ); + if button_response.clicked() { + self.token_creator_document_schemas_expanded = + !self.token_creator_document_schemas_expanded; + } + ui.label("Document Schemas"); + }); - // Force close if we need to reset - if self.should_reset_collapsing_states { - document_schemas_state.set_open(false); - } - - document_schemas_state.store(ui.ctx()); - - document_schemas_state - .show_header(ui, |ui| { - ui.label("Document Schemas"); - }) - .body(|ui| { - ui.add_space(3.0); + if self.token_creator_document_schemas_expanded { + ui.add_space(3.0); + ui.indent("document_schemas_section", |ui| { // Add link to dashpay.io - ui.horizontal(|ui| { + ui.horizontal(|ui| { ui.label("Paste JSON document schemas to include in the contract. Easily create document schemas here:"); ui.add(egui::Hyperlink::from_label_and_url( RichText::new("dashpay.io") @@ -1178,38 +1226,39 @@ impl TokensScreen { )); }); - ui.add_space(5.0); + ui.add_space(5.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; - let schemas_response = ui.add_sized( - [ui.available_width(), 120.0], - TextEdit::multiline(&mut self.document_schemas_input) - .text_color(crate::ui::theme::DashColors::text_primary(dark_mode)) - .background_color(crate::ui::theme::DashColors::input_background(dark_mode)), - ); + let dark_mode = ui.ctx().style().visuals.dark_mode; + let schemas_response = ui.add_sized( + [ui.available_width(), 120.0], + TextEdit::multiline(&mut self.document_schemas_input) + .text_color(crate::ui::theme::DashColors::text_primary(dark_mode)) + .background_color(crate::ui::theme::DashColors::input_background(dark_mode)), + ); - if schemas_response.changed() { - self.parse_document_schemas(); - } + if schemas_response.changed() { + self.parse_document_schemas(); + } - ui.add_space(5.0); + ui.add_space(5.0); - // Show validation result - if let Some(ref error) = self.document_schemas_error { + // Show validation result + if let Some(ref error) = self.document_schemas_error { + ui.colored_label( + Color32::DARK_RED, + format!("Schema validation error: {}", error), + ); + } else if self.parsed_document_schemas.is_some() { + let schema_count = self.parsed_document_schemas.as_ref().unwrap().len(); + if schema_count > 0 { ui.colored_label( - Color32::DARK_RED, - format!("Schema validation error: {}", error), + Color32::DARK_GREEN, + format!("✓ {} valid document schema(s) parsed", schema_count), ); - } else if self.parsed_document_schemas.is_some() { - let schema_count = self.parsed_document_schemas.as_ref().unwrap().len(); - if schema_count > 0 { - ui.colored_label( - Color32::DARK_GREEN, - format!("✓ {} valid document schema(s) parsed", schema_count), - ); - } } + } }); + } } /// Parse and validate the document schemas JSON input From 2935a5d2b7ee01073dfcfa14d79c4f45b8e9f84e Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:48:26 +0700 Subject: [PATCH 016/106] feat: implement new ConfirmationDialog component throughout app (#430) * feat: implement new ConfirmationDialog component throughout app * fix: remove backup files --- .../contracts_documents_screen.rs | 80 ++++--- src/ui/identities/identities_screen.rs | 15 +- src/ui/identities/withdraw_screen.rs | 149 +++++++------ src/ui/tokens/burn_tokens_screen.rs | 173 ++++++++------- src/ui/tokens/claim_tokens_screen.rs | 92 ++++---- src/ui/tokens/destroy_frozen_funds_screen.rs | 191 ++++++++--------- src/ui/tokens/direct_token_purchase_screen.rs | 152 ++++++------- src/ui/tokens/freeze_tokens_screen.rs | 174 ++++++++------- src/ui/tokens/mint_tokens_screen.rs | 202 ++++++++---------- src/ui/tokens/pause_tokens_screen.rs | 130 ++++++----- src/ui/tokens/resume_tokens_screen.rs | 130 ++++++----- .../data_contract_json_pop_up.rs | 69 +++++- src/ui/tokens/tokens_screen/mod.rs | 112 +++++----- src/ui/tokens/tokens_screen/token_creator.rs | 127 +++++------ src/ui/tokens/transfer_tokens_screen.rs | 175 ++++++++------- src/ui/tokens/unfreeze_tokens_screen.rs | 183 ++++++++-------- 16 files changed, 1103 insertions(+), 1051 deletions(-) diff --git a/src/ui/contracts_documents/contracts_documents_screen.rs b/src/ui/contracts_documents/contracts_documents_screen.rs index 259cc50ef..b164646c5 100644 --- a/src/ui/contracts_documents/contracts_documents_screen.rs +++ b/src/ui/contracts_documents/contracts_documents_screen.rs @@ -4,6 +4,8 @@ use crate::backend_task::contract::ContractTask; use crate::backend_task::document::DocumentTask::{self, FetchDocumentsPage}; // Updated import use crate::context::AppContext; use crate::model::qualified_contract::QualifiedContract; +use crate::ui::components::Component; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::contract_chooser_panel::{ ContractChooserState, add_contract_chooser_panel, }; @@ -57,7 +59,7 @@ pub struct DocumentQueryScreen { selected_index: Option, pub matching_documents: Vec, document_query_status: DocumentQueryStatus, - confirm_remove_contract_popup: bool, + confirmation_dialog: Option, contract_to_remove: Option, pending_document_type: DocumentType, pending_fields_selection: HashMap, @@ -122,7 +124,7 @@ impl DocumentQueryScreen { selected_index: None, matching_documents: vec![], document_query_status: DocumentQueryStatus::NotStarted, - confirm_remove_contract_popup: false, + confirmation_dialog: None, contract_to_remove: None, pending_document_type, pending_fields_selection, @@ -490,53 +492,44 @@ impl DocumentQueryScreen { let contract_to_remove = match &self.contract_to_remove { Some(contract) => *contract, None => { - self.confirm_remove_contract_popup = false; + self.confirmation_dialog = None; return AppAction::None; } }; - let mut app_action = AppAction::None; - let mut is_open = true; - - egui::Window::new("Confirm Remove Contract") - .collapsible(false) - .open(&mut is_open) - .show(ui.ctx(), |ui| { - let contract_alias_or_id = - match self.app_context.get_contract_by_id(&contract_to_remove) { - Ok(Some(contract)) => contract - .alias - .unwrap_or_else(|| contract.contract.id().to_string(Encoding::Base58)), - Ok(None) | Err(_) => contract_to_remove.to_string(Encoding::Base58), - }; - - ui.label(format!( + let contract_alias_or_id = match self.app_context.get_contract_by_id(&contract_to_remove) { + Ok(Some(contract)) => contract + .alias + .unwrap_or_else(|| contract.contract.id().to_string(Encoding::Base58)), + Ok(None) | Err(_) => contract_to_remove.to_string(Encoding::Base58), + }; + + let dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new( + "Confirm Remove Contract".to_string(), + format!( "Are you sure you want to remove contract \"{}\"?", contract_alias_or_id - )); - - // Confirm button - if ui.button("Confirm").clicked() { - app_action = AppAction::BackendTask(BackendTask::ContractTask(Box::new( - ContractTask::RemoveContract(contract_to_remove), - ))); - self.confirm_remove_contract_popup = false; - self.contract_to_remove = None; - } - - // Cancel button - if ui.button("Cancel").clicked() { - self.confirm_remove_contract_popup = false; - self.contract_to_remove = None; - } - }); + ), + ) + }); - // If user closes the popup window (the [x] button), also reset state - if !is_open { - self.confirm_remove_contract_popup = false; - self.contract_to_remove = None; + match dialog.show(ui).inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => { + let action = AppAction::BackendTask(BackendTask::ContractTask(Box::new( + ContractTask::RemoveContract(contract_to_remove), + ))); + self.confirmation_dialog = None; + self.contract_to_remove = None; + action + } + Some(ConfirmationStatus::Canceled) => { + self.confirmation_dialog = None; + self.contract_to_remove = None; + AppAction::None + } + None => AppAction::None, } - app_action } } @@ -708,8 +701,9 @@ impl ScreenLike for DocumentQueryScreen { if let AppAction::BackendTask(BackendTask::ContractTask(contract_task)) = &action { if let ContractTask::RemoveContract(contract_id) = **contract_task { action = AppAction::None; - self.confirm_remove_contract_popup = true; self.contract_to_remove = Some(contract_id); + // Clear any existing dialog to create a new one with updated content + self.confirmation_dialog = None; } } @@ -752,7 +746,7 @@ impl ScreenLike for DocumentQueryScreen { ); }); - if self.confirm_remove_contract_popup { + if self.contract_to_remove.is_some() { inner_action |= self.show_remove_contract_popup(ui); } inner_action diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 361d80b7b..dff31771a 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -836,8 +836,9 @@ impl IdentitiesScreen { action } - fn show_identity_to_remove(&mut self, ctx: &Context) { + fn show_identity_to_remove(&mut self, ctx: &Context) -> AppAction { if let Some(identity_to_remove) = self.identity_to_remove.clone() { + let action = AppAction::None; egui::Window::new("Confirm Removal") .collapsible(false) .resizable(false) @@ -884,6 +885,9 @@ impl IdentitiesScreen { } }); }); + action + } else { + AppAction::None } } @@ -1004,6 +1008,11 @@ impl ScreenLike for IdentitiesScreen { inner_action |= self.render_identities_view(ui, &identities_vec); } + // Handle identity removal confirmation dialog + if self.identity_to_remove.is_some() { + inner_action |= self.show_identity_to_remove(ctx); + } + // Show either refreshing indicator or message, but not both if let IdentitiesRefreshingStatus::Refreshing(start_time) = self.refreshing_status { ui.add_space(25.0); // Space above @@ -1040,10 +1049,6 @@ impl ScreenLike for IdentitiesScreen { inner_action }); - if self.identity_to_remove.is_some() { - self.show_identity_to_remove(ctx); - } - match action { AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::RefreshIdentity(_))) => { self.refreshing_status = diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index 720b6c404..85159e3ca 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -7,6 +7,7 @@ use crate::model::qualified_identity::encrypted_key_storage::PrivateKeyData; use crate::model::qualified_identity::{IdentityType, PrivateKeyTarget, QualifiedIdentity}; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; @@ -48,7 +49,7 @@ pub struct WithdrawalScreen { withdrawal_amount_input: Option, max_amount: u64, pub app_context: Arc, - confirmation_popup: bool, + confirmation_dialog: Option, withdraw_from_identity_status: WithdrawFromIdentityStatus, selected_wallet: Option>>, wallet_password: String, @@ -77,7 +78,7 @@ impl WithdrawalScreen { withdrawal_amount_input: None, max_amount, app_context: app_context.clone(), - confirmation_popup: false, + confirmation_dialog: None, withdraw_from_identity_status: WithdrawFromIdentityStatus::NotStarted, selected_wallet, wallet_password: String::new(), @@ -153,56 +154,68 @@ impl WithdrawalScreen { } fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let mut app_action = AppAction::None; - let mut is_open = true; - egui::Window::new("Confirm Withdrawal") - .collapsible(false) - .open(&mut is_open) - .show(ui.ctx(), |ui| { - let address = if self.withdrawal_address.is_empty() { - None - } else { - match Address::from_str(&self.withdrawal_address) { - Ok(address) => Some(address.assume_checked()), - Err(_) => { - self.withdraw_from_identity_status = - WithdrawFromIdentityStatus::ErrorMessage( - "Invalid withdrawal address".to_string(), - ); - None - } - } - }; - - let message_address = if address.is_some() { - self.withdrawal_address.clone() - } else if let Some(payout_address) = self - .identity - .masternode_payout_address(self.app_context.network) - { - format!("masternode payout address {}", payout_address) - } else if !self.app_context.is_developer_mode() { + let address = if self.withdrawal_address.is_empty() { + None + } else { + match Address::from_str(&self.withdrawal_address) { + Ok(address) => Some(address.assume_checked()), + Err(_) => { self.withdraw_from_identity_status = WithdrawFromIdentityStatus::ErrorMessage( - "No masternode payout address".to_string(), + "Invalid withdrawal address".to_string(), ); - return; - } else { - "to default address".to_string() - }; + self.confirmation_dialog = None; + return AppAction::None; + } + } + }; + + let message_address = if address.is_some() { + self.withdrawal_address.clone() + } else if let Some(payout_address) = self + .identity + .masternode_payout_address(self.app_context.network) + { + format!("masternode payout address {}", payout_address) + } else if !self.app_context.is_developer_mode() { + self.withdraw_from_identity_status = WithdrawFromIdentityStatus::ErrorMessage( + "No masternode payout address".to_string(), + ); + self.confirmation_dialog = None; + return AppAction::None; + } else { + "to default address".to_string() + }; - let Some(selected_key) = self.selected_key.as_ref() else { - self.withdraw_from_identity_status = - WithdrawFromIdentityStatus::ErrorMessage("No selected key".to_string()); - return; - }; + let Some(selected_key) = self.selected_key.as_ref() else { + self.withdraw_from_identity_status = + WithdrawFromIdentityStatus::ErrorMessage("No selected key".to_string()); + self.confirmation_dialog = None; + return AppAction::None; + }; - ui.label(format!( + let dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new( + "Confirm Withdrawal".to_string(), + format!( "Are you sure you want to withdraw {} to {}", self.withdrawal_amount .as_ref() .expect("Withdrawal amount should be present"), message_address - )); + ), + ) + .danger_mode(true) // Withdrawal is a destructive operation + }); + + match dialog.show(ui).inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => { + self.confirmation_dialog = None; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.withdraw_from_identity_status = + WithdrawFromIdentityStatus::WaitingForResult(now); // Use the amount directly from the stored amount let credits = self @@ -211,31 +224,21 @@ impl WithdrawalScreen { .expect("Withdrawal amount should be present") .value() as u128; - if ui.button("Confirm").clicked() { - self.confirmation_popup = false; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.withdraw_from_identity_status = - WithdrawFromIdentityStatus::WaitingForResult(now); - app_action = AppAction::BackendTask(BackendTask::IdentityTask( - IdentityTask::WithdrawFromIdentity( - self.identity.clone(), - address, - credits as Credits, - Some(selected_key.id()), - ), - )); - } - if ui.button("Cancel").clicked() { - self.confirmation_popup = false; - } - }); - if !is_open { - self.confirmation_popup = false; + AppAction::BackendTask(BackendTask::IdentityTask( + IdentityTask::WithdrawFromIdentity( + self.identity.clone(), + address, + credits as Credits, + Some(selected_key.id()), + ), + )) + } + Some(ConfirmationStatus::Canceled) => { + self.confirmation_dialog = None; + AppAction::None + } + None => AppAction::None, } - app_action } pub fn show_success(&self, ui: &mut Ui) -> AppAction { @@ -469,11 +472,19 @@ impl ScreenLike for WithdrawalScreen { .add_enabled(ready, button) .on_disabled_hover_text("Please enter a valid amount to withdraw") .clicked() + && self.confirmation_dialog.is_none() { - self.confirmation_popup = true; + // Validation will be done in show_confirmation_popup + self.confirmation_dialog = Some( + ConfirmationDialog::new( + "Confirm Withdrawal".to_string(), + "Loading...".to_string(), // Will be updated in show_confirmation_popup + ) + .danger_mode(true), + ); } - if self.confirmation_popup { + if self.confirmation_dialog.is_some() { inner_action |= self.show_confirmation_popup(ui); } diff --git a/src/ui/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs index 8292cccf9..c766017ad 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -1,4 +1,5 @@ use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; @@ -68,7 +69,7 @@ pub struct BurnTokensScreen { pub app_context: Arc, // Confirmation popup - show_confirmation_popup: bool, + confirmation_dialog: Option, // For password-based wallet unlocking, if needed selected_wallet: Option>>, @@ -204,7 +205,7 @@ impl BurnTokensScreen { status: BurnTokensStatus::NotStarted, error_message, app_context: app_context.clone(), - show_confirmation_popup: false, + confirmation_dialog: None, selected_wallet, wallet_password: String::new(), show_password: false, @@ -233,88 +234,80 @@ impl BurnTokensScreen { /// Renders a confirm popup with the final "Are you sure?" step fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - let mut is_open = true; - egui::Window::new("Confirm Burn") - .collapsible(false) - .open(&mut is_open) - .show(ui.ctx(), |ui| { - let amount = match self.amount.as_ref() { - Some(amount) if amount.value() > 0 => amount, - _ => { - self.error_message = - Some("Please enter a valid amount greater than 0.".into()); - self.status = BurnTokensStatus::ErrorMessage("Invalid amount".into()); - self.show_confirmation_popup = false; - return; - } - }; - - ui.label(format!("Are you sure you want to burn {}?", amount)); - - ui.add_space(10.0); + let amount = match self.amount.as_ref() { + Some(amount) if amount.value() > 0 => amount, + _ => { + self.error_message = Some("Please enter a valid amount greater than 0.".into()); + self.status = BurnTokensStatus::ErrorMessage("Invalid amount".into()); + self.confirmation_dialog = None; + return AppAction::None; + } + }; - // Confirm button - if ui.button("Confirm").clicked() { - self.show_confirmation_popup = false; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.status = BurnTokensStatus::WaitingForResult(now); - - // Grab the data contract for this token from the app context - let data_contract = - Arc::new(self.identity_token_info.data_contract.contract.clone()); - - let group_info = if self.group_action_id.is_some() { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( - GroupStateTransitionInfo { - group_contract_position: *pos, - action_id: self.group_action_id.unwrap(), - action_is_proposer: false, - }, - ) - }) - } else { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) - }) - }; - - // Dispatch the actual backend burn action - action = AppAction::BackendTasks( - vec![ - BackendTask::TokenTask(Box::new(TokenTask::BurnTokens { - owner_identity: self.identity_token_info.identity.clone(), - data_contract, - token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("Expected a key"), - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() - }, - amount: amount.value(), - group_info, - })), - BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), - ], - BackendTasksExecutionMode::Sequential, - ); - } + let dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new( + "Confirm Burn".to_string(), + format!("Are you sure you want to burn {}?", amount), + ) + .danger_mode(true) // Burning tokens is destructive + }); - // Cancel button - if ui.button("Cancel").clicked() { - self.show_confirmation_popup = false; - } - }); + match dialog.show(ui).inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => { + self.confirmation_dialog = None; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = BurnTokensStatus::WaitingForResult(now); + + // Grab the data contract for this token from the app context + let data_contract = + Arc::new(self.identity_token_info.data_contract.contract.clone()); + + let group_info = if self.group_action_id.is_some() { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: *pos, + action_id: self.group_action_id.unwrap(), + action_is_proposer: false, + }, + ) + }) + } else { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) + }) + }; - if !is_open { - self.show_confirmation_popup = false; + // Dispatch the actual backend burn action + AppAction::BackendTasks( + vec![ + BackendTask::TokenTask(Box::new(TokenTask::BurnTokens { + owner_identity: self.identity_token_info.identity.clone(), + data_contract, + token_position: self.identity_token_info.token_position, + signing_key: self.selected_key.clone().expect("Expected a key"), + public_note: if self.group_action_id.is_some() { + None + } else { + self.public_note.clone() + }, + amount: amount.value(), + group_info, + })), + BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), + ], + BackendTasksExecutionMode::Sequential, + ) + } + Some(ConfirmationStatus::Canceled) => { + self.confirmation_dialog = None; + AppAction::None + } + None => AppAction::None, } - action } /// Renders a simple "Success!" screen after completion @@ -596,12 +589,26 @@ impl ScreenLike for BurnTokensScreen { .corner_radius(3.0); if ui.add(button).clicked() { - self.show_confirmation_popup = true; + // Create confirmation dialog on button click + if self.confirmation_dialog.is_none() { + let amount = match self.amount.as_ref() { + Some(amount) if amount.value() > 0 => amount, + _ => return AppAction::None, + }; + + self.confirmation_dialog = Some( + ConfirmationDialog::new( + "Confirm Burn".to_string(), + format!("Are you sure you want to burn {}?", amount), + ) + .danger_mode(true), + ); + } } } - // If user pressed "Burn," show a popup - if self.show_confirmation_popup { + // Show confirmation dialog if it exists + if self.confirmation_dialog.is_some() { action |= self.show_confirmation_popup(ui); } diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 918ef055f..25bf04c1b 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -1,3 +1,5 @@ +use crate::ui::components::Component; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; @@ -54,7 +56,7 @@ pub struct ClaimTokensScreen { status: ClaimTokensStatus, error_message: Option, pub app_context: Arc, - show_confirmation_popup: bool, + confirmation_dialog: Option, selected_wallet: Option>>, wallet_password: String, show_password: bool, @@ -122,7 +124,7 @@ impl ClaimTokensScreen { status: ClaimTokensStatus::NotStarted, error_message, app_context: app_context.clone(), - show_confirmation_popup: false, + confirmation_dialog: None, selected_wallet, wallet_password: String::new(), show_password: false, @@ -181,52 +183,47 @@ impl ClaimTokensScreen { } fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - let mut is_open = true; let distribution_type = self .distribution_type .unwrap_or(TokenDistributionType::Perpetual); - egui::Window::new("Confirm Claim") - .collapsible(false) - .open(&mut is_open) - .show(ui.ctx(), |ui| { - ui.label("Are you sure you want to claim tokens for this contract?"); - ui.add_space(10.0); - // Confirm - if ui.button("Confirm").clicked() { - self.show_confirmation_popup = false; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.status = ClaimTokensStatus::WaitingForResult(now); - - action = AppAction::BackendTasks( - vec![ - BackendTask::TokenTask(Box::new(TokenTask::ClaimTokens { - data_contract: Arc::new(self.token_contract.contract.clone()), - token_position: self.identity_token_basic_info.token_position, - actor_identity: self.identity.clone(), - distribution_type, - signing_key: self.selected_key.clone().expect("No key selected"), - public_note: self.public_note.clone(), - })), - BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), - ], - BackendTasksExecutionMode::Sequential, - ); - } - - if ui.button("Cancel").clicked() { - self.show_confirmation_popup = false; - } - }); + let dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new( + "Confirm Claim".to_string(), + "Are you sure you want to claim tokens for this contract?".to_string(), + ) + }); - if !is_open { - self.show_confirmation_popup = false; + match dialog.show(ui).inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => { + self.confirmation_dialog = None; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = ClaimTokensStatus::WaitingForResult(now); + + AppAction::BackendTasks( + vec![ + BackendTask::TokenTask(Box::new(TokenTask::ClaimTokens { + data_contract: Arc::new(self.token_contract.contract.clone()), + token_position: self.identity_token_basic_info.token_position, + actor_identity: self.identity.clone(), + distribution_type, + signing_key: self.selected_key.clone().expect("No key selected"), + public_note: self.public_note.clone(), + })), + BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), + ], + BackendTasksExecutionMode::Sequential, + ) + } + Some(ConfirmationStatus::Canceled) => { + self.confirmation_dialog = None; + AppAction::None + } + None => AppAction::None, } - action } fn show_success_screen(&self, ui: &mut Ui) -> AppAction { @@ -511,13 +508,16 @@ impl ScreenLike for ClaimTokensScreen { "Please select a distribution type.".to_string(), ); return; - } else { - self.show_confirmation_popup = true; + } else if self.confirmation_dialog.is_none() { + self.confirmation_dialog = Some(ConfirmationDialog::new( + "Confirm Claim".to_string(), + "Are you sure you want to claim tokens for this contract?".to_string(), + )); } } - // If user pressed "Claim," show popup - if self.show_confirmation_popup { + // Show confirmation dialog if it exists + if self.confirmation_dialog.is_some() { action |= self.show_confirmation_popup(ui); } diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index 67da57535..cb4089170 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -1,10 +1,12 @@ use super::tokens_screen::IdentityTokenInfo; -use crate::app::{AppAction, BackendTasksExecutionMode}; +use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::component_trait::Component; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -76,8 +78,8 @@ pub struct DestroyFrozenFundsScreen { /// Basic references pub app_context: Arc, - /// Confirmation popup - show_confirmation_popup: bool, + /// Confirmation dialog + confirmation_dialog: Option, /// If password-based wallet unlocking is needed selected_wallet: Option>>, @@ -205,7 +207,7 @@ impl DestroyFrozenFundsScreen { status: DestroyFrozenFundsStatus::NotStarted, error_message, app_context: app_context.clone(), - show_confirmation_popup: false, + confirmation_dialog: None, selected_wallet, wallet_password: String::new(), show_password: false, @@ -226,100 +228,87 @@ impl DestroyFrozenFundsScreen { /// Confirmation popup fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - let mut is_open = true; - egui::Window::new("Confirm Destroy Frozen Funds") - .collapsible(false) - .open(&mut is_open) - .show(ui.ctx(), |ui| { - // Parse the user input into an Identifier - let maybe_frozen_id = Identifier::from_string_try_encodings( - &self.frozen_identity_id, - &[ - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, - dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, - ], - ); - - if maybe_frozen_id.is_err() { - self.error_message = Some("Invalid frozen identity format".into()); - self.status = DestroyFrozenFundsStatus::ErrorMessage("Invalid identity".into()); - self.show_confirmation_popup = false; - return; - } - - let frozen_id = maybe_frozen_id.unwrap(); - - ui.label(format!( - "Are you sure you want to destroy the frozen funds of identity {}?", - self.frozen_identity_id - )); - - ui.add_space(10.0); - - // Confirm button - if ui.button("Confirm").clicked() { - self.show_confirmation_popup = false; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.status = DestroyFrozenFundsStatus::WaitingForResult(now); - - // Grab the data contract for this token from the app context - let data_contract = - Arc::new(self.identity_token_info.data_contract.contract.clone()); - - let group_info = if self.group_action_id.is_some() { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( - GroupStateTransitionInfo { - group_contract_position: *pos, - action_id: self.group_action_id.unwrap(), - action_is_proposer: false, - }, - ) - }) - } else { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) - }) - }; - - // Dispatch the actual backend destroy action - action = AppAction::BackendTasks( - vec![ - BackendTask::TokenTask(Box::new(TokenTask::DestroyFrozenFunds { - actor_identity: self.identity.clone(), - data_contract, - token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("Expected a key"), - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() - }, - frozen_identity: frozen_id, - group_info, - })), - BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), - ], - BackendTasksExecutionMode::Sequential, - ); - } + let msg = format!( + "Are you sure you want to destroy frozen funds for identity {}? This action cannot be undone.", + self.frozen_identity_id + ); - // Cancel - if ui.button("Cancel").clicked() { - self.show_confirmation_popup = false; - } - }); + let confirmation_dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new("Confirm Destroy Frozen Funds", msg) + .confirm_text(Some("Destroy")) + .cancel_text(Some("Cancel")) + .danger_mode(true) + }); - if !is_open { - self.show_confirmation_popup = false; + let response = confirmation_dialog.show(ui); + match response.inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => { + self.confirmation_dialog = None; + self.confirmation_ok() + } + Some(ConfirmationStatus::Canceled) => { + self.confirmation_dialog = None; + AppAction::None + } + None => AppAction::None, } - action } + fn confirmation_ok(&mut self) -> AppAction { + let maybe_frozen_id = Identifier::from_string_try_encodings( + &self.frozen_identity_id, + &[ + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, + dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, + ], + ); + if maybe_frozen_id.is_err() { + self.error_message = Some("Invalid frozen identity format".into()); + self.status = DestroyFrozenFundsStatus::ErrorMessage("Invalid identity".into()); + return AppAction::None; + } + let frozen_id = maybe_frozen_id.unwrap(); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = DestroyFrozenFundsStatus::WaitingForResult(now); + + let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); + + let group_info = if self.group_action_id.is_some() { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: *pos, + action_id: self.group_action_id.unwrap(), + action_is_proposer: false, + }, + ) + }) + } else { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) + }) + }; + + AppAction::BackendTask(BackendTask::TokenTask(Box::new( + TokenTask::DestroyFrozenFunds { + actor_identity: self.identity.clone(), + data_contract, + token_position: self.identity_token_info.token_position, + signing_key: self.selected_key.clone().expect("No key selected"), + public_note: if self.group_action_id.is_some() { + None + } else { + self.public_note.clone() + }, + frozen_identity: frozen_id, + group_info, + }, + ))) + } /// Simple “Success” screen fn show_success_screen(&self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; @@ -585,12 +574,22 @@ impl ScreenLike for DestroyFrozenFundsScreen { .corner_radius(3.0); if ui.add(button).clicked() { - self.show_confirmation_popup = true; + // Initialize confirmation dialog when button is clicked + let msg = format!( + "Are you sure you want to destroy frozen funds for identity {}? This action cannot be undone.", + self.frozen_identity_id + ); + self.confirmation_dialog = Some( + ConfirmationDialog::new("Confirm Destroy Frozen Funds", msg) + .confirm_text(Some("Destroy")) + .cancel_text(Some("Cancel")) + .danger_mode(true), + ); } } - // If user pressed "Destroy," show a popup - if self.show_confirmation_popup { + // Show confirmation dialog if it exists + if self.confirmation_dialog.is_some() { action |= self.show_confirmation_popup(ui); } diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index 8afabb88a..37581abde 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -14,6 +14,8 @@ use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; use crate::model::wallet::Wallet; +use crate::ui::components::Component; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; @@ -53,7 +55,7 @@ pub struct PurchaseTokenScreen { pricing_fetch_attempted: bool, /// Screen stuff - show_confirmation_popup: bool, + confirmation_dialog: Option, status: PurchaseTokensStatus, error_message: Option, @@ -97,7 +99,7 @@ impl PurchaseTokenScreen { status: PurchaseTokensStatus::NotStarted, error_message: None, app_context: app_context.clone(), - show_confirmation_popup: false, + confirmation_dialog: None, selected_wallet, wallet_password: String::new(), show_password: false, @@ -188,88 +190,67 @@ impl PurchaseTokenScreen { /// Renders a confirm popup with the final "Are you sure?" step fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - let mut is_open = true; - egui::Window::new("Confirm Purchase") - .collapsible(false) - .open(&mut is_open) - .frame( - egui::Frame::default() - .fill(egui::Color32::from_rgb(245, 245, 245)) - .stroke(egui::Stroke::new( - 1.0, - egui::Color32::from_rgb(200, 200, 200), - )) - .shadow(egui::epaint::Shadow::default()) - .inner_margin(egui::Margin::same(20)) - .corner_radius(egui::CornerRadius::same(8)), - ) - .show(ui.ctx(), |ui| { - // Validate user input - let amount_ok = self.amount_to_purchase.parse::().ok(); - if amount_ok.is_none() { - self.error_message = Some("Please enter a valid amount.".into()); - self.status = PurchaseTokensStatus::ErrorMessage("Invalid amount".into()); - self.show_confirmation_popup = false; - return; - } + // Validate user input + let amount_ok = self.amount_to_purchase.parse::().ok(); + if amount_ok.is_none() { + self.error_message = Some("Please enter a valid amount.".into()); + self.status = PurchaseTokensStatus::ErrorMessage("Invalid amount".into()); + self.confirmation_dialog = None; + return AppAction::None; + } - let total_agreed_price_ok: Option = - self.total_agreed_price.parse::().ok(); - if total_agreed_price_ok.is_none() { - self.error_message = Some("Please enter a valid total agreed price.".into()); - self.status = - PurchaseTokensStatus::ErrorMessage("Invalid total agreed price".into()); - self.show_confirmation_popup = false; - return; - } + let total_agreed_price_ok: Option = self.total_agreed_price.parse::().ok(); + if total_agreed_price_ok.is_none() { + self.error_message = Some("Please enter a valid total agreed price.".into()); + self.status = PurchaseTokensStatus::ErrorMessage("Invalid total agreed price".into()); + self.confirmation_dialog = None; + return AppAction::None; + } - ui.label(format!( + let dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new( + "Confirm Purchase".to_string(), + format!( "Are you sure you want to purchase {} token(s) for {} Credits?", self.amount_to_purchase, self.total_agreed_price - )); - - ui.add_space(10.0); - - // Confirm button - if ui.button("Confirm").clicked() { - self.show_confirmation_popup = false; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.status = PurchaseTokensStatus::WaitingForResult(now); - - // Dispatch the actual backend purchase action - action = AppAction::BackendTasks( - vec![ - BackendTask::TokenTask(Box::new(TokenTask::PurchaseTokens { - identity: self.identity_token_info.identity.clone(), - data_contract: Arc::new( - self.identity_token_info.data_contract.contract.clone(), - ), - token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("Expected a key"), - amount: amount_ok.expect("Expected a valid amount"), - total_agreed_price: total_agreed_price_ok - .expect("Expected a valid total agreed price"), - })), - BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), - ], - BackendTasksExecutionMode::Sequential, - ); - } - - // Cancel button - if ui.button("Cancel").clicked() { - self.show_confirmation_popup = false; - } - }); + ), + ) + }); - if !is_open { - self.show_confirmation_popup = false; + match dialog.show(ui).inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => { + self.confirmation_dialog = None; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = PurchaseTokensStatus::WaitingForResult(now); + + // Dispatch the actual backend purchase action + AppAction::BackendTasks( + vec![ + BackendTask::TokenTask(Box::new(TokenTask::PurchaseTokens { + identity: self.identity_token_info.identity.clone(), + data_contract: Arc::new( + self.identity_token_info.data_contract.contract.clone(), + ), + token_position: self.identity_token_info.token_position, + signing_key: self.selected_key.clone().expect("Expected a key"), + amount: amount_ok.expect("Expected a valid amount"), + total_agreed_price: total_agreed_price_ok + .expect("Expected a valid total agreed price"), + })), + BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), + ], + BackendTasksExecutionMode::Sequential, + ) + } + Some(ConfirmationStatus::Canceled) => { + self.confirmation_dialog = None; + AppAction::None + } + None => AppAction::None, } - action } /// Renders a simple "Success!" screen after completion @@ -505,8 +486,15 @@ impl ScreenLike for PurchaseTokenScreen { .fill(Color32::from_rgb(0, 128, 255)) .corner_radius(3.0); - if ui.add(button).clicked() { - self.show_confirmation_popup = true; + if ui.add(button).clicked() && self.confirmation_dialog.is_none() { + // Validation will be done in show_confirmation_popup + self.confirmation_dialog = Some(ConfirmationDialog::new( + "Confirm Purchase".to_string(), + format!( + "Are you sure you want to purchase {} token(s) for {} Credits?", + self.amount_to_purchase, self.total_agreed_price + ), + )); } } else { let button = egui::Button::new( @@ -524,8 +512,8 @@ impl ScreenLike for PurchaseTokenScreen { ); } - // If the user pressed "Purchase," show a popup - if self.show_confirmation_popup { + // Show confirmation dialog if it exists + if self.confirmation_dialog.is_some() { action |= self.show_confirmation_popup(ui); } diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index 5ab4b07cf..fbaeab331 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -5,6 +5,8 @@ use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::component_trait::Component; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -64,8 +66,8 @@ pub struct FreezeTokensScreen { // Basic references pub app_context: Arc, - // Confirmation popup - show_confirmation_popup: bool, + // Confirmation dialog + confirmation_dialog: Option, // If password-based wallet unlocking is needed selected_wallet: Option>>, @@ -192,7 +194,7 @@ impl FreezeTokensScreen { status: FreezeTokensStatus::NotStarted, error_message, app_context: app_context.clone(), - show_confirmation_popup: false, + confirmation_dialog: None, selected_wallet, wallet_password: String::new(), show_password: false, @@ -215,92 +217,87 @@ impl FreezeTokensScreen { /// Confirmation popup fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - let mut is_open = true; - egui::Window::new("Confirm Freeze") - .collapsible(false) - .open(&mut is_open) - .show(ui.ctx(), |ui| { - // Validate user input - let parsed = Identifier::from_string_try_encodings( - &self.freeze_identity_id, - &[ - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, - dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, - ], - ); - if parsed.is_err() { - self.error_message = Some("Please enter a valid identity ID.".into()); - self.status = FreezeTokensStatus::ErrorMessage("Invalid identity".into()); - self.show_confirmation_popup = false; - return; - } - let freeze_id = parsed.unwrap(); - - ui.label(format!( - "Are you sure you want to freeze identity {}?", - self.freeze_identity_id - )); - - ui.add_space(10.0); + let msg = format!( + "Are you sure you want to freeze identity {}?", + self.freeze_identity_id + ); - // Confirm - if ui.button("Confirm").clicked() { - self.show_confirmation_popup = false; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.status = FreezeTokensStatus::WaitingForResult(now); - - // Grab the data contract for this token from the app context - let data_contract = - Arc::new(self.identity_token_info.data_contract.contract.clone()); - - let group_info = if self.group_action_id.is_some() { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( - GroupStateTransitionInfo { - group_contract_position: *pos, - action_id: self.group_action_id.unwrap(), - action_is_proposer: false, - }, - ) - }) - } else { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) - }) - }; - - // Dispatch to backend - action = AppAction::BackendTask(BackendTask::TokenTask(Box::new( - TokenTask::FreezeTokens { - actor_identity: self.identity.clone(), - data_contract, - token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("No key selected"), - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() - }, - freeze_identity: freeze_id, - group_info, - }, - ))); - } + let confirmation_dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new("Confirm Freeze", msg) + .confirm_text(Some("Confirm")) + .cancel_text(Some("Cancel")) + }); - // Cancel - if ui.button("Cancel").clicked() { - self.show_confirmation_popup = false; - } - }); + let response = confirmation_dialog.show(ui); + match response.inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => { + self.confirmation_dialog = None; + self.confirmation_ok() + } + Some(ConfirmationStatus::Canceled) => { + self.confirmation_dialog = None; + AppAction::None + } + None => AppAction::None, + } + } - if !is_open { - self.show_confirmation_popup = false; + /// Handle confirmation OK action + fn confirmation_ok(&mut self) -> AppAction { + // Validate user input + let parsed = Identifier::from_string_try_encodings( + &self.freeze_identity_id, + &[ + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, + dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, + ], + ); + if parsed.is_err() { + self.error_message = Some("Please enter a valid identity ID.".into()); + self.status = FreezeTokensStatus::ErrorMessage("Invalid identity".into()); + return AppAction::None; } - action + let freeze_id = parsed.unwrap(); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = FreezeTokensStatus::WaitingForResult(now); + + // Grab the data contract for this token from the app context + let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); + + let group_info = if self.group_action_id.is_some() { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: *pos, + action_id: self.group_action_id.unwrap(), + action_is_proposer: false, + }, + ) + }) + } else { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) + }) + }; + + // Dispatch to backend + AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::FreezeTokens { + actor_identity: self.identity.clone(), + data_contract, + token_position: self.identity_token_info.token_position, + signing_key: self.selected_key.clone().expect("No key selected"), + public_note: if self.group_action_id.is_some() { + None + } else { + self.public_note.clone() + }, + freeze_identity: freeze_id, + group_info, + }))) } /// Success screen @@ -561,12 +558,13 @@ impl ScreenLike for FreezeTokensScreen { .corner_radius(3.0); if ui.add(button).clicked() { - self.show_confirmation_popup = true; + // Initialize confirmation dialog when button is clicked + self.confirmation_dialog = None; // Reset for fresh dialog } } - // If user pressed "Freeze," show popup - if self.show_confirmation_popup { + // Show confirmation dialog if it exists + if self.confirmation_dialog.is_some() { action |= self.show_confirmation_popup(ui); } diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index 4a90e8aff..d7c93053f 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -7,13 +7,14 @@ use crate::model::amount::Amount; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::components::{Component, ComponentResponse}; use crate::ui::contracts_documents::group_actions_screen::GroupActionsScreen; use crate::ui::helpers::{TransactionType, add_identity_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; @@ -32,7 +33,6 @@ use dash_sdk::dpp::data_contract::group::accessors::v0::GroupV0Getters; use dash_sdk::dpp::group::{GroupStateTransitionInfo, GroupStateTransitionInfoStatus}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; -use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{Identifier, IdentityPublicKey}; use eframe::egui::{self, Color32, Context, Ui}; use egui::RichText; @@ -70,7 +70,7 @@ pub struct MintTokensScreen { pub app_context: Arc, /// Confirmation popup - show_confirmation_popup: bool, + confirmation_dialog: Option, // If needed for password-based wallet unlocking: selected_wallet: Option>>, @@ -199,7 +199,7 @@ impl MintTokensScreen { status: MintTokensStatus::NotStarted, error_message, app_context: app_context.clone(), - show_confirmation_popup: false, + confirmation_dialog: None, selected_wallet, wallet_password: String::new(), show_password: false, @@ -245,114 +245,93 @@ impl MintTokensScreen { /// Renders a confirm popup with the final "Are you sure?" step fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - let mut is_open = true; - egui::Window::new("Confirm Mint") - .collapsible(false) - .open(&mut is_open) - .show(ui.ctx(), |ui| { - // Validate user input - let Some(amount) = &self.amount else { - self.error_message = Some("Please enter a valid amount.".into()); - self.status = MintTokensStatus::ErrorMessage("Invalid amount".into()); - self.show_confirmation_popup = false; - return; - }; - - let maybe_identifier = if self.recipient_identity_id.trim().is_empty() { - None - } else { - // Attempt to parse from base58 or hex - match Identifier::from_string_try_encodings( - &self.recipient_identity_id, - &[Encoding::Base58, Encoding::Hex], - ) { - Ok(id) => Some(id), - Err(_) => { - self.error_message = Some("Invalid recipient identity format.".into()); - self.status = - MintTokensStatus::ErrorMessage("Invalid recipient identity".into()); - self.show_confirmation_popup = false; - return; - } - } - }; - - ui.label(format!( - "Are you sure you want to mint {} token(s)?", - amount - )); + let msg = format!( + "Are you sure you want to mint {} tokens to {}?", + self.amount.clone().unwrap_or(Amount::new(0, 0)), + self.recipient_identity_id + ); - // If user provided a recipient: - if let Some(ref recipient_id) = maybe_identifier { - ui.label(format!( - "Recipient: {}", - recipient_id.to_string(Encoding::Base58) - )); - } else { - ui.label("No recipient specified; tokens will be minted to default identity."); - } + let confirmation_dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new("Confirm Mint", msg) + .confirm_text(Some("Mint")) + .cancel_text(Some("Cancel")) + }); - ui.add_space(10.0); + let response = confirmation_dialog.show(ui); + match response.inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => { + self.confirmation_dialog = None; + self.confirmation_ok() + } + Some(ConfirmationStatus::Canceled) => { + self.confirmation_dialog = None; + AppAction::None + } + None => AppAction::None, + } + } - // Confirm button - if ui.button("Confirm").clicked() { - self.show_confirmation_popup = false; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.status = MintTokensStatus::WaitingForResult(now); - - let group_info = if self.group_action_id.is_some() { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( - GroupStateTransitionInfo { - group_contract_position: *pos, - action_id: self.group_action_id.unwrap(), - action_is_proposer: false, - }, - ) - }) - } else { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) - }) - }; - - // Dispatch the actual backend mint action - action = AppAction::BackendTask(BackendTask::TokenTask(Box::new( - TokenTask::MintTokens { - sending_identity: self.identity_token_info.identity.clone(), - data_contract: Arc::new( - self.identity_token_info.data_contract.contract.clone(), - ), - token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("Expected a key"), - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() - }, - amount: amount.value(), - recipient_id: maybe_identifier, - group_info, - }, - ))); - } + fn confirmation_ok(&mut self) -> AppAction { + if self.amount.is_none() || self.amount == Some(Amount::new(0, 0)) { + self.status = MintTokensStatus::ErrorMessage("Invalid amount".into()); + self.error_message = Some("Invalid amount".into()); + return AppAction::None; + } - // Cancel button - if ui.button("Cancel").clicked() { - self.show_confirmation_popup = false; - } - }); + let parsed_receiver_id = Identifier::from_string_try_encodings( + &self.recipient_identity_id, + &[ + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, + dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, + ], + ); - if !is_open { - self.show_confirmation_popup = false; + if parsed_receiver_id.is_err() { + self.status = MintTokensStatus::ErrorMessage("Invalid receiver".into()); + self.error_message = Some("Invalid receiver".into()); + return AppAction::None; } - action - } + let receiver_id = parsed_receiver_id.unwrap(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = MintTokensStatus::WaitingForResult(now); + + let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); + + let group_info = if self.group_action_id.is_some() { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: *pos, + action_id: self.group_action_id.unwrap(), + action_is_proposer: false, + }, + ) + }) + } else { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) + }) + }; + + AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::MintTokens { + sending_identity: self.identity_token_info.identity.clone(), + data_contract, + token_position: self.identity_token_info.token_position, + signing_key: self.selected_key.clone().expect("No key selected"), + public_note: if self.group_action_id.is_some() { + None + } else { + self.public_note.clone() + }, + recipient_id: Some(receiver_id), + amount: self.amount.clone().unwrap_or(Amount::new(0, 0)).value(), + group_info, + }))) + } /// Renders a simple "Success!" screen after completion fn show_success_screen(&self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; @@ -658,12 +637,21 @@ impl ScreenLike for MintTokensScreen { .corner_radius(3.0); if ui.add(button).clicked() { - self.show_confirmation_popup = true; + let msg = format!( + "Are you sure you want to mint {} tokens to {}?", + self.amount.clone().unwrap_or(Amount::new(0, 0)), + self.recipient_identity_id + ); + self.confirmation_dialog = Some( + ConfirmationDialog::new("Confirm Mint", msg) + .confirm_text(Some("Mint")) + .cancel_text(Some("Cancel")), + ); } } // If the user pressed "Mint," show a popup - if self.show_confirmation_popup { + if self.confirmation_dialog.is_some() { action |= self.show_confirmation_popup(ui); } diff --git a/src/ui/tokens/pause_tokens_screen.rs b/src/ui/tokens/pause_tokens_screen.rs index f6ed54f7d..95e43b4f8 100644 --- a/src/ui/tokens/pause_tokens_screen.rs +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -5,6 +5,8 @@ use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::Component; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; @@ -59,7 +61,7 @@ pub struct PauseTokensScreen { pub app_context: Arc, // Confirmation popup - show_confirmation_popup: bool, + confirmation_dialog: Option, // If password-based wallet unlocking is needed selected_wallet: Option>>, @@ -181,7 +183,7 @@ impl PauseTokensScreen { status: PauseTokensStatus::NotStarted, error_message, app_context: app_context.clone(), - show_confirmation_popup: false, + confirmation_dialog: None, selected_wallet, wallet_password: String::new(), show_password: false, @@ -189,69 +191,61 @@ impl PauseTokensScreen { } fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - let mut is_open = true; - egui::Window::new("Confirm Pause") - .collapsible(false) - .open(&mut is_open) - .show(ui.ctx(), |ui| { - ui.label("Are you sure you want to pause token transfers for this contract?"); - ui.add_space(10.0); + let dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new( + "Confirm Pause".to_string(), + "Are you sure you want to pause token transfers for this contract?".to_string(), + ) + }); - // Confirm - if ui.button("Confirm").clicked() { - self.show_confirmation_popup = false; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.status = PauseTokensStatus::WaitingForResult(now); - - // Grab the data contract for this token from the app context - let data_contract = - Arc::new(self.identity_token_info.data_contract.contract.clone()); - - let group_info = if self.group_action_id.is_some() { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( - GroupStateTransitionInfo { - group_contract_position: *pos, - action_id: self.group_action_id.unwrap(), - action_is_proposer: false, - }, - ) - }) - } else { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) - }) - }; - - action = AppAction::BackendTask(BackendTask::TokenTask(Box::new( - TokenTask::PauseTokens { - actor_identity: self.identity.clone(), - data_contract, - token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("No key selected"), - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() + match dialog.show(ui).inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => { + self.confirmation_dialog = None; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = PauseTokensStatus::WaitingForResult(now); + + // Grab the data contract for this token from the app context + let data_contract = + Arc::new(self.identity_token_info.data_contract.contract.clone()); + + let group_info = if self.group_action_id.is_some() { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: *pos, + action_id: self.group_action_id.unwrap(), + action_is_proposer: false, }, - group_info, - }, - ))); - } - - if ui.button("Cancel").clicked() { - self.show_confirmation_popup = false; - } - }); - - if !is_open { - self.show_confirmation_popup = false; + ) + }) + } else { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) + }) + }; + + AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::PauseTokens { + actor_identity: self.identity.clone(), + data_contract, + token_position: self.identity_token_info.token_position, + signing_key: self.selected_key.clone().expect("No key selected"), + public_note: if self.group_action_id.is_some() { + None + } else { + self.public_note.clone() + }, + group_info, + }))) + } + Some(ConfirmationStatus::Canceled) => { + self.confirmation_dialog = None; + AppAction::None + } + None => AppAction::None, } - action } fn show_success_screen(&self, ui: &mut Ui) -> AppAction { @@ -491,13 +485,17 @@ impl ScreenLike for PauseTokensScreen { .fill(Color32::from_rgb(0, 128, 255)) .corner_radius(3.0); - if ui.add(button).clicked() { - self.show_confirmation_popup = true; + if ui.add(button).clicked() && self.confirmation_dialog.is_none() { + self.confirmation_dialog = Some(ConfirmationDialog::new( + "Confirm Pause".to_string(), + "Are you sure you want to pause token transfers for this contract?" + .to_string(), + )); } } - // If user pressed "Pause," show popup - if self.show_confirmation_popup { + // Show confirmation dialog if it exists + if self.confirmation_dialog.is_some() { action |= self.show_confirmation_popup(ui); } diff --git a/src/ui/tokens/resume_tokens_screen.rs b/src/ui/tokens/resume_tokens_screen.rs index 5155985ab..70b4b2acd 100644 --- a/src/ui/tokens/resume_tokens_screen.rs +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -5,6 +5,8 @@ use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::Component; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; @@ -58,7 +60,7 @@ pub struct ResumeTokensScreen { pub app_context: Arc, // Confirmation popup - show_confirmation_popup: bool, + confirmation_dialog: Option, // If password-based wallet unlocking is needed selected_wallet: Option>>, @@ -180,7 +182,7 @@ impl ResumeTokensScreen { status: ResumeTokensStatus::NotStarted, error_message, app_context: app_context.clone(), - show_confirmation_popup: false, + confirmation_dialog: None, selected_wallet, wallet_password: String::new(), show_password: false, @@ -188,69 +190,62 @@ impl ResumeTokensScreen { } fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - let mut is_open = true; - egui::Window::new("Confirm Resume") - .collapsible(false) - .open(&mut is_open) - .show(ui.ctx(), |ui| { - ui.label("Are you sure you want to resume normal token actions for this contract?"); - ui.add_space(10.0); + let dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new( + "Confirm Resume".to_string(), + "Are you sure you want to resume normal token actions for this contract?" + .to_string(), + ) + }); - // Confirm - if ui.button("Confirm").clicked() { - self.show_confirmation_popup = false; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.status = ResumeTokensStatus::WaitingForResult(now); - - // Grab the data contract for this token from the app context - let data_contract = - Arc::new(self.identity_token_info.data_contract.contract.clone()); - - let group_info = if self.group_action_id.is_some() { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( - GroupStateTransitionInfo { - group_contract_position: *pos, - action_id: self.group_action_id.unwrap(), - action_is_proposer: false, - }, - ) - }) - } else { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) - }) - }; - - action = AppAction::BackendTask(BackendTask::TokenTask(Box::new( - TokenTask::ResumeTokens { - actor_identity: self.identity.clone(), - data_contract, - token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("No key selected"), - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() + match dialog.show(ui).inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => { + self.confirmation_dialog = None; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = ResumeTokensStatus::WaitingForResult(now); + + // Grab the data contract for this token from the app context + let data_contract = + Arc::new(self.identity_token_info.data_contract.contract.clone()); + + let group_info = if self.group_action_id.is_some() { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: *pos, + action_id: self.group_action_id.unwrap(), + action_is_proposer: false, }, - group_info, - }, - ))); - } - - if ui.button("Cancel").clicked() { - self.show_confirmation_popup = false; - } - }); - - if !is_open { - self.show_confirmation_popup = false; + ) + }) + } else { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) + }) + }; + + AppAction::BackendTask(BackendTask::TokenTask(Box::new(TokenTask::ResumeTokens { + actor_identity: self.identity.clone(), + data_contract, + token_position: self.identity_token_info.token_position, + signing_key: self.selected_key.clone().expect("No key selected"), + public_note: if self.group_action_id.is_some() { + None + } else { + self.public_note.clone() + }, + group_info, + }))) + } + Some(ConfirmationStatus::Canceled) => { + self.confirmation_dialog = None; + AppAction::None + } + None => AppAction::None, } - action } fn show_success_screen(&self, ui: &mut Ui) -> AppAction { @@ -491,13 +486,16 @@ impl ScreenLike for ResumeTokensScreen { .fill(Color32::from_rgb(0, 128, 255)) .corner_radius(3.0); - if ui.add(button).clicked() { - self.show_confirmation_popup = true; + if ui.add(button).clicked() && self.confirmation_dialog.is_none() { + self.confirmation_dialog = Some(ConfirmationDialog::new( + "Confirm Resume".to_string(), + "Are you sure you want to resume normal token actions for this contract?".to_string(), + )); } } - // If user pressed "Resume," show popup - if self.show_confirmation_popup { + // Show confirmation dialog if it exists + if self.confirmation_dialog.is_some() { action |= self.show_confirmation_popup(ui); } diff --git a/src/ui/tokens/tokens_screen/data_contract_json_pop_up.rs b/src/ui/tokens/tokens_screen/data_contract_json_pop_up.rs index 00ed9d364..80d5521ac 100644 --- a/src/ui/tokens/tokens_screen/data_contract_json_pop_up.rs +++ b/src/ui/tokens/tokens_screen/data_contract_json_pop_up.rs @@ -1,3 +1,4 @@ +use crate::ui::theme::{ComponentStyles, DashColors, Shape}; use crate::ui::tokens::tokens_screen::TokensScreen; use egui::Ui; @@ -6,18 +7,53 @@ impl TokensScreen { pub(super) fn render_data_contract_json_popup(&mut self, ui: &mut Ui) { if self.show_json_popup { let mut is_open = true; + + // Draw dark overlay behind the dialog for better visibility + let screen_rect = ui.ctx().screen_rect(); + let painter = ui.ctx().layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("json_popup_overlay"), + )); + painter.rect_filled( + screen_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), // Semi-transparent black overlay + ); + egui::Window::new("Data Contract JSON") .collapsible(false) .resizable(true) .max_height(600.0) .max_width(800.0) .scroll(true) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .open(&mut is_open) + .frame(egui::Frame { + inner_margin: egui::Margin::same(16), + outer_margin: egui::Margin::same(0), + corner_radius: egui::CornerRadius::same(8), + shadow: egui::epaint::Shadow { + offset: [0, 8], + blur: 16, + spread: 0, + color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), + }, + fill: ui.style().visuals.window_fill, + stroke: egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), + ), + }) .show(ui.ctx(), |ui| { // Display the JSON in a multiline text box - ui.add_space(4.0); - ui.label("Below is the data contract JSON:"); - ui.add_space(4.0); + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.add_space(10.0); + ui.label( + egui::RichText::new("Below is the data contract JSON:") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(10.0); egui::Resize::default() .id_salt("json_resize_area_for_contract") @@ -32,12 +68,29 @@ impl TokensScreen { }); }); - ui.add_space(10.0); + ui.add_space(20.0); + + // Close button styled like ConfirmationDialog + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let close_button = egui::Button::new( + egui::RichText::new("Close") + .color(ComponentStyles::secondary_button_text()), + ) + .fill(ComponentStyles::secondary_button_fill()) + .stroke(ComponentStyles::secondary_button_stroke()) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_SM)) + .min_size(egui::Vec2::new(80.0, 32.0)); - // A button to close - if ui.button("Close").clicked() { - self.show_json_popup = false; - } + if ui + .add(close_button) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + self.show_json_popup = false; + } + }); + }); }); // If the user closed the window via the "x" in the corner diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 62c293789..5bf8c7f7c 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -60,6 +60,7 @@ use crate::model::amount::Amount; use crate::model::qualified_identity::{IdentityType, QualifiedIdentity}; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; @@ -1048,8 +1049,10 @@ pub struct TokensScreen { // Remove token confirm_remove_identity_token_balance_popup: bool, identity_token_balance_to_remove: Option, + remove_identity_token_balance_confirmation_dialog: Option, confirm_remove_token_popup: bool, token_to_remove: Option, + remove_token_confirmation_dialog: Option, // Reward explanations reward_explanations: IndexMap, @@ -1080,6 +1083,7 @@ pub struct TokensScreen { start_as_paused_input: bool, main_control_group_input: String, show_token_creator_confirmation_popup: bool, + token_creator_confirmation_dialog: Option, token_creator_status: TokenCreatorStatus, token_creator_error_message: Option, show_advanced_keeps_history: bool, @@ -1401,8 +1405,10 @@ impl TokensScreen { // Remove token confirm_remove_identity_token_balance_popup: false, identity_token_balance_to_remove: None, + remove_identity_token_balance_confirmation_dialog: None, confirm_remove_token_popup: false, token_to_remove: None, + remove_token_confirmation_dialog: None, // Reward explanations reward_explanations: IndexMap::new(), @@ -1418,6 +1424,7 @@ impl TokensScreen { wallet_password: String::new(), show_password: false, show_token_creator_confirmation_popup: false, + token_creator_confirmation_dialog: None, token_creator_status: TokenCreatorStatus::NotStarted, token_creator_error_message: None, token_names_input: vec![( @@ -2389,20 +2396,28 @@ impl TokensScreen { } }; - let mut is_open = true; - - egui::Window::new("Confirm Stop Tracking Balance") - .collapsible(false) - .open(&mut is_open) - .show(ui.ctx(), |ui| { - ui.label(format!( + // Lazy initialization of the confirmation dialog + let confirmation_dialog = self + .remove_identity_token_balance_confirmation_dialog + .get_or_insert_with(|| { + ConfirmationDialog::new( + "Confirm Stop Tracking Balance", + format!( "Are you sure you want to stop tracking the token \"{}\" for identity \"{}\"?", token_to_remove.token_alias, token_to_remove.identity_id.to_string(Encoding::Base58) - )); + ), + ) + .confirm_text(Some("Confirm")) + .cancel_text(Some("Cancel")) + }); + + // Show the dialog and handle the response + let response = confirmation_dialog.show(ui).inner; - // Confirm button - if ui.button("Confirm").clicked() { + if let Some(status) = response.dialog_response { + match status { + ConfirmationStatus::Confirmed => { if let Err(e) = self .app_context .remove_token_balance(token_to_remove.token_id, token_to_remove.identity_id) @@ -2412,26 +2427,19 @@ impl TokensScreen { MessageType::Error, Utc::now(), )); - self.confirm_remove_identity_token_balance_popup = false; - self.identity_token_balance_to_remove = None; } else { - self.confirm_remove_identity_token_balance_popup = false; - self.identity_token_balance_to_remove = None; self.refresh(); - }; + } + self.confirm_remove_identity_token_balance_popup = false; + self.identity_token_balance_to_remove = None; + self.remove_identity_token_balance_confirmation_dialog = None; } - - // Cancel button - if ui.button("Cancel").clicked() { + ConfirmationStatus::Canceled => { self.confirm_remove_identity_token_balance_popup = false; self.identity_token_balance_to_remove = None; + self.remove_identity_token_balance_confirmation_dialog = None; } - }); - - // If user closes the popup window (the [x] button), also reset state - if !is_open { - self.confirm_remove_identity_token_balance_popup = false; - self.identity_token_balance_to_remove = None; + } } } @@ -2452,48 +2460,48 @@ impl TokensScreen { .map(|t| t.token_name.clone()) .unwrap_or_else(|| token_to_remove.to_string(Encoding::Base58)); - let mut is_open = true; - - egui::Window::new("Confirm Remove Token") - .collapsible(false) - .open(&mut is_open) - .show(ui.ctx(), |ui| { - ui.label(format!( + // Lazy initialization of the confirmation dialog + let confirmation_dialog = self.remove_token_confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new( + "Confirm Remove Token", + format!( "Are you sure you want to stop tracking the token \"{}\"? You can re-add it later. Your actual token balance will not change with this action.", token_name, - )); - - // Confirm button - if ui.button("Confirm").clicked() { - if let Err(e) = self.app_context.db.remove_token( - &token_to_remove, - &self.app_context, - ) { + ), + ) + .confirm_text(Some("Confirm")) + .cancel_text(Some("Cancel")) + }); + + // Show the dialog and handle the response + let response = confirmation_dialog.show(ui).inner; + + if let Some(status) = response.dialog_response { + match status { + ConfirmationStatus::Confirmed => { + if let Err(e) = self + .app_context + .db + .remove_token(&token_to_remove, &self.app_context) + { self.backend_message = Some(( format!("Error removing token balance: {}", e), MessageType::Error, Utc::now(), )); - self.confirm_remove_token_popup = false; - self.token_to_remove = None; } else { - self.confirm_remove_token_popup = false; - self.token_to_remove = None; self.refresh(); } + self.confirm_remove_token_popup = false; + self.token_to_remove = None; + self.remove_token_confirmation_dialog = None; } - - // Cancel button - if ui.button("Cancel").clicked() { + ConfirmationStatus::Canceled => { self.confirm_remove_token_popup = false; self.token_to_remove = None; + self.remove_token_confirmation_dialog = None; } - }); - - // If user closes the popup window (the [x] button), also reset state - if !is_open { - self.confirm_remove_token_popup = false; - self.token_to_remove = None; + } } } diff --git a/src/ui/tokens/tokens_screen/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index b6937ae9d..1e2644a55 100644 --- a/src/ui/tokens/tokens_screen/token_creator.rs +++ b/src/ui/tokens/tokens_screen/token_creator.rs @@ -18,6 +18,8 @@ use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::ui::components::styled::{StyledCheckbox}; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; +use crate::ui::components::Component; use crate::ui::helpers::{add_identity_key_chooser, TransactionType}; use crate::ui::tokens::tokens_screen::{TokenBuildArgs, TokenCreatorStatus, TokenNameLanguage, TokensScreen, ChangeControlRulesUI}; @@ -1051,65 +1053,66 @@ impl TokensScreen { /// Shows a popup "Are you sure?" for creating the token contract fn render_token_creator_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; - let mut is_open = true; - - egui::Window::new("Confirm Token Contract Registration") - .collapsible(false) - .open(&mut is_open) - .show(ui.ctx(), |ui| { - ui.label( - "Are you sure you want to register a new token contract with these settings?\n", - ); - let base_supply_display = self - .base_supply_amount - .as_ref() - .map(|amount| amount.to_string_opts(true, false)) - .unwrap_or_else(|| "0".to_string()); - let max_supply_display = self - .max_supply_amount - .as_ref() - .filter(|amount| amount.value() > 0) - .map(|amount| amount.to_string_opts(true, false)) - .unwrap_or_else(|| "None".to_string()); - ui.label(format!( - "Name: {}\nBase Supply: {}\nMax Supply: {}", - self.token_names_input[0].0, base_supply_display, max_supply_display, - )); - - ui.add_space(10.0); - ui.label(format!( - "Estimated cost to register this token is {} Dash", - self.estimate_registration_cost() as f64 / 100_000_000_000.0 - )); + // Prepare the confirmation message + let mut confirmation_message = + "Are you sure you want to register a new token contract with these settings?\n\n" + .to_string(); + let base_supply_display = self + .base_supply_amount + .as_ref() + .map(|amount| amount.to_string_opts(true, false)) + .unwrap_or_else(|| "0".to_string()); + let max_supply_display = self + .max_supply_amount + .as_ref() + .filter(|amount| amount.value() > 0) + .map(|amount| amount.to_string_opts(true, false)) + .unwrap_or_else(|| "None".to_string()); + + confirmation_message.push_str(&format!( + "Name: {}\nBase Supply: {}\nMax Supply: {}\n\n", + self.token_names_input[0].0, base_supply_display, max_supply_display, + )); + + confirmation_message.push_str(&format!( + "Estimated cost to register this token is {} Dash", + self.estimate_registration_cost() as f64 / 100_000_000_000.0 + )); + + // Check if marketplace is locked to NotTradeable forever + let mut is_danger_mode = false; + if let Some(args) = &self.cached_build_args { + let is_not_tradeable = args.marketplace_trade_mode == 0; + let marketplace_rules_locked = matches!( + args.marketplace_rules, + ChangeControlRules::V0(ChangeControlRulesV0 { + authorized_to_make_change: AuthorizedActionTakers::NoOne, + admin_action_takers: AuthorizedActionTakers::NoOne, + .. + }) + ); - ui.add_space(10.0); - - // Check if marketplace is locked to NotTradeable forever - if let Some(args) = &self.cached_build_args { - let is_not_tradeable = args.marketplace_trade_mode == 0; - let marketplace_rules_locked = matches!( - args.marketplace_rules, - ChangeControlRules::V0(ChangeControlRulesV0 { - authorized_to_make_change: AuthorizedActionTakers::NoOne, - admin_action_takers: AuthorizedActionTakers::NoOne, - .. - }) - ); + if is_not_tradeable && marketplace_rules_locked { + confirmation_message.push_str("\n\nWARNING: This token will be permanently set to NotTradeable and can NEVER be made tradeable in the future!"); + is_danger_mode = true; + } + } - if is_not_tradeable && marketplace_rules_locked { - ui.colored_label( - Color32::DARK_RED, - "WARNING: This token will be permanently set to NotTradeable and can NEVER be made tradeable in the future!" - ); - ui.add_space(10.0); - } - } + // Always create a fresh confirmation dialog to ensure current state is reflected + let confirmation_dialog = self.token_creator_confirmation_dialog.insert( + ConfirmationDialog::new("Confirm Token Contract Registration", confirmation_message) + .confirm_text(Some("Confirm")) + .cancel_text(Some("Cancel")) + .danger_mode(is_danger_mode), + ); - ui.add_space(10.0); + // Show the dialog and handle the response + let response = confirmation_dialog.show(ui).inner; - // Confirm - if ui.button("Confirm").clicked() { + if let Some(status) = response.dialog_response { + match status { + ConfirmationStatus::Confirmed => { let args = match &self.cached_build_args { Some(args) => args.clone(), None => { @@ -1119,8 +1122,8 @@ impl TokensScreen { Err(err) => { self.token_creator_error_message = Some(err); self.show_token_creator_confirmation_popup = false; - action = AppAction::None; - return; + self.token_creator_confirmation_dialog = None; + return AppAction::None; } } } @@ -1168,17 +1171,15 @@ impl TokensScreen { self.show_token_creator_confirmation_popup = false; let now = Utc::now().timestamp() as u64; self.token_creator_status = TokenCreatorStatus::WaitingForResult(now); + self.show_token_creator_confirmation_popup = false; + self.token_creator_confirmation_dialog = None; } - - // Cancel - if ui.button("Cancel").clicked() { + ConfirmationStatus::Canceled => { self.show_token_creator_confirmation_popup = false; + self.token_creator_confirmation_dialog = None; action = AppAction::None; } - }); - - if !is_open { - self.show_token_creator_confirmation_popup = false; + } } action diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 3426ce36e..41effdd87 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -1,4 +1,4 @@ -use crate::app::{AppAction, BackendTasksExecutionMode}; +use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; @@ -6,13 +6,14 @@ use crate::model::amount::Amount; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::components::{Component, ComponentResponse}; use crate::ui::helpers::{TransactionType, add_identity_key_chooser}; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; @@ -20,7 +21,6 @@ use crate::ui::theme::DashColors; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; -use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::TimestampMillis; use dash_sdk::platform::{Identifier, IdentityPublicKey}; use eframe::egui::{self, Context, Ui}; @@ -53,7 +53,7 @@ pub struct TransferTokensScreen { transfer_tokens_status: TransferTokensStatus, max_amount: Amount, pub app_context: Arc, - confirmation_popup: bool, + confirmation_dialog: Option, selected_wallet: Option>>, wallet_password: String, show_password: bool, @@ -99,7 +99,7 @@ impl TransferTokensScreen { transfer_tokens_status: TransferTokensStatus::NotStarted, max_amount, app_context: app_context.clone(), - confirmation_popup: false, + confirmation_dialog: None, selected_wallet, wallet_password: String::new(), show_password: false, @@ -159,91 +159,79 @@ impl TransferTokensScreen { } fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let mut app_action = AppAction::None; - let mut is_open = true; - egui::Window::new("Confirm Transfer") - .collapsible(false) - .open(&mut is_open) - .show(ui.ctx(), |ui| { - let identifier = if self.receiver_identity_id.is_empty() { - self.transfer_tokens_status = - TransferTokensStatus::ErrorMessage("Invalid identifier".to_string()); - self.confirmation_popup = false; - return; - } else { - match Identifier::from_string_try_encodings( - &self.receiver_identity_id, - &[Encoding::Base58, Encoding::Hex], - ) { - Ok(identifier) => identifier, - Err(_) => { - self.transfer_tokens_status = TransferTokensStatus::ErrorMessage( - "Invalid identifier".to_string(), - ); - self.confirmation_popup = false; - return; - } - } - }; - - if self.selected_key.is_none() { - self.transfer_tokens_status = - TransferTokensStatus::ErrorMessage("No selected key".to_string()); - self.confirmation_popup = false; - return; - }; - - ui.label(format!( - "Are you sure you want to transfer {} {} to {}?", - self.amount.as_ref().expect("Amount should be set"), - self.identity_token_balance.token_alias, - self.receiver_identity_id - )); - - if ui.button("Confirm").clicked() { - self.confirmation_popup = false; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.transfer_tokens_status = TransferTokensStatus::WaitingForResult(now); - let data_contract = Arc::new( - self.app_context - .get_unqualified_contract_by_id( - &self.identity_token_balance.data_contract_id, - ) - .expect("Contracts not loaded") - .expect("Data contract not found"), - ); - app_action |= AppAction::BackendTasks( - vec![ - BackendTask::TokenTask(Box::new(TokenTask::TransferTokens { - sending_identity: self.identity.clone(), - recipient_id: identifier, - amount: { - // Use the amount value directly - self.amount.as_ref().expect("Amount should be set").value() - }, - data_contract, - token_position: self.identity_token_balance.token_position, - signing_key: self.selected_key.clone().expect("Expected a key"), - public_note: self.public_note.clone(), - })), - BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), - ], - BackendTasksExecutionMode::Sequential, - ); - } - if ui.button("Cancel").clicked() { - self.confirmation_popup = false; - } - }); - if !is_open { - self.confirmation_popup = false; + let msg = format!( + "Are you sure you want to transfer {} tokens to {}?", + self.amount.clone().unwrap_or(Amount::new(0, 0)), + self.receiver_identity_id + ); + + let confirmation_dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new("Confirm Transfer", msg) + .confirm_text(Some("Transfer")) + .cancel_text(Some("Cancel")) + }); + + let response = confirmation_dialog.show(ui); + match response.inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => { + self.confirmation_dialog = None; + self.confirmation_ok() + } + Some(ConfirmationStatus::Canceled) => { + self.confirmation_dialog = None; + AppAction::None + } + None => AppAction::None, } - app_action } + fn confirmation_ok(&mut self) -> AppAction { + if self.amount.is_none() || self.amount == Some(Amount::new(0, 0)) { + self.transfer_tokens_status = + TransferTokensStatus::ErrorMessage("Invalid amount".into()); + return AppAction::None; + } + + let parsed_receiver_id = Identifier::from_string_try_encodings( + &self.receiver_identity_id, + &[ + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, + dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, + ], + ); + + if parsed_receiver_id.is_err() { + self.transfer_tokens_status = + TransferTokensStatus::ErrorMessage("Invalid receiver".into()); + return AppAction::None; + } + + let receiver_id = parsed_receiver_id.unwrap(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.transfer_tokens_status = TransferTokensStatus::WaitingForResult(now); + + let data_contract = Arc::new( + self.app_context + .get_unqualified_contract_by_id(&self.identity_token_balance.data_contract_id) + .expect("Failed to get data contract") + .expect("Data contract not found"), + ); + + AppAction::BackendTask(BackendTask::TokenTask(Box::new( + TokenTask::TransferTokens { + sending_identity: self.identity.clone(), + recipient_id: receiver_id, + amount: self.amount.clone().unwrap_or(Amount::new(0, 0)).value(), + data_contract, + token_position: self.identity_token_balance.token_position, + signing_key: self.selected_key.clone().expect("No key selected"), + public_note: self.public_note.clone(), + }, + ))) + } pub fn show_success(&self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; @@ -481,11 +469,20 @@ impl ScreenLike for TransferTokensScreen { "Amount must be greater than zero".to_string(), ); } else { - self.confirmation_popup = true; + let msg = format!( + "Are you sure you want to transfer {} tokens to {}?", + self.amount.clone().unwrap_or(Amount::new(0, 0)), + self.receiver_identity_id + ); + self.confirmation_dialog = Some( + ConfirmationDialog::new("Confirm Transfer", msg) + .confirm_text(Some("Transfer")) + .cancel_text(Some("Cancel")), + ); } } - if self.confirmation_popup { + if self.confirmation_dialog.is_some() { return self.show_confirmation_popup(ui); } diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index 9f0d12138..7e29ae721 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -5,6 +5,8 @@ use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::component_trait::Component; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -68,8 +70,8 @@ pub struct UnfreezeTokensScreen { // Basic references pub app_context: Arc, - // Confirmation popup - show_confirmation_popup: bool, + // Confirmation dialog + confirmation_dialog: Option, // If password-based wallet unlocking is needed selected_wallet: Option>>, @@ -197,7 +199,7 @@ impl UnfreezeTokensScreen { status: UnfreezeTokensStatus::NotStarted, error_message, app_context: app_context.clone(), - show_confirmation_popup: false, + confirmation_dialog: None, selected_wallet, wallet_password: String::new(), show_password: false, @@ -218,92 +220,88 @@ impl UnfreezeTokensScreen { } fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - let mut is_open = true; - egui::Window::new("Confirm Unfreeze") - .collapsible(false) - .open(&mut is_open) - .show(ui.ctx(), |ui| { - // Validate user input - let parsed = Identifier::from_string_try_encodings( - &self.unfreeze_identity_id, - &[ - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, - dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, - ], - ); - if parsed.is_err() { - self.error_message = Some("Please enter a valid identity ID.".into()); - self.status = UnfreezeTokensStatus::ErrorMessage("Invalid identity ID".into()); - self.show_confirmation_popup = false; - return; - } - let unfreeze_id = parsed.unwrap(); - - ui.label(format!( - "Are you sure you want to unfreeze identity {}?", - self.unfreeze_identity_id - )); - - ui.add_space(10.0); + let msg = format!( + "Are you sure you want to unfreeze identity {}?", + self.unfreeze_identity_id + ); - // Confirm - if ui.button("Confirm").clicked() { - self.show_confirmation_popup = false; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.status = UnfreezeTokensStatus::WaitingForResult(now); - - // Grab the data contract for this token from the app context - let data_contract = - Arc::new(self.identity_token_info.data_contract.contract.clone()); - - let group_info = if self.group_action_id.is_some() { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( - GroupStateTransitionInfo { - group_contract_position: *pos, - action_id: self.group_action_id.unwrap(), - action_is_proposer: false, - }, - ) - }) - } else { - self.group.as_ref().map(|(pos, _)| { - GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) - }) - }; - - // Dispatch to backend - action |= AppAction::BackendTask(BackendTask::TokenTask(Box::new( - TokenTask::UnfreezeTokens { - actor_identity: self.identity.clone(), - data_contract, - token_position: self.identity_token_info.token_position, - signing_key: self.selected_key.clone().expect("No key selected"), - public_note: if self.group_action_id.is_some() { - None - } else { - self.public_note.clone() - }, - unfreeze_identity: unfreeze_id, - group_info, - }, - ))); - } + let confirmation_dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new("Confirm Unfreeze", msg) + .confirm_text(Some("Confirm")) + .cancel_text(Some("Cancel")) + }); - // Cancel - if ui.button("Cancel").clicked() { - self.show_confirmation_popup = false; - } - }); + let response = confirmation_dialog.show(ui); + match response.inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => { + self.confirmation_dialog = None; + self.confirmation_ok() + } + Some(ConfirmationStatus::Canceled) => { + self.confirmation_dialog = None; + AppAction::None + } + None => AppAction::None, + } + } - if !is_open { - self.show_confirmation_popup = false; + fn confirmation_ok(&mut self) -> AppAction { + // Validate user input + let parsed = Identifier::from_string_try_encodings( + &self.unfreeze_identity_id, + &[ + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, + dash_sdk::dpp::platform_value::string_encoding::Encoding::Hex, + ], + ); + if parsed.is_err() { + self.error_message = Some("Please enter a valid identity ID.".into()); + self.status = UnfreezeTokensStatus::ErrorMessage("Invalid identity ID".into()); + return AppAction::None; } - action + let unfreeze_id = parsed.unwrap(); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.status = UnfreezeTokensStatus::WaitingForResult(now); + + // Grab the data contract for this token from the app context + let data_contract = Arc::new(self.identity_token_info.data_contract.contract.clone()); + + let group_info = if self.group_action_id.is_some() { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoOtherSigner( + GroupStateTransitionInfo { + group_contract_position: *pos, + action_id: self.group_action_id.unwrap(), + action_is_proposer: false, + }, + ) + }) + } else { + self.group.as_ref().map(|(pos, _)| { + GroupStateTransitionInfoStatus::GroupStateTransitionInfoProposer(*pos) + }) + }; + + // Dispatch to backend + AppAction::BackendTask(BackendTask::TokenTask(Box::new( + TokenTask::UnfreezeTokens { + actor_identity: self.identity.clone(), + data_contract, + token_position: self.identity_token_info.token_position, + signing_key: self.selected_key.clone().expect("No key selected"), + public_note: if self.group_action_id.is_some() { + None + } else { + self.public_note.clone() + }, + unfreeze_identity: unfreeze_id, + group_info, + }, + ))) } fn show_success_screen(&self, ui: &mut Ui) -> AppAction { @@ -564,12 +562,21 @@ impl ScreenLike for UnfreezeTokensScreen { .corner_radius(3.0); if ui.add(button).clicked() { - self.show_confirmation_popup = true; + // Initialize confirmation dialog when button is clicked + let msg = format!( + "Are you sure you want to unfreeze identity {}?", + self.unfreeze_identity_id + ); + self.confirmation_dialog = Some( + ConfirmationDialog::new("Confirm Unfreeze", msg) + .confirm_text(Some("Confirm")) + .cancel_text(Some("Cancel")), + ); } } - // If user pressed "Unfreeze," show popup - if self.show_confirmation_popup { + // Show confirmation dialog if it exists + if self.confirmation_dialog.is_some() { action |= self.show_confirmation_popup(ui); } From f1b31d7c4fb42b26b610e61f26d66ba63d2fb707 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Thu, 21 Aug 2025 14:51:50 +0700 Subject: [PATCH 017/106] fix: confirmation dialog on withdrawal screen always showing "loading" --- src/ui/identities/withdraw_screen.rs | 10 +- src/ui/tokens/tokens_screen/my_tokens.rs | 294 ++++++++++++----------- 2 files changed, 150 insertions(+), 154 deletions(-) diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index 85159e3ca..0be9a7f0d 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -474,14 +474,8 @@ impl ScreenLike for WithdrawalScreen { .clicked() && self.confirmation_dialog.is_none() { - // Validation will be done in show_confirmation_popup - self.confirmation_dialog = Some( - ConfirmationDialog::new( - "Confirm Withdrawal".to_string(), - "Loading...".to_string(), // Will be updated in show_confirmation_popup - ) - .danger_mode(true), - ); + // Create dialog directly in show_confirmation_popup with correct message + inner_action |= self.show_confirmation_popup(ui); } if self.confirmation_dialog.is_some() { diff --git a/src/ui/tokens/tokens_screen/my_tokens.rs b/src/ui/tokens/tokens_screen/my_tokens.rs index cad02080c..a6d0bac44 100644 --- a/src/ui/tokens/tokens_screen/my_tokens.rs +++ b/src/ui/tokens/tokens_screen/my_tokens.rs @@ -589,60 +589,62 @@ impl TokensScreen { ) -> AppAction { let mut pos = 0; let mut action = AppAction::None; - ui.spacing_mut().item_spacing.x = 5.0; - - if range.contains(&pos) { - if itb.available_actions.can_transfer { - if let Some(balance) = itb.balance { - // Transfer - if ui.button("Transfer").clicked() { - action = AppAction::AddScreen(Screen::TransferTokensScreen( - TransferTokensScreen::new( - itb.to_token_balance(balance), - &self.app_context, - ), - )); - } + ui.spacing_mut().item_spacing.x = 5.0; + + if range.contains(&pos) { + if itb.available_actions.can_transfer { + if let Some(balance) = itb.balance { + // Transfer + if ui.button("Transfer").clicked() { + action = AppAction::AddScreen(Screen::TransferTokensScreen( + TransferTokensScreen::new( + itb.to_token_balance(balance), + &self.app_context, + ), + )); } - } else { - // Disabled, grayed-out Transfer button - ui.add_enabled( - false, - egui::Button::new(RichText::new("Transfer").color(Color32::GRAY)), - ) - .on_hover_text("Transfer not available"); } + } else { + // Disabled, grayed-out Transfer button + ui.add_enabled( + false, + egui::Button::new(RichText::new("Transfer").color(Color32::GRAY)), + ) + .on_hover_text("Transfer not available"); } + } - pos += 1; + pos += 1; - // Claim - if itb.available_actions.can_claim { - if range.contains(&pos) && ui.button("Claim").clicked() { - match self.app_context.get_contract_by_token_id(&itb.token_id) { - Ok(Some(contract)) => { - action = AppAction::AddScreen(Screen::ClaimTokensScreen(ClaimTokensScreen::new( + // Claim + if itb.available_actions.can_claim { + if range.contains(&pos) && ui.button("Claim").clicked() { + match self.app_context.get_contract_by_token_id(&itb.token_id) { + Ok(Some(contract)) => { + action = AppAction::AddScreen(Screen::ClaimTokensScreen( + ClaimTokensScreen::new( itb.into(), contract, token_info.token_configuration.clone(), &self.app_context, - ))); - ui.close_kind(egui::UiKind::Menu); - } - Ok(None) => { - self.set_error_message(Some("Token contract not found".to_string())); - } - Err(e) => { - self.set_error_message(Some(format!("Error fetching token contract: {e}"))); - } + ), + )); + ui.close_kind(egui::UiKind::Menu); + } + Ok(None) => { + self.set_error_message(Some("Token contract not found".to_string())); + } + Err(e) => { + self.set_error_message(Some(format!("Error fetching token contract: {e}"))); } } - pos += 1; } + pos += 1; + } - if itb.available_actions.can_mint { - if range.contains(&pos) && ui.button("Mint").clicked() { - match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { + if itb.available_actions.can_mint { + if range.contains(&pos) && ui.button("Mint").clicked() { + match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( Screen::MintTokensScreen( @@ -658,13 +660,13 @@ impl TokensScreen { } }; - ui.close_kind(egui::UiKind::Menu); - } - pos += 1; + ui.close_kind(egui::UiKind::Menu); } - if itb.available_actions.can_burn { - if range.contains(&pos) && ui.button("Burn").clicked() { - match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { + pos += 1; + } + if itb.available_actions.can_burn { + if range.contains(&pos) && ui.button("Burn").clicked() { + match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( Screen::BurnTokensScreen( @@ -679,13 +681,13 @@ impl TokensScreen { self.set_error_message(Some(e)); } }; - ui.close_kind(egui::UiKind::Menu); - } - pos += 1; + ui.close_kind(egui::UiKind::Menu); } - if itb.available_actions.can_freeze { - if range.contains(&pos) && ui.button("Freeze").clicked() { - match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { + pos += 1; + } + if itb.available_actions.can_freeze { + if range.contains(&pos) && ui.button("Freeze").clicked() { + match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( Screen::FreezeTokensScreen( @@ -700,13 +702,13 @@ impl TokensScreen { self.set_error_message(Some(e)); } }; - ui.close_kind(egui::UiKind::Menu); - } - pos += 1; + ui.close_kind(egui::UiKind::Menu); } - if itb.available_actions.can_destroy { - if range.contains(&pos) && ui.button("Destroy Frozen Identity Tokens").clicked() { - match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { + pos += 1; + } + if itb.available_actions.can_destroy { + if range.contains(&pos) && ui.button("Destroy Frozen Identity Tokens").clicked() { + match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( Screen::DestroyFrozenFundsScreen( @@ -721,13 +723,13 @@ impl TokensScreen { self.set_error_message(Some(e)); } }; - ui.close_kind(egui::UiKind::Menu); - } - pos += 1; + ui.close_kind(egui::UiKind::Menu); } - if itb.available_actions.can_unfreeze { - if range.contains(&pos) && ui.button("Unfreeze").clicked() { - match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { + pos += 1; + } + if itb.available_actions.can_unfreeze { + if range.contains(&pos) && ui.button("Unfreeze").clicked() { + match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( Screen::UnfreezeTokensScreen( @@ -742,14 +744,14 @@ impl TokensScreen { self.set_error_message(Some(e)); } }; - ui.close_kind(egui::UiKind::Menu); - } - pos += 1; + ui.close_kind(egui::UiKind::Menu); } - if itb.available_actions.can_do_emergency_action { - if range.contains(&pos) { - if ui.button("Pause").clicked() { - match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { + pos += 1; + } + if itb.available_actions.can_do_emergency_action { + if range.contains(&pos) { + if ui.button("Pause").clicked() { + match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( Screen::PauseTokensScreen( @@ -764,14 +766,14 @@ impl TokensScreen { self.set_error_message(Some(e)); } }; - ui.close_kind(egui::UiKind::Menu); - } - pos += 1; + ui.close_kind(egui::UiKind::Menu); } + pos += 1; + } - if range.contains(&pos) { - if ui.button("Resume").clicked() { - match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { + if range.contains(&pos) { + if ui.button("Resume").clicked() { + match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( Screen::ResumeTokensScreen( @@ -786,23 +788,23 @@ impl TokensScreen { self.set_error_message(Some(e)); } }; - ui.close_kind(egui::UiKind::Menu); - } - pos += 1; - } - } - if itb.available_actions.can_claim { - if range.contains(&pos) && ui.button("View Claims").clicked() { - action = AppAction::AddScreen(Screen::ViewTokenClaimsScreen( - ViewTokenClaimsScreen::new(itb.into(), &self.app_context), - )); - ui.close_kind(egui::UiKind::Menu); + ui.close_kind(egui::UiKind::Menu); } pos += 1; } - if itb.available_actions.can_update_config { - if range.contains(&pos) && ui.button("Update Config").clicked() { - match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { + } + if itb.available_actions.can_claim { + if range.contains(&pos) && ui.button("View Claims").clicked() { + action = AppAction::AddScreen(Screen::ViewTokenClaimsScreen( + ViewTokenClaimsScreen::new(itb.into(), &self.app_context), + )); + ui.close_kind(egui::UiKind::Menu); + } + pos += 1; + } + if itb.available_actions.can_update_config { + if range.contains(&pos) && ui.button("Update Config").clicked() { + match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( Screen::UpdateTokenConfigScreen(Box::new( @@ -817,50 +819,50 @@ impl TokensScreen { self.set_error_message(Some(e)); } }; - ui.close_kind(egui::UiKind::Menu); - } - pos += 1; + ui.close_kind(egui::UiKind::Menu); } - if itb.available_actions.can_maybe_purchase { - if range.contains(&pos) { - // Check if we have pricing data - let has_pricing_data = self.token_pricing_data.contains_key(&itb.token_id); - let is_loading = self - .pricing_loading_state - .get(&itb.token_id) - .copied() + pos += 1; + } + if itb.available_actions.can_maybe_purchase { + if range.contains(&pos) { + // Check if we have pricing data + let has_pricing_data = self.token_pricing_data.contains_key(&itb.token_id); + let is_loading = self + .pricing_loading_state + .get(&itb.token_id) + .copied() + .unwrap_or(false); + + if is_loading { + // Show loading spinner + ui.add(egui::Spinner::new()); + } else if has_pricing_data { + // Check if identity has enough credits for at least one token + let has_credits = self + .app_context + .get_identity_by_id(&itb.identity_id) + .map(|identity_opt| { + identity_opt + .map(|identity| { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + // Check if identity has enough credits for the minimum token price + if let Some(Some(pricing)) = + self.token_pricing_data.get(&itb.token_id) + { + let min_price = get_min_token_price(pricing); + identity.identity.balance() >= min_price + } else { + false + } + }) + .unwrap_or(false) + }) .unwrap_or(false); - if is_loading { - // Show loading spinner - ui.add(egui::Spinner::new()); - } else if has_pricing_data { - // Check if identity has enough credits for at least one token - let has_credits = self - .app_context - .get_identity_by_id(&itb.identity_id) - .map(|identity_opt| { - identity_opt - .map(|identity| { - use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - // Check if identity has enough credits for the minimum token price - if let Some(Some(pricing)) = - self.token_pricing_data.get(&itb.token_id) - { - let min_price = get_min_token_price(pricing); - identity.identity.balance() >= min_price - } else { - false - } - }) - .unwrap_or(false) - }) - .unwrap_or(false); - - if has_credits { - // Purchase button enabled - if ui.button("Purchase").clicked() { - match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { + if has_credits { + // Purchase button enabled + if ui.button("Purchase").clicked() { + match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( Screen::PurchaseTokenScreen( @@ -875,11 +877,11 @@ impl TokensScreen { self.set_error_message(Some(e)); } }; - ui.close_kind(egui::UiKind::Menu); - } - } else { - // Disabled, grayed-out Purchase button - ui.add_enabled( + ui.close_kind(egui::UiKind::Menu); + } + } else { + // Disabled, grayed-out Purchase button + ui.add_enabled( false, egui::Button::new(RichText::new("Purchase").color(egui::Color32::GRAY)), ) @@ -891,15 +893,15 @@ impl TokensScreen { "No credits available for purchase".to_string() } }); - } } } - pos += 1; } - if itb.available_actions.can_set_price && range.contains(&pos) { - // Set Price - if ui.button("Set Price").clicked() { - match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { + pos += 1; + } + if itb.available_actions.can_set_price && range.contains(&pos) { + // Set Price + if ui.button("Set Price").clicked() { + match IdentityTokenInfo::try_from_identity_token_maybe_balance_with_actions_with_lookup(itb, &self.app_context) { Ok(info) => { action = AppAction::AddScreen( Screen::SetTokenPriceScreen( @@ -915,9 +917,9 @@ impl TokensScreen { } }; - ui.close_kind(egui::UiKind::Menu); - } + ui.close_kind(egui::UiKind::Menu); } + } action } From d6126c67187c9c38520a43bc0012d12c40fa971a Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Fri, 19 Sep 2025 18:07:46 +0700 Subject: [PATCH 018/106] feat: Dash Masternode List and Quorum Viewer (#428) * a lot of work on DML viewer * more work * more work * more work * more work * more work * more work * more work * more work * ui improvements * ui improvements * optimizations * fast start * more work * more work * more work * fmt * much more work * fixes * updates * fix * fmt * revert * update for dashcore 40 * params for testnet * added testnet diff * fixes * clippy * more clippy * fixes * clean UI * use backend tasks * backend messages * coderabbit suggestions to add timeouts and prevent infinite loops * transient and fatal errors * update dash core configs for testnet * fmt * fix timeout * fix h-3 error * clear state when switching networks --------- Co-authored-by: pauldelucia --- .github/workflows/clippy.yml | 2 +- CLAUDE.md | 2 +- Cargo.lock | 1044 ++-- Cargo.toml | 5 +- artifacts/mn_list_diff_0_2227096.bin | Bin 0 -> 523465 bytes artifacts/mn_list_diff_testnet_0_1296600.bin | Bin 0 -> 124106 bytes dash_core_configs/mainnet.conf | 2 +- dash_core_configs/testnet.conf | 2 +- rust-toolchain.toml | 2 +- snap/snapcraft.yaml | 2 +- src/app.rs | 49 +- src/backend_task/core/mod.rs | 6 +- src/backend_task/identity/load_identity.rs | 87 +- .../identity/load_identity_from_wallet.rs | 2 +- src/backend_task/identity/mod.rs | 2 +- src/backend_task/mnlist.rs | 128 + src/backend_task/mod.rs | 26 + src/backend_task/tokens/burn_tokens.rs | 46 +- src/backend_task/tokens/claim_tokens.rs | 47 +- src/backend_task/tokens/mint_tokens.rs | 50 +- src/backend_task/tokens/query_tokens.rs | 16 +- src/backend_task/tokens/set_token_price.rs | 5 +- src/backend_task/tokens/transfer_tokens.rs | 132 +- src/components/core_p2p_handler.rs | 474 ++ src/components/core_zmq_listener.rs | 40 +- src/components/mod.rs | 1 + src/context.rs | 2 +- src/database/contracts.rs | 43 +- src/database/wallet.rs | 2 +- .../encrypted_key_storage.rs | 4 +- src/model/qualified_identity/mod.rs | 2 +- src/model/wallet/asset_lock_transaction.rs | 2 +- src/model/wallet/mod.rs | 14 +- src/ui/components/amount_input.rs | 30 +- src/ui/components/contract_chooser_panel.rs | 105 +- src/ui/components/identity_selector.rs | 14 +- .../tools_subscreen_chooser_panel.rs | 73 +- .../contracts_documents_screen.rs | 20 +- .../document_action_screen.rs | 10 +- .../group_actions_screen.rs | 14 +- .../register_contract_screen.rs | 18 +- .../update_contract_screen.rs | 18 +- src/ui/dpns/dpns_contested_names_screen.rs | 28 +- src/ui/helpers.rs | 34 +- .../identities/add_new_identity_screen/mod.rs | 49 +- src/ui/identities/identities_screen.rs | 49 +- .../identities/top_up_identity_screen/mod.rs | 49 +- src/ui/mod.rs | 34 +- src/ui/network_chooser_screen.rs | 54 +- src/ui/tokens/add_token_by_id_screen.rs | 42 +- src/ui/tokens/burn_tokens_screen.rs | 31 +- src/ui/tokens/claim_tokens_screen.rs | 9 +- src/ui/tokens/destroy_frozen_funds_screen.rs | 31 +- src/ui/tokens/direct_token_purchase_screen.rs | 9 +- src/ui/tokens/freeze_tokens_screen.rs | 31 +- src/ui/tokens/mint_tokens_screen.rs | 31 +- src/ui/tokens/pause_tokens_screen.rs | 31 +- src/ui/tokens/resume_tokens_screen.rs | 31 +- src/ui/tokens/set_token_price_screen.rs | 31 +- src/ui/tokens/tokens_screen/distributions.rs | 22 +- src/ui/tokens/tokens_screen/mod.rs | 79 +- src/ui/tokens/unfreeze_tokens_screen.rs | 31 +- src/ui/tokens/update_token_config.rs | 18 +- src/ui/tools/masternode_list_diff_screen.rs | 4407 +++++++++++++++++ src/ui/tools/mod.rs | 1 + src/ui/tools/transition_visualizer_screen.rs | 25 +- src/ui/wallets/add_new_wallet_screen.rs | 4 +- src/ui/wallets/import_wallet_screen.rs | 19 +- src/ui/wallets/wallets_screen/mod.rs | 4 +- 69 files changed, 6423 insertions(+), 1304 deletions(-) create mode 100644 artifacts/mn_list_diff_0_2227096.bin create mode 100644 artifacts/mn_list_diff_testnet_0_1296600.bin create mode 100644 src/backend_task/mnlist.rs create mode 100644 src/components/core_p2p_handler.rs create mode 100644 src/ui/tools/masternode_list_diff_screen.rs diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index ba81213f4..47348b757 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -34,7 +34,7 @@ jobs: - name: Install Rust toolchain uses: actions-rs/toolchain@v1 with: - toolchain: 1.88 + toolchain: 1.89 components: clippy override: true diff --git a/CLAUDE.md b/CLAUDE.md index 799f3a55b..63839fe89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ cross build --target x86_64-pc-windows-gnu --release ## Development Environment Setup ### Prerequisites -1. **Rust**: Version 1.88+ (enforced by rust-toolchain.toml) +1. **Rust**: Version 1.89+ (enforced by rust-toolchain.toml) 2. **System Dependencies** (Ubuntu): `build-essential libssl-dev pkg-config unzip` 3. **Protocol Buffers**: protoc v25.2+ required for dash-sdk 4. **Dash Core Wallet**: Must be synced for full functionality diff --git a/Cargo.lock b/Cargo.lock index 8c22ce4af..7b0b0ddb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "ab_glyph" -version = "0.2.29" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3672c180e71eeaaac3a541fbbc5f5ad4def8b747c595ad30d674e43049f7b0" +checksum = "e074464580a518d16a7126262fffaaa47af89d4099d4cb403f8ed938ba12ee7d" dependencies = [ "ab_glyph_rasterizer", "owned_ttf_parser", @@ -14,9 +14,9 @@ dependencies = [ [[package]] name = "ab_glyph_rasterizer" -version = "0.1.8" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71b1793ee61086797f5c80b6efa2b8ffa6d5dd703f118545808a7f2e27f7046" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "accesskit" @@ -49,7 +49,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bfae7c152994a31dc7d99b8eeac7784a919f71d1b306f4b83217e110fd3824c" dependencies = [ "accesskit", - "hashbrown 0.15.4", + "hashbrown 0.15.5", ] [[package]] @@ -60,7 +60,7 @@ checksum = "692dd318ff8a7a0ffda67271c4bd10cf32249656f4e49390db0b26ca92b095f2" dependencies = [ "accesskit", "accesskit_consumer", - "hashbrown 0.15.4", + "hashbrown 0.15.5", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -74,7 +74,7 @@ checksum = "c5f7474c36606d0fe4f438291d667bae7042ea2760f506650ad2366926358fc8" dependencies = [ "accesskit", "accesskit_atspi_common", - "async-channel 2.3.1", + "async-channel 2.5.0", "async-executor", "async-task", "atspi", @@ -92,7 +92,7 @@ checksum = "70a042b62c9c05bf7b616f015515c17d2813f3ba89978d6f4fc369735d60700a" dependencies = [ "accesskit", "accesskit_consumer", - "hashbrown 0.15.4", + "hashbrown 0.15.5", "static_assertions", "windows", "windows-core", @@ -208,7 +208,7 @@ dependencies = [ "ndk", "ndk-context", "ndk-sys", - "num_enum 0.7.3", + "num_enum 0.7.4", "thiserror 1.0.69", ] @@ -235,9 +235,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -265,29 +265,29 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "arbitrary" @@ -389,7 +389,7 @@ dependencies = [ "enumflags2", "futures-channel", "futures-util", - "rand 0.9.1", + "rand 0.9.2", "raw-window-handle", "serde", "serde_repr", @@ -406,7 +406,7 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "event-listener 5.4.0", + "event-listener 5.4.1", "event-listener-strategy", "futures-core", "pin-project-lite", @@ -425,9 +425,9 @@ dependencies = [ [[package]] name = "async-channel" -version = "2.3.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ "concurrent-queue", "event-listener-strategy", @@ -451,9 +451,9 @@ dependencies = [ [[package]] name = "async-fs" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" +checksum = "09f7e37c0ed80b2a977691c47dae8625cfb21e205827106c64f7c588766b2e50" dependencies = [ "async-lock", "blocking", @@ -466,7 +466,7 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-executor", "async-io", "async-lock", @@ -477,9 +477,9 @@ dependencies = [ [[package]] name = "async-io" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1237c0ae75a0f3765f58910ff9cdd0a12eeb39ab2f4c7de23262f337f0aacbb3" +checksum = "19634d6336019ef220f09fd31168ce5c184b295cbf80345437cc36094ef223ca" dependencies = [ "async-lock", "cfg-if", @@ -488,19 +488,18 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.0.7", + "rustix 1.0.8", "slab", - "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "async-lock" -version = "3.4.0" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" dependencies = [ - "event-listener 5.4.0", + "event-listener 5.4.1", "event-listener-strategy", "pin-project-lite", ] @@ -518,21 +517,20 @@ dependencies = [ [[package]] name = "async-process" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cde3f4e40e6021d7acffc90095cbd6dc54cb593903d1de5832f435eb274b85dc" +checksum = "65daa13722ad51e6ab1a1b9c01299142bc75135b337923cfa10e79bbbd669f00" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-io", "async-lock", "async-signal", "async-task", "blocking", "cfg-if", - "event-listener 5.4.0", + "event-listener 5.4.1", "futures-lite", - "rustix 1.0.7", - "tracing", + "rustix 1.0.8", ] [[package]] @@ -543,14 +541,14 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] name = "async-signal" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7605a4e50d4b06df3898d5a70bf5fde51ed9059b0434b73105193bc27acce0d" +checksum = "f567af260ef69e1d52c2b560ce0ea230763e6fbb9214a85d768760a920e3e3c1" dependencies = [ "async-io", "async-lock", @@ -558,10 +556,10 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.0.7", + "rustix 1.0.8", "signal-hook-registry", "slab", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -604,7 +602,7 @@ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -678,15 +676,15 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "backon" -version = "1.5.1" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "302eaff5357a264a2c42f127ecb8bac761cf99749fc3dc95677e2743991f99e7" +checksum = "592277618714fbcecda9a02ba7a8781f319d26532a88553bbacc77ba5d2b3a8d" dependencies = [ "fastrand", "tokio", @@ -713,6 +711,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals 0.3.0", + "bitcoin_hashes 0.14.0", +] + [[package]] name = "base64" version = "0.13.1" @@ -786,6 +794,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "unicode-normalization", + "zeroize", ] [[package]] @@ -824,6 +833,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" + [[package]] name = "bitcoin-io" version = "0.1.3" @@ -836,7 +851,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" dependencies = [ - "bitcoin-internals", + "bitcoin-internals 0.2.0", "hex-conservative 0.1.2", ] @@ -928,11 +943,11 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.1" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ - "async-channel 2.3.1", + "async-channel 2.5.0", "async-task", "futures-io", "futures-lite", @@ -942,8 +957,7 @@ dependencies = [ [[package]] name = "blsful" version = "3.0.0-pre8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384e5e9866cb7f830f06a6633ba998697d5a826e99e8c78376deaadd33cda7be" +source = "git+https://github.com/dashpay/agora-blsful?rev=be108b2cf6ac64eedbe04f91c63731533c8956bc#be108b2cf6ac64eedbe04f91c63731533c8956bc" dependencies = [ "anyhow", "blstrs_plus", @@ -959,7 +973,7 @@ dependencies = [ "sha2", "sha3", "subtle", - "thiserror 2.0.12", + "thiserror 2.0.14", "uint-zigzag", "vsss-rs", "zeroize", @@ -1006,28 +1020,28 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.18.1" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytemuck" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" +checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.9.3" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ecc273b49b3205b83d648f0690daa588925572cc5063745bfe547fe7ec8e1a1" +checksum = "4f154e572231cb6ba2bd1176980827e3d5dc04cc183a75dea38109fbdd672d29" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -1079,9 +1093,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.26" +version = "1.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac" +checksum = "2352e5597e9c544d5e6d9c95190d5d27738ade584fa8db0a16e130e5c2b5296e" dependencies = [ "jobserver", "libc", @@ -1210,9 +1224,9 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" dependencies = [ "error-code", ] @@ -1335,9 +1349,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -1400,9 +1414,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crunchy" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" @@ -1467,13 +1481,13 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] name = "dapi-grpc" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "dapi-grpc-macros", "futures-core", @@ -1485,17 +1499,18 @@ dependencies = [ "serde_json", "tenderdash-proto", "tonic", - "tonic-build", + "tonic-prost", + "tonic-prost-build", ] [[package]] name = "dapi-grpc-macros" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "heck", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -1533,7 +1548,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -1544,7 +1559,20 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.103", + "syn 2.0.104", +] + +[[package]] +name = "dash-context-provider" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +dependencies = [ + "dpp", + "drive", + "hex", + "serde", + "serde_json", + "thiserror 1.0.69", ] [[package]] @@ -1582,6 +1610,7 @@ dependencies = [ "native-dialog", "nix", "qrcode", + "rand 0.8.5", "raw-cpuid", "regex", "rfd", @@ -1592,7 +1621,7 @@ dependencies = [ "serde_yaml", "sha2", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.14", "tokio", "tokio-util", "tracing", @@ -1605,10 +1634,21 @@ dependencies = [ "zxcvbn", ] +[[package]] +name = "dash-network" +version = "0.40.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=befd0356bebfcd0d06d1028d8a03bfa4c78bd219#befd0356bebfcd0d06d1028d8a03bfa4c78bd219" +dependencies = [ + "bincode", + "bincode_derive", + "hex", + "serde", +] + [[package]] name = "dash-sdk" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "arc-swap", "async-trait", @@ -1618,7 +1658,7 @@ dependencies = [ "ciborium", "dapi-grpc", "dapi-grpc-macros", - "dashcore-rpc", + "dash-context-provider", "derive_more 1.0.0", "dotenvy", "dpp", @@ -1628,12 +1668,13 @@ dependencies = [ "futures", "hex", "http", + "js-sys", "lru", "rs-dapi-client", "rustls-pemfile", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.14", "tokio", "tokio-util", "tracing", @@ -1642,35 +1683,39 @@ dependencies = [ [[package]] name = "dashcore" -version = "0.39.6" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.39.6#51df58f5d5d499f5ee80ab17076ff70b5347c7db" +version = "0.40.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=befd0356bebfcd0d06d1028d8a03bfa4c78bd219#befd0356bebfcd0d06d1028d8a03bfa4c78bd219" dependencies = [ "anyhow", "base64-compat", "bech32", - "bitflags 2.9.1", + "bincode", + "bincode_derive", + "bitvec", "blake3", "blsful", + "dash-network", "dashcore-private", "dashcore_hashes", "ed25519-dalek", "hex", "hex_lit", + "log", "rustversion", "secp256k1", "serde", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] name = "dashcore-private" -version = "0.39.6" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.39.6#51df58f5d5d499f5ee80ab17076ff70b5347c7db" +version = "0.40.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=befd0356bebfcd0d06d1028d8a03bfa4c78bd219#befd0356bebfcd0d06d1028d8a03bfa4c78bd219" [[package]] name = "dashcore-rpc" -version = "0.39.6" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.39.6#51df58f5d5d499f5ee80ab17076ff70b5347c7db" +version = "0.40.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=befd0356bebfcd0d06d1028d8a03bfa4c78bd219#befd0356bebfcd0d06d1028d8a03bfa4c78bd219" dependencies = [ "dashcore-rpc-json", "hex", @@ -1682,12 +1727,13 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" -version = "0.39.6" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.39.6#51df58f5d5d499f5ee80ab17076ff70b5347c7db" +version = "0.40.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=befd0356bebfcd0d06d1028d8a03bfa4c78bd219#befd0356bebfcd0d06d1028d8a03bfa4c78bd219" dependencies = [ "bincode", "dashcore", "hex", + "key-wallet", "serde", "serde_json", "serde_repr", @@ -1696,9 +1742,10 @@ dependencies = [ [[package]] name = "dashcore_hashes" -version = "0.39.6" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.39.6#51df58f5d5d499f5ee80ab17076ff70b5347c7db" +version = "0.40.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=befd0356bebfcd0d06d1028d8a03bfa4c78bd219#befd0356bebfcd0d06d1028d8a03bfa4c78bd219" dependencies = [ + "bincode", "dashcore-private", "secp256k1", "serde", @@ -1719,19 +1766,19 @@ dependencies = [ [[package]] name = "dashpay-contract" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] name = "data-contracts" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "dashpay-contract", "dpns-contract", @@ -1741,7 +1788,7 @@ dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.14", "token-history-contract", "wallet-utils-contract", "withdrawals-contract", @@ -1775,7 +1822,7 @@ checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -1796,7 +1843,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -1806,7 +1853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -1835,7 +1882,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", "unicode-xid", ] @@ -1847,7 +1894,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -1928,7 +1975,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -1969,19 +2016,19 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] name = "dpp" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "anyhow", "async-trait", @@ -1994,17 +2041,19 @@ dependencies = [ "chrono-tz", "ciborium", "dashcore", + "dashcore-rpc", "data-contracts", "derive_more 1.0.0", "env_logger", "getrandom 0.2.16", "hex", - "indexmap 2.9.0", + "indexmap 2.10.0", "integer-encoding", "itertools 0.13.0", + "key-wallet", "lazy_static", "nohash-hasher", - "num_enum 0.7.3", + "num_enum 0.7.4", "once_cell", "platform-serialization", "platform-serialization-derive", @@ -2018,13 +2067,14 @@ dependencies = [ "serde_repr", "sha2", "strum", - "thiserror 2.0.12", + "thiserror 2.0.14", + "tracing", ] [[package]] name = "drive" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "bincode", "byteorder", @@ -2036,34 +2086,35 @@ dependencies = [ "grovedb-path", "grovedb-version", "hex", - "indexmap 2.9.0", + "indexmap 2.10.0", "integer-encoding", "nohash-hasher", "platform-version", "serde", "sqlparser", - "thiserror 2.0.12", + "thiserror 2.0.14", "tracing", ] [[package]] name = "drive-proof-verifier" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "bincode", "dapi-grpc", + "dash-context-provider", "derive_more 1.0.0", "dpp", "drive", "hex", - "indexmap 2.9.0", + "indexmap 2.10.0", "platform-serialization", "platform-serialization-derive", "serde", "serde_json", "tenderdash-abci", - "thiserror 2.0.12", + "thiserror 2.0.14", "tracing", ] @@ -2111,9 +2162,9 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", @@ -2374,7 +2425,7 @@ checksum = "a1ab991c1362ac86c61ab6f556cff143daa22e5a15e4e189df818b2fd19fe65b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -2394,7 +2445,7 @@ checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -2415,7 +2466,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -2426,7 +2477,7 @@ checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -2500,12 +2551,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2522,9 +2573,9 @@ checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -2537,7 +2588,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.4.0", + "event-listener 5.4.1", "pin-project-lite", ] @@ -2581,13 +2632,13 @@ dependencies = [ [[package]] name = "feature-flags-contract" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] @@ -2620,18 +2671,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] -[[package]] -name = "flex-error" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c606d892c9de11507fa0dcffc116434f94e105d0bbdc4e405b61519464c49d7b" -dependencies = [ - "paste", -] - [[package]] name = "fnv" version = "1.0.7" @@ -2671,7 +2714,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -2763,9 +2806,9 @@ checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-lite" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ "fastrand", "futures-core", @@ -2782,7 +2825,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -2900,9 +2943,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "gloo-timers" @@ -3010,8 +3053,7 @@ dependencies = [ [[package]] name = "grovedb" version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "611077565b279965fa34897787ae52f79471f0476db785116cceb92077f237ad" +source = "git+https://github.com/dashpay/grovedb?rev=1ecedf530fbc5b5e12edf1bc607bd288c187ddde#1ecedf530fbc5b5e12edf1bc607bd288c187ddde" dependencies = [ "bincode", "bincode_derive", @@ -3022,42 +3064,39 @@ dependencies = [ "grovedb-version", "hex", "hex-literal", - "indexmap 2.9.0", + "indexmap 2.10.0", "integer-encoding", "reqwest", "sha2", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] name = "grovedb-costs" version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ab159c3f82b0387f6a27a54930b18aa594b507013de947c8e909cf61abb75fe" +source = "git+https://github.com/dashpay/grovedb?rev=1ecedf530fbc5b5e12edf1bc607bd288c187ddde#1ecedf530fbc5b5e12edf1bc607bd288c187ddde" dependencies = [ "integer-encoding", "intmap", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] name = "grovedb-epoch-based-storage-flags" version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dce2f34c6bfddb3a26696b42e6169f986330513e0e9f4c5d7ba290d09867a5e" +source = "git+https://github.com/dashpay/grovedb?rev=1ecedf530fbc5b5e12edf1bc607bd288c187ddde#1ecedf530fbc5b5e12edf1bc607bd288c187ddde" dependencies = [ "grovedb-costs", "hex", "integer-encoding", "intmap", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] name = "grovedb-merk" version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4580e54da0031d2f36e50312f3361005099bceeb8adb0f6ccbf87a0880cd1b08" +source = "git+https://github.com/dashpay/grovedb?rev=1ecedf530fbc5b5e12edf1bc607bd288c187ddde#1ecedf530fbc5b5e12edf1bc607bd288c187ddde" dependencies = [ "bincode", "bincode_derive", @@ -3069,16 +3108,15 @@ dependencies = [ "grovedb-version", "grovedb-visualize", "hex", - "indexmap 2.9.0", + "indexmap 2.10.0", "integer-encoding", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] name = "grovedb-path" version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d61e09bb3055358974ceb65b91752064979450092014d91a6bc4a52d77887ea" +source = "git+https://github.com/dashpay/grovedb?rev=1ecedf530fbc5b5e12edf1bc607bd288c187ddde#1ecedf530fbc5b5e12edf1bc607bd288c187ddde" dependencies = [ "hex", ] @@ -3086,18 +3124,16 @@ dependencies = [ [[package]] name = "grovedb-version" version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d61d27c76d49758b365a9e4a9da7f995f976b9525626bf645aef258024defd2" +source = "git+https://github.com/dashpay/grovedb?rev=1ecedf530fbc5b5e12edf1bc607bd288c187ddde#1ecedf530fbc5b5e12edf1bc607bd288c187ddde" dependencies = [ - "thiserror 2.0.12", + "thiserror 2.0.14", "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] name = "grovedb-visualize" version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaebfe3c1e5f263f14fd25ab060543b31eb4b9d6bdc44fe220e88df6be7ddf59" +source = "git+https://github.com/dashpay/grovedb?rev=1ecedf530fbc5b5e12edf1bc607bd288c187ddde#1ecedf530fbc5b5e12edf1bc607bd288c187ddde" dependencies = [ "hex", "itertools 0.14.0", @@ -3105,9 +3141,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.10" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ "atomic-waker", "bytes", @@ -3115,7 +3151,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.9.0", + "indexmap 2.10.0", "slab", "tokio", "tokio-util", @@ -3160,9 +3196,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", @@ -3175,7 +3211,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.15.4", + "hashbrown 0.15.5", ] [[package]] @@ -3399,9 +3435,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.14" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ "base64 0.22.1", "bytes", @@ -3586,12 +3622,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", - "hashbrown 0.15.4", + "hashbrown 0.15.5", "serde", ] @@ -3612,18 +3648,18 @@ checksum = "0d762194228a2f1c11063e46e32e5acb96e66e906382b9eb5441f2e0504bbd5a" [[package]] name = "intmap" -version = "3.1.1" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6958acfd72ba79d943b048ab4064c671018b6a348a715b5b8931baf975439553" +checksum = "16dd999647b7a027fadf2b3041a4ea9c8ae21562823fe5cbdecd46537d535ae2" dependencies = [ "serde", ] [[package]] name = "io-uring" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" dependencies = [ "bitflags 2.9.1", "cfg-if", @@ -3678,9 +3714,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jiff" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a194df1107f33c79f4f93d02c80798520551949d59dfad22b6157048a88cca93" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" dependencies = [ "jiff-static", "log", @@ -3691,13 +3727,13 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c6e1db7ed32c6c71b759497fae34bf7933636f75a251b9e736555da426f6442" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -3734,9 +3770,9 @@ dependencies = [ [[package]] name = "jpeg-decoder" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" [[package]] name = "js-sys" @@ -3778,15 +3814,38 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "key-wallet" +version = "0.40.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=befd0356bebfcd0d06d1028d8a03bfa4c78bd219#befd0356bebfcd0d06d1028d8a03bfa4c78bd219" +dependencies = [ + "base58ck", + "bip39", + "bitflags 2.9.1", + "dash-network", + "dashcore", + "dashcore-private", + "dashcore_hashes", + "getrandom 0.2.16", + "hex", + "hkdf", + "rand 0.8.5", + "secp256k1", + "serde", + "serde_json", + "sha2", + "zeroize", +] + [[package]] name = "keyword-search-contract" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] @@ -3829,9 +3888,9 @@ checksum = "744a4c881f502e98c2241d2e5f50040ac73b30194d64452bb6260393b53f0dc9" [[package]] name = "libc" -version = "0.2.172" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libloading" @@ -3840,7 +3899,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.53.2", + "windows-targets 0.53.3", ] [[package]] @@ -3851,13 +3910,13 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ "bitflags 2.9.1", "libc", - "redox_syscall 0.5.13", + "redox_syscall 0.5.17", ] [[package]] @@ -3871,6 +3930,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3891,9 +3959,9 @@ checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "litrs" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" +checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" [[package]] name = "lock_api" @@ -3920,18 +3988,18 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown 0.15.4", + "hashbrown 0.15.5", ] [[package]] name = "masternode-reward-shares-contract" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] @@ -3951,9 +4019,9 @@ checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "memmap2" -version = "0.9.5" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f" +checksum = "483758ad303d734cec05e5c12b41d7e93e6a6390c5e9dae6bdeb7c1259012d28" dependencies = [ "libc", ] @@ -4055,15 +4123,15 @@ dependencies = [ "cfg_aliases", "codespan-reporting", "half", - "hashbrown 0.15.4", + "hashbrown 0.15.5", "hexf-parse", - "indexmap 2.9.0", + "indexmap 2.10.0", "log", "num-traits", "once_cell", "rustc-hash 1.1.0", "strum", - "thiserror 2.0.12", + "thiserror 2.0.14", "unicode-ident", ] @@ -4084,7 +4152,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation 0.3.1", "raw-window-handle", - "thiserror 2.0.12", + "thiserror 2.0.14", "versions", "wfd", "which 7.0.3", @@ -4118,7 +4186,7 @@ dependencies = [ "jni-sys", "log", "ndk-sys", - "num_enum 0.7.3", + "num_enum 0.7.4", "raw-window-handle", "thiserror 1.0.69", ] @@ -4227,7 +4295,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -4293,11 +4361,12 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" dependencies = [ - "num_enum_derive 0.7.3", + "num_enum_derive 0.7.4", + "rustversion", ] [[package]] @@ -4314,14 +4383,14 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" dependencies = [ "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -4712,7 +4781,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -4766,9 +4835,9 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "owned_ttf_parser" -version = "0.25.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec719bbf3b2a81c109a4e20b1f129b5566b7dce654bc3872f6a05abf82b2c4" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" dependencies = [ "ttf-parser", ] @@ -4806,7 +4875,7 @@ checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.13", + "redox_syscall 0.5.17", "smallvec", "windows-targets 0.52.6", ] @@ -4831,12 +4900,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "percent-encoding" version = "2.3.1" @@ -4850,7 +4913,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap 2.9.0", + "indexmap 2.10.0", ] [[package]] @@ -4893,7 +4956,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", "unicase", ] @@ -4924,7 +4987,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -4968,8 +5031,8 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "platform-serialization" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "bincode", "platform-version", @@ -4977,55 +5040,55 @@ dependencies = [ [[package]] name = "platform-serialization-derive" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", "virtue 0.0.17", ] [[package]] name = "platform-value" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "base64 0.22.1", "bincode", "bs58", "ciborium", "hex", - "indexmap 2.9.0", + "indexmap 2.10.0", "platform-serialization", "platform-version", "rand 0.8.5", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.14", "treediff", ] [[package]] name = "platform-version" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "bincode", "grovedb-version", "once_cell", - "thiserror 2.0.12", + "thiserror 2.0.14", "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", ] [[package]] name = "platform-versioning" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -5043,17 +5106,16 @@ dependencies = [ [[package]] name = "polling" -version = "3.8.0" +version = "3.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b53a684391ad002dd6a596ceb6c74fd004fdce75f4be2e3f615068abbea5fd50" +checksum = "b5bd19146350fe804f7cb2669c851c03d69da628803dab0d98018142aaa5d829" dependencies = [ "cfg-if", "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.0.7", - "tracing", - "windows-sys 0.59.0", + "rustix 1.0.8", + "windows-sys 0.60.2", ] [[package]] @@ -5115,12 +5177,12 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.34" +version = "0.2.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55" +checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2" dependencies = [ "proc-macro2", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -5144,24 +5206,24 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" dependencies = [ "unicode-ident", ] [[package]] name = "profiling" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" [[package]] name = "prost" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" dependencies = [ "bytes", "prost-derive", @@ -5169,12 +5231,12 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ "heck", - "itertools 0.14.0", + "itertools 0.13.0", "log", "multimap", "once_cell", @@ -5182,29 +5244,31 @@ dependencies = [ "prettyplease", "prost", "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", "regex", - "syn 2.0.103", + "syn 2.0.104", "tempfile", ] [[package]] name = "prost-derive" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.13.0", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] name = "prost-types" -version = "0.13.5" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" dependencies = [ "prost", ] @@ -5220,6 +5284,15 @@ dependencies = [ "unicase", ] +[[package]] +name = "pulldown-cmark-to-cmark" +version = "21.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5b6a0769a491a08b31ea5c62494a8f144ee0987d86d670a8af4df1e1b7cde75" +dependencies = [ + "pulldown-cmark", +] + [[package]] name = "qrcode" version = "0.14.1" @@ -5259,9 +5332,9 @@ dependencies = [ [[package]] name = "r-efi" -version = "5.2.0" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "radium" @@ -5282,9 +5355,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -5383,22 +5456,22 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.13" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ "bitflags 2.9.1", ] [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] @@ -5453,9 +5526,9 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "reqwest" -version = "0.12.20" +version = "0.12.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", @@ -5546,8 +5619,8 @@ dependencies = [ [[package]] name = "rs-dapi-client" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "backon", "chrono", @@ -5557,15 +5630,17 @@ dependencies = [ "gloo-timers", "hex", "http", + "http-body-util", "http-serde", "lru", "rand 0.8.5", "serde", "serde_json", "sha2", - "thiserror 2.0.12", + "thiserror 2.0.14", "tokio", "tonic-web-wasm-client", + "tower-service", "tracing", "wasm-bindgen-futures", ] @@ -5604,7 +5679,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.103", + "syn 2.0.104", "walkdir", ] @@ -5620,9 +5695,9 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" @@ -5660,22 +5735,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "rustls" -version = "0.23.27" +version = "0.23.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" dependencies = [ "log", "once_cell", @@ -5695,7 +5770,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.2.0", + "security-framework 3.3.0", ] [[package]] @@ -5718,9 +5793,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.3" +version = "0.103.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" dependencies = [ "ring", "rustls-pki-types", @@ -5729,9 +5804,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -5832,9 +5907,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.2.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +checksum = "80fb1d92c5028aa318b4b8bd7302a5bfcf48be96a37fc6fc790f806b0004ee0c" dependencies = [ "bitflags 2.9.1", "core-foundation 0.10.1", @@ -5894,16 +5969,16 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.142" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" dependencies = [ - "indexmap 2.9.0", + "indexmap 2.10.0", "itoa", "memchr", "ryu", @@ -5918,7 +5993,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -5967,7 +6042,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -5976,7 +6051,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.9.0", + "indexmap 2.10.0", "itoa", "ryu", "serde", @@ -6031,9 +6106,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ "libc", ] @@ -6061,12 +6136,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "slab" -version = "0.4.9" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "slotmap" @@ -6130,12 +6202,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.10" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6219,7 +6291,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -6250,9 +6322,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.103" +version = "2.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4307e30089d6fd6aff212f2da3a1f9e32f3223b1f010fb09b7c95f90f3ca1e8" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" dependencies = [ "proc-macro2", "quote", @@ -6276,7 +6348,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -6334,47 +6406,47 @@ dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.0.7", + "rustix 1.0.8", "windows-sys 0.59.0", ] [[package]] name = "tenderdash-abci" -version = "1.4.0" -source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.4.0#e2dd15f39246081e7d569e585ab78ff5340116ac" +version = "1.5.0-dev.2" +source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.0-dev.2#3f6ac716c42125a01caceb42cc5997efa41c88fc" dependencies = [ "bytes", "hex", "lhash", "semver", "tenderdash-proto", - "thiserror 2.0.12", + "thiserror 2.0.14", "tracing", "url", ] [[package]] name = "tenderdash-proto" -version = "1.4.0" -source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.4.0#e2dd15f39246081e7d569e585ab78ff5340116ac" +version = "1.5.0-dev.2" +source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.0-dev.2#3f6ac716c42125a01caceb42cc5997efa41c88fc" dependencies = [ "bytes", "chrono", "derive_more 2.0.1", - "flex-error", "num-derive", "num-traits", "prost", "serde", "subtle-encoding", "tenderdash-proto-compiler", + "thiserror 2.0.14", "time", ] [[package]] name = "tenderdash-proto-compiler" -version = "1.4.0" -source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.4.0#e2dd15f39246081e7d569e585ab78ff5340116ac" +version = "1.5.0-dev.2" +source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.0-dev.2#3f6ac716c42125a01caceb42cc5997efa41c88fc" dependencies = [ "fs_extra", "prost-build", @@ -6405,11 +6477,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.14", ] [[package]] @@ -6420,18 +6492,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -6546,20 +6618,20 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] name = "tokio" -version = "1.46.1" +version = "1.47.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" dependencies = [ "backtrace", "bytes", @@ -6572,7 +6644,7 @@ dependencies = [ "slab", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6583,7 +6655,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -6619,9 +6691,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.15" +version = "0.7.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" dependencies = [ "bytes", "futures-core", @@ -6658,7 +6730,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.9.0", + "indexmap 2.10.0", "toml_datetime", "winnow 0.5.40", ] @@ -6669,18 +6741,18 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.9.0", + "indexmap 2.10.0", "serde", "serde_spanned", "toml_datetime", - "winnow 0.7.11", + "winnow 0.7.12", ] [[package]] name = "tonic" -version = "0.13.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" +checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" dependencies = [ "async-trait", "base64 0.22.1", @@ -6694,9 +6766,9 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "prost", "rustls-native-certs", "socket2", + "sync_wrapper", "tokio", "tokio-rustls", "tokio-stream", @@ -6704,28 +6776,53 @@ dependencies = [ "tower-layer", "tower-service", "tracing", - "webpki-roots 0.26.11", + "webpki-roots 1.0.2", ] [[package]] name = "tonic-build" -version = "0.13.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac6f67be712d12f0b41328db3137e0d0757645d8904b4cb7d51cd9c2279e847" +checksum = "4c40aaccc9f9eccf2cd82ebc111adc13030d23e887244bc9cfa5d1d636049de3" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "tonic-prost" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a16cba4043dc3ff43fcb3f96b4c5c154c64cbd18ca8dce2ab2c6a451d058a2" dependencies = [ "prettyplease", "proc-macro2", "prost-build", "prost-types", "quote", - "syn 2.0.103", + "syn 2.0.104", + "tempfile", + "tonic-build", ] [[package]] name = "tonic-web-wasm-client" -version = "0.7.1" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66e3bb7acca55e6790354be650f4042d418fcf8e2bc42ac382348f2b6bf057e5" +checksum = "898cd44be5e23e59d2956056538f1d6b3c5336629d384ffd2d92e76f87fb98ff" dependencies = [ "base64 0.22.1", "byteorder", @@ -6737,7 +6834,7 @@ dependencies = [ "httparse", "js-sys", "pin-project", - "thiserror 2.0.12", + "thiserror 2.0.14", "tonic", "tower-service", "wasm-bindgen", @@ -6754,7 +6851,7 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", - "indexmap 2.9.0", + "indexmap 2.10.0", "pin-project-lite", "slab", "sync_wrapper", @@ -6808,13 +6905,13 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1ffbcf9c6f6b99d386e7444eb608ba646ae452a36b39737deb9663b610f662" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -6929,9 +7026,9 @@ checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "unicode-normalization" -version = "0.1.22" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" dependencies = [ "tinyvec", ] @@ -6944,9 +7041,9 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.1.14" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" [[package]] name = "unicode-xid" @@ -6978,9 +7075,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.0.11" +version = "3.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a3e9af6113ecd57b8c63d3cd76a385b2e3881365f1f489e54f49801d0c83ea" +checksum = "9f0fde9bc91026e381155f8c67cb354bcd35260b2f4a29bcc84639f762760c39" dependencies = [ "base64 0.22.1", "flate2", @@ -6996,9 +7093,9 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fadf18427d33828c311234884b7ba2afb57143e6e7e69fda7ee883b624661e36" +checksum = "59db78ad1923f2b1be62b6da81fe80b173605ca0d57f85da2e005382adf693f7" dependencies = [ "base64 0.22.1", "http", @@ -7044,9 +7141,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" dependencies = [ "getrandom 0.3.3", "js-sys", @@ -7147,13 +7244,13 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] @@ -7202,7 +7299,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", "wasm-bindgen-shared", ] @@ -7237,7 +7334,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -7266,13 +7363,13 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe770181423e5fc79d3e2a7f4410b7799d5aab1de4372853de3c6aa13ca24121" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" dependencies = [ "cc", "downcast-rs", - "rustix 0.38.44", + "rustix 1.0.8", "scoped-tls", "smallvec", "wayland-sys", @@ -7280,12 +7377,12 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978fa7c67b0847dbd6a9f350ca2569174974cd4082737054dbb7fbb79d7d9a61" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ "bitflags 2.9.1", - "rustix 0.38.44", + "rustix 1.0.8", "wayland-backend", "wayland-scanner", ] @@ -7303,20 +7400,20 @@ dependencies = [ [[package]] name = "wayland-cursor" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65317158dec28d00416cb16705934070aef4f8393353d41126c54264ae0f182" +checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" dependencies = [ - "rustix 0.38.44", + "rustix 1.0.8", "wayland-client", "xcursor", ] [[package]] name = "wayland-protocols" -version = "0.32.8" +version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "779075454e1e9a521794fed15886323ea0feda3f8b0fc1390f5398141310422a" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ "bitflags 2.9.1", "wayland-backend", @@ -7326,9 +7423,9 @@ dependencies = [ [[package]] name = "wayland-protocols-plasma" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd38cdad69b56ace413c6bcc1fbf5acc5e2ef4af9d5f8f1f9570c0c83eae175" +checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" dependencies = [ "bitflags 2.9.1", "wayland-backend", @@ -7339,9 +7436,9 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cb6cdc73399c0e06504c437fe3cf886f25568dd5454473d565085b36d6a8bbf" +checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" dependencies = [ "bitflags 2.9.1", "wayland-backend", @@ -7352,9 +7449,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.6" +version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "896fdafd5d28145fce7958917d69f2fd44469b1d4e861cb5961bcbeebc6d1484" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" dependencies = [ "proc-macro2", "quick-xml 0.37.5", @@ -7363,9 +7460,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.6" +version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbcebb399c77d5aa9fa5db874806ee7b4eba4e73650948e8f93963f128896615" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" dependencies = [ "dlib", "log", @@ -7395,12 +7492,11 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5df295f8451142f1856b1bd86a606dfe9587d439bc036e319c827700dbd555e" +checksum = "aaf4f3c0ba838e82b4e5ccc4157003fb8c324ee24c058470ffb82820becbde98" dependencies = [ "core-foundation 0.10.1", - "home", "jni", "log", "ndk-context", @@ -7416,14 +7512,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.0", + "webpki-roots 1.0.2", ] [[package]] name = "webpki-roots" -version = "1.0.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" dependencies = [ "rustls-pki-types", ] @@ -7454,7 +7550,7 @@ dependencies = [ "bitflags 2.9.1", "cfg_aliases", "document-features", - "hashbrown 0.15.4", + "hashbrown 0.15.5", "js-sys", "log", "parking_lot", @@ -7482,8 +7578,8 @@ dependencies = [ "bitflags 2.9.1", "cfg_aliases", "document-features", - "hashbrown 0.15.4", - "indexmap 2.9.0", + "hashbrown 0.15.5", + "indexmap 2.10.0", "log", "naga", "once_cell", @@ -7493,7 +7589,7 @@ dependencies = [ "raw-window-handle", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.12", + "thiserror 2.0.14", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-types", @@ -7523,7 +7619,7 @@ dependencies = [ "portable-atomic", "raw-window-handle", "renderdoc-sys", - "thiserror 2.0.12", + "thiserror 2.0.14", "wgpu-types", ] @@ -7537,7 +7633,7 @@ dependencies = [ "bytemuck", "js-sys", "log", - "thiserror 2.0.12", + "thiserror 2.0.14", "web-sys", ] @@ -7549,7 +7645,7 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" dependencies = [ "either", "env_home", - "rustix 1.0.7", + "rustix 1.0.8", "winsafe", ] @@ -7560,7 +7656,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" dependencies = [ "env_home", - "rustix 1.0.7", + "rustix 1.0.8", "winsafe", ] @@ -7649,7 +7745,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -7660,7 +7756,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -7681,9 +7777,9 @@ dependencies = [ [[package]] name = "windows-registry" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3bab093bdd303a1240bb99b8aba8ea8a69ee19d34c9e2ef9594e708a4878820" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ "windows-link", "windows-result", @@ -7750,7 +7846,7 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.2", + "windows-targets 0.53.3", ] [[package]] @@ -7801,10 +7897,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ + "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -8006,9 +8103,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winit" -version = "0.30.11" +version = "0.30.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4409c10174df8779dc29a4788cac85ed84024ccbc1743b776b21a520ee1aaf4" +checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732" dependencies = [ "ahash", "android-activity", @@ -8067,9 +8164,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" dependencies = [ "memchr", ] @@ -8101,8 +8198,8 @@ dependencies = [ [[package]] name = "withdrawals-contract" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?rev=5f93c70720a1ea09f91c9142668e17f314986f03#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "2.0.0" +source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" dependencies = [ "num_enum 0.5.11", "platform-value", @@ -8110,7 +8207,7 @@ dependencies = [ "serde", "serde_json", "serde_repr", - "thiserror 2.0.12", + "thiserror 2.0.14", ] [[package]] @@ -8162,9 +8259,9 @@ checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" [[package]] name = "xcursor" -version = "0.3.8" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef33da6b1660b4ddbfb3aef0ade110c8b8a781a3b6382fa5f2b5b040fd55f61" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" [[package]] name = "xkbcommon-dl" @@ -8187,9 +8284,9 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xml-rs" -version = "0.8.26" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62ce76d9b56901b19a74f19431b0d8b3bc7ca4ad685a746dfd78ca8f4fc6bda" +checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7" [[package]] name = "yoke" @@ -8211,15 +8308,15 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", "synstructure", ] [[package]] name = "zbus" -version = "5.7.1" +version = "5.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3a7c7cee313d044fca3f48fa782cb750c79e4ca76ba7bc7718cd4024cdf6f68" +checksum = "4bb4f9a464286d42851d18a605f7193b8febaf5b0919d71c6399b7b26e5b0aad" dependencies = [ "async-broadcast", "async-executor", @@ -8231,7 +8328,7 @@ dependencies = [ "async-trait", "blocking", "enumflags2", - "event-listener 5.4.0", + "event-listener 5.4.1", "futures-core", "futures-lite", "hex", @@ -8242,7 +8339,7 @@ dependencies = [ "tracing", "uds_windows", "windows-sys 0.59.0", - "winnow 0.7.11", + "winnow 0.7.12", "zbus_macros", "zbus_names", "zvariant", @@ -8266,7 +8363,7 @@ checksum = "dc6821851fa840b708b4cbbaf6241868cabc85a2dc22f426361b0292bfc0b836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", "zbus-lockstep", "zbus_xml", "zvariant", @@ -8274,14 +8371,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.7.1" +version = "5.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a17e7e5eec1550f747e71a058df81a9a83813ba0f6a95f39c4e218bdc7ba366a" +checksum = "ef9859f68ee0c4ee2e8cde84737c78e3f4c54f946f2a38645d0d4c7a95327659" dependencies = [ "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", "zbus_names", "zvariant", "zvariant_utils", @@ -8295,7 +8392,7 @@ checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" dependencies = [ "serde", "static_assertions", - "winnow 0.7.11", + "winnow 0.7.12", "zvariant", ] @@ -8314,22 +8411,22 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.25" +version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.25" +version = "0.8.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -8349,7 +8446,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", "synstructure", ] @@ -8371,7 +8468,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] @@ -8424,9 +8521,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" dependencies = [ "yoke", "zerofrom", @@ -8441,26 +8538,29 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", ] [[package]] name = "zip" -version = "2.4.2" +version = "5.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "2f852905151ac8d4d06fdca66520a661c09730a74c6d4e2b0f27b436b382e532" dependencies = [ "arbitrary", "crc32fast", - "crossbeam-utils", - "displaydoc", "flate2", - "indexmap 2.9.0", + "indexmap 2.10.0", "memchr", - "thiserror 2.0.12", "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" + [[package]] name = "zmq" version = "0.10.0" @@ -8497,29 +8597,29 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.5.3" +version = "5.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d30786f75e393ee63a21de4f9074d4c038d52c5b1bb4471f955db249f9dffb1" +checksum = "d91b3680bb339216abd84714172b5138a4edac677e641ef17e1d8cb1b3ca6e6f" dependencies = [ "endi", "enumflags2", "serde", "url", - "winnow 0.7.11", + "winnow 0.7.12", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.5.3" +version = "5.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75fda702cd42d735ccd48117b1630432219c0e9616bf6cb0f8350844ee4d9580" +checksum = "3a8c68501be459a8dbfffbe5d792acdd23b4959940fc87785fb013b32edbc208" dependencies = [ "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.103", + "syn 2.0.104", "zvariant_utils", ] @@ -8533,8 +8633,8 @@ dependencies = [ "quote", "serde", "static_assertions", - "syn 2.0.103", - "winnow 0.7.11", + "syn 2.0.104", + "winnow 0.7.12", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d118bc955..c5b0b636b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.9.0" license = "MIT" edition = "2024" default-run = "dash-evo-tool" -rust-version = "1.88" +rust-version = "1.89" [dependencies] tokio-util = { version = "0.7.15" } @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.30.1", features = ["signal"] } eframe = { version = "0.32.0", features = ["persistence"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "5f93c70720a1ea09f91c9142668e17f314986f03" } +dash-sdk = { git = "https://github.com/dashpay/platform", features = ["core_key_wallet", "core_bincode", "core_quorum-validation", "core_verification", "core_rpc_client"], rev = "d3f3c930a618030be807bb257e7434ef3df090b5" } thiserror = "2.0.12" serde = "1.0.219" serde_json = "1.0.140" @@ -30,6 +30,7 @@ itertools = "0.14.0" enum-iterator = "2.1.0" futures = "0.3.31" tracing = "0.1.41" +rand = "0.8" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } dotenvy = "0.15.7" envy = "0.4.2" diff --git a/artifacts/mn_list_diff_0_2227096.bin b/artifacts/mn_list_diff_0_2227096.bin new file mode 100644 index 0000000000000000000000000000000000000000..a75870c796d3b9f18548dd4e24406022577f36a8 GIT binary patch literal 523465 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%0DYLPF$p*dE|ZFt@-MDw7CpCL6a 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≤f4_SlX_#2le(YV= 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%0D2#IbQ~KIUc~Y3iU7Ld)r(UjWBi5aeaCEUY2y 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�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%0D?YP0Ua($K{&^=I^LRc2|C1y= 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

P|4@G5~CfToF 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 eGFM!$t_d!|-66OJ2o@ZII|O%!dBb^i?>YHy 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 KrVu>eqV=YD6O!1T6)^YKAlS>8Y$PAe4j74D`sf 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 { // Store the asset lock transaction in the database - match app_context.received_transaction_finality(&tx, Some(is_lock), None) { + match app_context.received_transaction_finality( + &tx, + Some(is_lock.clone()), + None, + ) { Ok(utxos) => { let core_item = - CoreItem::ReceivedAvailableUTXOTransaction(tx.clone(), utxos); + CoreItem::InstantLockedTransaction(tx.clone(), utxos, is_lock); self.visible_screen_mut() .display_task_result(BackendTaskSuccessResult::CoreItem(core_item)); } @@ -714,7 +731,13 @@ impl App for AppState { eprintln!("Failed to store asset lock: {}", e); } } - ZMQMessage::ChainLockedBlock(_) => {} + ZMQMessage::ChainLockedBlock(block, chain_lock) => { + self.visible_screen_mut().display_task_result( + BackendTaskSuccessResult::CoreItem(CoreItem::ChainLockedBlock( + block, chain_lock, + )), + ); + } } } diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 72f168837..170c61d20 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -8,7 +8,9 @@ use crate::context::AppContext; use crate::model::wallet::Wallet; use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dashcore_rpc::{Auth, Client}; -use dash_sdk::dpp::dashcore::{Address, ChainLock, Network, OutPoint, Transaction, TxOut}; +use dash_sdk::dpp::dashcore::{ + Address, Block, ChainLock, InstantLock, Network, OutPoint, Transaction, TxOut, +}; use std::path::PathBuf; use std::sync::{Arc, RwLock}; @@ -40,6 +42,7 @@ impl PartialEq for CoreTask { #[derive(Debug, Clone, PartialEq)] pub enum CoreItem { + InstantLockedTransaction(Transaction, Vec<(OutPoint, TxOut, Address)>, InstantLock), ReceivedAvailableUTXOTransaction(Transaction, Vec<(OutPoint, TxOut, Address)>), ChainLock(ChainLock, Network), ChainLocks( @@ -48,6 +51,7 @@ pub enum CoreItem { Option, Option, ), // Mainnet, Testnet, Devnet, Local + ChainLockedBlock(Block, ChainLock), } impl AppContext { diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index e2d646d8a..b82da7ad9 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -69,8 +69,9 @@ impl AppContext { let wallets = self.wallets.read().unwrap().clone(); - if identity_type != IdentityType::User && owner_private_key_bytes.is_some() { - let owner_private_key_bytes = owner_private_key_bytes.unwrap(); + if identity_type != IdentityType::User + && let Some(owner_private_key_bytes) = owner_private_key_bytes + { let key = self.verify_owner_key_exists_on_identity(&identity, &owner_private_key_bytes)?; let key_id = key.id(); @@ -89,8 +90,9 @@ impl AppContext { ); } - if identity_type != IdentityType::User && payout_address_private_key_bytes.is_some() { - let payout_address_private_key_bytes = payout_address_private_key_bytes.unwrap(); + if identity_type != IdentityType::User + && let Some(payout_address_private_key_bytes) = payout_address_private_key_bytes + { let key = self.verify_payout_address_key_exists_on_identity( &identity, &payout_address_private_key_bytes, @@ -112,46 +114,49 @@ impl AppContext { } // If the identity type is not a User, and we have a voting private key, verify it - let associated_voter_identity = if identity_type != IdentityType::User - && voting_private_key_bytes.is_some() - { - let voting_private_key_bytes = voting_private_key_bytes.unwrap(); - if let Ok(private_key) = - PrivateKey::from_slice(voting_private_key_bytes.as_slice(), self.network) - { - // Make the vote identifier - let address = private_key.public_key(&Secp256k1::new()).pubkey_hash(); - let voter_identifier = - Identifier::create_voter_identifier(identity_id.as_bytes(), address.as_ref()); + let associated_voter_identity = if identity_type != IdentityType::User { + if let Some(voting_private_key_bytes) = voting_private_key_bytes { + if let Ok(private_key) = + PrivateKey::from_byte_array(&voting_private_key_bytes, self.network) + { + // Make the vote identifier + let address = private_key.public_key(&Secp256k1::new()).pubkey_hash(); + let voter_identifier = Identifier::create_voter_identifier( + identity_id.as_bytes(), + address.as_ref(), + ); - // Fetch the voter identifier - let voter_identity = - match Identity::fetch_by_identifier(sdk, voter_identifier).await { - Ok(Some(identity)) => identity, - Ok(None) => return Err("Voter Identity not found".to_string()), - Err(e) => return Err(format!("Error fetching voter identity: {}", e)), - }; + // Fetch the voter identifier + let voter_identity = + match Identity::fetch_by_identifier(sdk, voter_identifier).await { + Ok(Some(identity)) => identity, + Ok(None) => return Err("Voter Identity not found".to_string()), + Err(e) => return Err(format!("Error fetching voter identity: {}", e)), + }; - let key = self.verify_voting_key_exists_on_identity( - &voter_identity, - &voting_private_key_bytes, - )?; - let qualified_key = - QualifiedIdentityPublicKey::from_identity_public_key_with_wallets_check( - key.clone(), - self.network, - &wallets.values().collect::>(), + let key = self.verify_voting_key_exists_on_identity( + &voter_identity, + &voting_private_key_bytes, + )?; + let qualified_key = + QualifiedIdentityPublicKey::from_identity_public_key_with_wallets_check( + key.clone(), + self.network, + &wallets.values().collect::>(), + ); + encrypted_private_keys.insert( + (PrivateKeyOnVoterIdentity, key.id()), + ( + qualified_key, + PrivateKeyData::Clear(voting_private_key_bytes), + ), ); - encrypted_private_keys.insert( - (PrivateKeyOnVoterIdentity, key.id()), - ( - qualified_key, - PrivateKeyData::Clear(voting_private_key_bytes), - ), - ); - Some((voter_identity, key)) + Some((voter_identity, key)) + } else { + return Err("Voting private key is not valid".to_string()); + } } else { - return Err("Voting private key is not valid".to_string()); + None } } else { None @@ -167,7 +172,7 @@ impl AppContext { verify_key_input(key_string, "User Key") .transpose()? .and_then(|sk| { - PrivateKey::from_slice(sk.as_slice(), self.network) + PrivateKey::from_byte_array(&sk, self.network) .map_err(|e| e.to_string()) }), ) diff --git a/src/backend_task/identity/load_identity_from_wallet.rs b/src/backend_task/identity/load_identity_from_wallet.rs index bad0699f3..a3c097d6e 100644 --- a/src/backend_task/identity/load_identity_from_wallet.rs +++ b/src/backend_task/identity/load_identity_from_wallet.rs @@ -9,12 +9,12 @@ use crate::model::qualified_identity::{ }; use crate::model::wallet::WalletArcRef; use dash_sdk::Sdk; -use dash_sdk::dpp::dashcore::bip32::{DerivationPath, KeyDerivationType}; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{KeyID, KeyType}; +use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, KeyDerivationType}; use dash_sdk::dpp::platform_value::Value; use dash_sdk::drive::query::{WhereClause, WhereOperator}; use dash_sdk::platform::types::identity::PublicKeyHash; diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index aee36f4b7..e1b50b5d2 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -17,7 +17,6 @@ use crate::model::qualified_identity::qualified_identity_public_key::QualifiedId use crate::model::qualified_identity::{IdentityType, PrivateKeyTarget, QualifiedIdentity}; use crate::model::wallet::{Wallet, WalletArcRef, WalletSeedHash}; use dash_sdk::Sdk; -use dash_sdk::dashcore_rpc::dashcore::bip32::DerivationPath; use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; use dash_sdk::dashcore_rpc::dashcore::{Address, PrivateKey, TxOut}; use dash_sdk::dpp::ProtocolError; @@ -29,6 +28,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dash_sdk::dpp::identity::{KeyID, KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::dpp::prelude::AssetLockProof; use dash_sdk::platform::{Identifier, Identity, IdentityPublicKey}; use std::collections::{BTreeMap, HashMap, HashSet}; diff --git a/src/backend_task/mnlist.rs b/src/backend_task/mnlist.rs new file mode 100644 index 000000000..4dfd2995a --- /dev/null +++ b/src/backend_task/mnlist.rs @@ -0,0 +1,128 @@ +use crate::backend_task::BackendTaskSuccessResult; +use crate::components::core_p2p_handler::CoreP2PHandler; +use crate::context::AppContext; +use dash_sdk::dashcore_rpc::RpcApi; +use dash_sdk::dpp::dashcore::bls_sig_utils::BLSSignature; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::dashcore::{BlockHash, Network}; + +#[derive(Debug, Clone, PartialEq)] +pub enum MnListTask { + FetchEndDmlDiff { + base_block_height: u32, + base_block_hash: BlockHash, + block_height: u32, + block_hash: BlockHash, + validate_quorums: bool, + }, + FetchEndQrInfo { + known_block_hashes: Vec, + block_hash: BlockHash, + }, + FetchEndQrInfoWithDmls { + known_block_hashes: Vec, + block_hash: BlockHash, + }, + FetchChainLocks { + base_block_height: u32, + block_height: u32, + }, + /// Fetch a sequence of MNListDiffs for validation purposes + /// Each tuple is (base_height, base_hash, height, hash) + FetchDiffsChain { + chain: Vec<(u32, BlockHash, u32, BlockHash)>, + }, +} + +pub async fn run_mnlist_task( + app: &AppContext, + task: MnListTask, +) -> Result { + match task { + MnListTask::FetchEndDmlDiff { + base_block_height, + base_block_hash, + block_height, + block_hash, + validate_quorums: _, + } => { + let network = app.network; + let mut p2p = CoreP2PHandler::new(network, None)?; + let diff = p2p.get_dml_diff(base_block_hash, block_hash)?; + Ok(BackendTaskSuccessResult::MnListFetchedDiff { + base_height: base_block_height, + height: block_height, + diff, + }) + } + MnListTask::FetchEndQrInfo { + known_block_hashes, + block_hash, + } => { + let network = app.network; + let mut p2p = CoreP2PHandler::new(network, None)?; + let qr_info = p2p.get_qr_info(known_block_hashes, block_hash)?; + Ok(BackendTaskSuccessResult::MnListFetchedQrInfo { qr_info }) + } + MnListTask::FetchEndQrInfoWithDmls { + known_block_hashes, + block_hash, + } => { + // For now, fetch QRInfo; UI can integrate included diffs from QRInfo + let network = app.network; + let mut p2p = CoreP2PHandler::new(network, None)?; + let qr_info = p2p.get_qr_info(known_block_hashes, block_hash)?; + Ok(BackendTaskSuccessResult::MnListFetchedQrInfo { qr_info }) + } + MnListTask::FetchChainLocks { + base_block_height, + block_height, + } => { + let client = app.core_client.read().unwrap(); + // Determine the range (replicate UI logic approximately) + let loaded_list_height = match app.network { + Network::Dash => 2_227_096, + Network::Testnet => 1_296_600, + _ => 0, + }; + let max_blocks = 2000u32; + let start_height = if base_block_height < loaded_list_height { + block_height.saturating_sub(max_blocks) + } else { + base_block_height + }; + let end_height = start_height.saturating_add(max_blocks).min(block_height); + + let mut out: Vec<((u32, BlockHash), Option)> = Vec::new(); + for h in start_height..end_height { + if let Ok(bh2) = client.get_block_hash(h) { + // Convert RPC hash to DPP hash + let bh = BlockHash::from_byte_array(bh2.to_byte_array()); + // Get block and extract coinbase best_cl_signature + if let Ok(block) = client.get_block(&bh2) { + let sig_opt = block + .coinbase() + .and_then(|cb| cb.special_transaction_payload.as_ref()) + .and_then(|pl| pl.clone().to_coinbase_payload().ok()) + .and_then(|cp| cp.best_cl_signature) + .map(|sig| sig.to_bytes().into()); + out.push(((h, bh), sig_opt)); + } else { + out.push(((h, bh), None)); + } + } + } + Ok(BackendTaskSuccessResult::MnListChainLockSigs { entries: out }) + } + MnListTask::FetchDiffsChain { chain } => { + let network = app.network; + let mut p2p = CoreP2PHandler::new(network, None)?; + let mut items = Vec::with_capacity(chain.len()); + for (base_h, base_hash, h, hash) in chain { + let diff = p2p.get_dml_diff(base_hash, hash)?; + items.push(((base_h, h), diff)); + } + Ok(BackendTaskSuccessResult::MnListFetchedDiffs { items }) + } + } +} diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 1c5eb7299..773dcfbe3 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -34,6 +34,7 @@ pub mod contract; pub mod core; pub mod document; pub mod identity; +pub mod mnlist; pub mod platform_info; pub mod register_contract; pub mod system_task; @@ -53,6 +54,7 @@ pub enum BackendTask { BroadcastStateTransition(StateTransition), TokenTask(Box), SystemTask(SystemTask), + MnListTask(mnlist::MnListTask), PlatformInfo(PlatformInfoTaskRequestType), None, } @@ -99,6 +101,27 @@ pub enum BackendTaskSuccessResult { }, UpdatedThemePreference(crate::ui::theme::ThemeMode), PlatformInfo(PlatformInfoTaskResult), + // MNList-specific results + MnListFetchedDiff { + base_height: u32, + height: u32, + diff: dash_sdk::dpp::dashcore::network::message_sml::MnListDiff, + }, + MnListFetchedQrInfo { + qr_info: dash_sdk::dpp::dashcore::network::message_qrinfo::QRInfo, + }, + MnListChainLockSigs { + entries: Vec<( + (u32, dash_sdk::dpp::dashcore::BlockHash), + Option, + )>, + }, + MnListFetchedDiffs { + items: Vec<( + (u32, u32), + dash_sdk::dpp::dashcore::network::message_sml::MnListDiff, + )>, + }, } impl BackendTaskSuccessResult {} @@ -171,6 +194,9 @@ impl AppContext { self.run_token_task(*token_task, &sdk, sender).await } BackendTask::SystemTask(system_task) => self.run_system_task(system_task, sender).await, + BackendTask::MnListTask(mnlist_task) => { + mnlist::run_mnlist_task(self, mnlist_task).await + } BackendTask::PlatformInfo(platform_info_task) => { self.run_platform_info_task(platform_info_task).await } diff --git a/src/backend_task/tokens/burn_tokens.rs b/src/backend_task/tokens/burn_tokens.rs index c50ba5c26..bb7163056 100644 --- a/src/backend_task/tokens/burn_tokens.rs +++ b/src/backend_task/tokens/burn_tokens.rs @@ -89,21 +89,16 @@ impl AppContext { BurnResult::HistoricalDocument(document) => { if let (Some(owner_value), Some(amount_value)) = (document.get("ownerId"), document.get("amount")) - { - if let (Value::Identifier(owner_bytes), Value::U64(amount)) = + && let (Value::Identifier(owner_bytes), Value::U64(amount)) = (owner_value, amount_value) - { - if let Ok(owner_id) = Identifier::from_bytes(owner_bytes) { - if let Err(e) = self - .insert_token_identity_balance(&token_id, &owner_id, *amount) - { - eprintln!( - "Failed to update token balance from historical document: {}", - e - ); - } - } - } + && let Ok(owner_id) = Identifier::from_bytes(owner_bytes) + && let Err(e) = + self.insert_token_identity_balance(&token_id, &owner_id, *amount) + { + eprintln!( + "Failed to update token balance from historical document: {}", + e + ); } } @@ -111,21 +106,16 @@ impl AppContext { BurnResult::GroupActionWithDocument(_, Some(document)) => { if let (Some(owner_value), Some(amount_value)) = (document.get("ownerId"), document.get("amount")) - { - if let (Value::Identifier(owner_bytes), Value::U64(amount)) = + && let (Value::Identifier(owner_bytes), Value::U64(amount)) = (owner_value, amount_value) - { - if let Ok(owner_id) = Identifier::from_bytes(owner_bytes) { - if let Err(e) = self - .insert_token_identity_balance(&token_id, &owner_id, *amount) - { - eprintln!( - "Failed to update token balance from group action document: {}", - e - ); - } - } - } + && let Ok(owner_id) = Identifier::from_bytes(owner_bytes) + && let Err(e) = + self.insert_token_identity_balance(&token_id, &owner_id, *amount) + { + eprintln!( + "Failed to update token balance from group action document: {}", + e + ); } } diff --git a/src/backend_task/tokens/claim_tokens.rs b/src/backend_task/tokens/claim_tokens.rs index b3a3b8473..ec22b2fff 100644 --- a/src/backend_task/tokens/claim_tokens.rs +++ b/src/backend_task/tokens/claim_tokens.rs @@ -73,23 +73,13 @@ impl AppContext { ClaimResult::Document(document) => { if let (Some(claimer_value), Some(amount_value)) = (document.get("claimerId"), document.get("amount")) - { - if let (Value::Identifier(claimer_bytes), Value::U64(amount)) = + && let (Value::Identifier(claimer_bytes), Value::U64(amount)) = (claimer_value, amount_value) - { - if let Ok(claimer_id) = Identifier::from_bytes(claimer_bytes) { - if let Err(e) = self.insert_token_identity_balance( - &token_id, - &claimer_id, - *amount, - ) { - eprintln!( - "Failed to update token balance from claim document: {}", - e - ); - } - } - } + && let Ok(claimer_id) = Identifier::from_bytes(claimer_bytes) + && let Err(e) = + self.insert_token_identity_balance(&token_id, &claimer_id, *amount) + { + eprintln!("Failed to update token balance from claim document: {}", e); } } @@ -97,23 +87,16 @@ impl AppContext { ClaimResult::GroupActionWithDocument(_, document) => { if let (Some(claimer_value), Some(amount_value)) = (document.get("claimerId"), document.get("amount")) - { - if let (Value::Identifier(claimer_bytes), Value::U64(amount)) = + && let (Value::Identifier(claimer_bytes), Value::U64(amount)) = (claimer_value, amount_value) - { - if let Ok(claimer_id) = Identifier::from_bytes(claimer_bytes) { - if let Err(e) = self.insert_token_identity_balance( - &token_id, - &claimer_id, - *amount, - ) { - eprintln!( - "Failed to update token balance from group action document: {}", - e - ); - } - } - } + && let Ok(claimer_id) = Identifier::from_bytes(claimer_bytes) + && let Err(e) = + self.insert_token_identity_balance(&token_id, &claimer_id, *amount) + { + eprintln!( + "Failed to update token balance from group action document: {}", + e + ); } } } diff --git a/src/backend_task/tokens/mint_tokens.rs b/src/backend_task/tokens/mint_tokens.rs index f23ee1142..86a806745 100644 --- a/src/backend_task/tokens/mint_tokens.rs +++ b/src/backend_task/tokens/mint_tokens.rs @@ -96,23 +96,16 @@ impl AppContext { MintResult::HistoricalDocument(document) => { if let (Some(recipient_value), Some(amount_value)) = (document.get("recipientId"), document.get("amount")) - { - if let (Value::Identifier(recipient_bytes), Value::U64(amount)) = + && let (Value::Identifier(recipient_bytes), Value::U64(amount)) = (recipient_value, amount_value) - { - if let Ok(recipient_id) = Identifier::from_bytes(recipient_bytes) { - if let Err(e) = self.insert_token_identity_balance( - &token_id, - &recipient_id, - *amount, - ) { - eprintln!( - "Failed to update token balance from historical document: {}", - e - ); - } - } - } + && let Ok(recipient_id) = Identifier::from_bytes(recipient_bytes) + && let Err(e) = + self.insert_token_identity_balance(&token_id, &recipient_id, *amount) + { + eprintln!( + "Failed to update token balance from historical document: {}", + e + ); } } @@ -120,23 +113,16 @@ impl AppContext { MintResult::GroupActionWithDocument(_, Some(document)) => { if let (Some(recipient_value), Some(amount_value)) = (document.get("recipientId"), document.get("amount")) - { - if let (Value::Identifier(recipient_bytes), Value::U64(amount)) = + && let (Value::Identifier(recipient_bytes), Value::U64(amount)) = (recipient_value, amount_value) - { - if let Ok(recipient_id) = Identifier::from_bytes(recipient_bytes) { - if let Err(e) = self.insert_token_identity_balance( - &token_id, - &recipient_id, - *amount, - ) { - eprintln!( - "Failed to update token balance from group action document: {}", - e - ); - } - } - } + && let Ok(recipient_id) = Identifier::from_bytes(recipient_bytes) + && let Err(e) = + self.insert_token_identity_balance(&token_id, &recipient_id, *amount) + { + eprintln!( + "Failed to update token balance from group action document: {}", + e + ); } } diff --git a/src/backend_task/tokens/query_tokens.rs b/src/backend_task/tokens/query_tokens.rs index 1ea3e8d46..4020030c8 100644 --- a/src/backend_task/tokens/query_tokens.rs +++ b/src/backend_task/tokens/query_tokens.rs @@ -44,14 +44,14 @@ impl AppContext { // store the order for deterministic pagination let mut contract_ids: Vec = Vec::with_capacity(kw_docs.len()); for (_doc_id, doc_opt) in kw_docs.iter() { - if let Some(doc) = doc_opt { - if let Some(cid_val) = doc.get("contractId") { - contract_ids.push( - cid_val - .to_identifier() - .map_err(|e| format!("Bad contractId: {e}"))?, - ); - } + if let Some(doc) = doc_opt + && let Some(cid_val) = doc.get("contractId") + { + contract_ids.push( + cid_val + .to_identifier() + .map_err(|e| format!("Bad contractId: {e}"))?, + ); } } diff --git a/src/backend_task/tokens/set_token_price.rs b/src/backend_task/tokens/set_token_price.rs index eb5222ee1..7b8643e2e 100644 --- a/src/backend_task/tokens/set_token_price.rs +++ b/src/backend_task/tokens/set_token_price.rs @@ -31,9 +31,12 @@ impl AppContext { data_contract.clone(), token_position, sending_identity.identity.id(), - token_pricing_schedule, ); + if let Some(pricing_schedule) = token_pricing_schedule { + builder = builder.with_token_pricing_schedule(pricing_schedule); + } + if let Some(note) = public_note { builder = builder.with_public_note(note); } diff --git a/src/backend_task/tokens/transfer_tokens.rs b/src/backend_task/tokens/transfer_tokens.rs index 71e5d232c..93a7a2b8e 100644 --- a/src/backend_task/tokens/transfer_tokens.rs +++ b/src/backend_task/tokens/transfer_tokens.rs @@ -99,43 +99,39 @@ impl AppContext { document.get("senderAmount"), document.get("recipientId"), document.get("recipientAmount"), + ) && let ( + Value::Identifier(sender_bytes), + Value::U64(sender_amount), + Value::Identifier(recipient_bytes), + Value::U64(recipient_amount), + ) = ( + sender_value, + sender_amount_value, + recipient_value, + recipient_amount_value, + ) && let (Ok(sender_id), Ok(recipient_id)) = ( + Identifier::from_bytes(sender_bytes), + Identifier::from_bytes(recipient_bytes), ) { - if let ( - Value::Identifier(sender_bytes), - Value::U64(sender_amount), - Value::Identifier(recipient_bytes), - Value::U64(recipient_amount), - ) = ( - sender_value, - sender_amount_value, - recipient_value, - recipient_amount_value, + if let Err(e) = self.insert_token_identity_balance( + &token_id, + &sender_id, + *sender_amount, ) { - if let (Ok(sender_id), Ok(recipient_id)) = ( - Identifier::from_bytes(sender_bytes), - Identifier::from_bytes(recipient_bytes), - ) { - if let Err(e) = self.insert_token_identity_balance( - &token_id, - &sender_id, - *sender_amount, - ) { - eprintln!( - "Failed to update sender token balance from historical document: {}", - e - ); - } - if let Err(e) = self.insert_token_identity_balance( - &token_id, - &recipient_id, - *recipient_amount, - ) { - eprintln!( - "Failed to update recipient token balance from historical document: {}", - e - ); - } - } + eprintln!( + "Failed to update sender token balance from historical document: {}", + e + ); + } + if let Err(e) = self.insert_token_identity_balance( + &token_id, + &recipient_id, + *recipient_amount, + ) { + eprintln!( + "Failed to update recipient token balance from historical document: {}", + e + ); } } } @@ -152,43 +148,39 @@ impl AppContext { document.get("senderAmount"), document.get("recipientId"), document.get("recipientAmount"), + ) && let ( + Value::Identifier(sender_bytes), + Value::U64(sender_amount), + Value::Identifier(recipient_bytes), + Value::U64(recipient_amount), + ) = ( + sender_value, + sender_amount_value, + recipient_value, + recipient_amount_value, + ) && let (Ok(sender_id), Ok(recipient_id)) = ( + Identifier::from_bytes(sender_bytes), + Identifier::from_bytes(recipient_bytes), ) { - if let ( - Value::Identifier(sender_bytes), - Value::U64(sender_amount), - Value::Identifier(recipient_bytes), - Value::U64(recipient_amount), - ) = ( - sender_value, - sender_amount_value, - recipient_value, - recipient_amount_value, + if let Err(e) = self.insert_token_identity_balance( + &token_id, + &sender_id, + *sender_amount, ) { - if let (Ok(sender_id), Ok(recipient_id)) = ( - Identifier::from_bytes(sender_bytes), - Identifier::from_bytes(recipient_bytes), - ) { - if let Err(e) = self.insert_token_identity_balance( - &token_id, - &sender_id, - *sender_amount, - ) { - eprintln!( - "Failed to update sender token balance from group action document: {}", - e - ); - } - if let Err(e) = self.insert_token_identity_balance( - &token_id, - &recipient_id, - *recipient_amount, - ) { - eprintln!( - "Failed to update recipient token balance from group action document: {}", - e - ); - } - } + eprintln!( + "Failed to update sender token balance from group action document: {}", + e + ); + } + if let Err(e) = self.insert_token_identity_balance( + &token_id, + &recipient_id, + *recipient_amount, + ) { + eprintln!( + "Failed to update recipient token balance from group action document: {}", + e + ); } } } diff --git a/src/components/core_p2p_handler.rs b/src/components/core_p2p_handler.rs new file mode 100644 index 000000000..c7bee96e1 --- /dev/null +++ b/src/components/core_p2p_handler.rs @@ -0,0 +1,474 @@ +use chrono::Utc; +use dash_sdk::dpp::dashcore::BlockHash; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::dashcore::consensus::{deserialize, serialize}; +use dash_sdk::dpp::dashcore::network::constants::ServiceFlags; +use dash_sdk::dpp::dashcore::network::message::{NetworkMessage, RawNetworkMessage}; +use dash_sdk::dpp::dashcore::network::message_qrinfo::QRInfo; +use dash_sdk::dpp::dashcore::network::message_sml::{GetMnListDiff, MnListDiff}; +use dash_sdk::dpp::dashcore::network::{Address, message_network, message_qrinfo}; +use rand::prelude::StdRng; +use rand::{Rng, SeedableRng}; +use sha2::{Digest, Sha256}; +use std::io::{ErrorKind, Read, Write}; +use std::net::TcpStream; +use std::thread; +use std::time::Duration; + +#[derive(Debug)] +pub struct CoreP2PHandler { + pub network: Network, + pub port: u16, + pub stream: TcpStream, + pub handshake_success: bool, +} + +/// Dash P2P header length in bytes +const HEADER_LENGTH: usize = 24; + +/// Maximum message payload size (e.g. 0x02000000 bytes) +const MAX_MSG_LENGTH: usize = 0x02000000; + +/// Compute double-SHA256 on the given data. +fn double_sha256(data: &[u8]) -> [u8; 32] { + let hash1 = Sha256::digest(data); + let hash2 = Sha256::digest(hash1); + let mut result = [0u8; 32]; + result.copy_from_slice(&hash2); + result +} + +#[derive(Debug)] +enum ReadMessageError { + Transient, + Fatal(String), +} + +impl CoreP2PHandler { + pub fn new(network: Network, use_port: Option) -> Result { + let port = use_port.unwrap_or(match network { + Network::Dash => 9999, // Dash Mainnet default + Network::Testnet => 19999, // Dash Testnet default + Network::Devnet => 29999, // Dash Devnet default + Network::Regtest => 29999, // Dash Regtest default + _ => panic!("Unsupported network type"), + }); + let stream = TcpStream::connect_timeout( + &format!("127.0.0.1:{}", port) + .parse() + .map_err(|e| format!("Invalid address: {}", e))?, + Duration::from_secs(5), + ) + .map_err(|e| format!("Failed to connect: {}", e))?; + // Set per-socket timeouts so reads/writes don't block forever + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .map_err(|e| format!("set_read_timeout failed: {}", e))?; + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .map_err(|e| format!("set_write_timeout failed: {}", e))?; + println!("Connected to Dash Core at 127.0.0.1:{}", port); + Ok(CoreP2PHandler { + network, + port, + stream, + handshake_success: false, + }) + } + + /// Sends a network message over the provided stream and waits for a response. + pub fn send_dml_request_message( + &mut self, + network_message: NetworkMessage, + ) -> Result { + if !self.handshake_success { + self.handshake()?; + } + let stream = &mut self.stream; + let raw_message = RawNetworkMessage { + magic: self.network.magic(), + payload: network_message, + }; + let encoded_message = serialize(&raw_message); + stream + .write_all(&encoded_message) + .map_err(|e| format!("Failed to send message: {}", e))?; + println!("Sent getmnlistdiff message to Dash Core"); + + let (mut command, mut payload); + let start_time = std::time::Instant::now(); + let timeout = Duration::from_secs(5); + loop { + if start_time.elapsed() > timeout { + return Err("Timeout waiting for mnlistdiff message".to_string()); + } + match self.read_message() { + Ok((c, p)) => { + command = c; + payload = p; + } + Err(ReadMessageError::Transient) => { + thread::sleep(Duration::from_millis(10)); + continue; + } + Err(ReadMessageError::Fatal(e)) => return Err(e), + } + if command == "mnlistdiff" { + println!("Got mnlistdiff message"); + break; + } else { + thread::sleep(Duration::from_millis(10)); + } + } + + // let log_file_path = app_user_data_file_path("DML.DAT").expect("should create DML.dat"); + // let mut log_file = match std::fs::File::create(log_file_path) { + // Ok(file) => file, + // Err(e) => panic!("Failed to create log file: {:?}", e), + // }; + // + // log_file.write_all(&payload).expect("expected to write"); + + let response_message: RawNetworkMessage = deserialize(&payload).map_err(|e| { + format!( + "Failed to deserialize response: {}, payload {}", + e, + hex::encode(payload) + ) + })?; + + match response_message.payload { + NetworkMessage::MnListDiff(diff) => Ok(diff), + network_message => Err(format!( + "Unexpected response type, expected MnListDiff, got {:?}", + network_message + )), + } + } + + /// Sends a network message over the provided stream and waits for a response. + pub fn send_qr_info_request_message( + &mut self, + network_message: NetworkMessage, + ) -> Result { + if !self.handshake_success { + self.handshake()?; + } + let stream = &mut self.stream; + let raw_message = RawNetworkMessage { + magic: self.network.magic(), + payload: network_message, + }; + let encoded_message = serialize(&raw_message); + stream + .write_all(&encoded_message) + .map_err(|e| format!("Failed to send message: {}", e))?; + println!("Sent qr info request message to Dash Core"); + + let (mut command, mut payload); + // QRInfo on mainnet can take noticeably longer to prepare. + // Temporarily increase socket read timeout and our overall wait. + let (socket_timeout, overall_timeout) = match self.network { + Network::Dash => (Duration::from_secs(60), Duration::from_secs(60)), + _ => (Duration::from_secs(15), Duration::from_secs(15)), + }; + let previous_socket_timeout = self + .stream + .read_timeout() + .map_err(|e| format!("get_read_timeout failed: {}", e))?; + self + .stream + .set_read_timeout(Some(socket_timeout)) + .map_err(|e| format!("set_read_timeout failed: {}", e))?; + let start_time = std::time::Instant::now(); + let timeout = overall_timeout; + loop { + if start_time.elapsed() > timeout { + // Restore previous socket timeout before returning + self + .stream + .set_read_timeout(previous_socket_timeout) + .map_err(|e| format!("restore set_read_timeout failed: {}", e))?; + return Err("Timeout waiting for qrinfo message".to_string()); + } + match self.read_message() { + Ok((c, p)) => { + command = c; + payload = p; + } + Err(ReadMessageError::Transient) => { + thread::sleep(Duration::from_millis(10)); + continue; + } + Err(ReadMessageError::Fatal(e)) => return Err(e), + } + if command == "qrinfo" { + println!("Got qrinfo message"); + // Restore previous socket timeout + self + .stream + .set_read_timeout(previous_socket_timeout) + .map_err(|e| format!("restore set_read_timeout failed: {}", e))?; + break; + } else { + thread::sleep(Duration::from_millis(10)); + } + } + + // let log_file_path = app_user_data_file_path("QR_INFO.DAT").expect("should create DML.dat"); + // let mut log_file = match std::fs::File::create(log_file_path) { + // Ok(file) => file, + // Err(e) => panic!("Failed to create log file: {:?}", e), + // }; + // + // log_file.write_all(&payload).expect("expected to write"); + + let response_message: RawNetworkMessage = deserialize(&payload).map_err(|e| { + format!( + "Failed to deserialize response: {}, payload {}", + e, + hex::encode(payload) + ) + })?; + + match response_message.payload { + NetworkMessage::QRInfo(qr_info) => { + // let bytes = serialize(&qr_info); + // let log_file_path = app_user_data_file_path("QR_INFO.DAT").expect("should create DML.dat"); + // let mut log_file = match std::fs::File::create(log_file_path) { + // Ok(file) => file, + // Err(e) => panic!("Failed to create log file: {:?}", e), + // }; + // + // log_file.write_all(&bytes).expect("expected to write"); + Ok(qr_info) + } + network_message => Err(format!( + "Unexpected response type, expected QrInfo, got {:?}", + network_message + )), + } + } + + // Note: get_dml_diff and get_qr_info are already defined above (lines ~351 and ~364) + /// Perform the handshake (version/verack exchange) with the peer. + pub fn handshake(&mut self) -> Result<(), String> { + let mut rng = StdRng::from_entropy(); + + // Build a version message. + let version_msg = NetworkMessage::Version(message_network::VersionMessage { + version: 70235, + services: ServiceFlags::NONE, + timestamp: Utc::now().timestamp(), + receiver: Address { + services: ServiceFlags::BLOOM, + address: Default::default(), + port: self.stream.peer_addr().map_err(|e| e.to_string())?.port(), + }, + sender: Address { + services: ServiceFlags::NONE, + address: Default::default(), + port: self.stream.local_addr().map_err(|e| e.to_string())?.port(), + }, + nonce: rng.r#gen(), + user_agent: "/dash-evo-tool:0.9/".to_string(), + start_height: 0, + relay: false, + mn_auth_challenge: rng.r#gen(), + masternode_connection: false, + }); + + // Wrap it in a raw message. + let raw_version = RawNetworkMessage { + magic: self.network.magic(), + payload: version_msg, + }; + let encoded_version = serialize(&raw_version); + self.stream + .write_all(&encoded_version) + .map_err(|e| format!("Failed to send version: {}", e))?; + println!("Sent version message"); + + thread::sleep(Duration::from_millis(50)); + + // Read and process incoming messages until handshake is complete. + self.run_handshake_loop()?; + self.handshake_success = true; + Ok(()) + } + + fn read_message(&mut self) -> Result<(String, Vec), ReadMessageError> { + let mut header_buf = [0u8; HEADER_LENGTH]; + // Read the header. + self.stream + .read_exact(&mut header_buf) + .map_err(|e| match e.kind() { + ErrorKind::WouldBlock | ErrorKind::TimedOut => ReadMessageError::Transient, + _ => ReadMessageError::Fatal(format!("Error reading header: {}", e)), + })?; + + // If the first 4 bytes don't match our network magic, shift until we do. + const MAX_SYNC_ATTEMPTS: usize = 1024; // Prevent reading more than 1KB looking for magic + let mut sync_attempts = 0; + while u32::from_le_bytes(header_buf[0..4].try_into().unwrap()) != self.network.magic() { + sync_attempts += 1; + if sync_attempts > MAX_SYNC_ATTEMPTS { + return Err(ReadMessageError::Fatal( + "Failed to find network magic in stream".to_string(), + )); + } + // Shift left by one byte. + for i in 0..HEADER_LENGTH - 1 { + header_buf[i] = header_buf[i + 1]; + } + // Read one more byte. + let mut one_byte = [0u8; 1]; + self.stream + .read_exact(&mut one_byte) + .map_err(|e| match e.kind() { + ErrorKind::WouldBlock | ErrorKind::TimedOut => ReadMessageError::Transient, + _ => { + ReadMessageError::Fatal(format!("Error reading while syncing magic: {}", e)) + } + })?; + header_buf[HEADER_LENGTH - 1] = one_byte[0]; + } + + // Extract the command. + let command_bytes = &header_buf[4..16]; + let command = String::from_utf8_lossy(command_bytes) + .trim_matches('\0') + .to_string(); + + // Payload length (little-endian u32) + let payload_len_u32 = u32::from_le_bytes(header_buf[16..20].try_into().unwrap()); + if payload_len_u32 > MAX_MSG_LENGTH as u32 { + return Err(ReadMessageError::Fatal(format!( + "Payload length {} exceeds maximum", + payload_len_u32 + ))); + } + let payload_len = payload_len_u32 as usize; + + // Expected checksum. + let expected_checksum = &header_buf[20..24]; + + // Read the payload. + let mut payload_buf = vec![0u8; payload_len]; + self.stream + .read_exact(&mut payload_buf) + .map_err(|e| match e.kind() { + ErrorKind::WouldBlock | ErrorKind::TimedOut => ReadMessageError::Transient, + _ => ReadMessageError::Fatal(format!("Error reading payload: {}", e)), + })?; + + // Compute and verify checksum. + let computed_checksum = &double_sha256(&payload_buf)[0..4]; + if computed_checksum != expected_checksum { + return Err(ReadMessageError::Fatal(format!( + "Checksum mismatch for {}: computed {:x?}, expected {:x?}, payload is {:x?}", + command, computed_checksum, expected_checksum, payload_buf + ))); + } + let mut total_buf = header_buf.to_vec(); + total_buf.append(&mut payload_buf); + Ok((command, total_buf)) + } + + /// The handshake loop: read messages until we complete the version/verack exchange. + fn run_handshake_loop(&mut self) -> Result<(), String> { + // Expect a version message from the peer, with a timeout. + let start_time = std::time::Instant::now(); + let timeout = Duration::from_secs(5); + let (command, payload) = loop { + if start_time.elapsed() > timeout { + return Err("Timeout waiting for version message".to_string()); + } + match self.read_message() { + Ok(res) => break res, + Err(ReadMessageError::Transient) => { + thread::sleep(Duration::from_millis(10)); + continue; + } + Err(ReadMessageError::Fatal(e)) => return Err(e), + } + }; + if command != "version" { + return Err(format!("Expected version message, got {}", command)); + } + // Deserialize the version message payload. + let raw: RawNetworkMessage = deserialize(&payload) + .map_err(|e| format!("Failed to deserialize version payload: {}", e))?; + match raw.payload { + NetworkMessage::Version(peer_version) => { + println!("Received peer version: {:?}", peer_version); + } + _ => { + return Err("Deserialized message was not a version message".to_string()); + } + } + + let start_time = std::time::Instant::now(); + let timeout = Duration::from_secs(5); + loop { + if start_time.elapsed() > timeout { + return Err("Timeout waiting for verack message".to_string()); + } + let (command, _) = match self.read_message() { + Ok(res) => res, + Err(ReadMessageError::Transient) => { + thread::sleep(Duration::from_millis(10)); + continue; + } + Err(ReadMessageError::Fatal(e)) => return Err(e), + }; + if command == "verack" { + println!("Got verack message"); + break; + } else { + thread::sleep(Duration::from_millis(10)); + } + } + + // Send verack. + let verack_msg = NetworkMessage::Verack; + let raw_verack = RawNetworkMessage { + magic: self.network.magic(), + payload: verack_msg, + }; + let encoded_verack = serialize(&raw_verack); + self.stream + .write_all(&encoded_verack) + .map_err(|e| format!("Failed to send verack: {}", e))?; + + println!("Sent verack message"); + Ok(()) + } + + /// Sends a `GetMnListDiff` request after completing the handshake. + pub fn get_dml_diff( + &mut self, + base_block_hash: BlockHash, + block_hash: BlockHash, + ) -> Result { + let get_mnlist_diff_msg = NetworkMessage::GetMnListD(GetMnListDiff { + base_block_hash, + block_hash, + }); + self.send_dml_request_message(get_mnlist_diff_msg) + } + + /// Sends a `GetMnListDiff` request after completing the handshake. + pub fn get_qr_info( + &mut self, + known_block_hashes: Vec, + block_request_hash: BlockHash, + ) -> Result { + let get_mnlist_diff_msg = NetworkMessage::GetQRInfo(message_qrinfo::GetQRInfo { + base_block_hashes: known_block_hashes, + block_request_hash, + extra_share: true, + }); + self.send_qr_info_request_message(get_mnlist_diff_msg) + } +} diff --git a/src/components/core_zmq_listener.rs b/src/components/core_zmq_listener.rs index 86f35df32..5b8d6bdab 100644 --- a/src/components/core_zmq_listener.rs +++ b/src/components/core_zmq_listener.rs @@ -1,6 +1,6 @@ use crossbeam_channel::Sender; use dash_sdk::dpp::dashcore::consensus::Decodable; -use dash_sdk::dpp::dashcore::{Block, InstantLock, Network, Transaction}; +use dash_sdk::dpp::dashcore::{Block, ChainLock, InstantLock, Network, Transaction}; use dash_sdk::dpp::prelude::CoreBlockHeight; use std::error::Error; use std::io::Cursor; @@ -34,8 +34,7 @@ pub struct CoreZMQListener { pub enum ZMQMessage { ISLockedTransaction(Transaction, InstantLock), - ChainLockedBlock(#[allow(dead_code)] Block), - #[allow(dead_code)] // May be used for chain-locked transactions + ChainLockedBlock(Block, ChainLock), ChainLockedLockedTransaction(Transaction, CoreBlockHeight), } @@ -138,7 +137,7 @@ impl CoreZMQListener { let data_bytes = data_message.as_bytes(); match topic { - "rawchainlock" => { + "rawchainlocksig" => { // println!("Received raw chain locked block:"); // println!("Data (hex): {}", hex::encode(data_bytes)); @@ -148,20 +147,33 @@ impl CoreZMQListener { // Deserialize the LLMQChainLock match Block::consensus_decode(&mut cursor) { Ok(block) => { - // Send the ChainLock and Network back to the main thread - if let Err(e) = sender.send(( - ZMQMessage::ChainLockedBlock(block), - network, - )) { - eprintln!( - "Error sending data to main thread: {}", - e - ); + match ChainLock::consensus_decode(&mut cursor) { + Ok(chain_lock) => { + // Send the ChainLock and Network back to the main thread + if let Err(e) = sender.send(( + ZMQMessage::ChainLockedBlock( + block, chain_lock, + ), + network, + )) { + eprintln!( + "Error sending data to main thread: {}", + e + ); + } + } + Err(e) => { + eprintln!( + "Error deserializing InstantLock: {}", + e + ); + } } } Err(e) => { eprintln!( - "Error deserializing chain locked block: {}", + "Error deserializing chain locked block: bytes({}) error: {}", + hex::encode(data_bytes), e ); } diff --git a/src/components/mod.rs b/src/components/mod.rs index 6339bfa30..63b1335f0 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1 +1,2 @@ +pub mod core_p2p_handler; pub mod core_zmq_listener; diff --git a/src/context.rs b/src/context.rs index c71f28694..883b57aaa 100644 --- a/src/context.rs +++ b/src/context.rs @@ -517,7 +517,7 @@ impl AppContext { /// The cache is invalidated immediately and the guard prevents concurrent access /// until the database operation is complete. This ensures atomicity and prevents /// race conditions regardless of whether the database operation succeeds or fails. - pub fn invalidate_settings_cache(&self) -> SettingsCacheGuard { + pub fn invalidate_settings_cache(&'_ self) -> SettingsCacheGuard<'_> { let mut guard = self.cached_settings.write().unwrap(); *guard = None; guard diff --git a/src/database/contracts.rs b/src/database/contracts.rs index 72e2dd765..9144f5915 100644 --- a/src/database/contracts.rs +++ b/src/database/contracts.rs @@ -56,29 +56,28 @@ impl Database { InsertTokensToo::SomeTokensShouldBeAdded(positions) => positions, }; for token_contract_position in positions { - if let Some(token_id) = data_contract.token_id(token_contract_position) { - if let Ok(token_configuration) = + if let Some(token_id) = data_contract.token_id(token_contract_position) + && let Ok(token_configuration) = data_contract.expected_token_configuration(token_contract_position) - { - let config = config::standard(); - let Some(serialized_token_configuration) = - bincode::encode_to_vec(token_configuration, config).ok() - else { - // We should always be able to serialize - return Ok(()); - }; - let token_name = token_configuration - .conventions() - .singular_form_by_language_code_or_default("en"); - self.insert_token( - &token_id, - token_name, - serialized_token_configuration.as_slice(), - &data_contract.id(), - token_contract_position, - app_context, - )?; - } + { + let config = config::standard(); + let Some(serialized_token_configuration) = + bincode::encode_to_vec(token_configuration, config).ok() + else { + // We should always be able to serialize + return Ok(()); + }; + let token_name = token_configuration + .conventions() + .singular_form_by_language_code_or_default("en"); + self.insert_token( + &token_id, + token_name, + serialized_token_configuration.as_slice(), + &data_contract.id(), + token_contract_position, + app_context, + )?; } } } diff --git a/src/database/wallet.rs b/src/database/wallet.rs index 7ad878bbc..72dbd23c3 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -8,7 +8,6 @@ use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::transaction::special_transaction::TransactionPayload; use dash_sdk::dpp::balances::credits::Duffs; use dash_sdk::dpp::dashcore::address::{NetworkChecked, NetworkUnchecked}; -use dash_sdk::dpp::dashcore::bip32::{DerivationPath, ExtendedPubKey}; use dash_sdk::dpp::dashcore::consensus::deserialize; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::{ @@ -17,6 +16,7 @@ use dash_sdk::dpp::dashcore::{ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, ExtendedPubKey}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::{AssetLockProof, CoreBlockHeight}; use rusqlite::params; diff --git a/src/model/qualified_identity/encrypted_key_storage.rs b/src/model/qualified_identity/encrypted_key_storage.rs index c5fb77ec7..8bfe92ca1 100644 --- a/src/model/qualified_identity/encrypted_key_storage.rs +++ b/src/model/qualified_identity/encrypted_key_storage.rs @@ -6,10 +6,10 @@ use bincode::enc::Encoder; use bincode::error::{DecodeError, EncodeError}; use bincode::{BorrowDecode, Decode, Encode}; use dash_sdk::dashcore_rpc::dashcore::Network; -use dash_sdk::dashcore_rpc::dashcore::bip32::DerivationPath; -use dash_sdk::dpp::dashcore::bip32::ChildNumber; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{KeyID, Purpose, SecurityLevel}; +use dash_sdk::dpp::key_wallet::bip32::ChildNumber; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::sync::{Arc, RwLock}; diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 7f7e16b10..a09d8cbf8 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -8,7 +8,6 @@ use bincode::{Decode, Encode}; use dash_sdk::dashcore_rpc::dashcore::{PubkeyHash, signer}; use dash_sdk::dpp::bls_signatures::{Bls12381G2Impl, SignatureSchemes}; use dash_sdk::dpp::dashcore::address::Payload; -use dash_sdk::dpp::dashcore::bip32::ChildNumber; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::{Address, Network, ScriptHash}; use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; @@ -20,6 +19,7 @@ use dash_sdk::dpp::identity::hash::IdentityPublicKeyHashMethodsV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::signer::Signer; use dash_sdk::dpp::identity::{Identity, KeyID, KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::key_wallet::bip32::ChildNumber; use dash_sdk::dpp::platform_value::BinaryData; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::state_transition::errors::InvalidIdentityPublicKeyTypeError; diff --git a/src/model/wallet/asset_lock_transaction.rs b/src/model/wallet/asset_lock_transaction.rs index 1e3265a06..ce8e13099 100644 --- a/src/model/wallet/asset_lock_transaction.rs +++ b/src/model/wallet/asset_lock_transaction.rs @@ -1,7 +1,6 @@ use crate::context::AppContext; use crate::model::wallet::Wallet; use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; -use dash_sdk::dpp::dashcore::psbt::serialize::Serialize; use dash_sdk::dpp::dashcore::secp256k1::Message; use dash_sdk::dpp::dashcore::sighash::SighashCache; use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload; @@ -9,6 +8,7 @@ use dash_sdk::dpp::dashcore::transaction::special_transaction::asset_lock::Asset use dash_sdk::dpp::dashcore::{ Address, Network, OutPoint, PrivateKey, ScriptBuf, Transaction, TxIn, TxOut, }; +use dash_sdk::dpp::key_wallet::psbt::serialize::Serialize; use std::collections::BTreeMap; impl Wallet { diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index c7d880490..52a1929c2 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -2,12 +2,12 @@ mod asset_lock_transaction; pub mod encryption; mod utxos; -use dash_sdk::dashcore_rpc::dashcore::bip32::{ChildNumber, ExtendedPubKey, KeyDerivationType}; +use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, ExtendedPubKey, KeyDerivationType}; -use dash_sdk::dpp::dashcore::bip32::DerivationPath; use dash_sdk::dpp::dashcore::{ Address, InstantLock, Network, OutPoint, PrivateKey, PublicKey, Transaction, TxOut, }; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use std::collections::{BTreeMap, HashMap}; use std::fmt::Debug; use std::ops::Range; @@ -776,11 +776,11 @@ impl Wallet { context: &AppContext, ) -> Result<(), String> { // Check if the new balance differs from the current one. - if let Some(current_balance) = self.address_balances.get(address) { - if *current_balance == new_balance { - // If the balance hasn't changed, skip the update. - return Ok(()); - } + if let Some(current_balance) = self.address_balances.get(address) + && *current_balance == new_balance + { + // If the balance hasn't changed, skip the update. + return Ok(()); } // If there's no current balance or it has changed, update it. diff --git a/src/ui/components/amount_input.rs b/src/ui/components/amount_input.rs index da497541f..0230eb91f 100644 --- a/src/ui/components/amount_input.rs +++ b/src/ui/components/amount_input.rs @@ -262,24 +262,24 @@ impl AmountInput { } // Check if amount exceeds maximum - if let Some(max_amount) = self.max_amount { - if amount.value() > max_amount { - return Err(format!( - "Amount {} exceeds allowed maximum {}", - amount, - Amount::new(max_amount, self.decimal_places) - )); - } + if let Some(max_amount) = self.max_amount + && amount.value() > max_amount + { + return Err(format!( + "Amount {} exceeds allowed maximum {}", + amount, + Amount::new(max_amount, self.decimal_places) + )); } // Check if amount is below minimum - if let Some(min_amount) = self.min_amount { - if amount.value() < min_amount { - return Err(format!( - "Amount must be at least {}", - Amount::new(min_amount, self.decimal_places) - )); - } + if let Some(min_amount) = self.min_amount + && amount.value() < min_amount + { + return Err(format!( + "Amount must be at least {}", + Amount::new(min_amount, self.decimal_places) + )); } Ok(Some(amount)) diff --git a/src/ui/components/contract_chooser_panel.rs b/src/ui/components/contract_chooser_panel.rs index ea24d24e8..1738f67aa 100644 --- a/src/ui/components/contract_chooser_panel.rs +++ b/src/ui/components/contract_chooser_panel.rs @@ -540,63 +540,62 @@ pub fn add_contract_chooser_panel( }); // Show context menu if right-clicked - if chooser_state.show_context_menu { - if let Some(ref contract_id_str) = chooser_state.right_click_contract_id { - // Find the contract that was right-clicked - let contract_opt = contracts - .iter() - .find(|c| c.contract.id().to_string(Encoding::Base58) == *contract_id_str); - - if let Some(contract) = contract_opt { - egui::Window::new("Contract Menu") - .id(egui::Id::new("contract_context_menu")) - .title_bar(false) - .resizable(false) - .collapsible(false) - .fixed_pos(chooser_state.context_menu_position) - .show(ctx, |ui| { - ui.set_min_width(150.0); - - // Copy Hex option - if ui.button("Copy (Hex)").clicked() { - // Serialize contract to bytes - if let Ok(bytes) = - contract.contract.serialize_to_bytes_with_platform_version( - app_context.platform_version(), - ) - { - let hex_string = hex::encode(&bytes); - ui.ctx().copy_text(hex_string); - } - chooser_state.show_context_menu = false; + if chooser_state.show_context_menu + && let Some(ref contract_id_str) = chooser_state.right_click_contract_id + { + // Find the contract that was right-clicked + let contract_opt = contracts + .iter() + .find(|c| c.contract.id().to_string(Encoding::Base58) == *contract_id_str); + + if let Some(contract) = contract_opt { + egui::Window::new("Contract Menu") + .id(egui::Id::new("contract_context_menu")) + .title_bar(false) + .resizable(false) + .collapsible(false) + .fixed_pos(chooser_state.context_menu_position) + .show(ctx, |ui| { + ui.set_min_width(150.0); + + // Copy Hex option + if ui.button("Copy (Hex)").clicked() { + // Serialize contract to bytes + if let Ok(bytes) = + contract.contract.serialize_to_bytes_with_platform_version( + app_context.platform_version(), + ) + { + let hex_string = hex::encode(&bytes); + ui.ctx().copy_text(hex_string); } + chooser_state.show_context_menu = false; + } - // Copy JSON option - if ui.button("Copy (JSON)").clicked() { - // Convert contract to JSON - if let Ok(json_value) = - contract.contract.to_json(app_context.platform_version()) - { - if let Ok(json_string) = serde_json::to_string_pretty(&json_value) { - ui.ctx().copy_text(json_string); - } - } - chooser_state.show_context_menu = false; - } - }); - - // Close menu if clicked elsewhere - if ctx.input(|i| i.pointer.any_click()) { - // Check if click was outside the menu - let menu_rect = egui::Rect::from_min_size( - chooser_state.context_menu_position, - egui::vec2(150.0, 70.0), // Approximate size - ); - if let Some(pointer_pos) = ctx.pointer_interact_pos() { - if !menu_rect.contains(pointer_pos) { - chooser_state.show_context_menu = false; + // Copy JSON option + if ui.button("Copy (JSON)").clicked() { + // Convert contract to JSON + if let Ok(json_value) = + contract.contract.to_json(app_context.platform_version()) + && let Ok(json_string) = serde_json::to_string_pretty(&json_value) + { + ui.ctx().copy_text(json_string); } + chooser_state.show_context_menu = false; } + }); + + // Close menu if clicked elsewhere + if ctx.input(|i| i.pointer.any_click()) { + // Check if click was outside the menu + let menu_rect = egui::Rect::from_min_size( + chooser_state.context_menu_position, + egui::vec2(150.0, 70.0), // Approximate size + ); + if let Some(pointer_pos) = ctx.pointer_interact_pos() + && !menu_rect.contains(pointer_pos) + { + chooser_state.show_context_menu = false; } } } diff --git a/src/ui/components/identity_selector.rs b/src/ui/components/identity_selector.rs index f62d7112d..a30227dd3 100644 --- a/src/ui/components/identity_selector.rs +++ b/src/ui/components/identity_selector.rs @@ -187,16 +187,16 @@ impl<'a> Widget for IdentitySelector<'a> { } // If the "Other" option is disabled, we automatically select first identity - if !self.other_option && self.identity_str.is_empty() { - if let Some(first_identity) = self + if !self.other_option + && self.identity_str.is_empty() + && let Some(first_identity) = self .identities .keys() .find(|id| !self.exclude_identities.contains(id)) - { - *self.identity_str = first_identity.to_string(Encoding::Base58); - // trigger change handling to update the selected identity - self.on_change(); - } + { + *self.identity_str = first_identity.to_string(Encoding::Base58); + // trigger change handling to update the selected identity + self.on_change(); } // Check if current identity_str matches any existing identity; current_identity = None means diff --git a/src/ui/components/tools_subscreen_chooser_panel.rs b/src/ui/components/tools_subscreen_chooser_panel.rs index f213865af..739e41b5a 100644 --- a/src/ui/components/tools_subscreen_chooser_panel.rs +++ b/src/ui/components/tools_subscreen_chooser_panel.rs @@ -12,6 +12,7 @@ pub enum ToolsSubscreen { ProofViewer, ContractViewer, PlatformInfo, + MasternodeListDiff, } impl ToolsSubscreen { @@ -23,6 +24,7 @@ impl ToolsSubscreen { Self::DocumentViewer => "Document deserializer", Self::ContractViewer => "Contract deserializer", Self::PlatformInfo => "Platform info", + Self::MasternodeListDiff => "Masternode list diff inspector", } } } @@ -38,6 +40,7 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext ToolsSubscreen::DocumentViewer, ToolsSubscreen::ContractViewer, ToolsSubscreen::PlatformInfo, + ToolsSubscreen::MasternodeListDiff, ]; let active_screen = match app_context.get_settings() { @@ -54,6 +57,9 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext ToolsSubscreen::ContractViewer } ui::RootScreenType::RootScreenToolsPlatformInfoScreen => ToolsSubscreen::PlatformInfo, + ui::RootScreenType::RootScreenToolsMasternodeListDiffScreen => { + ToolsSubscreen::MasternodeListDiff + } _ => ToolsSubscreen::ProofLog, }, _ => ToolsSubscreen::ProofLog, // Fallback to Active screen if settings unavailable @@ -118,39 +124,42 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext if ui.add(button).clicked() { // Handle navigation based on which subscreen is selected match subscreen { - ToolsSubscreen::ProofLog => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenToolsProofLogScreen, - ) - } - ToolsSubscreen::TransactionViewer => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenToolsTransitionVisualizerScreen, - ) - } - ToolsSubscreen::ProofViewer => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenToolsProofVisualizerScreen, - ) - } - ToolsSubscreen::DocumentViewer => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenToolsDocumentVisualizerScreen, - ) + ToolsSubscreen::ProofLog => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenToolsProofLogScreen, + ) + } + ToolsSubscreen::TransactionViewer => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenToolsTransitionVisualizerScreen, + ) + } + ToolsSubscreen::ProofViewer => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenToolsProofVisualizerScreen, + ) + } + ToolsSubscreen::DocumentViewer => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenToolsDocumentVisualizerScreen, + ) + } + ToolsSubscreen::ContractViewer => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenToolsContractVisualizerScreen, + ) + } + ToolsSubscreen::PlatformInfo => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenToolsPlatformInfoScreen, + ) + } + ToolsSubscreen::MasternodeListDiff => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenToolsMasternodeListDiffScreen) + } + } } - ToolsSubscreen::ContractViewer => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenToolsContractVisualizerScreen, - ) - } - ToolsSubscreen::PlatformInfo => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenToolsPlatformInfoScreen, - ) - } - } - } - ui.add_space(Spacing::SM); } }); diff --git a/src/ui/contracts_documents/contracts_documents_screen.rs b/src/ui/contracts_documents/contracts_documents_screen.rs index b164646c5..ec9f32388 100644 --- a/src/ui/contracts_documents/contracts_documents_screen.rs +++ b/src/ui/contracts_documents/contracts_documents_screen.rs @@ -698,13 +698,13 @@ impl ScreenLike for DocumentQueryScreen { &mut self.contract_chooser_state, ); - if let AppAction::BackendTask(BackendTask::ContractTask(contract_task)) = &action { - if let ContractTask::RemoveContract(contract_id) = **contract_task { - action = AppAction::None; - self.contract_to_remove = Some(contract_id); - // Clear any existing dialog to create a new one with updated content - self.confirmation_dialog = None; - } + if let AppAction::BackendTask(BackendTask::ContractTask(contract_task)) = &action + && let ContractTask::RemoveContract(contract_id) = **contract_task + { + action = AppAction::None; + self.contract_to_remove = Some(contract_id); + // Clear any existing dialog to create a new one with updated content + self.confirmation_dialog = None; } // Custom central panel with adjusted margins for Document Query screen @@ -774,10 +774,8 @@ fn doc_to_filtered_string( let mut filtered_map = serde_json::Map::new(); for (field_name, &is_checked) in selected_fields { - if is_checked { - if let Some(field_value) = obj.get(field_name) { - filtered_map.insert(field_name.clone(), field_value.clone()); - } + if is_checked && let Some(field_value) = obj.get(field_name) { + filtered_map.insert(field_name.clone(), field_value.clone()); } } diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index b5bbe761f..ae35c0826 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -379,11 +379,11 @@ impl DocumentActionScreen { } } - if let Some(backend_message) = &self.backend_message { - if backend_message.contains("No owned documents found") { - ui.add_space(10.0); - ui.label("No owned documents found."); - } + if let Some(backend_message) = &self.backend_message + && backend_message.contains("No owned documents found") + { + ui.add_space(10.0); + ui.label("No owned documents found."); } // Show fetching status diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index 3418eb419..a7b24b695 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -601,15 +601,15 @@ impl ScreenLike for GroupActionsScreen { _ => {} } - if fetch_clicked { - if let (Some(contract), Some(identity)) = ( + if fetch_clicked + && let (Some(contract), Some(identity)) = ( self.selected_contract.clone(), self.selected_identity.clone(), - ) { - action |= AppAction::BackendTask(BackendTask::ContractTask(Box::new( - ContractTask::FetchActiveGroupActions(contract, identity), - ))); - } + ) + { + action |= AppAction::BackendTask(BackendTask::ContractTask(Box::new( + ContractTask::FetchActiveGroupActions(contract, identity), + ))); } if let FetchGroupActionsStatus::Complete(group_actions) = diff --git a/src/ui/contracts_documents/register_contract_screen.rs b/src/ui/contracts_documents/register_contract_screen.rs index 2aa2f2f42..71a8b7048 100644 --- a/src/ui/contracts_documents/register_contract_screen.rs +++ b/src/ui/contracts_documents/register_contract_screen.rs @@ -205,15 +205,15 @@ impl RegisterDataContractScreen { } } - if let AppAction::BackendTask(BackendTask::ContractTask(contract_task)) = &app_action { - if let ContractTask::RegisterDataContract(_, _, _, _) = **contract_task { - self.broadcast_status = BroadcastStatus::Broadcasting( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(), - ); - } + if let AppAction::BackendTask(BackendTask::ContractTask(contract_task)) = &app_action + && let ContractTask::RegisterDataContract(_, _, _, _) = **contract_task + { + self.broadcast_status = BroadcastStatus::Broadcasting( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + ); } app_action diff --git a/src/ui/contracts_documents/update_contract_screen.rs b/src/ui/contracts_documents/update_contract_screen.rs index bc1cb1f6c..5c52dc131 100644 --- a/src/ui/contracts_documents/update_contract_screen.rs +++ b/src/ui/contracts_documents/update_contract_screen.rs @@ -248,15 +248,15 @@ impl UpdateDataContractScreen { } } - if let AppAction::BackendTask(BackendTask::ContractTask(contract_task)) = &app_action { - if let ContractTask::UpdateDataContract(_, _, _) = **contract_task { - self.broadcast_status = BroadcastStatus::FetchingNonce( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(), - ); - } + if let AppAction::BackendTask(BackendTask::ContractTask(contract_task)) = &app_action + && let ContractTask::UpdateDataContract(_, _, _) = **contract_task + { + self.broadcast_status = BroadcastStatus::FetchingNonce( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + ); } app_action diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 36dbae643..43195990c 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -1117,13 +1117,13 @@ impl DPNSScreen { vote.1 = ScheduledVoteCastingStatus::InProgress; // Mark in our Arc as well - if let Ok(mut sched_guard) = self.scheduled_votes.lock() { - if let Some(t) = sched_guard.iter_mut().find(|(sv, _)| { + if let Ok(mut sched_guard) = self.scheduled_votes.lock() + && let Some(t) = sched_guard.iter_mut().find(|(sv, _)| { sv.voter_id == vote.0.voter_id && sv.contested_name == vote.0.contested_name - }) { - t.1 = ScheduledVoteCastingStatus::InProgress; - } + }) + { + t.1 = ScheduledVoteCastingStatus::InProgress; } // dispatch the actual cast let local_ids = @@ -1876,12 +1876,12 @@ impl ScreenLike for DPNSScreen { } } BackendTaskSuccessResult::CastScheduledVote(vote) => { - if let Ok(mut guard) = self.scheduled_votes.lock() { - if let Some((_, status)) = guard.iter_mut().find(|(v, _)| { + if let Ok(mut guard) = self.scheduled_votes.lock() + && let Some((_, status)) = guard.iter_mut().find(|(v, _)| { v.contested_name == vote.contested_name && v.voter_id == vote.voter_id - }) { - *status = ScheduledVoteCastingStatus::Completed; - } + }) + { + *status = ScheduledVoteCastingStatus::Completed; } } _ => {} @@ -2144,10 +2144,10 @@ impl ScreenLike for DPNSScreen { } // If we have a pending backend task from scheduling (e.g. after immediate votes) - if action == AppAction::None { - if let Some(bt) = self.pending_backend_task.take() { - action = AppAction::BackendTask(bt); - } + if action == AppAction::None + && let Some(bt) = self.pending_backend_task.take() + { + action = AppAction::BackendTask(bt); } action } diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index d6b72eada..7edafbb4f 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -265,23 +265,25 @@ pub fn add_identity_key_chooser_with_doc_type<'a, T>( let allowed_purposes = transaction_type.allowed_purposes(); let allowed_security_levels = if transaction_type == TransactionType::DocumentAction - && document_type.is_some() { - // For document actions with a specific document type, use its security requirement - let required_level = - document_type.unwrap().security_level_requirement(); - let allowed_levels = - SecurityLevel::CRITICAL as u8..=required_level as u8; - let allowed_levels: Vec = [ - SecurityLevel::CRITICAL, - SecurityLevel::HIGH, - SecurityLevel::MEDIUM, - ] - .iter() - .cloned() - .filter(|level| allowed_levels.contains(&(*level as u8))) - .collect(); - allowed_levels + if let Some(document_type) = document_type { + // For document actions with a specific document type, use its security requirement + let required_level = document_type.security_level_requirement(); + let allowed_levels = + SecurityLevel::CRITICAL as u8..=required_level as u8; + let allowed_levels: Vec = [ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ] + .iter() + .cloned() + .filter(|level| allowed_levels.contains(&(*level as u8))) + .collect(); + allowed_levels + } else { + transaction_type.allowed_security_levels() + } } else { transaction_type.allowed_security_levels() }; diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index 5de98ea6c..fbb73777b 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -892,16 +892,15 @@ impl ScreenLike for AddNewIdentityScreen { match *step { WalletFundedScreenStep::ChooseFundingMethod => {} WalletFundedScreenStep::WaitingOnFunds => { - if let Some(funding_address) = self.funding_address.as_ref() { - if let BackendTaskSuccessResult::CoreItem( + if let Some(funding_address) = self.funding_address.as_ref() + && let BackendTaskSuccessResult::CoreItem( CoreItem::ReceivedAvailableUTXOTransaction(_, outpoints_with_addresses), ) = backend_task_success_result - { - for (outpoint, tx_out, address) in outpoints_with_addresses { - if funding_address == &address { - *step = WalletFundedScreenStep::FundsReceived; - self.funding_utxo = Some((outpoint, tx_out, address)) - } + { + for (outpoint, tx_out, address) in outpoints_with_addresses { + if funding_address == &address { + *step = WalletFundedScreenStep::FundsReceived; + self.funding_utxo = Some((outpoint, tx_out, address)) } } } @@ -912,27 +911,23 @@ impl ScreenLike for AddNewIdentityScreen { if let BackendTaskSuccessResult::CoreItem( CoreItem::ReceivedAvailableUTXOTransaction(tx, _), ) = backend_task_success_result - { - if let Some(TransactionPayload::AssetLockPayloadType(asset_lock_payload)) = + && let Some(TransactionPayload::AssetLockPayloadType(asset_lock_payload)) = tx.special_transaction_payload - { - if asset_lock_payload.credit_outputs.iter().any(|tx_out| { - let Ok(address) = Address::from_script( - &tx_out.script_pubkey, - self.app_context.network, - ) else { - return false; - }; - if let Some(wallet) = &self.selected_wallet { - let wallet = wallet.read().unwrap(); - wallet.known_addresses.contains_key(&address) - } else { - false - } - }) { - *step = WalletFundedScreenStep::WaitingForPlatformAcceptance; + && asset_lock_payload.credit_outputs.iter().any(|tx_out| { + let Ok(address) = + Address::from_script(&tx_out.script_pubkey, self.app_context.network) + else { + return false; + }; + if let Some(wallet) = &self.selected_wallet { + let wallet = wallet.read().unwrap(); + wallet.known_addresses.contains_key(&address) + } else { + false } - } + }) + { + *step = WalletFundedScreenStep::WaitingForPlatformAcceptance; } } WalletFundedScreenStep::WaitingForPlatformAcceptance => { diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index dff31771a..523f70fcb 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -123,10 +123,10 @@ impl IdentitiesScreen { if desired_idx >= lock.len() { break; } - if let Some(current_idx) = lock.get_index_of(&id) { - if current_idx != desired_idx { - lock.swap_indices(current_idx, desired_idx); - } + if let Some(current_idx) = lock.get_index_of(&id) + && current_idx != desired_idx + { + lock.swap_indices(current_idx, desired_idx); } } } @@ -197,17 +197,14 @@ impl IdentitiesScreen { } fn wallet_name_for(&self, qi: &QualifiedIdentity) -> String { - if let Some(master_identity_public_key) = qi.private_keys.find_master_key() { - if let Some(wallet_derivation_path) = + if let Some(master_identity_public_key) = qi.private_keys.find_master_key() + && let Some(wallet_derivation_path) = &master_identity_public_key.in_wallet_at_derivation_path - { - if let Some(alias) = self - .wallet_seed_hash_cache - .get(&wallet_derivation_path.wallet_seed_hash) - { - return alias.clone(); - } - } + && let Some(alias) = self + .wallet_seed_hash_cache + .get(&wallet_derivation_path.wallet_seed_hash) + { + return alias.clone(); } "".to_owned() } @@ -272,10 +269,10 @@ impl IdentitiesScreen { // Up/down reorder methods fn move_identity_up(&mut self, identity_id: &Identifier) { let mut lock = self.identities.lock().unwrap(); - if let Some(idx) = lock.get_index_of(identity_id) { - if idx > 0 { - lock.swap_indices(idx, idx - 1); - } + if let Some(idx) = lock.get_index_of(identity_id) + && idx > 0 + { + lock.swap_indices(idx, idx - 1); } drop(lock); self.save_current_order(); @@ -284,10 +281,10 @@ impl IdentitiesScreen { // arrow down fn move_identity_down(&mut self, identity_id: &Identifier) { let mut lock = self.identities.lock().unwrap(); - if let Some(idx) = lock.get_index_of(identity_id) { - if idx + 1 < lock.len() { - lock.swap_indices(idx, idx + 1); - } + if let Some(idx) = lock.get_index_of(identity_id) + && idx + 1 < lock.len() + { + lock.swap_indices(idx, idx + 1); } drop(lock); self.save_current_order(); @@ -308,10 +305,10 @@ impl IdentitiesScreen { // basically reorder the underlying IndexMap to match ephemeral_list for (desired_idx, qi) in ephemeral_list.into_iter().enumerate() { let id = qi.identity.id(); - if let Some(current_idx) = lock.get_index_of(&id) { - if current_idx != desired_idx { - lock.swap_indices(current_idx, desired_idx); - } + if let Some(current_idx) = lock.get_index_of(&id) + && current_idx != desired_idx + { + lock.swap_indices(current_idx, desired_idx); } } } diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index d3e7878a4..13d844691 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -375,16 +375,15 @@ impl ScreenLike for TopUpIdentityScreen { match *step { WalletFundedScreenStep::ChooseFundingMethod => {} WalletFundedScreenStep::WaitingOnFunds => { - if let Some(funding_address) = self.funding_address.as_ref() { - if let BackendTaskSuccessResult::CoreItem( + if let Some(funding_address) = self.funding_address.as_ref() + && let BackendTaskSuccessResult::CoreItem( CoreItem::ReceivedAvailableUTXOTransaction(_, outpoints_with_addresses), ) = backend_task_success_result - { - for (outpoint, tx_out, address) in outpoints_with_addresses { - if funding_address == &address { - *step = WalletFundedScreenStep::FundsReceived; - self.funding_utxo = Some((outpoint, tx_out, address)) - } + { + for (outpoint, tx_out, address) in outpoints_with_addresses { + if funding_address == &address { + *step = WalletFundedScreenStep::FundsReceived; + self.funding_utxo = Some((outpoint, tx_out, address)) } } } @@ -395,27 +394,23 @@ impl ScreenLike for TopUpIdentityScreen { if let BackendTaskSuccessResult::CoreItem( CoreItem::ReceivedAvailableUTXOTransaction(tx, _), ) = backend_task_success_result - { - if let Some(TransactionPayload::AssetLockPayloadType(asset_lock_payload)) = + && let Some(TransactionPayload::AssetLockPayloadType(asset_lock_payload)) = tx.special_transaction_payload - { - if asset_lock_payload.credit_outputs.iter().any(|tx_out| { - let Ok(address) = Address::from_script( - &tx_out.script_pubkey, - self.app_context.network, - ) else { - return false; - }; - if let Some(wallet) = &self.wallet { - let wallet = wallet.read().unwrap(); - wallet.known_addresses.contains_key(&address) - } else { - false - } - }) { - *step = WalletFundedScreenStep::WaitingForPlatformAcceptance; + && asset_lock_payload.credit_outputs.iter().any(|tx_out| { + let Ok(address) = + Address::from_script(&tx_out.script_pubkey, self.app_context.network) + else { + return false; + }; + if let Some(wallet) = &self.wallet { + let wallet = wallet.read().unwrap(); + wallet.known_addresses.contains_key(&address) + } else { + false } - } + }) + { + *step = WalletFundedScreenStep::WaitingForPlatformAcceptance; } } WalletFundedScreenStep::WaitingForPlatformAcceptance => { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b7fb41094..415ca385f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -23,6 +23,7 @@ use crate::ui::tokens::transfer_tokens_screen::TransferTokensScreen; use crate::ui::tokens::view_token_claims_screen::ViewTokenClaimsScreen; use crate::ui::tools::contract_visualizer_screen::ContractVisualizerScreen; use crate::ui::tools::document_visualizer_screen::DocumentVisualizerScreen; +use crate::ui::tools::masternode_list_diff_screen::MasternodeListDiffScreen; use crate::ui::tools::platform_info_screen::PlatformInfoScreen; use crate::ui::tools::proof_log_screen::ProofLogScreen; use crate::ui::tools::proof_visualizer_screen::ProofVisualizerScreen; @@ -87,6 +88,7 @@ pub enum RootScreenType { RootScreenMyTokenBalances, RootScreenTokenSearch, RootScreenTokenCreator, + RootScreenToolsMasternodeListDiffScreen, RootScreenToolsContractVisualizerScreen, RootScreenToolsPlatformInfoScreen, } @@ -113,6 +115,7 @@ impl RootScreenType { RootScreenType::RootScreenToolsDocumentVisualizerScreen => 15, RootScreenType::RootScreenToolsContractVisualizerScreen => 16, RootScreenType::RootScreenToolsPlatformInfoScreen => 17, + RootScreenType::RootScreenToolsMasternodeListDiffScreen => 18, } } @@ -137,6 +140,7 @@ impl RootScreenType { 15 => Some(RootScreenType::RootScreenToolsDocumentVisualizerScreen), 16 => Some(RootScreenType::RootScreenToolsContractVisualizerScreen), 17 => Some(RootScreenType::RootScreenToolsPlatformInfoScreen), + 18 => Some(RootScreenType::RootScreenToolsMasternodeListDiffScreen), _ => None, } } @@ -161,6 +165,9 @@ impl From for ScreenType { RootScreenType::RootScreenMyTokenBalances => ScreenType::TokenBalances, RootScreenType::RootScreenTokenSearch => ScreenType::TokenSearch, RootScreenType::RootScreenTokenCreator => ScreenType::TokenCreator, + RootScreenType::RootScreenToolsMasternodeListDiffScreen => { + ScreenType::MasternodeListDiff + } RootScreenType::RootScreenToolsDocumentVisualizerScreen => { ScreenType::DocumentsVisualizer } @@ -200,6 +207,7 @@ pub enum ScreenType { RegisterContract, UpdateContract, ProofLog, + MasternodeListDiff, TopUpIdentity(QualifiedIdentity), ScheduledVotes, AddContracts, @@ -350,7 +358,6 @@ impl ScreenType { ScreenType::GroupActions => { Screen::GroupActionsScreen(GroupActionsScreen::new(app_context)) } - // Token Screens ScreenType::TokenBalances => Screen::TokensScreen(Box::new(TokensScreen::new( app_context, @@ -406,6 +413,9 @@ impl ScreenType { app_context, ))) } + ScreenType::MasternodeListDiff => { + Screen::MasternodeListDiffScreen(MasternodeListDiffScreen::new(app_context)) + } ScreenType::AddTokenById => Screen::AddTokenById(AddTokenByIdScreen::new(app_context)), ScreenType::PurchaseTokenScreen(identity_token_info) => Screen::PurchaseTokenScreen( PurchaseTokenScreen::new(identity_token_info.clone(), app_context), @@ -445,6 +455,7 @@ pub enum Screen { WalletsBalancesScreen(WalletsBalancesScreen), AddContractsScreen(AddContractsScreen), ProofVisualizerScreen(ProofVisualizerScreen), + MasternodeListDiffScreen(MasternodeListDiffScreen), PlatformInfoScreen(PlatformInfoScreen), // Token Screens @@ -493,6 +504,16 @@ impl Screen { Screen::ProofLogScreen(screen) => screen.app_context = app_context, Screen::AddContractsScreen(screen) => screen.app_context = app_context, Screen::ProofVisualizerScreen(screen) => screen.app_context = app_context, + Screen::MasternodeListDiffScreen(screen) => { + let old_net = screen.app_context.network; + if old_net != app_context.network { + // Switch context and clear state to avoid cross-network bleed + screen.app_context = app_context.clone(); + screen.clear(); + } else { + screen.app_context = app_context; + } + } Screen::DocumentVisualizerScreen(screen) => screen.app_context = app_context, Screen::PlatformInfoScreen(screen) => screen.app_context = app_context, @@ -608,6 +629,7 @@ impl Screen { Screen::ProofLogScreen(_) => ScreenType::ProofLog, Screen::AddContractsScreen(_) => ScreenType::AddContracts, Screen::ProofVisualizerScreen(_) => ScreenType::ProofVisualizer, + Screen::MasternodeListDiffScreen(_) => ScreenType::MasternodeListDiff, Screen::DocumentVisualizerScreen(_) => ScreenType::DocumentsVisualizer, Screen::PlatformInfoScreen(_) => ScreenType::PlatformInfo, @@ -703,6 +725,7 @@ impl ScreenLike for Screen { Screen::ProofLogScreen(screen) => screen.refresh(), Screen::AddContractsScreen(screen) => screen.refresh(), Screen::ProofVisualizerScreen(screen) => screen.refresh(), + Screen::MasternodeListDiffScreen(screen) => screen.refresh(), Screen::DocumentVisualizerScreen(screen) => screen.refresh(), Screen::ContractVisualizerScreen(screen) => screen.refresh(), Screen::PlatformInfoScreen(screen) => screen.refresh(), @@ -752,6 +775,7 @@ impl ScreenLike for Screen { Screen::ProofLogScreen(screen) => screen.refresh_on_arrival(), Screen::AddContractsScreen(screen) => screen.refresh_on_arrival(), Screen::ProofVisualizerScreen(screen) => screen.refresh_on_arrival(), + Screen::MasternodeListDiffScreen(screen) => screen.refresh_on_arrival(), Screen::DocumentVisualizerScreen(screen) => screen.refresh_on_arrival(), Screen::ContractVisualizerScreen(screen) => screen.refresh_on_arrival(), Screen::PlatformInfoScreen(screen) => screen.refresh_on_arrival(), @@ -801,6 +825,7 @@ impl ScreenLike for Screen { Screen::ProofLogScreen(screen) => screen.ui(ctx), Screen::AddContractsScreen(screen) => screen.ui(ctx), Screen::ProofVisualizerScreen(screen) => screen.ui(ctx), + Screen::MasternodeListDiffScreen(screen) => screen.ui(ctx), Screen::DocumentVisualizerScreen(screen) => screen.ui(ctx), Screen::ContractVisualizerScreen(screen) => screen.ui(ctx), Screen::PlatformInfoScreen(screen) => screen.ui(ctx), @@ -858,6 +883,9 @@ impl ScreenLike for Screen { Screen::ProofLogScreen(screen) => screen.display_message(message, message_type), Screen::AddContractsScreen(screen) => screen.display_message(message, message_type), Screen::ProofVisualizerScreen(screen) => screen.display_message(message, message_type), + Screen::MasternodeListDiffScreen(screen) => { + screen.display_message(message, message_type) + } Screen::DocumentVisualizerScreen(screen) => { screen.display_message(message, message_type) } @@ -960,6 +988,9 @@ impl ScreenLike for Screen { Screen::ProofVisualizerScreen(screen) => { screen.display_task_result(backend_task_success_result) } + Screen::MasternodeListDiffScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } Screen::ContractVisualizerScreen(screen) => { screen.display_task_result(backend_task_success_result) } @@ -1038,6 +1069,7 @@ impl ScreenLike for Screen { Screen::ProofLogScreen(screen) => screen.pop_on_success(), Screen::AddContractsScreen(screen) => screen.pop_on_success(), Screen::ProofVisualizerScreen(screen) => screen.pop_on_success(), + Screen::MasternodeListDiffScreen(screen) => screen.pop_on_success(), Screen::DocumentVisualizerScreen(screen) => screen.pop_on_success(), Screen::ContractVisualizerScreen(screen) => screen.pop_on_success(), Screen::PlatformInfoScreen(screen) => screen.pop_on_success(), diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index d98ea17c7..6365f9554 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -232,8 +232,7 @@ impl NetworkChooserScreen { .min_size(egui::vec2(120.0, 32.0)), ) .clicked() - { - if let Some(path) = rfd::FileDialog::new().pick_file() { + && let Some(path) = rfd::FileDialog::new().pick_file() { let file_name = path.file_name().and_then(|f| f.to_str()); if let Some(file_name) = file_name { @@ -280,7 +279,6 @@ impl NetworkChooserScreen { } } } - } if (self.custom_dash_qt_path.is_some() || self.custom_dash_qt_error_message.is_some()) @@ -608,35 +606,31 @@ impl NetworkChooserScreen { ); if ui.button("Save Password").clicked() { // 1) Reload the config - if let Ok(mut config) = Config::load() { - if let Some(local_cfg) = config.config_for_network(Network::Regtest).clone() { - let updated_local_config = local_cfg - .update_core_rpc_password(self.local_network_dashmate_password.clone()); - config.update_config_for_network( - Network::Regtest, - updated_local_config.clone(), - ); - if let Err(e) = config.save() { - eprintln!("Failed to save config to .env: {e}"); + if let Ok(mut config) = Config::load() + && let Some(local_cfg) = config.config_for_network(Network::Regtest).clone() + { + let updated_local_config = local_cfg + .update_core_rpc_password(self.local_network_dashmate_password.clone()); + config + .update_config_for_network(Network::Regtest, updated_local_config.clone()); + if let Err(e) = config.save() { + eprintln!("Failed to save config to .env: {e}"); + } + + // 5) Update our local AppContext in memory + if let Some(local_app_context) = &self.local_app_context { + { + // Overwrite the config field with the new password + let mut cfg_lock = local_app_context.config.write().unwrap(); + *cfg_lock = updated_local_config; } - // 5) Update our local AppContext in memory - if let Some(local_app_context) = &self.local_app_context { - { - // Overwrite the config field with the new password - let mut cfg_lock = local_app_context.config.write().unwrap(); - *cfg_lock = updated_local_config; - } - - // 6) Re-init the client & sdk from the updated config - if let Err(e) = - Arc::clone(local_app_context).reinit_core_client_and_sdk() - { - eprintln!("Failed to re-init local RPC client and sdk: {}", e); - } else { - // Trigger SwitchNetworks - app_action = AppAction::SwitchNetwork(Network::Regtest); - } + // 6) Re-init the client & sdk from the updated config + if let Err(e) = Arc::clone(local_app_context).reinit_core_client_and_sdk() { + eprintln!("Failed to re-init local RPC client and sdk: {}", e); + } else { + // Trigger SwitchNetworks + app_action = AppAction::SwitchNetwork(Network::Regtest); } } } diff --git a/src/ui/tokens/add_token_by_id_screen.rs b/src/ui/tokens/add_token_by_id_screen.rs index 7c35155e9..4e4c5e183 100644 --- a/src/ui/tokens/add_token_by_id_screen.rs +++ b/src/ui/tokens/add_token_by_id_screen.rs @@ -128,33 +128,31 @@ impl AddTokenByIdScreen { } fn render_add_button(&mut self, ui: &mut Ui) -> AppAction { - if let (Some(contract), Some(tok)) = (&self.fetched_contract, &self.selected_token) { - if ui + if let (Some(contract), Some(tok)) = (&self.fetched_contract, &self.selected_token) + && ui .add( egui::Button::new(RichText::new("Add Token").color(Color32::WHITE)) .fill(Color32::from_rgb(0, 120, 0)), ) .clicked() - { - let insert_mode = - InsertTokensToo::SomeTokensShouldBeAdded(vec![tok.token_position]); - - // Set status to show we're processing - self.status = AddTokenStatus::Searching(chrono::Utc::now().timestamp() as u32); - - // None for alias; change if you allow user alias input - return AppAction::BackendTasks( - vec![ - BackendTask::ContractTask(Box::new(ContractTask::SaveDataContract( - contract.clone(), - None, - insert_mode, - ))), - BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), - ], - crate::app::BackendTasksExecutionMode::Sequential, - ); - } + { + let insert_mode = InsertTokensToo::SomeTokensShouldBeAdded(vec![tok.token_position]); + + // Set status to show we're processing + self.status = AddTokenStatus::Searching(chrono::Utc::now().timestamp() as u32); + + // None for alias; change if you allow user alias input + return AppAction::BackendTasks( + vec![ + BackendTask::ContractTask(Box::new(ContractTask::SaveDataContract( + contract.clone(), + None, + insert_mode, + ))), + BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), + ], + crate::app::BackendTasksExecutionMode::Sequential, + ); } AppAction::None } diff --git a/src/ui/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs index c766017ad..39fe3ec8d 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -170,17 +170,17 @@ impl BurnTokensScreen { }; let mut is_unilateral_group_member = false; - if group.is_some() { - if let Some((_, group)) = group.clone() { - let your_power = group - .members() - .get(&identity_token_info.identity.identity.id()); - - if let Some(your_power) = your_power { - if your_power >= &group.required_power() { - is_unilateral_group_member = true; - } - } + if group.is_some() + && let Some((_, group)) = group.clone() + { + let your_power = group + .members() + .get(&identity_token_info.identity.identity.id()); + + if let Some(your_power) = your_power + && your_power >= &group.required_power() + { + is_unilateral_group_member = true; } }; @@ -376,13 +376,12 @@ impl ScreenLike for BurnTokensScreen { fn refresh(&mut self) { // If you need to reload local identity data or re-check keys - if let Ok(all_identities) = self.app_context.load_local_user_identities() { - if let Some(updated_identity) = all_identities + if let Ok(all_identities) = self.app_context.load_local_user_identities() + && let Some(updated_identity) = all_identities .into_iter() .find(|id| id.identity.id() == self.identity_token_info.identity.identity.id()) - { - self.identity_token_info.identity = updated_identity; - } + { + self.identity_token_info.identity = updated_identity; } } diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 25bf04c1b..6c6083666 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -263,13 +263,12 @@ impl ScreenLike for ClaimTokensScreen { } fn refresh(&mut self) { - if let Ok(all) = self.app_context.load_local_qualified_identities() { - if let Some(updated) = all + if let Ok(all) = self.app_context.load_local_qualified_identities() + && let Some(updated) = all .into_iter() .find(|id| id.identity.id() == self.identity.identity.id()) - { - self.identity = updated; - } + { + self.identity = updated; } } diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index cb4089170..4961bc83e 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -168,17 +168,17 @@ impl DestroyFrozenFundsScreen { }; let mut is_unilateral_group_member = false; - if group.is_some() { - if let Some((_, group)) = group.clone() { - let your_power = group - .members() - .get(&identity_token_info.identity.identity.id()); - - if let Some(your_power) = your_power { - if your_power >= &group.required_power() { - is_unilateral_group_member = true; - } - } + if group.is_some() + && let Some((_, group)) = group.clone() + { + let your_power = group + .members() + .get(&identity_token_info.identity.identity.id()); + + if let Some(your_power) = your_power + && your_power >= &group.required_power() + { + is_unilateral_group_member = true; } }; @@ -379,13 +379,12 @@ impl ScreenLike for DestroyFrozenFundsScreen { fn refresh(&mut self) { // Reload the identity data if needed - if let Ok(all_identities) = self.app_context.load_local_user_identities() { - if let Some(updated_identity) = all_identities + if let Ok(all_identities) = self.app_context.load_local_user_identities() + && let Some(updated_identity) = all_identities .into_iter() .find(|id| id.identity.id() == self.identity.identity.id()) - { - self.identity = updated_identity; - } + { + self.identity = updated_identity; } } diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index 37581abde..a06f23b94 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -319,13 +319,12 @@ impl ScreenLike for PurchaseTokenScreen { fn refresh(&mut self) { // If you need to reload local identity data or re-check keys: - if let Ok(all_identities) = self.app_context.load_local_user_identities() { - if let Some(updated_identity) = all_identities + if let Ok(all_identities) = self.app_context.load_local_user_identities() + && let Some(updated_identity) = all_identities .into_iter() .find(|id| id.identity.id() == self.identity_token_info.identity.identity.id()) - { - self.identity_token_info.identity = updated_identity; - } + { + self.identity_token_info.identity = updated_identity; } } diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index fbaeab331..1fbc483b5 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -160,17 +160,17 @@ impl FreezeTokensScreen { }; let mut is_unilateral_group_member = false; - if group.is_some() { - if let Some((_, group)) = group.clone() { - let your_power = group - .members() - .get(&identity_token_info.identity.identity.id()); - - if let Some(your_power) = your_power { - if your_power >= &group.required_power() { - is_unilateral_group_member = true; - } - } + if group.is_some() + && let Some((_, group)) = group.clone() + { + let your_power = group + .members() + .get(&identity_token_info.identity.identity.id()); + + if let Some(your_power) = your_power + && your_power >= &group.required_power() + { + is_unilateral_group_member = true; } }; @@ -365,13 +365,12 @@ impl ScreenLike for FreezeTokensScreen { fn refresh(&mut self) { // Reload identity if needed - if let Ok(all_identities) = self.app_context.load_local_user_identities() { - if let Some(updated_identity) = all_identities + if let Ok(all_identities) = self.app_context.load_local_user_identities() + && let Some(updated_identity) = all_identities .into_iter() .find(|id| id.identity.id() == self.identity.identity.id()) - { - self.identity = updated_identity; - } + { + self.identity = updated_identity; } } diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index d7c93053f..a1ab3d629 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -163,17 +163,17 @@ impl MintTokensScreen { }; let mut is_unilateral_group_member = false; - if group.is_some() { - if let Some((_, group)) = group.clone() { - let your_power = group - .members() - .get(&identity_token_info.identity.identity.id()); - - if let Some(your_power) = your_power { - if your_power >= &group.required_power() { - is_unilateral_group_member = true; - } - } + if group.is_some() + && let Some((_, group)) = group.clone() + { + let your_power = group + .members() + .get(&identity_token_info.identity.identity.id()); + + if let Some(your_power) = your_power + && your_power >= &group.required_power() + { + is_unilateral_group_member = true; } }; @@ -398,13 +398,12 @@ impl ScreenLike for MintTokensScreen { fn refresh(&mut self) { // If you need to reload local identity data or re-check keys: - if let Ok(all_identities) = self.app_context.load_local_user_identities() { - if let Some(updated_identity) = all_identities + if let Ok(all_identities) = self.app_context.load_local_user_identities() + && let Some(updated_identity) = all_identities .into_iter() .find(|id| id.identity.id() == self.identity_token_info.identity.identity.id()) - { - self.identity_token_info.identity = updated_identity; - } + { + self.identity_token_info.identity = updated_identity; } } diff --git a/src/ui/tokens/pause_tokens_screen.rs b/src/ui/tokens/pause_tokens_screen.rs index 95e43b4f8..c9333cfdf 100644 --- a/src/ui/tokens/pause_tokens_screen.rs +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -150,17 +150,17 @@ impl PauseTokensScreen { }; let mut is_unilateral_group_member = false; - if group.is_some() { - if let Some((_, group)) = group.clone() { - let your_power = group - .members() - .get(&identity_token_info.identity.identity.id()); - - if let Some(your_power) = your_power { - if your_power >= &group.required_power() { - is_unilateral_group_member = true; - } - } + if group.is_some() + && let Some((_, group)) = group.clone() + { + let your_power = group + .members() + .get(&identity_token_info.identity.identity.id()); + + if let Some(your_power) = your_power + && your_power >= &group.required_power() + { + is_unilateral_group_member = true; } }; @@ -312,13 +312,12 @@ impl ScreenLike for PauseTokensScreen { } fn refresh(&mut self) { - if let Ok(all) = self.app_context.load_local_user_identities() { - if let Some(updated) = all + if let Ok(all) = self.app_context.load_local_user_identities() + && let Some(updated) = all .into_iter() .find(|id| id.identity.id() == self.identity.identity.id()) - { - self.identity = updated; - } + { + self.identity = updated; } } diff --git a/src/ui/tokens/resume_tokens_screen.rs b/src/ui/tokens/resume_tokens_screen.rs index 70b4b2acd..dccec0693 100644 --- a/src/ui/tokens/resume_tokens_screen.rs +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -149,17 +149,17 @@ impl ResumeTokensScreen { }; let mut is_unilateral_group_member = false; - if group.is_some() { - if let Some((_, group)) = group.clone() { - let your_power = group - .members() - .get(&identity_token_info.identity.identity.id()); - - if let Some(your_power) = your_power { - if your_power >= &group.required_power() { - is_unilateral_group_member = true; - } - } + if group.is_some() + && let Some((_, group)) = group.clone() + { + let your_power = group + .members() + .get(&identity_token_info.identity.identity.id()); + + if let Some(your_power) = your_power + && your_power >= &group.required_power() + { + is_unilateral_group_member = true; } }; @@ -312,13 +312,12 @@ impl ScreenLike for ResumeTokensScreen { } fn refresh(&mut self) { - if let Ok(all) = self.app_context.load_local_user_identities() { - if let Some(updated) = all + if let Ok(all) = self.app_context.load_local_user_identities() + && let Some(updated) = all .into_iter() .find(|id| id.identity.id() == self.identity.identity.id()) - { - self.identity = updated; - } + { + self.identity = updated; } } diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index f3e42a24d..977ff1b09 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -172,17 +172,17 @@ impl SetTokenPriceScreen { }; let mut is_unilateral_group_member = false; - if group.is_some() { - if let Some((_, group)) = group.clone() { - let your_power = group - .members() - .get(&identity_token_info.identity.identity.id()); - - if let Some(your_power) = your_power { - if your_power >= &group.required_power() { - is_unilateral_group_member = true; - } - } + if group.is_some() + && let Some((_, group)) = group.clone() + { + let your_power = group + .members() + .get(&identity_token_info.identity.identity.id()); + + if let Some(your_power) = your_power + && your_power >= &group.required_power() + { + is_unilateral_group_member = true; } }; @@ -752,13 +752,12 @@ impl ScreenLike for SetTokenPriceScreen { fn refresh(&mut self) { // If you need to reload local identity data or re-check keys: - if let Ok(all_identities) = self.app_context.load_local_user_identities() { - if let Some(updated_identity) = all_identities + if let Ok(all_identities) = self.app_context.load_local_user_identities() + && let Some(updated_identity) = all_identities .into_iter() .find(|id| id.identity.id() == self.identity_token_info.identity.identity.id()) - { - self.identity_token_info.identity = updated_identity; - } + { + self.identity_token_info.identity = updated_identity; } } diff --git a/src/ui/tokens/tokens_screen/distributions.rs b/src/ui/tokens/tokens_screen/distributions.rs index 9085dffd1..5cc810ce2 100644 --- a/src/ui/tokens/tokens_screen/distributions.rs +++ b/src/ui/tokens/tokens_screen/distributions.rs @@ -378,18 +378,8 @@ Emits tokens in fixed amounts for specific intervals. if response.changed() { sanitize_u64(&mut self.step_count_input); } - if !self.step_count_input.is_empty() { - if let Ok((perpetual_dist_interval_input, step_count_input)) = self - .perpetual_dist_interval_input - .parse::() - .and_then(|perpetual_dist_interval_input| { - self.step_count_input.parse::().map( - |step_count_input| { - (perpetual_dist_interval_input, step_count_input) - }, - ) - }) - { + if !self.step_count_input.is_empty() + && let Ok((perpetual_dist_interval_input, step_count_input)) = self.perpetual_dist_interval_input.parse::().and_then(|perpetual_dist_interval_input| self.step_count_input.parse::().map(|step_count_input| (perpetual_dist_interval_input, step_count_input))) { let text = match self.perpetual_dist_type { PerpetualDistributionIntervalTypeUI::None => "".to_string(), PerpetualDistributionIntervalTypeUI::BlockBased => { @@ -424,7 +414,6 @@ Emits tokens in fixed amounts for specific intervals. ui.label(RichText::new(text).color(Color32::GRAY)); } - } }); ui.horizontal(|ui| { @@ -529,8 +518,8 @@ Emits tokens in fixed amounts for specific intervals. sanitize_u64(&mut amount_str); } - if let Ok((perpetual_dist_interval_input, step_position)) = self.perpetual_dist_interval_input.parse::().and_then(|perpetual_dist_interval_input| steps_str.parse::().map(|step_count_input| (perpetual_dist_interval_input, step_count_input))) { - if let Ok(amount) = amount_str.parse::() { + if let Ok((perpetual_dist_interval_input, step_position)) = self.perpetual_dist_interval_input.parse::().and_then(|perpetual_dist_interval_input| steps_str.parse::().map(|step_count_input| (perpetual_dist_interval_input, step_count_input))) + && let Ok(amount) = amount_str.parse::() { let every_text = match self.perpetual_dist_type { PerpetualDistributionIntervalTypeUI::None => "".to_string(), PerpetualDistributionIntervalTypeUI::BlockBased => { @@ -601,9 +590,6 @@ Emits tokens in fixed amounts for specific intervals. ui.label(RichText::new(text).color(Color32::GRAY)); } - - } - // If remove is clicked, remove the step at index i // and *do not* increment i, because the next element // now “shifts” into this index. diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 5bf8c7f7c..28d375dcd 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -462,8 +462,8 @@ impl ChangeControlRulesUI { ); ui.end_row(); - if let Some(special_case_option) = special_case_option { - if action_name == "Freeze" && self.rules.authorized_to_make_change != AuthorizedActionTakers::NoOne { + if let Some(special_case_option) = special_case_option + && action_name == "Freeze" && self.rules.authorized_to_make_change != AuthorizedActionTakers::NoOne { ui.horizontal(|ui| { ui.checkbox( special_case_option, @@ -479,7 +479,6 @@ impl ChangeControlRulesUI { }); ui.end_row(); } - } }); }); } @@ -793,29 +792,29 @@ impl ChangeControlRulesUI { action_name: &str, ) -> Result { // 1) Update self.rules.authorized_to_make_change if it’s Identity or Group - if let AuthorizedActionTakers::Identity(_) = self.rules.authorized_to_make_change { - if let Some(ref id_str) = self.authorized_identity { - let parsed = Identifier::from_string(id_str, Encoding::Base58).map_err(|_| { - format!( - "Invalid base58 identifier for {} authorized identity", - action_name - ) - })?; - self.rules.authorized_to_make_change = AuthorizedActionTakers::Identity(parsed); - } + if let AuthorizedActionTakers::Identity(_) = self.rules.authorized_to_make_change + && let Some(ref id_str) = self.authorized_identity + { + let parsed = Identifier::from_string(id_str, Encoding::Base58).map_err(|_| { + format!( + "Invalid base58 identifier for {} authorized identity", + action_name + ) + })?; + self.rules.authorized_to_make_change = AuthorizedActionTakers::Identity(parsed); } // 2) Update self.rules.admin_action_takers if it’s Identity or Group - if let AuthorizedActionTakers::Identity(_) = self.rules.admin_action_takers { - if let Some(ref id_str) = self.admin_identity { - let parsed = Identifier::from_string(id_str, Encoding::Base58).map_err(|_| { - format!( - "Invalid base58 identifier for {} admin identity", - action_name - ) - })?; - self.rules.admin_action_takers = AuthorizedActionTakers::Identity(parsed); - } + if let AuthorizedActionTakers::Identity(_) = self.rules.admin_action_takers + && let Some(ref id_str) = self.admin_identity + { + let parsed = Identifier::from_string(id_str, Encoding::Base58).map_err(|_| { + format!( + "Invalid base58 identifier for {} admin identity", + action_name + ) + })?; + self.rules.admin_action_takers = AuthorizedActionTakers::Identity(parsed); } // 3) Construct the ChangeControlRules @@ -1722,17 +1721,17 @@ impl TokensScreen { let response = tri_state(ui, &mut parent_state, "Keep history"); // propagate changes from parent to all children - if response.clicked() { - if let Some(val) = parent_state { - self.token_advanced_keeps_history.keeps_transfer_history = val; - self.token_advanced_keeps_history.keeps_freezing_history = val; - self.token_advanced_keeps_history.keeps_minting_history = val; - self.token_advanced_keeps_history.keeps_burning_history = val; - self.token_advanced_keeps_history - .keeps_direct_pricing_history = val; - self.token_advanced_keeps_history - .keeps_direct_purchase_history = val; - } + if response.clicked() + && let Some(val) = parent_state + { + self.token_advanced_keeps_history.keeps_transfer_history = val; + self.token_advanced_keeps_history.keeps_freezing_history = val; + self.token_advanced_keeps_history.keeps_minting_history = val; + self.token_advanced_keeps_history.keeps_burning_history = val; + self.token_advanced_keeps_history + .keeps_direct_pricing_history = val; + self.token_advanced_keeps_history + .keeps_direct_purchase_history = val; } ui.add_space(8.0); @@ -2846,10 +2845,10 @@ impl ScreenLike for TokensScreen { } } - if action == AppAction::None { - if let Some(bt) = self.pending_backend_task.take() { - action = AppAction::BackendTask(bt); - } + if action == AppAction::None + && let Some(bt) = self.pending_backend_task.take() + { + action = AppAction::BackendTask(bt); } action } @@ -2869,8 +2868,6 @@ impl ScreenLike for TokensScreen { { self.token_creator_status = TokenCreatorStatus::ErrorMessage(msg.to_string()); self.token_creator_error_message = Some(msg.to_string()); - } else { - return; } } TokensSubscreen::MyTokens => { @@ -2916,8 +2913,6 @@ impl ScreenLike for TokensScreen { MessageType::Success, Utc::now(), )); - } else { - return; } } } diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index 7e29ae721..2f891b837 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -165,17 +165,17 @@ impl UnfreezeTokensScreen { }; let mut is_unilateral_group_member = false; - if group.is_some() { - if let Some((_, group)) = group.clone() { - let your_power = group - .members() - .get(&identity_token_info.identity.identity.id()); - - if let Some(your_power) = your_power { - if your_power >= &group.required_power() { - is_unilateral_group_member = true; - } - } + if group.is_some() + && let Some((_, group)) = group.clone() + { + let your_power = group + .members() + .get(&identity_token_info.identity.identity.id()); + + if let Some(your_power) = your_power + && your_power >= &group.required_power() + { + is_unilateral_group_member = true; } }; @@ -368,13 +368,12 @@ impl ScreenLike for UnfreezeTokensScreen { } fn refresh(&mut self) { - if let Ok(all_identities) = self.app_context.load_local_user_identities() { - if let Some(updated_identity) = all_identities + if let Ok(all_identities) = self.app_context.load_local_user_identities() + && let Some(updated_identity) = all_identities .into_iter() .find(|id| id.identity.id() == self.identity.identity.id()) - { - self.identity = updated_identity; - } + { + self.identity = updated_identity; } } diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 9cd7e6a04..49460b84d 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -207,10 +207,10 @@ impl UpdateTokenConfigScreen { .members() .get(&self.identity_token_info.identity.identity.id()); - if let Some(your_power) = your_power { - if your_power >= &group.required_power() { - self.is_unilateral_group_member = true; - } + if let Some(your_power) = your_power + && your_power >= &group.required_power() + { + self.is_unilateral_group_member = true; } } } @@ -614,13 +614,12 @@ impl UpdateTokenConfigScreen { ui.label(&self.update_text); ui.horizontal(|ui| { - if let Some(opt_json) = opt_json { - if ui.button("View Current").clicked() { + if let Some(opt_json) = opt_json + && ui.button("View Current").clicked() { self.update_text = serde_json::to_string_pretty(opt_json).unwrap_or_default(); // Update displayed text } - } if !self.text_input_error.is_empty() { ui.colored_label(Color32::RED, &self.text_input_error); @@ -987,12 +986,11 @@ impl ScreenLike for UpdateTokenConfigScreen { // Central panel island_central_panel(ctx, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { - if let Some(msg) = &self.backend_message { - if msg.1 == MessageType::Success { + if let Some(msg) = &self.backend_message + && msg.1 == MessageType::Success { action |= self.show_success_screen(ui); return; } - } ui.heading("Update Token Configuration"); ui.add_space(10.0); diff --git a/src/ui/tools/masternode_list_diff_screen.rs b/src/ui/tools/masternode_list_diff_screen.rs new file mode 100644 index 000000000..713b00c21 --- /dev/null +++ b/src/ui/tools/masternode_list_diff_screen.rs @@ -0,0 +1,4407 @@ +use crate::app::AppAction; +use crate::backend_task::core::CoreItem; +use crate::backend_task::mnlist::MnListTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::components::core_p2p_handler::CoreP2PHandler; +use crate::context::AppContext; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use dash_sdk::dashcore_rpc::RpcApi; +use dash_sdk::dashcore_rpc::json::QuorumType; +use dash_sdk::dpp::dashcore::bls_sig_utils::BLSSignature; +use dash_sdk::dpp::dashcore::consensus::serialize as serialize2; +use dash_sdk::dpp::dashcore::consensus::{Decodable, deserialize, serialize}; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::dashcore::network::constants::NetworkExt; +use dash_sdk::dpp::dashcore::network::message_qrinfo::{QRInfo, QuorumSnapshot}; +use dash_sdk::dpp::dashcore::network::message_sml::MnListDiff; +use dash_sdk::dpp::dashcore::sml::llmq_entry_verification::LLMQEntryVerificationStatus; +use dash_sdk::dpp::dashcore::sml::llmq_type::LLMQType; +use dash_sdk::dpp::dashcore::sml::masternode_list::MasternodeList; +use dash_sdk::dpp::dashcore::sml::masternode_list_engine::{ + MasternodeListEngine, MasternodeListEngineBlockContainer, +}; +use dash_sdk::dpp::dashcore::sml::masternode_list_entry::EntryMasternodeType; +use dash_sdk::dpp::dashcore::sml::masternode_list_entry::qualified_masternode_list_entry::QualifiedMasternodeListEntry; +use dash_sdk::dpp::dashcore::sml::quorum_entry::qualified_quorum_entry::{ + QualifiedQuorumEntry, VerifyingChainLockSignaturesType, +}; +use dash_sdk::dpp::dashcore::sml::quorum_validation_error::ClientDataRetrievalError; +use dash_sdk::dpp::dashcore::transaction::special_transaction::quorum_commitment::QuorumEntry; +use dash_sdk::dpp::dashcore::{ + Block, BlockHash as BlockHash2, ChainLock, InstantLock, Transaction, +}; +use dash_sdk::dpp::dashcore::{ + BlockHash, ChainLock as ChainLock2, InstantLock as InstantLock2, Network, ProTxHash, QuorumHash, +}; +use dash_sdk::dpp::prelude::CoreBlockHeight; +use eframe::egui::{self, Context, ScrollArea, Ui}; +use egui::{Align, Color32, Frame, Layout, Margin, RichText, Stroke, TextEdit, Vec2}; +use itertools::Itertools; +use rfd::FileDialog; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::fs; +use std::path::Path; +use std::sync::Arc; + +type HeightHash = (u32, BlockHash); + +enum SelectedQRItem { + SelectedSnapshot(QuorumSnapshot), + MNListDiff(Box), + QuorumEntry(Box), +} + +/// Screen for viewing MNList diffs (diffs in the masternode list and quorums) +pub struct MasternodeListDiffScreen { + pub app_context: Arc, + + /// Are we syncing? + syncing: bool, + + /// The chain locked blocks received through zmq that we can attempt to verify + chain_locked_blocks: BTreeMap, + + /// Instant send locked transactions received through zmq that we can attempt to verify + instant_send_transactions: Vec<(Transaction, InstantLock, bool)>, + + /// The user‐entered base block height (as text) + base_block_height: String, + /// The user‐entered end block height (as text) + end_block_height: String, + + show_popup_for_render_masternode_list_engine: bool, + + /// Selected tab (0 = Diffs, 1 = Masternode Lists) + selected_tab: usize, + + /// The engine to compute masternode lists + masternode_list_engine: MasternodeListEngine, + + /// Masternode_list_heights with all quorum heights known + masternode_lists_with_all_quorum_heights_known: BTreeSet, + + /// The list of MNList diff items (one per block height) + mnlist_diffs: BTreeMap<(CoreBlockHeight, CoreBlockHeight), MnListDiff>, + + /// The list of qr infos + qr_infos: BTreeMap, + + /// Selected MNList diff + selected_dml_diff_key: Option<(CoreBlockHeight, CoreBlockHeight)>, + + /// This is to know which ones we have already checked for quorum heights + dml_diffs_with_cached_quorum_heights: HashSet<(CoreBlockHeight, CoreBlockHeight)>, + + /// Selected MNList + selected_dml_height_key: Option, + + /// Selected display option + selected_option_index: Option, + /// Selected quorum within the MNList diff + selected_quorum_in_diff_index: Option, + + /// Selected masternode within the MNList diff + selected_masternode_in_diff_index: Option, + + /// Selected quorum within the MNList diff + selected_quorum_hash_in_mnlist_diff: Option<(LLMQType, QuorumHash)>, + + /// Selected quorum within the quorum_viewer + selected_quorum_type_in_quorum_viewer: Option, + + /// Selected quorum within the quorum_viewer + selected_quorum_hash_in_quorum_viewer: Option, + + /// Selected masternode within the MNList diff + selected_masternode_pro_tx_hash: Option, + + /// Search term + search_term: Option, + + /// The block height cache + block_height_cache: BTreeMap, + + /// The block hash cache + block_hash_cache: BTreeMap, + + /// The masternode list quorum hash cache + masternode_list_quorum_hash_cache: + BTreeMap>>, + + chain_lock_sig_cache: BTreeMap<(CoreBlockHeight, BlockHash), Option>, + + chain_lock_reversed_sig_cache: BTreeMap>, + + error: Option, + selected_qr_field: Option, + selected_qr_list_index: Option, + selected_core_item: Option<(CoreItem, bool)>, + selected_qr_item: Option, + pending: Option, + queued_task: Option, + message: Option<(String, MessageType)>, +} + +impl MasternodeListDiffScreen { + /// Create a new MNListDiffScreen + pub fn new(app_context: &Arc) -> Self { + let mut mnlist_diffs = BTreeMap::new(); + let engine = match app_context.network { + Network::Dash => { + use std::env; + println!( + "Current working directory: {:?}", + env::current_dir().unwrap() + ); + let file_path = "artifacts/mn_list_diff_0_2227096.bin"; + // Attempt to load and parse the MNListDiff file + if Path::new(file_path).exists() { + match fs::read(file_path) { + Ok(bytes) => { + let diff: MnListDiff = + deserialize(bytes.as_slice()).expect("expected to deserialize"); + mnlist_diffs.insert((0, 2227096), diff.clone()); + MasternodeListEngine::initialize_with_diff_to_height( + diff, + 2227096, + Network::Dash, + ) + .expect("expected to start engine") + } + Err(e) => { + eprintln!("Failed to read MNListDiff file: {}", e); + MasternodeListEngine::default_for_network(Network::Dash) + } + } + } else { + eprintln!("MNListDiff file not found: {}", file_path); + MasternodeListEngine::default_for_network(Network::Dash) + } + } + Network::Testnet => { + let file_path = "artifacts/mn_list_diff_testnet_0_1296600.bin"; + // Attempt to load and parse the MNListDiff file + if Path::new(file_path).exists() { + match fs::read(file_path) { + Ok(bytes) => { + let diff: MnListDiff = + deserialize(bytes.as_slice()).expect("expected to deserialize"); + mnlist_diffs.insert((0, 1296600), diff.clone()); + MasternodeListEngine::initialize_with_diff_to_height( + diff, + 1296600, + Network::Testnet, + ) + .expect("expected to start engine") + } + Err(e) => { + eprintln!("Failed to read MNListDiff file: {}", e); + MasternodeListEngine::default_for_network(Network::Testnet) + } + } + } else { + eprintln!("MNListDiff file not found: {}", file_path); + MasternodeListEngine::default_for_network(Network::Dash) + } + } + _ => MasternodeListEngine::default_for_network(app_context.network), + }; + + Self { + app_context: app_context.clone(), + syncing: false, + chain_locked_blocks: Default::default(), + instant_send_transactions: vec![], + base_block_height: "".to_string(), + end_block_height: "".to_string(), + show_popup_for_render_masternode_list_engine: false, + selected_tab: 0, + masternode_list_engine: engine, + search_term: None, + mnlist_diffs, + qr_infos: Default::default(), + selected_dml_diff_key: None, + dml_diffs_with_cached_quorum_heights: Default::default(), + selected_dml_height_key: None, + selected_option_index: None, + selected_quorum_in_diff_index: None, + selected_masternode_in_diff_index: None, + selected_quorum_hash_in_mnlist_diff: None, + selected_quorum_type_in_quorum_viewer: None, + selected_quorum_hash_in_quorum_viewer: None, + selected_masternode_pro_tx_hash: None, + error: None, + selected_qr_field: None, + selected_qr_list_index: None, + block_height_cache: Default::default(), + block_hash_cache: Default::default(), + masternode_list_quorum_hash_cache: Default::default(), + selected_qr_item: None, + selected_core_item: None, + masternode_lists_with_all_quorum_heights_known: Default::default(), + chain_lock_sig_cache: Default::default(), + chain_lock_reversed_sig_cache: Default::default(), + pending: None, + queued_task: None, + message: None, + } + } + + fn get_height_or_error_as_string(&self, block_hash: &BlockHash) -> String { + match self.get_height(block_hash) { + Ok(height) => height.to_string(), + Err(e) => format!("Failed to get height for {}: {}", block_hash, e), + } + } + + /// Build a backend task that fetches the extra diffs needed to validate non-rotating quorums. + /// Returns None if requirements cannot be computed. + fn build_validation_diffs_task(&mut self) -> Option { + // Determine hashes we need to validate + let hashes = self + .masternode_list_engine + .latest_masternode_list_non_rotating_quorum_hashes( + &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], + true, + ); + if hashes.is_empty() { + return None; + } + + // Compute target validation heights (h-8) + let mut heights: BTreeSet = BTreeSet::new(); + for quorum_hash in &hashes { + if let Ok(h) = self.get_height_and_cache(quorum_hash) + && h >= 8 + { + heights.insert(h - 8); + } + } + if heights.is_empty() { + return None; + } + + let client = self.app_context.core_client.read().unwrap(); + let mut chain: Vec<(u32, BlockHash, u32, BlockHash)> = Vec::new(); + + // Determine base starting point similar to previous logic + let (first_engine_height, first_engine_hash_opt) = self + .masternode_list_engine + .masternode_lists + .first_key_value() + .map(|(h, l)| (*h, Some(l.block_hash))) + .unwrap_or((0, None)); + + let oldest_needed = *heights.first().unwrap(); + let mut base_height: u32; + let mut base_hash: BlockHash; + if first_engine_height != 0 && first_engine_height < oldest_needed { + base_height = first_engine_height; + base_hash = first_engine_hash_opt.unwrap(); + } else { + // Use genesis as base + base_height = 0; + let Ok(genesis) = client.get_block_hash(0) else { + return None; + }; + base_hash = BlockHash::from_byte_array(genesis.to_byte_array()); + } + + for h in heights { + let Ok(bh) = client.get_block_hash(h) else { + continue; + }; + let bh = BlockHash::from_byte_array(bh.to_byte_array()); + chain.push((base_height, base_hash, h, bh)); + base_height = h; + base_hash = bh; + } + + if chain.is_empty() { + return None; + } + Some(BackendTask::MnListTask(MnListTask::FetchDiffsChain { + chain, + })) + } + + fn get_height(&self, block_hash: &BlockHash) -> Result { + let Some(height) = self + .masternode_list_engine + .block_container + .get_height(block_hash) + else { + let Some(height) = self.block_height_cache.get(block_hash) else { + println!( + "Asking core for height no cache {} ({})", + block_hash, + block_hash.reverse() + ); + return match self + .app_context + .core_client + .read() + .unwrap() + .get_block_header_info( + &(BlockHash2::from_byte_array(block_hash.to_byte_array())), + ) { + Ok(block_hash) => Ok(block_hash.height as CoreBlockHeight), + Err(e) => Err(e.to_string()), + }; + }; + return Ok(*height); + }; + Ok(height) + } + + #[allow(dead_code)] + fn get_height_and_cache_or_error_as_string(&mut self, block_hash: &BlockHash) -> String { + match self.get_height_and_cache(block_hash) { + Ok(height) => height.to_string(), + Err(e) => format!("Failed to get height for {}: {}", block_hash, e), + } + } + + fn get_height_and_cache(&mut self, block_hash: &BlockHash) -> Result { + let Some(height) = self + .masternode_list_engine + .block_container + .get_height(block_hash) + else { + let Some(height) = self.block_height_cache.get(block_hash) else { + println!( + "Asking core for height {} ({})", + block_hash, + block_hash.reverse() + ); + return match self + .app_context + .core_client + .read() + .unwrap() + .get_block_header_info( + &(BlockHash2::from_byte_array(block_hash.to_byte_array())), + ) { + Ok(result) => { + self.block_height_cache + .insert(*block_hash, result.height as CoreBlockHeight); + self.masternode_list_engine + .feed_block_height(result.height as CoreBlockHeight, *block_hash); + Ok(result.height as CoreBlockHeight) + } + Err(e) => Err(e.to_string()), + }; + }; + return Ok(*height); + }; + Ok(height) + } + + #[allow(dead_code)] + fn get_chain_lock_sig_and_cache( + &mut self, + block_hash: &BlockHash, + ) -> Result, String> { + let height = self.get_height_and_cache(block_hash)?; + if !self + .chain_lock_sig_cache + .contains_key(&(height, *block_hash)) + { + let block = self + .app_context + .core_client + .read() + .unwrap() + .get_block(&(BlockHash2::from_byte_array(block_hash.to_byte_array()))) + .map_err(|e| e.to_string())?; + let Some(coinbase) = block + .coinbase() + .and_then(|coinbase| coinbase.special_transaction_payload.as_ref()) + .and_then(|payload| payload.clone().to_coinbase_payload().ok()) + else { + return Err(format!("coinbase not found on block hash {}", block_hash)); + }; + //todo clean up + self.chain_lock_sig_cache.insert( + (height, *block_hash), + coinbase.best_cl_signature.map(|sig| sig.to_bytes().into()), + ); + if let Some(sig) = coinbase.best_cl_signature.map(|sig| sig.to_bytes().into()) { + self.chain_lock_reversed_sig_cache + .entry(sig) + .or_default() + .insert((height, *block_hash)); + } + } + + Ok(*self + .chain_lock_sig_cache + .get(&(height, *block_hash)) + .unwrap()) + } + + fn get_chain_lock_sig(&self, block_hash: &BlockHash) -> Result, String> { + let height = self.get_height(block_hash)?; + if !self + .chain_lock_sig_cache + .contains_key(&(height, *block_hash)) + { + let block = self + .app_context + .core_client + .read() + .unwrap() + .get_block(&(BlockHash2::from_byte_array(block_hash.to_byte_array()))) + .map_err(|e| e.to_string())?; + let Some(coinbase) = block + .coinbase() + .and_then(|coinbase| coinbase.special_transaction_payload.as_ref()) + .and_then(|payload| payload.clone().to_coinbase_payload().ok()) + else { + return Err(format!("coinbase not found on block hash {}", block_hash)); + }; + Ok(coinbase.best_cl_signature.map(|sig| sig.to_bytes().into())) + } else { + Ok(*self + .chain_lock_sig_cache + .get(&(height, *block_hash)) + .unwrap()) + } + } + + fn get_block_hash(&self, height: CoreBlockHeight) -> Result { + let Some(block_hash) = self + .masternode_list_engine + .block_container + .get_hash(&height) + else { + let Some(block_hash) = self.block_hash_cache.get(&height) else { + // println!("Asking core for hash of {}", height); + return match self + .app_context + .core_client + .read() + .unwrap() + .get_block_hash(height) + { + Ok(block_hash) => Ok(BlockHash::from_byte_array(block_hash.to_byte_array())), + Err(e) => Err(e.to_string()), + }; + }; + return Ok(*block_hash); + }; + Ok(*block_hash) + } + + #[allow(dead_code)] + fn get_block_hash_and_cache(&mut self, height: CoreBlockHeight) -> Result { + // First, try to get the hash from masternode_list_engine's block_container. + if let Some(block_hash) = self + .masternode_list_engine + .block_container + .get_hash(&height) + { + return Ok(*block_hash); + } + + // Then, check the cache. + if let Some(cached_hash) = self.block_hash_cache.get(&height) { + return Ok(*cached_hash); + } + + // If not cached, retrieve from core client and insert into cache. + // println!("Asking core for hash of {} and caching it", height); + match self + .app_context + .core_client + .read() + .unwrap() + .get_block_hash(height) + { + Ok(core_block_hash) => { + let block_hash = BlockHash::from_byte_array(core_block_hash.to_byte_array()); + self.block_hash_cache.insert(height, block_hash); + Ok(block_hash) + } + Err(e) => Err(e.to_string()), + } + } + // + // fn feed_qr_info_cl_sigs(&mut self, qr_info: &QRInfo) { + // let heights = match self.masternode_list_engine.required_cl_sig_heights(qr_info) { + // Ok(heights) => heights, + // Err(e) => { + // self.error = Some(e.to_string()); + // return; + // } + // }; + // for height in heights { + // let block_hash = match self.get_block_hash(height) { + // Ok(block_hash) => block_hash, + // Err(e) => { + // self.error = Some(e.to_string()); + // return; + // } + // }; + // let maybe_chain_lock_sig = match self + // .app_context + // .core_client + // .get_block(&(BlockHash2::from_byte_array(block_hash.to_byte_array()))) + // { + // Ok(block) => { + // let Some(coinbase) = block + // .coinbase() + // .and_then(|coinbase| coinbase.special_transaction_payload.as_ref()) + // .and_then(|payload| payload.clone().to_coinbase_payload().ok()) + // else { + // self.error = + // Some(format!("coinbase not found on block hash {}", block_hash)); + // return; + // }; + // coinbase.best_cl_signature + // } + // Err(e) => { + // self.error = Some(e.to_string()); + // return; + // } + // }; + // if let Some(maybe_chain_lock_sig) = maybe_chain_lock_sig { + // self.masternode_list_engine.feed_chain_lock_sig( + // block_hash, + // BLSSignature::from(maybe_chain_lock_sig.to_bytes()), + // ); + // } + // } + // } + + #[allow(dead_code)] + fn feed_qr_info_block_heights(&mut self, qr_info: &QRInfo) { + let mn_list_diffs = [ + &qr_info.mn_list_diff_tip, + &qr_info.mn_list_diff_h, + &qr_info.mn_list_diff_at_h_minus_c, + &qr_info.mn_list_diff_at_h_minus_2c, + &qr_info.mn_list_diff_at_h_minus_3c, + ]; + + // If h-4c exists, add it to the list + if let Some((_, mn_list_diff_h_minus_4c)) = + &qr_info.quorum_snapshot_and_mn_list_diff_at_h_minus_4c + { + mn_list_diffs.iter().for_each(|&mn_list_diff| { + self.feed_mn_list_diff_heights(mn_list_diff); + }); + + // Feed h-4c separately + self.feed_mn_list_diff_heights(mn_list_diff_h_minus_4c); + } else { + mn_list_diffs.iter().for_each(|&mn_list_diff| { + self.feed_mn_list_diff_heights(mn_list_diff); + }); + } + + // Process `last_commitment_per_index` quorum hashes + qr_info + .last_commitment_per_index + .iter() + .for_each(|quorum_entry| { + self.feed_quorum_entry_height(quorum_entry); + }); + + // Process `mn_list_diff_list` (extra diffs) + qr_info.mn_list_diff_list.iter().for_each(|mn_list_diff| { + self.feed_mn_list_diff_heights(mn_list_diff); + }); + } + + /// **Helper function:** Feeds the base and block hash heights of an `MnListDiff` + fn feed_mn_list_diff_heights(&mut self, mn_list_diff: &MnListDiff) { + // Feed base block hash height + if let Ok(base_height) = self.get_height(&mn_list_diff.base_block_hash) { + println!("feeding {} {}", base_height, mn_list_diff.base_block_hash); + self.masternode_list_engine + .feed_block_height(base_height, mn_list_diff.base_block_hash); + } else { + self.error = Some(format!( + "Failed to get height for base block hash: {}", + mn_list_diff.base_block_hash + )); + } + + // Feed block hash height + if let Ok(block_height) = self.get_height(&mn_list_diff.block_hash) { + println!("feeding {} {}", block_height, mn_list_diff.block_hash); + self.masternode_list_engine + .feed_block_height(block_height, mn_list_diff.block_hash); + } else { + self.error = Some(format!( + "Failed to get height for block hash: {}", + mn_list_diff.block_hash + )); + } + } + + /// **Helper function:** Feeds the quorum hash height of a `QuorumEntry` + fn feed_quorum_entry_height(&mut self, quorum_entry: &QuorumEntry) { + if let Ok(height) = self.get_height(&quorum_entry.quorum_hash) { + self.masternode_list_engine + .feed_block_height(height, quorum_entry.quorum_hash); + } else { + self.error = Some(format!( + "Failed to get height for quorum hash: {}", + quorum_entry.quorum_hash + )); + } + } + + fn parse_heights(&mut self) -> Result<(HeightHash, HeightHash), String> { + let base = if self.base_block_height.is_empty() { + self.base_block_height = "0".to_string(); + match self + .app_context + .core_client + .read() + .unwrap() + .get_block_hash(0) + { + Ok(block_hash) => (0, BlockHash::from_byte_array(block_hash.to_byte_array())), + Err(e) => { + return Err(e.to_string()); + } + } + } else { + match self.base_block_height.trim().parse() { + Ok(start) => match self + .app_context + .core_client + .read() + .unwrap() + .get_block_hash(start) + { + Ok(block_hash) => ( + start, + BlockHash::from_byte_array(block_hash.to_byte_array()), + ), + Err(e) => { + return Err(e.to_string()); + } + }, + Err(e) => { + return Err(e.to_string()); + } + } + }; + let end = if self.end_block_height.is_empty() { + match self + .app_context + .core_client + .read() + .unwrap() + .get_best_block_hash() + { + Ok(block_hash) => { + match self + .app_context + .core_client + .read() + .unwrap() + .get_block_header_info(&block_hash) + { + Ok(header) => { + self.end_block_height = format!("{}", header.height); + ( + header.height as u32, + BlockHash::from_byte_array(block_hash.to_byte_array()), + ) + } + Err(e) => { + return Err(e.to_string()); + } + } + } + Err(e) => { + return Err(e.to_string()); + } + } + } else { + match self.end_block_height.trim().parse() { + Ok(end) => match self + .app_context + .core_client + .read() + .unwrap() + .get_block_hash(end) + { + Ok(block_hash) => (end, BlockHash::from_byte_array(block_hash.to_byte_array())), + Err(e) => { + return Err(e.to_string()); + } + }, + Err(e) => { + return Err(e.to_string()); + } + } + }; + Ok((base, end)) + } + + fn serialize_masternode_list_engine(&self) -> Result { + match bincode::encode_to_vec(&self.masternode_list_engine, bincode::config::standard()) { + Ok(encoded_bytes) => Ok(hex::encode(encoded_bytes)), // Convert to hex string + Err(e) => Err(format!("Serialization failed: {}", e)), + } + } + + fn insert_mn_list_diff(&mut self, mn_list_diff: &MnListDiff) { + let base_block_hash = mn_list_diff.base_block_hash; + let base_height = match self.get_height_and_cache(&base_block_hash) { + Ok(height) => height, + Err(e) => { + self.error = Some(e); + return; + } + }; + let block_hash = mn_list_diff.block_hash; + let height = match self.get_height_and_cache(&block_hash) { + Ok(height) => height, + Err(e) => { + self.error = Some(e); + return; + } + }; + + self.mnlist_diffs + .insert((base_height, height), mn_list_diff.clone()); + } + + fn fetch_rotated_quorum_info( + &mut self, + p2p_handler: &mut CoreP2PHandler, + base_block_hash: BlockHash, + block_hash: BlockHash, + ) -> Option { + let mut known_block_hashes: Vec<_> = self + .mnlist_diffs + .values() + .map(|mn_list_diff| mn_list_diff.block_hash) + .collect(); + known_block_hashes.push(base_block_hash); + println!( + "requesting with known_block_hashes {}", + known_block_hashes + .iter() + .map(|bh| bh.to_string()) + .join(", ") + ); + let qr_info = match p2p_handler.get_qr_info(known_block_hashes, block_hash) { + Ok(list_diff) => list_diff, + Err(e) => { + self.error = Some(e); + return None; + } + }; + self.insert_mn_list_diff(&qr_info.mn_list_diff_tip); + self.insert_mn_list_diff(&qr_info.mn_list_diff_h); + self.insert_mn_list_diff(&qr_info.mn_list_diff_at_h_minus_c); + self.insert_mn_list_diff(&qr_info.mn_list_diff_at_h_minus_2c); + self.insert_mn_list_diff(&qr_info.mn_list_diff_at_h_minus_3c); + if let Some((_, mn_list_diff_at_h_minus_4c)) = + &qr_info.quorum_snapshot_and_mn_list_diff_at_h_minus_4c + { + self.insert_mn_list_diff(mn_list_diff_at_h_minus_4c); + } + for diff in &qr_info.mn_list_diff_list { + self.insert_mn_list_diff(diff) + } + self.qr_infos.insert(block_hash, qr_info.clone()); + Some(qr_info) + } + + fn fetch_diffs_with_hashes( + &mut self, + p2p_handler: &mut CoreP2PHandler, + hashes: BTreeSet, + ) { + let mut hashes_needed_to_validate = BTreeMap::new(); + for quorum_hash in hashes { + let height = match self.get_height_and_cache(&quorum_hash) { + Ok(height) => height, + Err(e) => { + self.error = Some(e.to_string()); + return; + } + }; + let validation_hash = match self + .app_context + .core_client + .read() + .unwrap() + .get_block_hash(height - 8) + { + Ok(block_hash) => block_hash, + Err(e) => { + self.error = Some(e.to_string()); + return; + } + }; + hashes_needed_to_validate.insert( + height - 8, + BlockHash::from_byte_array(validation_hash.to_byte_array()), + ); + } + + if let Some((oldest_needed_height, _)) = hashes_needed_to_validate.first_key_value() { + let (first_engine_height, first_masternode_list) = self + .masternode_list_engine + .masternode_lists + .first_key_value() + .unwrap(); + let (mut base_block_height, mut base_block_hash) = if *first_engine_height + < *oldest_needed_height + { + (*first_engine_height, first_masternode_list.block_hash) + } else { + let known_genesis_block_hash = match self + .masternode_list_engine + .network + .known_genesis_block_hash() + { + None => match self + .app_context + .core_client + .read() + .unwrap() + .get_block_hash(0) + { + Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), + Err(e) => { + self.error = Some(e.to_string()); + return; + } + }, + Some(known_genesis_block_hash) => known_genesis_block_hash, + }; + (0, known_genesis_block_hash) + }; + + for (core_block_height, block_hash) in hashes_needed_to_validate { + self.fetch_single_dml( + p2p_handler, + base_block_hash, + base_block_height, + block_hash, + core_block_height, + false, + ); + base_block_hash = block_hash; + base_block_height = core_block_height; + } + } + } + + fn fetch_single_dml( + &mut self, + p2p_handler: &mut CoreP2PHandler, + base_block_hash: BlockHash, + base_block_height: u32, + block_hash: BlockHash, + block_height: u32, + validate_quorums: bool, + ) { + let list_diff = match p2p_handler.get_dml_diff(base_block_hash, block_hash) { + Ok(list_diff) => list_diff, + Err(e) => { + self.error = Some(e); + return; + } + }; + + if base_block_height == 0 && self.masternode_list_engine.masternode_lists.is_empty() { + self.masternode_list_engine = match MasternodeListEngine::initialize_with_diff_to_height( + list_diff.clone(), + block_height, + self.app_context.network, + ) { + Ok(masternode_list_engine) => masternode_list_engine, + Err(e) => { + self.error = Some(e.to_string()); + return; + } + } + } else if let Err(e) = self.masternode_list_engine.apply_diff( + list_diff.clone(), + Some(block_height), + false, + None, + ) { + self.error = Some(e.to_string()); + return; + } + + if validate_quorums && !self.masternode_list_engine.masternode_lists.is_empty() { + let hashes = self + .masternode_list_engine + .latest_masternode_list_non_rotating_quorum_hashes( + &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], + true, + ); + self.fetch_diffs_with_hashes(p2p_handler, hashes); + let hashes = self + .masternode_list_engine + .latest_masternode_list_rotating_quorum_hashes(&[]); + for hash in &hashes { + let height = match self.get_height_and_cache(hash) { + Ok(height) => height, + Err(e) => { + self.error = Some(e.to_string()); + return; + } + }; + self.block_height_cache.insert(*hash, height); + } + + if let Err(e) = self + .masternode_list_engine + .verify_non_rotating_masternode_list_quorums( + block_height, + &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], + ) + { + self.error = Some(e.to_string()); + } + } + + self.mnlist_diffs + .insert((base_block_height, block_height), list_diff); + } + + // fn fetch_range_dml(&mut self, step: u32, include_at_minus_8: bool, count: u32) { + // let ((base_block_height, base_block_hash), (block_height, block_hash)) = + // match self.parse_heights() { + // Ok(a) => a, + // Err(e) => { + // self.error = Some(e); + // return; + // } + // }; + // + // let mut p2p_handler = match CoreP2PHandler::new(self.app_context.network, None) { + // Ok(p2p_handler) => p2p_handler, + // Err(e) => { + // self.error = Some(e); + // return; + // } + // }; + // + // let rem = block_height % 24; + // + // let intermediate_block_height = (block_height - rem).saturating_sub(count * step); + // + // let intermediate_block_hash = match self + // .app_context + // .core_client + // .get_block_hash(intermediate_block_height) + // { + // Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), + // Err(e) => { + // self.error = Some(e.to_string()); + // return; + // } + // }; + // + // self.fetch_single_dml( + // &mut p2p_handler, + // base_block_hash, + // base_block_height, + // intermediate_block_hash, + // intermediate_block_height, + // false, + // ); + // + // let mut last_height = intermediate_block_height; + // let mut last_block_hash = intermediate_block_hash; + // + // for _i in 0..count { + // if include_at_minus_8 { + // let end_height = last_height + step - 8; + // let end_block_hash = match self.app_context.core_client.read().unwrap().get_block_hash(end_height) { + // Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), + // Err(e) => { + // self.error = Some(e.to_string()); + // return; + // } + // }; + // self.fetch_single_dml( + // &mut p2p_handler, + // last_block_hash, + // last_height, + // end_block_hash, + // end_height, + // ); + // last_height = end_height; + // last_block_hash = end_block_hash; + // + // let end_height = last_height + 8; + // let end_block_hash = match self.app_context.core_client.read().unwrap().get_block_hash(end_height) { + // Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), + // Err(e) => { + // self.error = Some(e.to_string()); + // return; + // } + // }; + // self.fetch_single_dml( + // &mut p2p_handler, + // last_block_hash, + // last_height, + // end_block_hash, + // end_height, + // ); + // last_height = end_height; + // last_block_hash = end_block_hash; + // } else { + // let end_height = last_height + step; + // let end_block_hash = match self.app_context.core_client.read().unwrap().get_block_hash(end_height) { + // Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), + // Err(e) => { + // self.error = Some(e.to_string()); + // return; + // } + // }; + // self.fetch_single_dml( + // &mut p2p_handler, + // last_block_hash, + // last_height, + // end_block_hash, + // end_height, + // ); + // last_height = end_height; + // last_block_hash = end_block_hash; + // } + // } + // + // if rem != 0 { + // let end_height = last_height + rem; + // let end_block_hash = match self.app_context.core_client.read().unwrap().get_block_hash(end_height) { + // Ok(block_hash) => BlockHash::from_byte_array(block_hash.to_byte_array()), + // Err(e) => { + // self.error = Some(e.to_string()); + // return; + // } + // }; + // self.fetch_single_dml( + // &mut p2p_handler, + // last_block_hash, + // last_height, + // end_block_hash, + // end_height, + // ); + // } + // + // // Reset selections when new data is loaded + // self.selected_dml_diff_key = None; + // self.selected_quorum_in_diff_index = None; + // } + + /// Clear all data and reset to initial state + pub(crate) fn clear(&mut self) { + self.masternode_list_engine = + MasternodeListEngine::default_for_network(self.app_context.network); + + // Clear cached data structures + self.mnlist_diffs.clear(); + self.qr_infos.clear(); + self.chain_locked_blocks.clear(); + self.instant_send_transactions.clear(); + self.block_height_cache.clear(); + self.block_hash_cache.clear(); + self.masternode_list_quorum_hash_cache.clear(); + self.masternode_lists_with_all_quorum_heights_known.clear(); + self.dml_diffs_with_cached_quorum_heights.clear(); + self.chain_lock_sig_cache.clear(); + self.chain_lock_reversed_sig_cache.clear(); + + // Reset selections and UI state + self.selected_dml_diff_key = None; + self.selected_dml_height_key = None; + self.selected_option_index = None; + self.selected_quorum_in_diff_index = None; + self.selected_masternode_in_diff_index = None; + self.selected_quorum_hash_in_mnlist_diff = None; + self.selected_masternode_pro_tx_hash = None; + self.selected_qr_item = None; + self.selected_core_item = None; + self.pending = None; + self.queued_task = None; + self.search_term = None; + self.error = None; + self.message = None; + } + + /// Clear all data except the oldest MNList diff starting from height 0 + fn clear_keep_base(&mut self) { + let (engine, start_end_diff) = + if let Some(((start, end), oldest_diff)) = self.mnlist_diffs.first_key_value() { + if start == &0 { + MasternodeListEngine::initialize_with_diff_to_height( + oldest_diff.clone(), + *end, + self.app_context.network, + ) + .map(|engine| (engine, Some(((*start, *end), oldest_diff.clone())))) + .unwrap_or(( + MasternodeListEngine::default_for_network(self.app_context.network), + None, + )) + } else { + ( + MasternodeListEngine::default_for_network(self.app_context.network), + None, + ) + } + } else { + ( + MasternodeListEngine::default_for_network(self.app_context.network), + None, + ) + }; + + self.masternode_list_engine = engine; + self.mnlist_diffs = Default::default(); + if let Some((key, oldest_diff)) = start_end_diff { + self.mnlist_diffs.insert(key, oldest_diff); + } + self.selected_dml_diff_key = None; + self.selected_dml_height_key = None; + self.selected_option_index = None; + self.selected_quorum_in_diff_index = None; + self.selected_masternode_in_diff_index = None; + self.selected_quorum_hash_in_mnlist_diff = None; + self.selected_masternode_pro_tx_hash = None; + self.qr_infos = Default::default(); + self.message = None; + // Clear chain lock signatures caches as these are independent of the retained base diff + self.chain_lock_sig_cache.clear(); + self.chain_lock_reversed_sig_cache.clear(); + } + + /// Fetch the MNList diffs between the given base and end block heights. + /// In a real implementation, you would replace the dummy function below with a call to + /// dash_core’s DB (or other data source) to retrieve the MNList diffs. + #[allow(dead_code)] + fn fetch_end_dml_diff(&mut self, validate_quorums: bool) { + let ((base_block_height, base_block_hash), (block_height, block_hash)) = + match self.parse_heights() { + Ok(a) => a, + Err(e) => { + self.error = Some(e); + return; + } + }; + + let mut p2p_handler = match CoreP2PHandler::new(self.app_context.network, None) { + Ok(p2p_handler) => p2p_handler, + Err(e) => { + self.error = Some(e); + return; + } + }; + + self.fetch_single_dml( + &mut p2p_handler, + base_block_hash, + base_block_height, + block_hash, + block_height, + validate_quorums, + ); + + // Reset selections when new data is loaded + self.selected_dml_diff_key = None; + self.selected_quorum_in_diff_index = None; + } + + #[allow(dead_code)] + fn fetch_end_qr_info(&mut self) { + let ((_, base_block_hash), (_, block_hash)) = match self.parse_heights() { + Ok(a) => a, + Err(e) => { + self.error = Some(e); + return; + } + }; + + let mut p2p_handler = match CoreP2PHandler::new(self.app_context.network, None) { + Ok(p2p_handler) => p2p_handler, + Err(e) => { + self.error = Some(e); + return; + } + }; + + self.fetch_rotated_quorum_info(&mut p2p_handler, base_block_hash, block_hash); + + // Reset selections when new data is loaded + self.selected_dml_diff_key = None; + self.selected_quorum_in_diff_index = None; + } + + #[allow(dead_code)] + fn fetch_chain_locks(&mut self) { + let ((base_block_height, _base_block_hash), (block_height, _block_hash)) = + match self.parse_heights() { + Ok(a) => a, + Err(e) => { + self.error = Some(e); + return; + } + }; + + let max_blocks = 2000; + + let loaded_list_height = match self.app_context.network { + Network::Dash => 2227096, + Network::Testnet => 1296600, + _ => 0, + }; + + let start_height = if base_block_height < loaded_list_height { + block_height - max_blocks + } else { + base_block_height + }; + + let end_height = std::cmp::min(start_height + max_blocks, block_height); + + for i in start_height..end_height { + if let Ok(block_hash) = self.get_block_hash_and_cache(i) { + self.get_chain_lock_sig_and_cache(&block_hash).ok(); + } + } + } + + #[allow(dead_code)] + fn sync(&mut self) { + if !self.syncing { + self.syncing = true; + self.fetch_end_qr_info_with_dmls(); + } + } + + #[allow(dead_code)] + fn fetch_end_qr_info_with_dmls(&mut self) { + let ((_, base_block_hash), (_, block_hash)) = match self.parse_heights() { + Ok(a) => a, + Err(e) => { + self.error = Some(e); + return; + } + }; + + let mut p2p_handler = match CoreP2PHandler::new(self.app_context.network, None) { + Ok(p2p_handler) => p2p_handler, + Err(e) => { + self.error = Some(e); + return; + } + }; + + let Some(qr_info) = + self.fetch_rotated_quorum_info(&mut p2p_handler, base_block_hash, block_hash) + else { + return; + }; + + self.feed_qr_info_and_get_dmls(qr_info, Some(p2p_handler)) + } + + fn feed_qr_info_and_get_dmls( + &mut self, + qr_info: QRInfo, + core_p2phandler: Option, + ) { + let mut p2p_handler = match core_p2phandler { + None => match CoreP2PHandler::new(self.app_context.network, None) { + Ok(p2p_handler) => p2p_handler, + Err(e) => { + self.error = Some(e); + return; + } + }, + Some(core_p2phandler) => core_p2phandler, + }; + + // Extracting immutable references before calling `feed_qr_info` + let get_height_fn = { + let block_height_cache = &self.block_height_cache; + let app_context = &self.app_context; + + move |block_hash: &BlockHash| { + if block_hash.as_byte_array() == &[0; 32] { + return Ok(0); + } + if let Some(height) = block_height_cache.get(block_hash) { + return Ok(*height); + } + match app_context + .core_client + .read() + .unwrap() + .get_block_header_info( + &(BlockHash2::from_byte_array(block_hash.to_byte_array())), + ) { + Ok(block_info) => Ok(block_info.height as CoreBlockHeight), + Err(_) => Err(ClientDataRetrievalError::RequiredBlockNotPresent( + *block_hash, + )), + } + } + }; + + if let Err(e) = + self.masternode_list_engine + .feed_qr_info(qr_info, false, true, Some(get_height_fn)) + { + self.error = Some(e.to_string()); + return; + } + + let hashes = self + .masternode_list_engine + .latest_masternode_list_non_rotating_quorum_hashes( + &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], + true, + ); + self.fetch_diffs_with_hashes(&mut p2p_handler, hashes); + let hashes = self + .masternode_list_engine + .latest_masternode_list_rotating_quorum_hashes(&[]); + for hash in &hashes { + let height = match self.get_height_and_cache(hash) { + Ok(height) => height, + Err(e) => { + self.error = Some(e.to_string()); + return; + } + }; + self.block_height_cache.insert(*hash, height); + } + + if let Some(latest_masternode_list) = self.masternode_list_engine.latest_masternode_list() + && let Err(e) = self + .masternode_list_engine + .verify_non_rotating_masternode_list_quorums( + latest_masternode_list.known_height, + &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], + ) + { + self.error = Some(e.to_string()); + } + + // Reset selections when new data is loaded + self.selected_dml_diff_key = None; + self.selected_quorum_in_diff_index = None; + } + + /// Render the input area at the top (base and end block height fields plus Get DMLs button) + fn render_input_area(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + ScrollArea::horizontal() + .id_salt("dml_input_row_scroll") + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label("Base Block Height:"); + ui.add(TextEdit::singleline(&mut self.base_block_height).desired_width(80.0)); + ui.label("End Block Height:"); + ui.add(TextEdit::singleline(&mut self.end_block_height).desired_width(80.0)); + if ui.button("Get single end DML diff").clicked() + && let Ok(((base_h, base_hash), (h, hash))) = self.parse_heights() + { + self.pending = Some(PendingTask::DmlDiffSingle); + action = AppAction::BackendTask(BackendTask::MnListTask( + MnListTask::FetchEndDmlDiff { + base_block_height: base_h, + base_block_hash: base_hash, + block_height: h, + block_hash: hash, + validate_quorums: false, + }, + )); + } + if ui.button("Get single end QR info").clicked() + && let Ok(((_, base_hash), (_, hash))) = self.parse_heights() + { + self.pending = Some(PendingTask::QrInfo); + // Build known_block_hashes from current diffs + base hash (old UI behavior) + let mut known_block_hashes: Vec<_> = self + .mnlist_diffs + .values() + .map(|mn_list_diff| mn_list_diff.block_hash) + .collect(); + known_block_hashes.push(base_hash); + action = AppAction::BackendTask(BackendTask::MnListTask( + MnListTask::FetchEndQrInfo { + known_block_hashes, + block_hash: hash, + }, + )); + } + if ui.button("Get DMLs w/o rotation").clicked() + && let Ok(((base_h, base_hash), (h, hash))) = self.parse_heights() + { + self.pending = Some(PendingTask::DmlDiffNoRotation); + action = AppAction::BackendTask(BackendTask::MnListTask( + MnListTask::FetchEndDmlDiff { + base_block_height: base_h, + base_block_hash: base_hash, + block_height: h, + block_hash: hash, + validate_quorums: true, + }, + )); + } + if ui.button("Get DMLs w/ rotation").clicked() + && let Ok(((_, base_hash), (_, hash))) = self.parse_heights() + { + self.pending = Some(PendingTask::QrInfoWithDmls); + // Build known_block_hashes from current diffs + base hash (old UI behavior) + let mut known_block_hashes: Vec<_> = self + .mnlist_diffs + .values() + .map(|mn_list_diff| mn_list_diff.block_hash) + .collect(); + known_block_hashes.push(base_hash); + action = AppAction::BackendTask(BackendTask::MnListTask( + MnListTask::FetchEndQrInfoWithDmls { + known_block_hashes, + block_hash: hash, + }, + )); + } + if ui.button("Sync").clicked() + && let Ok(((_, base_hash), (_, hash))) = self.parse_heights() + { + self.pending = Some(PendingTask::QrInfoWithDmls); + // Build known_block_hashes from current diffs + base hash (old UI behavior) + let mut known_block_hashes: Vec<_> = self + .mnlist_diffs + .values() + .map(|mn_list_diff| mn_list_diff.block_hash) + .collect(); + known_block_hashes.push(base_hash); + action = AppAction::BackendTask(BackendTask::MnListTask( + MnListTask::FetchEndQrInfoWithDmls { + known_block_hashes, + block_hash: hash, + }, + )); + } + if ui.button("Get chain locks").clicked() + && let Ok(((base_h, _), (h, _))) = self.parse_heights() + { + self.pending = Some(PendingTask::ChainLocks); + action = AppAction::BackendTask(BackendTask::MnListTask( + MnListTask::FetchChainLocks { + base_block_height: base_h, + block_height: h, + }, + )); + } + if ui + .button("Clear") + .on_hover_text("Clear all data and reset to initial state.") + .clicked() + { + self.clear(); + self.display_message("Cleared all data", MessageType::Success); + } + if ui + .button("Clear keep base") + .on_hover_text( + "Clear all data except the oldest MNList diff starting from height 0.", + ) + .clicked() + { + self.clear_keep_base(); + self.display_message( + "Cleared data and kept base diff", + MessageType::Success, + ); + } + }); + // Add bottom padding so the horizontal scrollbar doesn't overlap buttons + ui.add_space(12.0); + }); + action + } + + fn load_masternode_list_engine(&mut self) { + if let Some(path) = rfd::FileDialog::new() + .add_filter("Binary", &["dat"]) + .pick_file() + { + match std::fs::read(&path) { + Ok(bytes) => { + match bincode::decode_from_slice::( + &bytes, + bincode::config::standard(), + ) { + Ok((engine, _)) => { + self.masternode_list_engine = engine; + } + Err(e) => { + eprintln!("Failed to decode QRInfo: {}", e); + } + } + } + Err(e) => { + eprintln!("Failed to read file: {:?}", e); + } + } + } + } + + fn save_masternode_list_engine(&mut self) { + // Serialize the masternode list engine + let serialized = match self.serialize_masternode_list_engine() { + Ok(serialized) => serialized, + Err(e) => { + self.error = Some(format!("Serialization failed: {}", e)); + return; + } + }; + + // Open a file save dialog + if let Some(path) = FileDialog::new() + .set_title("Save Masternode List Engine") + .add_filter("JSON", &["hex"]) + .add_filter("Binary", &["bin"]) + .set_file_name("masternode_list_engine.hex") + .save_file() + { + // Attempt to write the serialized data to the selected file + match fs::write(&path, serialized) { + Ok(_) => { + println!("Masternode list engine saved to {:?}", path); + } + Err(e) => { + self.error = Some(format!("Failed to save file: {}", e)); + } + } + } + } + + fn render_masternode_lists(&mut self, ui: &mut Ui) { + ui.heading("Masternode lists"); + ScrollArea::vertical() + .id_salt("dml_list_scroll_area") + .show(ui, |ui| { + for height in self.masternode_list_engine.masternode_lists.keys() { + let height_label = format!("{}", height); + + if ui + .selectable_label( + self.selected_dml_height_key == Some(*height), + height_label, + ) + .clicked() + { + self.selected_dml_diff_key = None; + self.selected_dml_height_key = Some(*height); + self.selected_quorum_in_diff_index = None; + } + } + }); + } + + /// Render MNList diffs list (block heights) + fn render_diff_list(&mut self, ui: &mut Ui) { + ui.heading("MNList Diffs"); + ScrollArea::vertical() + .id_salt("dml_list_scroll_area") + .show(ui, |ui| { + for (key, _dml) in self.mnlist_diffs.iter() { + let block_label = format!("Base: {} -> Block: {}", key.0, key.1); + + if ui + .selectable_label(self.selected_dml_diff_key == Some(*key), block_label) + .clicked() + { + self.selected_dml_diff_key = Some(*key); + self.selected_dml_height_key = None; + self.selected_quorum_in_diff_index = None; + } + } + }); + } + + /// Render the list of quorums for the selected DML + fn render_new_quorums(&mut self, ui: &mut Ui) { + ui.heading("New Quorums"); + + let should_get_heights = if let Some(selected_key) = self.selected_dml_diff_key { + if self.mnlist_diffs.contains_key(&selected_key) { + !self + .dml_diffs_with_cached_quorum_heights + .contains(&selected_key) + } else { + false + } + } else { + false + }; + + let heights = if should_get_heights { + if let Some(selected_key) = self.selected_dml_diff_key { + if let Some(quorums) = self + .mnlist_diffs + .get(&selected_key) + .map(|dml| dml.new_quorums.clone()) + { + let mut map = HashMap::new(); + for quorum in quorums { + let height = self + .get_height_and_cache(&quorum.quorum_hash) + .ok() + .unwrap_or_default(); + map.insert(quorum.quorum_hash, height); + } + map + } else { + HashMap::new() + } + } else { + HashMap::new() + } + } else if let Some(selected_key) = self.selected_dml_diff_key { + if let Some(quorums) = self + .mnlist_diffs + .get(&selected_key) + .map(|dml| dml.new_quorums.clone()) + { + let mut map = HashMap::new(); + for quorum in quorums { + let height = self + .get_height(&quorum.quorum_hash) + .ok() + .unwrap_or_default(); + map.insert(quorum.quorum_hash, height); + } + map + } else { + HashMap::new() + } + } else { + HashMap::new() + }; + + let new_quorums = self + .selected_dml_diff_key + .and_then(|selected_key| self.mnlist_diffs.get(&selected_key)) + .map(|diff| &diff.new_quorums); + + if let Some(new_quorums) = new_quorums { + ScrollArea::vertical() + .id_salt("quorum_list_scroll_area") + .show(ui, |ui| { + for (q_index, quorum) in new_quorums.iter().enumerate() { + let quorum_height = heights + .get(&quorum.quorum_hash) + .copied() + .unwrap_or_default(); + if ui + .selectable_label( + self.selected_quorum_in_diff_index == Some(q_index), + format!( + "Quorum height {} [..]{}{} Type: {}", + quorum_height, + quorum.quorum_hash.to_string().as_str().split_at(58).1, + quorum + .quorum_index + .map(|i| format!(" (index {})", i)) + .unwrap_or_default(), + QuorumType::from(quorum.llmq_type as u32) + ), + ) + .clicked() + { + self.selected_quorum_in_diff_index = Some(q_index); + self.selected_masternode_in_diff_index = None; + } + } + }); + } else { + ui.label("Select a block height to show quorums."); + } + } + + fn render_selected_masternode_list_items(&mut self, ui: &mut Ui) { + ui.heading("Masternode List Explorer"); + + // Define available options for selection + let options = ["Quorums", "Masternodes"]; + let selected_index = self.selected_option_index.unwrap_or(0); + + // Render the selection buttons + ui.horizontal(|ui| { + for (index, option) in options.iter().enumerate() { + if ui + .selectable_label(selected_index == index, *option) + .clicked() + { + self.selected_option_index = Some(index); + } + } + }); + + ui.separator(); + + // Borrow mn_list separately to avoid multiple borrows of `self` + if self.selected_dml_height_key.is_some() { + ScrollArea::vertical() + .id_salt("mnlist_items_scroll_area") + .show(ui, |ui| match selected_index { + 0 => self.render_quorums_in_masternode_list(ui), + 1 => self.render_masternodes_in_masternode_list(ui), + _ => (), + }); + } else { + ui.label("Select a block height to show details."); + } + } + + fn render_quorums_in_masternode_list(&mut self, ui: &mut Ui) { + let mut heights: BTreeMap = BTreeMap::new(); + let mut masternode_block_hash = None; + if let Some(selected_height) = self.selected_dml_height_key { + if !self + .masternode_lists_with_all_quorum_heights_known + .contains(&selected_height) + { + if let Some(quorum_hashes) = self + .masternode_list_engine + .masternode_lists + .get(&selected_height) + .map(|list| { + list.quorums + .values() + .flat_map(|quorums| quorums.keys()) + .copied() + .collect::>() + }) + { + for quorum_hash in quorum_hashes.iter() { + if let Ok(height) = self.get_height_and_cache(quorum_hash) { + heights.insert(*quorum_hash, height); + } + } + } + self.masternode_lists_with_all_quorum_heights_known + .insert(selected_height); + } + if let Some(mn_list) = self + .masternode_list_engine + .masternode_lists + .get(&selected_height) + { + masternode_block_hash = Some(mn_list.block_hash); + for (llmq_type, quorum_map) in &mn_list.quorums { + if llmq_type == &LLMQType::Llmqtype50_60 + || llmq_type == &LLMQType::Llmqtype400_85 + { + continue; + } + for quorum_hash in quorum_map.keys() { + if let Ok(height) = self.get_height(quorum_hash) { + heights.insert(*quorum_hash, height); + } + } + } + self.masternode_list_quorum_hash_cache + .entry(mn_list.block_hash) + .or_insert_with(|| { + let mut btree_map = BTreeMap::new(); + for (llmq_type, quorum_map) in &mn_list.quorums { + let quorums_by_height = quorum_map + .iter() + .map(|(quorum_hash, quorum_entry)| { + ( + heights.get(quorum_hash).copied().unwrap_or_default(), + quorum_entry.clone(), + ) + }) + .collect(); + btree_map.insert(*llmq_type, quorums_by_height); + } + btree_map + }); + } + } + if let Some(quorums) = masternode_block_hash + .and_then(|block_hash| self.masternode_list_quorum_hash_cache.get(&block_hash)) + { + ui.heading("Quorums in Masternode List"); + ui.label("(excluding 50_60 and 400_85)"); + ScrollArea::vertical() + .id_salt("quorum_list_scroll_area") + .show(ui, |ui| { + for (llmq_type, quorum_map) in quorums { + if llmq_type == &LLMQType::Llmqtype50_60 + || llmq_type == &LLMQType::Llmqtype400_85 + { + continue; + } + for (quorum_height, quorum_entry) in quorum_map.iter() { + if ui + .selectable_label( + self.selected_quorum_hash_in_mnlist_diff + == Some(( + *llmq_type, + quorum_entry.quorum_entry.quorum_hash, + )), + format!( + "Quorum {} Type: {} Valid {}", + quorum_height, + QuorumType::from(*llmq_type as u32), + quorum_entry.verified + == LLMQEntryVerificationStatus::Verified + ), + ) + .clicked() + { + self.selected_quorum_hash_in_mnlist_diff = + Some((*llmq_type, quorum_entry.quorum_entry.quorum_hash)); + self.selected_masternode_pro_tx_hash = None; + self.selected_dml_diff_key = None; + } + } + } + }); + } + } + + /// Filter masternodes based on the search term + fn filter_masternodes( + &self, + mn_list: &MasternodeList, + ) -> BTreeMap { + // If no search term, return all masternodes + if let Some(search_term) = &self.search_term { + let search_term = search_term.to_lowercase(); + + if search_term.len() < 3 { + return mn_list.masternodes.clone(); // Require at least 3 characters to filter + } + + mn_list + .masternodes + .iter() + .filter(|(pro_tx_hash, mn_entry)| { + let masternode = &mn_entry.masternode_list_entry; + + // Convert fields to lowercase for case-insensitive search + let pro_tx_hash_str = pro_tx_hash.to_string().to_lowercase(); + let confirmed_hash_str = masternode + .confirmed_hash + .map(|h| h.to_string().to_lowercase()) + .unwrap_or_default(); + let service_ip = masternode.service_address.ip().to_string().to_lowercase(); + let operator_public_key = + masternode.operator_public_key.to_string().to_lowercase(); + let voting_key_id = masternode.key_id_voting.to_string().to_lowercase(); + + // Check reversed versions + let pro_tx_hash_reversed = pro_tx_hash.reverse().to_string().to_lowercase(); + let confirmed_hash_reversed = masternode + .confirmed_hash + .map(|h| h.reverse().to_string().to_lowercase()) + .unwrap_or_default(); + + // Match against search term + pro_tx_hash_str.contains(&search_term) + || confirmed_hash_str.contains(&search_term) + || service_ip.contains(&search_term) + || operator_public_key.contains(&search_term) + || voting_key_id.contains(&search_term) + || pro_tx_hash_reversed.contains(&search_term) + || confirmed_hash_reversed.contains(&search_term) + }) + .map(|(pro_tx_hash, entry)| (*pro_tx_hash, entry.clone())) + .collect() + } else { + mn_list.masternodes.clone() + } + } + + /// Render search bar + fn render_search_bar(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Search:"); + let mut search_term = self.search_term.clone().unwrap_or_default(); + let response = ui.add(TextEdit::singleline(&mut search_term).desired_width(200.0)); + + if response.changed() { + self.search_term = if search_term.trim().is_empty() { + None + } else { + Some(search_term) + }; + } + }); + } + + fn render_masternodes_in_masternode_list(&mut self, ui: &mut Ui) { + if let Some(selected_height) = self.selected_dml_height_key + && self + .masternode_list_engine + .masternode_lists + .contains_key(&selected_height) + { + ui.heading("Masternodes in List"); + self.render_search_bar(ui); + } + if let Some(selected_height) = self.selected_dml_height_key + && let Some(mn_list) = self + .masternode_list_engine + .masternode_lists + .get(&selected_height) + { + let filtered_masternodes = self.filter_masternodes(mn_list); + ScrollArea::vertical() + .id_salt("masternode_list_scroll_area") + .show(ui, |ui| { + for (pro_tx_hash, masternode) in filtered_masternodes.iter() { + if ui + .selectable_label( + self.selected_masternode_pro_tx_hash == Some(*pro_tx_hash), + format!( + "{} {} {}", + if masternode.masternode_list_entry.mn_type + == EntryMasternodeType::Regular + { + "MN" + } else { + "EN" + }, + masternode.masternode_list_entry.service_address.ip(), + pro_tx_hash.to_string().as_str().split_at(5).0 + ), + ) + .clicked() + { + self.selected_quorum_hash_in_mnlist_diff = None; + self.selected_masternode_pro_tx_hash = Some(*pro_tx_hash); + } + } + }); + } + } + + fn render_masternode_list_page(&mut self, ui: &mut Ui) { + // Use a left-to-right layout that fills the available height so columns can expand fully + let full_w = ui.available_width(); + let full_h = ui.available_height(); + ui.allocate_ui_with_layout( + egui::Vec2::new(full_w, full_h), + Layout::left_to_right(Align::Min), + |ui| { + // Left column (Fixed width: 120px) + ui.allocate_ui_with_layout( + egui::Vec2::new(120.0, ui.available_height()), + Layout::top_down(Align::Min), + |ui| { + self.render_masternode_lists(ui); + }, + ); + + ui.separator(); + + // Middle column (40% of the remaining space) + let mid_w = ui.available_width() * 0.4; + ui.allocate_ui_with_layout( + egui::Vec2::new(mid_w, ui.available_height()), + Layout::top_down(Align::Min), + |ui| { + self.render_selected_masternode_list_items(ui); + }, + ); + + // Right column (Remaining space) + ui.allocate_ui_with_layout( + egui::Vec2::new(ui.available_width(), ui.available_height()), + Layout::top_down(Align::Min), + |ui| { + if self.selected_quorum_hash_in_mnlist_diff.is_some() { + self.render_quorum_details(ui); + } else if self.selected_masternode_pro_tx_hash.is_some() { + self.render_mn_details(ui); + } + }, + ); + }, + ); + } + + fn render_selected_tab(&mut self, ui: &mut Ui) { + // Define available tabs + let mut tabs = vec![ + "Masternode Lists", + "Quorums", + "Diffs", + "QRInfo", + "Known Blocks", + "Known Chain Lock Sigs", + "Core Items", + "Save Masternode List Engine", + "Load Masternode List Engine", + ]; + + if self.syncing { + tabs.push("Stop Syncing"); + } + + // Render the selection buttons (scrollable horizontally) styled as buttons + ScrollArea::horizontal() + .id_salt("dml_tabs_scroll") + .show(ui, |ui| { + ui.horizontal(|ui| { + for (index, tab) in tabs.iter().enumerate() { + let is_selected = self.selected_tab == index; + if is_selected { + // Match the selected look used under "Masternode List Explorer" + let _ = ui.selectable_label(true, *tab); + } else if ui.button(*tab).clicked() { + match index { + 7 => { + // Show the popup when "Masternode List Engine" is selected + self.show_popup_for_render_masternode_list_engine = true; + } + 8 => { + self.load_masternode_list_engine(); + } + 9 => { + self.syncing = false; + } + index => self.selected_tab = index, + } + } + } + }); + // Add bottom padding so the horizontal scrollbar doesn't overlap tabs + ui.add_space(12.0); + }); + + ui.separator(); + + // Scroll only the content below the tab row; for the Masternode Lists page, + // let its own columns manage scrolling independently. + if self.selected_tab == 0 { + // Make the Masternode Lists section occupy remaining height + let full_w = ui.available_width(); + let full_h = ui.available_height(); + ui.allocate_ui_with_layout( + egui::Vec2::new(full_w, full_h), + Layout::top_down(Align::Min), + |ui| { + self.render_masternode_list_page(ui); + }, + ); + } else { + ScrollArea::vertical() + .auto_shrink([false; 2]) + .id_salt("dml_tab_content_scroll") + .show(ui, |ui| match self.selected_tab { + 1 => self.render_quorums(ui), + 2 => self.render_diffs(ui), + 3 => self.render_qr_info(ui), + 4 => self.render_engine_known_blocks(ui), + 5 => self.render_known_chain_lock_sigs(ui), + 6 => self.render_core_items(ui), + _ => {} + }); + } + + // Render the confirmation popup if needed + if self.show_popup_for_render_masternode_list_engine { + egui::Window::new("Confirmation") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .show(ui.ctx(), |ui| { + ui.label("This operation will take about 10 seconds. Are you sure you wish to continue?"); + + ui.horizontal(|ui| { + if ui.button("Yes").clicked() { + self.save_masternode_list_engine(); + self.show_popup_for_render_masternode_list_engine = false; + } + if ui.button("Cancel").clicked() { + self.show_popup_for_render_masternode_list_engine = false; + } + }); + }); + } + } + + fn render_known_chain_lock_sigs(&mut self, ui: &mut Ui) { + ui.heading("Known Chain Lock Sigs"); + + ScrollArea::vertical() + .id_salt("known_chain_lock_sigs_scroll") + .show(ui, |ui| { + egui::Grid::new("known_chain_lock_sigs_grid") + .num_columns(3) // Two columns: Block Height | Block Hash | Sig + .striped(true) + .show(ui, |ui| { + ui.label("Block Height"); + ui.label("Block Hash"); + ui.label("Chain Lock Sig"); + ui.end_row(); + + for ((height, block_hash), sig) in &self.chain_lock_sig_cache { + ui.label(format!("{}", height)); + ui.label(format!("{}", block_hash)); + if let Some(sig) = sig { + ui.label(format!("{}", sig)); + } else { + ui.label("None"); + } + + ui.end_row(); + } + }); + }); + } + + fn render_engine_known_blocks(&mut self, ui: &mut Ui) { + ui.heading("Known Blocks in Masternode List Engine"); + + // Add Save/Load functionality + ui.horizontal(|ui| { + if ui.button("Save Block Container").clicked() { + // Open native save dialog + if let Some(path) = FileDialog::new() + .set_file_name("block_container.dat") + .add_filter("Data Files", &["dat"]) + .save_file() + { + // Serialize and save the block container + let serialized_data = bincode::encode_to_vec( + &self.masternode_list_engine.block_container, + bincode::config::standard(), + ) + .expect("serialize container"); + if let Err(e) = std::fs::write(&path, serialized_data) { + eprintln!("Failed to write file: {}", e); + } + } + } + }); + + ScrollArea::vertical() + .id_salt("known_blocks_scroll") + .show(ui, |ui| { + ui.label(format!( + "Total Known Blocks: {}", + self.masternode_list_engine + .block_container + .known_block_count() + )); + + egui::Grid::new("known_blocks_grid") + .num_columns(2) // Two columns: Block Height | Block Hash + .striped(true) + .show(ui, |ui| { + ui.label("Block Height"); + ui.label("Block Hash"); + ui.end_row(); + + let MasternodeListEngineBlockContainer::BTreeMapContainer(map) = + &self.masternode_list_engine.block_container; + + // Sort block heights for ordered display + let mut known_blocks: Vec<_> = map.block_heights.iter().collect(); + known_blocks.sort_by_key(|(_, height)| *height); + + for (block_hash, height) in known_blocks { + ui.label(format!("{}", height)); + let hash_str = format!("{}", block_hash); + + if ui.selectable_label(false, hash_str.clone()).clicked() { + ui.ctx().copy_text(hash_str.clone()); + } + + ui.end_row(); + } + }); + }); + } + + fn render_diffs(&mut self, ui: &mut Ui) { + // Add Save/Load functionality + ui.horizontal(|ui| { + if ui.button("Save MN List Diffs").clicked() { + // Open native save dialog + if let Some(path) = FileDialog::new() + .set_file_name("mnlistdiffs.dat") + .add_filter("Data Files", &["dat"]) + .save_file() + { + // Serialize and save the block container + let serialized_data = + bincode::encode_to_vec(&self.mnlist_diffs, bincode::config::standard()) + .expect("serialize container"); + if let Err(e) = std::fs::write(&path, serialized_data) { + eprintln!("Failed to write file: {}", e); + } + } + } + }); + // Create a three-column layout: + // - Left column: list of MNList Diffs (by block height) + // - Middle column: list of quorums for the selected DML + // - Right column: quorum details + ui.horizontal(|ui| { + ui.allocate_ui_with_layout( + egui::Vec2::new(150.0, 800.0), // Set fixed width for left column + Layout::top_down(Align::Min), + |ui| { + self.render_diff_list(ui); + }, + ); + + ui.separator(); // Optional: Adds a visual separator + + ui.allocate_ui_with_layout( + egui::Vec2::new(ui.available_width() * 0.4, 800.0), // Middle column + Layout::top_down(Align::Min), + |ui| { + self.render_selected_dml_items(ui); + }, + ); + + ui.allocate_ui_with_layout( + egui::Vec2::new(ui.available_width(), ui.available_height()), // Right column takes remaining space + Layout::top_down(Align::Min), + |ui| { + if self.selected_quorum_in_diff_index.is_some() { + self.render_quorum_details(ui); + } else if self.selected_masternode_in_diff_index.is_some() { + self.render_mn_details(ui); + } + }, + ); + }); + } + + fn render_masternode_changes(&mut self, ui: &mut Ui) { + ui.heading("Masternode changes"); + if let Some(selected_key) = self.selected_dml_diff_key { + if let Some(dml) = self.mnlist_diffs.get(&selected_key) { + ScrollArea::vertical() + .id_salt("quorum_list_scroll_area") + .show(ui, |ui| { + for (m_index, masternode) in dml.new_masternodes.iter().enumerate() { + if ui + .selectable_label( + self.selected_masternode_in_diff_index == Some(m_index), + format!( + "{} {} {}", + if masternode.mn_type == EntryMasternodeType::Regular { + "MN" + } else { + "EN" + }, + masternode.service_address.ip(), + masternode + .pro_reg_tx_hash + .to_string() + .as_str() + .split_at(5) + .0 + ), + ) + .clicked() + { + self.selected_quorum_in_diff_index = None; + self.selected_masternode_in_diff_index = Some(m_index); + } + } + }); + } + } else { + ui.label("Select a block height to show quorums."); + } + } + + fn render_mn_diff_chain_locks(&mut self, ui: &mut Ui) { + ui.heading("MN list diff chain locks"); + if let Some(selected_key) = self.selected_dml_diff_key + && let Some(dml) = self.mnlist_diffs.get(&selected_key) + { + ScrollArea::vertical() + .id_salt("quorum_list_chain_locks_scroll_area") + .show(ui, |ui| { + for (index, sig) in dml.quorums_chainlock_signatures.iter().enumerate() { + ui.group(|ui| { + ui.label(format!("Signature #{}", index)); + ui.monospace(format!( + "Signature: {}", + hex::encode(sig.signature.as_bytes()) + )); + ui.label(format!("Index Set: {:?}", sig.index_set)); + }); + } + }); + } + } + + fn save_mn_list_diff(&mut self) { + let Some(selected_key) = self.selected_dml_diff_key else { + self.error = Some("No MNListDiff selected.".to_string()); + return; + }; + + let Some(mn_list_diff) = self.mnlist_diffs.get(&selected_key) else { + self.error = Some("Failed to retrieve selected MNListDiff.".to_string()); + return; + }; + + // Extract block heights from the selected key + let (base_block_height, block_height) = selected_key; + + // Serialize the MNListDiff + let serialized = serialize(mn_list_diff); + + // Generate the dynamic filename + let file_name = format!("mn_list_diff_{}_{}.bin", base_block_height, block_height); + + // Open a file save dialog with the generated file name + if let Some(path) = FileDialog::new() + .set_title("Save MNListDiff") + .add_filter("Binary", &["bin"]) + .set_file_name(&file_name) // Set the dynamic filename + .save_file() + { + // Attempt to write the serialized data to the selected file + match fs::write(&path, serialized) { + Ok(_) => { + println!("MNListDiff saved to {:?}", path); + } + Err(e) => { + self.error = Some(format!("Failed to save file: {}", e)); + } + } + } + } + + /// Render the list of items for the selected DML, with a selector at the top + fn render_selected_dml_items(&mut self, ui: &mut Ui) { + ui.heading("Masternode List Diff Explorer"); + + // Define available options for selection + let options = [ + "New Quorums", + "Masternode Changes", + "Chain Locks", + "Save Diff", + ]; + let selected_index = self.selected_option_index.unwrap_or(0); + + // Render the selection buttons + ui.horizontal(|ui| { + for (index, option) in options.iter().enumerate() { + if ui + .selectable_label(selected_index == index, *option) + .clicked() + { + // If the user selects "Save MNListDiff", trigger save function + if index == 3 { + self.save_mn_list_diff(); + } else { + self.selected_option_index = Some(index); + } + } + } + }); + + ui.separator(); + + // Determine the selected category and display corresponding information + if let Some(selected_key) = self.selected_dml_diff_key { + if self.mnlist_diffs.contains_key(&selected_key) { + ScrollArea::vertical() + .id_salt("dml_items_scroll_area") + .show(ui, |ui| match selected_index { + 0 => self.render_new_quorums(ui), + 1 => self.render_masternode_changes(ui), + 2 => self.render_mn_diff_chain_locks(ui), + _ => (), + }); + } + } else { + ui.label("Select a block height to show details."); + } + } + + pub fn required_cl_sig_heights(&self, quorum: &QuorumEntry) -> BTreeSet { + let mut required_heights = BTreeSet::new(); + let Ok(quorum_block_height) = self.get_height(&quorum.quorum_hash) else { + return BTreeSet::new(); + }; + let llmq_params = quorum.llmq_type.params(); + let quorum_index = quorum_block_height % llmq_params.dkg_params.interval; + let cycle_base_height = quorum_block_height - quorum_index; + let cycle_length = llmq_params.dkg_params.interval; + for i in 0..=3 { + required_heights.insert(cycle_base_height - i * cycle_length - 8); + } + required_heights + } + + /// Render the details for the selected quorum + fn render_quorum_details(&mut self, ui: &mut Ui) { + ui.heading("Quorum Details"); + if let Some(dml_key) = self.selected_dml_diff_key { + if let Some(dml) = self.mnlist_diffs.get(&dml_key) { + if let Some(q_index) = self.selected_quorum_in_diff_index { + if let Some(quorum) = dml.new_quorums.get(q_index) { + Frame::NONE + .stroke(Stroke::new(1.0, Color32::BLACK)) + .show(ui, |ui| { + ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); + let height = self.get_height(&quorum.quorum_hash).ok(); + + // Build a vector of optional signatures with slots matching new_quorums length + let mut quorum_sig_lookup: Vec> = vec![None; dml.new_quorums.len()]; + + // Fill each slot with the corresponding signature + for quorum_sig_obj in &dml.quorums_chainlock_signatures { + for &index in &quorum_sig_obj.index_set { + if let Some(slot) = quorum_sig_lookup.get_mut(index as usize) { + *slot = Some(&quorum_sig_obj.signature); + } else { + return; + } + } + } + + // Verify all slots have been filled + if quorum_sig_lookup.iter().any(Option::is_none) { + return; + } + + let chain_lock_msg = if let Some(a) = quorum_sig_lookup.get(q_index) { + if let Some(b) = a { + hex::encode(b) + } else { + "Error a".to_string() + } + } else { + "Error b".to_string() + }; + + let expected_chain_lock_sig = if let Some(height) = height { + if let Ok(hash) = self.get_block_hash(height - 8) { + if let Ok(Some(sig)) = self.get_chain_lock_sig(&hash) { + hex::encode(sig) + } else { + "Error (Did not find chain lock sig for hash)".to_string() + } + } else { + "Error (Did not find block hash of 8 blocks ago)".to_string() + } + } else { + "Error (Did not find quorum hash height)".to_string() + }; + if quorum.llmq_type.is_rotating_quorum_type() { + ScrollArea::vertical().id_salt("render_quorum_details").show(ui, |ui| { + ui.label(format!( + "Version: {}\nQuorum Hash Height: {}\nQuorum Hash: {}\nCycle Hash Height: {}\nQuorum Index: {}\nSigners: {} members\nValid Members: {} members\nQuorum Public Key: {}\nAssociated Chain Lock Sig: {}\nExpected Chain Lock Sig: {}", + quorum.version, + self.get_height(&quorum.quorum_hash).ok().map(|height| format!("{}", height)).unwrap_or("Unknown".to_string()), + quorum.quorum_hash, + self.get_height(&quorum.quorum_hash).ok().and_then(|height| quorum.quorum_index.map(|index| format!("{}", height - index as CoreBlockHeight))).unwrap_or("Unknown".to_string()), + quorum.quorum_index.map(|quorum_index| quorum_index.to_string()).unwrap_or("Unknown".to_string()), + quorum.signers.iter().filter(|&&b| b).count(), + quorum.valid_members.iter().filter(|&&b| b).count(), + quorum.quorum_public_key, + chain_lock_msg, + expected_chain_lock_sig, + )); + }); + } else { + ScrollArea::vertical().id_salt("render_quorum_details").show(ui, |ui| { + ui.label(format!( + "Version: {}\nQuorum Hash Height: {}\nQuorum Hash: {}\nSigners: {} members\nValid Members: {} members\nQuorum Public Key: {}\nAssociated Chain Lock Sig: {}\nExpected Chain Lock Sig: {}", + quorum.version, + self.get_height(&quorum.quorum_hash).ok().map(|height| format!("{}", height)).unwrap_or("Unknown".to_string()), + quorum.quorum_hash, + quorum.signers.iter().filter(|&&b| b).count(), + quorum.valid_members.iter().filter(|&&b| b).count(), + quorum.quorum_public_key, + chain_lock_msg, + expected_chain_lock_sig, + )); + }); + } + }); + } + } else { + ui.label("Select a quorum to view details."); + } + } + } else if let Some(selected_height) = self.selected_dml_height_key { + if let Some(mn_list) = self + .masternode_list_engine + .masternode_lists + .get(&selected_height) + { + if let Some((llmq_type, quorum_hash)) = self.selected_quorum_hash_in_mnlist_diff { + if let Some(quorum) = mn_list + .quorums + .get(&llmq_type) + .and_then(|quorums_by_type| quorums_by_type.get(&quorum_hash)) + { + let height = self.get_height(&quorum.quorum_entry.quorum_hash).ok(); + let chain_lock_sig = + if quorum.quorum_entry.llmq_type.is_rotating_quorum_type() { + let heights = self.required_cl_sig_heights(&quorum.quorum_entry); + format!( + "heights [{}]", + heights.iter().map(|h| h.to_string()).join(" | ") + ) + } else if let Some(height) = height { + if let Ok(hash) = self.get_block_hash(height - 8) { + if let Ok(Some(sig)) = self.get_chain_lock_sig(&hash) { + hex::encode(sig) + } else { + "Error (Did not find chain lock sig for hash)".to_string() + } + } else { + "Error (Did not find block hash of 8 blocks ago)".to_string() + } + } else { + "Error (Did not find quorum hash height)".to_string() + }; + + let get_used_heights = |bls_signature: BLSSignature| { + let Some(used) = self.chain_lock_reversed_sig_cache.get(&bls_signature) + else { + return String::default(); + }; + if used.is_empty() { + String::default() + } else if used.len() == 1 { + format!(" [height: {}]", used.iter().next().unwrap().0) + } else { + format!( + " [height: {} to {}]", + used.iter().next().unwrap().0, + used.last().unwrap().0 + ) + } + }; + + let associated_chain_lock_sig = match quorum.verifying_chain_lock_signature + { + Some(VerifyingChainLockSignaturesType::NonRotating( + associated_chain_lock_sig, + )) => hex::encode(associated_chain_lock_sig), + Some(VerifyingChainLockSignaturesType::Rotating( + associated_chain_lock_sigs, + )) => { + format!( + "[\n-3: {}{}\n-2: {}{}\n-1: {}{}\n0: {}{}\n]", + hex::encode(associated_chain_lock_sigs[0]), + get_used_heights(associated_chain_lock_sigs[0]), + hex::encode(associated_chain_lock_sigs[1]), + get_used_heights(associated_chain_lock_sigs[1]), + hex::encode(associated_chain_lock_sigs[2]), + get_used_heights(associated_chain_lock_sigs[2]), + hex::encode(associated_chain_lock_sigs[3]), + get_used_heights(associated_chain_lock_sigs[3]) + ) + } + None => "None set".to_string(), + }; + + Frame::NONE + .stroke(Stroke::new(1.0, Color32::BLACK)) + .show(ui, |ui| { + ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); + ScrollArea::vertical().id_salt("render_quorum_details_2").show(ui, |ui| { + ui.label(format!( + "Quorum Type: {}\nQuorum Height: {}\nQuorum Hash: {}\nCommitment Hash: {}\nCommitment Data: {}\nEntry Hash: {}\nSigners: {} members\nValid Members: {} members\nQuorum Public Key: {}\nValidation Status: {}\nAssociated Chain Lock Sig: {}\nExpected Chain Lock Sig: {}", + QuorumType::from(quorum.quorum_entry.llmq_type as u32), + self.get_height(&quorum.quorum_entry.quorum_hash).ok().map(|height| format!("{}", height)).unwrap_or("Unknown".to_string()), + quorum.quorum_entry.quorum_hash, + quorum.commitment_hash, + hex::encode(quorum.quorum_entry.commitment_data()), + quorum.entry_hash, + quorum.quorum_entry.signers.iter().filter(|&&b| b).count(), + quorum.quorum_entry.valid_members.iter().filter(|&&b| b).count(), + quorum.quorum_entry.quorum_public_key, + quorum.verified, + associated_chain_lock_sig, + chain_lock_sig, + )); + }); + }); + } + } else { + ui.label("Select a quorum to view details."); + } + } + } else { + ui.label("Select a block height and quorum."); + } + } + + /// Render the details for the selected Masternode + fn render_mn_details(&mut self, ui: &mut Ui) { + ui.heading("Masternode Details"); + + if let Some(dml_key) = self.selected_dml_diff_key { + if let Some(dml) = self.mnlist_diffs.get(&dml_key) { + if let Some(mn_index) = self.selected_masternode_in_diff_index { + if let Some(masternode) = dml.new_masternodes.get(mn_index) { + Frame::NONE + .stroke(Stroke::new(1.0, Color32::BLACK)) + .show(ui, |ui| { + ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); + ScrollArea::vertical().id_salt("render_mn_details").show( + ui, + |ui| { + ui.label(format!( + "Version: {}\n\ + ProRegTxHash: {}\n\ + Confirmed Hash: {}\n\ + Service Address: {}:{}\n\ + Operator Public Key: {}\n\ + Voting Key ID: {}\n\ + Is Valid: {}\n\ + Masternode Type: {}", + masternode.version, + masternode.pro_reg_tx_hash.reverse(), + match masternode.confirmed_hash { + None => "No confirmed hash".to_string(), + Some(confirmed_hash) => + confirmed_hash.reverse().to_string(), + }, + masternode.service_address.ip(), + masternode.service_address.port(), + masternode.operator_public_key, + masternode.key_id_voting, + masternode.is_valid, + match masternode.mn_type { + EntryMasternodeType::Regular => + "Regular".to_string(), + EntryMasternodeType::HighPerformance { + platform_http_port, + platform_node_id, + } => { + format!( + "High Performance (Port: {}, Node ID: {})", + platform_http_port, platform_node_id + ) + } + } + )); + }, + ); + }); + } + } else { + ui.label("Select a Masternode to view details."); + } + } + } else if let Some(selected_height) = self.selected_dml_height_key { + if let Some(mn_list) = self + .masternode_list_engine + .masternode_lists + .get(&selected_height) + && let Some(selected_pro_tx_hash) = self.selected_masternode_pro_tx_hash + && let Some(qualified_masternode) = mn_list.masternodes.get(&selected_pro_tx_hash) + { + let masternode = &qualified_masternode.masternode_list_entry; + Frame::NONE + .stroke(Stroke::new(1.0, Color32::BLACK)) + .show(ui, |ui| { + ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); + ScrollArea::vertical() + .id_salt("render_mn_details_2") + .show(ui, |ui| { + ui.label(format!( + "Version: {}\n\ + ProRegTxHash: {}\n\ + Confirmed Hash: {}\n\ + Service Address: {}:{}\n\ + Operator Public Key: {}\n\ + Voting Key ID: {}\n\ + Is Valid: {}\n\ + Masternode Type: {}\n\ + Entry Hash: {}\n\ + Confirmed Hash hashed with ProRegTx: {}\n", + masternode.version, + masternode.pro_reg_tx_hash.reverse(), + match masternode.confirmed_hash { + None => "No confirmed hash".to_string(), + Some(confirmed_hash) => + confirmed_hash.reverse().to_string(), + }, + masternode.service_address.ip(), + masternode.service_address.port(), + masternode.operator_public_key, + masternode.key_id_voting, + masternode.is_valid, + match masternode.mn_type { + EntryMasternodeType::Regular => "Regular".to_string(), + EntryMasternodeType::HighPerformance { + platform_http_port, + platform_node_id, + } => { + format!( + "High Performance (Port: {}, Node ID: {})", + platform_http_port, platform_node_id + ) + } + }, + hex::encode(qualified_masternode.entry_hash), + if let Some(hash) = + qualified_masternode.confirmed_hash_hashed_with_pro_reg_tx + { + hash.reverse().to_string() + } else { + "None".to_string() + }, + )); + }); + }); + } + } else { + ui.label("Select a block height and Masternode."); + } + } + + fn render_selected_shapshot_details(ui: &mut Ui, snapshot: &QuorumSnapshot) { + ui.heading("Quorum Snapshot Details"); + + // Display Skip List Mode + ui.label(format!("Skip List Mode: {}", snapshot.skip_list_mode)); + + // Display Active Quorum Members (Bitset) + ui.label(format!( + "Active Quorum Members: {} members", + snapshot.active_quorum_members.len() + )); + + // Show active members in a scrollable area + ScrollArea::vertical() + .id_salt("render_snapshot_details") + .show(ui, |ui| { + ui.label("Active Quorum Members:"); + for (i, active) in snapshot.active_quorum_members.iter().enumerate() { + ui.label(format!( + "Member {}: {}", + i, + if *active { "Active" } else { "Inactive" } + )); + } + }); + + ui.separator(); + + // Display Skip List + ui.label(format!("Skip List: {} entries", snapshot.skip_list.len())); + + // Show skip list entries + ScrollArea::vertical() + .id_salt("render_snapshot_details_2") + .show(ui, |ui| { + ui.label("Skip List Entries:"); + for (i, skip_entry) in snapshot.skip_list.iter().enumerate() { + ui.label(format!("Entry {}: {}", i, skip_entry)); + } + }); + } + + fn render_qr_info(&mut self, ui: &mut Ui) { + ui.heading("QRInfo Viewer"); + + // Select the first available QRInfo if none is selected + let selected_qr_info = { + let Some((_, selected_qr_info)) = self.qr_infos.first_key_value() else { + ui.label("No QRInfo available."); + if ui.button("Load QR Info").clicked() + && let Some(path) = FileDialog::new() + .add_filter("Data Files", &["dat"]) + .pick_file() + { + match std::fs::read(&path) { + Ok(bytes) => { + // Let's first try consensus decode + match QRInfo::consensus_decode(&mut std::io::Cursor::new(&bytes)) { + Ok(qr_info) => { + let key = qr_info.mn_list_diff_tip.block_hash; + self.qr_infos.insert(key, qr_info.clone()); + self.feed_qr_info_and_get_dmls(qr_info, None); + } + Err(_) => { + match bincode::decode_from_slice::( + &bytes, + bincode::config::standard(), + ) { + Ok((qr_info, _)) => { + let key = qr_info.mn_list_diff_tip.block_hash; + self.qr_infos.insert(key, qr_info); + } + Err(e) => { + eprintln!("Failed to decode QRInfo: {}", e); + } + } + } + } + } + Err(e) => { + eprintln!("Failed to read file: {}", e); + } + } + } + return; + }; + selected_qr_info.clone() + }; + + if let Ok(height) = self.get_height(&selected_qr_info.mn_list_diff_tip.block_hash) { + // Add Save/Load functionality + ui.horizontal(|ui| { + if ui.button("Save QR Info").clicked() { + // Open native save dialog + if let Some(path) = FileDialog::new() + .set_file_name(format!("qrinfo_{}.dat", height)) + .add_filter("Data Files", &["dat"]) + .save_file() + { + // Serialize and save the block container + let serialized_data = + bincode::encode_to_vec(&selected_qr_info, bincode::config::standard()) + .expect("serialize container"); + if let Err(e) = std::fs::write(&path, serialized_data) { + eprintln!("Failed to write file: {}", e); + } + } + } + }); + } + + // Track user selections + if self.selected_qr_field.is_none() { + self.selected_qr_field = Some("Quorum Snapshots".to_string()); + } + + ui.horizontal(|ui| { + // Left Panel: Fields of QRInfo + ui.allocate_ui_with_layout( + egui::Vec2::new(180.0, ui.available_height()), + Layout::top_down(Align::Min), + |ui| { + ui.label("QRInfo Fields:"); + let fields = [ + "Rotated Quorums At Index", + "Masternode List Diffs", + "Quorum Snapshots", + "Quorum Snapshot List", + "MN List Diff List", + ]; + + for field in &fields { + if ui + .selectable_label( + self.selected_qr_field.as_deref() == Some(*field), + *field, + ) + .clicked() + { + self.selected_qr_field = Some(field.to_string()); + self.selected_qr_list_index = None; + self.selected_qr_item = None; + } + } + }, + ); + + ui.separator(); + + // Center Panel: Items in the selected field + ui.allocate_ui_with_layout( + egui::Vec2::new(ui.available_width() * 0.5, ui.available_height()), + Layout::top_down(Align::Min), + |ui| { + ui.heading("Selected Field Items"); + + match self.selected_qr_field.as_deref() { + Some("Quorum Snapshots") => { + self.render_quorum_snapshots(ui, &selected_qr_info) + } + Some("Masternode List Diffs") => { + self.render_mn_list_diffs(ui, &selected_qr_info) + } + Some("Rotated Quorums At Index") => self.render_last_commitments( + ui, + selected_qr_info + .last_commitment_per_index + .first() + .map(|entry| entry.quorum_hash), + ), + Some("Quorum Snapshot List") => { + self.render_quorum_snapshot_list(ui, &selected_qr_info) + } + Some("MN List Diff List") => { + self.render_mn_list_diff_list(ui, &selected_qr_info) + } + _ => { + ui.label("Select a field to display."); + } + } + }, + ); + + ui.separator(); + + // Right Panel: Detailed View of Selected Item + ui.allocate_ui_with_layout( + egui::Vec2::new(ui.available_width(), ui.available_height()), + Layout::top_down(Align::Min), + |ui| { + if let Some(selected_item) = &self.selected_qr_item { + match selected_item { + SelectedQRItem::SelectedSnapshot(snapshot) => { + Self::render_selected_shapshot_details(ui, snapshot); + } + SelectedQRItem::MNListDiff(mn_list_diff) => { + self.render_selected_mn_list_diff(ui, mn_list_diff); + } + SelectedQRItem::QuorumEntry(quorum_entry) => { + Self::render_selected_quorum_entry(ui, quorum_entry); + } + } + } else { + ui.label("Select an item to view details."); + } + }, + ); + }); + } + fn render_selected_mn_list_diff(&self, ui: &mut Ui, mn_list_diff: &MnListDiff) { + ui.heading("MNListDiff Details"); + + // General MNListDiff Info + ui.label(format!( + "Version: {}\nBase Block Hash: {} ({})\nBlock Hash: {} ({})", + mn_list_diff.version, + mn_list_diff.base_block_hash, + self.get_height_or_error_as_string(&mn_list_diff.base_block_hash), + mn_list_diff.block_hash, + self.get_height_or_error_as_string(&mn_list_diff.block_hash) + )); + + ui.label(format!( + "Total Transactions: {}", + mn_list_diff.total_transactions + )); + + ui.separator(); + + // Merkle Tree Data + ui.heading("Merkle Tree"); + ui.label(format!( + "Merkle Hashes: {} entries", + mn_list_diff.merkle_hashes.len() + )); + ScrollArea::vertical() + .id_salt("render_selected_mn_list_diff") + .show(ui, |ui| { + for (i, merkle_hash) in mn_list_diff.merkle_hashes.iter().enumerate() { + ui.label(format!("{}: {}", i, merkle_hash)); + } + }); + + ui.separator(); + ui.label(format!( + "Merkle Flags ({} bytes)", + mn_list_diff.merkle_flags.len() + )); + + // Coinbase Transaction + ui.heading("Coinbase Transaction"); + ScrollArea::vertical() + .id_salt("render_selected_mn_list_diff_2") + .show(ui, |ui| { + ui.label(format!( + "Coinbase TXID: {}\nSize: {} bytes", + mn_list_diff.coinbase_tx.txid(), + mn_list_diff.coinbase_tx.size() + )); + }); + + ui.separator(); + + // Masternode Changes + ui.heading("Masternode Changes"); + ui.label(format!( + "New Masternodes: {}\nDeleted Masternodes: {}", + mn_list_diff.new_masternodes.len(), + mn_list_diff.deleted_masternodes.len(), + )); + + ScrollArea::vertical() + .id_salt("render_selected_mn_list_diff_3") + .show(ui, |ui| { + ui.heading("New Masternodes"); + for masternode in &mn_list_diff.new_masternodes { + ui.label(format!( + "{} {}:{}", + masternode.pro_reg_tx_hash, + masternode.service_address.ip(), + masternode.service_address.port(), + )); + } + + ui.separator(); + ui.heading("Removed Masternodes"); + for removed_pro_tx in &mn_list_diff.deleted_masternodes { + ui.label(removed_pro_tx.to_string()); + } + }); + + ui.separator(); + + // Quorum Changes + ui.heading("Quorum Changes"); + ui.label(format!( + "New Quorums: {}\nDeleted Quorums: {}", + mn_list_diff.new_quorums.len(), + mn_list_diff.deleted_quorums.len() + )); + + ScrollArea::vertical() + .id_salt("render_selected_mn_list_diff_4") + .show(ui, |ui| { + ui.heading("New Quorums"); + for quorum in &mn_list_diff.new_quorums { + ui.label(format!( + "Quorum {} Type: {}", + quorum.quorum_hash, + QuorumType::from(quorum.llmq_type as u32) + )); + } + + ui.separator(); + ui.heading("Removed Quorums"); + for deleted_quorum in &mn_list_diff.deleted_quorums { + ui.label(format!( + "Quorum {} Type: {}", + deleted_quorum.quorum_hash, + QuorumType::from(deleted_quorum.llmq_type as u32) + )); + } + }); + + ui.separator(); + + // Quorums ChainLock Signatures + ui.heading("Quorums ChainLock Signatures"); + ui.label(format!( + "Total ChainLock Signatures: {}", + mn_list_diff.quorums_chainlock_signatures.len() + )); + + ScrollArea::vertical() + .id_salt("render_selected_mn_list_diff_5") + .show(ui, |ui| { + for (i, cl_sig) in mn_list_diff.quorums_chainlock_signatures.iter().enumerate() { + ui.label(format!( + "Signature {}: {} for indexes [{}]", + i, + hex::encode(cl_sig.signature), + cl_sig + .index_set + .iter() + .map(|index| index.to_string()) + .collect::>() + .join("-") + )); + } + }); + } + + fn render_quorum_snapshots(&mut self, ui: &mut Ui, qr_info: &QRInfo) { + let snapshots = [ + ("Quorum Snapshot h-c", &qr_info.quorum_snapshot_at_h_minus_c), + ( + "Quorum Snapshot h-2c", + &qr_info.quorum_snapshot_at_h_minus_2c, + ), + ( + "Quorum Snapshot h-3c", + &qr_info.quorum_snapshot_at_h_minus_3c, + ), + ]; + + if let Some((qs4c, _)) = &qr_info.quorum_snapshot_and_mn_list_diff_at_h_minus_4c { + snapshots.iter().for_each(|(name, snapshot)| { + if ui + .selectable_label(self.selected_qr_list_index == Some(name.to_string()), *name) + .clicked() + { + self.selected_qr_list_index = Some(name.to_string()); + self.selected_qr_item = + Some(SelectedQRItem::SelectedSnapshot((*snapshot).clone())); + } + }); + + if ui + .selectable_label( + self.selected_qr_list_index == Some("Quorum Snapshot h-4c".to_string()), + "Quorum Snapshot h-4c", + ) + .clicked() + { + self.selected_qr_list_index = Some("Quorum Snapshot h-4c".to_string()); + self.selected_qr_item = Some(SelectedQRItem::SelectedSnapshot((*qs4c).clone())); + } + } + } + + fn render_selected_quorum_entry(ui: &mut Ui, qualified_quorum_entry: &QualifiedQuorumEntry) { + ui.heading("Quorum Entry Details"); + + // General Quorum Info + ui.label(format!( + "Version: {}\nQuorum Type: {}\nQuorum Hash: {}", + qualified_quorum_entry.quorum_entry.version, + QuorumType::from(qualified_quorum_entry.quorum_entry.llmq_type as u32), + qualified_quorum_entry.quorum_entry.quorum_hash + )); + + ui.label(format!( + "Quorum Index: {}", + qualified_quorum_entry + .quorum_entry + .quorum_index + .map_or("None".to_string(), |idx| idx.to_string()) + )); + + ui.separator(); + + // **Additional Qualified Quorum Entry Information** + ui.heading("Quorum Verification Details"); + let verification_symbol = match &qualified_quorum_entry.verified { + LLMQEntryVerificationStatus::Verified => "✔ Verified".to_string(), + LLMQEntryVerificationStatus::Invalid(reason) => format!("❌ Invalid ({})", reason), + LLMQEntryVerificationStatus::Unknown => "⬜ Unknown".to_string(), + LLMQEntryVerificationStatus::Skipped(reason) => format!("⬜ Skipped ({})", reason), + }; + ui.label(format!("Verification Status: {}", verification_symbol)); + + ui.separator(); + + ui.heading("Commitment & Entry Hashes"); + ScrollArea::vertical() + .id_salt("commitment_entry_hash") + .show(ui, |ui| { + ui.label(format!( + "Commitment Hash: {}", + qualified_quorum_entry.commitment_hash + )); + ui.label(format!("Entry Hash: {}", qualified_quorum_entry.entry_hash)); + }); + + ui.separator(); + + // Signers & Valid Members + ui.heading("Quorum Members"); + ui.label(format!( + "Total Signers: {}\nValid Members: {}", + qualified_quorum_entry + .quorum_entry + .signers + .iter() + .filter(|&&b| b) + .count(), + qualified_quorum_entry + .quorum_entry + .valid_members + .iter() + .filter(|&&b| b) + .count() + )); + + ScrollArea::vertical() + .id_salt("quorum_members_grid") + .show(ui, |ui| { + ui.label(format!( + "Total Signers: {}\nValid Members: {}", + qualified_quorum_entry + .quorum_entry + .signers + .iter() + .filter(|&&b| b) + .count(), + qualified_quorum_entry + .quorum_entry + .valid_members + .iter() + .filter(|&&b| b) + .count() + )); + + ui.separator(); + + ui.heading("Signers & Valid Members Grid"); + + egui::Grid::new("quorum_members_grid") + .num_columns(8) // Adjust based on UI width + .striped(true) + .show(ui, |ui| { + for (i, (is_signer, is_valid)) in qualified_quorum_entry + .quorum_entry + .signers + .iter() + .zip(qualified_quorum_entry.quorum_entry.valid_members.iter()) + .enumerate() + { + let text = match (*is_signer, *is_valid) { + (true, true) => "✔✔", + (true, false) => "✔❌", + (false, true) => "❌✔", + (false, false) => "❌❌", + }; + + let response = ui.label(text); + + // Tooltip on hover to show member index + if response.hovered() { + ui.ctx().debug_painter().text( + response.rect.center(), + egui::Align2::CENTER_CENTER, + format!("Member {}", i), + egui::FontId::proportional(14.0), + egui::Color32::BLUE, + ); + } + + // Create a new row every 8 members + if (i + 1) % 8 == 0 { + ui.end_row(); + } + } + }); + }); + + ui.separator(); + + // Quorum Public Key + ui.heading("Quorum Public Key"); + ScrollArea::vertical() + .id_salt("render_selected_quorum_entry_2") + .show(ui, |ui| { + ui.label(format!( + "Public Key: {}", + qualified_quorum_entry.quorum_entry.quorum_public_key + )); + }); + + ui.separator(); + + // Quorum Verification Vector Hash + ui.heading("Verification Vector Hash"); + ui.label(format!( + "Quorum VVec Hash: {}", + qualified_quorum_entry.quorum_entry.quorum_vvec_hash + )); + + ui.separator(); + + // Threshold Signature + ui.heading("Threshold Signature"); + ScrollArea::vertical() + .id_salt("render_selected_quorum_entry_3") + .show(ui, |ui| { + ui.label(format!( + "Signature: {}", + hex::encode(qualified_quorum_entry.quorum_entry.threshold_sig.to_bytes()) + )); + }); + + ui.separator(); + + // Aggregated Signature + ui.heading("All Commitment Aggregated Signature"); + ScrollArea::vertical() + .id_salt("render_selected_quorum_entry_4") + .show(ui, |ui| { + ui.label(format!( + "Signature: {}", + hex::encode( + qualified_quorum_entry + .quorum_entry + .all_commitment_aggregated_signature + .to_bytes() + ) + )); + }); + } + + fn show_mn_list_diff_heights_as_string( + &mut self, + mn_list_diff: &MnListDiff, + last_diff: Option<&MnListDiff>, + ) -> String { + let base_height_as_string = match self.get_height_and_cache(&mn_list_diff.base_block_hash) { + Ok(height) => height.to_string(), + Err(_) => "?".to_string(), + }; + + let height = self.get_height_and_cache(&mn_list_diff.block_hash).ok(); + + let height_as_string = match height { + Some(height) => height.to_string(), + None => "?".to_string(), + }; + + let extra_block_diff_info = height + .and_then(|height| { + last_diff.and_then(|diff| { + self.get_height(&diff.block_hash) + .ok() + .and_then(|start_height| { + height + .checked_sub(start_height) + .map(|diff| format!(" (+ {})", diff)) + }) + }) + }) + .unwrap_or_default(); + + format!( + "{} -> {}{}", + base_height_as_string, height_as_string, extra_block_diff_info + ) + } + + fn render_mn_list_diffs(&mut self, ui: &mut Ui, qr_info: &QRInfo) { + let mn_diffs = [ + ( + format!( + "MNListDiff h-3c {}", + self.show_mn_list_diff_heights_as_string( + &qr_info.mn_list_diff_at_h_minus_3c, + qr_info + .quorum_snapshot_and_mn_list_diff_at_h_minus_4c + .as_ref() + .map(|(_, diff)| diff) + ) + ), + &qr_info.mn_list_diff_at_h_minus_3c, + ), + ( + format!( + "MNListDiff h-2c {}", + self.show_mn_list_diff_heights_as_string( + &qr_info.mn_list_diff_at_h_minus_2c, + Some(&qr_info.mn_list_diff_at_h_minus_3c) + ) + ), + &qr_info.mn_list_diff_at_h_minus_2c, + ), + ( + format!( + "MNListDiff h-c {}", + self.show_mn_list_diff_heights_as_string( + &qr_info.mn_list_diff_at_h_minus_c, + Some(&qr_info.mn_list_diff_at_h_minus_2c) + ) + ), + &qr_info.mn_list_diff_at_h_minus_c, + ), + ( + format!( + "MNListDiff h {}", + self.show_mn_list_diff_heights_as_string( + &qr_info.mn_list_diff_h, + Some(&qr_info.mn_list_diff_at_h_minus_c) + ) + ), + &qr_info.mn_list_diff_h, + ), + ( + format!( + "MNListDiff Tip {}", + self.show_mn_list_diff_heights_as_string( + &qr_info.mn_list_diff_tip, + Some(&qr_info.mn_list_diff_h) + ) + ), + &qr_info.mn_list_diff_tip, + ), + ]; + if let Some((_, mn_diff4c)) = &qr_info.quorum_snapshot_and_mn_list_diff_at_h_minus_4c { + let string = format!( + "MNListDiff h-4c {}", + self.show_mn_list_diff_heights_as_string(mn_diff4c, None) + ); + + if ui + .selectable_label( + self.selected_qr_list_index == Some(string.clone()), + string.as_str(), + ) + .clicked() + { + self.selected_qr_list_index = Some(string); + self.selected_qr_item = + Some(SelectedQRItem::MNListDiff(Box::new((*mn_diff4c).clone()))); + } + } + + mn_diffs.iter().for_each(|(name, diff)| { + if ui + .selectable_label(self.selected_qr_list_index == Some(name.to_string()), name) + .clicked() + { + self.selected_qr_list_index = Some(name.to_string()); + self.selected_qr_item = Some(SelectedQRItem::MNListDiff(Box::new((*diff).clone()))); + } + }); + } + + fn render_last_commitments(&mut self, ui: &mut Ui, cycle_hash: Option) { + let Some(cycle_hash) = cycle_hash else { + ui.label("QR Info had no rotated quorums. This should not happen."); + return; + }; + let Some(cycle_quorums) = self + .masternode_list_engine + .rotated_quorums_per_cycle + .get(&cycle_hash) + else { + ui.label(format!( + "Engine does not know of cycle {} at height {}, we know of cycles [{}]", + cycle_hash, + self.get_height_or_error_as_string(&cycle_hash), + self.masternode_list_engine + .rotated_quorums_per_cycle + .keys() + .map(|key| format!("{}, {}", self.get_height_or_error_as_string(key), key)) + .join(", ") + )); + return; + }; + if cycle_quorums.is_empty() { + ui.label(format!( + "Engine does not contain any rotated quorums for cycle {}", + cycle_hash + )); + } + for (index, commitment) in cycle_quorums.iter().enumerate() { + // Determine the appropriate symbol based on verification status + let verification_symbol = match commitment.verified { + LLMQEntryVerificationStatus::Verified => "✔", // Checkmark + LLMQEntryVerificationStatus::Invalid(_) => "❌", // Cross + LLMQEntryVerificationStatus::Unknown | LLMQEntryVerificationStatus::Skipped(_) => { + "⬜" + } // Box + }; + + let label_text = format!("{} Quorum at Index {}", verification_symbol, index); + + if ui + .selectable_label( + self.selected_qr_list_index == Some(index.to_string()), + label_text, + ) + .clicked() + { + self.selected_qr_list_index = Some(index.to_string()); + self.selected_qr_item = + Some(SelectedQRItem::QuorumEntry(Box::new(commitment.clone()))); + } + } + } + + fn render_quorum_snapshot_list(&mut self, ui: &mut Ui, qr_info: &QRInfo) { + for (index, snapshot) in qr_info.quorum_snapshot_list.iter().enumerate() { + if ui + .selectable_label( + self.selected_qr_list_index == Some(index.to_string()), + format!("Snapshot {}", index), + ) + .clicked() + { + self.selected_qr_list_index = Some(index.to_string()); + self.selected_qr_item = Some(SelectedQRItem::SelectedSnapshot(snapshot.clone())); + } + } + } + + fn render_mn_list_diff_list(&mut self, ui: &mut Ui, qr_info: &QRInfo) { + for (index, diff) in qr_info.mn_list_diff_list.iter().enumerate() { + if ui + .selectable_label( + self.selected_qr_list_index == Some(index.to_string()), + format!("MNListDiff {}", index), + ) + .clicked() + { + self.selected_qr_list_index = Some(index.to_string()); + self.selected_qr_item = Some(SelectedQRItem::MNListDiff(Box::new(diff.clone()))); + } + } + } + + fn render_quorums(&mut self, ui: &mut Ui) { + ui.heading("Quorum Viewer"); + + // Get all available quorum types + let quorum_types: Vec = self + .masternode_list_engine + .quorum_statuses + .keys() + .cloned() + .collect(); + + // Ensure a quorum type is selected + if self.selected_quorum_type_in_quorum_viewer.is_none() { + self.selected_quorum_type_in_quorum_viewer = quorum_types.first().copied(); + } + + // Render quorum type selection bar + ui.horizontal(|ui| { + for quorum_type in &quorum_types { + if ui + .selectable_label( + self.selected_quorum_type_in_quorum_viewer == Some(*quorum_type), + quorum_type.to_string(), + ) + .clicked() + { + self.selected_quorum_type_in_quorum_viewer = Some(*quorum_type); + self.selected_quorum_hash_in_quorum_viewer = None; // Reset selected quorum when switching types + } + } + }); + + ui.separator(); + + let Some(selected_quorum_type) = self.selected_quorum_type_in_quorum_viewer else { + ui.label("No quorum types available."); + return; + }; + + let Some(quorum_map) = self + .masternode_list_engine + .quorum_statuses + .get(&selected_quorum_type) + else { + ui.label("No quorums found for this type."); + return; + }; + + // Create a horizontal layout to align quorum hashes on the left and heights on the right + ui.horizontal(|ui| { + // Left Column: Quorum Hashes + ui.allocate_ui_with_layout( + egui::Vec2::new(500.0, 800.0), + Layout::top_down(Align::Min), + |ui| { + ui.heading(format!("Quorums of Type: {}", selected_quorum_type)); + + ScrollArea::vertical() + .id_salt("quorum_hashes_scroll") + .show(ui, |ui| { + egui::Grid::new("quorum_hashes_grid") + .num_columns(2) // Two columns: Quorum Hash | Status + .striped(true) + .show(ui, |ui| { + ui.label("Quorum Hash"); + ui.label("Status"); + ui.end_row(); + + for (quorum_hash, (_, _, status)) in quorum_map { + let hash_label = format!("{}", quorum_hash); + + // Display quorum hash as selectable + let hash_response = ui.selectable_label( + self.selected_quorum_hash_in_quorum_viewer + == Some(*quorum_hash), + hash_label, + ); + + if hash_response.clicked() { + self.selected_quorum_hash_in_quorum_viewer = + Some(*quorum_hash); + } + + // Determine status symbol + let (status_symbol, tooltip_text) = match status { + LLMQEntryVerificationStatus::Verified => ("✔", None), + LLMQEntryVerificationStatus::Invalid(reason) => { + ("❌", Some(reason.to_string())) + } + LLMQEntryVerificationStatus::Unknown => ("⬜", None), + LLMQEntryVerificationStatus::Skipped(reason) => { + ("⚠", Some(reason.to_string())) + } + }; + + // Display small status icon + let status_response = ui.label(status_symbol); + + // Show tooltip on hover if there's an error message + if let Some(tooltip) = tooltip_text + && status_response.hovered() + { + ui.ctx().debug_painter().text( + status_response.rect.center(), + egui::Align2::CENTER_CENTER, + tooltip, + egui::FontId::proportional(14.0), + egui::Color32::RED, + ); + } + + ui.end_row(); + } + }); + }); + }, + ); + + ui.separator(); + + // Right Column: Heights where selected quorum exists + ui.allocate_ui_with_layout( + Vec2::new(500.0, 800.0), + Layout::top_down(Align::Min), + |ui| { + ui.heading("Quorum Heights"); + + if let Some(selected_quorum_hash) = self.selected_quorum_hash_in_quorum_viewer { + if let Some((heights, key, status)) = quorum_map.get(&selected_quorum_hash) + { + ui.label(format!("Public Key: {}", key)); + ui.label(format!("Verification Status: {}", status)); + ScrollArea::vertical() + .id_salt("quorum_heights_scroll") + .show(ui, |ui| { + for height in heights { + ui.label(format!("Height: {}", height)); + } + }); + } else { + ui.label("Selected quorum not found."); + } + } else { + ui.label("Select a quorum to see its heights."); + } + }, + ); + }); + } + + #[allow(dead_code)] + fn render_selected_item_details(&mut self, ui: &mut Ui, selected_item: String) { + ui.heading("Details"); + + ScrollArea::vertical().show(ui, |ui| { + ui.monospace(selected_item); + }); + } + + /// Render core items, including chain-locked blocks and instant send transactions. + fn render_core_items(&mut self, ui: &mut Ui) { + ui.heading("Core Items Viewer"); + + // Layout: Left (ChainLocked Blocks), Middle (InstantSend Transactions), Right (Details) + ui.horizontal(|ui| { + // Left Column: Chain Locked Blocks + ui.allocate_ui_with_layout( + Vec2::new(200.0, 1000.0), + Layout::top_down(Align::Min), + |ui| { + ui.heading("ChainLocked Blocks"); + + ScrollArea::vertical().id_salt("chain_locked_blocks_scroll").show(ui, |ui| { + for (block_height, (block, chain_lock, is_valid)) in + self.chain_locked_blocks.iter() + { + let label_text = format!( + "{} {} {}", + if *is_valid { "✔" } else { "❌" }, + block_height, + block.header.block_hash() + ); + + if ui + .selectable_label( + matches!(self.selected_core_item, Some((CoreItem::ChainLockedBlock(_, ref l), _)) if l.block_height == *block_height), + label_text, + ) + .clicked() + { + self.selected_core_item = Some((CoreItem::ChainLockedBlock(block.clone(), chain_lock.clone()), *is_valid)); + } + } + }); + }, + ); + + ui.separator(); + + // Middle Column: Instant Send Transactions + ui.allocate_ui_with_layout( + egui::Vec2::new(300.0, 1000.0), + Layout::top_down(Align::Min), + |ui| { + ui.heading("Instant Send Transactions"); + + ScrollArea::vertical().id_salt("instant_send_scroll").show(ui, |ui| { + for (transaction, instant_lock, is_valid) in + self.instant_send_transactions.iter() + { + let label_text = format!( + "{} TxID: {}", + if *is_valid { "✔" } else { "❌" }, + transaction.txid() + ); + + if ui + .selectable_label( + matches!(self.selected_core_item, Some((CoreItem::InstantLockedTransaction(ref t, _, _), _)) if t == transaction), + label_text, + ) + .clicked() + { + self.selected_core_item = Some((CoreItem::InstantLockedTransaction(transaction.clone(), vec![], instant_lock.clone()), *is_valid)); + } + } + }); + }, + ); + + ui.separator(); + + // Right Column: Details of the Selected Item + ui.allocate_ui_with_layout( + egui::Vec2::new(ui.available_width(), ui.available_height()), + Layout::top_down(Align::Min), + |ui| { + if let Some((selected_core_item, _)) = &self.selected_core_item { + match selected_core_item { + CoreItem::ChainLockedBlock(..) => self.render_chain_lock_details(ui), + CoreItem::InstantLockedTransaction(..) => self.render_instant_send_details(ui), + _ => { + ui.label("Select an item to view details."); + }, + } + } else { + ui.label("Select an item to view details."); + } + }, + ); + }); + } + + /// Render details of a selected ChainLock + fn render_chain_lock_details(&mut self, ui: &mut Ui) { + ui.heading("ChainLock Details"); + + if let Some((CoreItem::ChainLockedBlock(block, chain_lock), is_valid)) = + &self.selected_core_item + { + ui.label(format!( + "Block Height: {}\nBlock Hash: {}\nValid: {}", + chain_lock.block_height, + chain_lock.block_hash, + if *is_valid { "✔ Yes" } else { "❌ No" }, + )); + + ui.separator(); + + ui.heading("Block Transactions"); + ScrollArea::vertical() + .id_salt("block_tx_scroll") + .show(ui, |ui| { + if block.txdata.is_empty() { + ui.label("No transactions in this block."); + } else { + for transaction in &block.txdata { + ui.label(format!("TxID: {}", transaction.txid())); + } + } + }); + + ui.separator(); + ui.heading("Quorum Signature"); + ui.label(format!( + "Signature: {}", + hex::encode(chain_lock.signature.to_bytes()) + )); + + //todo clean this + let b = serialize2(chain_lock); + let chain_lock_2: ChainLock2 = deserialize(b.as_slice()).expect("todo"); + match self + .masternode_list_engine + .chain_lock_potential_quorum_under(&chain_lock_2) + { + Ok(Some(quorum)) => { + ui.label(format!("Quorum Hash: {}", quorum.quorum_entry.quorum_hash,)); + ui.label(format!( + "Request Id: {}", + chain_lock.request_id().expect("expected request id") + )); + let sign_id = chain_lock_2 + .sign_id( + quorum.quorum_entry.llmq_type, + quorum.quorum_entry.quorum_hash, + None, + ) + .expect("expected sign id"); + ui.label(format!("Sign Hash (Sign ID): {}", sign_id)); + if let Err(e) = quorum + .verify_message_digest(sign_id.to_byte_array(), chain_lock_2.signature) + { + ui.label(format!("Signature Verification Error: {}", e)); + } + } + Ok(None) => { + ui.label("No quorum".to_string()); + } + Err(err) => { + ui.label(format!("Error finding quorum: {}", err)); + } + }; + + ui.separator(); + + ui.heading("Data"); + + ui.label(format!("Block Data {}", hex::encode(serialize2(block)),)); + + ui.label(format!("Lock Data {}", hex::encode(serialize2(chain_lock)),)); + + ui.separator(); + } else { + ui.label("No ChainLock selected."); + } + } + + /// Render details of a selected Instant Send transaction + fn render_instant_send_details(&mut self, ui: &mut Ui) { + ui.heading("Instant Send Details"); + + if let Some((CoreItem::InstantLockedTransaction(transaction, _, instant_lock), is_valid)) = + &self.selected_core_item + { + ui.label(format!( + "TxID: {}\nValid: {}\nCycle Hash:{}", + transaction.txid(), + if *is_valid { "✔ Yes" } else { "❌ No" }, + instant_lock.cyclehash, + )); + + ui.separator(); + + ui.heading("Transaction Inputs"); + ScrollArea::vertical() + .id_salt("tx_inputs_scroll") + .show(ui, |ui| { + if transaction.input.is_empty() { + ui.label("No inputs."); + } else { + for txin in &transaction.input { + ui.label(format!( + "Input: {}:{}", + txin.previous_output.txid, txin.previous_output.vout + )); + } + } + }); + + ui.separator(); + ui.heading("Transaction Outputs"); + ScrollArea::vertical() + .id_salt("tx_outputs_scroll") + .show(ui, |ui| { + if transaction.output.is_empty() { + ui.label("No outputs."); + } else { + for txout in &transaction.output { + ui.label(format!( + "Output: {} sat -> {}", + txout.value, txout.script_pubkey + )); + } + } + }); + + ui.separator(); + ui.heading("Signing Info"); + + //todo clean this + let b = serialize2(instant_lock); + let instant_lock_2: InstantLock2 = deserialize(b.as_slice()).expect("todo"); + match self.masternode_list_engine.is_lock_quorum(&instant_lock_2) { + Ok((quorum, request_sign_id, index)) => { + ui.label(format!( + "Quorum Hash: {} at index {}", + quorum.quorum_entry.quorum_hash, index, + )); + ui.label(format!("Request Id: {}", request_sign_id)); + let sign_id = instant_lock_2 + .sign_id( + quorum.quorum_entry.llmq_type, + quorum.quorum_entry.quorum_hash, + Some(request_sign_id), + ) + .expect("expected sign id"); + ui.label(format!("Sign Hash (Sign ID): {}", sign_id)); + if let Err(e) = quorum + .verify_message_digest(sign_id.to_byte_array(), instant_lock_2.signature) + { + ui.label(format!("Signature Verification Error: {}", e)); + } + } + Err(err) => { + ui.label(format!("Error finding quorum: {}", err)); + } + }; + + ui.separator(); + ui.heading("Quorum Signature"); + ui.label(format!( + "Signature: {}", + hex::encode(instant_lock.signature.to_bytes()) + )); + + ui.separator(); + + ui.heading("Data"); + + ui.label(format!( + "Transaction Data {}", + hex::encode(serialize2(transaction)), + )); + + ui.label(format!( + "Lock Data {}", + hex::encode(serialize2(instant_lock)), + )); + } else { + ui.label("No Instant Send transaction selected."); + } + } + + fn attempt_verify_chain_lock(&self, chain_lock: &ChainLock) -> bool { + let b = serialize2(chain_lock); + let chain_lock_2: ChainLock2 = deserialize(b.as_slice()).expect("todo"); + self.masternode_list_engine + .verify_chain_lock(&chain_lock_2) + .is_ok() + } + + fn attempt_verify_transaction_lock(&self, instant_lock: &InstantLock) -> bool { + let b = serialize2(instant_lock); + let instant_lock_2: InstantLock2 = deserialize(b.as_slice()).expect("todo"); + self.masternode_list_engine + .verify_is_lock(&instant_lock_2) + .is_ok() + } + + fn received_new_block(&mut self, block: Block, chain_lock: ChainLock) { + let valid = self.attempt_verify_chain_lock(&chain_lock); + self.end_block_height = chain_lock.block_height.to_string(); + if self.syncing + && let Some((base_block_height, masternode_list)) = self + .masternode_list_engine + .masternode_lists + .last_key_value() + && *base_block_height < chain_lock.block_height + { + let mut p2p_handler = match CoreP2PHandler::new(self.app_context.network, None) { + Ok(p2p_handler) => p2p_handler, + Err(e) => { + self.error = Some(e); + return; + } + }; + + let Some(qr_info) = self.fetch_rotated_quorum_info( + &mut p2p_handler, + masternode_list.block_hash, + chain_lock.block_hash.to_byte_array().into(), + ) else { + return; + }; + + self.feed_qr_info_and_get_dmls(qr_info, Some(p2p_handler)); + + // self.fetch_single_dml( + // &mut p2p_handler, + // masternode_list.block_hash, + // *base_block_height, + // BlockHash::from_byte_array(chain_lock.block_hash.to_byte_array()), + // chain_lock.block_height, + // true, + // ); + + // Reset selections when new data is loaded + self.selected_dml_diff_key = None; + self.selected_quorum_in_diff_index = None; + } + self.chain_locked_blocks + .insert(chain_lock.block_height, (block, chain_lock, valid)); + } +} + +impl ScreenLike for MasternodeListDiffScreen { + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Error => { + self.pending = None; + self.error = Some(message.to_string()); + } + MessageType::Success => { + self.message = Some((message.to_string(), message_type)); + } + MessageType::Info => { + // Do not show transient info messages to avoid noisy black text banners. + } + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::CoreItem(core_item) = backend_task_success_result { + // println!("received core item {:?}", core_item); + match core_item { + CoreItem::InstantLockedTransaction(transaction, _, instant_lock) => { + let valid = self.attempt_verify_transaction_lock(&instant_lock); + self.instant_send_transactions + .push((transaction, instant_lock, valid)); + } + CoreItem::ChainLockedBlock(block, chain_lock) => { + self.received_new_block(block, chain_lock); + } + _ => {} + } + return; + } + match backend_task_success_result { + BackendTaskSuccessResult::MnListFetchedDiff { + base_height, + height, + diff, + } => { + // Apply to engine similarly to original UI method + if base_height == 0 && self.masternode_list_engine.masternode_lists.is_empty() { + match MasternodeListEngine::initialize_with_diff_to_height( + diff.clone(), + height, + self.app_context.network, + ) { + Ok(engine) => self.masternode_list_engine = engine, + Err(e) => self.error = Some(e.to_string()), + } + } else if let Err(e) = + self.masternode_list_engine + .apply_diff(diff.clone(), Some(height), false, None) + { + self.error = Some(e.to_string()); + } + self.mnlist_diffs.insert((base_height, height), diff); + // If this was the no-rotation path, queue the extra diffs needed for verification (restored behavior) + if matches!(self.pending, Some(PendingTask::DmlDiffNoRotation)) { + if let Some(task) = self.build_validation_diffs_task() { + self.queued_task = Some(task); + self.display_message( + "Fetched DMLs (no rotation); fetching validation diffs…", + MessageType::Info, + ); + } else if !self.masternode_list_engine.masternode_lists.is_empty() { + // Fallback: attempt verification directly + if let Err(e) = self + .masternode_list_engine + .verify_non_rotating_masternode_list_quorums( + height, + &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], + ) + { + self.error = Some(e.to_string()); + } + self.pending = None; + self.display_message("Fetched DMLs (no rotation)", MessageType::Success); + } else { + self.pending = None; + self.display_message("Fetched DMLs (no rotation)", MessageType::Success); + } + } else { + self.pending = None; + self.display_message("Fetched DML diff", MessageType::Success); + } + self.selected_dml_diff_key = None; + self.selected_quorum_in_diff_index = None; + } + BackendTaskSuccessResult::MnListFetchedQrInfo { qr_info } => { + // Warm heights and cache diffs before feed_qr_info (replicates old flow) + self.insert_mn_list_diff(&qr_info.mn_list_diff_tip); + self.insert_mn_list_diff(&qr_info.mn_list_diff_h); + self.insert_mn_list_diff(&qr_info.mn_list_diff_at_h_minus_c); + self.insert_mn_list_diff(&qr_info.mn_list_diff_at_h_minus_2c); + self.insert_mn_list_diff(&qr_info.mn_list_diff_at_h_minus_3c); + if let Some((_, d)) = &qr_info.quorum_snapshot_and_mn_list_diff_at_h_minus_4c { + self.insert_mn_list_diff(d); + } + for d in &qr_info.mn_list_diff_list { + self.insert_mn_list_diff(d); + } + + // Apply to engine using the same closure as before to resolve heights + let block_height_cache = self.block_height_cache.clone(); + let app_context = self.app_context.clone(); + let get_height_fn = move |block_hash: &BlockHash| { + if block_hash.as_byte_array() == &[0; 32] { + return Ok(0); + } + if let Some(height) = block_height_cache.get(block_hash) { + return Ok(*height); + } + match app_context + .core_client + .read() + .unwrap() + .get_block_header_info( + &(BlockHash2::from_byte_array(block_hash.to_byte_array())), + ) { + Ok(block_info) => Ok(block_info.height as CoreBlockHeight), + Err(_) => Err(ClientDataRetrievalError::RequiredBlockNotPresent( + *block_hash, + )), + } + }; + if let Err(e) = self.masternode_list_engine.feed_qr_info( + qr_info.clone(), + false, + true, + Some(get_height_fn), + ) { + self.error = Some(e.to_string()); + } + // Store full qr_info for the QR tab + let key = qr_info.mn_list_diff_tip.block_hash; + self.qr_infos.insert(key, qr_info); + self.selected_dml_diff_key = None; + self.selected_quorum_in_diff_index = None; + // Queue extra diffs required for verification (previous behavior) + if let Some(task) = self.build_validation_diffs_task() { + self.queued_task = Some(task); + self.display_message( + "Fetched QR info + DMLs; fetching validation diffs…", + MessageType::Info, + ); + } else { + self.pending = None; + self.display_message("Fetched QR info + DMLs", MessageType::Success); + } + } + BackendTaskSuccessResult::MnListFetchedDiffs { items } => { + // Apply returned diffs sequentially + for ((base_h, h), diff) in items { + if base_h == 0 && self.masternode_list_engine.masternode_lists.is_empty() { + if let Ok(engine) = MasternodeListEngine::initialize_with_diff_to_height( + diff.clone(), + h, + self.app_context.network, + ) { + self.masternode_list_engine = engine; + } + } else { + let _ = self.masternode_list_engine.apply_diff( + diff.clone(), + Some(h), + false, + None, + ); + } + self.mnlist_diffs.insert((base_h, h), diff); + } + // Update rotating quorum heights cache (previous behavior) + let hashes = self + .masternode_list_engine + .latest_masternode_list_rotating_quorum_hashes(&[]); + for hash in &hashes { + if let Ok(height) = self.get_height_and_cache(hash) { + self.block_height_cache.insert(*hash, height); + } + } + // Verify non-rotating quorums as before + if let Some(latest_masternode_list) = + self.masternode_list_engine.latest_masternode_list() + && let Err(e) = self + .masternode_list_engine + .verify_non_rotating_masternode_list_quorums( + latest_masternode_list.known_height, + &[LLMQType::Llmqtype50_60, LLMQType::Llmqtype400_85], + ) + { + self.error = Some(e.to_string()); + } + self.pending = None; + self.display_message( + "Fetched validation diffs and verified non-rotating quorums", + MessageType::Success, + ); + } + BackendTaskSuccessResult::MnListChainLockSigs { entries } => { + for ((h, bh), sig) in entries { + self.chain_lock_sig_cache.insert((h, bh), sig); + if let Some(sig) = sig { + self.chain_lock_reversed_sig_cache + .entry(sig) + .or_default() + .insert((h, bh)); + } + } + self.pending = None; + self.display_message("Fetched chain lock signatures", MessageType::Success); + } + _ => {} + } + } + + fn refresh_on_arrival(&mut self) { + // Optionally refresh data when this screen is shown + } + + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![("Tools", AppAction::None)], + vec![], + ); + + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenToolsMasternodeListDiffScreen, + ); + + action |= add_tools_subscreen_chooser_panel(ctx, self.app_context.as_ref()); + + // Styled central panel consistent with other tool screens; scroll only below tab row + action |= island_central_panel(ctx, |ui| { + // Top: input area (base/end block height + Get DMLs button) + let mut inner = AppAction::None; + inner |= self.render_input_area(ui); + // If we queued a backend task from a prior result processing, send it now + if let Some(task) = self.queued_task.take() { + inner |= AppAction::BackendTask(task); + } + + if let Some((msg, msg_type)) = self.message.clone() { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let message_color = match msg_type { + MessageType::Error => Color32::from_rgb(255, 100, 100), + MessageType::Info => crate::ui::theme::DashColors::text_primary(dark_mode), + // Dark green for success text + MessageType::Success => Color32::DARK_GREEN, + }; + ui.horizontal(|ui| { + Frame::new() + .fill(message_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, message_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(msg).color(message_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.message = None; + } + }); + }); + }); + ui.add_space(10.0); + } + + if let Some(error_msg) = self.error.clone() { + let message_color = Color32::from_rgb(255, 100, 100); + ui.horizontal(|ui| { + Frame::new() + .fill(message_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, message_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(error_msg).color(message_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.error = None; + } + }); + }); + }); + ui.add_space(10.0); + } + + // Pending spinner (Dash Blue spinner, black text) + if let Some(p) = self.pending { + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.scope(|ui| { + let style = ui.style_mut(); + // Force spinner (fg stroke) to Dash Blue + style.visuals.widgets.inactive.fg_stroke.color = + crate::ui::theme::DashColors::DASH_BLUE; + style.visuals.widgets.active.fg_stroke.color = + crate::ui::theme::DashColors::DASH_BLUE; + style.visuals.widgets.hovered.fg_stroke.color = + crate::ui::theme::DashColors::DASH_BLUE; + ui.add(egui::Spinner::new()); + }); + let label = match p { + PendingTask::DmlDiffSingle => "Fetching DML diff…", + PendingTask::DmlDiffNoRotation => "Fetching DMLs (no rotation)…", + PendingTask::QrInfo => "Fetching QR info…", + PendingTask::QrInfoWithDmls => "Fetching QR info + DMLs…", + PendingTask::ChainLocks => "Fetching chain locks…", + }; + ui.colored_label(Color32::BLACK, label); + }); + ui.add_space(6.0); + } + + ui.separator(); + + self.render_selected_tab(ui); + inner + }); + action + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingTask { + DmlDiffSingle, + DmlDiffNoRotation, + QrInfo, + QrInfoWithDmls, + ChainLocks, +} diff --git a/src/ui/tools/mod.rs b/src/ui/tools/mod.rs index 76b8690be..3bc6af157 100644 --- a/src/ui/tools/mod.rs +++ b/src/ui/tools/mod.rs @@ -1,5 +1,6 @@ pub mod contract_visualizer_screen; pub mod document_visualizer_screen; +pub mod masternode_list_diff_screen; pub mod platform_info_screen; pub mod proof_log_screen; pub mod proof_visualizer_screen; diff --git a/src/ui/tools/transition_visualizer_screen.rs b/src/ui/tools/transition_visualizer_screen.rs index ab1c05c8d..a05f39a1e 100644 --- a/src/ui/tools/transition_visualizer_screen.rs +++ b/src/ui/tools/transition_visualizer_screen.rs @@ -58,14 +58,13 @@ impl TransitionVisualizerScreen { match value { Value::Object(map) => { // Check if this is a contractBounds object with an id - if map.contains_key("type") && map.contains_key("id") { - if let (Some(Value::String(type_str)), Some(Value::String(id))) = + if map.contains_key("type") + && map.contains_key("id") + && let (Some(Value::String(type_str)), Some(Value::String(id))) = (map.get("type"), map.get("id")) - { - if type_str == "singleContract" { - ids.push(id.clone()); - } - } + && type_str == "singleContract" + { + ids.push(id.clone()); } // Recursively check all values for val in map.values() { @@ -241,12 +240,12 @@ impl TransitionVisualizerScreen { .as_secs(); self.broadcast_status = TransitionBroadcastStatus::Submitting(now); - if let Some(json) = &self.parsed_json { - if let Ok(state_transition) = serde_json::from_str(json) { - app_action = AppAction::BackendTask( - BackendTask::BroadcastStateTransition(state_transition), - ); - } + if let Some(json) = &self.parsed_json + && let Ok(state_transition) = serde_json::from_str(json) + { + app_action = AppAction::BackendTask( + BackendTask::BroadcastStateTransition(state_transition), + ); } } } diff --git a/src/ui/wallets/add_new_wallet_screen.rs b/src/ui/wallets/add_new_wallet_screen.rs index ea5b2d170..6ce596402 100644 --- a/src/ui/wallets/add_new_wallet_screen.rs +++ b/src/ui/wallets/add_new_wallet_screen.rs @@ -10,10 +10,10 @@ use crate::model::wallet::encryption::{DASH_SECRET_MESSAGE, encrypt_message}; use crate::model::wallet::{ClosedKeyItem, OpenWalletSeed, Wallet, WalletSeed}; use crate::ui::components::entropy_grid::U256EntropyGrid; use bip39::{Language, Mnemonic}; -use dash_sdk::dashcore_rpc::dashcore::bip32::{ChildNumber, DerivationPath}; use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::dashcore::bip32::{ExtendedPrivKey, ExtendedPubKey}; +use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; +use dash_sdk::dpp::key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey}; use eframe::emath::Align; use egui::{Color32, ComboBox, Direction, Frame, Grid, Layout, Margin, RichText, Stroke, Ui, Vec2}; use std::sync::atomic::Ordering; diff --git a/src/ui/wallets/import_wallet_screen.rs b/src/ui/wallets/import_wallet_screen.rs index 591b6801a..d2f37f169 100644 --- a/src/ui/wallets/import_wallet_screen.rs +++ b/src/ui/wallets/import_wallet_screen.rs @@ -12,10 +12,10 @@ use crate::ui::wallets::add_new_wallet_screen::{ DASH_BIP44_ACCOUNT_0_PATH_MAINNET, DASH_BIP44_ACCOUNT_0_PATH_TESTNET, }; use bip39::Mnemonic; -use dash_sdk::dashcore_rpc::dashcore::bip32::DerivationPath; use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::dashcore::bip32::{ExtendedPrivKey, ExtendedPubKey}; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::dpp::key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey}; use egui::{Color32, ComboBox, Direction, Grid, Layout, RichText, Stroke, Ui, Vec2}; use std::sync::atomic::Ordering; use std::sync::{Arc, RwLock}; @@ -263,11 +263,10 @@ impl ScreenLike for ImportWalletScreen { Ok(mnemonic) => { self.seed_phrase = Some(mnemonic); // Clear any existing seed phrase error - if let Some(ref mut error) = self.error { - if error.contains("Invalid seed phrase") { + if let Some(ref mut error) = self.error + && error.contains("Invalid seed phrase") { self.error = None; } - } } Err(_) => { self.seed_phrase = None; @@ -277,20 +276,18 @@ impl ScreenLike for ImportWalletScreen { } else { // Clear seed phrase and error if not all words are filled self.seed_phrase = None; - if let Some(ref mut error) = self.error { - if error.contains("Invalid seed phrase") { + if let Some(ref mut error) = self.error + && error.contains("Invalid seed phrase") { self.error = None; } - } } // Display error message if seed phrase is invalid - if let Some(ref error_msg) = self.error { - if error_msg.contains("Invalid seed phrase") { + if let Some(ref error_msg) = self.error + && error_msg.contains("Invalid seed phrase") { ui.add_space(10.0); ui.colored_label(Color32::from_rgb(255, 100, 100), error_msg); } - } if self.seed_phrase.is_none() { return; diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index edfa21969..b9ef9b18f 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -11,7 +11,7 @@ use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike, ScreenType}; use chrono::{DateTime, Utc}; use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; -use dash_sdk::dpp::dashcore::bip32::{ChildNumber, DerivationPath}; +use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; use eframe::egui::{self, ComboBox, Context, Ui}; use egui::{Color32, Frame, Margin, RichText}; use egui_extras::{Column, TableBuilder}; @@ -824,7 +824,7 @@ impl ScreenLike for WalletsBalancesScreen { let message_color = match message_type { MessageType::Error => egui::Color32::from_rgb(255, 100, 100), MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => egui::Color32::from_rgb(100, 255, 100), + MessageType::Success => egui::Color32::DARK_GREEN, }; // Display message in a prominent frame From 3d059d29cd9c8197ab54952b32f9bae58582803a Mon Sep 17 00:00:00 2001 From: thephez Date: Tue, 23 Sep 2025 04:55:10 -0400 Subject: [PATCH 019/106] chore: bump version to 1.0.0-dev (#439) The 1.0-dev branch builds were still reporting as 0.9.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7b0b0ddb2..ceebaa5f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1577,7 +1577,7 @@ dependencies = [ [[package]] name = "dash-evo-tool" -version = "0.9.0" +version = "1.0.0-dev" dependencies = [ "aes-gcm", "arboard", diff --git a/Cargo.toml b/Cargo.toml index c5b0b636b..e07c5d7de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dash-evo-tool" -version = "0.9.0" +version = "1.0.0-dev" license = "MIT" edition = "2024" default-run = "dash-evo-tool" From 0f753f5e6f1691341a8225d3edb8247563132de6 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:10:38 +0700 Subject: [PATCH 020/106] feat: add left panel button labels and create contract subscreen chooser panel (#441) * feat: add left panel button labels and create contract subscreen chooser panel * fmt --- src/app.rs | 8 ++ src/components/core_p2p_handler.rs | 9 +- src/ui/components/contract_chooser_panel.rs | 2 +- .../contracts_subscreen_chooser_panel.rs | 128 ++++++++++++++++++ .../dpns_subscreen_chooser_panel.rs | 17 +-- src/ui/components/left_panel.rs | 44 ++++-- src/ui/components/mod.rs | 1 + .../tokens_subscreen_chooser_panel.rs | 10 +- .../tools_subscreen_chooser_panel.rs | 38 +++--- .../add_contracts_screen.rs | 6 + .../contracts_documents_screen.rs | 6 + .../dashpay_coming_soon_screen.rs | 60 ++++++++ .../document_action_screen.rs | 6 + .../group_actions_screen.rs | 6 + src/ui/contracts_documents/mod.rs | 1 + .../register_contract_screen.rs | 6 + .../update_contract_screen.rs | 6 + src/ui/dpns/dpns_contested_names_screen.rs | 57 ++++---- src/ui/mod.rs | 22 +++ src/ui/network_chooser_screen.rs | 2 +- src/ui/wallets/wallets_screen/mod.rs | 2 +- 21 files changed, 355 insertions(+), 82 deletions(-) create mode 100644 src/ui/components/contracts_subscreen_chooser_panel.rs create mode 100644 src/ui/contracts_documents/dashpay_coming_soon_screen.rs diff --git a/src/app.rs b/src/app.rs index 9c8b55d56..0608c3fb0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,6 +11,7 @@ use crate::database::Database; use crate::logging::initialize_logger; use crate::model::settings::Settings; use crate::ui::contracts_documents::contracts_documents_screen::DocumentQueryScreen; +use crate::ui::contracts_documents::dashpay_coming_soon_screen::DashPayComingSoonScreen; use crate::ui::dpns::dpns_contested_names_screen::{ DPNSScreen, DPNSSubscreen, ScheduledVoteCastingStatus, }; @@ -226,6 +227,7 @@ impl AppState { TokensScreen::new(&mainnet_app_context, TokensSubscreen::SearchTokens); let mut token_creator_screen = TokensScreen::new(&mainnet_app_context, TokensSubscreen::TokenCreator); + let mut contracts_dashpay_screen = DashPayComingSoonScreen::new(&mainnet_app_context); let mut network_chooser_screen = NetworkChooserScreen::new( &mainnet_app_context, @@ -263,6 +265,7 @@ impl AppState { proof_log_screen = ProofLogScreen::new(testnet_app_context); platform_info_screen = PlatformInfoScreen::new(testnet_app_context); masternode_list_diff_screen = MasternodeListDiffScreen::new(testnet_app_context); + contracts_dashpay_screen = DashPayComingSoonScreen::new(testnet_app_context); tokens_balances_screen = TokensScreen::new(testnet_app_context, TokensSubscreen::MyTokens); token_search_screen = @@ -312,6 +315,7 @@ impl AppState { masternode_list_diff_screen = MasternodeListDiffScreen::new(local_app_context); proof_log_screen = ProofLogScreen::new(local_app_context); platform_info_screen = PlatformInfoScreen::new(local_app_context); + contracts_dashpay_screen = DashPayComingSoonScreen::new(local_app_context); tokens_balances_screen = TokensScreen::new(local_app_context, TokensSubscreen::MyTokens); token_search_screen = @@ -445,6 +449,10 @@ impl AppState { RootScreenType::RootScreenDocumentQuery, Screen::DocumentQueryScreen(document_query_screen), ), + ( + RootScreenType::RootScreenContractsDashPay, + Screen::DashPayComingSoonScreen(contracts_dashpay_screen), + ), ( RootScreenType::RootScreenNetworkChooser, Screen::NetworkChooserScreen(network_chooser_screen), diff --git a/src/components/core_p2p_handler.rs b/src/components/core_p2p_handler.rs index c7bee96e1..6b354598b 100644 --- a/src/components/core_p2p_handler.rs +++ b/src/components/core_p2p_handler.rs @@ -176,8 +176,7 @@ impl CoreP2PHandler { .stream .read_timeout() .map_err(|e| format!("get_read_timeout failed: {}", e))?; - self - .stream + self.stream .set_read_timeout(Some(socket_timeout)) .map_err(|e| format!("set_read_timeout failed: {}", e))?; let start_time = std::time::Instant::now(); @@ -185,8 +184,7 @@ impl CoreP2PHandler { loop { if start_time.elapsed() > timeout { // Restore previous socket timeout before returning - self - .stream + self.stream .set_read_timeout(previous_socket_timeout) .map_err(|e| format!("restore set_read_timeout failed: {}", e))?; return Err("Timeout waiting for qrinfo message".to_string()); @@ -205,8 +203,7 @@ impl CoreP2PHandler { if command == "qrinfo" { println!("Got qrinfo message"); // Restore previous socket timeout - self - .stream + self.stream .set_read_timeout(previous_socket_timeout) .map_err(|e| format!("restore set_read_timeout failed: {}", e))?; break; diff --git a/src/ui/components/contract_chooser_panel.rs b/src/ui/components/contract_chooser_panel.rs index 1738f67aa..ac465fd3c 100644 --- a/src/ui/components/contract_chooser_panel.rs +++ b/src/ui/components/contract_chooser_panel.rs @@ -181,7 +181,7 @@ pub fn add_contract_chooser_panel( SidePanel::left("contract_chooser_panel") // Let the user resize this panel horizontally - .resizable(true) + .resizable(false) .default_width(270.0) // Increased to account for margins .frame( Frame::new() diff --git a/src/ui/components/contracts_subscreen_chooser_panel.rs b/src/ui/components/contracts_subscreen_chooser_panel.rs new file mode 100644 index 000000000..9921bc191 --- /dev/null +++ b/src/ui/components/contracts_subscreen_chooser_panel.rs @@ -0,0 +1,128 @@ +use crate::app::AppAction; +use crate::context::AppContext; +use crate::ui::theme::{DashColors, Shadow, Shape, Spacing, Typography}; +use crate::ui::{self, RootScreenType}; +use egui::{Context, Frame, Margin, RichText, SidePanel}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContractsSubscreen { + Contracts, + DPNS, + DashPay, +} + +impl ContractsSubscreen { + pub fn display_name(&self) -> &'static str { + match self { + ContractsSubscreen::Contracts => "All Contracts", + ContractsSubscreen::DPNS => "DPNS", + ContractsSubscreen::DashPay => "DashPay", + } + } +} + +pub fn add_contracts_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext) -> AppAction { + let mut action = AppAction::None; + + let subscreens = vec![ + ContractsSubscreen::Contracts, + ContractsSubscreen::DPNS, + ContractsSubscreen::DashPay, + ]; + + // Determine active selection from settings; default to Contracts + let active_screen = match app_context.get_settings() { + Ok(Some(settings)) => match settings.root_screen_type { + ui::RootScreenType::RootScreenDocumentQuery => ContractsSubscreen::Contracts, + ui::RootScreenType::RootScreenDPNSActiveContests + | ui::RootScreenType::RootScreenDPNSPastContests + | ui::RootScreenType::RootScreenDPNSOwnedNames + | ui::RootScreenType::RootScreenDPNSScheduledVotes => ContractsSubscreen::DPNS, + ui::RootScreenType::RootScreenContractsDashPay => ContractsSubscreen::DashPay, + _ => ContractsSubscreen::Contracts, + }, + _ => ContractsSubscreen::Contracts, + }; + + let dark_mode = ctx.style().visuals.dark_mode; + + SidePanel::left("contracts_subscreen_chooser_panel") + .resizable(false) + .default_width(270.0) + .frame( + Frame::new() + .fill(DashColors::background(dark_mode)) + .inner_margin(Margin::symmetric(10, 10)), + ) + .show(ctx, |ui| { + let available_height = ui.available_height(); + + Frame::new() + .fill(DashColors::surface(dark_mode)) + .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) + .inner_margin(Margin::same(Spacing::XL as i8)) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) + .shadow(Shadow::elevated()) + .show(ui, |ui| { + ui.set_min_height(available_height - 2.0 - (Spacing::XL * 2.0)); + ui.vertical(|ui| { + ui.label( + RichText::new("Contracts") + .font(Typography::heading_small()) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(Spacing::MD); + + for subscreen in subscreens { + let is_active = active_screen == subscreen; + + let button = if is_active { + egui::Button::new( + RichText::new(subscreen.display_name()) + .color(DashColors::WHITE) + .size(Typography::SCALE_SM), + ) + .fill(DashColors::DASH_BLUE) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) + .min_size(egui::Vec2::new(150.0, 28.0)) + } else { + egui::Button::new( + RichText::new(subscreen.display_name()) + .color(DashColors::text_primary(dark_mode)) + .size(Typography::SCALE_SM), + ) + .fill(DashColors::glass_white(dark_mode)) + .stroke(egui::Stroke::new(1.0, DashColors::border(dark_mode))) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) + .min_size(egui::Vec2::new(150.0, 28.0)) + }; + + if ui.add(button).clicked() { + action = match subscreen { + ContractsSubscreen::Contracts => { + AppAction::SetMainScreenThenGoToMainScreen( + RootScreenType::RootScreenDocumentQuery, + ) + } + ContractsSubscreen::DPNS => { + AppAction::SetMainScreenThenGoToMainScreen( + RootScreenType::RootScreenDPNSActiveContests, + ) + } + ContractsSubscreen::DashPay => { + AppAction::SetMainScreenThenGoToMainScreen( + RootScreenType::RootScreenContractsDashPay, + ) + } + }; + } + + ui.add_space(Spacing::SM); + } + }); + }); + }); + + action +} diff --git a/src/ui/components/dpns_subscreen_chooser_panel.rs b/src/ui/components/dpns_subscreen_chooser_panel.rs index 42bd73020..34210c79d 100644 --- a/src/ui/components/dpns_subscreen_chooser_panel.rs +++ b/src/ui/components/dpns_subscreen_chooser_panel.rs @@ -24,31 +24,28 @@ pub fn add_dpns_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext) ui::RootScreenType::RootScreenDPNSScheduledVotes => DPNSSubscreen::ScheduledVotes, _ => DPNSSubscreen::Active, }, - _ => DPNSSubscreen::Active, // Fallback to Active screen if settings unavailable + _ => DPNSSubscreen::Active, }; SidePanel::left("dpns_subscreen_chooser_panel") - .default_width(270.0) // Increased to account for margins + .resizable(true) + .default_width(270.0) .frame( Frame::new() - .fill(DashColors::background(dark_mode)) // Light background instead of transparent - .inner_margin(Margin::symmetric(10, 10)), // Add margins for island effect + .fill(DashColors::background(dark_mode)) + .inner_margin(Margin::symmetric(10, 10)), ) .show(ctx, |ui| { - // Fill the entire available height let available_height = ui.available_height(); - // Create an island panel with rounded edges that fills the height Frame::new() .fill(DashColors::surface(dark_mode)) .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) - .inner_margin(Margin::same(Spacing::MD_I8)) + .inner_margin(Margin::same(Spacing::XL as i8)) .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) .shadow(Shadow::elevated()) .show(ui, |ui| { - // Account for both outer margin (10px * 2) and inner margin - ui.set_min_height(available_height - 2.0 - (Spacing::MD_I8 as f32 * 2.0)); - // Display subscreen names + ui.set_min_height(available_height - 2.0 - (Spacing::XL * 2.0)); ui.vertical(|ui| { ui.label( RichText::new("DPNS Subscreens") diff --git a/src/ui/components/left_panel.rs b/src/ui/components/left_panel.rs index 6b34a0f2c..4f5a4be2b 100644 --- a/src/ui/components/left_panel.rs +++ b/src/ui/components/left_panel.rs @@ -56,21 +56,36 @@ pub fn add_left_panel( // Define the button details directly in this function let buttons = [ - ("I", RootScreenType::RootScreenIdentities, "identity.png"), - ("Q", RootScreenType::RootScreenDocumentQuery, "doc.png"), - ("O", RootScreenType::RootScreenMyTokenBalances, "tokens.png"), ( - "C", - RootScreenType::RootScreenDPNSActiveContests, - "voting.png", + "Identities", + RootScreenType::RootScreenIdentities, + "identity.png", ), - ("W", RootScreenType::RootScreenWalletsBalances, "wallet.png"), ( - "T", - RootScreenType::RootScreenToolsProofLogScreen, + "Contracts", + RootScreenType::RootScreenDocumentQuery, + "doc.png", + ), + ( + "Tokens", + RootScreenType::RootScreenMyTokenBalances, + "tokens.png", + ), + ( + "Wallets", + RootScreenType::RootScreenWalletsBalances, + "wallet.png", + ), + ( + "Tools", + RootScreenType::RootScreenToolsPlatformInfoScreen, "tools.png", ), - ("N", RootScreenType::RootScreenNetworkChooser, "config.png"), + ( + "Settings", + RootScreenType::RootScreenNetworkChooser, + "config.png", + ), ]; let panel_width = 60.0 + (Spacing::MD * 2.0); // Button width + margins @@ -79,6 +94,7 @@ pub fn add_left_panel( SidePanel::left("left_panel") .default_width(panel_width + 20.0) // Add extra width for margins + .resizable(false) .frame( Frame::new() .fill(DashColors::background(dark_mode)) @@ -118,6 +134,14 @@ pub fn add_left_panel( } else if added.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } + // Put the label beneath the icon + let color = if is_selected { + DashColors::DASH_BLUE + } else { + DashColors::DEEP_BLUE + }; + let label_text = RichText::new(*label).color(color).size(13.0); + ui.label(label_text); } else { // Fallback to a modern gradient button if texture loading fails if is_selected { diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index e728d66a6..ac63e7f56 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -2,6 +2,7 @@ pub mod amount_input; pub mod component_trait; pub mod confirmation_dialog; pub mod contract_chooser_panel; +pub mod contracts_subscreen_chooser_panel; pub mod dpns_subscreen_chooser_panel; pub mod entropy_grid; pub mod identity_selector; diff --git a/src/ui/components/tokens_subscreen_chooser_panel.rs b/src/ui/components/tokens_subscreen_chooser_panel.rs index e5ef2d119..add77bd30 100644 --- a/src/ui/components/tokens_subscreen_chooser_panel.rs +++ b/src/ui/components/tokens_subscreen_chooser_panel.rs @@ -27,18 +27,15 @@ pub fn add_tokens_subscreen_chooser_panel(ctx: &Context, app_context: &AppContex let dark_mode = ctx.style().visuals.dark_mode; SidePanel::left("tokens_subscreen_chooser_panel") - .resizable(true) - .default_width(270.0) // Increased to account for margins + .resizable(false) + .default_width(270.0) .frame( Frame::new() .fill(DashColors::background(dark_mode)) - .inner_margin(Margin::symmetric(10, 10)), // Add margins for island effect + .inner_margin(Margin::symmetric(10, 10)), ) .show(ctx, |ui| { - // Fill the entire available height let available_height = ui.available_height(); - - // Create an island panel with rounded edges that fills the height Frame::new() .fill(DashColors::surface(dark_mode)) .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) @@ -46,7 +43,6 @@ pub fn add_tokens_subscreen_chooser_panel(ctx: &Context, app_context: &AppContex .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) .shadow(Shadow::elevated()) .show(ui, |ui| { - // Account for both outer margin (10px * 2) and inner margin ui.set_min_height(available_height - 2.0 - (Spacing::XL * 2.0)); // Display subscreen names ui.vertical(|ui| { diff --git a/src/ui/components/tools_subscreen_chooser_panel.rs b/src/ui/components/tools_subscreen_chooser_panel.rs index 739e41b5a..79aa2f9f5 100644 --- a/src/ui/components/tools_subscreen_chooser_panel.rs +++ b/src/ui/components/tools_subscreen_chooser_panel.rs @@ -6,24 +6,24 @@ use egui::{Context, Frame, Margin, RichText, SidePanel}; #[derive(PartialEq)] pub enum ToolsSubscreen { + PlatformInfo, ProofLog, TransactionViewer, DocumentViewer, ProofViewer, ContractViewer, - PlatformInfo, MasternodeListDiff, } impl ToolsSubscreen { pub fn display_name(&self) -> &'static str { match self { + Self::PlatformInfo => "Platform info", Self::ProofLog => "Proof logs", Self::TransactionViewer => "Transaction deserializer", Self::ProofViewer => "Proof deserializer", Self::DocumentViewer => "Document deserializer", Self::ContractViewer => "Contract deserializer", - Self::PlatformInfo => "Platform info", Self::MasternodeListDiff => "Masternode list diff inspector", } } @@ -34,17 +34,18 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext let dark_mode = ctx.style().visuals.dark_mode; let subscreens = vec![ + ToolsSubscreen::PlatformInfo, ToolsSubscreen::ProofLog, ToolsSubscreen::ProofViewer, ToolsSubscreen::TransactionViewer, ToolsSubscreen::DocumentViewer, ToolsSubscreen::ContractViewer, - ToolsSubscreen::PlatformInfo, ToolsSubscreen::MasternodeListDiff, ]; let active_screen = match app_context.get_settings() { Ok(Some(settings)) => match settings.root_screen_type { + ui::RootScreenType::RootScreenToolsPlatformInfoScreen => ToolsSubscreen::PlatformInfo, ui::RootScreenType::RootScreenToolsProofLogScreen => ToolsSubscreen::ProofLog, ui::RootScreenType::RootScreenToolsTransitionVisualizerScreen => { ToolsSubscreen::TransactionViewer @@ -56,37 +57,32 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext ui::RootScreenType::RootScreenToolsContractVisualizerScreen => { ToolsSubscreen::ContractViewer } - ui::RootScreenType::RootScreenToolsPlatformInfoScreen => ToolsSubscreen::PlatformInfo, ui::RootScreenType::RootScreenToolsMasternodeListDiffScreen => { ToolsSubscreen::MasternodeListDiff } - _ => ToolsSubscreen::ProofLog, + _ => ToolsSubscreen::PlatformInfo, }, - _ => ToolsSubscreen::ProofLog, // Fallback to Active screen if settings unavailable + _ => ToolsSubscreen::PlatformInfo, // Fallback to Active screen if settings unavailable }; SidePanel::left("tools_subscreen_chooser_panel") - .default_width(270.0) // Increased to account for margins + .resizable(false) + .default_width(270.0) .frame( Frame::new() - .fill(DashColors::background(dark_mode)) // Light background instead of transparent - .inner_margin(Margin::symmetric(10, 10)), // Add margins for island effect + .fill(DashColors::background(dark_mode)) + .inner_margin(Margin::symmetric(10, 10)), ) .show(ctx, |ui| { - // Fill the entire available height let available_height = ui.available_height(); - - // Create an island panel with rounded edges that fills the height Frame::new() .fill(DashColors::surface(dark_mode)) .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) - .inner_margin(Margin::same(Spacing::MD_I8)) + .inner_margin(Margin::same(Spacing::XL as i8)) .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) .shadow(Shadow::elevated()) .show(ui, |ui| { - // Account for both outer margin (10px * 2) and inner margin - ui.set_min_height(available_height - 2.0 - (Spacing::MD_I8 as f32 * 2.0)); - // Display subscreen names + ui.set_min_height(available_height - 2.0 - (Spacing::XL * 2.0)); ui.vertical(|ui| { ui.label( RichText::new("Tools") @@ -124,6 +120,11 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext if ui.add(button).clicked() { // Handle navigation based on which subscreen is selected match subscreen { + ToolsSubscreen::PlatformInfo => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenToolsPlatformInfoScreen, + ) + } ToolsSubscreen::ProofLog => { action = AppAction::SetMainScreen( RootScreenType::RootScreenToolsProofLogScreen, @@ -149,11 +150,6 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext RootScreenType::RootScreenToolsContractVisualizerScreen, ) } - ToolsSubscreen::PlatformInfo => { - action = AppAction::SetMainScreen( - RootScreenType::RootScreenToolsPlatformInfoScreen, - ) - } ToolsSubscreen::MasternodeListDiff => { action = AppAction::SetMainScreen( RootScreenType::RootScreenToolsMasternodeListDiffScreen) diff --git a/src/ui/contracts_documents/add_contracts_screen.rs b/src/ui/contracts_documents/add_contracts_screen.rs index f8de7ab88..af2879347 100644 --- a/src/ui/contracts_documents/add_contracts_screen.rs +++ b/src/ui/contracts_documents/add_contracts_screen.rs @@ -323,6 +323,12 @@ impl ScreenLike for AddContractsScreen { crate::ui::RootScreenType::RootScreenDocumentQuery, ); + // Contracts sub-left panel + action |= crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel( + ctx, + &self.app_context, + ); + action |= island_central_panel(ctx, |ui| { ui.heading("Add Contracts"); ui.add_space(10.0); diff --git a/src/ui/contracts_documents/contracts_documents_screen.rs b/src/ui/contracts_documents/contracts_documents_screen.rs index ec9f32388..14cfcd51e 100644 --- a/src/ui/contracts_documents/contracts_documents_screen.rs +++ b/src/ui/contracts_documents/contracts_documents_screen.rs @@ -685,6 +685,12 @@ impl ScreenLike for DocumentQueryScreen { RootScreenType::RootScreenDocumentQuery, ); + // Contracts sub-left panel: DPNS / DashPay / Contracts (default) + action |= crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel( + ctx, + &self.app_context, + ); + action |= add_contract_chooser_panel( ctx, &mut self.contract_search_term, diff --git a/src/ui/contracts_documents/dashpay_coming_soon_screen.rs b/src/ui/contracts_documents/dashpay_coming_soon_screen.rs new file mode 100644 index 000000000..80b786b82 --- /dev/null +++ b/src/ui/contracts_documents/dashpay_coming_soon_screen.rs @@ -0,0 +1,60 @@ +use std::sync::Arc; + +use eframe::egui::Context; + +use crate::app::AppAction; +use crate::context::AppContext; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::{RootScreenType, ScreenLike}; + +pub struct DashPayComingSoonScreen { + pub app_context: Arc, +} + +impl DashPayComingSoonScreen { + pub fn new(app_context: &Arc) -> Self { + Self { + app_context: app_context.clone(), + } + } +} + +impl ScreenLike for DashPayComingSoonScreen { + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Contracts", AppAction::GoToMainScreen), + ("DashPay", AppAction::None), + ], + vec![], + ); + + // Keep Contracts highlighted in the main left panel + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenDocumentQuery, + ); + + // Contracts sub-left panel + action |= crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel( + ctx, + &self.app_context, + ); + + action |= island_central_panel(ctx, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(40.0); + ui.heading("Coming Soon"); + ui.add_space(20.0); + }); + AppAction::None + }); + + action + } +} diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index ae35c0826..c43c300b2 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -1470,6 +1470,12 @@ impl ScreenLike for DocumentActionScreen { crate::ui::RootScreenType::RootScreenDocumentQuery, ); + // Contracts sub-left panel + action |= crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel( + ctx, + &self.app_context, + ); + action |= island_central_panel(ctx, |ui| match &self.broadcast_status { BroadcastStatus::Broadcasted => { let success_message = format!("{} successful!", self.action_type.display_name()); diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index a7b24b695..5e40ce43c 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -489,6 +489,12 @@ impl ScreenLike for GroupActionsScreen { RootScreenType::RootScreenDocumentQuery, ); + // Contracts sub-left panel + action |= crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel( + ctx, + &self.app_context, + ); + let central_panel_action = island_central_panel(ctx, |ui| { ui.heading("Active Group Actions"); diff --git a/src/ui/contracts_documents/mod.rs b/src/ui/contracts_documents/mod.rs index 47ce18225..07011f768 100644 --- a/src/ui/contracts_documents/mod.rs +++ b/src/ui/contracts_documents/mod.rs @@ -1,5 +1,6 @@ pub mod add_contracts_screen; pub mod contracts_documents_screen; +pub mod dashpay_coming_soon_screen; pub mod document_action_screen; pub mod group_actions_screen; pub mod register_contract_screen; diff --git a/src/ui/contracts_documents/register_contract_screen.rs b/src/ui/contracts_documents/register_contract_screen.rs index 71a8b7048..b8386ffb6 100644 --- a/src/ui/contracts_documents/register_contract_screen.rs +++ b/src/ui/contracts_documents/register_contract_screen.rs @@ -322,6 +322,12 @@ impl ScreenLike for RegisterDataContractScreen { crate::ui::RootScreenType::RootScreenDocumentQuery, ); + // Contracts sub-left panel + action |= crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel( + ctx, + &self.app_context, + ); + action |= island_central_panel(ctx, |ui| { if self.broadcast_status == BroadcastStatus::Done { return self.show_success(ui); diff --git a/src/ui/contracts_documents/update_contract_screen.rs b/src/ui/contracts_documents/update_contract_screen.rs index 5c52dc131..6e9bc0c33 100644 --- a/src/ui/contracts_documents/update_contract_screen.rs +++ b/src/ui/contracts_documents/update_contract_screen.rs @@ -364,6 +364,12 @@ impl ScreenLike for UpdateDataContractScreen { crate::ui::RootScreenType::RootScreenDocumentQuery, ); + // Contracts sub-left panel + action |= crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel( + ctx, + &self.app_context, + ); + action |= island_central_panel(ctx, |ui| { if self.broadcast_status == BroadcastStatus::Done { return self.show_success(ui); diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 43195990c..8d57232c3 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -17,6 +17,7 @@ use crate::backend_task::identity::IdentityTask; use crate::context::AppContext; use crate::model::contested_name::{ContestState, ContestedName}; use crate::model::qualified_identity::{DPNSNameInfo, QualifiedIdentity}; +use crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel; use crate::ui::components::dpns_subscreen_chooser_panel::add_dpns_subscreen_chooser_panel; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::{StyledButton, island_central_panel}; @@ -383,15 +384,15 @@ impl DPNSScreen { .striped(false) .resizable(true) .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) - .column(Column::initial(200.0).resizable(true)) // Contested Name - .column(Column::initial(100.0).resizable(true)) // Locked - .column(Column::initial(100.0).resizable(true)) // Abstain - .column(Column::initial(200.0).resizable(true)) // Ending Time - .column(Column::initial(200.0).resizable(true)) // Last Updated - .column(Column::remainder()) // Contestants + .column(Column::auto().resizable(true)) // Contested Name + .column(Column::auto().resizable(true)) // Locked + .column(Column::auto().resizable(true)) // Abstain + .column(Column::auto().resizable(true)) // Ending Time + .column(Column::auto().resizable(true)) // Last Updated + .column(Column::auto().resizable(true)) // Contestants .header(30.0, |mut header| { header.col(|ui| { - if ui.button("Contested Name").clicked() { + if ui.button("Name").clicked() { self.toggle_sort(SortColumn::ContestedName); } }); @@ -491,10 +492,13 @@ impl DPNSScreen { // LOCK button row.col(|ui| { let label_text = format!("{}", locked_votes); + let dark_green = Color32::from_rgb(0, 100, 0); + let dark_mode = ui.ctx().style().visuals.dark_mode; + let normal_color = DashColors::text_primary(dark_mode); let text_widget = if is_locked_votes_bold { - RichText::new(label_text).strong() + RichText::new(label_text).strong().color(dark_green) } else { - RichText::new(label_text) + RichText::new(label_text).color(normal_color) }; // See if this (LOCK) is selected @@ -708,13 +712,13 @@ impl DPNSScreen { .striped(false) .resizable(true) .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) - .column(Column::initial(200.0).resizable(true)) // Name - .column(Column::initial(200.0).resizable(true)) // Ended Time - .column(Column::initial(200.0).resizable(true)) // Last Updated - .column(Column::initial(200.0).resizable(true)) // Awarded To + .column(Column::auto().resizable(true)) // Name + .column(Column::auto().resizable(true)) // Ended Time + .column(Column::auto().resizable(true)) // Last Updated + .column(Column::auto().resizable(true)) // Awarded To .header(30.0, |mut header| { header.col(|ui| { - if ui.button("Contested Name").clicked() { + if ui.button("Name").clicked() { self.toggle_sort(SortColumn::ContestedName); } }); @@ -895,9 +899,9 @@ impl DPNSScreen { .striped(false) .resizable(true) .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) - .column(Column::initial(200.0).resizable(true)) // DPNS Name - .column(Column::initial(400.0).resizable(true)) // Owner ID - .column(Column::initial(300.0).resizable(true)) // Acquired At + .column(Column::auto().resizable(true)) // DPNS Name + .column(Column::auto().resizable(true)) // Owner ID + .column(Column::auto().resizable(true)) // Acquired At .header(30.0, |mut header| { header.col(|ui| { if ui.button("Name").clicked() { @@ -972,15 +976,15 @@ impl DPNSScreen { .striped(false) .resizable(true) .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) - .column(Column::initial(100.0).resizable(true)) // ContestedName - .column(Column::initial(200.0).resizable(true)) // Voter - .column(Column::initial(200.0).resizable(true)) // Choice - .column(Column::initial(200.0).resizable(true)) // Time - .column(Column::initial(100.0).resizable(true)) // Status - .column(Column::initial(100.0).resizable(true)) // Actions + .column(Column::auto().resizable(true)) // ContestedName + .column(Column::auto().resizable(true)) // Voter + .column(Column::auto().resizable(true)) // Choice + .column(Column::auto().resizable(true)) // Time + .column(Column::auto().resizable(true)) // Status + .column(Column::auto().resizable(true)) // Actions .header(30.0, |mut header| { header.col(|ui| { - if ui.button("Contested Name").clicked() { + if ui.button("Name").clicked() { self.toggle_sort(SortColumn::ContestedName); } }); @@ -2014,7 +2018,10 @@ impl ScreenLike for DPNSScreen { } } - // Subscreen chooser + // Contracts area chooser (DPNS / DashPay / Contracts) + action |= add_contracts_subscreen_chooser_panel(ctx, self.app_context.as_ref()); + + // DPNS subscreen chooser action |= add_dpns_subscreen_chooser_panel(ctx, self.app_context.as_ref()); // Main panel diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 415ca385f..3a5ee7903 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -30,6 +30,7 @@ use crate::ui::tools::proof_visualizer_screen::ProofVisualizerScreen; use crate::ui::wallets::import_wallet_screen::ImportWalletScreen; use crate::ui::wallets::wallets_screen::WalletsBalancesScreen; use contracts_documents::add_contracts_screen::AddContractsScreen; +use contracts_documents::dashpay_coming_soon_screen::DashPayComingSoonScreen; use contracts_documents::group_actions_screen::GroupActionsScreen; use contracts_documents::register_contract_screen::RegisterDataContractScreen; use contracts_documents::update_contract_screen::UpdateDataContractScreen; @@ -91,6 +92,7 @@ pub enum RootScreenType { RootScreenToolsMasternodeListDiffScreen, RootScreenToolsContractVisualizerScreen, RootScreenToolsPlatformInfoScreen, + RootScreenContractsDashPay, } impl RootScreenType { @@ -116,6 +118,7 @@ impl RootScreenType { RootScreenType::RootScreenToolsContractVisualizerScreen => 16, RootScreenType::RootScreenToolsPlatformInfoScreen => 17, RootScreenType::RootScreenToolsMasternodeListDiffScreen => 18, + RootScreenType::RootScreenContractsDashPay => 19, } } @@ -141,6 +144,7 @@ impl RootScreenType { 16 => Some(RootScreenType::RootScreenToolsContractVisualizerScreen), 17 => Some(RootScreenType::RootScreenToolsPlatformInfoScreen), 18 => Some(RootScreenType::RootScreenToolsMasternodeListDiffScreen), + 19 => Some(RootScreenType::RootScreenContractsDashPay), _ => None, } } @@ -175,6 +179,7 @@ impl From for ScreenType { ScreenType::ContractsVisualizer } RootScreenType::RootScreenToolsPlatformInfoScreen => ScreenType::PlatformInfo, + RootScreenType::RootScreenContractsDashPay => ScreenType::ContractsDashPayComingSoon, } } } @@ -215,6 +220,7 @@ pub enum ScreenType { DocumentsVisualizer, ContractsVisualizer, PlatformInfo, + ContractsDashPayComingSoon, CreateDocument, DeleteDocument, ReplaceDocument, @@ -331,6 +337,9 @@ impl ScreenType { ScreenType::PlatformInfo => { Screen::PlatformInfoScreen(PlatformInfoScreen::new(app_context)) } + ScreenType::ContractsDashPayComingSoon => { + Screen::DashPayComingSoonScreen(DashPayComingSoonScreen::new(app_context)) + } ScreenType::CreateDocument => Screen::DocumentActionScreen(DocumentActionScreen::new( app_context.clone(), None, @@ -432,6 +441,7 @@ pub enum Screen { IdentitiesScreen(IdentitiesScreen), DPNSScreen(DPNSScreen), DocumentQueryScreen(DocumentQueryScreen), + DashPayComingSoonScreen(DashPayComingSoonScreen), AddNewWalletScreen(AddNewWalletScreen), ImportWalletScreen(ImportWalletScreen), AddNewIdentityScreen(AddNewIdentityScreen), @@ -481,6 +491,7 @@ impl Screen { match self { Screen::IdentitiesScreen(screen) => screen.app_context = app_context, Screen::DPNSScreen(screen) => screen.app_context = app_context, + Screen::DashPayComingSoonScreen(screen) => screen.app_context = app_context, Screen::AddExistingIdentityScreen(screen) => screen.app_context = app_context, Screen::KeyInfoScreen(screen) => screen.app_context = app_context, Screen::KeysScreen(screen) => screen.app_context = app_context, @@ -599,6 +610,7 @@ impl Screen { dpns_subscreen: DPNSSubscreen::ScheduledVotes, .. }) => ScreenType::ScheduledVotes, + Screen::DashPayComingSoonScreen(_) => ScreenType::ContractsDashPayComingSoon, Screen::TransitionVisualizerScreen(_) => ScreenType::TransitionVisualizer, Screen::ContractVisualizerScreen(_) => ScreenType::ContractsVisualizer, Screen::WithdrawalScreen(screen) => { @@ -704,6 +716,7 @@ impl ScreenLike for Screen { Screen::IdentitiesScreen(screen) => screen.refresh(), Screen::DPNSScreen(screen) => screen.refresh(), Screen::DocumentQueryScreen(screen) => screen.refresh(), + Screen::DashPayComingSoonScreen(screen) => screen.refresh(), Screen::AddNewWalletScreen(screen) => screen.refresh(), Screen::ImportWalletScreen(screen) => screen.refresh(), Screen::AddNewIdentityScreen(screen) => screen.refresh(), @@ -754,6 +767,7 @@ impl ScreenLike for Screen { Screen::IdentitiesScreen(screen) => screen.refresh_on_arrival(), Screen::DPNSScreen(screen) => screen.refresh_on_arrival(), Screen::DocumentQueryScreen(screen) => screen.refresh_on_arrival(), + Screen::DashPayComingSoonScreen(screen) => screen.refresh_on_arrival(), Screen::AddNewWalletScreen(screen) => screen.refresh_on_arrival(), Screen::ImportWalletScreen(screen) => screen.refresh_on_arrival(), Screen::AddNewIdentityScreen(screen) => screen.refresh_on_arrival(), @@ -804,6 +818,7 @@ impl ScreenLike for Screen { Screen::IdentitiesScreen(screen) => screen.ui(ctx), Screen::DPNSScreen(screen) => screen.ui(ctx), Screen::DocumentQueryScreen(screen) => screen.ui(ctx), + Screen::DashPayComingSoonScreen(screen) => screen.ui(ctx), Screen::AddNewWalletScreen(screen) => screen.ui(ctx), Screen::ImportWalletScreen(screen) => screen.ui(ctx), Screen::AddNewIdentityScreen(screen) => screen.ui(ctx), @@ -854,6 +869,9 @@ impl ScreenLike for Screen { Screen::IdentitiesScreen(screen) => screen.display_message(message, message_type), Screen::DPNSScreen(screen) => screen.display_message(message, message_type), Screen::DocumentQueryScreen(screen) => screen.display_message(message, message_type), + Screen::DashPayComingSoonScreen(screen) => { + screen.display_message(message, message_type) + } Screen::AddNewWalletScreen(screen) => screen.display_message(message, message_type), Screen::ImportWalletScreen(screen) => screen.display_message(message, message_type), Screen::AddNewIdentityScreen(screen) => screen.display_message(message, message_type), @@ -926,6 +944,9 @@ impl ScreenLike for Screen { Screen::DocumentQueryScreen(screen) => { screen.display_task_result(backend_task_success_result) } + Screen::DashPayComingSoonScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } Screen::AddNewWalletScreen(screen) => { screen.display_task_result(backend_task_success_result) } @@ -1048,6 +1069,7 @@ impl ScreenLike for Screen { Screen::IdentitiesScreen(screen) => screen.pop_on_success(), Screen::DPNSScreen(screen) => screen.pop_on_success(), Screen::DocumentQueryScreen(screen) => screen.pop_on_success(), + Screen::DashPayComingSoonScreen(screen) => screen.pop_on_success(), Screen::AddNewWalletScreen(screen) => screen.pop_on_success(), Screen::ImportWalletScreen(screen) => screen.pop_on_success(), Screen::AddNewIdentityScreen(screen) => screen.pop_on_success(), diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 6365f9554..309a72abf 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -731,7 +731,7 @@ impl ScreenLike for NetworkChooserScreen { action |= island_central_panel(ctx, |ui| { egui::ScrollArea::vertical() - .auto_shrink([false; 2]) + .auto_shrink([true; 2]) .show(ui, |ui| self.render_network_table(ui)) .inner }); diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index b9ef9b18f..30db7e648 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -848,7 +848,7 @@ impl ScreenLike for WalletsBalancesScreen { } egui::ScrollArea::vertical() - .auto_shrink([false; 2]) + .auto_shrink([true; 2]) .show(ui, |ui| { if self.app_context.wallets.read().unwrap().is_empty() { self.render_no_wallets_view(ui); From e4c1db6d29580e0505064357319666ea238c3a15 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Mon, 6 Oct 2025 16:00:02 +0700 Subject: [PATCH 021/106] feat: clarify identity index description for identity creation --- src/ui/identities/add_new_identity_screen/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index fbb73777b..87d8b2f0e 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -1002,12 +1002,12 @@ impl ScreenLike for AddNewIdentityScreen { let wallet = wallet_guard.read().unwrap(); if wallet.identities.is_empty() { ui.heading(format!( - "{}. Choose an identity index. Leave this 0 if this is your first identity for this wallet.", + "{}. Choose an identity index for the wallet. Leaving this 0 is recommended.", step_number )); } else { ui.heading(format!( - "{}. Choose an identity index. Leaving this {} is recommended.", + "{}. Choose an identity index for the wallet. Leaving this {} is recommended.", step_number, self.next_identity_id(), )); From e45631af5566eb16c6d154dd8912f0e70bb0b6e8 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Wed, 8 Oct 2025 14:11:47 +0700 Subject: [PATCH 022/106] fix: screen button labels display hard to see in dark mode --- src/ui/components/left_panel.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/components/left_panel.rs b/src/ui/components/left_panel.rs index 4f5a4be2b..62e5806f8 100644 --- a/src/ui/components/left_panel.rs +++ b/src/ui/components/left_panel.rs @@ -138,7 +138,7 @@ pub fn add_left_panel( let color = if is_selected { DashColors::DASH_BLUE } else { - DashColors::DEEP_BLUE + DashColors::text_primary(dark_mode) }; let label_text = RichText::new(*label).color(color).size(13.0); ui.label(label_text); From 39625c5286a9b252ae74556c4a4ff35aeded144a Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Fri, 17 Oct 2025 13:11:32 +0700 Subject: [PATCH 023/106] feat: left panel scroll (#443) * feat: left panel scroll * clippy --- src/ui/components/left_panel.rs | 280 ++++++++++++++++++-------------- 1 file changed, 162 insertions(+), 118 deletions(-) diff --git a/src/ui/components/left_panel.rs b/src/ui/components/left_panel.rs index 62e5806f8..7e084c0a4 100644 --- a/src/ui/components/left_panel.rs +++ b/src/ui/components/left_panel.rs @@ -6,6 +6,7 @@ use crate::ui::theme::{DashColors, Shadow, Shape, Spacing}; use dash_sdk::dashcore_rpc::dashcore::Network; use eframe::epaint::Margin; use egui::{Color32, Context, Frame, ImageButton, RichText, SidePanel, TextureHandle}; +use egui_extras::{Size, StripBuilder}; use rust_embed::RustEmbed; use std::sync::Arc; @@ -109,127 +110,170 @@ pub fn add_left_panel( .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) .shadow(Shadow::elevated()) .show(ui, |ui| { - ui.vertical_centered(|ui| { - for (label, screen_type, icon_path) in buttons.iter() { - let texture: Option = load_icon(ctx, icon_path); - let is_selected = selected_screen == *screen_type; - - let button_color = if is_selected { - Color32::WHITE // Bright white for selected - } else if dark_mode { - Color32::from_rgb(180, 180, 180) // Bright gray for visibility in dark mode - } else { - Color32::from_rgb(160, 160, 160) // Medium gray for contrast in light mode - }; - - // Add icon-based button if texture is loaded - if let Some(ref texture) = texture { - let button = - ImageButton::new(texture).frame(false).tint(button_color); - - let added = ui.add(button); - if added.clicked() { - action = - AppAction::SetMainScreenThenGoToMainScreen(*screen_type); - } else if added.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - // Put the label beneath the icon - let color = if is_selected { - DashColors::DASH_BLUE - } else { - DashColors::text_primary(dark_mode) - }; - let label_text = RichText::new(*label).color(color).size(13.0); - ui.label(label_text); - } else { - // Fallback to a modern gradient button if texture loading fails - if is_selected { - if GradientButton::new(*label, app_context) - .min_width(60.0) - .glow() - .show(ui) - .clicked() - { - action = AppAction::SetMainScreen(*screen_type); - } - } else { - let button = egui::Button::new(*label) - .fill(DashColors::glass_white(dark_mode)) - .stroke(egui::Stroke::new( - 1.0, - DashColors::glass_border(dark_mode), - )) - .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) - .min_size(egui::vec2(60.0, 60.0)); - - if ui.add(button).clicked() { - action = AppAction::SetMainScreen(*screen_type); - } - } - } - - ui.add_space(Spacing::MD); // Add some space between buttons - } - - // Push content to the top and dev label + logo to the bottom - ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| { - if app_context.is_developer_mode() { - ui.add_space(Spacing::MD); - let dev_label = egui::RichText::new("🔧 Dev mode") - .color(DashColors::GRADIENT_PURPLE) - .size(12.0); - if ui.label(dev_label).clicked() { - action = AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenNetworkChooser, - ); - }; - } - - // Show network name if not on main Dash network - if app_context.network != Network::Dash { - let (network_name, network_color) = match app_context.network { - Network::Testnet => ("Testnet", Color32::from_rgb(255, 165, 0)), - Network::Devnet => ("Devnet", Color32::DARK_RED), - Network::Regtest => { - ("Local Network", Color32::from_rgb(139, 69, 19)) - } - _ => ("Unknown", DashColors::DASH_BLUE), - }; - - ui.label( - RichText::new(network_name) - .color(network_color) - .size(12.0) - .strong(), - ); - ui.add_space(2.0); - } - - // Add Dash logo at the bottom - if let Some(dash_texture) = load_icon(ctx, "dash.png") { - if app_context.network == Network::Dash { - ui.add_space(Spacing::SM); - } - let logo_size = egui::vec2(50.0, 20.0); // Even smaller size, same aspect ratio - let logo_response = ui.add( - egui::Image::new(&dash_texture) - .fit_to_exact_size(logo_size) - .texture_options(egui::TextureOptions::LINEAR) // Smooth interpolation to reduce pixelation - .sense(egui::Sense::click()), - ); + // Reserve a fixed area at the bottom for the logo and labels, + // and make the button list above it vertically scrollable. + let mut bottom_reserved = Spacing::SM + 20.0; // spacing + logo height + if app_context.network != Network::Dash { + bottom_reserved += 22.0; // network label + spacing + } + if app_context.is_developer_mode() { + bottom_reserved += Spacing::MD + 16.0; // dev label area + } + + StripBuilder::new(ui) + .size(Size::remainder()) // top: fills remaining height + .size(Size::exact(bottom_reserved.max(40.0))) // bottom: reserved area + .vertical(|mut strip| { + // Top cell: scrollable list of buttons + strip.cell(|ui| { + egui::ScrollArea::vertical() + .id_salt("left_panel_buttons_scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + for (label, screen_type, icon_path) in buttons.iter() { + let texture: Option = load_icon(ctx, icon_path); + let is_selected = selected_screen == *screen_type; + + let button_color = if is_selected { + Color32::WHITE + } else if dark_mode { + Color32::from_rgb(180, 180, 180) + } else { + Color32::from_rgb(160, 160, 160) + }; + + if let Some(ref texture) = texture { + let button = ImageButton::new(texture) + .frame(false) + .tint(button_color); + + let added = ui.add(button); + if added.clicked() { + action = AppAction::SetMainScreenThenGoToMainScreen( + *screen_type, + ); + } else if added.hovered() { + ui.ctx().set_cursor_icon( + egui::CursorIcon::PointingHand, + ); + } + // Put the label beneath the icon + let color = if is_selected { + DashColors::DASH_BLUE + } else { + DashColors::text_primary(dark_mode) + }; + let label_text = + RichText::new(*label).color(color).size(13.0); + ui.label(label_text); + } else { + // Fallback button if texture not available + if is_selected { + if GradientButton::new(*label, app_context) + .min_width(60.0) + .glow() + .show(ui) + .clicked() + { + action = AppAction::SetMainScreen(*screen_type); + } + } else { + let button = egui::Button::new(*label) + .fill(DashColors::glass_white(dark_mode)) + .stroke(egui::Stroke::new( + 1.0, + DashColors::glass_border(dark_mode), + )) + .corner_radius(egui::CornerRadius::same( + Shape::RADIUS_MD, + )) + .min_size(egui::vec2(60.0, 60.0)); + + if ui.add(button).clicked() { + action = AppAction::SetMainScreen(*screen_type); + } + } + } - if logo_response.clicked() { - ui.ctx() - .open_url(egui::OpenUrl::new_tab("https://dash.org")); - } + ui.add_space(Spacing::MD); + } + }); + }); + }); - if logo_response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } - } + // Bottom cell: always visible logo and labels + strip.cell(|ui| { + ui.with_layout( + egui::Layout::bottom_up(egui::Align::Center), + |ui| { + // Dash logo at the very bottom + if let Some(dash_texture) = load_icon(ctx, "dash.png") { + if app_context.network == Network::Dash { + ui.add_space(Spacing::SM); + } + let logo_size = egui::vec2(50.0, 20.0); + let logo_response = ui.add( + egui::Image::new(&dash_texture) + .fit_to_exact_size(logo_size) + .texture_options(egui::TextureOptions::LINEAR) + .sense(egui::Sense::click()), + ); + + if logo_response.clicked() { + ui.ctx() + .open_url(egui::OpenUrl::new_tab("https://dash.org")); + } + + if logo_response.hovered() { + ui.ctx() + .set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + + // Network label (if not on mainnet) + if app_context.network != Network::Dash { + let (network_name, network_color) = match app_context.network { + Network::Testnet => ( + "Testnet", + Color32::from_rgb(255, 165, 0), + ), + Network::Devnet => ( + "Devnet", + Color32::DARK_RED, + ), + Network::Regtest => ( + "Local Network", + Color32::from_rgb(139, 69, 19), + ), + _ => ("Unknown", DashColors::DASH_BLUE), + }; + + ui.add_space(2.0); + ui.label( + RichText::new(network_name) + .color(network_color) + .size(12.0) + .strong(), + ); + } + + // Dev mode label (above network label if present) + if app_context.is_developer_mode() { + ui.add_space(Spacing::MD); + let dev_label = egui::RichText::new("🔧 Dev mode") + .color(DashColors::GRADIENT_PURPLE) + .size(12.0); + if ui.label(dev_label).clicked() { + action = AppAction::SetMainScreenThenGoToMainScreen( + RootScreenType::RootScreenNetworkChooser, + ); + } + } + }, + ); + }); }); - }); }); // Close the island frame }); From 56a54f1be07bf79916c3eadeb866e9f231d623b0 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Fri, 17 Oct 2025 15:40:02 +0700 Subject: [PATCH 024/106] feat: add existing identity by wallet + identity index (#444) * feat: add existing identity by wallet + identity index * fmt * remove logging * update derivation index label * add robustness * fix: screen button labels display hard to see in dark mode * feat: left panel scroll (#443) * feat: left panel scroll * clippy * feat: load all identities up to an index * clippy * fix --- .../identity/load_identity_from_wallet.rs | 295 +++++++++++++++--- src/backend_task/identity/mod.rs | 7 +- .../add_existing_identity_screen.rs | 164 +++++++++- 3 files changed, 412 insertions(+), 54 deletions(-) diff --git a/src/backend_task/identity/load_identity_from_wallet.rs b/src/backend_task/identity/load_identity_from_wallet.rs index a3c097d6e..4d1b589c4 100644 --- a/src/backend_task/identity/load_identity_from_wallet.rs +++ b/src/backend_task/identity/load_identity_from_wallet.rs @@ -1,4 +1,5 @@ use super::{BackendTaskSuccessResult, IdentityIndex}; +use crate::app::TaskResult; use crate::context::AppContext; use crate::model::qualified_identity::encrypted_key_storage::{ PrivateKeyData, WalletDerivationPath, @@ -9,15 +10,15 @@ use crate::model::qualified_identity::{ }; use crate::model::wallet::WalletArcRef; use dash_sdk::Sdk; -use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::document::DocumentV0Getters; +use dash_sdk::dpp::identity::KeyType; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::hash::IdentityPublicKeyHashMethodsV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dash_sdk::dpp::identity::{KeyID, KeyType}; use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, KeyDerivationType}; use dash_sdk::dpp::platform_value::Value; use dash_sdk::drive::query::{WhereClause, WhereOperator}; -use dash_sdk::platform::types::identity::PublicKeyHash; +use dash_sdk::platform::types::identity::NonUniquePublicKeyHashQuery; use dash_sdk::platform::{Document, DocumentQuery, Fetch, FetchMany, Identity}; use std::collections::BTreeMap; @@ -27,19 +28,83 @@ impl AppContext { sdk: &Sdk, wallet_arc_ref: WalletArcRef, identity_index: IdentityIndex, + sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { - let public_key = { - let wallet = wallet_arc_ref.wallet.write().unwrap(); - wallet.identity_authentication_ecdsa_public_key(self.network, identity_index, 0)? - }; + const AUTH_KEY_LOOKUP_WINDOW: u32 = 12; + + let mut fetched_identity: Option = None; + let mut queried_public_key = None; + let mut queried_wallet_key_index = None; + + for key_index in 0..AUTH_KEY_LOOKUP_WINDOW { + let public_key = { + let wallet = wallet_arc_ref.wallet.write().unwrap(); + wallet.identity_authentication_ecdsa_public_key( + self.network, + identity_index, + key_index, + )? + }; - let Some(identity) = - Identity::fetch(sdk, PublicKeyHash(public_key.pubkey_hash().to_byte_array())) + let key_hash = public_key.pubkey_hash().into(); + let query = NonUniquePublicKeyHashQuery { + key_hash, + after: None, + }; + + sender + .send(TaskResult::Success(Box::new( + BackendTaskSuccessResult::Message(format!( + "Searching for identity using key at index {}...", + key_index + )), + ))) .await - .map_err(|e| e.to_string())? - else { - return Ok(BackendTaskSuccessResult::None); + .map_err(|e| e.to_string())?; + match Identity::fetch(sdk, query).await { + Ok(Some(identity)) => { + fetched_identity = Some(identity); + queried_public_key = Some(public_key); + queried_wallet_key_index = Some(key_index); + break; + } + Ok(None) => continue, + Err(e) => return Err(e.to_string()), + } + } + + let identity = match fetched_identity { + Some(identity) => identity, + None => { + return Err(format!( + "No identity found for wallet identity index {} within the first {} derived authentication keys", + identity_index, AUTH_KEY_LOOKUP_WINDOW + )); + } + }; + + let queried_public_key = + queried_public_key.expect("queried public key should exist when identity is fetched"); + let queried_wallet_key_index = queried_wallet_key_index + .expect("wallet key index should exist when identity is fetched"); + + let queried_key_hash: [u8; 20] = queried_public_key.pubkey_hash().into(); + let matching_identity_key = identity.public_keys().values().find(|key| { + key.public_key_hash() + .ok() + .map(|hash| hash == queried_key_hash) + .unwrap_or(false) + }); + + let matching_identity_key = match matching_identity_key { + Some(key) => key, + None => { + return Err( + "Fetched identity does not contain the queried authentication key".to_string(), + ); + } }; + let matching_identity_key_id = matching_identity_key.id(); let identity_id = identity.id(); @@ -91,7 +156,16 @@ impl AppContext { }) .map_err(|e| format!("Error fetching DPNS names: {}", e))?; - let top_bound = identity.public_keys().len() as u32 + 5; + let highest_identity_key_id = identity + .public_keys() + .keys() + .copied() + .max() + .unwrap_or(matching_identity_key_id); + + let mut top_bound = highest_identity_key_id.saturating_add(1); + top_bound = top_bound.max(queried_wallet_key_index.saturating_add(1)); + top_bound = top_bound.saturating_add(5); let wallet_seed_hash; let (public_key_result_map, public_key_hash_result_map) = { @@ -105,46 +179,85 @@ impl AppContext { )? }; - let private_keys = identity.public_keys().values().filter_map(|public_key| { - let index: u32 = match public_key.key_type() { - KeyType::ECDSA_SECP256K1 => { - public_key_result_map.get(public_key.data().as_slice()).cloned() - } - KeyType::ECDSA_HASH160 => { - let hash: [u8;20] = public_key.data().as_slice().try_into().ok()?; - public_key_hash_result_map.get(&hash).cloned() - } - _ => None, - }?; - let derivation_path = DerivationPath::identity_authentication_path( - self.network, - KeyDerivationType::ECDSA, - identity_index, - index, + let private_keys_map = identity + .public_keys() + .values() + .filter_map(|public_key| { + let index: u32 = match public_key.key_type() { + KeyType::ECDSA_SECP256K1 => public_key_result_map + .get(public_key.data().as_slice()) + .cloned(), + KeyType::ECDSA_HASH160 => { + let hash: [u8; 20] = public_key.data().as_slice().try_into().ok()?; + public_key_hash_result_map.get(&hash).cloned() + } + _ => None, + }?; + let derivation_path = DerivationPath::identity_authentication_path( + self.network, + KeyDerivationType::ECDSA, + identity_index, + index, + ); + let wallet_derivation_path = WalletDerivationPath { + wallet_seed_hash, + derivation_path, + }; + Some(( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, public_key.id()), + ( + QualifiedIdentityPublicKey { + identity_public_key: public_key.clone(), + in_wallet_at_derivation_path: Some(wallet_derivation_path.clone()), + }, + PrivateKeyData::AtWalletDerivationPath(wallet_derivation_path), + ), + )) + }) + .collect::>(); + + if private_keys_map.is_empty() { + return Err("Could not match any identity keys to wallet derivation paths".to_string()); + } + + if !private_keys_map.contains_key(&( + PrivateKeyTarget::PrivateKeyOnMainIdentity, + matching_identity_key_id, + )) { + return Err( + "Unable to locate wallet derivation path for the queried identity key".to_string(), ); - let wallet_derivation_path = WalletDerivationPath { wallet_seed_hash, derivation_path}; - Some(((PrivateKeyTarget::PrivateKeyOnMainIdentity, public_key.id()), (QualifiedIdentityPublicKey { identity_public_key: public_key.clone(), in_wallet_at_derivation_path: Some(wallet_derivation_path.clone()) }, PrivateKeyData::AtWalletDerivationPath(wallet_derivation_path)))) - }).collect::>().into(); + } + + let private_keys = private_keys_map.into(); - let qualified_identity = QualifiedIdentity { - identity, + let wallet_seed_hash = wallet_arc_ref.wallet.read().unwrap().seed_hash(); + + let mut qualified_identity = QualifiedIdentity { + identity: identity.clone(), associated_voter_identity: None, associated_operator_identity: None, associated_owner_key_id: None, identity_type: IdentityType::User, alias: None, - private_keys, - dpns_names: maybe_owned_dpns_names, - associated_wallets: BTreeMap::from([( - wallet_arc_ref.wallet.read().unwrap().seed_hash(), - wallet_arc_ref.wallet.clone(), - )]), - wallet_index: Some(identity_index), + private_keys: Default::default(), + dpns_names: Vec::new(), + associated_wallets: BTreeMap::new(), + wallet_index: None, top_ups: Default::default(), status: IdentityStatus::Active, network: self.network, }; + qualified_identity.identity = identity; + qualified_identity.private_keys = private_keys; + qualified_identity.dpns_names = maybe_owned_dpns_names; + qualified_identity.associated_wallets = + BTreeMap::from([(wallet_seed_hash, wallet_arc_ref.wallet.clone())]); + qualified_identity.wallet_index = Some(identity_index); + qualified_identity.status = IdentityStatus::Active; + qualified_identity.network = self.network; + // Insert qualified identity into the database self.insert_local_qualified_identity( &qualified_identity, @@ -152,8 +265,108 @@ impl AppContext { ) .map_err(|e| format!("Database error: {}", e))?; + { + let mut wallet = wallet_arc_ref.wallet.write().unwrap(); + wallet + .identities + .insert(identity_index, qualified_identity.identity.clone()); + } + Ok(BackendTaskSuccessResult::Message( "Successfully loaded identity".to_string(), )) } + + pub(super) async fn load_user_identities_up_to_index( + &self, + sdk: &Sdk, + wallet_arc_ref: WalletArcRef, + max_identity_index: IdentityIndex, + sender: crate::utils::egui_mpsc::SenderAsync, + ) -> Result { + let wallet_ref = wallet_arc_ref; + + let mut loaded_indices = Vec::new(); + let mut missing_indices = Vec::new(); + + for identity_index in 0..=max_identity_index { + match self + .load_user_identity_from_wallet( + sdk, + wallet_ref.clone(), + identity_index, + sender.clone(), + ) + .await + { + Ok(_) => { + loaded_indices.push(identity_index); + sender + .send(TaskResult::Success(Box::new( + BackendTaskSuccessResult::Message(format!( + "Loaded identity at index {}.", + identity_index + )), + ))) + .await + .map_err(|e| e.to_string())?; + } + Err(error) => { + if error.starts_with("No identity found for wallet identity index") { + missing_indices.push(identity_index); + sender + .send(TaskResult::Success(Box::new( + BackendTaskSuccessResult::Message(format!( + "No identity found at index {}.", + identity_index + )), + ))) + .await + .map_err(|e| e.to_string())?; + } else { + return Err(error); + } + } + } + } + + if loaded_indices.is_empty() { + return Err(format!( + "No identities found up to index {}.", + max_identity_index + )); + } + + let summary = if missing_indices.is_empty() { + format!( + "Successfully loaded {} identit{} up to index {}.", + loaded_indices.len(), + if loaded_indices.len() == 1 { + "y" + } else { + "ies" + }, + max_identity_index + ) + } else { + let missing_display = missing_indices + .iter() + .map(|idx| idx.to_string()) + .collect::>() + .join(", "); + format!( + "Finished loading identities up to index {}. Loaded {} identit{}; no identity found at index(es): {}.", + max_identity_index, + loaded_indices.len(), + if loaded_indices.len() == 1 { + "y" + } else { + "ies" + }, + missing_display + ) + }; + + Ok(BackendTaskSuccessResult::Message(summary)) + } } diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index e1b50b5d2..c3d98eea9 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -249,6 +249,7 @@ pub enum IdentityTask { LoadIdentity(IdentityInputToLoad), #[allow(dead_code)] // May be used for finding identities in wallets SearchIdentityFromWallet(WalletArcRef, IdentityIndex), + SearchIdentitiesUpToIndex(WalletArcRef, IdentityIndex), RegisterIdentity(IdentityRegistrationInfo), TopUpIdentity(IdentityTopUpInfo), AddKeyToIdentity(QualifiedIdentity, QualifiedIdentityPublicKey, [u8; 32]), @@ -464,7 +465,11 @@ impl AppContext { .await } IdentityTask::SearchIdentityFromWallet(wallet, identity_index) => { - self.load_user_identity_from_wallet(sdk, wallet, identity_index) + self.load_user_identity_from_wallet(sdk, wallet, identity_index, sender) + .await + } + IdentityTask::SearchIdentitiesUpToIndex(wallet, max_identity_index) => { + self.load_user_identities_up_to_index(sdk, wallet, max_identity_index, sender) .await } IdentityTask::TopUpIdentity(top_up_info) => { diff --git a/src/ui/identities/add_existing_identity_screen.rs b/src/ui/identities/add_existing_identity_screen.rs index 4b34b8947..8348fcf8f 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -54,6 +54,18 @@ fn load_testnet_nodes_from_yml(file_path: &str) -> Option { serde_yaml::from_str(&file_content).expect("expected proper yaml") } +#[derive(Clone, Copy, PartialEq, Eq)] +enum LoadIdentityMode { + ByIdentityId, + ByWallet, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum WalletIdentitySearchMode { + SpecificIndex, + UpToIndex, +} + #[derive(PartialEq)] pub enum AddIdentityStatus { NotStarted, @@ -79,6 +91,10 @@ pub struct AddExistingIdentityScreen { pub identity_index_input: String, pub app_context: Arc, show_pop_up_info: Option, + mode: LoadIdentityMode, + backend_message: Option, + wallet_search_mode: WalletIdentitySearchMode, + success_message: Option, } impl AddExistingIdentityScreen { @@ -106,6 +122,10 @@ impl AddExistingIdentityScreen { identity_index_input: String::new(), app_context: app_context.clone(), show_pop_up_info: None, + mode: LoadIdentityMode::ByIdentityId, + backend_message: None, + wallet_search_mode: WalletIdentitySearchMode::SpecificIndex, + success_message: None, } } @@ -254,7 +274,7 @@ impl AddExistingIdentityScreen { action } - fn _render_wallet_selection(&mut self, ui: &mut Ui) { + fn render_wallet_selection(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { if self.app_context.has_wallet.load(Ordering::Relaxed) { let wallets = &self.app_context.wallets.read().unwrap(); @@ -305,15 +325,24 @@ impl AddExistingIdentityScreen { }); } - fn _render_from_wallet(&mut self, ui: &mut egui::Ui, wallets_len: usize) -> AppAction { + fn render_by_wallet(&mut self, ui: &mut egui::Ui, wallets_len: usize) -> AppAction { let mut action = AppAction::None; + if wallets_len == 0 { + ui.colored_label( + Color32::GRAY, + "No wallets available. Import or create a wallet to search by derivation path.", + ); + return action; + } + // Wallet selection if wallets_len > 1 { - self._render_wallet_selection(ui); + self.render_wallet_selection(ui); } if self.selected_wallet.is_none() { + ui.label("Select a wallet to search for linked identities."); return action; }; @@ -323,26 +352,79 @@ impl AddExistingIdentityScreen { return action; } - // Identity index input + let mut wallet_mode_changed = false; ui.horizontal(|ui| { - ui.label("Identity Index:"); + ui.label("Search type:"); + wallet_mode_changed |= ui + .selectable_value( + &mut self.wallet_search_mode, + WalletIdentitySearchMode::SpecificIndex, + "Specific index", + ) + .changed(); + wallet_mode_changed |= ui + .selectable_value( + &mut self.wallet_search_mode, + WalletIdentitySearchMode::UpToIndex, + "All up to index", + ) + .changed(); + }); + if wallet_mode_changed { + self.add_identity_status = AddIdentityStatus::NotStarted; + self.error_message = None; + self.backend_message = None; + self.success_message = None; + } + ui.add_space(6.0); + + let identity_index_label = match self.wallet_search_mode { + WalletIdentitySearchMode::SpecificIndex => "Identity index:", + WalletIdentitySearchMode::UpToIndex => "Highest identity index to search (inclusive):", + }; + + ui.horizontal(|ui| { + ui.label(identity_index_label); ui.text_edit_singleline(&mut self.identity_index_input); }); - if ui.button("Search For Identity").clicked() { + match self.wallet_search_mode { + WalletIdentitySearchMode::SpecificIndex => { + ui.label("This is the derivation index used when the identity was created."); + } + WalletIdentitySearchMode::UpToIndex => { + ui.label( + "Searches each derivation index starting at 0 up to the provided index (inclusive).", + ); + } + } + + let button_label = match self.wallet_search_mode { + WalletIdentitySearchMode::SpecificIndex => "Search For Identity", + WalletIdentitySearchMode::UpToIndex => "Load Identities", + }; + + if ui.button(button_label).clicked() { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Time went backwards") .as_secs(); self.add_identity_status = AddIdentityStatus::WaitingForResult(now); + self.backend_message = None; + self.success_message = None; // Parse identity index input if let Ok(identity_index) = self.identity_index_input.trim().parse::() { + let wallet_ref = self.selected_wallet.as_ref().unwrap().clone().into(); action = AppAction::BackendTask(BackendTask::IdentityTask( - IdentityTask::SearchIdentityFromWallet( - self.selected_wallet.as_ref().unwrap().clone().into(), - identity_index, - ), + match self.wallet_search_mode { + WalletIdentitySearchMode::SpecificIndex => { + IdentityTask::SearchIdentityFromWallet(wallet_ref, identity_index) + } + WalletIdentitySearchMode::UpToIndex => { + IdentityTask::SearchIdentitiesUpToIndex(wallet_ref, identity_index) + } + }, )); } else { // Handle invalid index input (optional) @@ -411,7 +493,11 @@ impl AddExistingIdentityScreen { ui.add_space(50.0); ui.heading("🎉"); - ui.heading("Successfully loaded identity."); + let success_text = self + .success_message + .clone() + .unwrap_or_else(|| "Successfully loaded identity.".to_string()); + ui.label(RichText::new(success_text)); ui.add_space(20.0); @@ -426,6 +512,8 @@ impl AddExistingIdentityScreen { self.error_message = None; self.show_pop_up_info = None; self.add_identity_status = AddIdentityStatus::NotStarted; + self.backend_message = None; + self.success_message = None; } ui.add_space(5.0); @@ -474,7 +562,18 @@ impl ScreenLike for AddExistingIdentityScreen { match message_type { MessageType::Success => { if message == "Successfully loaded identity" { + self.success_message = Some("Successfully loaded identity.".to_string()); self.add_identity_status = AddIdentityStatus::Complete; + self.backend_message = None; + } else if (message.starts_with("Successfully loaded ") + && message.contains(" up to index ")) + || message.starts_with("Finished loading identities up to index ") + { + self.success_message = Some(message.to_string()); + self.add_identity_status = AddIdentityStatus::Complete; + self.backend_message = None; + } else { + self.backend_message = Some(message.to_string()); } } MessageType::Info => {} @@ -520,7 +619,44 @@ impl ScreenLike for AddExistingIdentityScreen { return; } - inner_action |= self.render_by_identity(ui); + let mut mode_changed = false; + ui.horizontal(|ui| { + mode_changed |= ui + .selectable_value( + &mut self.mode, + LoadIdentityMode::ByIdentityId, + "By Identity", + ) + .changed(); + mode_changed |= ui + .selectable_value( + &mut self.mode, + LoadIdentityMode::ByWallet, + "By Wallet", + ) + .changed(); + }); + ui.add_space(10.0); + + if mode_changed { + self.add_identity_status = AddIdentityStatus::NotStarted; + self.error_message = None; + self.backend_message = None; + self.success_message = None; + } + + match self.mode { + LoadIdentityMode::ByIdentityId => { + inner_action |= self.render_by_identity(ui); + } + LoadIdentityMode::ByWallet => { + let wallets_len = { + let wallets = self.app_context.wallets.read().unwrap(); + wallets.len() + }; + inner_action |= self.render_by_wallet(ui, wallets_len); + } + } ui.add_space(10.0); @@ -554,6 +690,10 @@ impl ScreenLike for AddExistingIdentityScreen { }; ui.label(format!("Loading... Time taken so far: {}", display_time)); + + if self.backend_message.is_some() { + ui.label(self.backend_message.clone().unwrap().to_string()); + } } AddIdentityStatus::ErrorMessage(msg) => { ui.colored_label(egui::Color32::DARK_RED, format!("Error: {}", msg)); From 36146bdb4951a32399976fb742b6774b2f968eb7 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Fri, 17 Oct 2025 15:54:47 +0700 Subject: [PATCH 025/106] feat: remove wallet (#445) * feat: remove wallet * clippy * Clarify wallet removal warning message Updated warning message for wallet removal to clarify that keys will no longer work unless the wallet is re-imported. --- src/context.rs | 29 ++++++ src/database/wallet.rs | 39 ++++++++ src/ui/wallets/wallets_screen/mod.rs | 128 +++++++++++++++++++++++++-- 3 files changed, 187 insertions(+), 9 deletions(-) diff --git a/src/context.rs b/src/context.rs index 883b57aaa..f8f356e68 100644 --- a/src/context.rs +++ b/src/context.rs @@ -825,6 +825,35 @@ impl AppContext { self.db.remove_token(token_id, self) } + pub fn remove_wallet(&self, seed_hash: &WalletSeedHash) -> Result<(), String> { + { + let wallets = self + .wallets + .read() + .map_err(|_| "Failed to access wallets".to_string())?; + if !wallets.contains_key(seed_hash) { + return Err("Wallet not found".to_string()); + } + } + + self.db + .remove_wallet(seed_hash, &self.network) + .map_err(|e| e.to_string())?; + + let mut wallets = self + .wallets + .write() + .map_err(|_| "Failed to update wallets".to_string())?; + + wallets.remove(seed_hash); + let has_wallet = !wallets.is_empty(); + drop(wallets); + + self.has_wallet.store(has_wallet, Ordering::Relaxed); + + Ok(()) + } + #[allow(dead_code)] // May be used for storing token balances pub fn insert_token_identity_balance( &self, diff --git a/src/database/wallet.rs b/src/database/wallet.rs index 72dbd23c3..1812de9e7 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -68,6 +68,45 @@ impl Database { Ok(()) } + /// Remove a wallet and all associated records from the database. + /// + /// This clears dependent records (addresses, utxos, asset locks, identity links) + /// to keep the database consistent before deleting the wallet itself. + pub fn remove_wallet(&self, seed_hash: &[u8; 32], network: &Network) -> rusqlite::Result<()> { + let network_str = network.to_string(); + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + + let mut address_stmt = + tx.prepare("SELECT address FROM wallet_addresses WHERE seed_hash = ?")?; + let address_rows = + address_stmt.query_map(params![seed_hash], |row| row.get::<_, String>(0))?; + let mut addresses = Vec::new(); + for address in address_rows { + addresses.push(address?); + } + drop(address_stmt); + + for address in addresses { + tx.execute( + "DELETE FROM utxos WHERE address = ? AND network = ?", + params![address, &network_str], + )?; + } + + tx.execute( + "UPDATE identity SET wallet = NULL, wallet_index = NULL WHERE wallet = ? AND network = ?", + params![seed_hash, &network_str], + )?; + + tx.execute( + "DELETE FROM wallet WHERE seed_hash = ? AND network = ?", + params![seed_hash, &network_str], + )?; + + tx.commit() + } + /// Update only the alias and is_main fields of a wallet #[allow(dead_code)] // May be used for batch wallet metadata updates pub fn update_wallet_alias_and_main( diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 30db7e648..bb19a8dbe 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -2,7 +2,9 @@ use crate::app::{AppAction, DesiredAppAction}; use crate::backend_task::BackendTask; use crate::backend_task::core::CoreTask; use crate::context::AppContext; -use crate::model::wallet::Wallet; +use crate::model::wallet::{Wallet, WalletSeedHash}; +use crate::ui::components::component_trait::Component; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; @@ -49,6 +51,9 @@ pub struct WalletsBalancesScreen { wallet_password: String, show_password: bool, error_message: Option, + remove_wallet_dialog: Option, + pending_wallet_removal: Option, + pending_wallet_removal_alias: Option, } pub trait DerivationPathHelpers { @@ -141,6 +146,9 @@ impl WalletsBalancesScreen { wallet_password: String::new(), show_password: false, error_message: None, + remove_wallet_dialog: None, + pending_wallet_removal: None, + pending_wallet_removal_alias: None, } } @@ -565,16 +573,14 @@ impl WalletsBalancesScreen { } fn render_bottom_options(&mut self, ui: &mut Ui) { + let wallet_is_open = self + .selected_wallet + .as_ref() + .is_some_and(|wallet_guard| wallet_guard.read().unwrap().is_open()); + if self.selected_filters.contains("Funds") { ui.add_space(10.0); - // Check if wallet is unlocked - let wallet_is_open = if let Some(wallet_guard) = &self.selected_wallet { - wallet_guard.read().unwrap().is_open() - } else { - false - }; - if wallet_is_open { ui.horizontal(|ui| { if ui @@ -585,10 +591,114 @@ impl WalletsBalancesScreen { } }); } else { - // Show wallet unlock UI + // Show wallet unlock UI for locked wallets when Funds filter is active self.render_wallet_unlock_if_needed(ui); } } + + if self.selected_wallet.is_some() { + ui.add_space(16.0); + let dark_mode = ui.ctx().style().visuals.dark_mode; + + let remove_button = egui::Button::new( + RichText::new("🗑 Remove Wallet") + .color(Color32::WHITE) + .size(14.0), + ) + .min_size(egui::vec2(0.0, 28.0)) + .fill(DashColors::error_color(!dark_mode)) + .stroke(egui::Stroke::NONE) + .corner_radius(4.0); + + if ui.add(remove_button).clicked() + && let Some(selected_wallet) = &self.selected_wallet { + let wallet = selected_wallet.read().unwrap(); + let alias = wallet + .alias + .clone() + .unwrap_or_else(|| "Unnamed Wallet".to_string()); + let seed_hash = wallet.seed_hash(); + drop(wallet); + + self.pending_wallet_removal = Some(seed_hash); + self.pending_wallet_removal_alias = Some(alias.clone()); + + let message = format!( + "Removing wallet \"{}\" will delete its local data, including addresses, balances, and asset locks stored on this device. Identities linked to it will remain but the keys derived from this wallet will no longer work unless the wallet is re-imported. Continue?", + alias + ); + + self.remove_wallet_dialog = Some( + ConfirmationDialog::new("Remove Wallet", message) + .confirm_text(Some("Remove")) + .cancel_text(Some("Cancel")) + .danger_mode(true), + ); + } + + if let Some(dialog) = self.remove_wallet_dialog.as_mut() { + let response = dialog.show(ui); + if let Some(status) = response.inner.dialog_response { + match status { + ConfirmationStatus::Confirmed => { + self.remove_wallet_dialog = None; + if let Some(seed_hash) = self.pending_wallet_removal.take() { + let alias = self + .pending_wallet_removal_alias + .take() + .unwrap_or_else(|| "Unnamed Wallet".to_string()); + self.handle_wallet_removal(seed_hash, alias); + } else { + self.pending_wallet_removal_alias = None; + } + } + ConfirmationStatus::Canceled => { + self.remove_wallet_dialog = None; + self.pending_wallet_removal = None; + self.pending_wallet_removal_alias = None; + } + } + } + } + } + } + + fn handle_wallet_removal(&mut self, seed_hash: WalletSeedHash, alias: String) { + match self.app_context.remove_wallet(&seed_hash) { + Ok(()) => { + let next_wallet = self + .app_context + .wallets + .read() + .ok() + .and_then(|wallets| wallets.values().next().cloned()); + + self.selected_wallet = next_wallet; + + if self.selected_wallet.is_none() { + self.selected_filters.clear(); + self.selected_filters.insert("Funds".to_string()); + } + + self.show_rename_dialog = false; + self.rename_input.clear(); + self.wallet_password.clear(); + self.show_password = false; + self.error_message = None; + self.refreshing = false; + + self.display_message( + &format!("Removed wallet \"{}\" successfully", alias), + MessageType::Success, + ); + } + Err(err) => { + self.display_message( + &format!("Failed to remove wallet: {}", err), + MessageType::Error, + ); + } + } } fn render_wallet_asset_locks(&mut self, ui: &mut Ui) -> AppAction { From b376c8daaa7f854445a183df3149eb3985ea501c Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Fri, 17 Oct 2025 16:03:37 +0700 Subject: [PATCH 026/106] fix: identity creation and top-up via QR code handling (#447) * fix: identity creation handling * cleanup * cleanup * fix identity topup too * remove note --- src/backend_task/identity/mod.rs | 6 +- .../identity/register_identity.rs | 104 --------------- src/backend_task/identity/top_up_identity.rs | 9 -- .../by_using_unused_asset_lock.rs | 18 +-- .../by_using_unused_balance.rs | 26 ++-- .../by_wallet_qr_code.rs | 120 ++++++++++-------- .../identities/add_new_identity_screen/mod.rs | 31 +++-- src/ui/identities/funding_common.rs | 45 +++++++ .../by_using_unused_asset_lock.rs | 2 - .../by_using_unused_balance.rs | 8 +- .../by_wallet_qr_code.rs | 24 +++- .../identities/top_up_identity_screen/mod.rs | 97 +++++++++----- 12 files changed, 235 insertions(+), 255 deletions(-) diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index c3d98eea9..ddf53bd97 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -453,7 +453,7 @@ impl AppContext { .await } IdentityTask::RegisterIdentity(registration_info) => { - self.register_identity(registration_info, sender).await + self.register_identity(registration_info).await } IdentityTask::RegisterDpnsName(input) => self.register_dpns_name(sdk, input).await, IdentityTask::RefreshIdentity(qualified_identity) => self @@ -472,9 +472,7 @@ impl AppContext { self.load_user_identities_up_to_index(sdk, wallet, max_identity_index, sender) .await } - IdentityTask::TopUpIdentity(top_up_info) => { - self.top_up_identity(top_up_info, sender).await - } + IdentityTask::TopUpIdentity(top_up_info) => self.top_up_identity(top_up_info).await, IdentityTask::RefreshLoadedIdentitiesOwnedDPNSNames => { self.refresh_loaded_identities_dpns_names(sender).await } diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index 837460c2f..63f9258a3 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -1,4 +1,3 @@ -use crate::app::TaskResult; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::identity::{IdentityRegistrationInfo, RegisterIdentityFundingMethod}; use crate::context::AppContext; @@ -20,93 +19,9 @@ use std::collections::BTreeMap; use std::time::Duration; impl AppContext { - // pub(crate) async fn broadcast_and_retrieve_asset_lock( - // &self, - // asset_lock_transaction: &Transaction, - // address: &Address, - // ) -> Result { - // // Use the span only for synchronous logging before the first await. - // // tracing::debug_span!( - // // "broadcast_and_retrieve_asset_lock", - // // transaction_id = asset_lock_transaction.txid().to_string(), - // // ) - // // .in_scope(|| { - // // tracing::debug!("Starting asset lock broadcast."); - // // }); - // - // let sdk = &self.sdk; - // - // let block_hash = sdk - // .execute(GetBlockchainStatusRequest {}, RequestSettings::default()) - // .await? - // .chain - // .map(|chain| chain.best_block_hash) - // .ok_or_else(|| dash_sdk::Error::DapiClientError("Missing `chain` field".to_owned()))?; - // - // // tracing::debug!( - // // "Starting the stream from the tip block hash {}", - // // hex::encode(&block_hash) - // // ); - // - // let mut asset_lock_stream = sdk - // .start_instant_send_lock_stream(block_hash, address) - // .await?; - // - // // tracing::debug!("Stream is started."); - // - // let request = BroadcastTransactionRequest { - // transaction: asset_lock_transaction.serialize(), - // allow_high_fees: false, - // bypass_limits: false, - // }; - // - // // tracing::debug!("Broadcasting the transaction."); - // - // match sdk.execute(request, RequestSettings::default()).await { - // Ok(_) => {} - // Err(error) if error.to_string().contains("AlreadyExists") => { - // // tracing::warn!("Transaction already broadcasted."); - // - // let GetTransactionResponse { block_hash, .. } = sdk - // .execute( - // GetTransactionRequest { - // id: asset_lock_transaction.txid().to_string(), - // }, - // RequestSettings::default(), - // ) - // .await?; - // - // // tracing::debug!( - // // "Restarting the stream from the transaction mined block hash {}", - // // hex::encode(&block_hash) - // // ); - // - // asset_lock_stream = sdk - // .start_instant_send_lock_stream(block_hash, address) - // .await?; - // - // // tracing::debug!("Stream restarted."); - // } - // Err(error) => { - // // tracing::error!("Transaction broadcast failed: {error}"); - // return Err(error.into()); - // } - // } - // - // // tracing::debug!("Waiting for asset lock proof."); - // - // sdk.wait_for_asset_lock_proof_for_transaction( - // asset_lock_stream, - // asset_lock_transaction, - // Some(Duration::from_secs(4 * 60)), - // ) - // .await - // } - pub(super) async fn register_identity( &self, input: IdentityRegistrationInfo, - sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { let IdentityRegistrationInfo { alias_input, @@ -203,12 +118,6 @@ impl AppContext { }; let tx_id = asset_lock_transaction.txid(); - // todo: maybe one day we will want to use platform again, but for right now we use - // the local core as it is more stable - // let asset_lock_proof = self - // .broadcast_and_retrieve_asset_lock(&asset_lock_transaction, &change_address) - // .await - // .map_err(|e| e.to_string())?; { let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); @@ -270,12 +179,6 @@ impl AppContext { }; let tx_id = asset_lock_transaction.txid(); - // todo: maybe one day we will want to use platform again, but for right now we use - // the local core as it is more stable - // let asset_lock_proof = self - // .broadcast_and_retrieve_asset_lock(&asset_lock_transaction, &change_address) - // .await - // .map_err(|e| e.to_string())?; { let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); @@ -414,13 +317,6 @@ impl AppContext { .set_asset_lock_identity_id(tx_id.as_byte_array(), identity_id.as_bytes()) .map_err(|e| e.to_string())?; - sender - .send(TaskResult::Success(Box::new( - BackendTaskSuccessResult::None, - ))) - .await - .map_err(|e| e.to_string())?; - Ok(BackendTaskSuccessResult::RegisteredIdentity( qualified_identity, )) diff --git a/src/backend_task/identity/top_up_identity.rs b/src/backend_task/identity/top_up_identity.rs index b2bb94808..3743b4d47 100644 --- a/src/backend_task/identity/top_up_identity.rs +++ b/src/backend_task/identity/top_up_identity.rs @@ -1,4 +1,3 @@ -use crate::app::TaskResult; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::identity::{IdentityTopUpInfo, TopUpIdentityFundingMethod}; use crate::context::AppContext; @@ -21,7 +20,6 @@ impl AppContext { pub(super) async fn top_up_identity( &self, input: IdentityTopUpInfo, - sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { let IdentityTopUpInfo { mut qualified_identity, @@ -331,13 +329,6 @@ impl AppContext { .map_err(|e| e.to_string())?; } - sender - .send(TaskResult::Success(Box::new( - BackendTaskSuccessResult::None, - ))) - .await - .map_err(|e| e.to_string())?; - Ok(BackendTaskSuccessResult::ToppedUpIdentity( qualified_identity, )) diff --git a/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs b/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs index 628dd333f..aea781aeb 100644 --- a/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs +++ b/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs @@ -102,18 +102,14 @@ impl AddNewIdentityScreen { ui.add_space(20.0); } - ui.vertical_centered(|ui| { - match step { - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement <="); - ui.add_space(20.0); - ui.label("NOTE: If this gets stuck, the funds were likely either transferred to the wallet or asset locked,\nand you can use the funding method selector in step 1 to change the method and use those funds to complete the process."); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); - } - _ => {} + ui.vertical_centered(|ui| match step { + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement <="); } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); + } + _ => {} }); ui.add_space(40.0); diff --git a/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs b/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs index fe6c25daf..81612bedc 100644 --- a/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs +++ b/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs @@ -61,23 +61,17 @@ impl AddNewIdentityScreen { ui.add_space(20.0); } - ui.vertical_centered(|ui| { - match step { - WalletFundedScreenStep::WaitingForAssetLock => { - ui.heading("=> Waiting for Core Chain to produce proof of transfer of funds. <="); - ui.add_space(20.0); - ui.label("NOTE: If this gets stuck, the funds were likely either transferred to the wallet or asset locked,\nand you can use the funding method selector in step 1 to change the method and use those funds to complete the process."); - } - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement <="); - ui.add_space(20.0); - ui.label("NOTE: If this gets stuck, the funds were likely either transferred to the wallet or asset locked,\nand you can use the funding method selector in step 1 to change the method and use those funds to complete the process."); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); - } - _ => {} + ui.vertical_centered(|ui| match step { + WalletFundedScreenStep::WaitingForAssetLock => { + ui.heading("=> Waiting for Core Chain to produce proof of transfer of funds. <="); } + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement <="); + } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); + } + _ => {} }); ui.add_space(40.0); diff --git a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs index 14b905714..c3c9c3455 100644 --- a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs @@ -6,7 +6,7 @@ use crate::backend_task::identity::{ use crate::ui::identities::add_new_identity_screen::{ AddNewIdentityScreen, WalletFundedScreenStep, }; -use crate::ui::identities::funding_common::{copy_to_clipboard, generate_qr_code_image}; +use crate::ui::identities::funding_common::{self, copy_to_clipboard, generate_qr_code_image}; use dash_sdk::dashcore_rpc::RpcApi; use eframe::epaint::TextureHandle; use egui::{Color32, Ui}; @@ -119,6 +119,15 @@ impl AddNewIdentityScreen { } pub fn render_ui_by_wallet_qr_code(&mut self, ui: &mut Ui, step_number: u32) -> AppAction { + // Update state when funds land on the QR funding address + if let Some(utxo) = funding_common::capture_qr_funding_utxo_if_available( + &self.step, + self.selected_wallet.as_ref(), + self.funding_address.as_ref(), + ) { + self.funding_utxo = Some(utxo); + } + // Extract the step from the RwLock to minimize borrow scope let step = *self.step.read().unwrap(); @@ -136,6 +145,11 @@ impl AddNewIdentityScreen { self.render_funding_amount_input(ui); + if step == WalletFundedScreenStep::WaitingOnFunds { + ui.ctx() + .request_repaint_after(std::time::Duration::from_secs(1)); + } + let Ok(amount_dash) = self.funding_amount.parse::() else { return AppAction::None; }; @@ -148,65 +162,65 @@ impl AddNewIdentityScreen { egui::Layout::top_down(egui::Align::Min).with_cross_align(egui::Align::Center), |ui| { if let Err(e) = self.render_qr_code(ui, amount_dash) { - self.error_message = Some(e); - } - - ui.add_space(20.0); + self.error_message = Some(e); + } - if let Some(error_message) = self.error_message.as_ref() { - ui.colored_label(Color32::DARK_RED, error_message); ui.add_space(20.0); - } - match step { - WalletFundedScreenStep::ChooseFundingMethod => {} - WalletFundedScreenStep::WaitingOnFunds => { - ui.heading("=> Waiting for funds. <="); + if let Some(error_message) = self.error_message.as_ref() { + ui.colored_label(Color32::DARK_RED, error_message); + ui.add_space(20.0); } - WalletFundedScreenStep::FundsReceived => { - let Some(selected_wallet) = &self.selected_wallet else { - return AppAction::None; - }; - if let Some((utxo, tx_out, address)) = self.funding_utxo.clone() { - let identity_input = IdentityRegistrationInfo { - alias_input: self.alias_input.clone(), - keys: self.identity_keys.clone(), - wallet: Arc::clone(selected_wallet), // Clone the Arc reference - wallet_identity_index: self.identity_id_number, - identity_funding_method: RegisterIdentityFundingMethod::FundWithUtxo( - utxo, - tx_out, - address, - self.identity_id_number, - ), - }; - - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::WaitingForAssetLock; - // Create the backend task to register the identity - return AppAction::BackendTask(BackendTask::IdentityTask( - IdentityTask::RegisterIdentity(identity_input), - )) + match step { + WalletFundedScreenStep::ChooseFundingMethod => {} + WalletFundedScreenStep::WaitingOnFunds => { + ui.heading("=> Waiting for funds. <="); + } + WalletFundedScreenStep::FundsReceived => { + let Some(selected_wallet) = &self.selected_wallet else { + return AppAction::None; + }; + if let Some((utxo, tx_out, address)) = self.funding_utxo.clone() { + let identity_input = IdentityRegistrationInfo { + alias_input: self.alias_input.clone(), + keys: self.identity_keys.clone(), + wallet: Arc::clone(selected_wallet), // Clone the Arc reference + wallet_identity_index: self.identity_id_number, + identity_funding_method: + RegisterIdentityFundingMethod::FundWithUtxo( + utxo, + tx_out, + address, + self.identity_id_number, + ), + }; + + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::WaitingForAssetLock; + + // Create the backend task to register the identity + return AppAction::BackendTask(BackendTask::IdentityTask( + IdentityTask::RegisterIdentity(identity_input), + )); + } + } + WalletFundedScreenStep::ReadyToCreate => {} + WalletFundedScreenStep::WaitingForAssetLock => { + ui.heading( + "=> Waiting for Core Chain to produce proof of transfer of funds. <=", + ); + } + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement. <="); + } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); } } - WalletFundedScreenStep::ReadyToCreate => {} - WalletFundedScreenStep::WaitingForAssetLock => { - ui.heading("=> Waiting for Core Chain to produce proof of transfer of funds. <="); - ui.add_space(20.0); - ui.label("NOTE: If this gets stuck, the funds were likely either transferred to the wallet or asset locked,\nand you can use the funding method selector in step 1 to change the method and use those funds to complete the process."); - } - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement. <="); - ui.add_space(20.0); - ui.label("NOTE: If this gets stuck, the funds were likely either transferred to the wallet or asset locked,\nand you can use the funding method selector in step 1 to change the method and use those funds to complete the process."); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); - } - } - AppAction::None - }); + AppAction::None + }, + ); ui.add_space(40.0); diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index 87d8b2f0e..040b1a540 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -888,19 +888,29 @@ impl ScreenLike for AddNewIdentityScreen { } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::RegisteredIdentity(qualified_identity) = + &backend_task_success_result + { + self.successful_qualified_identity_id = Some(qualified_identity.identity.id()); + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::Success; + return; + } + let mut step = self.step.write().unwrap(); - match *step { + let current_step = *step; + match current_step { WalletFundedScreenStep::ChooseFundingMethod => {} WalletFundedScreenStep::WaitingOnFunds => { if let Some(funding_address) = self.funding_address.as_ref() && let BackendTaskSuccessResult::CoreItem( CoreItem::ReceivedAvailableUTXOTransaction(_, outpoints_with_addresses), - ) = backend_task_success_result + ) = &backend_task_success_result { for (outpoint, tx_out, address) in outpoints_with_addresses { - if funding_address == &address { + if funding_address == address { *step = WalletFundedScreenStep::FundsReceived; - self.funding_utxo = Some((outpoint, tx_out, address)) + self.funding_utxo = Some((*outpoint, tx_out.clone(), address.clone())) } } } @@ -910,9 +920,9 @@ impl ScreenLike for AddNewIdentityScreen { WalletFundedScreenStep::WaitingForAssetLock => { if let BackendTaskSuccessResult::CoreItem( CoreItem::ReceivedAvailableUTXOTransaction(tx, _), - ) = backend_task_success_result + ) = &backend_task_success_result && let Some(TransactionPayload::AssetLockPayloadType(asset_lock_payload)) = - tx.special_transaction_payload + &tx.special_transaction_payload && asset_lock_payload.credit_outputs.iter().any(|tx_out| { let Ok(address) = Address::from_script(&tx_out.script_pubkey, self.app_context.network) @@ -930,14 +940,7 @@ impl ScreenLike for AddNewIdentityScreen { *step = WalletFundedScreenStep::WaitingForPlatformAcceptance; } } - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - if let BackendTaskSuccessResult::RegisteredIdentity(qualified_identity) = - backend_task_success_result - { - self.successful_qualified_identity_id = Some(qualified_identity.identity.id()); - *step = WalletFundedScreenStep::Success; - } - } + WalletFundedScreenStep::WaitingForPlatformAcceptance => {} WalletFundedScreenStep::Success => {} } } diff --git a/src/ui/identities/funding_common.rs b/src/ui/identities/funding_common.rs index 4dd6802d9..d1909e044 100644 --- a/src/ui/identities/funding_common.rs +++ b/src/ui/identities/funding_common.rs @@ -3,6 +3,11 @@ use eframe::epaint::{Color32, ColorImage}; use egui::Vec2; use image::Luma; use qrcode::QrCode; +use std::sync::{Arc, RwLock}; + +use crate::model::wallet::Wallet; +use dash_sdk::dashcore_rpc::dashcore::Address; +use dash_sdk::dpp::dashcore::{OutPoint, TxOut}; #[derive(Eq, PartialEq, Ord, PartialOrd, Copy, Clone)] pub enum WalletFundedScreenStep { @@ -47,3 +52,43 @@ pub fn copy_to_clipboard(text: &str) -> Result<(), String> { .set_text(text.to_string()) .map_err(|e| e.to_string()) } + +pub fn capture_qr_funding_utxo_if_available( + step: &Arc>, + wallet: Option<&Arc>>, + funding_address: Option<&Address>, +) -> Option<(OutPoint, TxOut, Address)> { + if !matches!( + *step.read().expect("wallet funding step lock poisoned"), + WalletFundedScreenStep::WaitingOnFunds + ) { + return None; + } + + let address = funding_address.cloned()?; + + let wallet_arc = wallet?; + + let candidate_utxo = { + let wallet = wallet_arc + .read() + .expect("wallet lock poisoned while checking funding UTXO"); + wallet.utxos.get(&address).and_then(|utxos| { + utxos + .iter() + .filter(|(_, tx_out)| tx_out.value > 0) + .max_by_key(|(_, tx_out)| tx_out.value) + .map(|(outpoint, tx_out)| (*outpoint, tx_out.clone())) + }) + }; + + if let Some((outpoint, tx_out)) = candidate_utxo { + let mut step = step + .write() + .expect("wallet funding step write lock poisoned"); + *step = WalletFundedScreenStep::FundsReceived; + Some((outpoint, tx_out, address)) + } else { + None + } +} diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs index ccb420274..4b9ea0a4c 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs @@ -115,8 +115,6 @@ impl TopUpIdentityScreen { ui.vertical_centered(|ui| match step { WalletFundedScreenStep::WaitingForPlatformAcceptance => { ui.heading("=> Waiting for Platform acknowledgement <="); - ui.add_space(20.0); - ui.label("NOTE: If this gets stuck, the funds were likely either transferred to the wallet or asset locked,\nand you can use the funding method selector in step 1 to change the method and use those funds to complete the process."); } WalletFundedScreenStep::Success => { ui.heading("...Success..."); diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs index 762f4a051..0a9e598e7 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs @@ -68,14 +68,12 @@ impl TopUpIdentityScreen { ui.vertical_centered(|ui| { match step { WalletFundedScreenStep::WaitingForAssetLock => { - ui.heading("=> Waiting for Core Chain to produce proof of transfer of funds. <="); - ui.add_space(20.0); - ui.label("NOTE: If this gets stuck, the funds were likely either transferred to the wallet or asset locked,\nand you can use the funding method selector in step 1 to change the method and use those funds to complete the process."); + ui.heading( + "=> Waiting for Core Chain to produce proof of transfer of funds. <=", + ); } WalletFundedScreenStep::WaitingForPlatformAcceptance => { ui.heading("=> Waiting for Platform acknowledgement <="); - ui.add_space(20.0); - ui.label("NOTE: If this gets stuck, the funds were likely either transferred to the wallet or asset locked,\nand you can use the funding method selector in step 1 to change the method and use those funds to complete the process."); } WalletFundedScreenStep::Success => { ui.heading("...Success..."); diff --git a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs index 4ee0b1575..d3bf7c213 100644 --- a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs @@ -1,7 +1,7 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::identity::{IdentityTask, IdentityTopUpInfo, TopUpIdentityFundingMethod}; -use crate::ui::identities::funding_common::{copy_to_clipboard, generate_qr_code_image}; +use crate::ui::identities::funding_common::{self, copy_to_clipboard, generate_qr_code_image}; use crate::ui::identities::top_up_identity_screen::{TopUpIdentityScreen, WalletFundedScreenStep}; use dash_sdk::dashcore_rpc::RpcApi; use eframe::epaint::TextureHandle; @@ -92,6 +92,15 @@ impl TopUpIdentityScreen { } pub fn render_ui_by_wallet_qr_code(&mut self, ui: &mut Ui, step_number: u32) -> AppAction { + // Update state when the QR funding address receives funds + if let Some(utxo) = funding_common::capture_qr_funding_utxo_if_available( + &self.step, + self.wallet.as_ref(), + self.funding_address.as_ref(), + ) { + self.funding_utxo = Some(utxo); + } + // Extract the step from the RwLock to minimize borrow scope let step = *self.step.read().unwrap(); @@ -107,6 +116,11 @@ impl TopUpIdentityScreen { self.top_up_funding_amount_input(ui); + if step == WalletFundedScreenStep::WaitingOnFunds { + ui.ctx() + .request_repaint_after(std::time::Duration::from_secs(1)); + } + let response = ui.vertical_centered(|ui| { // Only try to render QR code if we have a valid amount if let Ok(amount_dash) = self.funding_amount.parse::() { @@ -169,14 +183,12 @@ impl TopUpIdentityScreen { } WalletFundedScreenStep::ReadyToCreate => {} WalletFundedScreenStep::WaitingForAssetLock => { - ui.heading("=> Waiting for Core Chain to produce proof of transfer of funds. <="); - ui.add_space(20.0); - ui.label("NOTE: If this gets stuck, the funds were likely either transferred to the wallet or asset locked,\nand you can use the funding method selector in step 1 to change the method and use those funds to complete the process."); + ui.heading( + "=> Waiting for Core Chain to produce proof of transfer of funds. <=", + ); } WalletFundedScreenStep::WaitingForPlatformAcceptance => { ui.heading("=> Waiting for Platform acknowledgement. <="); - ui.add_space(20.0); - ui.label("NOTE: If this gets stuck, the funds were likely either transferred to the wallet or asset locked,\nand you can use the funding method selector in step 1 to change the method and use those funds to complete the process."); } WalletFundedScreenStep::Success => { ui.heading("...Success..."); diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index 13d844691..56b5f19ed 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -72,10 +72,15 @@ impl TopUpIdentityScreen { } fn render_wallet_selection(&mut self, ui: &mut Ui) -> bool { - if self.app_context.has_wallet.load(Ordering::Relaxed) { - let wallets = self.app_context.wallets.read().unwrap(); + let mut selected_wallet_update: Option>> = None; + let mut step_update_method: Option = None; + + let rendered = if self.app_context.has_wallet.load(Ordering::Relaxed) { + let wallets_guard = self.app_context.wallets.read().unwrap(); + let wallets = &*wallets_guard; + if wallets.len() > 1 { - // Get the current funding method + // Cache current funding method to avoid holding the lock across UI callbacks let funding_method = *self.funding_method.read().unwrap(); // Retrieve the alias of the currently selected wallet, if any @@ -115,19 +120,8 @@ impl TopUpIdentityScreen { ui.add_enabled_ui(has_required_resources, |ui| { if ui.selectable_label(is_selected, wallet_alias).clicked() { - // Update the selected wallet from app_context - self.wallet = Some(wallet.clone()); - // Reset the funding address - self.funding_address = None; - // Reset the funding asset lock - self.funding_asset_lock = None; - // Reset the funding UTXO - self.funding_utxo = None; - // Reset the copied to clipboard state - self.copied_to_clipboard = None; - // Reset the step to choose funding method - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::ChooseFundingMethod; + selected_wallet_update = Some(wallet.clone()); + step_update_method = Some(funding_method); } }); } @@ -135,7 +129,7 @@ impl TopUpIdentityScreen { true } else if let Some(wallet) = wallets.values().next() { if self.wallet.is_none() { - // Get the current funding method + // Cache current funding method to avoid holding the lock across updates let funding_method = *self.funding_method.read().unwrap(); // Check if the wallet has the required resources @@ -152,7 +146,8 @@ impl TopUpIdentityScreen { if has_required_resources { // Automatically select the only available wallet from app_context - self.wallet = Some(wallet.clone()); + selected_wallet_update = Some(wallet.clone()); + step_update_method = Some(funding_method); } } false @@ -161,7 +156,36 @@ impl TopUpIdentityScreen { } } else { false + }; + + if let Some(wallet) = selected_wallet_update { + self.wallet = Some(wallet); + self.funding_address = None; + self.funding_asset_lock = None; + self.funding_utxo = None; + self.copied_to_clipboard = None; + + if let Some(method) = step_update_method { + self.update_step_after_wallet_change(method); + } else { + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::ChooseFundingMethod; + } } + + rendered + } + + /// Adjust the current step to match the funding method after a wallet switch. + fn update_step_after_wallet_change(&mut self, funding_method: FundingMethod) { + let mut step = self.step.write().unwrap(); + *step = match funding_method { + FundingMethod::AddressWithQRCode => WalletFundedScreenStep::WaitingOnFunds, + FundingMethod::UseUnusedAssetLock | FundingMethod::UseWalletBalance => { + WalletFundedScreenStep::ReadyToCreate + } + FundingMethod::NoSelection => WalletFundedScreenStep::ChooseFundingMethod, + }; } fn render_funding_method(&mut self, ui: &mut egui::Ui) { @@ -371,19 +395,36 @@ impl ScreenLike for TopUpIdentityScreen { } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::ToppedUpIdentity(qualified_identity) = + &backend_task_success_result + { + self.identity = qualified_identity.clone(); + self.funding_address = None; + self.funding_utxo = None; + self.funding_amount.clear(); + self.funding_amount_exact = None; + self.copied_to_clipboard = None; + self.error_message = None; + + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::Success; + return; + } + let mut step = self.step.write().unwrap(); - match *step { + let current_step = *step; + match current_step { WalletFundedScreenStep::ChooseFundingMethod => {} WalletFundedScreenStep::WaitingOnFunds => { if let Some(funding_address) = self.funding_address.as_ref() && let BackendTaskSuccessResult::CoreItem( CoreItem::ReceivedAvailableUTXOTransaction(_, outpoints_with_addresses), - ) = backend_task_success_result + ) = &backend_task_success_result { for (outpoint, tx_out, address) in outpoints_with_addresses { - if funding_address == &address { + if funding_address == address { *step = WalletFundedScreenStep::FundsReceived; - self.funding_utxo = Some((outpoint, tx_out, address)) + self.funding_utxo = Some((*outpoint, tx_out.clone(), address.clone())) } } } @@ -393,9 +434,9 @@ impl ScreenLike for TopUpIdentityScreen { WalletFundedScreenStep::WaitingForAssetLock => { if let BackendTaskSuccessResult::CoreItem( CoreItem::ReceivedAvailableUTXOTransaction(tx, _), - ) = backend_task_success_result + ) = &backend_task_success_result && let Some(TransactionPayload::AssetLockPayloadType(asset_lock_payload)) = - tx.special_transaction_payload + &tx.special_transaction_payload && asset_lock_payload.credit_outputs.iter().any(|tx_out| { let Ok(address) = Address::from_script(&tx_out.script_pubkey, self.app_context.network) @@ -413,13 +454,7 @@ impl ScreenLike for TopUpIdentityScreen { *step = WalletFundedScreenStep::WaitingForPlatformAcceptance; } } - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - if let BackendTaskSuccessResult::ToppedUpIdentity(_qualified_identity) = - backend_task_success_result - { - *step = WalletFundedScreenStep::Success; - } - } + WalletFundedScreenStep::WaitingForPlatformAcceptance => {} WalletFundedScreenStep::Success => {} } } From aa48a47cdadeeb305d311db87e06f599fe9a69f4 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Fri, 17 Oct 2025 16:06:30 +0700 Subject: [PATCH 027/106] fix: keyword search required new platform version plus error handling and UI updates (#449) * fix: keyword search not displaying errors * update platform version and keyword search cleanup * fix --- Cargo.lock | 88 +++++++------- Cargo.toml | 2 +- .../identity/register_dpns_name.rs | 2 + src/backend_task/tokens/mod.rs | 2 + src/backend_task/tokens/query_tokens.rs | 19 ++- src/ui/components/contract_chooser_panel.rs | 4 +- .../document_action_screen.rs | 2 + .../tokens/tokens_screen/contract_details.rs | 113 ++++++++++-------- src/ui/tokens/tokens_screen/keyword_search.rs | 71 +++++++---- src/ui/tokens/tokens_screen/mod.rs | 3 +- src/ui/wallets/wallets_screen/mod.rs | 14 +-- 11 files changed, 175 insertions(+), 145 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ceebaa5f0..23e554f25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1486,8 +1486,8 @@ dependencies = [ [[package]] name = "dapi-grpc" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "dapi-grpc-macros", "futures-core", @@ -1505,8 +1505,8 @@ dependencies = [ [[package]] name = "dapi-grpc-macros" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "heck", "quote", @@ -1564,8 +1564,8 @@ dependencies = [ [[package]] name = "dash-context-provider" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "dpp", "drive", @@ -1647,8 +1647,8 @@ dependencies = [ [[package]] name = "dash-sdk" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "arc-swap", "async-trait", @@ -1766,8 +1766,8 @@ dependencies = [ [[package]] name = "dashpay-contract" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "platform-value", "platform-version", @@ -1777,8 +1777,8 @@ dependencies = [ [[package]] name = "data-contracts" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2016,8 +2016,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "platform-value", "platform-version", @@ -2027,8 +2027,8 @@ dependencies = [ [[package]] name = "dpp" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "anyhow", "async-trait", @@ -2073,8 +2073,8 @@ dependencies = [ [[package]] name = "drive" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "bincode", "byteorder", @@ -2098,8 +2098,8 @@ dependencies = [ [[package]] name = "drive-proof-verifier" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "bincode", "dapi-grpc", @@ -2632,8 +2632,8 @@ dependencies = [ [[package]] name = "feature-flags-contract" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "platform-value", "platform-version", @@ -3839,8 +3839,8 @@ dependencies = [ [[package]] name = "keyword-search-contract" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "platform-value", "platform-version", @@ -3993,8 +3993,8 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "platform-value", "platform-version", @@ -5031,8 +5031,8 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "platform-serialization" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "bincode", "platform-version", @@ -5040,8 +5040,8 @@ dependencies = [ [[package]] name = "platform-serialization-derive" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "proc-macro2", "quote", @@ -5051,8 +5051,8 @@ dependencies = [ [[package]] name = "platform-value" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "base64 0.22.1", "bincode", @@ -5071,8 +5071,8 @@ dependencies = [ [[package]] name = "platform-version" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "bincode", "grovedb-version", @@ -5083,8 +5083,8 @@ dependencies = [ [[package]] name = "platform-versioning" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "proc-macro2", "quote", @@ -5619,8 +5619,8 @@ dependencies = [ [[package]] name = "rs-dapi-client" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "backon", "chrono", @@ -6618,8 +6618,8 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "platform-value", "platform-version", @@ -7244,8 +7244,8 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "platform-value", "platform-version", @@ -8198,8 +8198,8 @@ dependencies = [ [[package]] name = "withdrawals-contract" -version = "2.0.0" -source = "git+https://github.com/dashpay/platform?rev=d3f3c930a618030be807bb257e7434ef3df090b5#d3f3c930a618030be807bb257e7434ef3df090b5" +version = "2.1.0-dev.8" +source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index e07c5d7de..cb80ea5b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.30.1", features = ["signal"] } eframe = { version = "0.32.0", features = ["persistence"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", features = ["core_key_wallet", "core_bincode", "core_quorum-validation", "core_verification", "core_rpc_client"], rev = "d3f3c930a618030be807bb257e7434ef3df090b5" } +dash-sdk = { git = "https://github.com/dashpay/platform", features = ["core_key_wallet", "core_bincode", "core_quorum-validation", "core_verification", "core_rpc_client"], rev = "d6f9d9e74b885fbac27948bce6bae60b0cbd7135" } thiserror = "2.0.12" serde = "1.0.219" serde_json = "1.0.140" diff --git a/src/backend_task/identity/register_dpns_name.rs b/src/backend_task/identity/register_dpns_name.rs index d7ff0c963..888b2ae25 100644 --- a/src/backend_task/identity/register_dpns_name.rs +++ b/src/backend_task/identity/register_dpns_name.rs @@ -60,6 +60,7 @@ impl AppContext { let preorder_document = Document::V0(DocumentV0 { id: preorder_id, owner_id: qualified_identity.identity.id(), + creator_id: None, properties: BTreeMap::from([( "saltedDomainHash".to_string(), salted_domain_hash.into(), @@ -78,6 +79,7 @@ impl AppContext { let domain_document = Document::V0(DocumentV0 { id: domain_id, owner_id: qualified_identity.identity.id(), + creator_id: None, properties: BTreeMap::from([ ("parentDomainName".to_string(), "dash".into()), ("normalizedParentDomainName".to_string(), "dash".into()), diff --git a/src/backend_task/tokens/mod.rs b/src/backend_task/tokens/mod.rs index 40a1c8b80..aa8c40ddb 100644 --- a/src/backend_task/tokens/mod.rs +++ b/src/backend_task/tokens/mod.rs @@ -720,6 +720,8 @@ impl AppContext { let mut validation_operations = Vec::new(); match dash_sdk::dpp::data_contract::document_type::DocumentType::try_from_schema( contract_id, + 0, + 0, &name, platform_value, None, // schema_defs diff --git a/src/backend_task/tokens/query_tokens.rs b/src/backend_task/tokens/query_tokens.rs index 4020030c8..014c57400 100644 --- a/src/backend_task/tokens/query_tokens.rs +++ b/src/backend_task/tokens/query_tokens.rs @@ -1,5 +1,9 @@ //! Execute token query by keyword on Platform +use crate::{ + backend_task::BackendTaskSuccessResult, context::AppContext, + ui::tokens::tokens_screen::ContractDescriptionInfo, +}; use dash_sdk::{ Sdk, dpp::{document::DocumentV0Getters, platform_value::Value}, @@ -10,11 +14,6 @@ use dash_sdk::{ }, }; -use crate::{ - backend_task::BackendTaskSuccessResult, context::AppContext, - ui::tokens::tokens_screen::ContractDescriptionInfo, -}; - impl AppContext { /// 1. Fetch all **contractKeywords** docs that match `keyword` from the Search Contract /// 2. For every `contractId` found, fetch its **shortDescription** document from the Search Contract @@ -39,7 +38,7 @@ impl AppContext { let kw_docs = Document::fetch_many(sdk, kw_query.clone()) .await - .map_err(|e| format!("Error fetching keyword docs: {e}"))?; + .map_err(|e| e.to_string())?; // store the order for deterministic pagination let mut contract_ids: Vec = Vec::with_capacity(kw_docs.len()); @@ -47,11 +46,7 @@ impl AppContext { if let Some(doc) = doc_opt && let Some(cid_val) = doc.get("contractId") { - contract_ids.push( - cid_val - .to_identifier() - .map_err(|e| format!("Bad contractId: {e}"))?, - ); + contract_ids.push(cid_val.to_identifier().map_err(|e| e.to_string())?); } } @@ -85,7 +80,7 @@ impl AppContext { let description = if let Some((_, Some(desc_doc))) = Document::fetch_many(sdk, desc_query) .await - .map_err(|e| format!("Error fetching description doc: {e}"))? + .map_err(|e| e.to_string())? .into_iter() .next() { diff --git a/src/ui/components/contract_chooser_panel.rs b/src/ui/components/contract_chooser_panel.rs index ac465fd3c..b5c8add4a 100644 --- a/src/ui/components/contract_chooser_panel.rs +++ b/src/ui/components/contract_chooser_panel.rs @@ -181,8 +181,8 @@ pub fn add_contract_chooser_panel( SidePanel::left("contract_chooser_panel") // Let the user resize this panel horizontally - .resizable(false) - .default_width(270.0) // Increased to account for margins + .resizable(true) + .default_width(270.0) .frame( Frame::new() .fill(DashColors::background(dark_mode)) diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index c43c300b2..67b39fd89 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -1244,6 +1244,7 @@ impl DocumentActionScreen { id, properties, owner_id, + creator_id: None, revision, created_at: None, updated_at: None, @@ -1412,6 +1413,7 @@ impl DocumentActionScreen { id: original_doc.id(), properties, owner_id: original_doc.owner_id(), + creator_id: original_doc.creator_id(), revision: new_revision, created_at: None, updated_at: None, diff --git a/src/ui/tokens/tokens_screen/contract_details.rs b/src/ui/tokens/tokens_screen/contract_details.rs index 64e2c8c5c..926471086 100644 --- a/src/ui/tokens/tokens_screen/contract_details.rs +++ b/src/ui/tokens/tokens_screen/contract_details.rs @@ -1,9 +1,9 @@ -use crate::app::AppAction; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; use crate::ui::tokens::tokens_screen::TokensScreen; +use crate::{app::AppAction, ui::theme::DashColors}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; -use egui::Ui; +use egui::{Frame, Margin, Ui}; impl TokensScreen { /// Renders details for the selected_contract_id. @@ -14,13 +14,28 @@ impl TokensScreen { ) -> AppAction { let mut action = AppAction::None; + let mut go_back = false; + ui.horizontal(|ui| { + if ui.button("Back to Search Results").clicked() { + go_back = true; + } + }); + + if go_back { + self.selected_contract_id = None; + self.contract_details_loading = false; + self.selected_contract_description = None; + self.selected_token_infos.clear(); + return action; + } + + ui.add_space(10.0); + // Show loading spinner if data is being fetched if self.contract_details_loading { - ui.vertical_centered(|ui| { - ui.add_space(50.0); - ui.heading("Loading contract details..."); - ui.add_space(20.0); - ui.add(egui::widgets::Spinner::default().size(50.0)); + ui.horizontal(|ui| { + ui.label("Loading contract details..."); + ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); }); return action; } @@ -29,10 +44,10 @@ impl TokensScreen { ui.heading("Contract Description:"); ui.add_space(10.0); ui.label(description.description.clone()); + ui.add_space(10.0); + ui.separator(); } - ui.add_space(10.0); - ui.separator(); ui.add_space(10.0); ui.heading("Tokens:"); @@ -42,54 +57,52 @@ impl TokensScreen { .filter(|token| token.data_contract_id == *contract_id) .cloned() .collect::>(); + let visuals = ui.visuals().clone(); for token in token_infos { - if token.data_contract_id == *contract_id { - ui.add_space(10.0); - ui.heading(format!("• {}", token.token_name.clone())); - ui.add_space(10.0); - ui.label(format!( - "ID: {}", - token.token_id.to_string(Encoding::Base58) - )); - ui.label(format!( - "Description: {}", - token + ui.add_space(10.0); + Frame::group(ui.style()) + .stroke(visuals.widgets.noninteractive.bg_stroke) + .fill(visuals.extreme_bg_color) + .inner_margin(Margin::same(12)) + .show(ui, |ui| { + ui.heading(token.token_name.clone()); + ui.add_space(6.0); + ui.label(format!( + "ID: {}", + token.token_id.to_string(Encoding::Base58) + )); + let description = token .description .clone() - .unwrap_or("No description".to_string()) - )); - } + .unwrap_or_else(|| "No description".to_string()); + ui.label(format!("Description: {}", description)); - ui.add_space(10.0); + ui.add_space(12.0); - // Add button to add token to my tokens - ui.horizontal(|ui| { - if ui.button("Add to My Tokens").clicked() { - match self.add_token_to_tracked_tokens(token.clone()) { - Ok(internal_action) => { - // Add token to my tokens - action |= internal_action; + ui.horizontal(|ui| { + if ui.button("Add to My Tokens").clicked() { + match self.add_token_to_tracked_tokens(token.clone()) { + Ok(internal_action) => { + action |= internal_action; + } + Err(e) => { + self.set_error_message(Some(e)); + } + } } - Err(e) => { - self.set_error_message(Some(e)); + if ui.button("View schema").clicked() { + match serde_json::to_string_pretty(&token.token_configuration) { + Ok(schema) => { + self.show_json_popup = true; + self.json_popup_text = schema; + } + Err(e) => { + self.set_error_message(Some(e.to_string())); + } + } } - } - } - if ui.button("View schema").clicked() { - // Show a popup window with the schema - match serde_json::to_string_pretty(&token.token_configuration) { - Ok(schema) => { - self.show_json_popup = true; - self.json_popup_text = schema; - } - Err(e) => { - self.set_error_message(Some(e.to_string())); - } - } - } - }); - - ui.add_space(20.0); + }); + }); } action diff --git a/src/ui/tokens/tokens_screen/keyword_search.rs b/src/ui/tokens/tokens_screen/keyword_search.rs index 13ceea4ad..5af0a2f4a 100644 --- a/src/ui/tokens/tokens_screen/keyword_search.rs +++ b/src/ui/tokens/tokens_screen/keyword_search.rs @@ -2,6 +2,7 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::contract::ContractTask; use crate::backend_task::tokens::TokenTask; +use crate::ui::theme::DashColors; use crate::ui::tokens::tokens_screen::{ ContractDescriptionInfo, ContractSearchStatus, TokensScreen, }; @@ -9,7 +10,7 @@ use chrono::Utc; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use eframe::emath::Align; use eframe::epaint::Color32; -use egui::Ui; +use egui::{RichText, Ui}; use egui_extras::{Column, TableBuilder}; impl TokensScreen { @@ -105,27 +106,28 @@ impl TokensScreen { ui.label("No tokens match your keyword."); } else { action |= self.render_search_results_table(ui, &results); - } - - // Pagination controls - ui.horizontal(|ui| { - if self.search_current_page > 1 && ui.button("Previous").clicked() { - // Go to previous page - action = self.goto_previous_search_page(); - } - - if !(self.next_cursors.is_empty() && self.previous_cursors.is_empty()) { - ui.label(format!("Page {}", self.search_current_page)); - } - - if self.search_has_next_page && ui.button("Next").clicked() { - // Go to next page - action = self.goto_next_search_page(); + // Pagination controls + if self.search_has_next_page || self.search_current_page > 1 { + ui.horizontal(|ui| { + if self.search_current_page > 1 && ui.button("Previous").clicked() { + // Go to previous page + action |= self.goto_previous_search_page(); + } + + if !(self.next_cursors.is_empty() && self.previous_cursors.is_empty()) { + ui.label(format!("Page {}", self.search_current_page)); + } + + if self.search_has_next_page && ui.button("Next").clicked() { + // Go to next page + action |= self.goto_next_search_page(); + } + }); } - }); + } } ContractSearchStatus::ErrorMessage(e) => { - ui.colored_label(Color32::RED, format!("Error: {}", e)); + ui.colored_label(Color32::DARK_RED, format!("Error: {}", e)); } } @@ -139,6 +141,8 @@ impl TokensScreen { ) -> AppAction { let mut action = AppAction::None; + let dark_mode = ui.visuals().dark_mode; + egui::ScrollArea::both().show(ui, |ui| { ui.set_min_width(ui.available_width()); ui.set_max_width(ui.available_width()); @@ -152,13 +156,28 @@ impl TokensScreen { .column(Column::initial(80.0).resizable(true)) // Action .header(30.0, |mut header| { header.col(|ui| { - ui.label("Contract ID"); + ui.label( + RichText::new("Contract ID") + .strong() + .size(14.0) + .color(DashColors::text_primary(dark_mode)), + ); }); header.col(|ui| { - ui.label("Contract Description"); + ui.label( + RichText::new("Contract Description") + .strong() + .size(14.0) + .color(DashColors::text_primary(dark_mode)), + ); }); header.col(|ui| { - ui.label("Action"); + ui.label( + RichText::new("Action") + .strong() + .size(14.0) + .color(DashColors::text_primary(dark_mode)), + ); }); }) .body(|mut body| { @@ -168,7 +187,13 @@ impl TokensScreen { ui.label(contract.data_contract_id.to_string(Encoding::Base58)); }); row.col(|ui| { - ui.label(contract.description.clone()); + let description = if contract.description.trim().is_empty() { + "None".to_string() + } else { + contract.description.clone() + }; + + ui.label(description); }); row.col(|ui| { // Example "Add" button diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 28d375dcd..1ae75df50 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -2894,13 +2894,12 @@ impl ScreenLike for TokensScreen { } } TokensSubscreen::SearchTokens => { - if msg.contains("Error fetching tokens") { + if msg_type == MessageType::Error { self.contract_search_status = ContractSearchStatus::ErrorMessage(msg.to_string()); // Clear adding status on error self.adding_token_start_time = None; self.adding_token_name = None; - self.backend_message = Some((msg.to_string(), msg_type, Utc::now())); } else if msg.contains("Added token") | msg.contains("Token already added") | msg.contains("Saved token to db") diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index bb19a8dbe..dee816ee8 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -983,11 +983,12 @@ impl ScreenLike for WalletsBalancesScreen { }); }); - ui.add_space(10.0); - ui.separator(); ui.add_space(10.0); if self.selected_wallet.is_some() { + ui.separator(); + ui.add_space(10.0); + // Always show the filter selector ui.vertical(|ui| { ui.heading( @@ -1023,15 +1024,6 @@ impl ScreenLike for WalletsBalancesScreen { ui.add_space(10.0); self.render_bottom_options(ui); - } else { - ui.vertical_centered(|ui| { - ui.add_space(50.0); - ui.label( - RichText::new("Please select a wallet to view its details") - .size(16.0) - .color(Color32::GRAY), - ); - }); } }); From c37e8a598c18c8b9777dcb2ac56b85c9fbfdc7e2 Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Fri, 17 Oct 2025 13:16:12 +0200 Subject: [PATCH 028/106] fix: handle decimals and fix schedules on price and purchase screens (#412) * fix: inactive button on set price group action * chore: apply review feedback * refactor: use token amount input * chore: update AmountInput * chore: tiered pricing * chore: direct purchase * chore: remove total agreed price * chore: max amount input defaults to max_credits * fmt * fix * clippy --------- Co-authored-by: pauldelucia Co-authored-by: Claude --- src/ui/components/amount_input.rs | 16 +- .../group_actions_screen.rs | 4 +- src/ui/tokens/direct_token_purchase_screen.rs | 306 +++++++++--- src/ui/tokens/set_token_price_screen.rs | 451 +++++++++++------- src/ui/wallets/wallets_screen/mod.rs | 47 +- 5 files changed, 569 insertions(+), 255 deletions(-) diff --git a/src/ui/components/amount_input.rs b/src/ui/components/amount_input.rs index 0230eb91f..abc071145 100644 --- a/src/ui/components/amount_input.rs +++ b/src/ui/components/amount_input.rs @@ -1,5 +1,6 @@ use crate::model::amount::Amount; use crate::ui::components::{Component, ComponentResponse}; +use dash_sdk::dpp::balances::credits::MAX_CREDITS; use dash_sdk::dpp::fee::Credits; use egui::{InnerResponse, Response, TextEdit, Ui, Vec2, WidgetText}; @@ -113,7 +114,7 @@ impl AmountInput { unit_name: amount.unit_name().map(|s| s.to_string()), label: None, hint_text: None, - max_amount: None, + max_amount: Some(MAX_CREDITS), min_amount: Some(1), // Default minimum is 1 (greater than zero) show_max_button: false, desired_width: None, @@ -176,6 +177,17 @@ impl AmountInput { self } + /// Sets value of the input field. + /// + /// This will update the internal state and mark the component as changed. + pub fn set_value(&mut self, value: Amount) -> &mut Self { + self.amount_str = value.to_string_without_unit(); + self.decimal_places = value.decimal_places(); + self.unit_name = value.unit_name().map(|s| s.to_string()); + self.changed = true; // Mark as changed to trigger validation + self + } + /// Sets the hint text for the input field. pub fn with_hint_text>(mut self, hint_text: T) -> Self { self.hint_text = Some(hint_text.into()); @@ -197,6 +209,8 @@ impl AmountInput { /// Sets the maximum amount allowed (mutable reference version). /// Use this for dynamic configuration when the max amount changes at runtime (e.g., balance updates). + /// + /// Defaults to [`MAX_CREDITS`](dash_sdk::dpp::balances::credits::MAX_CREDITS). pub fn set_max_amount(&mut self, max_amount: Option) -> &mut Self { self.max_amount = max_amount; self diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index 5e40ce43c..3c1f3e7c5 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -429,7 +429,9 @@ impl GroupActionsScreen { } TokenEvent::ChangePriceForDirectPurchase(schedule, note_opt) => { let mut change_price_screen = - SetTokenPriceScreen::new(identity_token_info, &self.app_context); + SetTokenPriceScreen::new(identity_token_info, &self.app_context) + .with_schedule(schedule.clone()); + change_price_screen.group_action_id = Some(action_id); change_price_screen.token_pricing_schedule = format!("{:?}", schedule); change_price_screen.public_note = note_opt.clone(); diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index a06f23b94..4e2de7d98 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -3,6 +3,8 @@ use std::sync::{Arc, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; +use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dash_sdk::dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::tokens::token_pricing_schedule::TokenPricingSchedule; use eframe::egui::{self, Color32, Context, Ui}; @@ -13,14 +15,16 @@ use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; +use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::wallet::Wallet; -use crate::ui::components::Component; +use crate::ui::components::amount_input::AmountInput; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::{Component, ComponentResponse}; use crate::ui::helpers::{TransactionType, add_identity_key_chooser}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; @@ -47,11 +51,11 @@ pub struct PurchaseTokenScreen { pub identity_token_info: IdentityTokenInfo, selected_key: Option, - // Specific to this transition - amount_to_purchase: String, - total_agreed_price: String, + // Specific to this transition - using AmountInput components following design pattern + amount_to_purchase_input: Option, + amount_to_purchase_value: Option, fetched_pricing_schedule: Option, - calculated_price: Option, + calculated_price_credits: Option, pricing_fetch_attempted: bool, /// Screen stuff @@ -91,10 +95,10 @@ impl PurchaseTokenScreen { Self { identity_token_info, selected_key: possible_key, - amount_to_purchase: "".to_string(), - total_agreed_price: "".to_string(), + amount_to_purchase_input: None, + amount_to_purchase_value: None, fetched_pricing_schedule: None, - calculated_price: None, + calculated_price_credits: None, pricing_fetch_attempted: false, status: PurchaseTokensStatus::NotStarted, error_message: None, @@ -106,17 +110,35 @@ impl PurchaseTokenScreen { } } - /// Renders a text input for the user to specify an amount to purchase + /// Renders AmountInput components for the user to specify an amount to purchase fn render_amount_input(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; ui.horizontal(|ui| { - ui.label("Amount to Purchase:"); - let response = ui.text_edit_singleline(&mut self.amount_to_purchase); + // Use AmountInput for token amount with lazy initialization + let amount_input = self.amount_to_purchase_input.get_or_insert_with(|| { + AmountInput::new( + Amount::new( + 0, + self.identity_token_info + .token_config + .conventions() + .decimals(), + ) + .with_unit_name(&self.identity_token_info.token_alias), + ) + .with_label("Amount to Purchase:") + .with_hint_text("Enter token amount to purchase") + .with_min_amount(Some(1)) + }); - // When amount changes, recalculate the price if we have pricing schedule - if response.changed() { + let response = amount_input.show(ui); + response.inner.update(&mut self.amount_to_purchase_value); + + // When amount changes, update domain data and recalculate the price + if response.inner.has_changed() { self.recalculate_price(); + self.confirmation_dialog = None; } // Fetch pricing button @@ -144,14 +166,46 @@ impl PurchaseTokenScreen { if let Some(pricing_schedule) = &self.fetched_pricing_schedule { ui.add_space(5.0); ui.label("Current pricing:"); + let dark_mode = ui.ctx().style().visuals.dark_mode; + match pricing_schedule { - TokenPricingSchedule::SinglePrice(price) => { - ui.label(format!(" Fixed price: {} credits per token", price)); + TokenPricingSchedule::SinglePrice(price_per_unit) => { + // Convert price per smallest unit to price per whole token for display, guarding for the minimal + // representable value (using Amount ref display which pads decimals properly) + if *price_per_unit == 0 { + ui.colored_label( + DashColors::error_color(dark_mode), + " Fixed price: FREE (pricing schedule stores 0 credits per unit)", + ); + } else { + let price_per_token = (*price_per_unit as u128) + .saturating_mul(self.token_decimal_multiplier() as u128) + .min(u64::MAX as u128) + as u64; + let price = Amount::new(price_per_token, DASH_DECIMAL_PLACES) + .with_unit_name("DASH"); + ui.label(format!(" Fixed price: {} per token", price)); + } } TokenPricingSchedule::SetPrices(tiers) => { ui.label(" Tiered pricing:"); - for (amount, price) in tiers { - ui.label(format!(" {} tokens: {} credits each", amount, price)); + for (amount_value, price_per_unit) in tiers { + let amount = Amount::from_token(&self.identity_token_info, *amount_value); + // Convert price per smallest unit to price per token for display + if *price_per_unit == 0 { + ui.colored_label( + DashColors::error_color(dark_mode), + format!(" {} tokens: FREE (tier stores 0 credits)", amount), + ); + } else { + let price_per_token = (*price_per_unit as u128) + .saturating_mul(self.token_decimal_multiplier() as u128) + .min(u64::MAX as u128) + as u64; + let price = Amount::new(price_per_token, DASH_DECIMAL_PLACES) + .with_unit_name("DASH"); + ui.label(format!(" {} tokens: {} each", amount, price)); + } } } } @@ -162,11 +216,12 @@ impl PurchaseTokenScreen { /// Recalculates the total price based on amount and pricing schedule fn recalculate_price(&mut self) { - if let (Some(pricing_schedule), Ok(amount)) = ( + if let (Some(pricing_schedule), Some(amount_value)) = ( &self.fetched_pricing_schedule, - self.amount_to_purchase.parse::(), + &self.amount_to_purchase_value, ) { - let price_per_token = match pricing_schedule { + let amount = amount_value.value(); + let price_per_unit = match pricing_schedule { TokenPricingSchedule::SinglePrice(price) => *price, TokenPricingSchedule::SetPrices(tiers) => { // Find the appropriate tier for this amount @@ -180,42 +235,46 @@ impl PurchaseTokenScreen { } }; - let total_price = amount.saturating_mul(price_per_token); - self.calculated_price = Some(total_price); - self.total_agreed_price = total_price.to_string(); + // The price from Platform is per smallest unit, and amount is in smallest units + // So we multiply them directly using wider arithmetic to avoid overflow + let total_price = (amount as u128) + .saturating_mul(price_per_unit as u128) + .min(u64::MAX as u128) as u64; + self.calculated_price_credits = Some(total_price); } else { - self.calculated_price = None; + self.calculated_price_credits = None; } } + fn token_decimal_multiplier(&self) -> u64 { + 10u64.pow( + self.identity_token_info + .token_config + .conventions() + .decimals() as u32, + ) + } + /// Renders a confirm popup with the final "Are you sure?" step fn show_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { - // Validate user input - let amount_ok = self.amount_to_purchase.parse::().ok(); - if amount_ok.is_none() { + let Some(amount) = self.amount_to_purchase_value.as_ref() else { self.error_message = Some("Please enter a valid amount.".into()); self.status = PurchaseTokensStatus::ErrorMessage("Invalid amount".into()); self.confirmation_dialog = None; return AppAction::None; - } + }; - let total_agreed_price_ok: Option = self.total_agreed_price.parse::().ok(); - if total_agreed_price_ok.is_none() { - self.error_message = Some("Please enter a valid total agreed price.".into()); - self.status = PurchaseTokensStatus::ErrorMessage("Invalid total agreed price".into()); + let Some(total_price_credits) = self.calculated_price_credits else { + self.error_message = + Some("Cannot calculate total price. Please fetch token pricing first.".into()); + self.status = PurchaseTokensStatus::ErrorMessage("No pricing fetched".into()); self.confirmation_dialog = None; return AppAction::None; - } + }; - let dialog = self.confirmation_dialog.get_or_insert_with(|| { - ConfirmationDialog::new( - "Confirm Purchase".to_string(), - format!( - "Are you sure you want to purchase {} token(s) for {} Credits?", - self.amount_to_purchase, self.total_agreed_price - ), - ) - }); + let Some(dialog) = self.confirmation_dialog.as_mut() else { + return AppAction::None; + }; match dialog.show(ui).inner.dialog_response { Some(ConfirmationStatus::Confirmed) => { @@ -226,7 +285,6 @@ impl PurchaseTokenScreen { .as_secs(); self.status = PurchaseTokensStatus::WaitingForResult(now); - // Dispatch the actual backend purchase action AppAction::BackendTasks( vec![ BackendTask::TokenTask(Box::new(TokenTask::PurchaseTokens { @@ -236,9 +294,8 @@ impl PurchaseTokenScreen { ), token_position: self.identity_token_info.token_position, signing_key: self.selected_key.clone().expect("Expected a key"), - amount: amount_ok.expect("Expected a valid amount"), - total_agreed_price: total_agreed_price_ok - .expect("Expected a valid total agreed price"), + amount: amount.value(), + total_agreed_price: total_price_credits, })), BackendTask::TokenTask(Box::new(TokenTask::QueryMyTokenBalances)), ], @@ -454,12 +511,17 @@ impl ScreenLike for PurchaseTokenScreen { ui.add_space(10.0); - // Display calculated price - if let Some(calculated_price) = self.calculated_price { + // Display calculated price and total agreed price input + if let Some(calculated_price_credits) = self.calculated_price_credits { ui.group(|ui| { ui.heading("Calculated total price:"); - ui.label(format!("{} credits", calculated_price)); + let dash_amount = Amount::new(calculated_price_credits, DASH_DECIMAL_PLACES) + .with_unit_name("DASH"); + ui.label(format!("{} DASH ({} credits)",dash_amount, calculated_price_credits)); ui.label("Note: This is the calculated price based on the current pricing schedule."); + + ui.add_space(10.0); + }); } else if self.fetched_pricing_schedule.is_some() { ui.colored_label( @@ -474,9 +536,15 @@ impl ScreenLike for PurchaseTokenScreen { ui.separator(); ui.add_space(10.0); - // Purchase button (disabled if no pricing is available) - let can_purchase = - self.fetched_pricing_schedule.is_some() && self.calculated_price.is_some(); + // Purchase button (disabled if no valid amounts are available) + let can_purchase = self.fetched_pricing_schedule.is_some() + && self.calculated_price_credits.unwrap_or_default() > 0 + && self + .amount_to_purchase_value + .as_ref() + .map(|v| v.value()) + .unwrap_or_default() + > 0; let purchase_text = "Purchase".to_string(); if can_purchase { @@ -486,14 +554,29 @@ impl ScreenLike for PurchaseTokenScreen { .corner_radius(3.0); if ui.add(button).clicked() && self.confirmation_dialog.is_none() { - // Validation will be done in show_confirmation_popup - self.confirmation_dialog = Some(ConfirmationDialog::new( - "Confirm Purchase".to_string(), - format!( - "Are you sure you want to purchase {} token(s) for {} Credits?", - self.amount_to_purchase, self.total_agreed_price - ), - )); + if let (Some(amount), Some(total_price_credits)) = ( + self.amount_to_purchase_value.as_ref(), + self.calculated_price_credits, + ) { + let total_price_dash = + Amount::new(total_price_credits, DASH_DECIMAL_PLACES) + .with_unit_name("DASH"); + + self.confirmation_dialog = Some(ConfirmationDialog::new( + "Confirm Purchase".to_string(), + format!( + "Are you sure you want to purchase {} for {} ({} Credits)?", + amount, total_price_dash, total_price_credits + ), + )); + } else { + self.error_message = Some( + "Cannot calculate total price. Please fetch token pricing first." + .into(), + ); + self.status = + PurchaseTokensStatus::ErrorMessage("No pricing fetched".into()); + } } } else { let button = egui::Button::new( @@ -576,3 +659,106 @@ impl ScreenWithWalletUnlock for PurchaseTokenScreen { self.error_message.as_ref() } } + +#[cfg(test)] +mod tests { + use crate::model::amount::DASH_DECIMAL_PLACES; + + #[test] + fn test_token_pricing_storage_and_calculation() { + // Test how prices should be stored and calculated + + // Case 1: Token with 8 decimals (like the user's case) + let token_decimals_8 = 8u8; + let user_price_per_token_dash = 0.001; // User wants 0.001 DASH per token + let user_price_per_token_credits = + (user_price_per_token_dash * 10f64.powi(DASH_DECIMAL_PLACES as i32)) as u64; + + println!("Test 1 - Token with 8 decimals, price 0.001 DASH per token:"); + println!( + " User enters: {} DASH per token", + user_price_per_token_dash + ); + println!( + " In credits: {} credits per token", + user_price_per_token_credits + ); + + // Platform expects price per smallest unit, not per token + let decimal_divisor_8 = 10u64.pow(token_decimals_8 as u32); + let platform_price_per_smallest_unit = user_price_per_token_credits / decimal_divisor_8; + + println!( + " Platform stores: {} credits per smallest unit", + platform_price_per_smallest_unit + ); + + // When buying 1 token (100,000,000 smallest units) + let tokens_to_buy = 1u64; + let amount_smallest_units = tokens_to_buy * 10u64.pow(token_decimals_8 as u32); + let total_price = amount_smallest_units * platform_price_per_smallest_unit; + + println!( + " Buying {} token ({} smallest units)", + tokens_to_buy, amount_smallest_units + ); + println!( + " Total: {} credits (should be {} credits for 0.001 DASH)", + total_price, user_price_per_token_credits + ); + + assert_eq!( + total_price, user_price_per_token_credits, + "Total should match expected price" + ); + + // Case 2: Token with 2 decimals + let token_decimals_2 = 2u8; + let user_price_2 = 0.1; // 0.1 DASH per token + let user_price_credits_2 = (user_price_2 * 10f64.powi(DASH_DECIMAL_PLACES as i32)) as u64; + + let divisor_2 = 10u64.pow(token_decimals_2 as u32); + let platform_price_2 = user_price_credits_2 / divisor_2; + + // Buy 5 tokens + let amount_2 = 5 * 10u64.pow(token_decimals_2 as u32); // 500 smallest units + let total_2 = amount_2 * platform_price_2; + + println!("\nTest 2 - Token with 2 decimals, 5 tokens at 0.1 DASH each:"); + println!( + " Platform price: {} credits per smallest unit", + platform_price_2 + ); + println!(" Total for 5 tokens: {} credits", total_2); + + assert_eq!( + total_2, + 5 * user_price_credits_2, + "Should be 0.5 DASH total" + ); + + // Case 3: Token with 0 decimals + let _token_decimals_0 = 0u8; + let user_price_0 = 0.05; // 0.05 DASH per token + let user_price_credits_0 = (user_price_0 * 10f64.powi(DASH_DECIMAL_PLACES as i32)) as u64; + + // With 0 decimals, price per token = price per smallest unit + let platform_price_0 = user_price_credits_0; // No division needed + + let amount_0 = 10; // 10 tokens = 10 smallest units (no decimals) + let total_0 = amount_0 * platform_price_0; + + println!("\nTest 3 - Token with 0 decimals, 10 tokens at 0.05 DASH each:"); + println!( + " Platform price: {} credits per smallest unit", + platform_price_0 + ); + println!(" Total for 10 tokens: {} credits", total_0); + + assert_eq!( + total_0, + 10 * user_price_credits_0, + "Should be 0.5 DASH total" + ); + } +} diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 977ff1b09..36d699e94 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -3,7 +3,10 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; +use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::wallet::Wallet; +use crate::ui::components::ComponentResponse; +use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; @@ -22,6 +25,7 @@ use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; +use dash_sdk::dpp::data_contract::associated_token::token_configuration_convention::accessors::v0::TokenConfigurationConventionV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use dash_sdk::dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; use dash_sdk::dpp::data_contract::group::Group; @@ -46,6 +50,24 @@ pub enum PricingType { RemovePricing, } +impl From for PricingType { + fn from(schedule: TokenPricingSchedule) -> Self { + match schedule { + TokenPricingSchedule::SinglePrice(_) => PricingType::SinglePrice, + TokenPricingSchedule::SetPrices(_) => PricingType::TieredPricing, + } + } +} + +impl From> for PricingType { + fn from(schedule: Option) -> Self { + match schedule { + Some(schedule) => PricingType::from(schedule), + None => PricingType::RemovePricing, + } + } +} + /// Internal states for the mint process. #[derive(PartialEq)] pub enum SetTokenPriceStatus { @@ -65,9 +87,15 @@ pub struct SetTokenPriceScreen { pub group_action_id: Option, pub token_pricing_schedule: String, - pricing_type: PricingType, - single_price: String, - tiered_prices: Vec<(String, String)>, + /// Token pricing schedule to use; if None, we will remove the pricing schedule + pub pricing_type: PricingType, + + // AmountInput components for pricing - following the design pattern + single_price_amount: Option, + single_price_input: Option, + + // Tiered pricing with AmountInput components + pub tiered_prices: Vec<(Option, Option)>, // (amount_input, price_input) status: SetTokenPriceStatus, error_message: Option, @@ -84,14 +112,50 @@ pub struct SetTokenPriceScreen { show_password: bool, } +/// 1 Dash = 100,000,000,000 credits +pub const CREDITS_PER_DASH: Credits = 100_000_000_000; + impl SetTokenPriceScreen { - /// Converts Dash amount to credits (1 Dash = 100,000,000,000 credits) - fn dash_to_credits(dash_amount: f64) -> Credits { - (dash_amount * 100_000_000_000.0) as Credits + fn token_decimal_divisor(&self) -> u64 { + 10u64.pow( + self.identity_token_info + .token_config + .conventions() + .decimals() as u32, + ) + } + + fn minimum_price_amount(&self) -> Amount { + Amount::new(self.token_decimal_divisor(), DASH_DECIMAL_PLACES).with_unit_name("DASH") + } + + fn validate_price_for_token(&self, price: &Amount) -> Result { + let credits_price_per_token = price.value(); + if credits_price_per_token == 0 { + return Err("Price must be greater than 0".to_string()); + } + + let decimal_divisor = self.token_decimal_divisor(); + + if credits_price_per_token < decimal_divisor { + return Err(format!( + "Price too low for this token's precision. Minimum price is {}.", + self.minimum_price_amount() + )); + } + + if credits_price_per_token % decimal_divisor != 0 { + return Err(format!( + "Price must be in multiples of {} to match the token decimals.", + self.minimum_price_amount() + )); + } + + Ok(credits_price_per_token / decimal_divisor) } pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { - let possible_key = identity_token_info + let possible_key: Option<&IdentityPublicKey> = identity_token_info .identity .identity .get_first_public_key_matching( @@ -202,9 +266,10 @@ impl SetTokenPriceScreen { is_unilateral_group_member, group_action_id: None, token_pricing_schedule: "".to_string(), - pricing_type: PricingType::SinglePrice, - single_price: "".to_string(), - tiered_prices: vec![("1".to_string(), "".to_string())], + pricing_type: PricingType::RemovePricing, + single_price_amount: None, + single_price_input: None, + tiered_prices: vec![(None, None)], status: SetTokenPriceStatus::NotStarted, error_message: None, app_context: app_context.clone(), @@ -216,6 +281,57 @@ impl SetTokenPriceScreen { } } + pub fn with_schedule(self, token_pricing_schedule: Option) -> Self { + let token_decimals = self + .identity_token_info + .token_config + .conventions() + .decimals(); + let decimal_multiplier = 10u64.pow(token_decimals as u32); + + let (single_price_amount, tiered_prices) = match &token_pricing_schedule { + Some(TokenPricingSchedule::SinglePrice(price_per_smallest_unit)) => { + // Convert price per smallest unit back to price per token for display + let price_per_token = price_per_smallest_unit * decimal_multiplier; + let amount = + Amount::new(price_per_token, DASH_DECIMAL_PLACES).with_unit_name("DASH"); + (Some(amount), vec![(None, None)]) + } + Some(TokenPricingSchedule::SetPrices(prices)) => { + let tiered_prices = prices + .iter() + .map(|(amount, price_per_smallest_unit)| { + // Create amount input for token threshold + let amount_input = AmountInput::new(Amount::from_token( + &self.identity_token_info, + *amount, + )) + .with_hint_text("Token amount threshold"); + + // Convert price per smallest unit back to price per token for display + let price_per_token = price_per_smallest_unit * decimal_multiplier; + let price = Amount::new(price_per_token, DASH_DECIMAL_PLACES) + .with_unit_name("DASH"); + let price_input = AmountInput::new(price) + .with_hint_text("Enter price in Dash") + .with_min_amount(Some(1)); + (Some(amount_input), Some(price_input)) + }) + .collect::>(); + + (None, tiered_prices) + } + None => (None, vec![(None, None)]), + }; + + Self { + pricing_type: PricingType::from(token_pricing_schedule), + single_price_amount, + tiered_prices, + ..self + } + } + /// Renders the pricing input UI fn render_pricing_input(&mut self, ui: &mut Ui) { // Radio buttons for pricing type @@ -242,30 +358,47 @@ impl SetTokenPriceScreen { match self.pricing_type { PricingType::SinglePrice => { ui.label("Set a fixed price per token:"); - ui.horizontal(|ui| { - ui.label("Price per token (Dash):"); - ui.text_edit_singleline(&mut self.single_price); + + if self.token_decimal_divisor() > 1 { + ui.colored_label( + Color32::DARK_RED, + format!( + "Prices must be multiples of {} to match this token's precision.", + self.minimum_price_amount() + ), + ); + } + + // Lazy initialization of AmountInput following the design pattern + let single_price_input = self.single_price_input.get_or_insert_with(|| { + let initial_amount = self + .single_price_amount + .as_ref() + .cloned() + .unwrap_or_else(|| Amount::new_dash(0.0)); + AmountInput::new(initial_amount) + .with_label("Price per token:") + .with_hint_text("Enter price in Dash") + .with_min_amount(Some(1)) // Minimum 1 credit (very small amount) }); - // Show preview - if !self.single_price.is_empty() { - if let Ok(price) = self.single_price.parse::() { - if price > 0.0 { - ui.add_space(5.0); - let credits = Self::dash_to_credits(price); - ui.colored_label( - Color32::DARK_GREEN, - format!("Price: {} Dash per token ({} credits)", price, credits), - ); - } else { - ui.colored_label(Color32::DARK_RED, "X Price must be greater than 0"); - } - } else { - ui.colored_label( - Color32::DARK_RED, - "X Invalid price - must be a positive number", - ); - } + let response = single_price_input.show(ui); + + // Update the domain data if there's a valid change + if response.inner.has_changed() && response.inner.is_valid() { + self.single_price_amount = response.inner.changed_value().clone(); + } + + // Show validation preview + if let Some(amount) = &self.single_price_amount + && amount.value() > 0 + { + ui.add_space(5.0); + let credits = amount.value(); + ui.colored_label( + Color32::DARK_GREEN, + format!("Price: {} per token ({} credits)", amount, credits), + ); } } PricingType::TieredPricing => { @@ -313,50 +446,44 @@ impl SetTokenPriceScreen { }); }) .body(|mut body| { - for (i, (amount, price)) in self.tiered_prices.iter_mut().enumerate() { - body.row(25.0, |mut row| { + for i in 0..self.tiered_prices.len() { + body.row(30.0, |mut row| { row.col(|ui| { if i == 0 { - // First tier is hardcoded to 1 token - ui.label("1"); - *amount = "1".to_string(); // Ensure it's always 1 + // First tier is hardcoded to 1 token - create AmountInput with value 1 + let amount_input = + self.tiered_prices[i].0.get_or_insert_with(|| { + AmountInput::new(Amount::from_token( + &self.identity_token_info, + 1, + )) + .with_hint_text("Token amount threshold") + }); + amount_input.show(ui); + // Make sure it's always 1 - we could disable editing or show as read-only } else { - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.add( - egui::TextEdit::singleline(amount) - .hint_text( - RichText::new("100").color(Color32::GRAY), - ) - .desired_width(100.0) - .text_color( - crate::ui::theme::DashColors::text_primary( - dark_mode, - ), - ) - .background_color( - crate::ui::theme::DashColors::input_background( - dark_mode, - ), - ), - ); + // Other tiers use AmountInput for token amounts + let amount_input = + self.tiered_prices[i].0.get_or_insert_with(|| { + AmountInput::new(Amount::from_token( + &self.identity_token_info, + 0, + )) + .with_hint_text("Token amount threshold") + }); + amount_input.show(ui); } }); row.col(|ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.add( - egui::TextEdit::singleline(price) - .hint_text(RichText::new("50").color(Color32::GRAY)) - .desired_width(120.0) - .text_color(crate::ui::theme::DashColors::text_primary( - dark_mode, - )) - .background_color( - crate::ui::theme::DashColors::input_background( - dark_mode, - ), - ), - ); - ui.label(" Dash"); + // Use AmountInput for price with lazy initialization + let price_input = + self.tiered_prices[i].1.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .with_hint_text("Enter price in Dash") + .with_min_amount(Some(1)) // Minimum 1 credit + }); + + let _response = price_input.show(ui); }); row.col(|ui| { if can_remove && i > 0 && ui.small_button("X").clicked() { @@ -374,8 +501,8 @@ impl SetTokenPriceScreen { ui.add_space(10.0); ui.horizontal(|ui| { if ui.button("+ Add Tier").clicked() { - // Add empty tier - user will fill in values - self.tiered_prices.push(("".to_string(), "".to_string())); + // Add empty tier with lazy initialization + self.tiered_prices.push((None, None)); } }); @@ -394,19 +521,20 @@ impl SetTokenPriceScreen { let mut valid_tiers = Vec::new(); let mut has_errors = false; - for (amount_str, price_str) in &self.tiered_prices { - if amount_str.trim().is_empty() || price_str.trim().is_empty() { - continue; - } + for (amount_input, price_input) in &self.tiered_prices { + let Some(price) = price_input.as_ref().and_then(|input| input.current_value()) else { + continue; // Skip if no price input is available + }; - match (amount_str.parse::(), price_str.parse::()) { - (Ok(amount), Ok(price)) if price > 0.0 => { - valid_tiers.push((amount, price)); - } - _ => { - has_errors = true; - } - } + let Some(amount_value) = amount_input + .as_ref() + .and_then(|input| input.current_value()) + else { + has_errors = true; + continue; // Skip if amount is invalid + }; + + valid_tiers.push((amount_value, price)); } // Only show preview if there are valid tiers or errors @@ -414,7 +542,7 @@ impl SetTokenPriceScreen { ui.group(|ui| { // Sort tiers by amount if !valid_tiers.is_empty() { - valid_tiers.sort_by_key(|(amount, _)| *amount); + valid_tiers.sort_by_key(|(amount, _)| amount.value()); } if has_errors { @@ -424,12 +552,20 @@ impl SetTokenPriceScreen { if !valid_tiers.is_empty() { ui.colored_label(Color32::DARK_GREEN, "Pricing Structure:"); for (amount, price) in &valid_tiers { - let credits = Self::dash_to_credits(*price); + let credits = price.value(); ui.label(format!( - " - {} or more tokens: {} Dash each ({} credits)", + " - {} or more tokens: {} each ({} credits)", amount, price, credits )); } + + if self.token_decimal_divisor() > 1 { + ui.add_space(5.0); + ui.label(format!( + "Each tier price must be a multiple of {}.", + self.minimum_price_amount() + )); + } } }); } @@ -439,49 +575,36 @@ impl SetTokenPriceScreen { fn create_pricing_schedule(&self) -> Result, String> { match self.pricing_type { PricingType::RemovePricing => Ok(None), - PricingType::SinglePrice => { - if self.single_price.trim().is_empty() { - return Err("Please enter a price".to_string()); - } - match self.single_price.trim().parse::() { - Ok(dash_price) if dash_price > 0.0 => { - let credits_price = Self::dash_to_credits(dash_price); - Ok(Some(TokenPricingSchedule::SinglePrice(credits_price))) - } - Ok(_) => Err("Price must be greater than 0".to_string()), - Err(_) => Err("Invalid price - must be a positive number".to_string()), - } - } + PricingType::SinglePrice => match &self.single_price_amount { + Some(amount) => self + .validate_price_for_token(amount) + .map(|price| Some(TokenPricingSchedule::SinglePrice(price))), + None => Err("Please enter a price".to_string()), + }, PricingType::TieredPricing => { let mut map = std::collections::BTreeMap::new(); - for (amount_str, price_str) in &self.tiered_prices { - if amount_str.trim().is_empty() || price_str.trim().is_empty() { + for (amount_input, price_input) in &self.tiered_prices { + let Some(price) = price_input.as_ref().and_then(|input| input.current_value()) + else { continue; - } + }; - let amount = amount_str.trim().parse::().map_err(|_| { - format!( - "Invalid amount '{}' - must be a positive number", - amount_str.trim() - ) - })?; - let dash_price = price_str.trim().parse::().map_err(|_| { - format!( - "Invalid price '{}' - must be a positive number", - price_str.trim() - ) - })?; - - if dash_price <= 0.0 { - return Err(format!( - "Price '{}' must be greater than 0", - price_str.trim() - )); + let Some(amount_value) = amount_input + .as_ref() + .and_then(|input| input.current_value()) + else { + continue; + }; + + let amount = amount_value.value(); + if amount == 0 { + continue; } - let credits_price = Self::dash_to_credits(dash_price); - map.insert(amount, credits_price); + let price_per_smallest_unit = self.validate_price_for_token(&price)?; + + map.insert(amount, price_per_smallest_unit); } if map.is_empty() { @@ -497,45 +620,31 @@ impl SetTokenPriceScreen { fn validate_pricing_configuration(&self) -> Result<(), String> { match self.pricing_type { PricingType::RemovePricing => Ok(()), - PricingType::SinglePrice => { - if self.single_price.trim().is_empty() { - return Err("Please enter a price".to_string()); - } - match self.single_price.trim().parse::() { - Ok(price) if price > 0.0 => Ok(()), - Ok(_) => Err("Price must be greater than 0".to_string()), - Err(_) => Err("Invalid price format - must be a positive number".to_string()), - } - } + PricingType::SinglePrice => match &self.single_price_amount { + Some(amount) => self.validate_price_for_token(amount).map(|_| ()), + None => Err("Please enter a price".to_string()), + }, PricingType::TieredPricing => { let mut valid_tiers = 0; - for (amount_str, price_str) in &self.tiered_prices { - if amount_str.trim().is_empty() || price_str.trim().is_empty() { + for (amount_input, price_input) in &self.tiered_prices { + let Some(price) = price_input.as_ref().and_then(|input| input.current_value()) + else { continue; - } + }; - let _amount = amount_str.trim().parse::().map_err(|_| { - format!( - "Invalid amount '{}' - must be a whole number", - amount_str.trim() - ) - })?; + let Some(amount_value) = amount_input + .as_ref() + .and_then(|input| input.current_value()) + else { + continue; + }; - let price = price_str.trim().parse::().map_err(|_| { - format!( - "Invalid price '{}' - must be a positive number", - price_str.trim() - ) - })?; - - if price <= 0.0 { - return Err(format!( - "Price '{}' must be greater than 0", - price_str.trim() - )); + if amount_value.value() == 0 { + continue; } + self.validate_price_for_token(&price)?; valid_tiers += 1; } @@ -559,10 +668,10 @@ impl SetTokenPriceScreen { "WARNING: Are you sure you want to remove the pricing schedule? This will make the token unavailable for direct purchase.".to_string() } PricingType::SinglePrice => { - if let Ok(dash_price) = self.single_price.trim().parse::() { + if let Some(amount) = &self.single_price_amount { format!( - "Are you sure you want to set a fixed price of {} Dash per token?", - dash_price + "Are you sure you want to set a fixed price of {} per token?", + amount ) } else { "Are you sure you want to set the pricing schedule?".to_string() @@ -570,20 +679,22 @@ impl SetTokenPriceScreen { } PricingType::TieredPricing => { let mut message = "Are you sure you want to set the following tiered pricing?".to_string(); - for (amount_str, price_str) in &self.tiered_prices { - if amount_str.trim().is_empty() || price_str.trim().is_empty() { + for (amount_input, price_input) in &self.tiered_prices { + let Some(price) = price_input.as_ref().and_then(|input| input.current_value()) else { + continue; // Skip if no price input is available + }; + + let Some(amount_value) = amount_input + .as_ref() + .and_then(|input| input.current_value()) + else { continue; - } - if let (Ok(amount), Ok(dash_price)) = ( - amount_str.trim().parse::(), - price_str.trim().parse::(), - ) { - message.push_str(&format!( - " - - {} or more tokens: {} Dash each", - amount, dash_price - )); - } + }; + + message.push_str(&format!( + "\n - {} or more tokens: {} each", + amount_value, price + )); } message } diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index dee816ee8..99094982e 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -611,30 +611,31 @@ impl WalletsBalancesScreen { .corner_radius(4.0); if ui.add(remove_button).clicked() - && let Some(selected_wallet) = &self.selected_wallet { - let wallet = selected_wallet.read().unwrap(); - let alias = wallet - .alias - .clone() - .unwrap_or_else(|| "Unnamed Wallet".to_string()); - let seed_hash = wallet.seed_hash(); - drop(wallet); - - self.pending_wallet_removal = Some(seed_hash); - self.pending_wallet_removal_alias = Some(alias.clone()); - - let message = format!( - "Removing wallet \"{}\" will delete its local data, including addresses, balances, and asset locks stored on this device. Identities linked to it will remain but the keys derived from this wallet will no longer work unless the wallet is re-imported. Continue?", - alias - ); + && let Some(selected_wallet) = &self.selected_wallet + { + let wallet = selected_wallet.read().unwrap(); + let alias = wallet + .alias + .clone() + .unwrap_or_else(|| "Unnamed Wallet".to_string()); + let seed_hash = wallet.seed_hash(); + drop(wallet); + + self.pending_wallet_removal = Some(seed_hash); + self.pending_wallet_removal_alias = Some(alias.clone()); + + let message = format!( + "Removing wallet \"{}\" will delete its local data, including addresses, balances, and asset locks stored on this device. Identities linked to it will remain but the keys derived from this wallet will no longer work unless the wallet is re-imported. Continue?", + alias + ); - self.remove_wallet_dialog = Some( - ConfirmationDialog::new("Remove Wallet", message) - .confirm_text(Some("Remove")) - .cancel_text(Some("Cancel")) - .danger_mode(true), - ); - } + self.remove_wallet_dialog = Some( + ConfirmationDialog::new("Remove Wallet", message) + .confirm_text(Some("Remove")) + .cancel_text(Some("Cancel")) + .danger_mode(true), + ); + } if let Some(dialog) = self.remove_wallet_dialog.as_mut() { let response = dialog.show(ui); From 94d1721b811f12e93cbab439399ac501400d8927 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Tue, 21 Oct 2025 14:33:58 +0700 Subject: [PATCH 029/106] fix: showing used asset locks as unused (#448) --- .../identity/register_identity.rs | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index 63f9258a3..9cc7b06e4 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -225,14 +225,15 @@ impl AppContext { let public_keys = keys.to_public_keys_map(); - match Identity::fetch_by_identifier(&sdk, identity_id).await { - Ok(Some(_)) => return Err("Identity already exists".to_string()), - Ok(None) => {} + let existing_identity = match Identity::fetch_by_identifier(&sdk, identity_id).await { + Ok(result) => result, Err(e) => return Err(format!("Error fetching identity: {}", e)), }; - let identity = Identity::new_with_id_and_keys(identity_id, public_keys, sdk.version()) - .expect("expected to make identity"); + let identity = existing_identity.clone().unwrap_or_else(|| { + Identity::new_with_id_and_keys(identity_id, public_keys, sdk.version()) + .expect("expected to make identity") + }); let wallet_seed_hash = { wallet.read().unwrap().seed_hash() }; let mut qualified_identity = QualifiedIdentity { @@ -258,6 +259,42 @@ impl AppContext { qualified_identity.alias = Some(alias_input); } + if let Some(existing_identity) = existing_identity { + qualified_identity.identity = existing_identity; + qualified_identity.status = IdentityStatus::Unknown; + + self.insert_local_qualified_identity( + &qualified_identity, + &Some((wallet_id, wallet_identity_index)), + ) + .map_err(|e| e.to_string())?; + + { + let mut wallet = wallet.write().unwrap(); + wallet + .unused_asset_locks + .retain(|(tx, _, _, _, _)| tx.txid() != tx_id); + wallet + .identities + .insert(wallet_identity_index, qualified_identity.identity.clone()); + } + + self.db + .set_asset_lock_identity_id(tx_id.as_byte_array(), identity_id.as_bytes()) + .map_err(|e| e.to_string())?; + + sender + .send(TaskResult::Success(Box::new( + BackendTaskSuccessResult::None, + ))) + .await + .map_err(|e| e.to_string())?; + + return Ok(BackendTaskSuccessResult::RegisteredIdentity( + qualified_identity, + )); + } + self.insert_local_qualified_identity( &qualified_identity, &Some((wallet_id, wallet_identity_index)), From adc0911fda0d6b972c0d69c331b6b549ee70253e Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Tue, 21 Oct 2025 14:45:57 +0700 Subject: [PATCH 030/106] chore: remove unused task sender --- src/backend_task/identity/register_identity.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index 9cc7b06e4..a3505ecce 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -283,13 +283,6 @@ impl AppContext { .set_asset_lock_identity_id(tx_id.as_byte_array(), identity_id.as_bytes()) .map_err(|e| e.to_string())?; - sender - .send(TaskResult::Success(Box::new( - BackendTaskSuccessResult::None, - ))) - .await - .map_err(|e| e.to_string())?; - return Ok(BackendTaskSuccessResult::RegisteredIdentity( qualified_identity, )); From b1d907b5ff624a0521f3b6a0cf668564543b626a Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Tue, 21 Oct 2025 17:03:18 +0700 Subject: [PATCH 031/106] chore: update to Platform V10 --- src/context.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/context.rs b/src/context.rs index f8f356e68..e8132edac 100644 --- a/src/context.rs +++ b/src/context.rs @@ -31,8 +31,7 @@ use dash_sdk::dpp::state_transition::StateTransitionSigningOptions; use dash_sdk::dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; use dash_sdk::dpp::version::PlatformVersion; -use dash_sdk::dpp::version::v8::PLATFORM_V8; -use dash_sdk::dpp::version::v9::PLATFORM_V9; +use dash_sdk::dpp::version::v10::PLATFORM_V10; use dash_sdk::platform::{DataContract, Identifier}; use dash_sdk::query_types::IndexMap; use egui::Context; @@ -883,10 +882,10 @@ impl AppContext { pub(crate) const fn default_platform_version(network: &Network) -> &'static PlatformVersion { // TODO: Use self.sdk.read().unwrap().version() instead of hardcoding match network { - Network::Dash => &PLATFORM_V8, - Network::Testnet => &PLATFORM_V9, - Network::Devnet => &PLATFORM_V9, - Network::Regtest => &PLATFORM_V9, + Network::Dash => &PLATFORM_V10, + Network::Testnet => &PLATFORM_V10, + Network::Devnet => &PLATFORM_V10, + Network::Regtest => &PLATFORM_V10, _ => panic!("unsupported network"), } } From c54e6b71214602757cf184a90b6cab9285bfc839 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Fri, 24 Oct 2025 10:43:55 +0700 Subject: [PATCH 032/106] feat: GroveSTARK ZK proofs (#450) * works * works * fix * ok * ok * ok * ok * ok * fix verification message showing in generation screen * remove advanced settings. set default 16 grinding bits and 128 bit security * ok * lock * ok * clippy * fmt * clippy and fmt * pin grovestark dep * fix * ok * clippy * rename file * remove ProofData * cleanup * fix * rename GroveStark to GroveSTARK * rename zk_proofs_screen to grovestark_screen * rename ZKProofs tool subscreen to GroveSTARK * move grovestark prover to model * rename ContractsDashpayComingSoon to ContractsDashpay * rename ContractsDashpay to Dashpay * rename dashpay stuff * fixes * ok * update grovestark rev * update --- Cargo.lock | 3397 ++++++++++++----- Cargo.toml | 9 +- src/app.rs | 21 +- src/backend_task/grovestark.rs | 65 + src/backend_task/mod.rs | 30 +- src/context.rs | 6 +- src/model/grovestark_prover.rs | 530 +++ src/model/mod.rs | 1 + src/model/wallet/encryption.rs | 9 +- .../contracts_subscreen_chooser_panel.rs | 12 +- .../tools_subscreen_chooser_panel.rs | 10 +- .../contracts_documents_screen.rs | 2 +- .../dashpay_coming_soon_screen.rs | 8 +- src/ui/dpns/dpns_contested_names_screen.rs | 2 +- src/ui/mod.rs | 54 +- src/ui/tokens/tokens_screen/distributions.rs | 5 +- src/ui/tools/grovestark_screen.rs | 1183 ++++++ src/ui/tools/mod.rs | 1 + 18 files changed, 4277 insertions(+), 1068 deletions(-) create mode 100644 src/backend_task/grovestark.rs create mode 100644 src/model/grovestark_prover.rs create mode 100644 src/ui/tools/grovestark_screen.rs diff --git a/Cargo.lock b/Cargo.lock index 23e554f25..8d6834125 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "ab_glyph" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e074464580a518d16a7126262fffaaa47af89d4099d4cb403f8ed938ba12ee7d" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" dependencies = [ "ab_glyph_rasterizer", "owned_ttf_parser", @@ -94,8 +94,8 @@ dependencies = [ "accesskit_consumer", "hashbrown 0.15.5", "static_assertions", - "windows", - "windows-core", + "windows 0.61.3", + "windows-core 0.61.2", ] [[package]] @@ -112,15 +112,6 @@ dependencies = [ "winit", ] -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -134,7 +125,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "crypto-common", - "generic-array 0.14.7", + "generic-array 0.14.9", ] [[package]] @@ -169,7 +160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "serde", "version_check", @@ -198,7 +189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046" dependencies = [ "android-properties", - "bitflags 2.9.1", + "bitflags 2.10.0", "cc", "cesu8", "jni", @@ -207,8 +198,8 @@ dependencies = [ "log", "ndk", "ndk-context", - "ndk-sys", - "num_enum 0.7.4", + "ndk-sys 0.6.0+11769913", + "num_enum 0.7.5", "thiserror 1.0.69", ] @@ -218,12 +209,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -235,9 +220,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.20" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -250,9 +235,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.11" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" @@ -285,36 +270,36 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arbitrary" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" dependencies = [ "derive_arbitrary", ] [[package]] name = "arboard" -version = "3.6.0" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55f533f8e0af236ffe5eb979b99381df3258853f00ba2e44b6e1955292c75227" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.1", - "objc2-app-kit 0.3.1", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "x11rb", ] @@ -336,6 +321,70 @@ dependencies = [ "password-hash", ] +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "derivative", + "digest", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-std", + "digest", + "num-bigint", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand 0.8.5", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -360,6 +409,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + [[package]] name = "ashpd" version = "0.10.3" @@ -437,9 +495,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb812ffb58524bdd10860d7d974e2f01cc0950c2438a74ee5ec2e2280c6c4ffa" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" dependencies = [ "async-task", "concurrent-queue", @@ -451,9 +509,9 @@ dependencies = [ [[package]] name = "async-fs" -version = "2.1.3" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f7e37c0ed80b2a977691c47dae8625cfb21e205827106c64f7c588766b2e50" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ "async-lock", "blocking", @@ -477,20 +535,20 @@ dependencies = [ [[package]] name = "async-io" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19634d6336019ef220f09fd31168ce5c184b295cbf80345437cc36094ef223ca" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ - "async-lock", + "autocfg", "cfg-if", "concurrent-queue", "futures-io", "futures-lite", "parking", "polling", - "rustix 1.0.8", + "rustix 1.1.2", "slab", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -517,9 +575,9 @@ dependencies = [ [[package]] name = "async-process" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65daa13722ad51e6ab1a1b9c01299142bc75135b337923cfa10e79bbbd669f00" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ "async-channel 2.5.0", "async-io", @@ -530,7 +588,7 @@ dependencies = [ "cfg-if", "event-listener 5.4.1", "futures-lite", - "rustix 1.0.8", + "rustix 1.1.2", ] [[package]] @@ -541,14 +599,14 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] name = "async-signal" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f567af260ef69e1d52c2b560ce0ea230763e6fbb9214a85d768760a920e3e3c1" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" dependencies = [ "async-io", "async-lock", @@ -556,17 +614,17 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.0.8", + "rustix 1.1.2", "signal-hook-registry", "slab", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "async-std" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "730294c1c08c2e0f85759590518f6333f0d5a0a766a27d519c1b244c3dfd8a24" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" dependencies = [ "async-channel 1.9.0", "async-global-executor", @@ -596,13 +654,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -682,29 +740,14 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "backon" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "592277618714fbcecda9a02ba7a8781f319d26532a88553bbacc77ba5d2b3a8d" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ "fastrand", "tokio", ] -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - [[package]] name = "base16ct" version = "0.2.0" @@ -754,6 +797,15 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bincode" version = "2.0.0-rc.3" @@ -773,6 +825,24 @@ dependencies = [ "virtue 0.0.13", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.107", +] + [[package]] name = "bip37-bloom-filter" version = "0.1.0" @@ -873,11 +943,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -914,13 +984,19 @@ dependencies = [ "constant_time_eq", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array 0.14.7", + "generic-array 0.14.9", ] [[package]] @@ -934,11 +1010,11 @@ dependencies = [ [[package]] name = "block2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "340d2f0bdb2a43c1d3cd40513185b2bd7def0aa1052f956455114bc98f82dcf2" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.1", + "objc2 0.6.3", ] [[package]] @@ -956,8 +1032,34 @@ dependencies = [ [[package]] name = "blsful" -version = "3.0.0-pre8" -source = "git+https://github.com/dashpay/agora-blsful?rev=be108b2cf6ac64eedbe04f91c63731533c8956bc#be108b2cf6ac64eedbe04f91c63731533c8956bc" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d267776bf4742935d219fcdbdf590bed0f7e5fccdf5bd168fb30b2543a0b2b24" +dependencies = [ + "anyhow", + "blstrs_plus", + "hex", + "hkdf", + "merlin", + "pairing", + "rand 0.8.5", + "rand_chacha 0.3.1", + "rand_core 0.6.4", + "serde", + "serde_bare", + "sha2", + "sha3", + "subtle", + "thiserror 2.0.17", + "uint-zigzag", + "vsss-rs 5.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "zeroize", +] + +[[package]] +name = "blsful" +version = "3.0.0" +source = "git+https://github.com/dashpay/agora-blsful?rev=0c34a7a488a0bd1c9a9a2196e793b303ad35c900#0c34a7a488a0bd1c9a9a2196e793b303ad35c900" dependencies = [ "anyhow", "blstrs_plus", @@ -973,9 +1075,9 @@ dependencies = [ "sha2", "sha3", "subtle", - "thiserror 2.0.14", + "thiserror 2.0.17", "uint-zigzag", - "vsss-rs", + "vsss-rs 5.1.0 (git+https://github.com/dashpay/vsss-rs?branch=main)", "zeroize", ] @@ -1026,22 +1128,22 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytemuck" -version = "1.23.2" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.1" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f154e572231cb6ba2bd1176980827e3d5dc04cc183a75dea38109fbdd672d29" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -1065,13 +1167,23 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "calloop" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "log", "polling", "rustix 0.38.44", @@ -1093,10 +1205,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.32" +version = "1.2.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2352e5597e9c544d5e6d9c95190d5d27738ade584fa8db0a16e130e5c2b5296e" +checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -1108,6 +1221,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfg-expr" version = "0.15.8" @@ -1120,9 +1242,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -1141,17 +1263,16 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1222,6 +1343,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -1248,6 +1380,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "colored" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "combine" version = "4.6.7" @@ -1424,7 +1565,7 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array 0.14.7", + "generic-array 0.14.9", "rand_core 0.6.4", "serdect", "subtle", @@ -1437,7 +1578,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ - "generic-array 0.14.7", + "generic-array 0.14.9", "rand_core 0.6.4", "typenum", ] @@ -1481,36 +1622,64 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", +] + +[[package]] +name = "dapi-grpc" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "dapi-grpc-macros 2.0.1", + "futures-core", + "getrandom 0.2.16", + "platform-version 2.0.1", + "prost 0.13.5", + "serde", + "serde_bytes", + "serde_json", + "tenderdash-proto 1.4.0", + "tonic 0.13.1", + "tonic-build 0.13.1", ] [[package]] name = "dapi-grpc" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ - "dapi-grpc-macros", + "dapi-grpc-macros 2.1.0-rc.1", "futures-core", "getrandom 0.2.16", - "platform-version", - "prost", + "platform-version 2.1.0-rc.1", + "prost 0.14.1", "serde", "serde_bytes", "serde_json", - "tenderdash-proto", - "tonic", + "tenderdash-proto 1.5.0-dev.2", + "tonic 0.14.2", "tonic-prost", "tonic-prost-build", ] [[package]] name = "dapi-grpc-macros" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "heck", + "quote", + "syn 2.0.107", +] + +[[package]] +name = "dapi-grpc-macros" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ "heck", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -1548,7 +1717,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -1559,16 +1728,16 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] name = "dash-context-provider" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ - "dpp", - "drive", + "dpp 2.1.0-rc.1", + "drive 2.1.0-rc.1", "hex", "serde", "serde_json", @@ -1583,17 +1752,18 @@ dependencies = [ "arboard", "argon2", "base64 0.22.1", - "bincode", + "bincode 2.0.0-rc.3", "bip39", - "bitflags 2.9.1", + "bitflags 2.10.0", "chrono", "chrono-humanize", "crossbeam-channel", "dark-light", - "dash-sdk", + "dash-sdk 2.1.0-rc.1", "derive_more 2.0.1", "directories", "dotenvy", + "ed25519-dalek", "eframe", "egui", "egui_commonmark", @@ -1602,6 +1772,7 @@ dependencies = [ "enum-iterator", "envy", "futures", + "grovestark", "hex", "humantime", "image", @@ -1612,6 +1783,7 @@ dependencies = [ "qrcode", "rand 0.8.5", "raw-cpuid", + "rayon", "regex", "rfd", "rusqlite", @@ -1621,7 +1793,7 @@ dependencies = [ "serde_yaml", "sha2", "tempfile", - "thiserror 2.0.14", + "thiserror 2.0.17", "tokio", "tokio-util", "tracing", @@ -1637,9 +1809,9 @@ dependencies = [ [[package]] name = "dash-network" version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=befd0356bebfcd0d06d1028d8a03bfa4c78bd219#befd0356bebfcd0d06d1028d8a03bfa4c78bd219" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.40.0#c877c1a74d145e2003d549619698511513db925c" dependencies = [ - "bincode", + "bincode 2.0.0-rc.3", "bincode_derive", "hex", "serde", @@ -1647,8 +1819,43 @@ dependencies = [ [[package]] name = "dash-sdk" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "arc-swap", + "async-trait", + "backon", + "bip37-bloom-filter", + "chrono", + "ciborium", + "dapi-grpc 2.0.1", + "dapi-grpc-macros 2.0.1", + "dashcore-rpc 0.39.6", + "derive_more 1.0.0", + "dotenvy", + "dpp 2.0.1", + "drive 2.0.1", + "drive-proof-verifier 2.0.1", + "envy", + "futures", + "hex", + "http", + "lru", + "rs-dapi-client 2.0.1", + "rustls-pemfile", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tokio-util", + "tracing", + "zeroize", +] + +[[package]] +name = "dash-sdk" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ "arc-swap", "async-trait", @@ -1656,47 +1863,69 @@ dependencies = [ "bip37-bloom-filter", "chrono", "ciborium", - "dapi-grpc", - "dapi-grpc-macros", + "dapi-grpc 2.1.0-rc.1", + "dapi-grpc-macros 2.1.0-rc.1", "dash-context-provider", "derive_more 1.0.0", "dotenvy", - "dpp", - "drive", - "drive-proof-verifier", + "dpp 2.1.0-rc.1", + "drive 2.1.0-rc.1", + "drive-proof-verifier 2.1.0-rc.1", "envy", "futures", "hex", "http", "js-sys", "lru", - "rs-dapi-client", + "rs-dapi-client 2.1.0-rc.1", "rustls-pemfile", "serde", "serde_json", - "thiserror 2.0.14", + "thiserror 2.0.17", "tokio", "tokio-util", "tracing", "zeroize", ] +[[package]] +name = "dashcore" +version = "0.39.6" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.39.6#51df58f5d5d499f5ee80ab17076ff70b5347c7db" +dependencies = [ + "anyhow", + "base64-compat", + "bech32", + "bitflags 2.10.0", + "blake3", + "blsful 3.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "dashcore-private 0.39.6", + "dashcore_hashes 0.39.6", + "ed25519-dalek", + "hex", + "hex_lit", + "rustversion", + "secp256k1", + "serde", + "thiserror 2.0.17", +] + [[package]] name = "dashcore" version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=befd0356bebfcd0d06d1028d8a03bfa4c78bd219#befd0356bebfcd0d06d1028d8a03bfa4c78bd219" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.40.0#c877c1a74d145e2003d549619698511513db925c" dependencies = [ "anyhow", "base64-compat", "bech32", - "bincode", + "bincode 2.0.0-rc.3", "bincode_derive", "bitvec", "blake3", - "blsful", + "blsful 3.0.0 (git+https://github.com/dashpay/agora-blsful?rev=0c34a7a488a0bd1c9a9a2196e793b303ad35c900)", "dash-network", - "dashcore-private", - "dashcore_hashes", + "dashcore-private 0.40.0", + "dashcore_hashes 0.40.0", "ed25519-dalek", "hex", "hex_lit", @@ -1704,20 +1933,38 @@ dependencies = [ "rustversion", "secp256k1", "serde", - "thiserror 2.0.14", + "thiserror 2.0.17", ] +[[package]] +name = "dashcore-private" +version = "0.39.6" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.39.6#51df58f5d5d499f5ee80ab17076ff70b5347c7db" + [[package]] name = "dashcore-private" version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=befd0356bebfcd0d06d1028d8a03bfa4c78bd219#befd0356bebfcd0d06d1028d8a03bfa4c78bd219" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.40.0#c877c1a74d145e2003d549619698511513db925c" + +[[package]] +name = "dashcore-rpc" +version = "0.39.6" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.39.6#51df58f5d5d499f5ee80ab17076ff70b5347c7db" +dependencies = [ + "dashcore-rpc-json 0.39.6", + "hex", + "jsonrpc", + "log", + "serde", + "serde_json", +] [[package]] name = "dashcore-rpc" version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=befd0356bebfcd0d06d1028d8a03bfa4c78bd219#befd0356bebfcd0d06d1028d8a03bfa4c78bd219" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.40.0#c877c1a74d145e2003d549619698511513db925c" dependencies = [ - "dashcore-rpc-json", + "dashcore-rpc-json 0.40.0", "hex", "jsonrpc", "log", @@ -1725,13 +1972,27 @@ dependencies = [ "serde_json", ] +[[package]] +name = "dashcore-rpc-json" +version = "0.39.6" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.39.6#51df58f5d5d499f5ee80ab17076ff70b5347c7db" +dependencies = [ + "bincode 2.0.0-rc.3", + "dashcore 0.39.6", + "hex", + "serde", + "serde_json", + "serde_repr", + "serde_with", +] + [[package]] name = "dashcore-rpc-json" version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=befd0356bebfcd0d06d1028d8a03bfa4c78bd219#befd0356bebfcd0d06d1028d8a03bfa4c78bd219" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.40.0#c877c1a74d145e2003d549619698511513db925c" dependencies = [ - "bincode", - "dashcore", + "bincode 2.0.0-rc.3", + "dashcore 0.40.0", "hex", "key-wallet", "serde", @@ -1742,18 +2003,28 @@ dependencies = [ [[package]] name = "dashcore_hashes" -version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=befd0356bebfcd0d06d1028d8a03bfa4c78bd219#befd0356bebfcd0d06d1028d8a03bfa4c78bd219" +version = "0.39.6" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.39.6#51df58f5d5d499f5ee80ab17076ff70b5347c7db" dependencies = [ - "bincode", - "dashcore-private", + "dashcore-private 0.39.6", "secp256k1", "serde", ] [[package]] -name = "dashmap" -version = "5.5.3" +name = "dashcore_hashes" +version = "0.40.0" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.40.0#c877c1a74d145e2003d549619698511513db925c" +dependencies = [ + "bincode 2.0.0-rc.3", + "dashcore-private 0.40.0", + "secp256k1", + "serde", +] + +[[package]] +name = "dashmap" +version = "5.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" dependencies = [ @@ -1766,32 +2037,62 @@ dependencies = [ [[package]] name = "dashpay-contract" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "platform-value 2.0.1", + "platform-version 2.0.1", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "dashpay-contract" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ - "platform-value", - "platform-version", + "platform-value 2.1.0-rc.1", + "platform-version 2.1.0-rc.1", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "data-contracts" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "dashpay-contract 2.0.1", + "dpns-contract 2.0.1", + "feature-flags-contract 2.0.1", + "keyword-search-contract 2.0.1", + "masternode-reward-shares-contract 2.0.1", + "platform-value 2.0.1", + "platform-version 2.0.1", "serde_json", - "thiserror 2.0.14", + "thiserror 2.0.17", + "token-history-contract 2.0.1", + "wallet-utils-contract 2.0.1", + "withdrawals-contract 2.0.1", ] [[package]] name = "data-contracts" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" -dependencies = [ - "dashpay-contract", - "dpns-contract", - "feature-flags-contract", - "keyword-search-contract", - "masternode-reward-shares-contract", - "platform-value", - "platform-version", +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +dependencies = [ + "dashpay-contract 2.1.0-rc.1", + "dpns-contract 2.1.0-rc.1", + "feature-flags-contract 2.1.0-rc.1", + "keyword-search-contract 2.1.0-rc.1", + "masternode-reward-shares-contract 2.1.0-rc.1", + "platform-value 2.1.0-rc.1", + "platform-version 2.1.0-rc.1", "serde_json", - "thiserror 2.0.14", - "token-history-contract", - "wallet-utils-contract", - "withdrawals-contract", + "thiserror 2.0.17", + "token-history-contract 2.1.0-rc.1", + "wallet-utils-contract 2.1.0-rc.1", + "withdrawals-contract 2.1.0-rc.1", ] [[package]] @@ -1806,23 +2107,34 @@ dependencies = [ [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" dependencies = [ "powerfmt", - "serde", + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] name = "derive_arbitrary" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -1843,7 +2155,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -1853,7 +2165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -1882,7 +2194,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", "unicode-xid", ] @@ -1894,7 +2206,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -1946,7 +2258,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1961,10 +2273,10 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.9.1", - "block2 0.6.1", + "bitflags 2.10.0", + "block2 0.6.2", "libc", - "objc2 0.6.1", + "objc2 0.6.3", ] [[package]] @@ -1975,7 +2287,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -2016,113 +2328,213 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "platform-value 2.0.1", + "platform-version 2.0.1", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "dpns-contract" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +dependencies = [ + "platform-value 2.1.0-rc.1", + "platform-version 2.1.0-rc.1", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "dpp" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" dependencies = [ - "platform-value", - "platform-version", + "anyhow", + "async-trait", + "base64 0.22.1", + "bincode 2.0.0-rc.3", + "bincode_derive", + "bs58", + "byteorder", + "chrono", + "chrono-tz", + "ciborium", + "dashcore 0.39.6", + "data-contracts 2.0.1", + "derive_more 1.0.0", + "env_logger", + "getrandom 0.2.16", + "hex", + "indexmap 2.12.0", + "integer-encoding", + "itertools 0.13.0", + "lazy_static", + "nohash-hasher", + "num_enum 0.7.5", + "once_cell", + "platform-serialization 2.0.1", + "platform-serialization-derive 2.0.1", + "platform-value 2.0.1", + "platform-version 2.0.1", + "platform-versioning 2.0.1", + "rand 0.8.5", + "regex", + "serde", "serde_json", - "thiserror 2.0.14", + "serde_repr", + "sha2", + "strum 0.26.3", + "thiserror 2.0.17", ] [[package]] name = "dpp" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ "anyhow", "async-trait", "base64 0.22.1", - "bincode", + "bincode 2.0.0-rc.3", "bincode_derive", "bs58", "byteorder", "chrono", "chrono-tz", "ciborium", - "dashcore", - "dashcore-rpc", - "data-contracts", + "dashcore 0.40.0", + "dashcore-rpc 0.40.0", + "data-contracts 2.1.0-rc.1", "derive_more 1.0.0", "env_logger", "getrandom 0.2.16", "hex", - "indexmap 2.10.0", + "indexmap 2.12.0", "integer-encoding", "itertools 0.13.0", "key-wallet", "lazy_static", "nohash-hasher", - "num_enum 0.7.4", + "num_enum 0.7.5", "once_cell", - "platform-serialization", - "platform-serialization-derive", - "platform-value", - "platform-version", - "platform-versioning", + "platform-serialization 2.1.0-rc.1", + "platform-serialization-derive 2.1.0-rc.1", + "platform-value 2.1.0-rc.1", + "platform-version 2.1.0-rc.1", + "platform-versioning 2.1.0-rc.1", "rand 0.8.5", "regex", "serde", "serde_json", "serde_repr", "sha2", - "strum", - "thiserror 2.0.14", + "strum 0.26.3", + "thiserror 2.0.17", + "tracing", +] + +[[package]] +name = "drive" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "bincode 2.0.0-rc.3", + "byteorder", + "derive_more 1.0.0", + "dpp 2.0.1", + "grovedb", + "grovedb-costs", + "grovedb-epoch-based-storage-flags", + "grovedb-path", + "grovedb-version", + "hex", + "indexmap 2.12.0", + "integer-encoding", + "nohash-hasher", + "platform-version 2.0.1", + "serde", + "sqlparser", + "thiserror 2.0.17", "tracing", ] [[package]] name = "drive" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ - "bincode", + "bincode 2.0.0-rc.3", "byteorder", "derive_more 1.0.0", - "dpp", + "dpp 2.1.0-rc.1", "grovedb", "grovedb-costs", "grovedb-epoch-based-storage-flags", "grovedb-path", "grovedb-version", "hex", - "indexmap 2.10.0", + "indexmap 2.12.0", "integer-encoding", "nohash-hasher", - "platform-version", + "platform-version 2.1.0-rc.1", "serde", "sqlparser", - "thiserror 2.0.14", + "thiserror 2.0.17", + "tracing", +] + +[[package]] +name = "drive-proof-verifier" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "bincode 2.0.0-rc.3", + "dapi-grpc 2.0.1", + "derive_more 1.0.0", + "dpp 2.0.1", + "drive 2.0.1", + "hex", + "indexmap 2.12.0", + "platform-serialization 2.0.1", + "platform-serialization-derive 2.0.1", + "serde", + "serde_json", + "tenderdash-abci 1.4.0", + "thiserror 2.0.17", "tracing", ] [[package]] name = "drive-proof-verifier" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ - "bincode", - "dapi-grpc", + "bincode 2.0.0-rc.3", + "dapi-grpc 2.1.0-rc.1", "dash-context-provider", "derive_more 1.0.0", - "dpp", - "drive", + "dpp 2.1.0-rc.1", + "drive 2.1.0-rc.1", "hex", - "indexmap 2.10.0", - "platform-serialization", - "platform-serialization-derive", + "indexmap 2.12.0", + "platform-serialization 2.1.0-rc.1", + "platform-serialization-derive 2.1.0-rc.1", "serde", "serde_json", - "tenderdash-abci", - "thiserror 2.0.14", + "tenderdash-abci 1.5.0-dev.2", + "thiserror 2.0.17", "tracing", ] [[package]] name = "ecolor" -version = "0.32.0" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a631732d995184114016fab22fc7e3faf73d6841c2d7650395fe251fbcd9285" +checksum = "94bdf37f8d5bd9aa7f753573fdda9cf7343afa73dd28d7bfe9593bd9798fc07e" dependencies = [ "bytemuck", "emath", @@ -2157,6 +2569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8", + "serde", "signature", ] @@ -2168,6 +2581,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", + "merlin", "rand_core 0.6.4", "serde", "sha2", @@ -2177,9 +2591,9 @@ dependencies = [ [[package]] name = "eframe" -version = "0.32.0" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790ccfbb3dd556588342463454b2b2b13909e5fdce5bc2a1432a8aa69c8b7a" +checksum = "14d1c15e7bd136b309bd3487e6ffe5f668b354cd9768636a836dd738ac90eb0b" dependencies = [ "ahash", "bytemuck", @@ -2216,13 +2630,13 @@ dependencies = [ [[package]] name = "egui" -version = "0.32.0" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8470210c95a42cc985d9ffebfd5067eea55bdb1c3f7611484907db9639675e28" +checksum = "5d5d0306cd61ca75e29682926d71f2390160247f135965242e904a636f51c0dc" dependencies = [ "accesskit", "ahash", - "bitflags 2.9.1", + "bitflags 2.10.0", "emath", "epaint", "log", @@ -2236,9 +2650,9 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.32.0" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14de9942d8b9e99e2d830403c208ab1a6e052e925a7456a4f6f66d567d90de1d" +checksum = "c12eca13293f8eba27a32aaaa1c765bfbf31acd43e8d30d5881dcbe5e99ca0c7" dependencies = [ "ahash", "bytemuck", @@ -2256,9 +2670,9 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.32.0" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c490804a035cec9c826082894a3e1ecf4198accd3817deb10f7919108ebafab0" +checksum = "f95d0a91f9cb0dc2e732d49c2d521ac8948e1f0b758f306fb7b14d6f5db3927f" dependencies = [ "accesskit_winit", "ahash", @@ -2300,9 +2714,9 @@ dependencies = [ [[package]] name = "egui_extras" -version = "0.32.0" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f791a5937f518249016b276b3639ad2aa3824048b6f2161ec2b431ab325880a" +checksum = "dddbceddf39805fc6c62b1f7f9c05e23590b40844dc9ed89c6dc6dbc886e3e3b" dependencies = [ "ahash", "egui", @@ -2315,9 +2729,9 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.32.0" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d44f3fd4fdc5f960c9e9ef7327c26647edc3141abf96102980647129d49358e6" +checksum = "cc7037813341727937f9e22f78d912f3e29bc3c46e2f40a9e82bb51cbf5e4cfb" dependencies = [ "ahash", "bytemuck", @@ -2333,9 +2747,9 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.32.0" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dacd0e777f0557aebde97346a8c534e76607ce1e4f4a6e10a82d95ec5d5bca8" +checksum = "5bb00f16e00af09092c117515246732adba4ca4649463bdbc2ab6114a2944765" dependencies = [ "eframe", "egui", @@ -2358,7 +2772,7 @@ dependencies = [ "crypto-bigint", "digest", "ff", - "generic-array 0.14.7", + "generic-array 0.14.9", "group", "hkdf", "pkcs8", @@ -2372,8 +2786,21 @@ dependencies = [ [[package]] name = "elliptic-curve-tools" version = "0.1.2" +source = "git+https://github.com/mikelodder7/elliptic-curve-tools?rev=c989865fa71503d2cbf5c5795c4ebcf4a2f3221c#c989865fa71503d2cbf5c5795c4ebcf4a2f3221c" +dependencies = [ + "elliptic-curve", + "heapless", + "hex", + "multiexp", + "serde", + "zeroize", +] + +[[package]] +name = "elliptic-curve-tools" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48843edfbd0a370b3dd14cdbb4e446e9a8855311e6b2b57bf9a1fd1367bc317" +checksum = "1de2b6fae800f08032a6ea32995b52925b1d451bff9d445c8ab2932323277faf" dependencies = [ "elliptic-curve", "heapless", @@ -2385,9 +2812,9 @@ dependencies = [ [[package]] name = "emath" -version = "0.32.0" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45f057b141e7e46340c321400be74b793543b1b213036f0f989c35d35957c32e" +checksum = "45fd7bc25f769a3c198fe1cf183124bf4de3bd62ef7b4f1eaf6b08711a3af8db" dependencies = [ "bytemuck", "serde", @@ -2410,22 +2837,22 @@ checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" [[package]] name = "enum-iterator" -version = "2.1.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c280b9e6b3ae19e152d8e31cf47f18389781e119d4013a2a2bb0180e5facc635" +checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" dependencies = [ "enum-iterator-derive", ] [[package]] name = "enum-iterator-derive" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ab991c1362ac86c61ab6f556cff143daa22e5a15e4e189df818b2fd19fe65b" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -2445,7 +2872,7 @@ checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -2466,7 +2893,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -2477,14 +2904,14 @@ checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] name = "env_filter" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" dependencies = [ "log", "regex", @@ -2520,9 +2947,9 @@ dependencies = [ [[package]] name = "epaint" -version = "0.32.0" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94cca02195f0552c17cabdc02f39aa9ab6fbd815dac60ab1cd3d5b0aa6f9551c" +checksum = "63adcea970b7a13094fe97a36ab9307c35a750f9e24bf00bb7ef3de573e0fddb" dependencies = [ "ab_glyph", "ahash", @@ -2539,9 +2966,9 @@ dependencies = [ [[package]] name = "epaint_default_fonts" -version = "0.32.0" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8495e11ed527dff39663b8c36b6c2b2799d7e4287fb90556e455d72eca0b4d3" +checksum = "1537accc50c9cab5a272c39300bdd0dd5dca210f6e5e8d70be048df9596e7ca2" [[package]] name = "equivalent" @@ -2551,12 +2978,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2611,8 +3038,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ "bit-set 0.5.3", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata", + "regex-syntax", ] [[package]] @@ -2621,6 +3048,26 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.107", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -2632,13 +3079,24 @@ dependencies = [ [[package]] name = "feature-flags-contract" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "platform-value 2.0.1", + "platform-version 2.0.1", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "feature-flags-contract" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ - "platform-value", - "platform-version", + "platform-value 2.1.0-rc.1", + "platform-version 2.1.0-rc.1", "serde_json", - "thiserror 2.0.14", + "thiserror 2.0.17", ] [[package]] @@ -2658,6 +3116,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2666,15 +3130,24 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" dependencies = [ "crc32fast", "libz-rs-sys", "miniz_oxide", ] +[[package]] +name = "flex-error" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c606d892c9de11507fa0dcffc116434f94e105d0bbdc4e405b61519464c49d7b" +dependencies = [ + "paste", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2687,6 +3160,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -2714,7 +3193,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -2731,9 +3210,9 @@ checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -2825,7 +3304,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -2860,9 +3339,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -2871,22 +3350,23 @@ dependencies = [ [[package]] name = "generic-array" -version = "1.2.0" +version = "1.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c8444bc9d71b935156cc0ccab7f622180808af7867b1daae6547d773591703" +checksum = "985a5578ebdb02351d484a77fb27e7cb79272f1ba9bc24692d8243c3cfe40660" dependencies = [ - "serde", + "rustversion", + "serde_core", "typenum", ] [[package]] name = "gethostname" -version = "0.4.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "libc", - "windows-targets 0.48.5", + "rustix 1.1.2", + "windows-link 0.2.1", ] [[package]] @@ -2898,20 +3378,20 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasip2", ] [[package]] @@ -2924,12 +3404,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "gl_generator" version = "0.14.0" @@ -2977,7 +3451,7 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "cfg_aliases", "cgl", "dispatch2", @@ -2985,10 +3459,10 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.1", - "objc2-app-kit 0.3.1", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "once_cell", "raw-window-handle", "wayland-sys", @@ -3037,6 +3511,57 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.10.0", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.10.0", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "group" version = "0.13.0" @@ -3052,93 +3577,166 @@ dependencies = [ [[package]] name = "grovedb" -version = "3.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=1ecedf530fbc5b5e12edf1bc607bd288c187ddde#1ecedf530fbc5b5e12edf1bc607bd288c187ddde" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12b2378c5eda5b7cadceb34fc6e0a8fd87fe03fc04841a7d32a74ff73ccef71" dependencies = [ - "bincode", + "bincode 2.0.0-rc.3", "bincode_derive", "blake3", "grovedb-costs", "grovedb-merk", "grovedb-path", + "grovedb-storage", "grovedb-version", + "grovedb-visualize", "hex", "hex-literal", - "indexmap 2.10.0", + "indexmap 2.12.0", "integer-encoding", + "intmap", + "itertools 0.14.0", "reqwest", "sha2", - "thiserror 2.0.14", + "tempfile", + "thiserror 2.0.17", ] [[package]] name = "grovedb-costs" -version = "3.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=1ecedf530fbc5b5e12edf1bc607bd288c187ddde#1ecedf530fbc5b5e12edf1bc607bd288c187ddde" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e74fafe53bf5ae27128799856e557ef5cb2d7109f1f7bc7f4440bbd0f97c7072" dependencies = [ "integer-encoding", "intmap", - "thiserror 2.0.14", + "thiserror 2.0.17", ] [[package]] name = "grovedb-epoch-based-storage-flags" -version = "3.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=1ecedf530fbc5b5e12edf1bc607bd288c187ddde#1ecedf530fbc5b5e12edf1bc607bd288c187ddde" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6bdc033cc229b17cd02ee9d5c5a5a344788ed0e69ad7468b0d34d94b021fc4" dependencies = [ "grovedb-costs", "hex", "integer-encoding", "intmap", - "thiserror 2.0.14", + "thiserror 2.0.17", ] [[package]] name = "grovedb-merk" -version = "3.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=1ecedf530fbc5b5e12edf1bc607bd288c187ddde#1ecedf530fbc5b5e12edf1bc607bd288c187ddde" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dd6f733e9d5c15c98e05b68a2028e00b7f177baa51e8d8c1541102942a72b7" dependencies = [ - "bincode", + "bincode 2.0.0-rc.3", "bincode_derive", "blake3", "byteorder", + "colored", "ed", "grovedb-costs", "grovedb-path", + "grovedb-storage", "grovedb-version", "grovedb-visualize", "hex", - "indexmap 2.10.0", + "indexmap 2.12.0", "integer-encoding", - "thiserror 2.0.14", + "num_cpus", + "rand 0.8.5", + "thiserror 2.0.17", ] [[package]] name = "grovedb-path" -version = "3.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=1ecedf530fbc5b5e12edf1bc607bd288c187ddde#1ecedf530fbc5b5e12edf1bc607bd288c187ddde" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01f716520d6c6b0f25dc4a68bc7dded645826ed57d38a06a80716a487c09d23c" dependencies = [ "hex", ] [[package]] -name = "grovedb-version" -version = "3.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=1ecedf530fbc5b5e12edf1bc607bd288c187ddde#1ecedf530fbc5b5e12edf1bc607bd288c187ddde" +name = "grovedb-storage" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d04f3831fe210543a7246f2a60ae068f23eac5f9d53200d5a82785750f68fd" dependencies = [ - "thiserror 2.0.14", - "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", + "blake3", + "grovedb-costs", + "grovedb-path", + "grovedb-visualize", + "hex", + "integer-encoding", + "lazy_static", + "num_cpus", + "rocksdb", + "strum 0.27.2", + "tempfile", + "thiserror 2.0.17", ] [[package]] -name = "grovedb-visualize" -version = "3.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=1ecedf530fbc5b5e12edf1bc607bd288c187ddde#1ecedf530fbc5b5e12edf1bc607bd288c187ddde" +name = "grovedb-version" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc855662f05f41b10dd022226cb78e345a33f35c390e25338d21dedd45966ae" +dependencies = [ + "thiserror 2.0.17", + "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "grovedb-visualize" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34fa6f41c110d1d141bf912175f187ef51ac5d2a8f163dfd229be007461a548f" dependencies = [ "hex", "itertools 0.14.0", ] +[[package]] +name = "grovestark" +version = "0.1.0" +dependencies = [ + "ark-ff", + "base64 0.22.1", + "bincode 1.3.3", + "bincode 2.0.0-rc.3", + "blake3", + "bs58", + "curve25519-dalek", + "dash-sdk 2.0.1", + "ed25519-dalek", + "env_logger", + "grovedb", + "grovedb-costs", + "grovedb-merk", + "hex", + "log", + "num-bigint", + "num-traits", + "num_cpus", + "once_cell", + "rand 0.8.5", + "rayon", + "serde", + "serde_json", + "sha2", + "subtle", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber", + "winterfell", + "zeroize", +] + [[package]] name = "h2" version = "0.4.12" @@ -3151,7 +3749,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.10.0", + "indexmap 2.12.0", "slab", "tokio", "tokio-util", @@ -3160,13 +3758,14 @@ dependencies = [ [[package]] name = "half" -version = "2.6.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", "num-traits", + "zerocopy", ] [[package]] @@ -3189,10 +3788,6 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] [[package]] name = "hashbrown" @@ -3202,7 +3797,16 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +dependencies = [ + "foldhash 0.2.0", ] [[package]] @@ -3363,19 +3967,20 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", "http", "http-body", @@ -3383,6 +3988,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -3435,9 +4041,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "base64 0.22.1", "bytes", @@ -3451,7 +4057,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.1", "system-configuration", "tokio", "tower-service", @@ -3461,9 +4067,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -3471,7 +4077,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -3577,9 +4183,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -3598,12 +4204,13 @@ dependencies = [ [[package]] name = "image" -version = "0.25.6" +version = "0.25.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db35664ce6b9810857a38a906215e75a9c879f0696556a39f59c62829710251a" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" dependencies = [ "bytemuck", "byteorder-lite", + "moxcms", "num-traits", "png", "tiff", @@ -3622,13 +4229,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.16.0", "serde", + "serde_core", ] [[package]] @@ -3637,7 +4245,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "generic-array 0.14.7", + "generic-array 0.14.9", ] [[package]] @@ -3655,17 +4263,6 @@ dependencies = [ "serde", ] -[[package]] -name = "io-uring" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "libc", -] - [[package]] name = "ipnet" version = "2.11.0" @@ -3684,9 +4281,18 @@ dependencies = [ [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] [[package]] name = "itertools" @@ -3733,7 +4339,7 @@ checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -3760,25 +4366,19 @@ checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "jobserver" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "libc", ] -[[package]] -name = "jpeg-decoder" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" - [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" dependencies = [ "once_cell", "wasm-bindgen", @@ -3817,15 +4417,15 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=befd0356bebfcd0d06d1028d8a03bfa4c78bd219#befd0356bebfcd0d06d1028d8a03bfa4c78bd219" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.40.0#c877c1a74d145e2003d549619698511513db925c" dependencies = [ "base58ck", "bip39", - "bitflags 2.9.1", + "bitflags 2.10.0", "dash-network", - "dashcore", - "dashcore-private", - "dashcore_hashes", + "dashcore 0.40.0", + "dashcore-private 0.40.0", + "dashcore_hashes 0.40.0", "getrandom 0.2.16", "hex", "hkdf", @@ -3834,18 +4434,41 @@ dependencies = [ "serde", "serde_json", "sha2", + "tracing", "zeroize", ] [[package]] name = "keyword-search-contract" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "platform-value 2.0.1", + "platform-version 2.0.1", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "keyword-search-contract" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ - "platform-value", - "platform-version", + "platform-value 2.1.0-rc.1", + "platform-version 2.1.0-rc.1", "serde_json", - "thiserror 2.0.14", + "thiserror 2.0.17", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", ] [[package]] @@ -3888,18 +4511,18 @@ checksum = "744a4c881f502e98c2241d2e5f50040ac73b30194d64452bb6260393b53f0dc9" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libloading" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-targets 0.53.3", + "windows-link 0.2.1", ] [[package]] @@ -3910,13 +4533,28 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", + "libc", + "redox_syscall 0.5.18", +] + +[[package]] +name = "librocksdb-sys" +version = "0.17.3+10.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cef2a00ee60fe526157c9023edab23943fae1ce2ab6f4abb2a807c1746835de9" +dependencies = [ + "bindgen", + "bzip2-sys", + "cc", "libc", - "redox_syscall 0.5.17", + "libz-sys", + "lz4-sys", + "zstd-sys", ] [[package]] @@ -3939,6 +4577,17 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "libz-sys" +version = "1.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3947,9 +4596,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" @@ -3965,19 +4614,18 @@ checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" dependencies = [ "value-bag", ] @@ -3991,37 +4639,67 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "masternode-reward-shares-contract" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "platform-value 2.0.1", + "platform-version 2.0.1", + "serde_json", + "thiserror 2.0.17", +] + [[package]] name = "masternode-reward-shares-contract" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ - "platform-value", - "platform-version", + "platform-value 2.1.0-rc.1", + "platform-version 2.1.0-rc.1", "serde_json", - "thiserror 2.0.14", + "thiserror 2.0.17", ] [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] name = "memchr" -version = "2.7.5" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memmap2" -version = "0.9.7" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "483758ad303d734cec05e5c12b41d7e93e6a6390c5e9dae6bdeb7c1259012d28" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" dependencies = [ "libc", ] @@ -4047,6 +4725,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "metal" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +dependencies = [ + "bitflags 2.10.0", + "block", + "core-graphics-types", + "foreign-types 0.5.0", + "log", + "objc", + "paste", +] + [[package]] name = "mime" version = "0.3.17" @@ -4065,6 +4758,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -4077,23 +4776,34 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c588e11a3082784af229e23e8e4ecf5bcc6fbe4f69101e0421ce8d79da7f0b40" +dependencies = [ + "num-traits", + "pxfm", ] [[package]] name = "multiexp" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25a383da1ae933078ddb1e4141f1dd617b512b4183779d6977e6451b0e644806" +checksum = "7ec2ce93a6f06ac6cae04c1da3f2a6a24fcfc1f0eb0b4e0f3d302f0df45326cb" dependencies = [ "ff", "group", + "rand_core 0.6.4", "rustversion", "std-shims", "zeroize", @@ -4119,40 +4829,41 @@ checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" dependencies = [ "arrayvec", "bit-set 0.8.0", - "bitflags 2.9.1", + "bitflags 2.10.0", "cfg_aliases", "codespan-reporting", "half", "hashbrown 0.15.5", "hexf-parse", - "indexmap 2.10.0", + "indexmap 2.12.0", "log", "num-traits", "once_cell", "rustc-hash 1.1.0", - "strum", - "thiserror 2.0.14", + "spirv", + "strum 0.26.3", + "thiserror 2.0.17", "unicode-ident", ] [[package]] name = "native-dialog" -version = "0.9.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f006431cea71a83e6668378cb5abc2d52af299cbac6dca1780c6eeca90822df" +checksum = "1657b63bf0e60ee0eca886b5df70269240b6197b6ee46ec37da9a7d28d8e8e24" dependencies = [ "ascii", - "block2 0.6.1", + "block2 0.6.2", "dirs", "dispatch2", "formatx", - "objc2 0.6.1", - "objc2-app-kit 0.3.1", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "raw-window-handle", - "thiserror 2.0.14", + "thiserror 2.0.17", "versions", "wfd", "which 7.0.3", @@ -4182,11 +4893,11 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "jni-sys", "log", - "ndk-sys", - "num_enum 0.7.4", + "ndk-sys 0.6.0+11769913", + "num_enum 0.7.5", "raw-window-handle", "thiserror 1.0.69", ] @@ -4197,6 +4908,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -4212,7 +4932,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "cfg-if", "cfg_aliases", "libc", @@ -4225,6 +4945,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -4236,12 +4966,11 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "overload", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -4295,7 +5024,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -4361,11 +5090,11 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" dependencies = [ - "num_enum_derive 0.7.4", + "num_enum_derive 0.7.5", "rustversion", ] @@ -4383,14 +5112,23 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 3.3.0", + "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", ] [[package]] @@ -4411,9 +5149,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" dependencies = [ "objc2-encode", ] @@ -4424,33 +5162,28 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "block2 0.5.1", "libc", "objc2 0.5.2", - "objc2-core-data 0.2.2", - "objc2-core-image 0.2.2", + "objc2-core-data", + "objc2-core-image", "objc2-foundation 0.2.2", - "objc2-quartz-core 0.2.2", + "objc2-quartz-core", ] [[package]] name = "objc2-app-kit" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.9.1", - "block2 0.6.1", - "libc", - "objc2 0.6.1", - "objc2-cloud-kit 0.3.1", - "objc2-core-data 0.3.1", + "bitflags 2.10.0", + "block2 0.6.2", + "objc2 0.6.3", "objc2-core-foundation", "objc2-core-graphics", - "objc2-core-image 0.3.1", - "objc2-foundation 0.3.1", - "objc2-quartz-core 0.3.1", + "objc2-foundation 0.3.2", ] [[package]] @@ -4459,24 +5192,13 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", ] -[[package]] -name = "objc2-cloud-kit" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17614fdcd9b411e6ff1117dfb1d0150f908ba83a7df81b1f118005fe0a8ea15d" -dependencies = [ - "bitflags 2.9.1", - "objc2 0.6.1", - "objc2-foundation 0.3.1", -] - [[package]] name = "objc2-contacts" version = "0.2.2" @@ -4494,50 +5216,34 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] -[[package]] -name = "objc2-core-data" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291fbbf7d29287518e8686417cf7239c74700fd4b607623140a7d4a3c834329d" -dependencies = [ - "bitflags 2.9.1", - "objc2 0.6.1", - "objc2-foundation 0.3.1", -] - [[package]] name = "objc2-core-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.9.1", - "block2 0.6.1", + "bitflags 2.10.0", "dispatch2", - "libc", - "objc2 0.6.1", + "objc2 0.6.3", ] [[package]] name = "objc2-core-graphics" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.9.1", - "block2 0.6.1", + "bitflags 2.10.0", "dispatch2", - "libc", - "objc2 0.6.1", + "objc2 0.6.3", "objc2-core-foundation", "objc2-io-surface", - "objc2-metal 0.3.1", ] [[package]] @@ -4549,17 +5255,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal 0.2.2", -] - -[[package]] -name = "objc2-core-image" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79b3dc0cc4386b6ccf21c157591b34a7f44c8e75b064f85502901ab2188c007e" -dependencies = [ - "objc2 0.6.1", - "objc2-foundation 0.3.1", + "objc2-metal", ] [[package]] @@ -4586,7 +5282,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "block2 0.5.1", "dispatch", "libc", @@ -4595,25 +5291,23 @@ dependencies = [ [[package]] name = "objc2-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.9.1", - "block2 0.6.1", - "libc", - "objc2 0.6.1", + "bitflags 2.10.0", + "objc2 0.6.3", "objc2-core-foundation", ] [[package]] name = "objc2-io-surface" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.9.1", - "objc2 0.6.1", + "bitflags 2.10.0", + "objc2 0.6.3", "objc2-core-foundation", ] @@ -4635,45 +5329,23 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", ] -[[package]] -name = "objc2-metal" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f246c183239540aab1782457b35ab2040d4259175bd1d0c58e46ada7b47a874" -dependencies = [ - "bitflags 2.9.1", - "objc2 0.6.1", - "objc2-foundation 0.3.1", -] - [[package]] name = "objc2-quartz-core" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal 0.2.2", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ffb6a0cd5f182dc964334388560b12a57f7b74b3e2dec5e2722aa2dfb2ccd5" -dependencies = [ - "bitflags 2.9.1", - "objc2 0.6.1", - "objc2-foundation 0.3.1", + "objc2-metal", ] [[package]] @@ -4692,16 +5364,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "block2 0.5.1", "objc2 0.5.2", - "objc2-cloud-kit 0.2.2", - "objc2-core-data 0.2.2", - "objc2-core-image 0.2.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", "objc2-core-location", "objc2-foundation 0.2.2", "objc2-link-presentation", - "objc2-quartz-core 0.2.2", + "objc2-quartz-core", "objc2-symbols", "objc2-uniform-type-identifiers", "objc2-user-notifications", @@ -4724,22 +5396,13 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", "objc2-foundation 0.2.2", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -4748,9 +5411,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "opaque-debug" @@ -4760,11 +5423,11 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.73" +version = "0.10.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "cfg-if", "foreign-types 0.3.2", "libc", @@ -4781,7 +5444,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -4792,9 +5455,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.109" +version = "0.9.110" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" dependencies = [ "cc", "libc", @@ -4817,6 +5480,15 @@ dependencies = [ "libredox", ] +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -4827,12 +5499,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -4859,9 +5525,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -4869,15 +5535,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.17", + "redox_syscall 0.5.18", "smallvec", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] @@ -4900,11 +5566,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "petgraph" @@ -4913,7 +5585,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap 2.10.0", + "indexmap 2.12.0", ] [[package]] @@ -4956,7 +5628,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", "unicase", ] @@ -4987,7 +5659,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -5031,73 +5703,135 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "platform-serialization" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" dependencies = [ - "bincode", - "platform-version", + "bincode 2.0.0-rc.3", + "platform-version 2.0.1", +] + +[[package]] +name = "platform-serialization" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +dependencies = [ + "bincode 2.0.0-rc.3", + "platform-version 2.1.0-rc.1", +] + +[[package]] +name = "platform-serialization-derive" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.107", + "virtue 0.0.17", ] [[package]] name = "platform-serialization-derive" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", "virtue 0.0.17", ] [[package]] name = "platform-value" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "base64 0.22.1", + "bincode 2.0.0-rc.3", + "bs58", + "ciborium", + "hex", + "indexmap 2.12.0", + "platform-serialization 2.0.1", + "platform-version 2.0.1", + "rand 0.8.5", + "serde", + "serde_json", + "thiserror 2.0.17", + "treediff", +] + +[[package]] +name = "platform-value" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ "base64 0.22.1", - "bincode", + "bincode 2.0.0-rc.3", "bs58", "ciborium", "hex", - "indexmap 2.10.0", - "platform-serialization", - "platform-version", + "indexmap 2.12.0", + "platform-serialization 2.1.0-rc.1", + "platform-version 2.1.0-rc.1", "rand 0.8.5", "serde", "serde_json", - "thiserror 2.0.14", + "thiserror 2.0.17", "treediff", ] [[package]] name = "platform-version" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "bincode 2.0.0-rc.3", + "grovedb-version", + "once_cell", + "thiserror 2.0.17", + "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", +] + +[[package]] +name = "platform-version" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ - "bincode", + "bincode 2.0.0-rc.3", "grovedb-version", "once_cell", - "thiserror 2.0.14", + "thiserror 2.0.17", "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", ] [[package]] name = "platform-versioning" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", +] + +[[package]] +name = "platform-versioning" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.107", ] [[package]] name = "png" -version = "0.17.16" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.10.0", "crc32fast", "fdeflate", "flate2", @@ -5106,16 +5840,16 @@ dependencies = [ [[package]] name = "polling" -version = "3.10.0" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5bd19146350fe804f7cb2669c851c03d69da628803dab0d98018142aaa5d829" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.0.8", - "windows-sys 0.60.2", + "rustix 1.1.2", + "windows-sys 0.61.2", ] [[package]] @@ -5153,9 +5887,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" dependencies = [ "zerovec", ] @@ -5175,14 +5909,20 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "prettyplease" -version = "0.2.36" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -5197,18 +5937,18 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.22.27", + "toml_edit 0.23.7", ] [[package]] name = "proc-macro2" -version = "1.0.97" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -5219,6 +5959,16 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.1" @@ -5226,7 +5976,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.1", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools 0.14.0", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost 0.13.5", + "prost-types 0.13.5", + "regex", + "syn 2.0.107", + "tempfile", ] [[package]] @@ -5236,21 +6006,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" dependencies = [ "heck", - "itertools 0.13.0", + "itertools 0.14.0", "log", "multimap", "once_cell", "petgraph", "prettyplease", - "prost", - "prost-types", + "prost 0.14.1", + "prost-types 0.14.1", "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn 2.0.104", + "syn 2.0.107", "tempfile", ] +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.107", +] + [[package]] name = "prost-derive" version = "0.14.1" @@ -5258,10 +6041,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" dependencies = [ "anyhow", - "itertools 0.13.0", + "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", ] [[package]] @@ -5270,7 +6062,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" dependencies = [ - "prost", + "prost 0.14.1", ] [[package]] @@ -5279,7 +6071,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "memchr", "unicase", ] @@ -5293,6 +6085,15 @@ dependencies = [ "pulldown-cmark", ] +[[package]] +name = "pxfm" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +dependencies = [ + "num-traits", +] + [[package]] name = "qrcode" version = "0.14.1" @@ -5302,6 +6103,12 @@ dependencies = [ "image", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.36.2" @@ -5323,9 +6130,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] @@ -5398,7 +6205,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", ] [[package]] @@ -5410,13 +6217,19 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "range-alloc" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" + [[package]] name = "raw-cpuid" -version = "11.5.0" +version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6df7ab838ed27997ba19a4664507e6f82b41fe6e20be42929332156e5e85146" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", ] [[package]] @@ -5427,9 +6240,9 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] name = "rayon" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -5437,9 +6250,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -5456,11 +6269,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.17" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", ] [[package]] @@ -5471,52 +6284,37 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.14", + "thiserror 2.0.17", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] [[package]] name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - -[[package]] -name = "regex-syntax" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "renderdoc-sys" @@ -5526,9 +6324,9 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "reqwest" -version = "0.12.23" +version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64 0.22.1", "bytes", @@ -5573,14 +6371,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" dependencies = [ "ashpd 0.11.0", - "block2 0.6.1", + "block2 0.6.2", "dispatch2", "js-sys", "log", - "objc2 0.6.1", - "objc2-app-kit 0.3.1", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation 0.3.1", + "objc2-foundation 0.3.2", "pollster", "raw-window-handle", "urlencoding", @@ -5604,6 +6402,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rocksdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddb7af00d2b17dbd07d82c0063e25411959748ff03e8d4f96134c2ff41fce34f" +dependencies = [ + "libc", + "librocksdb-sys", +] + [[package]] name = "ron" version = "0.10.1" @@ -5611,7 +6419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "beceb6f7bf81c73e73aeef6dd1356d9a1b2b4909e1f0fc3e59b034f9572d7b7f" dependencies = [ "base64 0.22.1", - "bitflags 2.9.1", + "bitflags 2.10.0", "serde", "serde_derive", "unicode-ident", @@ -5619,12 +6427,38 @@ dependencies = [ [[package]] name = "rs-dapi-client" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "backon", + "chrono", + "dapi-grpc 2.0.1", + "futures", + "getrandom 0.2.16", + "gloo-timers", + "hex", + "http", + "http-serde", + "lru", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.17", + "tokio", + "tonic-web-wasm-client 0.7.1", + "tracing", + "wasm-bindgen-futures", +] + +[[package]] +name = "rs-dapi-client" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ "backon", "chrono", - "dapi-grpc", + "dapi-grpc 2.1.0-rc.1", "futures", "getrandom 0.2.16", "gloo-timers", @@ -5637,9 +6471,9 @@ dependencies = [ "serde", "serde_json", "sha2", - "thiserror 2.0.14", + "thiserror 2.0.17", "tokio", - "tonic-web-wasm-client", + "tonic-web-wasm-client 0.8.0", "tower-service", "tracing", "wasm-bindgen-futures", @@ -5651,7 +6485,7 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -5661,9 +6495,9 @@ dependencies = [ [[package]] name = "rust-embed" -version = "8.7.2" +version = "8.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a" +checksum = "fb44e1917075637ee8c7bcb865cf8830e3a92b5b1189e44e3a0ab5a0d5be314b" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -5672,33 +6506,27 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.7.2" +version = "8.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c" +checksum = "382499b49db77a7c19abd2a574f85ada7e9dbe125d5d1160fa5cad7c4cf71fc9" dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.104", + "syn 2.0.107", "walkdir", ] [[package]] name = "rust-embed-utils" -version = "8.7.2" +version = "8.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594" +checksum = "21fcbee55c2458836bcdbfffb6ec9ba74bbc23ca7aa6816015a3dd2c4d8fc185" dependencies = [ "sha2", "walkdir", ] -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - [[package]] name = "rustc-hash" version = "1.1.0" @@ -5726,7 +6554,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5735,22 +6563,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys 0.9.4", - "windows-sys 0.60.2", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.31" +version = "0.23.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" dependencies = [ "log", "once_cell", @@ -5763,14 +6591,14 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.3.0", + "security-framework 3.5.1", ] [[package]] @@ -5793,9 +6621,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.4" +version = "0.103.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" dependencies = [ "ring", "rustls-pki-types", @@ -5825,11 +6653,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5865,7 +6693,7 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", "der", - "generic-array 0.14.7", + "generic-array 0.14.9", "pkcs8", "subtle", "zeroize", @@ -5898,7 +6726,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -5907,11 +6735,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.3.0" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80fb1d92c5028aa318b4b8bd7302a5bfcf48be96a37fc6fc790f806b0004ee0c" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5920,9 +6748,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", @@ -5930,16 +6758,17 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] @@ -5954,35 +6783,46 @@ dependencies = [ [[package]] name = "serde_bytes" -version = "0.11.17" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8437fd221bde2d4ca316d61b90e337e9e702b3820b87d63caa9ba6c02bd06d96" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" dependencies = [ "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] name = "serde_json" -version = "1.0.142" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.12.0", "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -5993,7 +6833,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -6042,7 +6882,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -6051,7 +6891,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.12.0", "itoa", "ryu", "serde", @@ -6161,7 +7001,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "calloop", "calloop-wayland-source", "cursor-icon", @@ -6202,21 +7042,37 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.0" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", ] [[package]] name = "spin" -version = "0.9.8" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" dependencies = [ - "lock_api", + "bitflags 2.10.0", ] [[package]] @@ -6240,9 +7096,9 @@ dependencies = [ [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_assertions" @@ -6252,11 +7108,12 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "std-shims" -version = "0.1.1" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90e49360f31b0b75a6a82a5205c6103ea07a79a60808d44f5cc879d303337926" +checksum = "227c4f8561598188d0df96dbe749824576174bba278b5b6bb2eacff1066067d0" dependencies = [ - "hashbrown 0.14.5", + "hashbrown 0.16.0", + "rustversion", "spin", ] @@ -6278,7 +7135,16 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros", + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", ] [[package]] @@ -6291,7 +7157,19 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.104", + "syn 2.0.107", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.107", ] [[package]] @@ -6322,9 +7200,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b" dependencies = [ "proc-macro2", "quote", @@ -6348,7 +7226,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -6357,7 +7235,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -6399,15 +7277,30 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" -version = "3.20.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", - "rustix 1.0.8", - "windows-sys 0.59.0", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tenderdash-abci" +version = "1.4.0" +source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.4.0#e2dd15f39246081e7d569e585ab78ff5340116ac" +dependencies = [ + "bytes", + "hex", + "lhash", + "semver", + "tenderdash-proto 1.4.0", + "thiserror 2.0.17", + "tracing", + "url", ] [[package]] @@ -6419,12 +7312,30 @@ dependencies = [ "hex", "lhash", "semver", - "tenderdash-proto", - "thiserror 2.0.14", + "tenderdash-proto 1.5.0-dev.2", + "thiserror 2.0.17", "tracing", "url", ] +[[package]] +name = "tenderdash-proto" +version = "1.4.0" +source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.4.0#e2dd15f39246081e7d569e585ab78ff5340116ac" +dependencies = [ + "bytes", + "chrono", + "derive_more 2.0.1", + "flex-error", + "num-derive", + "num-traits", + "prost 0.13.5", + "serde", + "subtle-encoding", + "tenderdash-proto-compiler 1.4.0", + "time", +] + [[package]] name = "tenderdash-proto" version = "1.5.0-dev.2" @@ -6435,26 +7346,40 @@ dependencies = [ "derive_more 2.0.1", "num-derive", "num-traits", - "prost", + "prost 0.14.1", "serde", "subtle-encoding", - "tenderdash-proto-compiler", - "thiserror 2.0.14", + "tenderdash-proto-compiler 1.5.0-dev.2", + "thiserror 2.0.17", "time", ] +[[package]] +name = "tenderdash-proto-compiler" +version = "1.4.0" +source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.4.0#e2dd15f39246081e7d569e585ab78ff5340116ac" +dependencies = [ + "fs_extra", + "prost-build 0.13.5", + "regex", + "tempfile", + "ureq", + "walkdir", + "zip 2.4.2", +] + [[package]] name = "tenderdash-proto-compiler" version = "1.5.0-dev.2" source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.0-dev.2#3f6ac716c42125a01caceb42cc5997efa41c88fc" dependencies = [ "fs_extra", - "prost-build", + "prost-build 0.14.1", "regex", "tempfile", "ureq", "walkdir", - "zip", + "zip 5.1.1", ] [[package]] @@ -6477,11 +7402,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.14" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.14", + "thiserror-impl 2.0.17", ] [[package]] @@ -6492,18 +7417,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] name = "thiserror-impl" -version = "2.0.14" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -6526,20 +7451,23 @@ dependencies = [ [[package]] name = "tiff" -version = "0.9.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" dependencies = [ + "fax", "flate2", - "jpeg-decoder", + "half", + "quick-error", "weezl", + "zune-jpeg", ] [[package]] name = "time" -version = "0.3.41" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", "itoa", @@ -6552,15 +7480,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", @@ -6603,9 +7531,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -6618,44 +7546,52 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "platform-value 2.0.1", + "platform-version 2.0.1", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "token-history-contract" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ - "platform-value", - "platform-version", + "platform-value 2.1.0-rc.1", + "platform-version 2.1.0-rc.1", "serde_json", - "thiserror 2.0.14", + "thiserror 2.0.17", ] [[package]] name = "tokio" -version = "1.47.1" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", - "socket2", + "socket2 0.6.1", "tokio-macros", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -6670,9 +7606,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -6711,7 +7647,7 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_edit 0.22.27", ] @@ -6724,14 +7660,23 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.10.0", - "toml_datetime", + "indexmap 2.12.0", + "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -6741,11 +7686,63 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.12.0", "serde", "serde_spanned", - "toml_datetime", - "winnow 0.7.12", + "toml_datetime 0.6.11", + "winnow 0.7.13", +] + +[[package]] +name = "toml_edit" +version = "0.23.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +dependencies = [ + "indexmap 2.12.0", + "toml_datetime 0.7.3", + "toml_parser", + "winnow 0.7.13", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow 0.7.13", +] + +[[package]] +name = "tonic" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost 0.13.5", + "rustls-native-certs", + "socket2 0.5.10", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", + "webpki-roots 0.26.11", ] [[package]] @@ -6767,7 +7764,7 @@ dependencies = [ "percent-encoding", "pin-project", "rustls-native-certs", - "socket2", + "socket2 0.6.1", "sync_wrapper", "tokio", "tokio-rustls", @@ -6776,7 +7773,21 @@ dependencies = [ "tower-layer", "tower-service", "tracing", - "webpki-roots 1.0.2", + "webpki-roots 1.0.3", +] + +[[package]] +name = "tonic-build" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac6f67be712d12f0b41328db3137e0d0757645d8904b4cb7d51cd9c2279e847" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build 0.13.5", + "prost-types 0.13.5", + "quote", + "syn 2.0.107", ] [[package]] @@ -6788,7 +7799,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -6798,8 +7809,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" dependencies = [ "bytes", - "prost", - "tonic", + "prost 0.14.1", + "tonic 0.14.2", ] [[package]] @@ -6810,12 +7821,37 @@ checksum = "b4a16cba4043dc3ff43fcb3f96b4c5c154c64cbd18ca8dce2ab2c6a451d058a2" dependencies = [ "prettyplease", "proc-macro2", - "prost-build", - "prost-types", + "prost-build 0.14.1", + "prost-types 0.14.1", "quote", - "syn 2.0.104", + "syn 2.0.107", "tempfile", - "tonic-build", + "tonic-build 0.14.2", +] + +[[package]] +name = "tonic-web-wasm-client" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3bb7acca55e6790354be650f4042d418fcf8e2bc42ac382348f2b6bf057e5" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "httparse", + "js-sys", + "pin-project", + "thiserror 2.0.17", + "tonic 0.13.1", + "tower-service", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", ] [[package]] @@ -6834,8 +7870,8 @@ dependencies = [ "httparse", "js-sys", "pin-project", - "thiserror 2.0.14", - "tonic", + "thiserror 2.0.17", + "tonic 0.14.2", "tower-service", "wasm-bindgen", "wasm-bindgen-futures", @@ -6851,7 +7887,7 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", - "indexmap 2.10.0", + "indexmap 2.12.0", "pin-project-lite", "slab", "sync_wrapper", @@ -6868,7 +7904,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "bytes", "futures-util", "http", @@ -6911,7 +7947,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -6937,14 +7973,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -6982,15 +8018,15 @@ dependencies = [ [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "tz-rs" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1450bf2b99397e72070e7935c89facaa80092ac812502200375f1f7d33c71a1" +checksum = "14eff19b8dc1ace5bf7e4d920b2628ae3837f422ff42210cb1567cbf68b5accf" [[package]] name = "uds_windows" @@ -7020,9 +8056,9 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06" [[package]] name = "unicode-normalization" @@ -7041,9 +8077,9 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -7075,9 +8111,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.0.12" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f0fde9bc91026e381155f8c67cb354bcd35260b2f4a29bcc84639f762760c39" +checksum = "99ba1025f18a4a3fc3e9b48c868e9beb4f24f4b4b1a325bada26bd4119f46537" dependencies = [ "base64 0.22.1", "flate2", @@ -7088,14 +8124,14 @@ dependencies = [ "rustls-pki-types", "ureq-proto", "utf-8", - "webpki-roots 0.26.11", + "webpki-roots 1.0.3", ] [[package]] name = "ureq-proto" -version = "0.4.2" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59db78ad1923f2b1be62b6da81fe80b173605ca0d57f85da2e005382adf693f7" +checksum = "60b4531c118335662134346048ddb0e54cc86bd7e81866757873055f0e38f5d2" dependencies = [ "base64 0.22.1", "http", @@ -7105,9 +8141,9 @@ dependencies = [ [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", @@ -7141,12 +8177,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "js-sys", + "serde", "wasm-bindgen", ] @@ -7198,7 +8235,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80a7e511ce1795821207a837b7b1c8d8aca0c648810966ad200446ae58f6667f" dependencies = [ "itertools 0.14.0", - "nom", + "nom 8.0.0", ] [[package]] @@ -7221,8 +8258,26 @@ checksum = "fec4ebcc5594130c31b49594d55c0583fe80621f252f570b222ca4845cafd3cf" dependencies = [ "crypto-bigint", "elliptic-curve", - "elliptic-curve-tools", - "generic-array 1.2.0", + "elliptic-curve-tools 0.1.2", + "generic-array 1.3.4", + "hex", + "num", + "rand_core 0.6.4", + "serde", + "sha3", + "subtle", + "zeroize", +] + +[[package]] +name = "vsss-rs" +version = "5.1.0" +source = "git+https://github.com/dashpay/vsss-rs?branch=main#668f1406bf25a4b9a95cd97c9069f7a1632897c3" +dependencies = [ + "crypto-bigint", + "elliptic-curve", + "elliptic-curve-tools 0.2.0", + "generic-array 1.3.4", "hex", "num", "rand_core 0.6.4", @@ -7244,13 +8299,24 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +dependencies = [ + "platform-value 2.0.1", + "platform-version 2.0.1", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "wallet-utils-contract" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ - "platform-value", - "platform-version", + "platform-value 2.1.0-rc.1", + "platform-version 2.1.0-rc.1", "serde_json", - "thiserror 2.0.14", + "thiserror 2.0.17", ] [[package]] @@ -7269,45 +8335,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" dependencies = [ "bumpalo", "log", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" dependencies = [ "cfg-if", "js-sys", @@ -7318,9 +8385,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7328,22 +8395,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" dependencies = [ "unicode-ident", ] @@ -7369,7 +8436,7 @@ checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" dependencies = [ "cc", "downcast-rs", - "rustix 1.0.8", + "rustix 1.1.2", "scoped-tls", "smallvec", "wayland-sys", @@ -7381,8 +8448,8 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags 2.9.1", - "rustix 1.0.8", + "bitflags 2.10.0", + "rustix 1.1.2", "wayland-backend", "wayland-scanner", ] @@ -7393,7 +8460,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "cursor-icon", "wayland-backend", ] @@ -7404,7 +8471,7 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" dependencies = [ - "rustix 1.0.8", + "rustix 1.1.2", "wayland-client", "xcursor", ] @@ -7415,7 +8482,7 @@ version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -7427,7 +8494,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7440,7 +8507,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7472,9 +8539,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" dependencies = [ "js-sys", "wasm-bindgen", @@ -7492,16 +8559,16 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf4f3c0ba838e82b4e5ccc4157003fb8c324ee24c058470ffb82820becbde98" +checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" dependencies = [ "core-foundation 0.10.1", "jni", "log", "ndk-context", - "objc2 0.6.1", - "objc2-foundation 0.3.1", + "objc2 0.6.3", + "objc2-foundation 0.3.2", "url", "web-sys", ] @@ -7512,14 +8579,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.2", + "webpki-roots 1.0.3", ] [[package]] name = "webpki-roots" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" dependencies = [ "rustls-pki-types", ] @@ -7532,9 +8599,9 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "wfd" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e713040b67aae5bf1a0ae3e1ebba8cc29ab2b90da9aa1bff6e09031a8a41d7a8" +checksum = "0c17bbfb155305bcb79144f568c3b796275ba4db5d5856597bc85acefe29b819" dependencies = [ "libc", "winapi", @@ -7547,12 +8614,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec8fb398f119472be4d80bc3647339f56eb63b2a331f6a3d16e25d8144197dd9" dependencies = [ "arrayvec", - "bitflags 2.9.1", + "bitflags 2.10.0", "cfg_aliases", "document-features", "hashbrown 0.15.5", "js-sys", "log", + "naga", "parking_lot", "portable-atomic", "profiling", @@ -7560,6 +8628,7 @@ dependencies = [ "smallvec", "static_assertions", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", "wgpu-core", "wgpu-hal", @@ -7575,11 +8644,11 @@ dependencies = [ "arrayvec", "bit-set 0.8.0", "bit-vec 0.8.0", - "bitflags 2.9.1", + "bitflags 2.10.0", "cfg_aliases", "document-features", "hashbrown 0.15.5", - "indexmap 2.10.0", + "indexmap 2.12.0", "log", "naga", "once_cell", @@ -7589,12 +8658,32 @@ dependencies = [ "raw-window-handle", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.14", + "thiserror 2.0.17", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-types", ] +[[package]] +name = "wgpu-core-deps-apple" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd488b3239b6b7b185c3b045c39ca6bf8af34467a4c5de4e0b1a564135d093d" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09ad7aceb3818e52539acc679f049d3475775586f3f4e311c30165cf2c00445" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-core-deps-windows-linux-android" version = "25.0.0" @@ -7610,17 +8699,45 @@ version = "25.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f968767fe4d3d33747bbd1473ccd55bf0f6451f55d733b5597e67b5deab4ad17" dependencies = [ - "bitflags 2.9.1", + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.8.0", + "bitflags 2.10.0", + "block", + "bytemuck", + "cfg-if", "cfg_aliases", + "core-graphics-types", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.15.5", + "js-sys", + "khronos-egl", + "libc", "libloading", "log", + "metal", "naga", + "ndk-sys 0.5.0+25.2.9519653", + "objc", + "ordered-float", "parking_lot", "portable-atomic", + "profiling", + "range-alloc", "raw-window-handle", "renderdoc-sys", - "thiserror 2.0.14", + "smallvec", + "thiserror 2.0.17", + "wasm-bindgen", + "web-sys", "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", ] [[package]] @@ -7629,11 +8746,11 @@ version = "25.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2aa49460c2a8ee8edba3fca54325540d904dd85b2e086ada762767e17d06e8bc" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "bytemuck", "js-sys", "log", - "thiserror 2.0.14", + "thiserror 2.0.17", "web-sys", ] @@ -7645,7 +8762,7 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" dependencies = [ "either", "env_home", - "rustix 1.0.8", + "rustix 1.1.2", "winsafe", ] @@ -7656,7 +8773,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" dependencies = [ "env_home", - "rustix 1.0.8", + "rustix 1.1.2", "winsafe", ] @@ -7678,11 +8795,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7691,6 +8808,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -7698,9 +8825,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.61.2", "windows-future", - "windows-link", + "windows-link 0.1.3", "windows-numerics", ] @@ -7710,7 +8837,20 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core", + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", ] [[package]] @@ -7719,11 +8859,24 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -7732,31 +8885,53 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core", - "windows-link", + "windows-core 0.61.2", + "windows-link 0.1.3", "windows-threading", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.107", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.107", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -7765,14 +8940,20 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-numerics" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core", - "windows-link", + "windows-core 0.61.2", + "windows-link 0.1.3", ] [[package]] @@ -7781,9 +8962,18 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -7792,7 +8982,26 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", ] [[package]] @@ -7801,7 +9010,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", ] [[package]] @@ -7846,7 +9064,16 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.3", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", ] [[package]] @@ -7897,19 +9124,19 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.3" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -7918,7 +9145,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -7941,9 +9168,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -7965,9 +9192,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -7989,9 +9216,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -8001,9 +9228,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -8025,9 +9252,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -8049,9 +9276,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -8073,9 +9300,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -8097,9 +9324,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" @@ -8110,7 +9337,7 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.9.1", + "bitflags 2.10.0", "block2 0.5.1", "bytemuck", "calloop", @@ -8164,9 +9391,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.12" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" dependencies = [ "memchr", ] @@ -8188,26 +9415,140 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "winter-air" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef01227f23c7c331710f43b877a8333f5f8d539631eea763600f1a74bf018c7c" +dependencies = [ + "libm", + "winter-crypto", + "winter-fri", + "winter-math", + "winter-utils", +] + +[[package]] +name = "winter-crypto" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdb247bc142438798edb04067ab72a22cf815f57abbd7b78a6fa986fc101db8" +dependencies = [ + "blake3", + "sha3", + "winter-math", + "winter-utils", +] + +[[package]] +name = "winter-fri" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd592b943f9d65545683868aaf1b601eb66e52bfd67175347362efff09101d3a" +dependencies = [ + "winter-crypto", + "winter-math", + "winter-utils", +] + +[[package]] +name = "winter-math" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aecfb48ee6a8b4746392c8ff31e33e62df8528a3b5628c5af27b92b14aef1ea" +dependencies = [ + "winter-utils", +] + +[[package]] +name = "winter-maybe-async" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d31a19dae58475d019850e25b0170e94b16d382fbf6afee9c0e80fdc935e73e" +dependencies = [ + "quote", + "syn 2.0.107", +] + +[[package]] +name = "winter-prover" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cc631ed56cd39b78ef932c1ec4060cc6a44d114474291216c32f56655b3048" +dependencies = [ + "tracing", + "winter-air", + "winter-crypto", + "winter-fri", + "winter-math", + "winter-maybe-async", + "winter-utils", +] + +[[package]] +name = "winter-utils" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9951263ef5317740cd0f49e618db00c72fabb70b75756ea26c4d5efe462c04dd" +dependencies = [ + "rayon", +] + +[[package]] +name = "winter-verifier" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0425ea81f8f703a1021810216da12003175c7974a584660856224df04b2e2fdb" +dependencies = [ + "winter-air", + "winter-crypto", + "winter-fri", + "winter-math", + "winter-utils", +] + +[[package]] +name = "winterfell" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f824ddd5aec8ca6a54307f20c115485a8a919ea94dd26d496d856ca6185f4f" +dependencies = [ + "winter-air", + "winter-prover", + "winter-verifier", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "withdrawals-contract" +version = "2.0.1" +source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" dependencies = [ - "bitflags 2.9.1", + "num_enum 0.5.11", + "platform-value 2.0.1", + "platform-version 2.0.1", + "serde", + "serde_json", + "serde_repr", + "thiserror 2.0.17", ] [[package]] name = "withdrawals-contract" -version = "2.1.0-dev.8" -source = "git+https://github.com/dashpay/platform?rev=d6f9d9e74b885fbac27948bce6bae60b0cbd7135#d6f9d9e74b885fbac27948bce6bae60b0cbd7135" +version = "2.1.0-rc.1" +source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" dependencies = [ "num_enum 0.5.11", - "platform-value", - "platform-version", + "platform-value 2.1.0-rc.1", + "platform-version 2.1.0-rc.1", "serde", "serde_json", "serde_repr", - "thiserror 2.0.14", + "thiserror 2.0.17", ] [[package]] @@ -8238,24 +9579,24 @@ dependencies = [ [[package]] name = "x11rb" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", "libloading", "once_cell", - "rustix 0.38.44", + "rustix 1.1.2", "x11rb-protocol", ] [[package]] name = "x11rb-protocol" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "xcursor" @@ -8269,7 +9610,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "dlib", "log", "once_cell", @@ -8308,15 +9649,15 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", "synstructure", ] [[package]] name = "zbus" -version = "5.9.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb4f9a464286d42851d18a605f7193b8febaf5b0919d71c6399b7b26e5b0aad" +checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" dependencies = [ "async-broadcast", "async-executor", @@ -8338,8 +9679,9 @@ dependencies = [ "serde_repr", "tracing", "uds_windows", - "windows-sys 0.59.0", - "winnow 0.7.12", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.13", "zbus_macros", "zbus_names", "zvariant", @@ -8363,7 +9705,7 @@ checksum = "dc6821851fa840b708b4cbbaf6241868cabc85a2dc22f426361b0292bfc0b836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", "zbus-lockstep", "zbus_xml", "zvariant", @@ -8371,14 +9713,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.9.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef9859f68ee0c4ee2e8cde84737c78e3f4c54f946f2a38645d0d4c7a95327659" +checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" dependencies = [ - "proc-macro-crate 3.3.0", + "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", "zbus_names", "zvariant", "zvariant_utils", @@ -8392,7 +9734,7 @@ checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" dependencies = [ "serde", "static_assertions", - "winnow 0.7.12", + "winnow 0.7.13", "zvariant", ] @@ -8411,22 +9753,22 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.26" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.26" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -8446,15 +9788,15 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "serde", "zeroize_derive", @@ -8468,7 +9810,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", ] [[package]] @@ -8538,7 +9880,24 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.12.0", + "memchr", + "thiserror 2.0.17", + "zopfli", ] [[package]] @@ -8550,7 +9909,7 @@ dependencies = [ "arbitrary", "crc32fast", "flate2", - "indexmap 2.10.0", + "indexmap 2.12.0", "memchr", "zopfli", ] @@ -8595,46 +9954,70 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" -version = "5.6.0" +version = "5.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91b3680bb339216abd84714172b5138a4edac677e641ef17e1d8cb1b3ca6e6f" +checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" dependencies = [ "endi", "enumflags2", "serde", "url", - "winnow 0.7.12", + "winnow 0.7.13", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.6.0" +version = "5.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a8c68501be459a8dbfffbe5d792acdd23b4959940fc87785fb013b32edbc208" +checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" dependencies = [ - "proc-macro-crate 3.3.0", + "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.107", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.2.0" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16edfee43e5d7b553b77872d99bc36afdda75c223ca7ad5e3fbecd82ca5fc34" +checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" dependencies = [ "proc-macro2", "quote", "serde", - "static_assertions", - "syn 2.0.104", - "winnow 0.7.12", + "syn 2.0.107", + "winnow 0.7.13", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cb80ea5b6..765460965 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,9 @@ qrcode = "0.14.1" nix = { version = "0.30.1", features = ["signal"] } eframe = { version = "0.32.0", features = ["persistence"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", features = ["core_key_wallet", "core_bincode", "core_quorum-validation", "core_verification", "core_rpc_client"], rev = "d6f9d9e74b885fbac27948bce6bae60b0cbd7135" } +dash-sdk = { git = "https://www.github.com/dashpay/platform", rev = "68f77c085643edb9a46afd4ff51c9d20b4ce2912", features = ["core_key_wallet", "core_bincode", "core_quorum-validation", "core_verification", "core_rpc_client"] } +grovestark = { path = "../grovestark" } +rayon = "1.8" thiserror = "2.0.12" serde = "1.0.219" serde_json = "1.0.140" @@ -37,6 +39,7 @@ envy = "0.4.2" chrono = "0.4.41" chrono-humanize = "0.2.3" sha2 = "0.10.9" +ed25519-dalek = "2.1" arboard = { version = "3.6.0", default-features = false, features = [ "windows-sys", ] } @@ -68,9 +71,11 @@ native-dialog = "0.9.0" raw-cpuid = "11.5.0" [dev-dependencies] - tempfile = { version = "3.20.0" } egui_kittest = { version = "0.32.0", features = ["eframe"] } [lints.clippy] uninlined_format_args = "allow" + +[patch.crates-io] +elliptic-curve-tools = { git = "https://github.com/mikelodder7/elliptic-curve-tools", rev = "c989865fa71503d2cbf5c5795c4ebcf4a2f3221c" } diff --git a/src/app.rs b/src/app.rs index 0608c3fb0..40e42d086 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,7 @@ use crate::database::Database; use crate::logging::initialize_logger; use crate::model::settings::Settings; use crate::ui::contracts_documents::contracts_documents_screen::DocumentQueryScreen; -use crate::ui::contracts_documents::dashpay_coming_soon_screen::DashPayComingSoonScreen; +use crate::ui::contracts_documents::dashpay_coming_soon_screen::DashpayScreen; use crate::ui::dpns::dpns_contested_names_screen::{ DPNSScreen, DPNSSubscreen, ScheduledVoteCastingStatus, }; @@ -21,6 +21,7 @@ use crate::ui::theme::ThemeMode; use crate::ui::tokens::tokens_screen::{TokensScreen, TokensSubscreen}; use crate::ui::tools::contract_visualizer_screen::ContractVisualizerScreen; use crate::ui::tools::document_visualizer_screen::DocumentVisualizerScreen; +use crate::ui::tools::grovestark_screen::GroveSTARKScreen; use crate::ui::tools::masternode_list_diff_screen::MasternodeListDiffScreen; use crate::ui::tools::platform_info_screen::PlatformInfoScreen; use crate::ui::tools::proof_log_screen::ProofLogScreen; @@ -220,6 +221,7 @@ impl AppState { let mut contract_visualizer_screen = ContractVisualizerScreen::new(&mainnet_app_context); let mut proof_log_screen = ProofLogScreen::new(&mainnet_app_context); let mut platform_info_screen = PlatformInfoScreen::new(&mainnet_app_context); + let mut grovestark_screen = GroveSTARKScreen::new(&mainnet_app_context); let mut document_query_screen = DocumentQueryScreen::new(&mainnet_app_context); let mut tokens_balances_screen = TokensScreen::new(&mainnet_app_context, TokensSubscreen::MyTokens); @@ -227,7 +229,7 @@ impl AppState { TokensScreen::new(&mainnet_app_context, TokensSubscreen::SearchTokens); let mut token_creator_screen = TokensScreen::new(&mainnet_app_context, TokensSubscreen::TokenCreator); - let mut contracts_dashpay_screen = DashPayComingSoonScreen::new(&mainnet_app_context); + let mut contracts_dashpay_screen = DashpayScreen::new(&mainnet_app_context); let mut network_chooser_screen = NetworkChooserScreen::new( &mainnet_app_context, @@ -261,11 +263,12 @@ impl AppState { document_visualizer_screen = DocumentVisualizerScreen::new(testnet_app_context); contract_visualizer_screen = ContractVisualizerScreen::new(testnet_app_context); document_query_screen = DocumentQueryScreen::new(testnet_app_context); + grovestark_screen = GroveSTARKScreen::new(testnet_app_context); wallets_balances_screen = WalletsBalancesScreen::new(testnet_app_context); proof_log_screen = ProofLogScreen::new(testnet_app_context); platform_info_screen = PlatformInfoScreen::new(testnet_app_context); masternode_list_diff_screen = MasternodeListDiffScreen::new(testnet_app_context); - contracts_dashpay_screen = DashPayComingSoonScreen::new(testnet_app_context); + contracts_dashpay_screen = DashpayScreen::new(testnet_app_context); tokens_balances_screen = TokensScreen::new(testnet_app_context, TokensSubscreen::MyTokens); token_search_screen = @@ -288,6 +291,7 @@ impl AppState { document_query_screen = DocumentQueryScreen::new(devnet_app_context); masternode_list_diff_screen = MasternodeListDiffScreen::new(devnet_app_context); contract_visualizer_screen = ContractVisualizerScreen::new(devnet_app_context); + grovestark_screen = GroveSTARKScreen::new(devnet_app_context); wallets_balances_screen = WalletsBalancesScreen::new(devnet_app_context); proof_log_screen = ProofLogScreen::new(devnet_app_context); platform_info_screen = PlatformInfoScreen::new(devnet_app_context); @@ -311,11 +315,12 @@ impl AppState { document_visualizer_screen = DocumentVisualizerScreen::new(local_app_context); contract_visualizer_screen = ContractVisualizerScreen::new(local_app_context); document_query_screen = DocumentQueryScreen::new(local_app_context); + grovestark_screen = GroveSTARKScreen::new(local_app_context); wallets_balances_screen = WalletsBalancesScreen::new(local_app_context); masternode_list_diff_screen = MasternodeListDiffScreen::new(local_app_context); proof_log_screen = ProofLogScreen::new(local_app_context); platform_info_screen = PlatformInfoScreen::new(local_app_context); - contracts_dashpay_screen = DashPayComingSoonScreen::new(local_app_context); + contracts_dashpay_screen = DashpayScreen::new(local_app_context); tokens_balances_screen = TokensScreen::new(local_app_context, TokensSubscreen::MyTokens); token_search_screen = @@ -445,13 +450,17 @@ impl AppState { RootScreenType::RootScreenToolsPlatformInfoScreen, Screen::PlatformInfoScreen(platform_info_screen), ), + ( + RootScreenType::RootScreenToolsGroveSTARKScreen, + Screen::GroveSTARKScreen(grovestark_screen), + ), ( RootScreenType::RootScreenDocumentQuery, Screen::DocumentQueryScreen(document_query_screen), ), ( - RootScreenType::RootScreenContractsDashPay, - Screen::DashPayComingSoonScreen(contracts_dashpay_screen), + RootScreenType::RootScreenDashpay, + Screen::DashpayScreen(contracts_dashpay_screen), ), ( RootScreenType::RootScreenNetworkChooser, diff --git a/src/backend_task/grovestark.rs b/src/backend_task/grovestark.rs new file mode 100644 index 000000000..3a2487ee0 --- /dev/null +++ b/src/backend_task/grovestark.rs @@ -0,0 +1,65 @@ +use crate::backend_task::BackendTaskSuccessResult; +use crate::model::grovestark_prover::{GroveSTARKProver, ProofDataOutput}; +use dash_sdk::Sdk; + +pub async fn run_grovestark_task( + task: GroveSTARKTask, + sdk: &Sdk, +) -> Result { + match task { + GroveSTARKTask::GenerateProof { + identity_id, + contract_id, + document_type, + document_id, + key_id, + private_key, + public_key, + } => { + let prover = GroveSTARKProver::new(); + + match prover + .generate_proof( + sdk, + &identity_id, + &contract_id, + &document_type, + &document_id, + key_id, + &private_key, + &public_key, + ) + .await + { + Ok(proof_data) => Ok(BackendTaskSuccessResult::GeneratedZKProof(proof_data)), + Err(e) => Err(format!("Failed to generate proof: {}", e)), + } + } + GroveSTARKTask::VerifyProof { proof_data } => { + let prover = GroveSTARKProver::new(); + + match prover.verify_proof(&proof_data) { + Ok(is_valid) => Ok(BackendTaskSuccessResult::VerifiedZKProof( + is_valid, proof_data, + )), + Err(e) => Err(format!("Failed to verify proof: {}", e)), + } + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum GroveSTARKTask { + GenerateProof { + identity_id: String, + contract_id: String, + document_type: String, + document_id: String, + key_id: u32, + private_key: [u8; 32], + public_key: [u8; 32], + }, + VerifyProof { + proof_data: ProofDataOutput, + }, +} diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 773dcfbe3..32b801e36 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -7,17 +7,23 @@ use crate::backend_task::identity::IdentityTask; use crate::backend_task::platform_info::{PlatformInfoTaskRequestType, PlatformInfoTaskResult}; use crate::backend_task::system_task::SystemTask; use crate::context::AppContext; +use dash_sdk::dpp::dashcore::bls_sig_utils::BLSSignature; +use dash_sdk::dpp::dashcore::network::message_qrinfo::QRInfo; +use dash_sdk::dpp::dashcore::BlockHash; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::grovestark_prover::ProofDataOutput; use crate::ui::tokens::tokens_screen::{ ContractDescriptionInfo, IdentityTokenIdentifier, TokenInfo, }; use crate::utils::egui_mpsc::SenderAsync; use contested_names::ScheduledDPNSVote; use dash_sdk::dpp::balances::credits::TokenAmount; +use dash_sdk::dpp::dashcore::network::message_sml::MnListDiff; use dash_sdk::dpp::data_contract::associated_token::token_perpetual_distribution::distribution_function::evaluate_interval::IntervalEvaluationExplanation; use dash_sdk::dpp::group::group_action::GroupAction; use dash_sdk::dpp::prelude::DataContract; use dash_sdk::dpp::state_transition::StateTransition; +use dash_sdk::dpp::tokens::token_pricing_schedule::TokenPricingSchedule; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::dpp::voting::votes::Vote; use dash_sdk::platform::proto::get_documents_request::get_documents_request_v0::Start; @@ -27,12 +33,14 @@ use futures::future::join_all; use std::collections::BTreeMap; use std::sync::Arc; use tokens::TokenTask; +use grovestark::GroveSTARKTask; pub mod broadcast_state_transition; pub mod contested_names; pub mod contract; pub mod core; pub mod document; +pub mod grovestark; pub mod identity; pub mod mnlist; pub mod platform_info; @@ -56,6 +64,7 @@ pub enum BackendTask { SystemTask(SystemTask), MnListTask(mnlist::MnListTask), PlatformInfo(PlatformInfoTaskRequestType), + GroveSTARKTask(GroveSTARKTask), None, } @@ -97,30 +106,26 @@ pub enum BackendTaskSuccessResult { ActiveGroupActions(IndexMap), TokenPricing { token_id: Identifier, - prices: Option, + prices: Option, }, UpdatedThemePreference(crate::ui::theme::ThemeMode), PlatformInfo(PlatformInfoTaskResult), + GeneratedZKProof(ProofDataOutput), + VerifiedZKProof(bool, ProofDataOutput), // MNList-specific results MnListFetchedDiff { base_height: u32, height: u32, - diff: dash_sdk::dpp::dashcore::network::message_sml::MnListDiff, + diff: MnListDiff, }, MnListFetchedQrInfo { - qr_info: dash_sdk::dpp::dashcore::network::message_qrinfo::QRInfo, + qr_info: QRInfo, }, MnListChainLockSigs { - entries: Vec<( - (u32, dash_sdk::dpp::dashcore::BlockHash), - Option, - )>, + entries: Vec<((u32, BlockHash), Option)>, }, MnListFetchedDiffs { - items: Vec<( - (u32, u32), - dash_sdk::dpp::dashcore::network::message_sml::MnListDiff, - )>, + items: Vec<((u32, u32), MnListDiff)>, }, } @@ -200,6 +205,9 @@ impl AppContext { BackendTask::PlatformInfo(platform_info_task) => { self.run_platform_info_task(platform_info_task).await } + BackendTask::GroveSTARKTask(grovestark_task) => { + grovestark::run_grovestark_task(grovestark_task, &sdk).await + } BackendTask::None => Ok(BackendTaskSuccessResult::None), } } diff --git a/src/context.rs b/src/context.rs index e8132edac..dc0d35288 100644 --- a/src/context.rs +++ b/src/context.rs @@ -156,7 +156,9 @@ impl AppContext { .map(|w| (w.seed_hash(), Arc::new(RwLock::new(w)))) .collect(); - let animate = match config.developer_mode.unwrap_or(false) { + let developer_mode_enabled = config.developer_mode.unwrap_or(false); + + let animate = match developer_mode_enabled { true => { tracing::debug!("developer_mode is enabled, disabling animations"); AtomicBool::new(false) @@ -166,7 +168,7 @@ impl AppContext { let app_context = AppContext { network, - developer_mode: AtomicBool::new(config.developer_mode.unwrap_or(false)), + developer_mode: AtomicBool::new(developer_mode_enabled), devnet_name: None, db, sdk: sdk.into(), diff --git a/src/model/grovestark_prover.rs b/src/model/grovestark_prover.rs new file mode 100644 index 000000000..7ce95a826 --- /dev/null +++ b/src/model/grovestark_prover.rs @@ -0,0 +1,530 @@ +use dash_sdk::Sdk; +use dash_sdk::dpp::document::DocumentV0Getters; +use dash_sdk::dpp::identifier::Identifier; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{KeyID, KeyType}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::documents::document_query::DocumentQuery; +use dash_sdk::platform::{ + Document, DriveDocumentQuery, Fetch, FetchMany, IdentityKeysQuery, IdentityPublicKey, +}; +use ed25519_dalek::{Signer, SigningKey}; +use grovestark::{ + GroveSTARK, PublicInputs, STARKConfig, STARKProof, create_witness_from_platform_proofs, +}; +use serde::{Deserialize, Serialize}; +use std::time::Instant; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProofDataOutput { + pub proof: Vec, // Serialized STARK proof + pub public_inputs: PublicInputsData, + pub metadata: ProofMetadata, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PublicInputsData { + pub state_root: [u8; 32], + pub contract_id: [u8; 32], + pub message_hash: [u8; 32], + pub timestamp: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProofMetadata { + pub created_at: u64, + pub proof_size: usize, + pub generation_time_ms: u64, + pub security_level: u32, +} + +pub struct GroveSTARKProver { + prover: GroveSTARK, +} + +impl Default for GroveSTARKProver { + fn default() -> Self { + Self::new() + } +} + +impl GroveSTARKProver { + pub fn new() -> Self { + // Use GroveSTARK's default config + let config = STARKConfig::default(); + + Self { + prover: GroveSTARK::with_config(config), + } + } + + /// Generate a proof for document ownership + #[allow(clippy::too_many_arguments)] + pub async fn generate_proof( + &self, + sdk: &Sdk, + identity_id: &str, + contract_id: &str, + document_type: &str, + document_id: &str, + key_id: u32, + private_key: &[u8; 32], + public_key: &[u8; 32], + ) -> Result { + if cfg!(debug_assertions) { + return Err(GroveSTARKError::UnsupportedBuild( + "GroveSTARK proof generation requires a release build (cargo run --release)" + .to_string(), + )); + } + + let start_time = Instant::now(); + + tracing::info!("Starting ZK proof generation"); + tracing::info!("Identity ID: {}", identity_id); + tracing::info!("Contract ID: {}", contract_id); + tracing::info!("Document Type: {}", document_type); + tracing::info!("Document ID: {}", document_id); + + // Step 1: Parse identifiers + tracing::debug!("Parsing identifiers..."); + let identity_identifier = + Identifier::from_string(identity_id, Encoding::Base58).map_err(|e| { + tracing::error!("Failed to parse identity ID: {}", e); + GroveSTARKError::InvalidIdentityId(e.to_string()) + })?; + let contract_identifier = Identifier::from_string(contract_id, Encoding::Base58) + .map_err(|e| GroveSTARKError::InvalidContractId(e.to_string()))?; + + // Step 2: Fetch specific key with proof using new SDK API + tracing::info!("Fetching specific key {} with proof...", key_id); + + // Create a query for the specific key + let specific_key_ids: Vec = vec![key_id]; + let keys_query = IdentityKeysQuery::new(identity_identifier, specific_key_ids); + + // Fetch only the specified key with proof + let (specific_keys, _metadata, key_proof) = + IdentityPublicKey::fetch_many_with_metadata_and_proof(sdk, keys_query, None) + .await + .map_err(|e| { + tracing::error!("Failed to fetch key with proof: {}", e); + GroveSTARKError::Platform(e.to_string()) + })?; + + // Verify the key exists in the identity + let identity_key = specific_keys + .get(&key_id) + .and_then(|maybe_key| maybe_key.as_ref()) + .ok_or_else(|| { + tracing::error!("Key {} not found for identity", key_id); + GroveSTARKError::PrivateKeyNotAvailable + })?; + + // Verify it's an EdDSA key + if identity_key.key_type() != KeyType::EDDSA_25519_HASH160 { + return Err(GroveSTARKError::InvalidProof( + "Key is not EdDSA type required for ZK proofs".to_string(), + )); + } + + // Use the public key passed from the UI (derived from private key) + let public_key_bytes = *public_key; + + // 3. KEY PROOF (Raw bytes) + tracing::info!("=== 3. KEY PROOF (Raw bytes) ==="); + tracing::info!("Key proof size: {} bytes", key_proof.grovedb_proof.len()); + tracing::info!("Key proof hex: {}", hex::encode(&key_proof.grovedb_proof)); + + // Additional key details + tracing::info!("Key ID: {}", key_id); + tracing::info!("Key type: {:?}", identity_key.key_type()); + tracing::info!("Key purpose: {:?}", identity_key.purpose()); + tracing::info!( + "Identity key data (hash160): {} bytes - {}", + identity_key.data().len(), + hex::encode(identity_key.data().to_vec()) + ); + + // Step 3: Fetch contract and create DocumentQuery + tracing::info!("Fetching contract..."); + let contract = dash_sdk::platform::DataContract::fetch(sdk, contract_identifier) + .await + .map_err(|e| { + tracing::error!("Failed to fetch contract: {}", e); + GroveSTARKError::Platform(e.to_string()) + })? + .ok_or_else(|| { + tracing::error!("Contract not found for ID: {}", contract_id); + GroveSTARKError::InvalidContractId("Contract not found".to_string()) + })?; + + let document_id_identifier = Identifier::from_string( + document_id, + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, + ) + .map_err(|e| GroveSTARKError::Platform(e.to_string()))?; + + let query = DocumentQuery::new(contract, document_type) + .map_err(|e| GroveSTARKError::Platform(e.to_string()))? + .with_document_id(&document_id_identifier); + + tracing::info!("Fetching document with proof..."); + let (document_opt, _metadata, proof) = + Document::fetch_with_metadata_and_proof(sdk, query.clone(), None) + .await + .map_err(|e| { + tracing::error!("Failed to fetch document with proof: {}", e); + GroveSTARKError::Platform(e.to_string()) + })?; + + let document = document_opt.ok_or_else(|| { + tracing::error!("Document not found for ID: {}", document_id); + GroveSTARKError::DocumentNotFound + })?; + + // COMPREHENSIVE LOGGING FOR DEBUGGING + + // 1. REAL DOCUMENT (JSON format) + tracing::info!("=== 1. REAL DOCUMENT (JSON FORMAT) ==="); + if let Ok(json_value) = serde_json::to_value(&document) { + let json_pretty = serde_json::to_string_pretty(&json_value).unwrap_or_default(); + tracing::info!( + "Full JSON document as returned by Platform:\n{}", + json_pretty + ); + + // Also log specific fields we care about + if let Some(owner_id_value) = json_value.get("$ownerId") { + tracing::info!("$ownerId field in document: {}", owner_id_value); + } + if let Some(id_value) = json_value.get("$id") { + tracing::info!("$id field in document: {}", id_value); + } + if let Some(revision_value) = json_value.get("$revision") { + tracing::info!("$revision field in document: {}", revision_value); + } + } + + // For witness creation, we need proper serialization + let document_cbor = serde_json::to_vec(&document).map_err(|e| { + GroveSTARKError::SerializationError(format!("Failed to encode document: {}", e)) + })?; + + // 5. EXPECTED VALUES FOR VERIFICATION + let document_owner_id = document.owner_id(); + tracing::info!("=== 5. EXPECTED VALUES FOR VERIFICATION ==="); + tracing::info!( + "Document owner_id (base58): {}", + document_owner_id + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) + ); + tracing::info!( + "Document owner_id (hex): {}", + hex::encode(document_owner_id.to_buffer()) + ); + tracing::info!( + "Document owner_id (raw bytes): {:?}", + document_owner_id.to_buffer() + ); + + tracing::info!( + "Identity_id we're proving for (base58): {}", + identity_identifier + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) + ); + tracing::info!( + "Identity_id we're proving for (hex): {}", + hex::encode(identity_identifier.to_buffer()) + ); + tracing::info!( + "Identity_id we're proving for (raw bytes): {:?}", + identity_identifier.to_buffer() + ); + + // Ownership verification status + if document_owner_id == identity_identifier { + tracing::info!( + "✅ OWNER MATCH: Document owner matches proving identity - proof should succeed" + ); + } else { + tracing::warn!( + "⚠️ OWNER MISMATCH: Document owner does NOT match proving identity - proof should fail!" + ); + } + + // 2. DOCUMENT PROOF (Raw bytes) + tracing::info!("=== 2. DOCUMENT PROOF (Raw bytes) ==="); + tracing::info!("Document proof size: {} bytes", proof.grovedb_proof.len()); + tracing::info!("Document proof hex: {}", hex::encode(&proof.grovedb_proof)); + + // Step 4: Get current state root by verifying document proof + let drive_document_query: DriveDocumentQuery = (&query) + .try_into() + .map_err(|e: dash_sdk::error::Error| GroveSTARKError::Platform(e.to_string()))?; + let (state_root, _documents) = drive_document_query + .verify_proof(&proof.grovedb_proof, sdk.version()) + .map_err(|e| { + tracing::error!("Failed to verify document proof: {}", e); + GroveSTARKError::InvalidProof(e.to_string()) + })?; + + tracing::info!( + "Document proof root hash (hex): {}", + hex::encode(state_root) + ); + tracing::info!("Document proof root hash (raw bytes): {:?}", state_root); + + // Step 5: Create signing challenge + let challenge = create_challenge(&state_root, contract_id, document_id); + + // Step 6: Sign the challenge with Ed25519 (we don't use this signature in the new approach) + // The witness creation will handle the signing internally + + // Step 7: Log proof information + tracing::info!( + "Using separate proofs - key: {} bytes, document: {} bytes", + key_proof.grovedb_proof.len(), + proof.grovedb_proof.len() + ); + + // 6. OPTIONAL BUT HELPFUL + tracing::info!("=== 6. OPTIONAL BUT HELPFUL ==="); + tracing::info!("Contract ID (base58): {}", contract_id); + tracing::info!( + "Contract ID (hex): {}", + hex::encode(contract_identifier.to_buffer()) + ); + tracing::info!("Document Type: {}", document_type); + tracing::info!("Document ID (base58): {}", document_id); + tracing::info!( + "Document ID (hex): {}", + hex::encode(document_id_identifier.to_buffer()) + ); + tracing::info!("State root (hex): {}", hex::encode(state_root)); + tracing::info!("State root (raw bytes): {:?}", state_root); + + // Document CBOR details + tracing::info!("Document CBOR size: {} bytes", document_cbor.len()); + if document_cbor.len() <= 500 { + tracing::info!("Document CBOR (hex): {}", hex::encode(&document_cbor)); + } else { + tracing::info!( + "Document CBOR (first 500 bytes hex): {}", + hex::encode(&document_cbor[..500]) + ); + } + + // 4. EdDSA SIGNATURE COMPONENTS + tracing::info!("=== 4. EdDSA SIGNATURE COMPONENTS ==="); + + // Sign the challenge message + let signing_key = SigningKey::from_bytes(private_key); + let signature = signing_key.sign(&challenge); + let sig_bytes = signature.to_bytes(); + let mut signature_r = [0u8; 32]; + let mut signature_s = [0u8; 32]; + signature_r.copy_from_slice(&sig_bytes[0..32]); + signature_s.copy_from_slice(&sig_bytes[32..64]); + + tracing::info!("Signature R (hex): {}", hex::encode(signature_r)); + tracing::info!("Signature R (raw bytes): {:?}", signature_r); + tracing::info!("Signature S (hex): {}", hex::encode(signature_s)); + tracing::info!("Signature S (raw bytes): {:?}", signature_s); + tracing::info!("Public key (hex): {}", hex::encode(public_key_bytes)); + tracing::info!("Public key (raw bytes): {:?}", public_key_bytes); + tracing::info!("Message/Challenge (hex): {}", hex::encode(challenge)); + tracing::info!("Message/Challenge (raw bytes): {:?}", challenge); + + // Step 8: Use GroveSTARK's new platform proofs V2 API + tracing::info!("Creating witness with GroveSTARK platform proofs V2..."); + + let witness = create_witness_from_platform_proofs( + &proof.grovedb_proof, // Raw document proof from SDK + &key_proof.grovedb_proof, // Raw key proof from SDK + document_cbor.clone(), // Use the proper CBOR we created above + &public_key_bytes, // Public key bytes + &signature_r, // Signature R component + &signature_s, // Signature s component + &challenge, // Message to sign + ) + .map_err(|e| { + tracing::error!("GroveSTARK witness creation failed: {:?}", e); + GroveSTARKError::ProofGenerationFailed(format!( + "GroveSTARK witness creation failed: {:?}", + e + )) + })?; + + tracing::info!("Witness created successfully"); + + // Step 8: Prepare public inputs + let public_inputs = PublicInputs { + state_root, + contract_id: contract_identifier.to_buffer(), + message_hash: challenge, + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| GroveSTARKError::TimeError(e.to_string()))? + .as_secs(), + }; + + // Step 9: Generate the STARK proof + tracing::info!("Generating STARK proof (this normally takes around 10 seconds)..."); + eprintln!("Rayon thread pool size: {}", rayon::current_num_threads()); + let proof = self + .prover + .prove(witness, public_inputs.clone()) + .map_err(|e| { + tracing::error!("STARK proof generation failed: {}", e); + GroveSTARKError::ProofGenerationFailed(e.to_string()) + })?; + + tracing::info!("STARK proof generated successfully"); + + // Step 10: Serialize the proof + let serialized_proof = serde_json::to_vec(&proof) + .map_err(|e| GroveSTARKError::SerializationError(e.to_string()))?; + + let generation_time = start_time.elapsed(); + tracing::info!( + "Total proof generation time: {:.2}s", + generation_time.as_secs_f32() + ); + + Ok(ProofDataOutput { + proof: serialized_proof.clone(), + public_inputs: PublicInputsData { + state_root: public_inputs.state_root, + contract_id: public_inputs.contract_id, + message_hash: public_inputs.message_hash, + timestamp: public_inputs.timestamp, + }, + metadata: ProofMetadata { + created_at: public_inputs.timestamp, + proof_size: serialized_proof.len(), + generation_time_ms: generation_time.as_millis() as u64, + security_level: 128, // Default security level + }, + }) + } + + /// Verify a proof + pub fn verify_proof(&self, proof_data: &ProofDataOutput) -> Result { + if cfg!(debug_assertions) { + tracing::warn!("GroveSTARK proof verification attempted in debug build; aborting"); + return Err(GroveSTARKError::UnsupportedBuild( + "GroveSTARK proof verification requires a release build (cargo run --release)" + .to_string(), + )); + } + + // Step 1: Deserialize the proof + let stark_proof: STARKProof = serde_json::from_slice(&proof_data.proof) + .map_err(|e| GroveSTARKError::DeserializationError(e.to_string()))?; + + // Step 2: Reconstruct public inputs + let public_inputs = PublicInputs { + state_root: proof_data.public_inputs.state_root, + contract_id: proof_data.public_inputs.contract_id, + message_hash: proof_data.public_inputs.message_hash, + timestamp: proof_data.public_inputs.timestamp, + }; + + // Step 3: Verify the proof using GroveSTARK's verify method + self.prover + .verify(&stark_proof, &public_inputs) + .map_err(|e| GroveSTARKError::VerificationFailed(e.to_string())) + } +} + +impl ProofDataOutput { + /// Serialize the proof to JSON string + pub fn to_json_string(&self) -> Result { + serde_json::to_string(self).map_err(|e| GroveSTARKError::SerializationError(e.to_string())) + } + + /// Serialize the proof to base64-encoded JSON + pub fn to_base64(&self) -> Result { + use base64::{Engine as _, engine::general_purpose}; + let json_bytes = serde_json::to_vec(self) + .map_err(|e| GroveSTARKError::SerializationError(e.to_string()))?; + Ok(general_purpose::STANDARD.encode(json_bytes)) + } + + /// Deserialize from base64-encoded JSON + pub fn from_base64(base64_str: &str) -> Result { + use base64::{Engine as _, engine::general_purpose}; + let bytes = general_purpose::STANDARD.decode(base64_str).map_err(|e| { + GroveSTARKError::DeserializationError(format!("Base64 decode error: {}", e)) + })?; + serde_json::from_slice(&bytes) + .map_err(|e| GroveSTARKError::DeserializationError(e.to_string())) + } + + /// Deserialize from JSON string + pub fn from_json_string(json_str: &str) -> Result { + serde_json::from_str(json_str) + .map_err(|e| GroveSTARKError::DeserializationError(e.to_string())) + } +} + +/// Create a challenge message for signing +fn create_challenge(state_root: &[u8; 32], contract_id: &str, document_id: &str) -> [u8; 32] { + use sha2::{Digest, Sha256}; + + let mut hasher = Sha256::new(); + hasher.update(state_root); + hasher.update(contract_id.as_bytes()); + hasher.update(document_id.as_bytes()); + + let result = hasher.finalize(); + let mut hash = [0u8; 32]; + hash.copy_from_slice(&result); + hash +} + +#[derive(Debug, thiserror::Error)] +pub enum GroveSTARKError { + #[error("Platform error: {0}")] + Platform(String), + + #[error("Invalid identity ID: {0}")] + InvalidIdentityId(String), + + #[error("Invalid contract ID: {0}")] + InvalidContractId(String), + + #[error("Identity not found")] + IdentityNotFound, + + #[error("Document not found")] + DocumentNotFound, + + #[error("Private key not available")] + PrivateKeyNotAvailable, + + #[error("Proof generation failed: {0}")] + ProofGenerationFailed(String), + + #[error("Proof verification failed: {0}")] + VerificationFailed(String), + + #[error("Serialization error: {0}")] + SerializationError(String), + + #[error("Deserialization error: {0}")] + DeserializationError(String), + + #[error("Signing failed: {0}")] + SigningFailed(String), + + #[error("Invalid proof: {0}")] + InvalidProof(String), + + #[error("Time error: {0}")] + TimeError(String), + + #[error("{0}")] + UnsupportedBuild(String), +} diff --git a/src/model/mod.rs b/src/model/mod.rs index b04309945..ef4ce0794 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,5 +1,6 @@ pub mod amount; pub mod contested_name; +pub mod grovestark_prover; pub mod password_info; pub mod proof_log_item; pub mod qualified_contract; diff --git a/src/model/wallet/encryption.rs b/src/model/wallet/encryption.rs index 0630945be..6a75a7ca0 100644 --- a/src/model/wallet/encryption.rs +++ b/src/model/wallet/encryption.rs @@ -1,5 +1,5 @@ use aes_gcm::aead::Aead; -use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use aes_gcm::{Aes256Gcm, KeyInit}; use argon2::{self, Argon2}; use bip39::rand::{RngCore, rngs::OsRng}; @@ -50,7 +50,7 @@ pub fn encrypt_message( // Encrypt the seed let encrypted_seed = cipher - .encrypt(Nonce::from_slice(&nonce), message) + .encrypt(nonce.as_slice().into(), message) .map_err(|e| e.to_string())?; Ok((encrypted_seed, salt, nonce)) @@ -85,10 +85,7 @@ impl ClosedKeyItem { // Decrypt the seed let seed = cipher - .decrypt( - Nonce::from_slice(&self.nonce), - self.encrypted_seed.as_slice(), - ) + .decrypt(self.nonce.as_slice().into(), self.encrypted_seed.as_slice()) .map_err(|e| e.to_string())?; let sized_seed = seed.try_into().map_err(|e: Vec| { diff --git a/src/ui/components/contracts_subscreen_chooser_panel.rs b/src/ui/components/contracts_subscreen_chooser_panel.rs index 9921bc191..8b6768932 100644 --- a/src/ui/components/contracts_subscreen_chooser_panel.rs +++ b/src/ui/components/contracts_subscreen_chooser_panel.rs @@ -8,7 +8,7 @@ use egui::{Context, Frame, Margin, RichText, SidePanel}; pub enum ContractsSubscreen { Contracts, DPNS, - DashPay, + Dashpay, } impl ContractsSubscreen { @@ -16,7 +16,7 @@ impl ContractsSubscreen { match self { ContractsSubscreen::Contracts => "All Contracts", ContractsSubscreen::DPNS => "DPNS", - ContractsSubscreen::DashPay => "DashPay", + ContractsSubscreen::Dashpay => "Dashpay", } } } @@ -27,7 +27,7 @@ pub fn add_contracts_subscreen_chooser_panel(ctx: &Context, app_context: &AppCon let subscreens = vec![ ContractsSubscreen::Contracts, ContractsSubscreen::DPNS, - ContractsSubscreen::DashPay, + ContractsSubscreen::Dashpay, ]; // Determine active selection from settings; default to Contracts @@ -38,7 +38,7 @@ pub fn add_contracts_subscreen_chooser_panel(ctx: &Context, app_context: &AppCon | ui::RootScreenType::RootScreenDPNSPastContests | ui::RootScreenType::RootScreenDPNSOwnedNames | ui::RootScreenType::RootScreenDPNSScheduledVotes => ContractsSubscreen::DPNS, - ui::RootScreenType::RootScreenContractsDashPay => ContractsSubscreen::DashPay, + ui::RootScreenType::RootScreenDashpay => ContractsSubscreen::Dashpay, _ => ContractsSubscreen::Contracts, }, _ => ContractsSubscreen::Contracts, @@ -110,9 +110,9 @@ pub fn add_contracts_subscreen_chooser_panel(ctx: &Context, app_context: &AppCon RootScreenType::RootScreenDPNSActiveContests, ) } - ContractsSubscreen::DashPay => { + ContractsSubscreen::Dashpay => { AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenContractsDashPay, + RootScreenType::RootScreenDashpay, ) } }; diff --git a/src/ui/components/tools_subscreen_chooser_panel.rs b/src/ui/components/tools_subscreen_chooser_panel.rs index 79aa2f9f5..05a9db9b6 100644 --- a/src/ui/components/tools_subscreen_chooser_panel.rs +++ b/src/ui/components/tools_subscreen_chooser_panel.rs @@ -12,6 +12,7 @@ pub enum ToolsSubscreen { DocumentViewer, ProofViewer, ContractViewer, + GroveSTARK, MasternodeListDiff, } @@ -24,6 +25,7 @@ impl ToolsSubscreen { Self::ProofViewer => "Proof deserializer", Self::DocumentViewer => "Document deserializer", Self::ContractViewer => "Contract deserializer", + Self::GroveSTARK => "ZK Proofs", Self::MasternodeListDiff => "Masternode list diff inspector", } } @@ -40,6 +42,7 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext ToolsSubscreen::TransactionViewer, ToolsSubscreen::DocumentViewer, ToolsSubscreen::ContractViewer, + ToolsSubscreen::GroveSTARK, ToolsSubscreen::MasternodeListDiff, ]; @@ -60,6 +63,7 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext ui::RootScreenType::RootScreenToolsMasternodeListDiffScreen => { ToolsSubscreen::MasternodeListDiff } + ui::RootScreenType::RootScreenToolsGroveSTARKScreen => ToolsSubscreen::GroveSTARK, _ => ToolsSubscreen::PlatformInfo, }, _ => ToolsSubscreen::PlatformInfo, // Fallback to Active screen if settings unavailable @@ -154,12 +158,16 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext action = AppAction::SetMainScreen( RootScreenType::RootScreenToolsMasternodeListDiffScreen) } + ToolsSubscreen::GroveSTARK => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenToolsGroveSTARKScreen) + } } } ui.add_space(Spacing::SM); } }); - }); // Close the island frame + }); }); action diff --git a/src/ui/contracts_documents/contracts_documents_screen.rs b/src/ui/contracts_documents/contracts_documents_screen.rs index 14cfcd51e..f293b8621 100644 --- a/src/ui/contracts_documents/contracts_documents_screen.rs +++ b/src/ui/contracts_documents/contracts_documents_screen.rs @@ -685,7 +685,7 @@ impl ScreenLike for DocumentQueryScreen { RootScreenType::RootScreenDocumentQuery, ); - // Contracts sub-left panel: DPNS / DashPay / Contracts (default) + // Contracts sub-left panel: DPNS / Dashpay / Contracts (default) action |= crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel( ctx, &self.app_context, diff --git a/src/ui/contracts_documents/dashpay_coming_soon_screen.rs b/src/ui/contracts_documents/dashpay_coming_soon_screen.rs index 80b786b82..d561ede58 100644 --- a/src/ui/contracts_documents/dashpay_coming_soon_screen.rs +++ b/src/ui/contracts_documents/dashpay_coming_soon_screen.rs @@ -9,11 +9,11 @@ use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::{RootScreenType, ScreenLike}; -pub struct DashPayComingSoonScreen { +pub struct DashpayScreen { pub app_context: Arc, } -impl DashPayComingSoonScreen { +impl DashpayScreen { pub fn new(app_context: &Arc) -> Self { Self { app_context: app_context.clone(), @@ -21,14 +21,14 @@ impl DashPayComingSoonScreen { } } -impl ScreenLike for DashPayComingSoonScreen { +impl ScreenLike for DashpayScreen { fn ui(&mut self, ctx: &Context) -> AppAction { let mut action = add_top_panel( ctx, &self.app_context, vec![ ("Contracts", AppAction::GoToMainScreen), - ("DashPay", AppAction::None), + ("Dashpay", AppAction::None), ], vec![], ); diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 8d57232c3..0a6d66804 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -2018,7 +2018,7 @@ impl ScreenLike for DPNSScreen { } } - // Contracts area chooser (DPNS / DashPay / Contracts) + // Contracts area chooser (DPNS / Dashpay / Contracts) action |= add_contracts_subscreen_chooser_panel(ctx, self.app_context.as_ref()); // DPNS subscreen chooser diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 3a5ee7903..abc3b0910 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -23,6 +23,7 @@ use crate::ui::tokens::transfer_tokens_screen::TransferTokensScreen; use crate::ui::tokens::view_token_claims_screen::ViewTokenClaimsScreen; use crate::ui::tools::contract_visualizer_screen::ContractVisualizerScreen; use crate::ui::tools::document_visualizer_screen::DocumentVisualizerScreen; +use crate::ui::tools::grovestark_screen::GroveSTARKScreen; use crate::ui::tools::masternode_list_diff_screen::MasternodeListDiffScreen; use crate::ui::tools::platform_info_screen::PlatformInfoScreen; use crate::ui::tools::proof_log_screen::ProofLogScreen; @@ -30,7 +31,7 @@ use crate::ui::tools::proof_visualizer_screen::ProofVisualizerScreen; use crate::ui::wallets::import_wallet_screen::ImportWalletScreen; use crate::ui::wallets::wallets_screen::WalletsBalancesScreen; use contracts_documents::add_contracts_screen::AddContractsScreen; -use contracts_documents::dashpay_coming_soon_screen::DashPayComingSoonScreen; +use contracts_documents::dashpay_coming_soon_screen::DashpayScreen; use contracts_documents::group_actions_screen::GroupActionsScreen; use contracts_documents::register_contract_screen::RegisterDataContractScreen; use contracts_documents::update_contract_screen::UpdateDataContractScreen; @@ -92,7 +93,8 @@ pub enum RootScreenType { RootScreenToolsMasternodeListDiffScreen, RootScreenToolsContractVisualizerScreen, RootScreenToolsPlatformInfoScreen, - RootScreenContractsDashPay, + RootScreenToolsGroveSTARKScreen, + RootScreenDashpay, } impl RootScreenType { @@ -118,7 +120,8 @@ impl RootScreenType { RootScreenType::RootScreenToolsContractVisualizerScreen => 16, RootScreenType::RootScreenToolsPlatformInfoScreen => 17, RootScreenType::RootScreenToolsMasternodeListDiffScreen => 18, - RootScreenType::RootScreenContractsDashPay => 19, + RootScreenType::RootScreenDashpay => 19, + RootScreenType::RootScreenToolsGroveSTARKScreen => 20, } } @@ -144,7 +147,8 @@ impl RootScreenType { 16 => Some(RootScreenType::RootScreenToolsContractVisualizerScreen), 17 => Some(RootScreenType::RootScreenToolsPlatformInfoScreen), 18 => Some(RootScreenType::RootScreenToolsMasternodeListDiffScreen), - 19 => Some(RootScreenType::RootScreenContractsDashPay), + 19 => Some(RootScreenType::RootScreenDashpay), + 20 => Some(RootScreenType::RootScreenToolsGroveSTARKScreen), _ => None, } } @@ -179,7 +183,8 @@ impl From for ScreenType { ScreenType::ContractsVisualizer } RootScreenType::RootScreenToolsPlatformInfoScreen => ScreenType::PlatformInfo, - RootScreenType::RootScreenContractsDashPay => ScreenType::ContractsDashPayComingSoon, + RootScreenType::RootScreenToolsGroveSTARKScreen => ScreenType::GroveSTARK, + RootScreenType::RootScreenDashpay => ScreenType::Dashpay, } } } @@ -220,7 +225,8 @@ pub enum ScreenType { DocumentsVisualizer, ContractsVisualizer, PlatformInfo, - ContractsDashPayComingSoon, + GroveSTARK, + Dashpay, CreateDocument, DeleteDocument, ReplaceDocument, @@ -337,9 +343,8 @@ impl ScreenType { ScreenType::PlatformInfo => { Screen::PlatformInfoScreen(PlatformInfoScreen::new(app_context)) } - ScreenType::ContractsDashPayComingSoon => { - Screen::DashPayComingSoonScreen(DashPayComingSoonScreen::new(app_context)) - } + ScreenType::GroveSTARK => Screen::GroveSTARKScreen(GroveSTARKScreen::new(app_context)), + ScreenType::Dashpay => Screen::DashpayScreen(DashpayScreen::new(app_context)), ScreenType::CreateDocument => Screen::DocumentActionScreen(DocumentActionScreen::new( app_context.clone(), None, @@ -441,7 +446,7 @@ pub enum Screen { IdentitiesScreen(IdentitiesScreen), DPNSScreen(DPNSScreen), DocumentQueryScreen(DocumentQueryScreen), - DashPayComingSoonScreen(DashPayComingSoonScreen), + DashpayScreen(DashpayScreen), AddNewWalletScreen(AddNewWalletScreen), ImportWalletScreen(ImportWalletScreen), AddNewIdentityScreen(AddNewIdentityScreen), @@ -467,6 +472,7 @@ pub enum Screen { ProofVisualizerScreen(ProofVisualizerScreen), MasternodeListDiffScreen(MasternodeListDiffScreen), PlatformInfoScreen(PlatformInfoScreen), + GroveSTARKScreen(GroveSTARKScreen), // Token Screens TokensScreen(Box), @@ -491,7 +497,7 @@ impl Screen { match self { Screen::IdentitiesScreen(screen) => screen.app_context = app_context, Screen::DPNSScreen(screen) => screen.app_context = app_context, - Screen::DashPayComingSoonScreen(screen) => screen.app_context = app_context, + Screen::DashpayScreen(screen) => screen.app_context = app_context, Screen::AddExistingIdentityScreen(screen) => screen.app_context = app_context, Screen::KeyInfoScreen(screen) => screen.app_context = app_context, Screen::KeysScreen(screen) => screen.app_context = app_context, @@ -527,6 +533,7 @@ impl Screen { } Screen::DocumentVisualizerScreen(screen) => screen.app_context = app_context, Screen::PlatformInfoScreen(screen) => screen.app_context = app_context, + Screen::GroveSTARKScreen(screen) => screen.app_context = app_context, // Token Screens Screen::TokensScreen(screen) => screen.app_context = app_context, @@ -610,7 +617,7 @@ impl Screen { dpns_subscreen: DPNSSubscreen::ScheduledVotes, .. }) => ScreenType::ScheduledVotes, - Screen::DashPayComingSoonScreen(_) => ScreenType::ContractsDashPayComingSoon, + Screen::DashpayScreen(_) => ScreenType::Dashpay, Screen::TransitionVisualizerScreen(_) => ScreenType::TransitionVisualizer, Screen::ContractVisualizerScreen(_) => ScreenType::ContractsVisualizer, Screen::WithdrawalScreen(screen) => { @@ -644,6 +651,7 @@ impl Screen { Screen::MasternodeListDiffScreen(_) => ScreenType::MasternodeListDiff, Screen::DocumentVisualizerScreen(_) => ScreenType::DocumentsVisualizer, Screen::PlatformInfoScreen(_) => ScreenType::PlatformInfo, + Screen::GroveSTARKScreen(_) => ScreenType::GroveSTARK, // Token Screens Screen::TokensScreen(screen) @@ -716,7 +724,7 @@ impl ScreenLike for Screen { Screen::IdentitiesScreen(screen) => screen.refresh(), Screen::DPNSScreen(screen) => screen.refresh(), Screen::DocumentQueryScreen(screen) => screen.refresh(), - Screen::DashPayComingSoonScreen(screen) => screen.refresh(), + Screen::DashpayScreen(screen) => screen.refresh(), Screen::AddNewWalletScreen(screen) => screen.refresh(), Screen::ImportWalletScreen(screen) => screen.refresh(), Screen::AddNewIdentityScreen(screen) => screen.refresh(), @@ -742,6 +750,7 @@ impl ScreenLike for Screen { Screen::DocumentVisualizerScreen(screen) => screen.refresh(), Screen::ContractVisualizerScreen(screen) => screen.refresh(), Screen::PlatformInfoScreen(screen) => screen.refresh(), + Screen::GroveSTARKScreen(screen) => screen.refresh(), // Token Screens Screen::TokensScreen(screen) => screen.refresh(), @@ -767,7 +776,7 @@ impl ScreenLike for Screen { Screen::IdentitiesScreen(screen) => screen.refresh_on_arrival(), Screen::DPNSScreen(screen) => screen.refresh_on_arrival(), Screen::DocumentQueryScreen(screen) => screen.refresh_on_arrival(), - Screen::DashPayComingSoonScreen(screen) => screen.refresh_on_arrival(), + Screen::DashpayScreen(screen) => screen.refresh_on_arrival(), Screen::AddNewWalletScreen(screen) => screen.refresh_on_arrival(), Screen::ImportWalletScreen(screen) => screen.refresh_on_arrival(), Screen::AddNewIdentityScreen(screen) => screen.refresh_on_arrival(), @@ -793,6 +802,7 @@ impl ScreenLike for Screen { Screen::DocumentVisualizerScreen(screen) => screen.refresh_on_arrival(), Screen::ContractVisualizerScreen(screen) => screen.refresh_on_arrival(), Screen::PlatformInfoScreen(screen) => screen.refresh_on_arrival(), + Screen::GroveSTARKScreen(screen) => screen.refresh_on_arrival(), // Token Screens Screen::TokensScreen(screen) => screen.refresh_on_arrival(), @@ -818,7 +828,7 @@ impl ScreenLike for Screen { Screen::IdentitiesScreen(screen) => screen.ui(ctx), Screen::DPNSScreen(screen) => screen.ui(ctx), Screen::DocumentQueryScreen(screen) => screen.ui(ctx), - Screen::DashPayComingSoonScreen(screen) => screen.ui(ctx), + Screen::DashpayScreen(screen) => screen.ui(ctx), Screen::AddNewWalletScreen(screen) => screen.ui(ctx), Screen::ImportWalletScreen(screen) => screen.ui(ctx), Screen::AddNewIdentityScreen(screen) => screen.ui(ctx), @@ -844,6 +854,7 @@ impl ScreenLike for Screen { Screen::DocumentVisualizerScreen(screen) => screen.ui(ctx), Screen::ContractVisualizerScreen(screen) => screen.ui(ctx), Screen::PlatformInfoScreen(screen) => screen.ui(ctx), + Screen::GroveSTARKScreen(screen) => screen.ui(ctx), // Token Screens Screen::TokensScreen(screen) => screen.ui(ctx), @@ -869,9 +880,7 @@ impl ScreenLike for Screen { Screen::IdentitiesScreen(screen) => screen.display_message(message, message_type), Screen::DPNSScreen(screen) => screen.display_message(message, message_type), Screen::DocumentQueryScreen(screen) => screen.display_message(message, message_type), - Screen::DashPayComingSoonScreen(screen) => { - screen.display_message(message, message_type) - } + Screen::DashpayScreen(screen) => screen.display_message(message, message_type), Screen::AddNewWalletScreen(screen) => screen.display_message(message, message_type), Screen::ImportWalletScreen(screen) => screen.display_message(message, message_type), Screen::AddNewIdentityScreen(screen) => screen.display_message(message, message_type), @@ -911,6 +920,7 @@ impl ScreenLike for Screen { screen.display_message(message, message_type) } Screen::PlatformInfoScreen(screen) => screen.display_message(message, message_type), + Screen::GroveSTARKScreen(screen) => screen.display_message(message, message_type), // Token Screens Screen::TokensScreen(screen) => screen.display_message(message, message_type), @@ -944,7 +954,7 @@ impl ScreenLike for Screen { Screen::DocumentQueryScreen(screen) => { screen.display_task_result(backend_task_success_result) } - Screen::DashPayComingSoonScreen(screen) => { + Screen::DashpayScreen(screen) => { screen.display_task_result(backend_task_success_result) } Screen::AddNewWalletScreen(screen) => { @@ -1018,6 +1028,9 @@ impl ScreenLike for Screen { Screen::PlatformInfoScreen(screen) => { screen.display_task_result(backend_task_success_result) } + Screen::GroveSTARKScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } // Token Screens Screen::TokensScreen(screen) => screen.display_task_result(backend_task_success_result), @@ -1069,7 +1082,7 @@ impl ScreenLike for Screen { Screen::IdentitiesScreen(screen) => screen.pop_on_success(), Screen::DPNSScreen(screen) => screen.pop_on_success(), Screen::DocumentQueryScreen(screen) => screen.pop_on_success(), - Screen::DashPayComingSoonScreen(screen) => screen.pop_on_success(), + Screen::DashpayScreen(screen) => screen.pop_on_success(), Screen::AddNewWalletScreen(screen) => screen.pop_on_success(), Screen::ImportWalletScreen(screen) => screen.pop_on_success(), Screen::AddNewIdentityScreen(screen) => screen.pop_on_success(), @@ -1095,6 +1108,7 @@ impl ScreenLike for Screen { Screen::DocumentVisualizerScreen(screen) => screen.pop_on_success(), Screen::ContractVisualizerScreen(screen) => screen.pop_on_success(), Screen::PlatformInfoScreen(screen) => screen.pop_on_success(), + Screen::GroveSTARKScreen(screen) => screen.pop_on_success(), // Token Screens Screen::TokensScreen(screen) => screen.pop_on_success(), diff --git a/src/ui/tokens/tokens_screen/distributions.rs b/src/ui/tokens/tokens_screen/distributions.rs index 5cc810ce2..907517af8 100644 --- a/src/ui/tokens/tokens_screen/distributions.rs +++ b/src/ui/tokens/tokens_screen/distributions.rs @@ -379,7 +379,10 @@ Emits tokens in fixed amounts for specific intervals. sanitize_u64(&mut self.step_count_input); } if !self.step_count_input.is_empty() - && let Ok((perpetual_dist_interval_input, step_count_input)) = self.perpetual_dist_interval_input.parse::().and_then(|perpetual_dist_interval_input| self.step_count_input.parse::().map(|step_count_input| (perpetual_dist_interval_input, step_count_input))) { + && let Ok((perpetual_dist_interval_input, step_count_input)) = self + .perpetual_dist_interval_input + .parse::() + .and_then(|perpetual_dist_interval_input| self.step_count_input.parse::().map(|step_count_input| (perpetual_dist_interval_input, step_count_input))) { let text = match self.perpetual_dist_type { PerpetualDistributionIntervalTypeUI::None => "".to_string(), PerpetualDistributionIntervalTypeUI::BlockBased => { diff --git a/src/ui/tools/grovestark_screen.rs b/src/ui/tools/grovestark_screen.rs new file mode 100644 index 000000000..e6f95532d --- /dev/null +++ b/src/ui/tools/grovestark_screen.rs @@ -0,0 +1,1183 @@ +use crate::app::AppAction; +use crate::backend_task::BackendTask; +use crate::backend_task::grovestark::GroveSTARKTask; +use crate::context::AppContext; +use crate::model::qualified_identity::{PrivateKeyTarget, QualifiedIdentity}; +use crate::ui::RootScreenType; +use crate::ui::ScreenLike; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::theme::{DashColors, Shape, Spacing, Typography}; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{ + Identity, IdentityPublicKey, KeyType, Purpose, accessors::IdentityGettersV0, +}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use egui::{Button, ComboBox, Context, Frame, Grid, Margin, RichText, ScrollArea, TextEdit, Ui}; +use std::sync::Arc; +use std::time::Duration; + +#[derive(Clone, PartialEq)] +pub enum ProofMode { + Generate, + Verify, +} + +#[derive(Clone)] +pub struct VerificationResult { + pub is_valid: bool, + pub verified_at: u64, + pub contract_id: String, + pub security_level: u32, + pub error_message: Option, + pub technical_details: String, +} + +#[derive(Clone)] +pub struct ProofData { + pub full_proof: crate::model::grovestark_prover::ProofDataOutput, + pub hash: String, + pub size: usize, + pub generation_time: Duration, +} + +pub struct GroveSTARKScreen { + pub(crate) app_context: Arc, + mode: ProofMode, + + // Generation fields + selected_identity: Option, + selected_key: Option, + selected_contract: Option, + selected_document_type: Option, + available_document_types: Vec, // Document types for selected contract + selected_document: Option, + available_identities: Vec, + qualified_identities: Vec, // Store full qualified identities for key access + available_contracts: Vec<(String, String)>, // (id, name) + // Documents will be entered directly via text input + is_generating: bool, + generated_proof: Option, + proof_size: Option, + generation_time: Option, + security_level: u32, + + // Verification fields + proof_text: String, + is_verifying: bool, + verification_result: Option, + + // Error handling + gen_error_message: Option, + verify_error_message: Option, +} + +impl GroveSTARKScreen { + pub fn new(app_context: &Arc) -> Self { + // Load initial qualified identities + let qualified_identities = app_context + .load_local_qualified_identities() + .unwrap_or_default(); + + let available_identities = qualified_identities + .iter() + .map(|qualified_identity| qualified_identity.identity.clone()) + .collect(); + + tracing::info!( + "ZK Proofs screen loaded {} identities", + qualified_identities.len() + ); + + // Load initial contracts (exclude system contracts) + let excluded_aliases = ["dpns", "keyword_search", "token_history", "withdrawals"]; + let all_contracts = app_context.get_contracts(None, None).unwrap_or_default(); + + tracing::info!( + "ZK Proofs screen found {} total contracts", + all_contracts.len() + ); + + let available_contracts: Vec<(String, String)> = all_contracts + .into_iter() + .filter(|c| match &c.alias { + Some(alias) => { + let is_system = excluded_aliases.contains(&alias.as_str()); + if is_system { + tracing::debug!("Excluding system contract: {}", alias); + } + !is_system + } + None => true, + }) + .map(|qualified_contract| { + let id = qualified_contract + .contract + .id() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); + let name = qualified_contract + .alias + .unwrap_or_else(|| format!("Contract {}", &id[..8])); + tracing::debug!("Including contract: {} ({})", name, id); + (id, name) + }) + .collect(); + + tracing::info!( + "ZK Proofs screen loaded {} user contracts after filtering", + available_contracts.len() + ); + + Self { + app_context: app_context.clone(), + mode: ProofMode::Generate, + selected_identity: None, + selected_key: None, + selected_contract: None, + selected_document_type: None, + available_document_types: Vec::new(), + selected_document: None, + available_identities, + qualified_identities, + available_contracts, + is_generating: false, + generated_proof: None, + proof_size: None, + generation_time: None, + security_level: 128, + proof_text: String::new(), + is_verifying: false, + verification_result: None, + gen_error_message: None, + verify_error_message: None, + } + } + + fn refresh_identities(&mut self, app_context: &AppContext) { + let all_qualified_identities = app_context + .load_local_qualified_identities() + .unwrap_or_default(); + + // Filter identities to only show those with EdDSA keys + self.qualified_identities = all_qualified_identities + .into_iter() + .filter(|qi| self.has_eddsa_keys(&qi.identity)) + .collect(); + + self.available_identities = self + .qualified_identities + .iter() + .map(|qualified_identity| qualified_identity.identity.clone()) + .collect(); + } + + fn get_qualified_identity(&self, identity_id_str: &str) -> Option<&QualifiedIdentity> { + self.qualified_identities + .iter() + .find(|qi| qi.identity.id().to_string(Encoding::Base58) == identity_id_str) + } + + /// Check if an identity has any EdDSA keys suitable for ZK proofs + fn has_eddsa_keys(&self, identity: &Identity) -> bool { + identity.public_keys().iter().any(|(_, key)| { + matches!(key.key_type(), KeyType::EDDSA_25519_HASH160) + && (key.purpose() == Purpose::AUTHENTICATION || key.purpose() == Purpose::TRANSFER) + }) + } + + fn get_available_keys(&self, identity_id_str: &str) -> Vec<&IdentityPublicKey> { + if let Some(qualified_identity) = self.get_qualified_identity(identity_id_str) { + qualified_identity + .private_keys + .identity_public_keys() + .into_iter() + .filter(|(target, _)| **target == PrivateKeyTarget::PrivateKeyOnMainIdentity) + .map(|(_, key_ref)| &key_ref.identity_public_key) + .filter(|key| { + // Only show EdDSA keys suitable for signing + matches!(key.key_type(), KeyType::EDDSA_25519_HASH160) + && (key.purpose() == Purpose::AUTHENTICATION + || key.purpose() == Purpose::TRANSFER) + }) + .collect() + } else { + Vec::new() + } + } + + fn refresh_contracts(&mut self, app_context: &AppContext) { + let excluded_aliases = ["dpns", "keyword_search", "token_history", "withdrawals"]; + let all_contracts = app_context.get_contracts(None, None).unwrap_or_default(); + + self.available_contracts = all_contracts + .into_iter() + .filter(|c| match &c.alias { + Some(alias) => !excluded_aliases.contains(&alias.as_str()), + None => true, + }) + .map(|qualified_contract| { + let id = qualified_contract + .contract + .id() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); + let name = qualified_contract + .alias + .unwrap_or_else(|| format!("Contract {}", &id[..8])); + (id, name) + }) + .collect(); + + tracing::info!( + "Refreshed contracts: found {} user contracts", + self.available_contracts.len() + ); + } + + fn refresh_document_types(&mut self, app_context: &AppContext, contract_id: &str) { + self.available_document_types.clear(); + self.selected_document_type = None; + + if let Ok(contracts) = app_context.get_contracts(None, None) { + for contract in contracts { + let id = contract + .contract + .id() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); + + if id == contract_id { + self.available_document_types = contract + .contract + .document_types() + .keys() + .map(|s| s.to_string()) + .collect(); + + tracing::info!( + "Found {} document types for contract {}: {:?}", + self.available_document_types.len(), + &contract_id[..8], + self.available_document_types + ); + + break; + } + } + } + } + + fn generate_proof(&mut self, app_context: &AppContext) -> AppAction { + if cfg!(debug_assertions) { + self.gen_error_message = Some( + "GroveSTARK proof generation requires a release build (cargo run --release)." + .to_string(), + ); + self.is_generating = false; + return AppAction::None; + } + + // Reset any prior messages/results before starting a new generation + self.is_generating = true; + self.gen_error_message = None; + self.generated_proof = None; + self.proof_size = None; + self.generation_time = None; + + // Get the required IDs + let identity_id = match &self.selected_identity { + Some(id) => { + // Debug: Log the identity ID being used + tracing::info!( + "ZK Proof generation: Using identity ID: '{}' (length: {})", + id, + id.len() + ); + id.clone() + } + None => { + self.gen_error_message = Some("No identity selected".to_string()); + self.is_generating = false; + return AppAction::None; + } + }; + + let selected_key = match &self.selected_key { + Some(key) => key, + None => { + self.gen_error_message = Some("No key selected".to_string()); + self.is_generating = false; + return AppAction::None; + } + }; + + let contract_id = match &self.selected_contract { + Some(id) => { + tracing::info!( + "ZK Proof generation: Using contract ID: '{}' (length: {})", + id, + id.len() + ); + id.clone() + } + None => { + self.gen_error_message = Some("No contract selected".to_string()); + self.is_generating = false; + return AppAction::None; + } + }; + + let document_type = match &self.selected_document_type { + Some(doc_type) => { + tracing::info!("ZK Proof generation: Using document type: '{}'", doc_type); + doc_type.clone() + } + None => { + self.gen_error_message = Some("No document type selected".to_string()); + self.is_generating = false; + return AppAction::None; + } + }; + + let document_id = match &self.selected_document { + Some(id) => { + tracing::info!( + "ZK Proof generation: Using document ID: '{}' (length: {})", + id, + id.len() + ); + id.clone() + } + None => { + self.gen_error_message = Some("No document selected".to_string()); + self.is_generating = false; + return AppAction::None; + } + }; + + // Get the private key from the qualified identity + let private_key = match self.get_qualified_identity(&identity_id) { + Some(qualified_identity) => { + // Get the wallets for resolving encrypted keys + let wallets = app_context.wallets.read().unwrap(); + let wallet_vec: Vec<_> = wallets.values().cloned().collect(); + + // Try to get the private key + match qualified_identity.private_keys.get_resolve( + &( + PrivateKeyTarget::PrivateKeyOnMainIdentity, + selected_key.id(), + ), + &wallet_vec, + app_context.network, + ) { + Ok(Some((_, private_key_bytes))) => private_key_bytes, + Ok(None) => { + self.gen_error_message = + Some("Private key not found in storage".to_string()); + self.is_generating = false; + return AppAction::None; + } + Err(e) => { + self.gen_error_message = Some(format!("Failed to get private key: {}", e)); + self.is_generating = false; + return AppAction::None; + } + } + } + None => { + self.gen_error_message = Some("Qualified identity not found".to_string()); + self.is_generating = false; + return AppAction::None; + } + }; + + // For EDDSA_25519_HASH160, the key data is only 20 bytes (the hash) + // We need to derive the public key from the private key + let public_key = { + use ed25519_dalek::SigningKey; + let signing_key = SigningKey::from_bytes(&private_key); + let verifying_key = signing_key.verifying_key(); + *verifying_key.as_bytes() + }; + + // Use fixed parameters for simplicity and consistency + let task = BackendTask::GroveSTARKTask(GroveSTARKTask::GenerateProof { + identity_id, + contract_id, + document_type, + document_id, + key_id: selected_key.id(), + private_key, + public_key, + }); + + AppAction::BackendTask(task) + } + + fn verify_proof(&mut self, _app_context: &AppContext) -> AppAction { + if cfg!(debug_assertions) { + self.verify_error_message = Some( + "GroveSTARK proof verification requires a release build (cargo run --release)." + .to_string(), + ); + self.is_verifying = false; + return AppAction::None; + } + + self.is_verifying = true; + self.verify_error_message = None; + self.verification_result = None; // Clear any previous results + + // Parse the proof from pasted text + let proof_result = + // Try to parse from base64-encoded JSON first, then raw JSON + crate::model::grovestark_prover::ProofDataOutput::from_base64( + &self.proof_text, + ) + .or_else(|_| { + crate::model::grovestark_prover::ProofDataOutput::from_json_string( + &self.proof_text, + ) + }); + + match proof_result { + Ok(proof_data) => { + let task = BackendTask::GroveSTARKTask(GroveSTARKTask::VerifyProof { proof_data }); + AppAction::BackendTask(task) + } + Err(e) => { + self.verify_error_message = Some(format!("Failed to parse proof: {}", e)); + self.is_verifying = false; + AppAction::None + } + } + } + + fn copy_proof_to_clipboard(&self) { + if let Some(proof) = &self.generated_proof { + // Use the helper method to serialize to base64 + if let Ok(proof_base64) = proof.full_proof.to_base64() { + let _ = arboard::Clipboard::new() + .and_then(|mut clipboard| clipboard.set_text(proof_base64)); + } + } + } + + fn copy_verification_result(&self) { + if let Some(result) = &self.verification_result { + let text = format!( + "Verification Result: {}\nContract: {}\nSecurity Level: {}-bit", + if result.is_valid { "VALID" } else { "INVALID" }, + result.contract_id, + result.security_level + ); + let _ = arboard::Clipboard::new().and_then(|mut clipboard| clipboard.set_text(text)); + } + } + + fn truncate_id(id: &str) -> String { + if id.len() > 16 { + format!("{}...{}", &id[..6], &id[id.len() - 6..]) + } else { + id.to_string() + } + } + + fn format_timestamp(timestamp: u64) -> String { + chrono::DateTime::from_timestamp(timestamp as i64, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| "Unknown".to_string()) + } + + fn render_generation_ui(&mut self, ui: &mut Ui, app_context: &AppContext) -> Option { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let debug_build = cfg!(debug_assertions); + + ui.label( + RichText::new("Contract Membership Circuit") + .size(Typography::SCALE_XL) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.label( + RichText::new("Prove you own a document in a specific contract without revealing anything about your identity or the document.") + .size(Typography::SCALE_SM) + .color(DashColors::text_primary(dark_mode)) + ); + ui.add_space(Spacing::SM); + ui.separator(); + + if debug_build { + ui.colored_label( + egui::Color32::DARK_RED, + "GroveSTARK proofs require a release build (cargo run --release).", + ); + ui.add_space(Spacing::SM); + } + + // Step 1: Select Identity + Frame::new() + .inner_margin(Margin::same(Spacing::MD_I8)) + .fill(DashColors::surface(dark_mode)) + .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) + .show(ui, |ui| { + ui.label( + RichText::new("Step 1: Select Identity") + .size(Typography::SCALE_LG) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.horizontal(|ui| { + ui.label("Identity:"); + let mut identity_changed = false; + ComboBox::from_id_salt("identity_selector") + .selected_text(self.selected_identity.as_deref().unwrap_or( + if self.available_identities.is_empty() { + "No identities available" + } else { + "Select..." + }, + )) + .show_ui(ui, |ui| { + if self.available_identities.is_empty() { + ui.label("No identities with EdDSA keys found."); + ui.label( + RichText::new("ZK proofs require identities with EdDSA (Ed25519) keys. Please add an EdDSA key to an identity.") + .size(Typography::SCALE_XS) + .color(DashColors::text_secondary(dark_mode)) + ); + } else { + for identity in &self.available_identities { + let id_str = identity.id().to_string(Encoding::Base58); + if ui + .selectable_value( + &mut self.selected_identity, + Some(id_str.clone()), + Self::truncate_id(&id_str), + ) + .changed() + { + identity_changed = true; + } + } + } + }); + + // Reset key selection if identity changed + if identity_changed { + self.selected_key = None; + } + }); + + if let Some(id) = &self.selected_identity { + ui.label( + RichText::new("✅ Identity selected").color(egui::Color32::DARK_GREEN), + ); + + // Key selection + ui.separator(); + ui.label( + RichText::new("Select Key for Signing:") + .color(DashColors::text_primary(dark_mode)), + ); + + let available_keys: Vec = + self.get_available_keys(id).into_iter().cloned().collect(); + + if available_keys.is_empty() { + ui.label( + RichText::new("⚠️ No EdDSA keys available for ZK proof generation") + .color(egui::Color32::DARK_RED), + ); + ui.label( + RichText::new("ZK proofs require EdDSA (Ed25519) keys. Please add an EdDSA key to this identity.") + .size(Typography::SCALE_XS) + .color(DashColors::text_secondary(dark_mode)), + ); + } else { + ComboBox::from_id_salt("key_selector") + .selected_text( + self.selected_key + .as_ref() + .map(|k| { + format!( + "EdDSA Key {} ({} - {})", + k.id(), + k.purpose(), + k.security_level() + ) + }) + .unwrap_or_else(|| "Select key...".to_string()), + ) + .show_ui(ui, |ui| { + for key in &available_keys { + let key_label = format!( + "EdDSA Key {} ({} - {})", + key.id(), + key.purpose(), + key.security_level() + ); + ui.selectable_value( + &mut self.selected_key, + Some(key.clone()), + key_label, + ); + } + }); + + if self.selected_key.is_some() { + ui.label( + RichText::new("✅ EdDSA key selected").color(egui::Color32::DARK_GREEN), + ); + } + } + } + }); + + ui.add_space(Spacing::MD); + + // Step 2: Select Contract + Frame::new() + .inner_margin(Margin::same(Spacing::MD_I8)) + .fill(DashColors::surface(dark_mode)) + .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) + .show(ui, |ui| { + ui.label( + RichText::new("Step 2: Select Contract") + .size(Typography::SCALE_LG) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.horizontal(|ui| { + ui.label("Contract:"); + let mut contract_changed = false; + ComboBox::from_id_salt("contract_selector") + .selected_text(self.selected_contract.as_deref().unwrap_or( + if self.available_contracts.is_empty() { + "No contracts available" + } else { + "Select..." + }, + )) + .show_ui(ui, |ui| { + if self.available_contracts.is_empty() { + ui.label( + "No user contracts found. Please create a contract first.", + ); + } else { + for (id, name) in &self.available_contracts { + if ui + .selectable_value( + &mut self.selected_contract, + Some(id.clone()), + name, + ) + .changed() + { + contract_changed = true; + } + } + } + }); + + // If contract changed, refresh document types + if contract_changed && let Some(contract_id) = self.selected_contract.clone() { + self.refresh_document_types(app_context, &contract_id); + } + }); + + if let Some(_contract_id) = &self.selected_contract { + ui.label( + RichText::new("✅ Contract selected").color(egui::Color32::DARK_GREEN), + ); + + // Document Type selection + ui.separator(); + ui.label( + RichText::new("Select Document Type:") + .color(DashColors::text_primary(dark_mode)), + ); + + ui.horizontal(|ui| { + ui.label("Document Type:"); + ComboBox::from_id_salt("document_type_selector") + .selected_text(self.selected_document_type.as_deref().unwrap_or( + if self.available_document_types.is_empty() { + "No document types available" + } else { + "Select..." + }, + )) + .show_ui(ui, |ui| { + if self.available_document_types.is_empty() { + ui.label("No document types found for this contract."); + } else { + for doc_type in &self.available_document_types { + ui.selectable_value( + &mut self.selected_document_type, + Some(doc_type.clone()), + doc_type, + ); + } + } + }); + }); + + if self.selected_document_type.is_some() { + ui.label( + RichText::new("✅ Document type selected") + .color(egui::Color32::DARK_GREEN), + ); + } + } + }); + + ui.add_space(Spacing::MD); + + // Step 3: Select Document + Frame::new() + .inner_margin(Margin::same(Spacing::MD_I8)) + .fill(DashColors::surface(dark_mode)) + .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) + .show(ui, |ui| { + ui.label( + RichText::new("Step 3: Select Document") + .size(Typography::SCALE_LG) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.horizontal(|ui| { + ui.label("Document ID:"); + let mut document_id = + self.selected_document.as_deref().unwrap_or("").to_string(); + if ui.text_edit_singleline(&mut document_id).changed() { + self.selected_document = if document_id.is_empty() { + None + } else { + Some(document_id) + }; + } + }); + + if let Some(_doc_id) = &self.selected_document { + ui.label( + RichText::new("✅ Document selected").color(egui::Color32::DARK_GREEN), + ); + } + }); + + // Advanced Options removed to reduce confusion; defaults are used. + + ui.separator(); + + // Generate Button + let can_generate = self.selected_identity.is_some() + && self.selected_key.is_some() + && self.selected_contract.is_some() + && self.selected_document_type.is_some() + && self.selected_document.is_some(); + + let mut action = None; + ui.horizontal(|ui| { + if self.is_generating { + // Use Dash blue spinner instead of default + ui.add(egui::widgets::Spinner::new().color(DashColors::DASH_BLUE)); + ui.vertical(|ui| { + ui.label("Generating ZK proof..."); + }); + } else if ui + .add_enabled( + !debug_build && can_generate, + Button::new("🔐 Generate Proof"), + ) + .clicked() + { + action = Some(self.generate_proof(app_context)); + } + }); + if action.is_some() { + return action; + } + + // Error Display + if let Some(error) = &self.gen_error_message { + ui.colored_label(egui::Color32::RED, format!("Error: {}", error)); + } + + // Success Display + if let Some(_proof) = &self.generated_proof { + ui.separator(); + Frame::new() + .inner_margin(Margin::same(Spacing::MD_I8)) + .fill(DashColors::surface(dark_mode)) + .stroke(egui::Stroke::new(1.0, egui::Color32::DARK_GREEN)) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) + .show(ui, |ui| { + ui.label( + RichText::new("✅ Proof Generated Successfully!") + .color(egui::Color32::DARK_GREEN) + .strong(), + ); + + if ui.button("📋 Copy Proof").clicked() { + self.copy_proof_to_clipboard(); + } + }); + } + None + } + + fn render_verification_ui( + &mut self, + ui: &mut Ui, + app_context: &AppContext, + ) -> Option { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let debug_build = cfg!(debug_assertions); + + ui.label( + RichText::new("Verify Zero-Knowledge Proof") + .size(Typography::SCALE_XL) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(Spacing::SM); + ui.separator(); + + // Proof Input + Frame::new() + .inner_margin(Margin::same(Spacing::MD_I8)) + .fill(DashColors::surface(dark_mode)) + .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) + .show(ui, |ui| { + ui.label( + RichText::new("Paste Proof (Base64 or JSON):") + .color(DashColors::text_primary(dark_mode)), + ); + ui.add( + TextEdit::multiline(&mut self.proof_text) + .desired_width(f32::INFINITY) + .desired_rows(6), + ); + }); + + ui.separator(); + + // Error Display (above the button) + if let Some(error) = &self.verify_error_message { + ui.colored_label(egui::Color32::RED, format!("Error: {}", error)); + } + + // Verify Button + let can_verify = !self.proof_text.is_empty(); + + let mut action = None; + ui.horizontal(|ui| { + if self.is_verifying { + // Use Dash blue spinner instead of default + ui.add(egui::widgets::Spinner::new().color(DashColors::DASH_BLUE)); + ui.label("Verifying ZK proof..."); + } else if ui + .add_enabled(!debug_build && can_verify, Button::new("✅ Verify Proof")) + .clicked() + { + action = Some(self.verify_proof(app_context)); + } + }); + if action.is_some() { + return action; + } + + // Verification Result + if let Some(result) = &self.verification_result { + ui.separator(); + + if result.is_valid { + Frame::new() + .inner_margin(Margin::same(Spacing::MD_I8)) + .fill(DashColors::surface(dark_mode)) + .stroke(egui::Stroke::new(1.0, egui::Color32::DARK_GREEN)) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) + .show(ui, |ui| { + ui.colored_label(egui::Color32::DARK_GREEN, "✅ PROOF IS VALID"); + + Grid::new("verification_details") + .num_columns(2) + .show(ui, |ui| { + ui.label("Verified At:"); + ui.label(Self::format_timestamp(result.verified_at)); + ui.end_row(); + + ui.label("Document Exists:"); + ui.label("Yes"); + ui.end_row(); + + ui.label("Key Control:"); + ui.label("Verified"); + ui.end_row(); + + ui.label("Contract:"); + ui.label(&result.contract_id); + ui.end_row(); + + ui.label("Security Level:"); + ui.label(format!("{}-bit", result.security_level)); + ui.end_row(); + }); + + if ui.button("📋 Copy Result").clicked() { + self.copy_verification_result(); + } + }); + } else { + Frame::new() + .inner_margin(Margin::same(Spacing::MD_I8)) + .fill(DashColors::surface(dark_mode)) + .stroke(egui::Stroke::new(1.0, egui::Color32::RED)) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) + .show(ui, |ui| { + ui.colored_label(egui::Color32::RED, "❌ PROOF IS INVALID"); + if let Some(reason) = &result.error_message { + ui.label(format!("Reason: {}", reason)); + } + + ui.collapsing("Technical Details", |ui| { + ui.monospace(&result.technical_details); + }); + }); + } + } + None + } +} + +impl ScreenLike for GroveSTARKScreen { + fn refresh(&mut self) { + // Refresh implementation if needed + } + + fn refresh_on_arrival(&mut self) { + self.refresh(); + // Reload data in case it changed + let app_context = self.app_context.clone(); + self.refresh_identities(&app_context); + self.refresh_contracts(&app_context); + } + + fn display_message(&mut self, message: &str, message_type: crate::ui::MessageType) { + // Only record errors and scope them to the active mode + if message_type == crate::ui::MessageType::Error { + match self.mode { + ProofMode::Generate => self.gen_error_message = Some(message.to_string()), + ProofMode::Verify => self.verify_error_message = Some(message.to_string()), + } + self.is_generating = false; + self.is_verifying = false; + } + } + + fn display_task_result( + &mut self, + backend_task_success_result: crate::backend_task::BackendTaskSuccessResult, + ) { + use crate::backend_task::BackendTaskSuccessResult; + + match backend_task_success_result { + BackendTaskSuccessResult::GeneratedZKProof(proof_data) => { + self.is_generating = false; + let proof_size = proof_data.proof.len(); + self.generated_proof = Some(ProofData { + full_proof: proof_data.clone(), + hash: hex::encode(&proof_data.public_inputs.state_root[0..8]), + size: proof_size, + generation_time: std::time::Duration::from_millis( + proof_data.metadata.generation_time_ms, + ), + }); + self.proof_size = Some(format!("{} bytes", proof_data.metadata.proof_size)); + self.generation_time = Some(std::time::Duration::from_millis( + proof_data.metadata.generation_time_ms, + )); + self.gen_error_message = None; + } + BackendTaskSuccessResult::VerifiedZKProof(is_valid, proof_data) => { + self.is_verifying = false; + // Get contract ID from the proof data itself + let contract_id = hex::encode(proof_data.public_inputs.contract_id); + self.verification_result = Some(VerificationResult { + is_valid, + verified_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + contract_id, + security_level: self.security_level, + error_message: if !is_valid { + Some("Proof verification failed".to_string()) + } else { + None + }, + technical_details: format!( + "Verification result: {}", + if is_valid { "VALID" } else { "INVALID" } + ), + }); + self.verify_error_message = None; + } + _ => {} + } + } + + fn pop_on_success(&mut self) { + // Pop on success if needed + } + + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = AppAction::None; + + // Add top panel with breadcrumb + action |= add_top_panel( + ctx, + &self.app_context, + vec![("Tools", AppAction::None)], + vec![], + ); + + // Add left panel + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenToolsGroveSTARKScreen, + ); + + // Add tools subscreen chooser panel + action |= add_tools_subscreen_chooser_panel(ctx, self.app_context.as_ref()); + + // Add central panel with the main UI + let panel_action = island_central_panel(ctx, |ui| { + ui.label( + RichText::new("GroveSTARK Zero-Knowledge Proofs") + .size(Typography::SCALE_XL) + .strong() + .color(DashColors::text_primary(ui.ctx().style().visuals.dark_mode)), + ); + ui.add_space(5.0); + + // Add research warning + ui.label( + RichText::new("WARNING: GroveSTARK is a research project. It has not been audited and may contain bugs and security flaws. This feature is NOT ready for production usage.") + .size(Typography::SCALE_XS) + .color(DashColors::text_primary(ui.ctx().style().visuals.dark_mode)) + ); + ui.add_space(Spacing::SM); + ui.separator(); + + let mut content_action = AppAction::None; + let available_height = ui.available_height(); + + // Mode Toggle at the top + ui.horizontal(|ui| { + ui.label( + RichText::new("Mode:") + .size(Typography::SCALE_LG) + .strong() + .color(DashColors::text_primary(ui.ctx().style().visuals.dark_mode)), + ); + ui.add_space(10.0); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Generate button + let generate_selected = self.mode == ProofMode::Generate; + let generate_button = if generate_selected { + Button::new( + RichText::new("🔐 Generate Proof") + .color(DashColors::WHITE) + .size(Typography::SCALE_SM), + ) + .fill(DashColors::DASH_BLUE) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) + .min_size(egui::Vec2::new(150.0, 28.0)) + } else { + Button::new( + RichText::new("🔐 Generate Proof") + .color(DashColors::text_primary(dark_mode)) + .size(Typography::SCALE_SM), + ) + .fill(DashColors::glass_white(dark_mode)) + .stroke(egui::Stroke::new(1.0, DashColors::border(dark_mode))) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) + .min_size(egui::Vec2::new(150.0, 28.0)) + }; + + if ui.add(generate_button).clicked() { + self.mode = ProofMode::Generate; + } + + ui.add_space(5.0); + + // Verify button + let verify_selected = self.mode == ProofMode::Verify; + let verify_button = if verify_selected { + Button::new( + RichText::new("✅ Verify Proof") + .color(DashColors::WHITE) + .size(Typography::SCALE_SM), + ) + .fill(DashColors::DASH_BLUE) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) + .min_size(egui::Vec2::new(150.0, 28.0)) + } else { + Button::new( + RichText::new("✅ Verify Proof") + .color(DashColors::text_primary(dark_mode)) + .size(Typography::SCALE_SM), + ) + .fill(DashColors::glass_white(dark_mode)) + .stroke(egui::Stroke::new(1.0, DashColors::border(dark_mode))) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_MD)) + .min_size(egui::Vec2::new(150.0, 28.0)) + }; + + if ui.add(verify_button).clicked() { + self.mode = ProofMode::Verify; + } + }); + + ui.separator(); + ui.add_space(Spacing::SM); + + // Main content area with scrolling + ScrollArea::vertical() + .max_height(available_height - 100.0) // Reserve space for mode toggle and margins + .show(ui, |ui| { + // Clone app_context to avoid borrowing issues + let app_context = self.app_context.clone(); + // Render the appropriate UI based on mode + let maybe_action = match self.mode { + ProofMode::Generate => self.render_generation_ui(ui, &app_context), + ProofMode::Verify => self.render_verification_ui(ui, &app_context), + }; + if let Some(ui_action) = maybe_action { + content_action |= ui_action; + } + }); + + content_action + }); + + action |= panel_action; + + // Note: Confirmation dialog handling would be done within the UI context if needed + + action + } +} diff --git a/src/ui/tools/mod.rs b/src/ui/tools/mod.rs index 3bc6af157..02e084194 100644 --- a/src/ui/tools/mod.rs +++ b/src/ui/tools/mod.rs @@ -1,5 +1,6 @@ pub mod contract_visualizer_screen; pub mod document_visualizer_screen; +pub mod grovestark_screen; pub mod masternode_list_diff_screen; pub mod platform_info_screen; pub mod proof_log_screen; From ff64448bd0cd8c567d9c8def632e2cd1ff8f2db1 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Fri, 24 Oct 2025 10:49:06 +0700 Subject: [PATCH 033/106] chore: update grovestark rev --- Cargo.lock | 1 + Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 8d6834125..0292c5cf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3703,6 +3703,7 @@ dependencies = [ [[package]] name = "grovestark" version = "0.1.0" +source = "git+https://www.github.com/pauldelucia/grovestark?rev=5313ba9df590f114e11934e281f1e8c8bc462794#5313ba9df590f114e11934e281f1e8c8bc462794" dependencies = [ "ark-ff", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 765460965..c97db8f9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ nix = { version = "0.30.1", features = ["signal"] } eframe = { version = "0.32.0", features = ["persistence"] } base64 = "0.22.1" dash-sdk = { git = "https://www.github.com/dashpay/platform", rev = "68f77c085643edb9a46afd4ff51c9d20b4ce2912", features = ["core_key_wallet", "core_bincode", "core_quorum-validation", "core_verification", "core_rpc_client"] } -grovestark = { path = "../grovestark" } +grovestark = { git = "https://www.github.com/pauldelucia/grovestark", rev = "5313ba9df590f114e11934e281f1e8c8bc462794" } rayon = "1.8" thiserror = "2.0.12" serde = "1.0.219" From d9bfc154f7a6789e07ad8f8f45c3ee5617a0c1c6 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Tue, 28 Oct 2025 15:42:58 +0700 Subject: [PATCH 034/106] chore: update platform to v2.1.2 --- Cargo.lock | 198 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 2 files changed, 100 insertions(+), 100 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0292c5cf5..71a7c7e63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1645,13 +1645,13 @@ dependencies = [ [[package]] name = "dapi-grpc" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ - "dapi-grpc-macros 2.1.0-rc.1", + "dapi-grpc-macros 2.1.2", "futures-core", "getrandom 0.2.16", - "platform-version 2.1.0-rc.1", + "platform-version 2.1.2", "prost 0.14.1", "serde", "serde_bytes", @@ -1674,8 +1674,8 @@ dependencies = [ [[package]] name = "dapi-grpc-macros" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ "heck", "quote", @@ -1733,11 +1733,11 @@ dependencies = [ [[package]] name = "dash-context-provider" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ - "dpp 2.1.0-rc.1", - "drive 2.1.0-rc.1", + "dpp 2.1.2", + "drive 2.1.2", "hex", "serde", "serde_json", @@ -1759,7 +1759,7 @@ dependencies = [ "chrono-humanize", "crossbeam-channel", "dark-light", - "dash-sdk 2.1.0-rc.1", + "dash-sdk 2.1.2", "derive_more 2.0.1", "directories", "dotenvy", @@ -1854,8 +1854,8 @@ dependencies = [ [[package]] name = "dash-sdk" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ "arc-swap", "async-trait", @@ -1863,21 +1863,21 @@ dependencies = [ "bip37-bloom-filter", "chrono", "ciborium", - "dapi-grpc 2.1.0-rc.1", - "dapi-grpc-macros 2.1.0-rc.1", + "dapi-grpc 2.1.2", + "dapi-grpc-macros 2.1.2", "dash-context-provider", "derive_more 1.0.0", "dotenvy", - "dpp 2.1.0-rc.1", - "drive 2.1.0-rc.1", - "drive-proof-verifier 2.1.0-rc.1", + "dpp 2.1.2", + "drive 2.1.2", + "drive-proof-verifier 2.1.2", "envy", "futures", "hex", "http", "js-sys", "lru", - "rs-dapi-client 2.1.0-rc.1", + "rs-dapi-client 2.1.2", "rustls-pemfile", "serde", "serde_json", @@ -2048,11 +2048,11 @@ dependencies = [ [[package]] name = "dashpay-contract" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ - "platform-value 2.1.0-rc.1", - "platform-version 2.1.0-rc.1", + "platform-value 2.1.2", + "platform-version 2.1.2", "serde_json", "thiserror 2.0.17", ] @@ -2078,21 +2078,21 @@ dependencies = [ [[package]] name = "data-contracts" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" -dependencies = [ - "dashpay-contract 2.1.0-rc.1", - "dpns-contract 2.1.0-rc.1", - "feature-flags-contract 2.1.0-rc.1", - "keyword-search-contract 2.1.0-rc.1", - "masternode-reward-shares-contract 2.1.0-rc.1", - "platform-value 2.1.0-rc.1", - "platform-version 2.1.0-rc.1", +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +dependencies = [ + "dashpay-contract 2.1.2", + "dpns-contract 2.1.2", + "feature-flags-contract 2.1.2", + "keyword-search-contract 2.1.2", + "masternode-reward-shares-contract 2.1.2", + "platform-value 2.1.2", + "platform-version 2.1.2", "serde_json", "thiserror 2.0.17", - "token-history-contract 2.1.0-rc.1", - "wallet-utils-contract 2.1.0-rc.1", - "withdrawals-contract 2.1.0-rc.1", + "token-history-contract 2.1.2", + "wallet-utils-contract 2.1.2", + "withdrawals-contract 2.1.2", ] [[package]] @@ -2339,11 +2339,11 @@ dependencies = [ [[package]] name = "dpns-contract" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ - "platform-value 2.1.0-rc.1", - "platform-version 2.1.0-rc.1", + "platform-value 2.1.2", + "platform-version 2.1.2", "serde_json", "thiserror 2.0.17", ] @@ -2393,8 +2393,8 @@ dependencies = [ [[package]] name = "dpp" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ "anyhow", "async-trait", @@ -2408,7 +2408,7 @@ dependencies = [ "ciborium", "dashcore 0.40.0", "dashcore-rpc 0.40.0", - "data-contracts 2.1.0-rc.1", + "data-contracts 2.1.2", "derive_more 1.0.0", "env_logger", "getrandom 0.2.16", @@ -2421,11 +2421,11 @@ dependencies = [ "nohash-hasher", "num_enum 0.7.5", "once_cell", - "platform-serialization 2.1.0-rc.1", - "platform-serialization-derive 2.1.0-rc.1", - "platform-value 2.1.0-rc.1", - "platform-version 2.1.0-rc.1", - "platform-versioning 2.1.0-rc.1", + "platform-serialization 2.1.2", + "platform-serialization-derive 2.1.2", + "platform-value 2.1.2", + "platform-version 2.1.2", + "platform-versioning 2.1.2", "rand 0.8.5", "regex", "serde", @@ -2464,13 +2464,13 @@ dependencies = [ [[package]] name = "drive" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ "bincode 2.0.0-rc.3", "byteorder", "derive_more 1.0.0", - "dpp 2.1.0-rc.1", + "dpp 2.1.2", "grovedb", "grovedb-costs", "grovedb-epoch-based-storage-flags", @@ -2480,7 +2480,7 @@ dependencies = [ "indexmap 2.12.0", "integer-encoding", "nohash-hasher", - "platform-version 2.1.0-rc.1", + "platform-version 2.1.2", "serde", "sqlparser", "thiserror 2.0.17", @@ -2510,19 +2510,19 @@ dependencies = [ [[package]] name = "drive-proof-verifier" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ "bincode 2.0.0-rc.3", - "dapi-grpc 2.1.0-rc.1", + "dapi-grpc 2.1.2", "dash-context-provider", "derive_more 1.0.0", - "dpp 2.1.0-rc.1", - "drive 2.1.0-rc.1", + "dpp 2.1.2", + "drive 2.1.2", "hex", "indexmap 2.12.0", - "platform-serialization 2.1.0-rc.1", - "platform-serialization-derive 2.1.0-rc.1", + "platform-serialization 2.1.2", + "platform-serialization-derive 2.1.2", "serde", "serde_json", "tenderdash-abci 1.5.0-dev.2", @@ -3090,11 +3090,11 @@ dependencies = [ [[package]] name = "feature-flags-contract" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ - "platform-value 2.1.0-rc.1", - "platform-version 2.1.0-rc.1", + "platform-value 2.1.2", + "platform-version 2.1.2", "serde_json", "thiserror 2.0.17", ] @@ -4452,11 +4452,11 @@ dependencies = [ [[package]] name = "keyword-search-contract" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ - "platform-value 2.1.0-rc.1", - "platform-version 2.1.0-rc.1", + "platform-value 2.1.2", + "platform-version 2.1.2", "serde_json", "thiserror 2.0.17", ] @@ -4672,11 +4672,11 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ - "platform-value 2.1.0-rc.1", - "platform-version 2.1.0-rc.1", + "platform-value 2.1.2", + "platform-version 2.1.2", "serde_json", "thiserror 2.0.17", ] @@ -5713,11 +5713,11 @@ dependencies = [ [[package]] name = "platform-serialization" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ "bincode 2.0.0-rc.3", - "platform-version 2.1.0-rc.1", + "platform-version 2.1.2", ] [[package]] @@ -5733,8 +5733,8 @@ dependencies = [ [[package]] name = "platform-serialization-derive" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ "proc-macro2", "quote", @@ -5764,8 +5764,8 @@ dependencies = [ [[package]] name = "platform-value" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ "base64 0.22.1", "bincode 2.0.0-rc.3", @@ -5773,8 +5773,8 @@ dependencies = [ "ciborium", "hex", "indexmap 2.12.0", - "platform-serialization 2.1.0-rc.1", - "platform-version 2.1.0-rc.1", + "platform-serialization 2.1.2", + "platform-version 2.1.2", "rand 0.8.5", "serde", "serde_json", @@ -5796,8 +5796,8 @@ dependencies = [ [[package]] name = "platform-version" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ "bincode 2.0.0-rc.3", "grovedb-version", @@ -5818,8 +5818,8 @@ dependencies = [ [[package]] name = "platform-versioning" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ "proc-macro2", "quote", @@ -6454,12 +6454,12 @@ dependencies = [ [[package]] name = "rs-dapi-client" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ "backon", "chrono", - "dapi-grpc 2.1.0-rc.1", + "dapi-grpc 2.1.2", "futures", "getrandom 0.2.16", "gloo-timers", @@ -7558,11 +7558,11 @@ dependencies = [ [[package]] name = "token-history-contract" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ - "platform-value 2.1.0-rc.1", - "platform-version 2.1.0-rc.1", + "platform-value 2.1.2", + "platform-version 2.1.2", "serde_json", "thiserror 2.0.17", ] @@ -8311,11 +8311,11 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ - "platform-value 2.1.0-rc.1", - "platform-version 2.1.0-rc.1", + "platform-value 2.1.2", + "platform-version 2.1.2", "serde_json", "thiserror 2.0.17", ] @@ -9540,12 +9540,12 @@ dependencies = [ [[package]] name = "withdrawals-contract" -version = "2.1.0-rc.1" -source = "git+https://www.github.com/dashpay/platform?rev=68f77c085643edb9a46afd4ff51c9d20b4ce2912#68f77c085643edb9a46afd4ff51c9d20b4ce2912" +version = "2.1.2" +source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" dependencies = [ "num_enum 0.5.11", - "platform-value 2.1.0-rc.1", - "platform-version 2.1.0-rc.1", + "platform-value 2.1.2", + "platform-version 2.1.2", "serde", "serde_json", "serde_repr", diff --git a/Cargo.toml b/Cargo.toml index c97db8f9c..d54a8ed1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.30.1", features = ["signal"] } eframe = { version = "0.32.0", features = ["persistence"] } base64 = "0.22.1" -dash-sdk = { git = "https://www.github.com/dashpay/platform", rev = "68f77c085643edb9a46afd4ff51c9d20b4ce2912", features = ["core_key_wallet", "core_bincode", "core_quorum-validation", "core_verification", "core_rpc_client"] } +dash-sdk = { git = "https://www.github.com/dashpay/platform", tag = "v2.1.2", features = ["core_key_wallet", "core_bincode", "core_quorum-validation", "core_verification", "core_rpc_client"] } grovestark = { git = "https://www.github.com/pauldelucia/grovestark", rev = "5313ba9df590f114e11934e281f1e8c8bc462794" } rayon = "1.8" thiserror = "2.0.12" From a62557485f03bd6b55872443b3d448e9651fb655 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Tue, 28 Oct 2025 16:51:44 +0700 Subject: [PATCH 035/106] feat: derive keys from loaded wallets when loading identity by ID (#446) * feat: derive keys from loaded wallets when loading identity * clippy * chore: update platform version * ok * cleanup ui * cleanup ui * set max index search to 30 * set max const * k --- src/backend_task/identity/load_identity.rs | 280 ++++++++++++++++-- .../identity/load_identity_from_wallet.rs | 4 +- src/backend_task/identity/mod.rs | 2 + .../add_existing_identity_screen.rs | 184 ++++++++++-- .../identities/add_new_identity_screen/mod.rs | 6 +- 5 files changed, 432 insertions(+), 44 deletions(-) diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index b82da7ad9..c5f63dd43 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -4,26 +4,37 @@ use crate::context::AppContext; use crate::model::qualified_identity::PrivateKeyTarget::{ self, PrivateKeyOnMainIdentity, PrivateKeyOnVoterIdentity, }; -use crate::model::qualified_identity::encrypted_key_storage::PrivateKeyData; +use crate::model::qualified_identity::encrypted_key_storage::{ + PrivateKeyData, WalletDerivationPath, +}; use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; use crate::model::qualified_identity::{ DPNSNameInfo, IdentityStatus, IdentityType, QualifiedIdentity, }; +use crate::model::wallet::{Wallet, WalletSeedHash}; +use crate::ui::identities::add_new_identity_screen::MAX_IDENTITY_INDEX; use dash_sdk::Sdk; use dash_sdk::dashcore_rpc::dashcore::PrivateKey; use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::identifier::MasternodeIdentifiers; +use dash_sdk::dpp::identity::KeyType; use dash_sdk::dpp::identity::SecurityLevel; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, KeyDerivationType}; use dash_sdk::dpp::platform_value::Value; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::drive::query::{WhereClause, WhereOperator}; use dash_sdk::platform::{Document, DocumentQuery, Fetch, FetchMany, Identifier, Identity}; use egui::ahash::HashMap; use std::collections::BTreeMap; +use std::convert::TryInto; +use std::sync::{Arc, RwLock}; + +type WalletKeyMap = BTreeMap<(PrivateKeyTarget, u32), (QualifiedIdentityPublicKey, PrivateKeyData)>; +type WalletMatchResult = Option<(WalletSeedHash, u32, WalletKeyMap)>; impl AppContext { pub(super) async fn load_identity( @@ -39,6 +50,8 @@ impl AppContext { owner_private_key_input, payout_address_private_key_input, keys_input, + derive_keys_from_wallets, + selected_wallet_seed_hash, } = input; // Verify the voting private key @@ -69,6 +82,17 @@ impl AppContext { let wallets = self.wallets.read().unwrap().clone(); + if identity_type == IdentityType::User + && derive_keys_from_wallets + && let Some((_, _, wallet_private_keys)) = self.match_user_identity_keys_with_wallet( + &identity, + &wallets, + selected_wallet_seed_hash, + )? + { + encrypted_private_keys.extend(wallet_private_keys); + } + if identity_type != IdentityType::User && let Some(owner_private_key_bytes) = owner_private_key_bytes { @@ -198,44 +222,50 @@ impl AppContext { .unzip(); for (&key_id, public_key) in identity.public_keys().iter() { + let key_map_key = (PrivateKeyTarget::PrivateKeyOnMainIdentity, key_id); let qualified_key = QualifiedIdentityPublicKey::from_identity_public_key_with_wallets_check( public_key.clone(), self.network, &wallets.values().collect::>(), ); - - if let Some(wallet_derivation_path) = - qualified_key.in_wallet_at_derivation_path.clone() - { - encrypted_private_keys.insert( - (PrivateKeyTarget::PrivateKeyOnMainIdentity, key_id), - ( - qualified_key, - PrivateKeyData::AtWalletDerivationPath(wallet_derivation_path), - ), - ); - } else if let Some(private_key_bytes) = + if let Some(private_key_bytes) = public_key_lookup.get(public_key.data().0.as_slice()) { let private_data = match public_key.security_level() { SecurityLevel::MEDIUM => PrivateKeyData::AlwaysClear(*private_key_bytes), _ => PrivateKeyData::Clear(*private_key_bytes), }; - encrypted_private_keys.insert( - (PrivateKeyTarget::PrivateKeyOnMainIdentity, key_id), - (qualified_key, private_data), - ); - } else if let Some(private_key_bytes) = + encrypted_private_keys + .insert(key_map_key, (qualified_key.clone(), private_data)); + continue; + } + + if let Some(private_key_bytes) = public_key_hash_lookup.get(public_key.data().0.as_slice()) { let private_data = match public_key.security_level() { SecurityLevel::MEDIUM => PrivateKeyData::AlwaysClear(*private_key_bytes), _ => PrivateKeyData::Clear(*private_key_bytes), }; + encrypted_private_keys + .insert(key_map_key, (qualified_key.clone(), private_data)); + continue; + } + + if encrypted_private_keys.contains_key(&key_map_key) { + continue; + } + + if let Some(wallet_derivation_path) = + qualified_key.in_wallet_at_derivation_path.clone() + { encrypted_private_keys.insert( - (PrivateKeyTarget::PrivateKeyOnMainIdentity, key_id), - (qualified_key, private_data), + key_map_key, + ( + qualified_key, + PrivateKeyData::AtWalletDerivationPath(wallet_derivation_path), + ), ); } } @@ -317,8 +347,218 @@ impl AppContext { self.insert_local_qualified_identity(&qualified_identity, &wallet_info) .map_err(|e| format!("Database error: {}", e))?; + if let Some((wallet_seed_hash, identity_index)) = wallet_info + && let Some(wallet_arc) = wallets.get(&wallet_seed_hash) + { + let mut wallet = wallet_arc.write().unwrap(); + wallet + .identities + .insert(identity_index, qualified_identity.identity.clone()); + } + Ok(BackendTaskSuccessResult::Message( "Successfully loaded identity".to_string(), )) } + + fn match_user_identity_keys_with_wallet( + &self, + identity: &Identity, + wallets: &BTreeMap>>, + wallet_filter: Option, + ) -> Result { + let highest_identity_key_id = identity.public_keys().keys().copied().max().unwrap_or(0); + let top_bound = highest_identity_key_id.saturating_add(6).max(1); + + for (&wallet_seed_hash, wallet_arc) in wallets.iter() { + if wallet_filter.is_some_and(|filter| filter != wallet_seed_hash) { + continue; + } + let mut wallet = wallet_arc.write().unwrap(); + if !wallet.is_open() { + continue; + } + + if let Some((identity_index, wallet_private_keys)) = self + .attempt_match_identity_with_wallet( + identity, + &mut wallet, + wallet_seed_hash, + top_bound, + )? + { + drop(wallet); + return Ok(Some(( + wallet_seed_hash, + identity_index, + wallet_private_keys, + ))); + } + } + + Ok(None) + } + + fn attempt_match_identity_with_wallet( + &self, + identity: &Identity, + wallet: &mut Wallet, + wallet_seed_hash: WalletSeedHash, + top_bound: u32, + ) -> Result, String> { + let identity_id = identity.id(); + + if let Some((&identity_index, _)) = wallet + .identities + .iter() + .find(|(_, existing)| existing.id() == identity_id) + { + let (public_key_map, public_key_hash_map) = wallet + .identity_authentication_ecdsa_public_keys_data_map( + self.network, + identity_index, + 0..top_bound, + Some(self), + )?; + let wallet_private_keys = self.build_wallet_private_key_map( + identity, + wallet_seed_hash, + identity_index, + &public_key_map, + &public_key_hash_map, + ); + + if !wallet_private_keys.is_empty() { + return Ok(Some((identity_index, wallet_private_keys))); + } + } + + for candidate_index in 0..MAX_IDENTITY_INDEX { + let (public_key_map, public_key_hash_map) = wallet + .identity_authentication_ecdsa_public_keys_data_map( + self.network, + candidate_index, + 0..top_bound, + None, + )?; + + if !Self::identity_matches_wallet_key_material( + identity, + &public_key_map, + &public_key_hash_map, + ) { + continue; + } + + let (public_key_map, public_key_hash_map) = wallet + .identity_authentication_ecdsa_public_keys_data_map( + self.network, + candidate_index, + 0..top_bound, + Some(self), + )?; + + let wallet_private_keys = self.build_wallet_private_key_map( + identity, + wallet_seed_hash, + candidate_index, + &public_key_map, + &public_key_hash_map, + ); + + if wallet_private_keys.is_empty() { + continue; + } + + return Ok(Some((candidate_index, wallet_private_keys))); + } + + Ok(None) + } + + fn identity_matches_wallet_key_material( + identity: &Identity, + public_key_map: &BTreeMap, u32>, + public_key_hash_map: &BTreeMap<[u8; 20], u32>, + ) -> bool { + identity + .public_keys() + .values() + .any(|public_key| match public_key.key_type() { + KeyType::ECDSA_SECP256K1 => { + if public_key_map.contains_key(public_key.data().as_slice()) { + true + } else if let Ok(hash) = <[u8; 20]>::try_from(public_key.data().as_slice()) { + public_key_hash_map.contains_key(&hash) + } else { + false + } + } + KeyType::ECDSA_HASH160 => { + if let Ok(hash) = <[u8; 20]>::try_from(public_key.data().as_slice()) { + public_key_hash_map.contains_key(&hash) + } else { + false + } + } + _ => false, + }) + } + + fn build_wallet_private_key_map( + &self, + identity: &Identity, + wallet_seed_hash: WalletSeedHash, + identity_index: u32, + public_key_map: &BTreeMap, u32>, + public_key_hash_map: &BTreeMap<[u8; 20], u32>, + ) -> WalletKeyMap { + identity + .public_keys() + .values() + .filter_map(|public_key| { + let index = + match public_key.key_type() { + KeyType::ECDSA_SECP256K1 => public_key_map + .get(public_key.data().as_slice()) + .copied() + .or_else(|| { + public_key.data().as_slice().try_into().ok().and_then( + |hash: [u8; 20]| public_key_hash_map.get(&hash).copied(), + ) + }), + KeyType::ECDSA_HASH160 => public_key + .data() + .as_slice() + .try_into() + .ok() + .and_then(|hash: [u8; 20]| public_key_hash_map.get(&hash).copied()), + _ => None, + }?; + + let derivation_path = DerivationPath::identity_authentication_path( + self.network, + KeyDerivationType::ECDSA, + identity_index, + index, + ); + + let wallet_derivation_path = WalletDerivationPath { + wallet_seed_hash, + derivation_path, + }; + + Some(( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, public_key.id()), + ( + QualifiedIdentityPublicKey::from_identity_public_key_in_wallet( + public_key.clone(), + Some(wallet_derivation_path.clone()), + ), + PrivateKeyData::AtWalletDerivationPath(wallet_derivation_path), + ), + )) + }) + .collect() + } } diff --git a/src/backend_task/identity/load_identity_from_wallet.rs b/src/backend_task/identity/load_identity_from_wallet.rs index 4d1b589c4..0acf3ae15 100644 --- a/src/backend_task/identity/load_identity_from_wallet.rs +++ b/src/backend_task/identity/load_identity_from_wallet.rs @@ -55,8 +55,8 @@ impl AppContext { sender .send(TaskResult::Success(Box::new( BackendTaskSuccessResult::Message(format!( - "Searching for identity using key at index {}...", - key_index + "Searching for identity at index {} using key at index {}...", + identity_index, key_index )), ))) .await diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index ddf53bd97..e9611f899 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -43,6 +43,8 @@ pub struct IdentityInputToLoad { pub owner_private_key_input: String, pub payout_address_private_key_input: String, pub keys_input: Vec, + pub derive_keys_from_wallets: bool, + pub selected_wallet_seed_hash: Option, } #[derive(Debug, Clone, PartialEq)] diff --git a/src/ui/identities/add_existing_identity_screen.rs b/src/ui/identities/add_existing_identity_screen.rs index 8348fcf8f..b1db1db2f 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -85,6 +85,7 @@ pub struct AddExistingIdentityScreen { add_identity_status: AddIdentityStatus, testnet_loaded_nodes: Option, selected_wallet: Option>>, + identity_associated_with_wallet: bool, show_password: bool, wallet_password: String, error_message: Option, @@ -112,10 +113,11 @@ impl AddExistingIdentityScreen { voting_private_key_input: String::new(), owner_private_key_input: String::new(), payout_address_private_key_input: String::new(), - keys_input: vec![String::new(), String::new(), String::new()], + keys_input: vec![], add_identity_status: AddIdentityStatus::NotStarted, testnet_loaded_nodes, selected_wallet, + identity_associated_with_wallet: true, show_password: false, wallet_password: "".to_string(), error_message: None, @@ -142,6 +144,132 @@ impl AddExistingIdentityScreen { ui.add_space(10.0); } + let wallets_snapshot: Vec<(String, Arc>)> = { + let wallets_guard = self.app_context.wallets.read().unwrap(); + wallets_guard + .values() + .map(|wallet| { + let alias = wallet + .read() + .unwrap() + .alias + .clone() + .unwrap_or_else(|| "Unnamed Wallet".to_string()); + (alias, wallet.clone()) + }) + .collect() + }; + let has_wallets = !wallets_snapshot.is_empty(); + let mut should_return_early = false; + + ui.add_space(10.0); + + ui.vertical(|ui| { + ui.horizontal(|ui| { + let checkbox_response = ui.checkbox( + &mut self.identity_associated_with_wallet, + "Try to automatically derive private keys from loaded wallet", + ); + let response = crate::ui::helpers::info_icon_button( + ui, + "When enabled, Dash Evo Tool scans the selected unlocked wallet (or all unlocked wallets) right now to find matching keys.", + ); + if response.clicked() { + self.show_pop_up_info = Some( + "When enabled, Dash Evo Tool scans the selected unlocked wallet (or all unlocked wallets) right now to find matching keys." + .to_string(), + ); + } + + if checkbox_response.changed() && !self.identity_associated_with_wallet { + self.selected_wallet = None; + } + }); + + if self.identity_associated_with_wallet { + if has_wallets { + let selected_label = self + .selected_wallet + .as_ref() + .and_then(|selected| { + wallets_snapshot.iter().find_map(|(alias, wallet)| { + if Arc::ptr_eq(selected, wallet) { + Some(alias.clone()) + } else { + None + } + }) + }) + .unwrap_or_else(|| "All unlocked wallets".to_string()); + + ComboBox::from_id_salt("identity_wallet_selector") + .selected_text(selected_label) + .show_ui(ui, |ui| { + if ui + .selectable_label( + self.selected_wallet.is_none(), + "All unlocked wallets", + ) + .clicked() + { + self.selected_wallet = None; + } + + for (alias, wallet) in &wallets_snapshot { + let is_selected = self + .selected_wallet + .as_ref() + .is_some_and(|selected| Arc::ptr_eq(selected, wallet)); + + if ui.selectable_label(is_selected, alias).clicked() { + self.selected_wallet = Some(wallet.clone()); + } + } + }); + + if let Some(selected_wallet) = &self.selected_wallet { + let wallet_still_loaded = wallets_snapshot + .iter() + .any(|(_, wallet)| Arc::ptr_eq(wallet, selected_wallet)); + + if wallet_still_loaded { + let (needed_unlock, just_unlocked) = + self.render_wallet_unlock_if_needed(ui); + if needed_unlock && !just_unlocked { + should_return_early = true; + ui.colored_label( + Color32::DARK_RED, + "Press return/enter after typing the password.", + ); + } else if just_unlocked { + ui.colored_label( + Color32::GREEN, + "Wallet unlocked. We'll pull any matching keys automatically.", + ); + } + } else { + self.selected_wallet = None; + ui.colored_label( + Color32::RED, + "Selected wallet is no longer loaded. We'll search unlocked wallets instead.", + ); + } + } + } else { + ui.colored_label( + Color32::GRAY, + "No wallets are currently loaded. Import one to scan for keys.", + ); + } + } + }); + + if should_return_early { + return action; + } + + ui.add_space(10.0); + egui::Grid::new("add_existing_identity_grid") .num_columns(2) .spacing([10.0, 10.0]) @@ -153,22 +281,25 @@ impl AddExistingIdentityScreen { ui.end_row(); ui.label("Identity Type:"); - egui::ComboBox::from_id_salt("identity_type_selector") - .selected_text(format!("{:?}", self.identity_type)) - // .width(350.0) // This sets the entire row's width - .show_ui(ui, |ui| { - ui.selectable_value(&mut self.identity_type, IdentityType::User, "User"); - ui.selectable_value( - &mut self.identity_type, - IdentityType::Masternode, - "Masternode", - ); - ui.selectable_value( - &mut self.identity_type, - IdentityType::Evonode, - "Evonode", - ); - }); + + ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { + egui::ComboBox::from_id_salt("identity_type_selector") + .selected_text(format!("{:?}", self.identity_type)) + // .width(350.0) // This sets the entire row's width + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.identity_type, IdentityType::User, "User"); + ui.selectable_value( + &mut self.identity_type, + IdentityType::Masternode, + "Masternode", + ); + ui.selectable_value( + &mut self.identity_type, + IdentityType::Evonode, + "Evonode", + ); + }); + }); ui.label(""); ui.end_row(); @@ -246,10 +377,11 @@ impl AddExistingIdentityScreen { } } }); + ui.add_space(10.0); // Add button to add more keys - if ui.button("+ Add Key").clicked() { + if ui.button("+ Add key manually").clicked() { self.keys_input.push(String::new()); } ui.add_space(10.0); @@ -331,7 +463,7 @@ impl AddExistingIdentityScreen { if wallets_len == 0 { ui.colored_label( Color32::GRAY, - "No wallets available. Import or create a wallet to search by derivation path.", + "No wallets available. Import a wallet to search by derivation path.", ); return action; } @@ -380,7 +512,9 @@ impl AddExistingIdentityScreen { let identity_index_label = match self.wallet_search_mode { WalletIdentitySearchMode::SpecificIndex => "Identity index:", - WalletIdentitySearchMode::UpToIndex => "Highest identity index to search (inclusive):", + WalletIdentitySearchMode::UpToIndex => { + "Highest identity index to search (inclusive, max 29):" + } }; ui.horizontal(|ui| { @@ -436,6 +570,14 @@ impl AddExistingIdentityScreen { } fn load_identity_clicked(&mut self) -> AppAction { + let selected_wallet_seed_hash = if self.identity_associated_with_wallet { + self.selected_wallet + .as_ref() + .map(|wallet| wallet.read().unwrap().seed_hash()) + } else { + None + }; + let identity_input = IdentityInputToLoad { identity_id_input: self.identity_id_input.trim().to_string(), identity_type: self.identity_type, @@ -444,6 +586,8 @@ impl AddExistingIdentityScreen { owner_private_key_input: self.owner_private_key_input.clone(), payout_address_private_key_input: self.payout_address_private_key_input.clone(), keys_input: self.keys_input.clone(), + derive_keys_from_wallets: self.identity_associated_with_wallet, + selected_wallet_seed_hash, }; AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::LoadIdentity( diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index 040b1a540..833b40dbd 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -34,6 +34,8 @@ use std::fmt; use std::sync::atomic::Ordering; use std::sync::{Arc, RwLock}; +pub const MAX_IDENTITY_INDEX: u32 = 30; + #[derive(Debug, PartialEq, Eq, Copy, Clone)] pub enum FundingMethod { NoSelection, @@ -240,8 +242,8 @@ impl AddNewIdentityScreen { ComboBox::from_id_salt("identity_index") .selected_text(selected_text) .show_ui(ui, |ui| { - // Provide up to 30 entries for selection (0 to 29) - for i in 0..30 { + // Provide up to 30 entries for selection + for i in 0..MAX_IDENTITY_INDEX { let is_used = used_indices.contains(&i); let label = if is_used { format!("{} (used)", i) From 26a74a031020d1aad141b6d4ee6c453849fc90c6 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Tue, 28 Oct 2025 17:01:44 +0700 Subject: [PATCH 036/106] fix: wallet screen not updating wallets after network change (#451) * fix: wallet screen not updating wallets after network change * ui improvement * clippy --- src/ui/mod.rs | 5 ++++- src/ui/wallets/add_new_wallet_screen.rs | 3 ++- src/ui/wallets/wallets_screen/mod.rs | 23 +++++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index abc3b0910..db8579d9f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -516,7 +516,10 @@ impl Screen { Screen::AddNewWalletScreen(screen) => screen.app_context = app_context, Screen::TransferScreen(screen) => screen.app_context = app_context, Screen::TopUpIdentityScreen(screen) => screen.app_context = app_context, - Screen::WalletsBalancesScreen(screen) => screen.app_context = app_context, + Screen::WalletsBalancesScreen(screen) => { + screen.app_context = app_context; + screen.update_selected_wallet_for_network(); + } Screen::ImportWalletScreen(screen) => screen.app_context = app_context, Screen::ProofLogScreen(screen) => screen.app_context = app_context, Screen::AddContractsScreen(screen) => screen.app_context = app_context, diff --git a/src/ui/wallets/add_new_wallet_screen.rs b/src/ui/wallets/add_new_wallet_screen.rs index 6ce596402..0b5aa99f7 100644 --- a/src/ui/wallets/add_new_wallet_screen.rs +++ b/src/ui/wallets/add_new_wallet_screen.rs @@ -4,6 +4,7 @@ use crate::ui::ScreenLike; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; +use crate::ui::theme::DashColors; use eframe::egui::Context; use crate::model::wallet::encryption::{DASH_SECRET_MESSAGE, encrypt_message}; @@ -433,7 +434,7 @@ impl ScreenLike for AddNewWalletScreen { // Centered "Save Wallet" button at the bottom ui.with_layout(Layout::centered_and_justified(Direction::TopDown), |ui| { let save_button = egui::Button::new( - RichText::new("Save Wallet").strong().size(30.0), + RichText::new("Save Wallet").strong().size(30.0).color(DashColors::text_primary(ui.ctx().style().visuals.dark_mode)), ) .min_size(Vec2::new(300.0, 60.0)) .corner_radius(10.0) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 99094982e..d1874227c 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -152,6 +152,29 @@ impl WalletsBalancesScreen { } } + pub(crate) fn update_selected_wallet_for_network(&mut self) { + let selected_seed = self + .selected_wallet + .as_ref() + .and_then(|wallet| wallet.read().ok().map(|wallet| wallet.seed_hash())); + + let wallets = match self.app_context.wallets.read() { + Ok(guard) => guard, + Err(_) => { + self.selected_wallet = None; + return; + } + }; + + if let Some(seed_hash) = selected_seed + && let Some(wallet) = wallets.get(&seed_hash) { + self.selected_wallet = Some(wallet.clone()); + return; + } + + self.selected_wallet = wallets.values().next().cloned(); + } + fn add_receiving_address(&mut self) { if let Some(wallet) = &self.selected_wallet { let result = { From 32fa6bbebccfd85e7afe9ff39b40a86f8a20cb0c Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Tue, 28 Oct 2025 17:07:51 +0700 Subject: [PATCH 037/106] fix: some document actions were showing error when it was success (#452) * fix: some document actions were showing error when it was success * add space at bottom --- src/ui/contracts_documents/document_action_screen.rs | 10 +++++----- src/ui/helpers.rs | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index 67b39fd89..585822dd3 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -1507,11 +1507,11 @@ impl ScreenLike for DocumentActionScreen { } fn display_message(&mut self, message: &str, _message_type: crate::ui::MessageType) { - if message.contains("Document deleted successfully") - || message.contains("Document replaced successfully") - || message.contains("Document transferred successfully") - || message.contains("Document purchased successfully") - || message.contains("Document price set successfully") + if message.contains("deleted successfully") + || message.contains("replaced successfully") + || message.contains("transferred successfully") + || message.contains("purchased successfully") + || message.contains("price set successfully") { self.broadcast_status = BroadcastStatus::Broadcasted; } else { diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 7edafbb4f..8e3eeb99b 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -623,6 +623,7 @@ pub fn show_success_screen( action = button.1; } } + ui.add_space(100.0); }); action } From c934f13146dbcfd98272f6a97d2552c0c05f27c9 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Tue, 28 Oct 2025 17:53:40 +0700 Subject: [PATCH 038/106] feat: wallet unlock button (#459) --- src/ui/components/wallet_unlock.rs | 56 +++++++++++-------- .../add_existing_identity_screen.rs | 8 +-- src/ui/wallets/wallets_screen/mod.rs | 9 +-- 3 files changed, 40 insertions(+), 33 deletions(-) diff --git a/src/ui/components/wallet_unlock.rs b/src/ui/components/wallet_unlock.rs index 5c1f45b89..5650673ac 100644 --- a/src/ui/components/wallet_unlock.rs +++ b/src/ui/components/wallet_unlock.rs @@ -67,6 +67,8 @@ pub trait ScreenWithWalletUnlock { let mut local_error_message = self.error_message().cloned(); // Local variable for error message let wallet_password_mut = self.wallet_password_mut(); // Mutable reference to the password + let mut attempt_unlock = false; + ui.horizontal(|ui| { let dark_mode = ui.ctx().style().visuals.dark_mode; let password_input = ui.add( @@ -79,36 +81,46 @@ pub trait ScreenWithWalletUnlock { )), ); + if password_input.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) + { + attempt_unlock = true; + } + + ui.add_space(5.0); + // Checkbox to toggle password visibility StyledCheckbox::new(&mut local_show_password, "Show Password").show(ui); + }); - if password_input.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) - { - // Use the password from wallet_password_mut - let wallet_password_ref = &*wallet_password_mut; + ui.add_space(5.0); - let unlock_result = wallet.wallet_seed.open(wallet_password_ref); + if ui.button("Unlock").clicked() { + attempt_unlock = true; + } - match unlock_result { - Ok(_) => { - local_error_message = None; - unlocked = true; - } - Err(_) => { - if let Some(hint) = wallet.password_hint() { - local_error_message = Some(format!( - "Incorrect Password, password hint is {}", - hint - )); - } else { - local_error_message = Some("Incorrect Password".to_string()); - } + if attempt_unlock { + // Use the password from wallet_password_mut + let wallet_password_ref = &*wallet_password_mut; + + let unlock_result = wallet.wallet_seed.open(wallet_password_ref); + + match unlock_result { + Ok(_) => { + local_error_message = None; + unlocked = true; + } + Err(_) => { + if let Some(hint) = wallet.password_hint() { + local_error_message = + Some(format!("Incorrect Password, password hint is {}", hint)); + } else { + local_error_message = Some("Incorrect Password".to_string()); } } - // Clear the password field after submission - wallet_password_mut.zeroize(); } - }); + // Clear the password field after submission + wallet_password_mut.zeroize(); + } // Update `show_password` after the closure *self.show_password_mut() = local_show_password; diff --git a/src/ui/identities/add_existing_identity_screen.rs b/src/ui/identities/add_existing_identity_screen.rs index b1db1db2f..e139b3ed3 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -141,7 +141,6 @@ impl AddExistingIdentityScreen { if ui.button("Fill Random Masternode").clicked() { self.fill_random_masternode(); } - ui.add_space(10.0); } let wallets_snapshot: Vec<(String, Arc>)> = { @@ -227,6 +226,7 @@ impl AddExistingIdentityScreen { } }); + ui.add_space(10.0); if let Some(selected_wallet) = &self.selected_wallet { let wallet_still_loaded = wallets_snapshot .iter() @@ -237,10 +237,6 @@ impl AddExistingIdentityScreen { self.render_wallet_unlock_if_needed(ui); if needed_unlock && !just_unlocked { should_return_early = true; - ui.colored_label( - Color32::DARK_RED, - "Press return/enter after typing the password.", - ); } else if just_unlocked { ui.colored_label( Color32::GREEN, @@ -268,8 +264,6 @@ impl AddExistingIdentityScreen { return action; } - ui.add_space(10.0); - egui::Grid::new("add_existing_identity_grid") .num_columns(2) .spacing([10.0, 10.0]) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index d1874227c..75fd9ade8 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -167,10 +167,11 @@ impl WalletsBalancesScreen { }; if let Some(seed_hash) = selected_seed - && let Some(wallet) = wallets.get(&seed_hash) { - self.selected_wallet = Some(wallet.clone()); - return; - } + && let Some(wallet) = wallets.get(&seed_hash) + { + self.selected_wallet = Some(wallet.clone()); + return; + } self.selected_wallet = wallets.values().next().cloned(); } From dca4dc7ce49f799fe8e18c169f509f4dc9353058 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Mon, 10 Nov 2025 12:38:32 +0700 Subject: [PATCH 039/106] fix: scrollable unused asset locks on identity creation (#460) --- .../by_using_unused_asset_lock.rs | 87 ++++++++++--------- 1 file changed, 48 insertions(+), 39 deletions(-) diff --git a/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs b/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs index aea781aeb..6059a3549 100644 --- a/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs +++ b/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs @@ -21,6 +21,7 @@ impl AddNewIdentityScreen { } ui.heading("Select an unused asset lock:"); + ui.add_space(8.0); // Track the index of the currently selected asset lock (if any) let selected_index = self.funding_asset_lock.as_ref().and_then(|(_, proof, _)| { @@ -31,45 +32,53 @@ impl AddNewIdentityScreen { }); // Display the asset locks in a scrollable area - egui::ScrollArea::vertical().show(ui, |ui| { - for (index, (tx, address, amount, islock, proof)) in - wallet.unused_asset_locks.iter().enumerate() - { - ui.horizontal(|ui| { - let tx_id = tx.txid().to_string(); - let lock_amount = *amount as f64 * 1e-8; // Convert to DASH - let is_locked = if islock.is_some() { "Yes" } else { "No" }; - - // Display asset lock information with "Selected" if this one is selected - let selected_text = if Some(index) == selected_index { - " (Selected)" - } else { - "" - }; - - ui.label(format!( - "TxID: {}, Address: {}, Amount: {:.8} DASH, InstantLock: {}{}", - tx_id, address, lock_amount, is_locked, selected_text - )); - - // Button to select this asset lock - if ui.button("Select").clicked() { - // Update the selected asset lock - self.funding_asset_lock = Some(( - tx.clone(), - proof.clone().expect("Asset lock proof is required"), - address.clone(), - )); - - // Update the step to ready to create identity - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::ReadyToCreate; - } - }); - - ui.add_space(5.0); // Add space between each entry - } - }); + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .min_scrolled_height(180.0) + .show(ui, |ui| { + for (index, (tx, address, amount, islock, proof)) in + wallet.unused_asset_locks.iter().enumerate() + { + ui.group(|ui| { + ui.vertical(|ui| { + let tx_id = tx.txid().to_string(); + let lock_amount = *amount as f64 * 1e-8; // Convert to DASH + let is_locked = if islock.is_some() { "Yes" } else { "No" }; + + // Display asset lock information with "Selected" if this one is selected + if Some(index) == selected_index { + ui.colored_label( + Color32::from_rgb(0, 130, 90), + "Selected asset lock", + ); + } + + ui.label(format!("TxID: {}", tx_id)); + ui.label(format!("Address: {}", address)); + ui.label(format!("Amount: {:.8} DASH", lock_amount)); + ui.label(format!("InstantLock: {}", is_locked)); + + ui.add_space(6.0); + + // Button to select this asset lock stays visible regardless of wrapping + if ui.button("Select").clicked() { + // Update the selected asset lock + self.funding_asset_lock = Some(( + tx.clone(), + proof.clone().expect("Asset lock proof is required"), + address.clone(), + )); + + // Update the step to ready to create identity + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::ReadyToCreate; + } + }); + }); + + ui.add_space(6.0); // Add space between each entry + } + }); } pub fn render_ui_by_using_unused_asset_lock( From a6068eac04a3370b40862939f2545e79a8ddac81 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Mon, 17 Nov 2025 18:26:31 +0700 Subject: [PATCH 040/106] fix: no error display when trying to import a wallet twice on different networks (#466) * fix: no error display when trying to import a wallet twice on different networks * feat: add waiting for valid seed phrase message * fix --- src/ui/wallets/import_wallet_screen.rs | 73 +++++++++++++++++++++----- 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/src/ui/wallets/import_wallet_screen.rs b/src/ui/wallets/import_wallet_screen.rs index d2f37f169..6a28634d2 100644 --- a/src/ui/wallets/import_wallet_screen.rs +++ b/src/ui/wallets/import_wallet_screen.rs @@ -11,12 +11,12 @@ use crate::model::wallet::{ClosedKeyItem, OpenWalletSeed, Wallet, WalletSeed}; use crate::ui::wallets::add_new_wallet_screen::{ DASH_BIP44_ACCOUNT_0_PATH_MAINNET, DASH_BIP44_ACCOUNT_0_PATH_TESTNET, }; -use bip39::Mnemonic; +use bip39::{Language, Mnemonic}; use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; use dash_sdk::dpp::key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey}; -use egui::{Color32, ComboBox, Direction, Grid, Layout, RichText, Stroke, Ui, Vec2}; +use egui::{Color32, ComboBox, Direction, Frame, Grid, Layout, Margin, RichText, Stroke, Ui, Vec2}; use std::sync::atomic::Ordering; use std::sync::{Arc, RwLock}; use zxcvbn::zxcvbn; @@ -116,7 +116,7 @@ impl ImportWalletScreen { .store_wallet(&wallet, &self.app_context.network) .map_err(|e| { if e.to_string().contains("UNIQUE constraint failed: wallet.seed_hash") { - "This wallet has already been imported for another network. Each wallet can only be imported once per network. If you want to use this wallet on a different network, please switch networks first.".to_string() + "This wallet has already been imported for another network. Each wallet can only be imported once.".to_string() } else { e.to_string() } @@ -245,6 +245,35 @@ impl ScreenLike for ImportWalletScreen { action |= island_central_panel(ctx, |ui| { let mut inner_action = AppAction::None; + if let Some(error_msg) = self + .error + .clone() + .filter(|msg| !msg.contains("Invalid seed phrase")) + { + let message_color = Color32::from_rgb(255, 100, 100); + let mut dismiss_requested = false; + ui.horizontal(|ui| { + Frame::new() + .fill(message_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(Stroke::new(1.0, message_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(&error_msg).color(message_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + dismiss_requested = true; + } + }); + }); + }); + if dismiss_requested { + self.error = None; + } + ui.add_space(10.0); + } + // Add the scroll area to make the content scrollable both vertically and horizontally egui::ScrollArea::both() .auto_shrink([false; 2]) // Prevent shrinking when content is less than the available area @@ -257,9 +286,23 @@ impl ScreenLike for ImportWalletScreen { ui.heading("1. Select the seed phrase length and enter all words."); self.render_seed_phrase_input(ui); - // Check seed phrase validity whenever all words are filled - if self.seed_phrase_words.iter().all(|string| !string.is_empty()) { - match Mnemonic::parse_normalized(self.seed_phrase_words.join(" ").as_str()) { + let normalized_words: Vec = self + .seed_phrase_words + .iter() + .map(|word| word.trim().to_lowercase()) + .collect(); + let all_words_filled = normalized_words.iter().all(|word| !word.is_empty()); + let all_words_valid = all_words_filled + && normalized_words.iter().all(|word| { + Language::English + .word_list() + .binary_search(&word.as_str()) + .is_ok() + }); + + // Check seed phrase validity whenever all words are valid BIP39 words + if all_words_valid { + match Mnemonic::parse_normalized(normalized_words.join(" ").as_str()) { Ok(mnemonic) => { self.seed_phrase = Some(mnemonic); // Clear any existing seed phrase error @@ -282,12 +325,18 @@ impl ScreenLike for ImportWalletScreen { } } - // Display error message if seed phrase is invalid - if let Some(ref error_msg) = self.error - && error_msg.contains("Invalid seed phrase") { - ui.add_space(10.0); - ui.colored_label(Color32::from_rgb(255, 100, 100), error_msg); - } + ui.add_space(10.0); + + if !all_words_valid { + ui.colored_label( + Color32::from_gray(180), + "Waiting for a valid seed phrase...", + ); + } else if let Some(ref error_msg) = self.error + && error_msg.contains("Invalid seed phrase") + { + ui.colored_label(Color32::from_rgb(255, 100, 100), error_msg); + } if self.seed_phrase.is_none() { return; From 466b945527d1f8a3ac8b7094940efc0f95d39ba9 Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Tue, 16 Dec 2025 06:21:59 +0100 Subject: [PATCH 041/106] fix: logging should interpret RUST_LOG env variable (#467) --- src/database/wallet.rs | 2 +- src/logging.rs | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/database/wallet.rs b/src/database/wallet.rs index 1812de9e7..9d8ee8099 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -509,7 +509,7 @@ impl Database { identity.network = *network; tracing::trace!( - wallet_seed = ?wallet_seed_hash_array, + wallet_seed = hex::encode(wallet_seed_hash_array), wallet_alias = ?wallet.alias, identity = ?identity.identity.id().to_string(Encoding::Base58), identity_alias = ?identity.alias, diff --git a/src/logging.rs b/src/logging.rs index 4e36d79db..633e4b5f1 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -10,11 +10,12 @@ pub fn initialize_logger() { Ok(file) => file, Err(e) => panic!("Failed to create log file: {:?}", e), }; - - let filter = EnvFilter::try_new( - "info,dash_evo_tool=trace,dash_sdk=debug,tenderdash_abci=debug,drive=debug,drive_proof_verifier=debug,rs_dapi_client=debug,h2=warn", - ) - .unwrap_or_else(|e| panic!("Failed to create EnvFilter: {:?}", e)); + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { + EnvFilter::try_new( + "info,dash_evo_tool=trace,dash_sdk=debug,dash_sdk::platform::transition=trace,tenderdash_abci=debug,drive=debug,drive_proof_verifier=debug,rs_dapi_client=debug,h2=warn", + ) + .unwrap_or_else(|e| panic!("Failed to create EnvFilter: {:?}", e)) + }); let subscriber = tracing_subscriber::fmt() .with_env_filter(filter) From 9073eb3eadf0844be6fce166bc0c18d079c9960e Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:05:55 +0700 Subject: [PATCH 042/106] feat: signed dmg for mac (#437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: signed mac binaries and dmg * fix * fix * extra check * app bundle * fix * fix icon * feat: simplify icon padding to 3% for correct sizing * ok * ok * fmt * fix: split dmgs into arm64 and x86 instead of universal * cleanup * fmt * fix naming * ok * fix * disk space fix * fix * fix * fix attempt * fix: enhance disk space cleanup for macOS DMG creation - Add more aggressive cleanup of Xcode caches and derived data - Remove additional Cargo build artifacts (.rlib, .d files) - Clean system-level caches to free maximum space - Add cleanup step for both ARM64 and x86_64 macOS builds - Remove temporary icon files after app bundle creation This should resolve the "No space left on device" error during DMG creation on GitHub Actions runners. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * macos-latest * latest for arm too * revert * re-revert * clear cache * fix * cleanup * try large * omg * apply suggestion * cleanup * fix * fix * fix --------- Co-authored-by: Claude --- .github/workflows/release.yml | 579 ++++++++++++++++++++++++++-- Cargo.lock | 21 +- Cargo.toml | 3 + assets/DET_LOGO.png | Bin 0 -> 779233 bytes build.rs | 39 ++ src/components/core_zmq_listener.rs | 34 +- src/main.rs | 17 + 7 files changed, 644 insertions(+), 49 deletions(-) create mode 100644 assets/DET_LOGO.png create mode 100644 build.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0df10e6b..c708e4dfa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,19 +28,11 @@ jobs: - name: "linux-x86_64" runs-on: "ubuntu-22.04" target: "x86_64-unknown-linux-gnu" - platform: "x86_64-linux" + platform: "linux-x86_64" - name: "linux-arm64" runs-on: "ubuntu-22.04-arm" target: "aarch64-unknown-linux-gnu" - platform: "arm64-linux" - - name: "macos-x86_64" - runs-on: "macos-13" - target: "x86_64-apple-darwin" - platform: "x86_64-mac" - - name: "macos-arm64" - runs-on: "macos-latest" - target: "aarch64-apple-darwin" - platform: "arm64-mac" + platform: "linux-arm64" - name: "Windows" runs-on: "ubuntu-22.04" target: "x86_64-pc-windows-gnu" @@ -81,7 +73,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y build-essential pkg-config clang cmake unzip libsqlite3-dev gcc-mingw-w64 mingw-w64 libsqlite3-dev mingw-w64-x86-64-dev gcc-aarch64-linux-gnu zip && uname -a && cargo clean - name: Install protoc (ARM) - if: ${{ matrix.platform == 'arm64-linux' }} + if: ${{ matrix.platform == 'linux-arm64' }} run: curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v25.2/protoc-25.2-linux-aarch_64.zip && sudo unzip -o protoc-25.2-linux-aarch_64.zip -d /usr/local bin/protoc && sudo unzip -o protoc-25.2-linux-aarch_64.zip -d /usr/local 'include/*' && rm -f protoc-25.2-linux-aarch_64.zip env: PROTOC: /usr/local/bin/protoc @@ -98,18 +90,6 @@ jobs: env: PROTOC: /usr/local/bin/protoc - - name: Install protoc (Mac x64) - if: ${{ matrix.target == 'x86_64-apple-darwin' }} - run: curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v25.2/protoc-25.2-osx-x86_64.zip && sudo unzip -o protoc-25.2-osx-x86_64.zip -d /usr/local bin/protoc && sudo unzip -o protoc-25.2-osx-x86_64.zip -d /usr/local 'include/*' && rm -f protoc-25.2-osx-x86_64.zip && uname -a - env: - PROTOC: /usr/local/bin/protoc - - - name: Install protoc (Mac ARM) - if: ${{ matrix.target == 'aarch64-apple-darwin' }} - run: curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v25.2/protoc-25.2-osx-aarch_64.zip && sudo unzip -o protoc-25.2-osx-aarch_64.zip -d /usr/local bin/protoc && sudo unzip -o protoc-25.2-osx-aarch_64.zip -d /usr/local 'include/*' && rm -f protoc-25.2-osx-aarch_64.zip - env: - PROTOC: /usr/local/bin/protoc - - name: Windows libsql if: ${{ matrix.target == 'x86_64-pc-windows-gnu' }} run: curl -OL https://www.sqlite.org/2024/sqlite-dll-win-x64-3460100.zip && sudo unzip -o sqlite-dll-win-x64-3460100.zip -d winlibs && sudo chown -R runner:docker winlibs/ && pwd && ls -lah && cd winlibs && x86_64-w64-mingw32-dlltool -d sqlite3.def -l libsqlite3.a && ls -lah && cd .. @@ -138,27 +118,556 @@ jobs: name: dash-evo-tool-${{ matrix.platform }}.zip path: dash-evo-tool-${{ matrix.platform }}.zip + build-macos-arm64: + name: Build macOS ARM64 (Signed & Notarized) + runs-on: macos-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Add Rust target + run: rustup target add aarch64-apple-darwin + + - name: Initial disk cleanup + run: | + echo "Disk usage before initial cleanup:" + df -h + + # Clean up homebrew cache + brew cleanup --prune=all || true + + # Remove Xcode caches + sudo rm -rf ~/Library/Developer/CoreSimulator/Caches/dyld 2>/dev/null || true + sudo rm -rf ~/Library/Developer/Xcode/DerivedData 2>/dev/null || true + sudo rm -rf ~/Library/Caches/com.apple.dt.Xcode 2>/dev/null || true + + # Clean system caches + sudo rm -rf /Library/Caches/* 2>/dev/null || true + sudo rm -rf /System/Library/Caches/* 2>/dev/null || true + sudo rm -rf /private/var/folders/* 2>/dev/null || true + + echo "Disk usage after initial cleanup:" + df -h + + - name: Install protoc + run: | + brew install protobuf + protoc --version + + - name: Build ARM64 architecture + run: | + cargo build --release --target aarch64-apple-darwin + mkdir -p build + cp target/aarch64-apple-darwin/release/dash-evo-tool build/dash-evo-tool + chmod +x build/dash-evo-tool + + # Targeted cleanup - only remove build artifacts we don't need + rm -rf target/aarch64-apple-darwin/release/deps + rm -rf target/aarch64-apple-darwin/release/build + rm -rf target/aarch64-apple-darwin/release/incremental + rm -rf target/aarch64-apple-darwin/release/.fingerprint + rm -rf target/aarch64-apple-darwin/debug + rm -rf target/debug + + # Remove the actual binary from target since we copied it + rm -f target/aarch64-apple-darwin/release/dash-evo-tool + + # Create app bundle structure + mkdir -p "build/Dash Evo Tool.app/Contents/MacOS" + mkdir -p "build/Dash Evo Tool.app/Contents/Resources" + + # Move binary into app bundle + cp build/dash-evo-tool "build/Dash Evo Tool.app/Contents/MacOS/dash-evo-tool" + + # Create icon set and convert to ICNS + mkdir -p AppIcon.iconset + + # Create all required icon sizes from the logo (which already has 8% padding) + sips -z 16 16 assets/DET_LOGO.png --out AppIcon.iconset/icon_16x16.png + sips -z 32 32 assets/DET_LOGO.png --out AppIcon.iconset/icon_16x16@2x.png + sips -z 32 32 assets/DET_LOGO.png --out AppIcon.iconset/icon_32x32.png + sips -z 64 64 assets/DET_LOGO.png --out AppIcon.iconset/icon_32x32@2x.png + sips -z 128 128 assets/DET_LOGO.png --out AppIcon.iconset/icon_128x128.png + sips -z 256 256 assets/DET_LOGO.png --out AppIcon.iconset/icon_128x128@2x.png + sips -z 256 256 assets/DET_LOGO.png --out AppIcon.iconset/icon_256x256.png + sips -z 512 512 assets/DET_LOGO.png --out AppIcon.iconset/icon_256x256@2x.png + sips -z 512 512 assets/DET_LOGO.png --out AppIcon.iconset/icon_512x512.png + sips -z 1024 1024 assets/DET_LOGO.png --out AppIcon.iconset/icon_512x512@2x.png + iconutil -c icns AppIcon.iconset + cp AppIcon.icns "build/Dash Evo Tool.app/Contents/Resources/AppIcon.icns" + + # Create Info.plist + cat > "build/Dash Evo Tool.app/Contents/Info.plist" < + + + + CFBundleExecutable + dash-evo-tool + CFBundleIconFile + AppIcon + CFBundleIdentifier + org.dash.evo-tool + CFBundleName + Dash Evo Tool + CFBundleDisplayName + Dash Evo Tool + CFBundleVersion + 1.0.0 + CFBundleShortVersionString + 1.0.0 + CFBundlePackageType + APPL + CFBundleSignature + ???? + LSMinimumSystemVersion + 10.13 + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + + + EOF + + - name: Import signing certificates + uses: Apple-Actions/import-codesign-certs@v3 + with: + p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} + p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + + - name: Resolve signing identity + id: signid + run: | + ID=$(security find-identity -v -p codesigning | grep "Developer ID Application" | sed -E 's/.*"(.+)"/\1/' | head -n1) + echo "IDENTITY=$ID" >> "$GITHUB_OUTPUT" + + - name: Code sign app bundle with hardened runtime and timestamp + run: | + # Create entitlements file + cat > entitlements.plist < + + + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-jit + + com.apple.security.cs.disable-library-validation + + + + EOF + + # Sign the app bundle (deep signing to get all components) + codesign --force --deep --options runtime --timestamp \ + --sign "${{ steps.signid.outputs.IDENTITY }}" \ + --entitlements entitlements.plist \ + "build/Dash Evo Tool.app" + + # Verify the signature + codesign --verify --deep --strict --verbose=2 "build/Dash Evo Tool.app" + + - name: Free up disk space before DMG creation + run: | + echo "Disk usage before cleanup:" + df -h + du -sh ~/* 2>/dev/null | sort -rh | head -20 + + # Remove the ENTIRE target directory since we already copied the binary + rm -rf target + + # Remove the entire Cargo directory + rm -rf ~/.cargo + + # Clean up homebrew completely + brew cleanup --prune=all + rm -rf $(brew --cache) + + # Remove any unnecessary Xcode simulators and caches + sudo rm -rf ~/Library/Developer/CoreSimulator 2>/dev/null || true + sudo rm -rf ~/Library/Developer/Xcode 2>/dev/null || true + sudo rm -rf ~/Library/Caches 2>/dev/null || true + + # Remove temporary icon files after creating the app bundle + rm -rf AppIcon.iconset 2>/dev/null || true + + # Clean system caches more aggressively + sudo rm -rf /Library/Caches/* 2>/dev/null || true + sudo rm -rf /System/Library/Caches/* 2>/dev/null || true + sudo rm -rf /private/var/folders/* 2>/dev/null || true + sudo rm -rf /Users/runner/Library/Caches/* 2>/dev/null || true + + # Remove any iOS simulators and SDKs we don't need + sudo rm -rf /Library/Developer/CoreSimulator 2>/dev/null || true + sudo rm -rf /Applications/Xcode*.app/Contents/Developer/Platforms/iPhoneOS.platform 2>/dev/null || true + sudo rm -rf /Applications/Xcode*.app/Contents/Developer/Platforms/AppleTVOS.platform 2>/dev/null || true + sudo rm -rf /Applications/Xcode*.app/Contents/Developer/Platforms/WatchOS.platform 2>/dev/null || true + + echo "Disk usage after cleanup:" + df -h + du -sh ~/* 2>/dev/null | sort -rh | head -20 + + - name: Create DMG + run: | + # Get app size for sparse image + APP_SIZE=$(du -sm "build/Dash Evo Tool.app" | cut -f1) + DMG_SIZE=$((APP_SIZE + 50)) # Add 50MB padding + + # Create a sparse image instead of using srcfolder + # Sparse images only use disk space as needed + hdiutil create -size ${DMG_SIZE}m -type SPARSE -fs HFS+ -volname "Dash Evo Tool" temp.sparseimage + + # Mount the sparse image + hdiutil mount temp.sparseimage -mountpoint /Volumes/"Dash Evo Tool" + + # Copy app to mounted volume + cp -r "build/Dash Evo Tool.app" /Volumes/"Dash Evo Tool"/ + ln -s /Applications /Volumes/"Dash Evo Tool"/Applications + + # Remove macOS metadata directories that get created automatically + rm -rf /Volumes/"Dash Evo Tool"/.fseventsd + rm -rf /Volumes/"Dash Evo Tool"/.Spotlight-V100 + rm -f /Volumes/"Dash Evo Tool"/.DS_Store + + # Unmount + hdiutil detach /Volumes/"Dash Evo Tool" + + # Convert sparse image to compressed DMG + hdiutil convert temp.sparseimage -format UDZO -o dash-evo-tool-macos-arm64.dmg + + # Clean up sparse image + rm -f temp.sparseimage + + # Sign the DMG + codesign --force --sign "${{ steps.signid.outputs.IDENTITY }}" dash-evo-tool-macos-arm64.dmg + + - name: Validate Apple credentials + run: | + if [ -z "${{ secrets.APPLE_ID }}" ]; then + echo "Error: APPLE_ID secret is not set" + exit 1 + fi + if [ -z "${{ secrets.APPLE_TEAM_ID }}" ]; then + echo "Error: APPLE_TEAM_ID secret is not set" + exit 1 + fi + if [ -z "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" ]; then + echo "Error: APPLE_APP_SPECIFIC_PASSWORD secret is not set" + exit 1 + fi + echo "Apple credentials validation passed" + + - name: Notarize DMG + run: | + echo "Submitting DMG for notarization..." + xcrun notarytool submit dash-evo-tool-macos-arm64.dmg \ + --apple-id "${{ secrets.APPLE_ID }}" \ + --team-id "${{ secrets.APPLE_TEAM_ID }}" \ + --password "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" \ + --wait --verbose + + echo "Stapling notarization ticket..." + xcrun stapler staple dash-evo-tool-macos-arm64.dmg + + - name: Attest + uses: actions/attest-build-provenance@v1 + with: + subject-path: 'dash-evo-tool-macos-arm64.dmg' + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: dash-evo-tool-macos-arm64.dmg + path: dash-evo-tool-macos-arm64.dmg + + build-macos-x86: + name: Build macOS x86_64 (Signed & Notarized) + runs-on: macos-latest + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Add Rust target + run: rustup target add x86_64-apple-darwin + + - name: Initial disk cleanup + run: | + echo "Disk usage before initial cleanup:" + df -h + + # Clean up homebrew cache + brew cleanup --prune=all || true + + # Remove Xcode caches + sudo rm -rf ~/Library/Developer/CoreSimulator/Caches/dyld 2>/dev/null || true + sudo rm -rf ~/Library/Developer/Xcode/DerivedData 2>/dev/null || true + sudo rm -rf ~/Library/Caches/com.apple.dt.Xcode 2>/dev/null || true + + # Clean system caches + sudo rm -rf /Library/Caches/* 2>/dev/null || true + sudo rm -rf /System/Library/Caches/* 2>/dev/null || true + sudo rm -rf /private/var/folders/* 2>/dev/null || true + + echo "Disk usage after initial cleanup:" + df -h + + - name: Install protoc + run: | + brew install protobuf + protoc --version + + - name: Build x86_64 architecture + run: | + cargo build --release --target x86_64-apple-darwin + mkdir -p build + cp target/x86_64-apple-darwin/release/dash-evo-tool build/dash-evo-tool + chmod +x build/dash-evo-tool + + # Targeted cleanup - only remove build artifacts we don't need + rm -rf target/x86_64-apple-darwin/release/deps + rm -rf target/x86_64-apple-darwin/release/build + rm -rf target/x86_64-apple-darwin/release/incremental + rm -rf target/x86_64-apple-darwin/release/.fingerprint + rm -rf target/x86_64-apple-darwin/debug + rm -rf target/debug + + # Remove the actual binary from target since we copied it + rm -f target/x86_64-apple-darwin/release/dash-evo-tool + + # Create app bundle structure + mkdir -p "build/Dash Evo Tool.app/Contents/MacOS" + mkdir -p "build/Dash Evo Tool.app/Contents/Resources" + + # Move binary into app bundle + cp build/dash-evo-tool "build/Dash Evo Tool.app/Contents/MacOS/dash-evo-tool" + + # Create icon set and convert to ICNS + mkdir -p AppIcon.iconset + + # Create all required icon sizes from the logo (which already has 8% padding) + sips -z 16 16 assets/DET_LOGO.png --out AppIcon.iconset/icon_16x16.png + sips -z 32 32 assets/DET_LOGO.png --out AppIcon.iconset/icon_16x16@2x.png + sips -z 32 32 assets/DET_LOGO.png --out AppIcon.iconset/icon_32x32.png + sips -z 64 64 assets/DET_LOGO.png --out AppIcon.iconset/icon_32x32@2x.png + sips -z 128 128 assets/DET_LOGO.png --out AppIcon.iconset/icon_128x128.png + sips -z 256 256 assets/DET_LOGO.png --out AppIcon.iconset/icon_128x128@2x.png + sips -z 256 256 assets/DET_LOGO.png --out AppIcon.iconset/icon_256x256.png + sips -z 512 512 assets/DET_LOGO.png --out AppIcon.iconset/icon_256x256@2x.png + sips -z 512 512 assets/DET_LOGO.png --out AppIcon.iconset/icon_512x512.png + sips -z 1024 1024 assets/DET_LOGO.png --out AppIcon.iconset/icon_512x512@2x.png + iconutil -c icns AppIcon.iconset + cp AppIcon.icns "build/Dash Evo Tool.app/Contents/Resources/AppIcon.icns" + + # Create Info.plist + cat > "build/Dash Evo Tool.app/Contents/Info.plist" < + + + + CFBundleExecutable + dash-evo-tool + CFBundleIconFile + AppIcon + CFBundleIdentifier + org.dash.evo-tool + CFBundleName + Dash Evo Tool + CFBundleDisplayName + Dash Evo Tool + CFBundleVersion + 1.0.0 + CFBundleShortVersionString + 1.0.0 + CFBundlePackageType + APPL + CFBundleSignature + ???? + LSMinimumSystemVersion + 10.13 + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + + + EOF + + - name: Import signing certificates + uses: Apple-Actions/import-codesign-certs@v3 + with: + p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} + p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + + - name: Resolve signing identity + id: signid + run: | + ID=$(security find-identity -v -p codesigning | grep "Developer ID Application" | sed -E 's/.*"(.+)"/\1/' | head -n1) + echo "IDENTITY=$ID" >> "$GITHUB_OUTPUT" + + - name: Code sign app bundle with hardened runtime and timestamp + run: | + # Create entitlements file + cat > entitlements.plist < + + + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-jit + + com.apple.security.cs.disable-library-validation + + + + EOF + + # Sign the app bundle (deep signing to get all components) + codesign --force --deep --options runtime --timestamp \ + --sign "${{ steps.signid.outputs.IDENTITY }}" \ + --entitlements entitlements.plist \ + "build/Dash Evo Tool.app" + + # Verify the signature + codesign --verify --deep --strict --verbose=2 "build/Dash Evo Tool.app" + + - name: Free up disk space before DMG creation + run: | + echo "Disk usage before cleanup:" + df -h + du -sh ~/* 2>/dev/null | sort -rh | head -20 + + # Remove the ENTIRE target directory since we already copied the binary + rm -rf target + + # Remove the entire Cargo directory + rm -rf ~/.cargo + + # Clean up homebrew completely + brew cleanup --prune=all + rm -rf $(brew --cache) + + # Remove any unnecessary Xcode simulators and caches + sudo rm -rf ~/Library/Developer/CoreSimulator 2>/dev/null || true + sudo rm -rf ~/Library/Developer/Xcode 2>/dev/null || true + sudo rm -rf ~/Library/Caches 2>/dev/null || true + + # Remove temporary icon files after creating the app bundle + rm -rf AppIcon.iconset 2>/dev/null || true + + # Clean system caches more aggressively + sudo rm -rf /Library/Caches/* 2>/dev/null || true + sudo rm -rf /System/Library/Caches/* 2>/dev/null || true + sudo rm -rf /private/var/folders/* 2>/dev/null || true + sudo rm -rf /Users/runner/Library/Caches/* 2>/dev/null || true + + # Remove any iOS simulators and SDKs we don't need + sudo rm -rf /Library/Developer/CoreSimulator 2>/dev/null || true + sudo rm -rf /Applications/Xcode*.app/Contents/Developer/Platforms/iPhoneOS.platform 2>/dev/null || true + sudo rm -rf /Applications/Xcode*.app/Contents/Developer/Platforms/AppleTVOS.platform 2>/dev/null || true + sudo rm -rf /Applications/Xcode*.app/Contents/Developer/Platforms/WatchOS.platform 2>/dev/null || true + + echo "Disk usage after cleanup:" + df -h + du -sh ~/* 2>/dev/null | sort -rh | head -20 + + - name: Create DMG + run: | + # Get app size for sparse image + APP_SIZE=$(du -sm "build/Dash Evo Tool.app" | cut -f1) + DMG_SIZE=$((APP_SIZE + 50)) # Add 50MB padding + + # Create a sparse image instead of using srcfolder + # Sparse images only use disk space as needed + hdiutil create -size ${DMG_SIZE}m -type SPARSE -fs HFS+ -volname "Dash Evo Tool" temp.sparseimage + + # Mount the sparse image + hdiutil mount temp.sparseimage -mountpoint /Volumes/"Dash Evo Tool" + + # Copy app to mounted volume + cp -r "build/Dash Evo Tool.app" /Volumes/"Dash Evo Tool"/ + ln -s /Applications /Volumes/"Dash Evo Tool"/Applications + + # Unmount + hdiutil detach /Volumes/"Dash Evo Tool" + + # Convert sparse image to compressed DMG + hdiutil convert temp.sparseimage -format UDZO -o dash-evo-tool-macos-x86_64.dmg + + # Clean up sparse image + rm -f temp.sparseimage + + # Sign the DMG + codesign --force --sign "${{ steps.signid.outputs.IDENTITY }}" dash-evo-tool-macos-x86_64.dmg + + - name: Validate Apple credentials + run: | + if [ -z "${{ secrets.APPLE_ID }}" ]; then + echo "Error: APPLE_ID secret is not set" + exit 1 + fi + if [ -z "${{ secrets.APPLE_TEAM_ID }}" ]; then + echo "Error: APPLE_TEAM_ID secret is not set" + exit 1 + fi + if [ -z "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" ]; then + echo "Error: APPLE_APP_SPECIFIC_PASSWORD secret is not set" + exit 1 + fi + echo "Apple credentials validation passed" + + - name: Notarize DMG + run: | + echo "Submitting DMG for notarization..." + xcrun notarytool submit dash-evo-tool-macos-x86_64.dmg \ + --apple-id "${{ secrets.APPLE_ID }}" \ + --team-id "${{ secrets.APPLE_TEAM_ID }}" \ + --password "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" \ + --wait --verbose + + echo "Stapling notarization ticket..." + xcrun stapler staple dash-evo-tool-macos-x86_64.dmg + + - name: Attest + uses: actions/attest-build-provenance@v1 + with: + subject-path: 'dash-evo-tool-macos-x86_64.dmg' + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: dash-evo-tool-macos-x86_64.dmg + path: dash-evo-tool-macos-x86_64.dmg + release: name: Create GitHub Release - needs: build-and-release + needs: [build-and-release, build-macos-arm64, build-macos-x86] runs-on: ubuntu-latest steps: - name: Download Linux AMD64 Artifact uses: actions/download-artifact@v4 with: - name: dash-evo-tool-x86_64-linux.zip + name: dash-evo-tool-linux-x86_64.zip - name: Download Linux Arm64 Artifact uses: actions/download-artifact@v4 with: - name: dash-evo-tool-arm64-linux.zip - - name: Download MacOS AMD64 Artifact + name: dash-evo-tool-linux-arm64.zip + - name: Download macOS ARM64 Artifact uses: actions/download-artifact@v4 with: - name: dash-evo-tool-x86_64-mac.zip - - name: Download MacOS ARM64 Artifact + name: dash-evo-tool-macos-arm64.dmg + - name: Download macOS x86_64 Artifact uses: actions/download-artifact@v4 with: - name: dash-evo-tool-arm64-mac.zip + name: dash-evo-tool-macos-x86_64.dmg - name: Download Windows Artifact uses: actions/download-artifact@v4 with: @@ -171,10 +680,10 @@ jobs: with: tag_name: ${{ github.event.inputs.tag }} files: | - ./dash-evo-tool-x86_64-linux.zip - ./dash-evo-tool-arm64-linux.zip - ./dash-evo-tool-x86_64-mac.zip - ./dash-evo-tool-arm64-mac.zip + ./dash-evo-tool-linux-x86_64.zip + ./dash-evo-tool-linux-arm64.zip + ./dash-evo-tool-macos-arm64.dmg + ./dash-evo-tool-macos-x86_64.dmg ./dash-evo-tool-windows.zip draft: false - prerelease: true \ No newline at end of file + prerelease: true diff --git a/Cargo.lock b/Cargo.lock index 71a7c7e63..79a30de98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1800,6 +1800,7 @@ dependencies = [ "tracing-subscriber", "tz-rs", "which 8.0.0", + "winres", "zeroize", "zeromq", "zmq", @@ -7260,7 +7261,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml", + "toml 0.8.23", "version-compare", ] @@ -7640,6 +7641,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "0.8.23" @@ -9409,6 +9419,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winres" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c" +dependencies = [ + "toml 0.5.11", +] + [[package]] name = "winsafe" version = "0.0.19" diff --git a/Cargo.toml b/Cargo.toml index d54a8ed1d..fad7a8570 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,9 @@ raw-cpuid = "11.5.0" tempfile = { version = "3.20.0" } egui_kittest = { version = "0.32.0", features = ["eframe"] } +[build-dependencies] +winres = "0.1" + [lints.clippy] uninlined_format_args = "allow" diff --git a/assets/DET_LOGO.png b/assets/DET_LOGO.png new file mode 100644 index 0000000000000000000000000000000000000000..5c2010628a9baedba376dc548a7e6b4e181c1fc4 GIT binary patch literal 779233 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%0DVOQP2cEEy7wWq0h-#qq`u*RGqIMVg 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 z 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⁡$8te$zHvck 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�DYs_xSi0?ig+~oMr5UvicL+&c@ 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

  • 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 z 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ⅈ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
      tJsrH{|-(9TUWA;%3J0Sw!e%rY< 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 literal 0 HcmV?d00001 diff --git a/build.rs b/build.rs new file mode 100644 index 000000000..4e6bfb5c6 --- /dev/null +++ b/build.rs @@ -0,0 +1,39 @@ +use std::env; +use std::path::Path; + +fn main() { + println!("cargo:rerun-if-changed=assets/DET_LOGO.ico"); + + let target = env::var("TARGET").unwrap_or_default(); + if !target.contains("windows") { + return; + } + + let icon_path = Path::new("assets/DET_LOGO.ico"); + if !icon_path.exists() { + eprintln!( + "cargo:warning=Windows icon asset missing at {}", + icon_path.display() + ); + return; + } + + let mut res = winres::WindowsResource::new(); + let icon_str = icon_path + .to_str() + .expect("icon path must be valid UTF-8 for the resource compiler"); + res.set_icon(icon_str); + + if let Ok(version) = env::var("CARGO_PKG_VERSION") { + res.set("FileVersion", &version); + res.set("ProductVersion", &version); + } + + if let Ok(product_name) = env::var("CARGO_PKG_NAME") { + res.set("ProductName", &product_name); + } + + if let Err(err) = res.compile() { + panic!("Failed to embed Windows resources: {err}"); + } +} diff --git a/src/components/core_zmq_listener.rs b/src/components/core_zmq_listener.rs index 5b8d6bdab..cf68515e8 100644 --- a/src/components/core_zmq_listener.rs +++ b/src/components/core_zmq_listener.rs @@ -344,19 +344,27 @@ impl CoreZMQListener { let mut cursor = Cursor::new(data_bytes); match Block::consensus_decode(&mut cursor) { Ok(block) => { - if let Some(ref tx) = tx_zmq_status { - // ZMQ refresh socket connected status - tx.send(ZMQConnectionEvent::Connected) - .expect("Failed to send connected event"); - } - if let Err(e) = sender.send(( - ZMQMessage::ChainLockedBlock(block), - network, - )) { - eprintln!( - "Error sending data to main thread: {}", - e - ); + match ChainLock::consensus_decode(&mut cursor) { + Ok(chain_lock) => { + // Send the ChainLock and Network back to the main thread + if let Err(e) = sender.send(( + ZMQMessage::ChainLockedBlock( + block, chain_lock, + ), + network, + )) { + eprintln!( + "Error sending data to main thread: {}", + e + ); + } + } + Err(e) => { + eprintln!( + "Error deserializing ChainLock: {}", + e + ); + } } } Err(e) => { diff --git a/src/main.rs b/src/main.rs index 1ae6c3f09..13df84cb6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,11 +25,28 @@ fn main() -> eframe::Result<()> { runtime.block_on(start(&app_data_dir)) } +fn load_icon() -> egui::IconData { + let icon_bytes = include_bytes!("../assets/DET_LOGO.png"); + let image = image::load_from_memory(icon_bytes) + .expect("Failed to load icon") + .to_rgba8(); + let (width, height) = image.dimensions(); + egui::IconData { + rgba: image.into_raw(), + width, + height, + } +} + async fn start(app_data_dir: &std::path::Path) -> Result<(), eframe::Error> { + // Load icon for the window + let icon_data = load_icon(); + let native_options = eframe::NativeOptions { persist_window: true, // Persist window size and position centered: true, // Center window on startup if not maximized persistence_path: Some(app_data_dir.join("app.ron")), + viewport: egui::ViewportBuilder::default().with_icon(icon_data), ..Default::default() }; From 82399a26534533f887baa02e6d6565544b5c53ba Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Tue, 20 Jan 2026 00:18:02 +0700 Subject: [PATCH 043/106] feat: HD wallet system, DashPay integration, SPV support, and more (#464) * feat: dashpay * ok * ok * remove doc * ok * ok * database * ok * fix: use proper BackendTaskSuccessResults rather than Message * fmt and clippy * remove dashpay dip file * logo and cleanup * fix top panel spacing * ui stuff * fmt * dip 14 * image fetching * some fixes * cleaning up * todos * feat: SPV phase 1 (#440) * feat: spv in det * ui * progress bars * working progress updates * fmt * fast sync * add back progress monitoring * back to git dep * newer dashcore version * deduplication * spv context provider * fixes * small fix * clippy fixes * fixes --------- Co-authored-by: Quantum Explorer * feat: spv progress bars and sync from genesis when wallets are loaded (#454) * feat: spv progress bars * feat: start from 0 if syncing with wallets * clippy * fmt * update * fixes * fix: add sdk features * feat: add accounts and transactions to wallet screen * clippy * fmt * fix: dashpay database table initialization was hanging on new db (#463) * fix: display stuff * add accounts and much progress * feat: transaction history in wallet * clippy * ok * feat: hd wallet * send tx via wallet and wallet lock/unlock * update to PeerNetworkManager * ok * ok * ok * fmt * clippy * clippy * feat: clear SPV data button * fix: switch from dash core rpc to spv mode * feat: switch to DashSpvClientInterface * clean spv button fix * feat: send from wallet screen * feat: platform addresses * feat: platform address transitions * feat: platform address transitions * feat: move dashpay screen to top level * platform addresses sdk * remove async context provider fn * lock * update for new sdk with addresses and handle sdk result data * chore: update grovestark dependency * chore: update dash-sdk rev * fmt * remove elliptic curves patch * clippy * feat: use BackendTaskSuccessResult variants instead of Message(String) * fmt * clippy * update DIP18 implementation, fixes and some features around wallets and PAs * fix * wallet unlock on wallet screen * info popup component * small fixes * wallet unlock component * fix startup panic * welcome screen and dashpay stuff * cargo fix * cargo fmt * chore: update deps to use most recent dash-sdk * simplify welcome screen and move spv storage clear to advanced settings * default connect to peers via SPV DNS seeds * fix: exit app without waiting * fix error message and reverse quorum hash on lookup * fmt * settings to turn off spv auto sync * clippy * work on getting started flow * AddressProvider stuff * address sync stuff * many fixes all in one * fix * many fixes * fixes * amount input stuff * alignment in identity create screen for adding keys * more fixes * many improvements * simple token creator, word count selection for wallet seed phrases, more * fix: platform address info storage * feat: generate platform addresses and address sync post-checkpoint * many things * chore: update to recent changes in platform * docs: added few lines about .env file * fix: banned addresses and failure during address sync * fix: updates for new platform and fee estimation * fix: only take PA credits after checkpoint * feat: fee estimation and display * feat: fee estimation follow-up * more on fee displays * fix: proof logs in data contract create * fix: white on white text for fee estimation display * fix: always start as closed for advanced settings dropdown in settings screen * fix: hard to see data contracts in the register contract screen * fix: document create screen scroll * fix: fee estimations accuracy * fmt * clippy auto fixes * clippy manual fixes and pin dash-sdk dep * fix failing tests * fmt * fix: update rust toolchain to 1.92 * refactor: hide SPV behind dev mode * feat: warning about SPV being experimental * cleanup based on claude review * fmt * cleanup based on another claude review * fix: kittest failing * fix: remove fee result display in success screens and clean up dashpay avatar display * dashpay fixes and platform address fixes * refactor: move some AppContext functions to backend_task module * clippy * clippy * fix: refresh mode alignment in wallet screen and fmt * fix: failing test due to test_db issue * fix: dashpay avatar loading slow * fix: add rocksdb deps to ci workflow * fix: free up disk space in ci workflow * fix: display warning when partial wallet refresh success occurs * fix: propogate error for terminal balance sync * feat: more aggressive disk cleanup in ci workflow --------- Co-authored-by: Quantum Explorer Co-authored-by: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Co-authored-by: Ivan Shumkov --- .env.example | 8 +- .github/workflows/clippy.yml | 32 +- .github/workflows/release.yml | 31 +- .github/workflows/tests.yml | 30 +- .gitignore | 2 +- CLAUDE.md | 111 - Cargo.lock | 2154 ++++----- Cargo.toml | 25 +- README.md | 10 + dash_core_configs/devnet.conf | 4 +- icons/dashlogo.svg | 1 + icons/dashpay.png | Bin 0 -> 1809 bytes rust-toolchain.toml | 2 +- snap/snapcraft.yaml | 2 +- src/app.rs | 293 +- .../broadcast_state_transition.rs | 4 +- src/backend_task/contested_names/mod.rs | 4 +- .../query_dpns_contested_resources.rs | 4 +- src/backend_task/contract.rs | 18 +- src/backend_task/core/mod.rs | 505 ++- src/backend_task/core/recover_asset_locks.rs | 388 ++ .../core/refresh_single_key_wallet_info.rs | 91 + src/backend_task/core/refresh_wallet_info.rs | 294 +- .../core/send_single_key_wallet_payment.rs | 255 ++ src/backend_task/core/start_dash_qt.rs | 30 +- src/backend_task/dashpay.rs | 238 + .../dashpay/auto_accept_handler.rs | 121 + src/backend_task/dashpay/auto_accept_proof.rs | 331 ++ src/backend_task/dashpay/avatar_processing.rs | 346 ++ src/backend_task/dashpay/contact_info.rs | 524 +++ src/backend_task/dashpay/contact_requests.rs | 685 +++ src/backend_task/dashpay/contacts.rs | 520 +++ src/backend_task/dashpay/dip14_derivation.rs | 377 ++ src/backend_task/dashpay/encryption.rs | 272 ++ src/backend_task/dashpay/encryption_tests.rs | 174 + src/backend_task/dashpay/errors.rs | 258 ++ src/backend_task/dashpay/hd_derivation.rs | 188 + src/backend_task/dashpay/incoming_payments.rs | 359 ++ src/backend_task/dashpay/payments.rs | 422 ++ src/backend_task/dashpay/profile.rs | 532 +++ src/backend_task/dashpay/validation.rs | 415 ++ src/backend_task/document.rs | 61 +- .../identity/add_key_to_identity.rs | 62 +- .../identity/discover_identities.rs | 318 ++ src/backend_task/identity/load_identity.rs | 21 +- .../identity/load_identity_by_dpns_name.rs | 180 + .../identity/load_identity_from_wallet.rs | 72 +- src/backend_task/identity/mod.rs | 254 +- src/backend_task/identity/refresh_identity.rs | 4 +- .../refresh_loaded_identities_dpns_names.rs | 10 +- .../identity/register_dpns_name.rs | 50 +- .../identity/register_identity.rs | 494 +- src/backend_task/identity/top_up_identity.rs | 383 +- src/backend_task/identity/transfer.rs | 32 +- .../identity/withdraw_from_identity.rs | 98 +- src/backend_task/mod.rs | 215 +- src/backend_task/platform_info.rs | 56 + src/backend_task/register_contract.rs | 106 +- src/backend_task/tokens/burn_tokens.rs | 10 +- src/backend_task/tokens/claim_tokens.rs | 8 +- .../tokens/destroy_frozen_funds.rs | 15 +- src/backend_task/tokens/freeze_tokens.rs | 53 +- src/backend_task/tokens/mint_tokens.rs | 8 +- src/backend_task/tokens/mod.rs | 22 +- src/backend_task/tokens/pause_tokens.rs | 13 +- src/backend_task/tokens/purchase_tokens.rs | 92 +- .../tokens/query_my_token_balances.rs | 8 +- src/backend_task/tokens/resume_tokens.rs | 15 +- src/backend_task/tokens/set_token_price.rs | 56 +- src/backend_task/tokens/transfer_tokens.rs | 9 +- src/backend_task/tokens/unfreeze_tokens.rs | 53 +- .../tokens/update_token_config.rs | 22 +- src/backend_task/update_data_contract.rs | 77 +- .../wallet/fetch_platform_address_balances.rs | 548 +++ .../fund_platform_address_from_asset_lock.rs | 154 + ...fund_platform_address_from_wallet_utxos.rs | 194 + .../wallet/generate_receive_address.rs | 47 + src/backend_task/wallet/mod.rs | 78 + .../wallet/transfer_platform_credits.rs | 48 + .../wallet/withdraw_from_platform_address.rs | 62 + src/config.rs | 2 +- src/context.rs | 846 +++- src/context_provider.rs | 56 +- src/context_provider_spv.rs | 142 + src/database/asset_lock_transaction.rs | 5 +- src/database/contacts.rs | 153 + src/database/dashpay.rs | 934 ++++ src/database/identities.rs | 11 +- src/database/initialization.rs | 382 +- src/database/mod.rs | 108 + src/database/settings.rs | 407 +- src/database/single_key_wallet.rs | 265 ++ src/database/utxo.rs | 4 +- src/database/wallet.rs | 555 ++- src/lib.rs | 2 + src/logging.rs | 2 +- src/main.rs | 2 +- src/model/amount.rs | 2 +- src/model/fee_estimation.rs | 607 +++ src/model/mod.rs | 1 + .../encrypted_key_storage.rs | 18 + src/model/qualified_identity/mod.rs | 179 +- src/model/settings.rs | 76 +- src/model/wallet/asset_lock_transaction.rs | 48 + src/model/wallet/encryption.rs | 10 +- src/model/wallet/mod.rs | 1731 ++++++- src/model/wallet/single_key.rs | 427 ++ src/model/wallet/utxos.rs | 24 +- src/spv/error.rs | 58 + src/spv/manager.rs | 1190 +++++ src/spv/mod.rs | 5 + src/ui/components/amount_input.rs | 16 +- src/ui/components/contract_chooser_panel.rs | 1 + ....rs => dashpay_subscreen_chooser_panel.rs} | 116 +- .../dpns_subscreen_chooser_panel.rs | 7 +- src/ui/components/entropy_grid.rs | 28 +- src/ui/components/identity_selector.rs | 22 +- src/ui/components/info_popup.rs | 195 + src/ui/components/left_panel.rs | 124 +- src/ui/components/mod.rs | 4 +- .../tokens_subscreen_chooser_panel.rs | 7 +- .../tools_subscreen_chooser_panel.rs | 33 +- src/ui/components/top_panel.rs | 65 +- src/ui/components/wallet_unlock.rs | 41 +- src/ui/components/wallet_unlock_popup.rs | 270 ++ .../add_contracts_screen.rs | 75 +- .../contracts_documents_screen.rs | 11 +- .../dashpay_coming_soon_screen.rs | 60 - .../document_action_screen.rs | 373 +- .../group_actions_screen.rs | 28 +- src/ui/contracts_documents/mod.rs | 1 - .../register_contract_screen.rs | 556 ++- .../update_contract_screen.rs | 367 +- src/ui/dashpay/add_contact_screen.rs | 696 +++ src/ui/dashpay/contact_details.rs | 466 ++ src/ui/dashpay/contact_info_editor.rs | 391 ++ src/ui/dashpay/contact_profile_viewer.rs | 758 ++++ src/ui/dashpay/contact_requests.rs | 1011 +++++ src/ui/dashpay/contacts_list.rs | 1184 +++++ src/ui/dashpay/dashpay_screen.rs | 200 + src/ui/dashpay/mod.rs | 97 + src/ui/dashpay/profile_screen.rs | 1523 +++++++ src/ui/dashpay/profile_search.rs | 381 ++ src/ui/dashpay/qr_code_generator.rs | 440 ++ src/ui/dashpay/qr_scanner.rs | 367 ++ src/ui/dashpay/send_payment.rs | 872 ++++ src/ui/dpns/dpns_contested_names_screen.rs | 127 +- src/ui/helpers.rs | 499 +- .../add_existing_identity_screen.rs | 1012 +++-- .../by_platform_address.rs | 265 ++ .../by_using_unused_asset_lock.rs | 55 +- .../by_using_unused_balance.rs | 72 +- .../by_wallet_qr_code.rs | 97 +- .../identities/add_new_identity_screen/mod.rs | 900 ++-- .../add_new_identity_screen/success_screen.rs | 63 +- src/ui/identities/identities_screen.rs | 471 +- src/ui/identities/keys/add_key_screen.rs | 287 +- src/ui/identities/keys/key_info_screen.rs | 149 +- src/ui/identities/mod.rs | 2 +- .../identities/register_dpns_name_screen.rs | 427 +- .../by_platform_address.rs | 285 ++ .../by_using_unused_asset_lock.rs | 54 +- .../by_using_unused_balance.rs | 70 +- .../by_wallet_qr_code.rs | 105 +- .../identities/top_up_identity_screen/mod.rs | 274 +- .../top_up_identity_screen/success_screen.rs | 28 +- src/ui/identities/transfer_screen.rs | 511 ++- src/ui/identities/withdraw_screen.rs | 313 +- src/ui/mod.rs | 463 +- src/ui/network_chooser_screen.rs | 2053 +++++++-- src/ui/tokens/add_token_by_id_screen.rs | 55 +- src/ui/tokens/burn_tokens_screen.rs | 244 +- src/ui/tokens/claim_tokens_screen.rs | 199 +- src/ui/tokens/destroy_frozen_funds_screen.rs | 236 +- src/ui/tokens/direct_token_purchase_screen.rs | 229 +- src/ui/tokens/freeze_tokens_screen.rs | 267 +- src/ui/tokens/mint_tokens_screen.rs | 251 +- src/ui/tokens/pause_tokens_screen.rs | 238 +- src/ui/tokens/resume_tokens_screen.rs | 239 +- src/ui/tokens/set_token_price_screen.rs | 250 +- .../tokens/tokens_screen/contract_details.rs | 5 +- src/ui/tokens/tokens_screen/keyword_search.rs | 38 +- src/ui/tokens/tokens_screen/mod.rs | 109 +- src/ui/tokens/tokens_screen/my_tokens.rs | 149 +- src/ui/tokens/tokens_screen/token_creator.rs | 547 ++- src/ui/tokens/transfer_tokens_screen.rs | 217 +- src/ui/tokens/unfreeze_tokens_screen.rs | 245 +- src/ui/tokens/update_token_config.rs | 248 +- src/ui/tools/address_balance_screen.rs | 198 + src/ui/tools/contract_visualizer_screen.rs | 19 +- src/ui/tools/document_visualizer_screen.rs | 19 +- src/ui/tools/grovestark_screen.rs | 34 +- src/ui/tools/mod.rs | 1 + src/ui/tools/platform_info_screen.rs | 39 +- src/ui/wallets/account_summary.rs | 242 + src/ui/wallets/add_new_wallet_screen.rs | 676 ++- src/ui/wallets/import_mnemonic_screen.rs | 761 ++++ src/ui/wallets/import_wallet_screen.rs | 461 -- src/ui/wallets/mod.rs | 5 +- src/ui/wallets/send_screen.rs | 2157 +++++++++ src/ui/wallets/single_key_send_screen.rs | 1042 +++++ src/ui/wallets/wallets_screen/mod.rs | 4021 ++++++++++++++--- src/ui/welcome_screen.rs | 204 + tests/kittest/startup.rs | 12 +- 204 files changed, 47656 insertions(+), 7689 deletions(-) delete mode 100644 CLAUDE.md create mode 100644 icons/dashlogo.svg create mode 100644 icons/dashpay.png create mode 100644 src/backend_task/core/recover_asset_locks.rs create mode 100644 src/backend_task/core/refresh_single_key_wallet_info.rs create mode 100644 src/backend_task/core/send_single_key_wallet_payment.rs create mode 100644 src/backend_task/dashpay.rs create mode 100644 src/backend_task/dashpay/auto_accept_handler.rs create mode 100644 src/backend_task/dashpay/auto_accept_proof.rs create mode 100644 src/backend_task/dashpay/avatar_processing.rs create mode 100644 src/backend_task/dashpay/contact_info.rs create mode 100644 src/backend_task/dashpay/contact_requests.rs create mode 100644 src/backend_task/dashpay/contacts.rs create mode 100644 src/backend_task/dashpay/dip14_derivation.rs create mode 100644 src/backend_task/dashpay/encryption.rs create mode 100644 src/backend_task/dashpay/encryption_tests.rs create mode 100644 src/backend_task/dashpay/errors.rs create mode 100644 src/backend_task/dashpay/hd_derivation.rs create mode 100644 src/backend_task/dashpay/incoming_payments.rs create mode 100644 src/backend_task/dashpay/payments.rs create mode 100644 src/backend_task/dashpay/profile.rs create mode 100644 src/backend_task/dashpay/validation.rs create mode 100644 src/backend_task/identity/discover_identities.rs create mode 100644 src/backend_task/identity/load_identity_by_dpns_name.rs create mode 100644 src/backend_task/wallet/fetch_platform_address_balances.rs create mode 100644 src/backend_task/wallet/fund_platform_address_from_asset_lock.rs create mode 100644 src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs create mode 100644 src/backend_task/wallet/generate_receive_address.rs create mode 100644 src/backend_task/wallet/mod.rs create mode 100644 src/backend_task/wallet/transfer_platform_credits.rs create mode 100644 src/backend_task/wallet/withdraw_from_platform_address.rs create mode 100644 src/context_provider_spv.rs create mode 100644 src/database/contacts.rs create mode 100644 src/database/dashpay.rs create mode 100644 src/database/single_key_wallet.rs create mode 100644 src/model/fee_estimation.rs create mode 100644 src/model/wallet/single_key.rs create mode 100644 src/spv/error.rs create mode 100644 src/spv/manager.rs create mode 100644 src/spv/mod.rs rename src/ui/components/{contracts_subscreen_chooser_panel.rs => dashpay_subscreen_chooser_panel.rs} (50%) create mode 100644 src/ui/components/info_popup.rs create mode 100644 src/ui/components/wallet_unlock_popup.rs delete mode 100644 src/ui/contracts_documents/dashpay_coming_soon_screen.rs create mode 100644 src/ui/dashpay/add_contact_screen.rs create mode 100644 src/ui/dashpay/contact_details.rs create mode 100644 src/ui/dashpay/contact_info_editor.rs create mode 100644 src/ui/dashpay/contact_profile_viewer.rs create mode 100644 src/ui/dashpay/contact_requests.rs create mode 100644 src/ui/dashpay/contacts_list.rs create mode 100644 src/ui/dashpay/dashpay_screen.rs create mode 100644 src/ui/dashpay/mod.rs create mode 100644 src/ui/dashpay/profile_screen.rs create mode 100644 src/ui/dashpay/profile_search.rs create mode 100644 src/ui/dashpay/qr_code_generator.rs create mode 100644 src/ui/dashpay/qr_scanner.rs create mode 100644 src/ui/dashpay/send_payment.rs create mode 100644 src/ui/identities/add_new_identity_screen/by_platform_address.rs create mode 100644 src/ui/identities/top_up_identity_screen/by_platform_address.rs create mode 100644 src/ui/tools/address_balance_screen.rs create mode 100644 src/ui/wallets/account_summary.rs create mode 100644 src/ui/wallets/import_mnemonic_screen.rs delete mode 100644 src/ui/wallets/import_wallet_screen.rs create mode 100644 src/ui/wallets/send_screen.rs create mode 100644 src/ui/wallets/single_key_send_screen.rs create mode 100644 src/ui/welcome_screen.rs diff --git a/.env.example b/.env.example index 0a9bd218b..c7f0bb182 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,7 @@ +# THIS FILE MUST BE PLACED IN +# linux: ~/.config/dash-evo-tool/.env +# Mac: ~/Library/ApplicationSupport/Dash-Evo-Tool/.env + MAINNET_dapi_addresses=https://104.200.24.196:443,https://134.255.182.185:443,https://134.255.182.186:443,https://134.255.182.187:443,https://134.255.183.247:443,https://134.255.183.248:443,https://134.255.183.250:443,https://135.181.110.216:443,https://146.59.4.9:443,https://147.135.199.138:443,https://149.28.241.190:443,https://149.28.247.165:443,https://157.10.199.125:443,https://157.10.199.77:443,https://157.10.199.79:443,https://157.10.199.82:443,https://157.66.81.130:443,https://157.66.81.162:443,https://157.66.81.218:443,https://157.90.238.161:443,https://159.69.204.162:443,https://167.179.90.255:443,https://167.88.169.16:443,https://168.119.102.10:443,https://172.104.90.249:443,https://173.212.239.124:443,https://173.249.53.139:443,https://178.157.91.184:443,https://185.158.107.124:443,https://185.192.96.70:443,https://185.194.216.84:443,https://185.197.250.227:443,https://185.198.234.17:443,https://185.215.166.126:443,https://188.208.196.183:443,https://188.245.90.255:443,https://192.248.178.237:443,https://193.203.15.209:443,https://194.146.13.7:443,https://194.195.87.34:443,https://198.7.115.43:443,https://207.244.247.40:443,https://213.199.34.248:443,https://213.199.34.250:443,https://213.199.34.251:443,https://213.199.35.15:443,https://213.199.35.18:443,https://213.199.35.6:443,https://213.199.44.112:443,https://2.58.82.231:443,https://31.220.84.93:443,https://31.220.85.180:443,https://31.220.88.116:443,https://37.27.83.17:443,https://37.60.236.151:443,https://37.60.236.161:443,https://37.60.236.201:443,https://37.60.236.212:443,https://37.60.236.247:443,https://37.60.236.249:443,https://37.60.243.119:443,https://37.60.243.59:443,https://37.60.244.220:443,https://44.240.99.214:443,https://49.12.102.105:443,https://49.13.154.121:443,https://49.13.193.251:443,https://49.13.237.193:443,https://49.13.28.255:443,https://51.195.118.43:443,https://51.83.191.208:443,https://5.189.186.78:443,https://52.10.213.198:443,https://52.33.9.172:443,https://54.69.95.118:443,https://5.75.133.148:443,https://64.23.134.67:443,https://65.108.246.145:443,https://65.109.65.126:443,https://65.21.145.147:443,https://79.137.71.84:443,https://81.17.101.141:443,https://91.107.204.136:443,https://91.107.226.241:443,https://93.190.140.101:443,https://93.190.140.111:443,https://93.190.140.112:443,https://93.190.140.114:443,https://93.190.140.162:443,https://95.216.146.18:443 MAINNET_core_host=127.0.0.1 MAINNET_core_rpc_port=9998 @@ -32,7 +36,9 @@ LOCAL_dapi_addresses=http://127.0.0.1:2443,http://127.0.0.1:2543,http://127.0.0. LOCAL_core_host=127.0.0.1 LOCAL_core_rpc_port=20302 LOCAL_core_rpc_user=dashmate +# Use dashmate cli to retrive it: +# dashmate config get core.rpc.users.dashmate.password --config=local_seed LOCAL_core_rpc_password=password LOCAL_insight_api_url=http://localhost:3001/insight-api -LOCAL_core_zmq_endpoint=tcp://127.0.0.1:20302 +LOCAL_core_zmq_endpoint=tcp://127.0.0.1:50298 LOCAL_show_in_ui=true \ No newline at end of file diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index 47348b757..18d7075d4 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -16,6 +16,30 @@ jobs: runs-on: ubuntu-latest steps: + - name: Free disk space + run: | + echo "=== Disk space before cleanup ===" + df -h + # Remove large unnecessary directories + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android + sudo rm -rf /opt/ghc + sudo rm -rf /opt/hostedtoolcache/CodeQL + sudo rm -rf /opt/hostedtoolcache/go + sudo rm -rf /opt/hostedtoolcache/node + sudo rm -rf /usr/local/share/boost + sudo rm -rf /usr/share/swift + sudo rm -rf /usr/local/graalvm + sudo rm -rf /usr/local/.ghcup + # Clean docker + sudo docker image prune --all --force || true + sudo docker system prune --all --force || true + # Clean apt cache + sudo apt-get clean + sudo rm -rf /var/lib/apt/lists/* + echo "=== Disk space after cleanup ===" + df -h + - name: Checkout code uses: actions/checkout@v4 @@ -25,16 +49,14 @@ jobs: path: | ~/.cargo/registry ~/.cargo/git - target - key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo-clippy- - ${{ runner.os }}-cargo- + ${{ runner.os }}-cargo-registry- - name: Install Rust toolchain uses: actions-rs/toolchain@v1 with: - toolchain: 1.89 + toolchain: 1.92 components: clippy override: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c708e4dfa..cd296c688 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,31 @@ jobs: runs-on: ${{ matrix.runs-on }} steps: + - name: Free disk space + if: ${{ runner.os == 'Linux' }} + run: | + echo "=== Disk space before cleanup ===" + df -h + # Remove large unnecessary directories + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android + sudo rm -rf /opt/ghc + sudo rm -rf /opt/hostedtoolcache/CodeQL + sudo rm -rf /opt/hostedtoolcache/go + sudo rm -rf /opt/hostedtoolcache/node + sudo rm -rf /usr/local/share/boost + sudo rm -rf /usr/share/swift + sudo rm -rf /usr/local/graalvm + sudo rm -rf /usr/local/.ghcup + # Clean docker + sudo docker image prune --all --force || true + sudo docker system prune --all --force || true + # Clean apt cache + sudo apt-get clean + sudo rm -rf /var/lib/apt/lists/* + echo "=== Disk space after cleanup ===" + df -h + - name: Check out code uses: actions/checkout@v4 @@ -51,11 +76,9 @@ jobs: path: | ~/.cargo/registry ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo- - + ${{ runner.os }}-cargo-registry- - name: Setup prerequisites run: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 383340727..878f38fff 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,6 +16,30 @@ jobs: runs-on: ubuntu-latest steps: + - name: Free disk space + run: | + echo "=== Disk space before cleanup ===" + df -h + # Remove large unnecessary directories + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android + sudo rm -rf /opt/ghc + sudo rm -rf /opt/hostedtoolcache/CodeQL + sudo rm -rf /opt/hostedtoolcache/go + sudo rm -rf /opt/hostedtoolcache/node + sudo rm -rf /usr/local/share/boost + sudo rm -rf /usr/share/swift + sudo rm -rf /usr/local/graalvm + sudo rm -rf /usr/local/.ghcup + # Clean docker + sudo docker image prune --all --force || true + sudo docker system prune --all --force || true + # Clean apt cache + sudo apt-get clean + sudo rm -rf /var/lib/apt/lists/* + echo "=== Disk space after cleanup ===" + df -h + - name: Checkout code uses: actions/checkout@v4 @@ -25,11 +49,9 @@ jobs: path: | ~/.cargo/registry ~/.cargo/git - target - key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} restore-keys: | - ${{ runner.os }}-cargo-test- - ${{ runner.os }}-cargo- + ${{ runner.os }}-cargo-registry- - name: Install Rust toolchain uses: actions-rs/toolchain@v1 diff --git a/.gitignore b/.gitignore index 1af73ecdc..f038c0579 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ build-test/ .env .env.backups .testnet_nodes.yml -test_db +test_db* # Visual Studo Code configuration .vscode/ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 63839fe89..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,111 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Dash Evo Tool is a cross-platform GUI application built with Rust and egui for interacting with Dash Evolution. It supports identity management, DPNS username registration and voting, token operations, and state transition visualization across multiple networks (Mainnet, Testnet, Devnet, Regtest). - -## Build and Development Commands - -```bash -# Development build and run -cargo run - -# Production build -cargo build --release - -# Run linting (used in CI) -cargo clippy --all-features --all-targets -- -D warnings - -# Build for specific target (cross-compilation) -cross build --target x86_64-pc-windows-gnu --release -``` - -## Architecture Overview - -### Core Application Structure -- **Entry Point**: `src/main.rs` - Sets up Tokio runtime (40 worker threads), loads fonts, and launches egui app -- **App State Manager**: `src/app.rs` - Central state with screen management, network switching, and backend task coordination -- **Context System**: `src/context.rs` and `src/context_provider.rs` - Network-specific app contexts with SDK integration -- **Configuration**: `src/config.rs` - Environment and network configuration management - -### Module Organization -- `backend_task/` - Async task handlers organized by domain (identity, contracts, tokens, core, contested_names) -- `ui/` - Screen components organized by feature (identities, tokens, tools, wallets, contracts_documents, dpns) -- `database/` - SQLite persistence layer with tables for each domain -- `model/` - Data structures, including qualified identities with encrypted key storage -- `components/` - Shared components including ZMQ core listeners -- `utils/` - Parsers and helper functions - -### Key Design Patterns -- **Screen-based Navigation**: Stack-based screen management with `ScreenType` enum -- **Async Backend Tasks**: Communication via crossbeam channels with result handling -- **Network Isolation**: Separate app contexts per network with independent databases -- **Real-time Updates**: ZMQ listeners for core blockchain events on network-specific ports -- **Custom UI components**: we build a library of reusable widgets in `ui/components` whenever we need similar - widget displayed in more than 2 places - -### Critical Dependencies -- **dash-sdk**: Core Dash Platform SDK (git dependency, specific revision) -- **egui/eframe**: GUI framework with persistence features -- **tokio**: Full-featured async runtime -- **rusqlite**: SQLite with bundled libsqlite3 -- **zmq/zeromq**: Platform-specific ZMQ implementations (Unix vs Windows) - -## Development Environment Setup - -### Prerequisites -1. **Rust**: Version 1.89+ (enforced by rust-toolchain.toml) -2. **System Dependencies** (Ubuntu): `build-essential libssl-dev pkg-config unzip` -3. **Protocol Buffers**: protoc v25.2+ required for dash-sdk -4. **Dash Core Wallet**: Must be synced for full functionality - -### Application Data Locations -- **macOS**: `~/Library/Application Support/Dash-Evo-Tool/` -- **Windows**: `C:\Users\\AppData\Roaming\Dash-Evo-Tool\config` -- **Linux**: `/home//.config/dash-evo-tool/` - -Configuration loaded from `.env` file in application directory (created from `.env.example` on first run). - -## Key Implementation Details - -### Multi-Network Support -- Each network maintains separate SQLite databases -- ZMQ listeners on different ports per network (Core integration) -- Network switching preserves state and loaded identities -- Core wallet auto-startup with network-specific configurations - -### Security Architecture -- Identity private keys encrypted with Argon2 + AES-256-GCM -- Password-protected storage with zxcvbn strength validation -- Secure memory handling with zeroize for sensitive data -- CPU compatibility checking on x86 platforms - -### Performance Considerations -- 40-thread Tokio runtime for heavy blockchain operations -- Font loading optimized for international scripts (CJK, Arabic, Hebrew, etc.) -- SQLite connection pooling and prepared statements -- Efficient state updates via targeted screen refreshes - -### Cross-Platform Specifics -- Different ZMQ implementations (zmq vs zeromq for Windows) -- Platform-specific file dialogs and CPU detection -- Cross-compilation support via Cross.toml configuration -- Font rendering optimized per platform - -## Testing and CI - -- **Clippy**: Runs on push to main/v*-dev branches and PRs with strict warning enforcement -- **Release**: Multi-platform builds (Linux, macOS, Windows) with attestation -- No dedicated test suite currently - integration testing via manual workflows - -## Common Development Patterns - -When working with this codebase: -- Follow the modular organization: backend tasks in `backend_task/`, UI in `ui/` -- Use the context system for SDK operations rather than direct SDK calls -- Implement async operations as backend tasks with channel communication -- Screen transitions should update the screen stack in `app.rs` -- Database operations should follow the established schema patterns in `database/` -- Error handling uses `thiserror` for structured error types \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 79a30de98..d5c06de6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -169,9 +169,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -599,7 +599,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -660,7 +660,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -797,6 +797,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "bincode" version = "1.3.3" @@ -825,6 +831,29 @@ dependencies = [ "virtue 0.0.13", ] +[[package]] +name = "bindgen" +version = "0.65.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfdf7b466f9a4903edc73f95d6d2bcd5baf8ae620638762244d3f60143643cc5" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.110", + "which 4.4.2", +] + [[package]] name = "bindgen" version = "0.72.1" @@ -840,7 +869,7 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -999,6 +1028,15 @@ dependencies = [ "generic-array 0.14.9", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array 0.14.9", +] + [[package]] name = "block2" version = "0.5.1" @@ -1030,32 +1068,6 @@ dependencies = [ "piper", ] -[[package]] -name = "blsful" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d267776bf4742935d219fcdbdf590bed0f7e5fccdf5bd168fb30b2543a0b2b24" -dependencies = [ - "anyhow", - "blstrs_plus", - "hex", - "hkdf", - "merlin", - "pairing", - "rand 0.8.5", - "rand_chacha 0.3.1", - "rand_core 0.6.4", - "serde", - "serde_bare", - "sha2", - "sha3", - "subtle", - "thiserror 2.0.17", - "uint-zigzag", - "vsss-rs 5.1.0 (registry+https://github.com/rust-lang/crates.io-index)", - "zeroize", -] - [[package]] name = "blsful" version = "3.0.0" @@ -1077,7 +1089,7 @@ dependencies = [ "subtle", "thiserror 2.0.17", "uint-zigzag", - "vsss-rs 5.1.0 (git+https://github.com/dashpay/vsss-rs?branch=main)", + "vsss-rs", "zeroize", ] @@ -1143,7 +1155,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -1191,23 +1203,57 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "calloop" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" +dependencies = [ + "bitflags 2.10.0", + "polling", + "rustix 1.1.2", + "slab", + "tracing", +] + [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "calloop", + "calloop 0.13.0", "rustix 0.38.44", "wayland-backend", "wayland-client", ] +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.3", + "rustix 1.1.2", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" -version = "1.2.41" +version = "1.2.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" dependencies = [ "find-msvc-tools", "jobserver", @@ -1354,6 +1400,46 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.110", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -1374,6 +1460,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.4" @@ -1479,6 +1571,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1497,6 +1598,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam" version = "0.8.4" @@ -1622,66 +1729,28 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", -] - -[[package]] -name = "dapi-grpc" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" -dependencies = [ - "dapi-grpc-macros 2.0.1", - "futures-core", - "getrandom 0.2.16", - "platform-version 2.0.1", - "prost 0.13.5", - "serde", - "serde_bytes", - "serde_json", - "tenderdash-proto 1.4.0", - "tonic 0.13.1", - "tonic-build 0.13.1", + "syn 2.0.110", ] [[package]] name = "dapi-grpc" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ - "dapi-grpc-macros 2.1.2", + "dash-platform-macros", "futures-core", "getrandom 0.2.16", - "platform-version 2.1.2", - "prost 0.14.1", + "platform-version", + "prost", "serde", "serde_bytes", "serde_json", - "tenderdash-proto 1.5.0-dev.2", - "tonic 0.14.2", + "tenderdash-proto", + "tonic", "tonic-prost", "tonic-prost-build", ] -[[package]] -name = "dapi-grpc-macros" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" -dependencies = [ - "heck", - "quote", - "syn 2.0.107", -] - -[[package]] -name = "dapi-grpc-macros" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" -dependencies = [ - "heck", - "quote", - "syn 2.0.107", -] - [[package]] name = "dark-light" version = "2.0.0" @@ -1693,7 +1762,7 @@ dependencies = [ "objc2 0.5.2", "objc2-foundation 0.2.2", "web-sys", - "winreg", + "winreg 0.52.0", ] [[package]] @@ -1717,7 +1786,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -1728,18 +1797,17 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] name = "dash-context-provider" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ - "dpp 2.1.2", - "drive 2.1.2", + "dpp", + "drive", "hex", - "serde", "serde_json", "thiserror 1.0.69", ] @@ -1755,11 +1823,12 @@ dependencies = [ "bincode 2.0.0-rc.3", "bip39", "bitflags 2.10.0", + "cbc", "chrono", "chrono-humanize", "crossbeam-channel", "dark-light", - "dash-sdk 2.1.2", + "dash-sdk", "derive_more 2.0.1", "directories", "dotenvy", @@ -1785,6 +1854,8 @@ dependencies = [ "raw-cpuid", "rayon", "regex", + "reqwest", + "resvg", "rfd", "rusqlite", "rust-embed", @@ -1810,7 +1881,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.40.0#c877c1a74d145e2003d549619698511513db925c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" dependencies = [ "bincode 2.0.0-rc.3", "bincode_derive", @@ -1819,66 +1890,40 @@ dependencies = [ ] [[package]] -name = "dash-sdk" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +name = "dash-platform-macros" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ - "arc-swap", - "async-trait", - "backon", - "bip37-bloom-filter", - "chrono", - "ciborium", - "dapi-grpc 2.0.1", - "dapi-grpc-macros 2.0.1", - "dashcore-rpc 0.39.6", - "derive_more 1.0.0", - "dotenvy", - "dpp 2.0.1", - "drive 2.0.1", - "drive-proof-verifier 2.0.1", - "envy", - "futures", - "hex", - "http", - "lru", - "rs-dapi-client 2.0.1", - "rustls-pemfile", - "serde", - "serde_json", - "thiserror 2.0.17", - "tokio", - "tokio-util", - "tracing", - "zeroize", + "heck", + "quote", + "syn 2.0.110", ] [[package]] name = "dash-sdk" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ "arc-swap", "async-trait", - "backon", "bip37-bloom-filter", "chrono", "ciborium", - "dapi-grpc 2.1.2", - "dapi-grpc-macros 2.1.2", + "dapi-grpc", "dash-context-provider", + "dash-platform-macros", "derive_more 1.0.0", "dotenvy", - "dpp 2.1.2", - "drive 2.1.2", - "drive-proof-verifier 2.1.2", + "dpp", + "drive", + "drive-proof-verifier", "envy", "futures", "hex", "http", "js-sys", "lru", - "rs-dapi-client 2.1.2", + "rs-dapi-client", "rustls-pemfile", "serde", "serde_json", @@ -1890,43 +1935,52 @@ dependencies = [ ] [[package]] -name = "dashcore" -version = "0.39.6" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.39.6#51df58f5d5d499f5ee80ab17076ff70b5347c7db" +name = "dash-spv" +version = "0.40.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" dependencies = [ "anyhow", - "base64-compat", - "bech32", - "bitflags 2.10.0", - "blake3", - "blsful 3.0.0 (registry+https://github.com/rust-lang/crates.io-index)", - "dashcore-private 0.39.6", - "dashcore_hashes 0.39.6", - "ed25519-dalek", + "async-trait", + "bincode 1.3.3", + "blsful", + "chrono", + "clap", + "dashcore", + "dashcore_hashes", "hex", - "hex_lit", - "rustversion", - "secp256k1", + "hickory-resolver", + "indexmap 2.12.0", + "key-wallet", + "key-wallet-manager", + "log", + "rand 0.8.5", + "rayon", "serde", - "thiserror 2.0.17", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing", + "tracing-appender", + "tracing-subscriber", ] [[package]] name = "dashcore" version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.40.0#c877c1a74d145e2003d549619698511513db925c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" dependencies = [ "anyhow", "base64-compat", - "bech32", + "bech32 0.9.1", "bincode 2.0.0-rc.3", "bincode_derive", "bitvec", "blake3", - "blsful 3.0.0 (git+https://github.com/dashpay/agora-blsful?rev=0c34a7a488a0bd1c9a9a2196e793b303ad35c900)", + "blsful", "dash-network", - "dashcore-private 0.40.0", - "dashcore_hashes 0.40.0", + "dashcore-private", + "dashcore_hashes", "ed25519-dalek", "hex", "hex_lit", @@ -1937,35 +1991,17 @@ dependencies = [ "thiserror 2.0.17", ] -[[package]] -name = "dashcore-private" -version = "0.39.6" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.39.6#51df58f5d5d499f5ee80ab17076ff70b5347c7db" - [[package]] name = "dashcore-private" version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.40.0#c877c1a74d145e2003d549619698511513db925c" - -[[package]] -name = "dashcore-rpc" -version = "0.39.6" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.39.6#51df58f5d5d499f5ee80ab17076ff70b5347c7db" -dependencies = [ - "dashcore-rpc-json 0.39.6", - "hex", - "jsonrpc", - "log", - "serde", - "serde_json", -] +source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" [[package]] name = "dashcore-rpc" version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.40.0#c877c1a74d145e2003d549619698511513db925c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" dependencies = [ - "dashcore-rpc-json 0.40.0", + "dashcore-rpc-json", "hex", "jsonrpc", "log", @@ -1973,27 +2009,13 @@ dependencies = [ "serde_json", ] -[[package]] -name = "dashcore-rpc-json" -version = "0.39.6" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.39.6#51df58f5d5d499f5ee80ab17076ff70b5347c7db" -dependencies = [ - "bincode 2.0.0-rc.3", - "dashcore 0.39.6", - "hex", - "serde", - "serde_json", - "serde_repr", - "serde_with", -] - [[package]] name = "dashcore-rpc-json" version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.40.0#c877c1a74d145e2003d549619698511513db925c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" dependencies = [ "bincode 2.0.0-rc.3", - "dashcore 0.40.0", + "dashcore", "hex", "key-wallet", "serde", @@ -2002,23 +2024,14 @@ dependencies = [ "serde_with", ] -[[package]] -name = "dashcore_hashes" -version = "0.39.6" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.39.6#51df58f5d5d499f5ee80ab17076ff70b5347c7db" -dependencies = [ - "dashcore-private 0.39.6", - "secp256k1", - "serde", -] - [[package]] name = "dashcore_hashes" version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.40.0#c877c1a74d145e2003d549619698511513db925c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" dependencies = [ "bincode 2.0.0-rc.3", - "dashcore-private 0.40.0", + "dashcore-private", + "rs-x11-hash", "secp256k1", "serde", ] @@ -2038,63 +2051,45 @@ dependencies = [ [[package]] name = "dashpay-contract" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ - "platform-value 2.0.1", - "platform-version 2.0.1", + "platform-value", + "platform-version", "serde_json", "thiserror 2.0.17", ] [[package]] -name = "dashpay-contract" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" -dependencies = [ - "platform-value 2.1.2", - "platform-version 2.1.2", +name = "data-contracts" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +dependencies = [ + "dashpay-contract", + "dpns-contract", + "feature-flags-contract", + "keyword-search-contract", + "masternode-reward-shares-contract", + "platform-value", + "platform-version", "serde_json", "thiserror 2.0.17", + "token-history-contract", + "wallet-utils-contract", + "withdrawals-contract", ] [[package]] -name = "data-contracts" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" -dependencies = [ - "dashpay-contract 2.0.1", - "dpns-contract 2.0.1", - "feature-flags-contract 2.0.1", - "keyword-search-contract 2.0.1", - "masternode-reward-shares-contract 2.0.1", - "platform-value 2.0.1", - "platform-version 2.0.1", - "serde_json", - "thiserror 2.0.17", - "token-history-contract 2.0.1", - "wallet-utils-contract 2.0.1", - "withdrawals-contract 2.0.1", -] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" [[package]] -name = "data-contracts" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" -dependencies = [ - "dashpay-contract 2.1.2", - "dpns-contract 2.1.2", - "feature-flags-contract 2.1.2", - "keyword-search-contract 2.1.2", - "masternode-reward-shares-contract 2.1.2", - "platform-value 2.1.2", - "platform-version 2.1.2", - "serde_json", - "thiserror 2.0.17", - "token-history-contract 2.1.2", - "wallet-utils-contract 2.1.2", - "withdrawals-contract 2.1.2", -] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" [[package]] name = "der" @@ -2108,9 +2103,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", "serde_core", @@ -2135,7 +2130,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -2156,7 +2151,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -2166,7 +2161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -2195,7 +2190,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", "unicode-xid", ] @@ -2207,7 +2202,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -2288,7 +2283,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -2302,9 +2297,9 @@ dependencies = [ [[package]] name = "document-features" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ "litrs", ] @@ -2329,77 +2324,24 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" -dependencies = [ - "platform-value 2.0.1", - "platform-version 2.0.1", - "serde_json", - "thiserror 2.0.17", -] - -[[package]] -name = "dpns-contract" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ - "platform-value 2.1.2", - "platform-version 2.1.2", + "platform-value", + "platform-version", "serde_json", "thiserror 2.0.17", ] [[package]] name = "dpp" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" -dependencies = [ - "anyhow", - "async-trait", - "base64 0.22.1", - "bincode 2.0.0-rc.3", - "bincode_derive", - "bs58", - "byteorder", - "chrono", - "chrono-tz", - "ciborium", - "dashcore 0.39.6", - "data-contracts 2.0.1", - "derive_more 1.0.0", - "env_logger", - "getrandom 0.2.16", - "hex", - "indexmap 2.12.0", - "integer-encoding", - "itertools 0.13.0", - "lazy_static", - "nohash-hasher", - "num_enum 0.7.5", - "once_cell", - "platform-serialization 2.0.1", - "platform-serialization-derive 2.0.1", - "platform-value 2.0.1", - "platform-version 2.0.1", - "platform-versioning 2.0.1", - "rand 0.8.5", - "regex", - "serde", - "serde_json", - "serde_repr", - "sha2", - "strum 0.26.3", - "thiserror 2.0.17", -] - -[[package]] -name = "dpp" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ "anyhow", "async-trait", "base64 0.22.1", + "bech32 0.11.1", "bincode 2.0.0-rc.3", "bincode_derive", "bs58", @@ -2407,9 +2349,10 @@ dependencies = [ "chrono", "chrono-tz", "ciborium", - "dashcore 0.40.0", - "dashcore-rpc 0.40.0", - "data-contracts 2.1.2", + "dash-spv", + "dashcore", + "dashcore-rpc", + "data-contracts", "derive_more 1.0.0", "env_logger", "getrandom 0.2.16", @@ -2418,15 +2361,16 @@ dependencies = [ "integer-encoding", "itertools 0.13.0", "key-wallet", + "key-wallet-manager", "lazy_static", "nohash-hasher", "num_enum 0.7.5", "once_cell", - "platform-serialization 2.1.2", - "platform-serialization-derive 2.1.2", - "platform-value 2.1.2", - "platform-version 2.1.2", - "platform-versioning 2.1.2", + "platform-serialization", + "platform-serialization-derive", + "platform-value", + "platform-version", + "platform-versioning", "rand 0.8.5", "regex", "serde", @@ -2440,48 +2384,23 @@ dependencies = [ [[package]] name = "drive" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ "bincode 2.0.0-rc.3", "byteorder", "derive_more 1.0.0", - "dpp 2.0.1", - "grovedb", - "grovedb-costs", + "dpp", + "grovedb 4.0.0", + "grovedb-costs 4.0.0", "grovedb-epoch-based-storage-flags", - "grovedb-path", - "grovedb-version", + "grovedb-path 4.0.0", + "grovedb-version 4.0.0", "hex", "indexmap 2.12.0", "integer-encoding", "nohash-hasher", - "platform-version 2.0.1", - "serde", - "sqlparser", - "thiserror 2.0.17", - "tracing", -] - -[[package]] -name = "drive" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" -dependencies = [ - "bincode 2.0.0-rc.3", - "byteorder", - "derive_more 1.0.0", - "dpp 2.1.2", - "grovedb", - "grovedb-costs", - "grovedb-epoch-based-storage-flags", - "grovedb-path", - "grovedb-version", - "hex", - "indexmap 2.12.0", - "integer-encoding", - "nohash-hasher", - "platform-version 2.1.2", + "platform-version", "serde", "sqlparser", "thiserror 2.0.17", @@ -2490,43 +2409,21 @@ dependencies = [ [[package]] name = "drive-proof-verifier" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ "bincode 2.0.0-rc.3", - "dapi-grpc 2.0.1", - "derive_more 1.0.0", - "dpp 2.0.1", - "drive 2.0.1", - "hex", - "indexmap 2.12.0", - "platform-serialization 2.0.1", - "platform-serialization-derive 2.0.1", - "serde", - "serde_json", - "tenderdash-abci 1.4.0", - "thiserror 2.0.17", - "tracing", -] - -[[package]] -name = "drive-proof-verifier" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" -dependencies = [ - "bincode 2.0.0-rc.3", - "dapi-grpc 2.1.2", + "dapi-grpc", "dash-context-provider", "derive_more 1.0.0", - "dpp 2.1.2", - "drive 2.1.2", + "dpp", + "drive", "hex", "indexmap 2.12.0", - "platform-serialization 2.1.2", - "platform-serialization-derive 2.1.2", + "platform-serialization", + "platform-serialization-derive", "serde", - "serde_json", - "tenderdash-abci 1.5.0-dev.2", + "tenderdash-abci", "thiserror 2.0.17", "tracing", ] @@ -2784,19 +2681,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "elliptic-curve-tools" -version = "0.1.2" -source = "git+https://github.com/mikelodder7/elliptic-curve-tools?rev=c989865fa71503d2cbf5c5795c4ebcf4a2f3221c#c989865fa71503d2cbf5c5795c4ebcf4a2f3221c" -dependencies = [ - "elliptic-curve", - "heapless", - "hex", - "multiexp", - "serde", - "zeroize", -] - [[package]] name = "elliptic-curve-tools" version = "0.2.0" @@ -2836,6 +2720,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "enum-iterator" version = "2.3.0" @@ -2853,7 +2749,7 @@ checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -2873,7 +2769,7 @@ checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -2894,7 +2790,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -2905,7 +2801,7 @@ checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -2993,6 +2889,15 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "euclid" +version = "0.22.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -3066,7 +2971,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -3080,22 +2985,11 @@ dependencies = [ [[package]] name = "feature-flags-contract" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" -dependencies = [ - "platform-value 2.0.1", - "platform-version 2.0.1", - "serde_json", - "thiserror 2.0.17", -] - -[[package]] -name = "feature-flags-contract" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ - "platform-value 2.1.2", - "platform-version 2.1.2", + "platform-value", + "platform-version", "serde_json", "thiserror 2.0.17", ] @@ -3131,9 +3025,9 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", "libz-rs-sys", @@ -3141,13 +3035,10 @@ dependencies = [ ] [[package]] -name = "flex-error" -version = "0.4.4" +name = "float-cmp" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c606d892c9de11507fa0dcffc116434f94e105d0bbdc4e405b61519464c49d7b" -dependencies = [ - "paste", -] +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" [[package]] name = "fnv" @@ -3167,6 +3058,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -3194,7 +3108,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -3305,7 +3219,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -3351,9 +3265,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "1.3.4" +version = "1.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985a5578ebdb02351d484a77fb27e7cb79272f1ba9bc24692d8243c3cfe40660" +checksum = "eaf57c49a95fd1fe24b90b3033bee6dc7e8f1288d51494cb44e627c295e38542" dependencies = [ "rustversion", "serde_core", @@ -3405,6 +3319,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -3585,14 +3509,14 @@ dependencies = [ "bincode 2.0.0-rc.3", "bincode_derive", "blake3", - "grovedb-costs", - "grovedb-merk", - "grovedb-path", + "grovedb-costs 3.1.0", + "grovedb-merk 3.1.0", + "grovedb-path 3.1.0", "grovedb-storage", - "grovedb-version", - "grovedb-visualize", + "grovedb-version 3.1.0", + "grovedb-visualize 3.1.0", "hex", - "hex-literal", + "hex-literal 0.4.1", "indexmap 2.12.0", "integer-encoding", "intmap", @@ -3603,6 +3527,28 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "grovedb" +version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" +dependencies = [ + "bincode 2.0.0-rc.3", + "bincode_derive", + "blake3", + "grovedb-costs 4.0.0", + "grovedb-element", + "grovedb-merk 4.0.0", + "grovedb-path 4.0.0", + "grovedb-version 4.0.0", + "hex", + "hex-literal 1.1.0", + "indexmap 2.12.0", + "integer-encoding", + "reqwest", + "sha2", + "thiserror 2.0.17", +] + [[package]] name = "grovedb-costs" version = "3.1.0" @@ -3614,13 +3560,36 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "grovedb-costs" +version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" +dependencies = [ + "integer-encoding", + "intmap", + "thiserror 2.0.17", +] + +[[package]] +name = "grovedb-element" +version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" +dependencies = [ + "bincode 2.0.0-rc.3", + "bincode_derive", + "grovedb-path 4.0.0", + "grovedb-version 4.0.0", + "hex", + "integer-encoding", + "thiserror 2.0.17", +] + [[package]] name = "grovedb-epoch-based-storage-flags" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6bdc033cc229b17cd02ee9d5c5a5a344788ed0e69ad7468b0d34d94b021fc4" +version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" dependencies = [ - "grovedb-costs", + "grovedb-costs 4.0.0", "hex", "integer-encoding", "intmap", @@ -3639,11 +3608,11 @@ dependencies = [ "byteorder", "colored", "ed", - "grovedb-costs", - "grovedb-path", + "grovedb-costs 3.1.0", + "grovedb-path 3.1.0", "grovedb-storage", - "grovedb-version", - "grovedb-visualize", + "grovedb-version 3.1.0", + "grovedb-visualize 3.1.0", "hex", "indexmap 2.12.0", "integer-encoding", @@ -3652,6 +3621,27 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "grovedb-merk" +version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" +dependencies = [ + "bincode 2.0.0-rc.3", + "bincode_derive", + "blake3", + "byteorder", + "ed", + "grovedb-costs 4.0.0", + "grovedb-element", + "grovedb-path 4.0.0", + "grovedb-version 4.0.0", + "grovedb-visualize 4.0.0", + "hex", + "indexmap 2.12.0", + "integer-encoding", + "thiserror 2.0.17", +] + [[package]] name = "grovedb-path" version = "3.1.0" @@ -3661,6 +3651,14 @@ dependencies = [ "hex", ] +[[package]] +name = "grovedb-path" +version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" +dependencies = [ + "hex", +] + [[package]] name = "grovedb-storage" version = "3.1.0" @@ -3668,9 +3666,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d04f3831fe210543a7246f2a60ae068f23eac5f9d53200d5a82785750f68fd" dependencies = [ "blake3", - "grovedb-costs", - "grovedb-path", - "grovedb-visualize", + "grovedb-costs 3.1.0", + "grovedb-path 3.1.0", + "grovedb-visualize 3.1.0", "hex", "integer-encoding", "lazy_static", @@ -3691,6 +3689,15 @@ dependencies = [ "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "grovedb-version" +version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" +dependencies = [ + "thiserror 2.0.17", + "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "grovedb-visualize" version = "3.1.0" @@ -3701,10 +3708,19 @@ dependencies = [ "itertools 0.14.0", ] +[[package]] +name = "grovedb-visualize" +version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" +dependencies = [ + "hex", + "itertools 0.14.0", +] + [[package]] name = "grovestark" version = "0.1.0" -source = "git+https://www.github.com/pauldelucia/grovestark?rev=5313ba9df590f114e11934e281f1e8c8bc462794#5313ba9df590f114e11934e281f1e8c8bc462794" +source = "git+https://www.github.com/pauldelucia/grovestark?rev=c5823c8239792f75f93f59f025aa335ab6d42c36#c5823c8239792f75f93f59f025aa335ab6d42c36" dependencies = [ "ark-ff", "base64 0.22.1", @@ -3713,12 +3729,11 @@ dependencies = [ "blake3", "bs58", "curve25519-dalek", - "dash-sdk 2.0.1", "ed25519-dalek", "env_logger", - "grovedb", - "grovedb-costs", - "grovedb-merk", + "grovedb 3.1.0", + "grovedb-costs 3.1.0", + "grovedb-merk 3.1.0", "hex", "log", "num-bigint", @@ -3872,6 +3887,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + [[package]] name = "hex_lit" version = "0.1.1" @@ -3884,6 +3905,52 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "thiserror 2.0.17", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "smallvec", + "thiserror 2.0.17", + "tokio", + "tracing", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -3904,11 +3971,11 @@ dependencies = [ [[package]] name = "home" -version = "0.5.11" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4093,9 +4160,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", @@ -4106,9 +4173,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -4119,11 +4186,10 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -4134,42 +4200,38 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", - "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -4214,10 +4276,28 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", - "png", + "png 0.18.0", "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "indexmap" version = "1.9.3" @@ -4247,24 +4327,37 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array 0.14.9", ] [[package]] name = "integer-encoding" -version = "4.0.2" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d762194228a2f1c11063e46e32e5acb96e66e906382b9eb5441f2e0504bbd5a" +checksum = "14c00403deb17c3221a1fe4fb571b9ed0370b3dcd116553c77fa294a3d918699" [[package]] name = "intmap" -version = "3.1.2" +version = "3.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16dd999647b7a027fadf2b3041a4ea9c8ae21562823fe5cbdecd46537d535ae2" +checksum = "a2e611826a1868311677fdcdfbec9e8621d104c732d080f546a854530232f0ee" dependencies = [ "serde", ] +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -4273,9 +4366,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" dependencies = [ "memchr", "serde", @@ -4322,26 +4415,26 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jiff" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" dependencies = [ "jiff-static", "log", "portable-atomic", "portable-atomic-util", - "serde", + "serde_core", ] [[package]] name = "jiff-static" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -4378,9 +4471,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" dependencies = [ "once_cell", "wasm-bindgen", @@ -4419,15 +4512,18 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.40.0#c877c1a74d145e2003d549619698511513db925c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" dependencies = [ + "async-trait", "base58ck", + "bincode 2.0.0-rc.3", + "bincode_derive", "bip39", "bitflags 2.10.0", "dash-network", - "dashcore 0.40.0", - "dashcore-private 0.40.0", - "dashcore_hashes 0.40.0", + "dashcore", + "dashcore-private", + "dashcore_hashes", "getrandom 0.2.16", "hex", "hkdf", @@ -4441,23 +4537,26 @@ dependencies = [ ] [[package]] -name = "keyword-search-contract" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +name = "key-wallet-manager" +version = "0.40.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" dependencies = [ - "platform-value 2.0.1", - "platform-version 2.0.1", - "serde_json", - "thiserror 2.0.17", + "async-trait", + "bincode 2.0.0-rc.3", + "dashcore", + "dashcore_hashes", + "key-wallet", + "secp256k1", + "zeroize", ] [[package]] name = "keyword-search-contract" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ - "platform-value 2.1.2", - "platform-version 2.1.2", + "platform-value", + "platform-version", "serde_json", "thiserror 2.0.17", ] @@ -4490,6 +4589,17 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -4505,6 +4615,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "lhash" version = "1.1.0" @@ -4550,7 +4666,7 @@ version = "0.17.3+10.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cef2a00ee60fe526157c9023edab23943fae1ce2ab6f4abb2a807c1746835de9" dependencies = [ - "bindgen", + "bindgen 0.72.1", "bzip2-sys", "cc", "libc", @@ -4604,15 +4720,15 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "litrs" -version = "0.4.2" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" @@ -4662,22 +4778,11 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" -dependencies = [ - "platform-value 2.0.1", - "platform-version 2.0.1", - "serde_json", - "thiserror 2.0.17", -] - -[[package]] -name = "masternode-reward-shares-contract" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ - "platform-value 2.1.2", - "platform-version 2.1.2", + "platform-value", + "platform-version", "serde_json", "thiserror 2.0.17", ] @@ -4787,11 +4892,29 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "rustc_version", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "moxcms" -version = "0.7.7" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c588e11a3082784af229e23e8e4ecf5bcc6fbe4f69101e0421ce8d79da7f0b40" +checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6" dependencies = [ "num-traits", "pxfm", @@ -4850,9 +4973,9 @@ dependencies = [ [[package]] name = "native-dialog" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1657b63bf0e60ee0eca886b5df70269240b6197b6ee46ec37da9a7d28d8e8e24" +checksum = "454a816a8fed70bb5ba4ae90901073173dd5142f5df5ee503acde1ebcfaa4c4b" dependencies = [ "ascii", "block2 0.6.2", @@ -5026,7 +5149,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -5121,7 +5244,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -5410,6 +5533,10 @@ name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -5425,9 +5552,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.74" +version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ "bitflags 2.10.0", "cfg-if", @@ -5446,7 +5573,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -5457,9 +5584,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.110" +version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", @@ -5475,9 +5602,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orbclient" -version = "0.3.48" +version = "0.3.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba0b26cec2e24f08ed8bb31519a9333140a6599b867dac464bb150bdb796fd43" +checksum = "247ad146e19b9437f8604c21f8652423595cf710ad108af40e77d3ae6e96b827" dependencies = [ "libredox", ] @@ -5574,6 +5701,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -5630,7 +5763,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", "unicase", ] @@ -5644,6 +5777,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.10" @@ -5661,7 +5800,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -5705,68 +5844,28 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "platform-serialization" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" -dependencies = [ - "bincode 2.0.0-rc.3", - "platform-version 2.0.1", -] - -[[package]] -name = "platform-serialization" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ "bincode 2.0.0-rc.3", - "platform-version 2.1.2", -] - -[[package]] -name = "platform-serialization-derive" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.107", - "virtue 0.0.17", + "platform-version", ] [[package]] name = "platform-serialization-derive" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", "virtue 0.0.17", ] [[package]] name = "platform-value" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" -dependencies = [ - "base64 0.22.1", - "bincode 2.0.0-rc.3", - "bs58", - "ciborium", - "hex", - "indexmap 2.12.0", - "platform-serialization 2.0.1", - "platform-version 2.0.1", - "rand 0.8.5", - "serde", - "serde_json", - "thiserror 2.0.17", - "treediff", -] - -[[package]] -name = "platform-value" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ "base64 0.22.1", "bincode 2.0.0-rc.3", @@ -5774,8 +5873,8 @@ dependencies = [ "ciborium", "hex", "indexmap 2.12.0", - "platform-serialization 2.1.2", - "platform-version 2.1.2", + "platform-serialization", + "platform-version", "rand 0.8.5", "serde", "serde_json", @@ -5785,23 +5884,11 @@ dependencies = [ [[package]] name = "platform-version" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" -dependencies = [ - "bincode 2.0.0-rc.3", - "grovedb-version", - "once_cell", - "thiserror 2.0.17", - "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", -] - -[[package]] -name = "platform-version" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ "bincode 2.0.0-rc.3", - "grovedb-version", + "grovedb-version 4.0.0", "once_cell", "thiserror 2.0.17", "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", @@ -5809,22 +5896,25 @@ dependencies = [ [[package]] name = "platform-versioning" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] -name = "platform-versioning" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.107", + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", ] [[package]] @@ -5889,9 +5979,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] @@ -5924,7 +6014,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -5948,9 +6038,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] @@ -5961,16 +6051,6 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" -[[package]] -name = "prost" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" -dependencies = [ - "bytes", - "prost-derive 0.13.5", -] - [[package]] name = "prost" version = "0.14.1" @@ -5978,27 +6058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" dependencies = [ "bytes", - "prost-derive 0.14.1", -] - -[[package]] -name = "prost-build" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" -dependencies = [ - "heck", - "itertools 0.14.0", - "log", - "multimap", - "once_cell", - "petgraph", - "prettyplease", - "prost 0.13.5", - "prost-types 0.13.5", - "regex", - "syn 2.0.107", - "tempfile", + "prost-derive", ] [[package]] @@ -6014,28 +6074,15 @@ dependencies = [ "once_cell", "petgraph", "prettyplease", - "prost 0.14.1", - "prost-types 0.14.1", + "prost", + "prost-types", "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn 2.0.107", + "syn 2.0.110", "tempfile", ] -[[package]] -name = "prost-derive" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" -dependencies = [ - "anyhow", - "itertools 0.14.0", - "proc-macro2", - "quote", - "syn 2.0.107", -] - [[package]] name = "prost-derive" version = "0.14.1" @@ -6046,16 +6093,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.107", -] - -[[package]] -name = "prost-types" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" -dependencies = [ - "prost 0.13.5", + "syn 2.0.110", ] [[package]] @@ -6064,7 +6102,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" dependencies = [ - "prost 0.14.1", + "prost", ] [[package]] @@ -6080,9 +6118,9 @@ dependencies = [ [[package]] name = "pulldown-cmark-to-cmark" -version = "21.0.0" +version = "21.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5b6a0769a491a08b31ea5c62494a8f144ee0987d86d670a8af4df1e1b7cde75" +checksum = "8246feae3db61428fd0bb94285c690b460e4517d83152377543ca802357785f1" dependencies = [ "pulldown-cmark", ] @@ -6132,9 +6170,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -6357,15 +6395,40 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] +[[package]] +name = "resolv-conf" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + [[package]] name = "rfd" version = "0.15.4" @@ -6390,6 +6453,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -6427,14 +6499,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rs-dapi-client" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ "backon", "chrono", - "dapi-grpc 2.0.1", + "dapi-grpc", "futures", "getrandom 0.2.16", "gloo-timers", @@ -6448,37 +6526,20 @@ dependencies = [ "sha2", "thiserror 2.0.17", "tokio", - "tonic-web-wasm-client 0.7.1", + "tonic-web-wasm-client", "tracing", "wasm-bindgen-futures", ] [[package]] -name = "rs-dapi-client" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +name = "rs-x11-hash" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94ea852806513d6f5fd7750423300375bc8481a18ed033756c1a836257893a30" dependencies = [ - "backon", - "chrono", - "dapi-grpc 2.1.2", - "futures", - "getrandom 0.2.16", - "gloo-timers", - "hex", - "http", - "http-body-util", - "http-serde", - "lru", - "rand 0.8.5", - "serde", - "serde_json", - "sha2", - "thiserror 2.0.17", - "tokio", - "tonic-web-wasm-client 0.8.0", - "tower-service", - "tracing", - "wasm-bindgen-futures", + "bindgen 0.65.1", + "cc", + "libc", ] [[package]] @@ -6497,9 +6558,9 @@ dependencies = [ [[package]] name = "rust-embed" -version = "8.8.0" +version = "8.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb44e1917075637ee8c7bcb865cf8830e3a92b5b1189e44e3a0ab5a0d5be314b" +checksum = "947d7f3fad52b283d261c4c99a084937e2fe492248cb9a68a8435a861b8798ca" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -6508,22 +6569,22 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.8.0" +version = "8.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "382499b49db77a7c19abd2a574f85ada7e9dbe125d5d1160fa5cad7c4cf71fc9" +checksum = "5fa2c8c9e8711e10f9c4fd2d64317ef13feaab820a4c51541f1a8c8e2e851ab2" dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.107", + "syn 2.0.110", "walkdir", ] [[package]] name = "rust-embed-utils" -version = "8.8.0" +version = "8.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21fcbee55c2458836bcdbfffb6ec9ba74bbc23ca7aa6816015a3dd2c4d8fc185" +checksum = "60b161f275cb337fe0a44d924a5f4df0ed69c2c39519858f931ce61c779d3475" dependencies = [ "sha2", "walkdir", @@ -6578,9 +6639,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.34" +version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "log", "once_cell", @@ -6614,18 +6675,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.7" +version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ "ring", "rustls-pki-types", @@ -6638,6 +6699,24 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "ryu" version = "1.0.20" @@ -6683,7 +6762,7 @@ dependencies = [ "ab_glyph", "log", "memmap2", - "smithay-client-toolkit", + "smithay-client-toolkit 0.19.2", "tiny-skia", ] @@ -6810,7 +6889,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -6835,7 +6914,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -6884,7 +6963,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -6970,6 +7049,15 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "siphasher" version = "1.0.1" @@ -7004,8 +7092,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.10.0", - "calloop", - "calloop-wayland-source", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", "cursor-icon", "libc", "log", @@ -7022,14 +7110,41 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.10.0", + "calloop 0.14.3", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.2", + "thiserror 2.0.17", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + [[package]] name = "smithay-clipboard" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc8216eec463674a0e90f29e0ae41a4db573ec5b56b1c6c1c71615d249b6d846" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" dependencies = [ "libc", - "smithay-client-toolkit", + "smithay-client-toolkit 0.20.0", "wayland-backend", ] @@ -7124,6 +7239,9 @@ name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] [[package]] name = "strsim" @@ -7159,7 +7277,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -7171,7 +7289,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -7189,6 +7307,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo", + "siphasher", +] + [[package]] name = "syn" version = "1.0.109" @@ -7202,9 +7330,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.107" +version = "2.0.110" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b" +checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" dependencies = [ "proc-macro2", "quote", @@ -7228,7 +7356,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -7265,6 +7393,12 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tap" version = "1.0.1" @@ -7290,21 +7424,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "tenderdash-abci" -version = "1.4.0" -source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.4.0#e2dd15f39246081e7d569e585ab78ff5340116ac" -dependencies = [ - "bytes", - "hex", - "lhash", - "semver", - "tenderdash-proto 1.4.0", - "thiserror 2.0.17", - "tracing", - "url", -] - [[package]] name = "tenderdash-abci" version = "1.5.0-dev.2" @@ -7314,30 +7433,12 @@ dependencies = [ "hex", "lhash", "semver", - "tenderdash-proto 1.5.0-dev.2", + "tenderdash-proto", "thiserror 2.0.17", "tracing", "url", ] -[[package]] -name = "tenderdash-proto" -version = "1.4.0" -source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.4.0#e2dd15f39246081e7d569e585ab78ff5340116ac" -dependencies = [ - "bytes", - "chrono", - "derive_more 2.0.1", - "flex-error", - "num-derive", - "num-traits", - "prost 0.13.5", - "serde", - "subtle-encoding", - "tenderdash-proto-compiler 1.4.0", - "time", -] - [[package]] name = "tenderdash-proto" version = "1.5.0-dev.2" @@ -7348,26 +7449,12 @@ dependencies = [ "derive_more 2.0.1", "num-derive", "num-traits", - "prost 0.14.1", + "prost", "serde", "subtle-encoding", - "tenderdash-proto-compiler 1.5.0-dev.2", - "thiserror 2.0.17", - "time", -] - -[[package]] -name = "tenderdash-proto-compiler" -version = "1.4.0" -source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.4.0#e2dd15f39246081e7d569e585ab78ff5340116ac" -dependencies = [ - "fs_extra", - "prost-build 0.13.5", - "regex", - "tempfile", - "ureq", - "walkdir", - "zip 2.4.2", + "tenderdash-proto-compiler", + "thiserror 2.0.17", + "time", ] [[package]] @@ -7376,12 +7463,12 @@ version = "1.5.0-dev.2" source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.0-dev.2#3f6ac716c42125a01caceb42cc5997efa41c88fc" dependencies = [ "fs_extra", - "prost-build 0.14.1", + "prost-build", "regex", "tempfile", "ureq", "walkdir", - "zip 5.1.1", + "zip", ] [[package]] @@ -7419,7 +7506,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -7430,7 +7517,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -7507,6 +7594,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", + "png 0.17.16", "tiny-skia-path", ] @@ -7523,9 +7611,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -7548,22 +7636,11 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" -dependencies = [ - "platform-value 2.0.1", - "platform-version 2.0.1", - "serde_json", - "thiserror 2.0.17", -] - -[[package]] -name = "token-history-contract" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ - "platform-value 2.1.2", - "platform-version 2.1.2", + "platform-value", + "platform-version", "serde_json", "thiserror 2.0.17", ] @@ -7593,7 +7670,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -7629,9 +7706,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.16" +version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ "bytes", "futures-core", @@ -7725,37 +7802,6 @@ dependencies = [ "winnow 0.7.13", ] -[[package]] -name = "tonic" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" -dependencies = [ - "async-trait", - "base64 0.22.1", - "bytes", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "prost 0.13.5", - "rustls-native-certs", - "socket2 0.5.10", - "tokio", - "tokio-rustls", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", - "webpki-roots 0.26.11", -] - [[package]] name = "tonic" version = "0.14.2" @@ -7784,21 +7830,7 @@ dependencies = [ "tower-layer", "tower-service", "tracing", - "webpki-roots 1.0.3", -] - -[[package]] -name = "tonic-build" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac6f67be712d12f0b41328db3137e0d0757645d8904b4cb7d51cd9c2279e847" -dependencies = [ - "prettyplease", - "proc-macro2", - "prost-build 0.13.5", - "prost-types 0.13.5", - "quote", - "syn 2.0.107", + "webpki-roots", ] [[package]] @@ -7810,7 +7842,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -7820,8 +7852,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" dependencies = [ "bytes", - "prost 0.14.1", - "tonic 0.14.2", + "prost", + "tonic", ] [[package]] @@ -7832,37 +7864,12 @@ checksum = "b4a16cba4043dc3ff43fcb3f96b4c5c154c64cbd18ca8dce2ab2c6a451d058a2" dependencies = [ "prettyplease", "proc-macro2", - "prost-build 0.14.1", - "prost-types 0.14.1", + "prost-build", + "prost-types", "quote", - "syn 2.0.107", + "syn 2.0.110", "tempfile", - "tonic-build 0.14.2", -] - -[[package]] -name = "tonic-web-wasm-client" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66e3bb7acca55e6790354be650f4042d418fcf8e2bc42ac382348f2b6bf057e5" -dependencies = [ - "base64 0.22.1", - "byteorder", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "httparse", - "js-sys", - "pin-project", - "thiserror 2.0.17", - "tonic 0.13.1", - "tower-service", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", + "tonic-build", ] [[package]] @@ -7882,7 +7889,7 @@ dependencies = [ "js-sys", "pin-project", "thiserror 2.0.17", - "tonic 0.14.2", + "tonic", "tower-service", "wasm-bindgen", "wasm-bindgen-futures", @@ -7945,11 +7952,24 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.17", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.30" @@ -7958,7 +7978,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -8017,6 +8037,9 @@ name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] [[package]] name = "type-map" @@ -8065,27 +8088,63 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-ident" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-normalization" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.2.2" @@ -8122,20 +8181,19 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.1.2" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99ba1025f18a4a3fc3e9b48c868e9beb4f24f4b4b1a325bada26bd4119f46537" +checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" dependencies = [ "base64 0.22.1", "flate2", "log", "percent-encoding", "rustls", - "rustls-pemfile", "rustls-pki-types", "ureq-proto", "utf-8", - "webpki-roots 1.0.3", + "webpki-roots", ] [[package]] @@ -8168,6 +8226,33 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf-8" version = "0.7.6" @@ -8218,9 +8303,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version-compare" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" [[package]] name = "version_check" @@ -8261,25 +8346,6 @@ version = "0.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7302ac74a033bf17b6e609ceec0f891ca9200d502d31f02dc7908d3d98767c9d" -[[package]] -name = "vsss-rs" -version = "5.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec4ebcc5594130c31b49594d55c0583fe80621f252f570b222ca4845cafd3cf" -dependencies = [ - "crypto-bigint", - "elliptic-curve", - "elliptic-curve-tools 0.1.2", - "generic-array 1.3.4", - "hex", - "num", - "rand_core 0.6.4", - "serde", - "sha3", - "subtle", - "zeroize", -] - [[package]] name = "vsss-rs" version = "5.1.0" @@ -8287,8 +8353,8 @@ source = "git+https://github.com/dashpay/vsss-rs?branch=main#668f1406bf25a4b9a95 dependencies = [ "crypto-bigint", "elliptic-curve", - "elliptic-curve-tools 0.2.0", - "generic-array 1.3.4", + "elliptic-curve-tools", + "generic-array 1.3.5", "hex", "num", "rand_core 0.6.4", @@ -8310,22 +8376,11 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" -dependencies = [ - "platform-value 2.0.1", - "platform-version 2.0.1", - "serde_json", - "thiserror 2.0.17", -] - -[[package]] -name = "wallet-utils-contract" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ - "platform-value 2.1.2", - "platform-version 2.1.2", + "platform-value", + "platform-version", "serde_json", "thiserror 2.0.17", ] @@ -8356,9 +8411,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" dependencies = [ "cfg-if", "once_cell", @@ -8367,25 +8422,11 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.107", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" -version = "0.4.54" +version = "0.4.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" dependencies = [ "cfg-if", "js-sys", @@ -8396,9 +8437,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8406,22 +8447,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.107", - "wasm-bindgen-backend", + "syn 2.0.110", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" dependencies = [ "unicode-ident", ] @@ -8499,6 +8540,32 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfe33d551eb8bffd03ff067a8b44bb963919157841a99957151299a6307d19c" +dependencies = [ + "bitflags 2.10.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + [[package]] name = "wayland-protocols-plasma" version = "0.3.9" @@ -8550,9 +8617,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" dependencies = [ "js-sys", "wasm-bindgen", @@ -8586,27 +8653,18 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.3", -] - -[[package]] -name = "webpki-roots" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" dependencies = [ "rustls-pki-types", ] [[package]] name = "weezl" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" +checksum = "009936b22a61d342859b5f0ea64681cbb35a358ab548e2a44a8cf0dac2d980b8" [[package]] name = "wfd" @@ -8765,6 +8823,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "which" version = "7.0.3" @@ -8788,6 +8858,12 @@ dependencies = [ "winsafe", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -8909,7 +8985,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -8920,7 +8996,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -8931,7 +9007,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -8942,7 +9018,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -9351,7 +9427,7 @@ dependencies = [ "bitflags 2.10.0", "block2 0.5.1", "bytemuck", - "calloop", + "calloop 0.13.0", "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", @@ -9373,7 +9449,7 @@ dependencies = [ "redox_syscall 0.4.1", "rustix 0.38.44", "sctk-adwaita", - "smithay-client-toolkit", + "smithay-client-toolkit 0.19.2", "smol_str", "tracing", "unicode-segmentation", @@ -9409,6 +9485,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.52.0" @@ -9486,7 +9572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d31a19dae58475d019850e25b0170e94b16d382fbf6afee9c0e80fdc935e73e" dependencies = [ "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -9545,26 +9631,12 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "withdrawals-contract" -version = "2.0.1" -source = "git+https://github.com/dashpay/platform?tag=v2.0.1#5f93c70720a1ea09f91c9142668e17f314986f03" -dependencies = [ - "num_enum 0.5.11", - "platform-value 2.0.1", - "platform-version 2.0.1", - "serde", - "serde_json", - "serde_repr", - "thiserror 2.0.17", -] - -[[package]] -name = "withdrawals-contract" -version = "2.1.2" -source = "git+https://www.github.com/dashpay/platform?tag=v2.1.2#f49390fb4e44d2591066debde3e26d452f9d1ea2" +version = "3.0.0-dev.11" +source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" dependencies = [ "num_enum 0.5.11", - "platform-value 2.1.2", - "platform-version 2.1.2", + "platform-value", + "platform-version", "serde", "serde_json", "serde_repr", @@ -9573,9 +9645,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wyz" @@ -9645,17 +9717,22 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xml-rs" -version = "0.8.27" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmlwriter" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -9663,13 +9740,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", "synstructure", ] @@ -9725,7 +9802,7 @@ checksum = "dc6821851fa840b708b4cbbaf6241868cabc85a2dc22f426361b0292bfc0b836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", "zbus-lockstep", "zbus_xml", "zvariant", @@ -9740,7 +9817,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", "zbus_names", "zvariant", "zvariant_utils", @@ -9788,7 +9865,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -9808,7 +9885,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", "synstructure", ] @@ -9830,7 +9907,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", ] [[package]] @@ -9872,9 +9949,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", @@ -9883,9 +9960,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -9894,30 +9971,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.107", -] - -[[package]] -name = "zip" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" -dependencies = [ - "arbitrary", - "crc32fast", - "crossbeam-utils", - "displaydoc", - "flate2", - "indexmap 2.12.0", - "memchr", - "thiserror 2.0.17", - "zopfli", + "syn 2.0.110", ] [[package]] @@ -9964,9 +10024,9 @@ dependencies = [ [[package]] name = "zopfli" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" dependencies = [ "bumpalo", "crc32fast", @@ -10023,7 +10083,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.107", + "syn 2.0.110", "zvariant_utils", ] @@ -10036,7 +10096,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.107", + "syn 2.0.110", "winnow 0.7.13", ] diff --git a/Cargo.toml b/Cargo.toml index fad7a8570..acae0381e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "1.0.0-dev" license = "MIT" edition = "2024" default-run = "dash-evo-tool" -rust-version = "1.89" +rust-version = "1.92" [dependencies] tokio-util = { version = "0.7.15" } @@ -18,8 +18,16 @@ qrcode = "0.14.1" nix = { version = "0.30.1", features = ["signal"] } eframe = { version = "0.32.0", features = ["persistence"] } base64 = "0.22.1" -dash-sdk = { git = "https://www.github.com/dashpay/platform", tag = "v2.1.2", features = ["core_key_wallet", "core_bincode", "core_quorum-validation", "core_verification", "core_rpc_client"] } -grovestark = { git = "https://www.github.com/pauldelucia/grovestark", rev = "5313ba9df590f114e11934e281f1e8c8bc462794" } +dash-sdk = { git = "https://github.com/dashpay/platform.git", rev = "eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167", features = [ + "core_key_wallet", + "core_key_wallet_manager", + "core_bincode", + "core_quorum-validation", + "core_verification", + "core_rpc_client", + "core_spv", +] } +grovestark = { git = "https://www.github.com/pauldelucia/grovestark", rev = "c5823c8239792f75f93f59f025aa335ab6d42c36" } rayon = "1.8" thiserror = "2.0.12" serde = "1.0.219" @@ -46,7 +54,12 @@ arboard = { version = "3.6.0", default-features = false, features = [ directories = "6.0.0" rusqlite = { version = "0.37.0", features = ["functions"] } dark-light = "2.0.0" -image = { version = "0.25.6", default-features = false, features = ["png"] } +image = { version = "0.25.6", default-features = false, features = [ + "png", + "jpeg", +] } +resvg = "0.45" +reqwest = { version = "0.12", features = ["json", "stream"] } bitflags = "2.9.1" libsqlite3-sys = { version = "0.35.0", features = ["bundled"] } rust-embed = "8.7.2" @@ -54,6 +67,7 @@ zeroize = "1.8.1" zxcvbn = "3.1.0" argon2 = "0.5.3" # For Argon2 key derivation aes-gcm = "0.10.3" # For AES-256-GCM encryption +cbc = "0.1.2" # For CBC mode encryption crossbeam-channel = "0.5.15" regex = "1.11.1" humantime = "2.2.0" @@ -79,6 +93,3 @@ winres = "0.1" [lints.clippy] uninlined_format_args = "allow" - -[patch.crates-io] -elliptic-curve-tools = { git = "https://github.com/mikelodder7/elliptic-curve-tools", rev = "c989865fa71503d2cbf5c5795c4ebcf4a2f3221c" } diff --git a/README.md b/README.md index d3ba2043c..84cc820e5 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,16 @@ When the application runs for the first time, it creates an application director | Windows | `C:\Users\\AppData\Roaming\Dash-Evo-Tool\config` | | Linux | `/home//.config/dash-evo-tool/` | +#### Local Network or Devnet Configuration + +To connect to a local network or devnet, you need to configure the `.env` file with your network settings: + +1. Copy `.env.example` to the application directory for your OS (see table above) +2. Rename it to `.env` +3. Update the configuration values to match your local network or devnet settings + +See [`.env.example`](.env.example) for available configuration options. + ### Connect to a Network 1. **Open Network Chooser**: In the app, navigate to the **Network Chooser** screen. diff --git a/dash_core_configs/devnet.conf b/dash_core_configs/devnet.conf index 1f82a3b2a..b187cfc36 100644 --- a/dash_core_configs/devnet.conf +++ b/dash_core_configs/devnet.conf @@ -1,4 +1,4 @@ -devnet=cobblet +devnet=tadi [devnet] rpcport=29998 @@ -19,4 +19,4 @@ highsubsidyblocks=500 highsubsidyfactor=100 sporkaddr=yVy4AVRXHuax3pqB4G67pK4GtFEYHoZtv3 port=20001 -addnode=34.219.6.90:20001 \ No newline at end of file +addnode=35.93.130.185:20001 \ No newline at end of file diff --git a/icons/dashlogo.svg b/icons/dashlogo.svg new file mode 100644 index 000000000..10add1b16 --- /dev/null +++ b/icons/dashlogo.svg @@ -0,0 +1 @@ +Artboard 1 copy 2 \ No newline at end of file diff --git a/icons/dashpay.png b/icons/dashpay.png new file mode 100644 index 0000000000000000000000000000000000000000..7ee9dae91c01fa20a379e91322f9ef76f9814d80 GIT binary patch literal 1809 zcmYL~c{tPy7sr2NhOA?a8e_kfi$X+}u?=Q4_LB87j5SLb%ZReP*-hD{!dNZ|V@<9Q zF(c9-OAQkxbS-fyVuZ{4-sisOdA{fKJ?HtJzfYQt6^e%w#t8rbk2%`J?wA@UVrM(< z6oTmLF+g}blrcc2!IzH*g14i&kEJD`co>C9rL85j&*YLKY~f1f6~W063f474HEeO{^YE}rl<5693f~YJOHo?o(SZuJ7f(2 zY!}Q;jIR2zesyy3gWI1uqz$hJ=CnCtay8YuOlo(^%1QQo6fG|Me&e9u%XdjvYs!Oi zugz_Fm1)*+UW1r2%d-;ab0_ThRs7tA`lS<}$n9aq6n0)H`UKg7(0zVAc%u-JJzw_A2R$^wka79O1|@r_ zYfwBhxzxCGS%Fa#7OM!ac1$jVBS|F>6MxrFte!K88Yr@Z)Z2lxnM68ZFPb(V zTNO3*#mZZbU%|pd){_?eekzL3w7~DFIj)9B^)|=bi1n$3+>)${_fXy2c_g=U)Uum~ z5@Tcg_wycQlcxoM#nE>^JZRQ8hU%J!K3@zXI;j%>^pMMfK z?R>9_k|7DJ?MSQtOa5hA)>g)}#-7eOO!nA*`$7?;d8La{mTeg_KhleFkfdn1Sn?HI z_Zj9FX}i-^?=*vMh0MOe_$Dk>DsKy^JU-yES<+l{%Fpwk1S?W19~?%ZoK?j?Z*H%b z-I-Y))KqVy^QkzHDgHyV3xbgEWfs4kiRVnx*Jw{W&lC&BiTu2fmN+gwyTRU}8^uwy zQfyqtxBYY7?G~K8uaFs~&rbGerMz~&PE}L3Fk~V5PFjZxr+Dakgj9*8`6C-R;Eb27 zc}bz%vlCt)6)yL3ZW$;W@6&<3mu%sKVhh6Dg^0*#EM}lA0VFEJtLz)-SM@GCyo0%< zHXc5yu=C9ANh*3{T7#K`Tl{9ah-3IF150255giXV!?)8^y&y{fz5f)H1JS&DYH(vU z9T@bI^^ZcohPqQJR%{K%hDLg$;1FR0p>|9fH9>~EC&h;6Y|ir>f|9{bz^l4>$`^<}I!5ip2Kp>ERv3uag-+vN9Yh8V9wg)e39w2IbL7~6E)XzZAP7B)Go zbx~>NW;BtlB-ueOYjFv6%eh>A8xfa(qh<3smQn!Ob2GmkCOvuMk55j9ukBXvBN|ff zW(6-?Lq%U^dWKvWnux|?C#ojTP=9?o0Rkx^D=?s|xC z+Y{2TIqE3Heqo#OLbX6Ux1y9)0~^RK_in7^KxD->toCIo?19^WWn5BtheSi2wwQib z|Kr6uT6~A*cQa*Hp*cj-EV)S0Qg{?MN|0^)au;19!MR7WD7b93JP+!s2~h?nI8 zE^6S~-rlYwq3zN$UD@-sOAH>dRa}jHnt}JhBVv|v2u3Q z4JQzk#CmOEY<(znjWiA3^P`(W7${7N7p6kR;GG;zyW%W#b5PKfFz4d%e!(RrdtrS1 z-b`m2yo=M)GqbOwWMg*lI~S$i-UPOz8H#KqE$U zPKmd8_A5m-v!Sc+dfSygxhN63mb9$9-e-tP?ObMrR-1XOmUv=K?*19yDo0v6chRIq zIk<^PYJ3G(p7@JvLdZ*1_}=|}y~u{W3T*h2I!qEL#LU%_L0CzxJR**y3T#PZe|fX1 zzTXrzDKDiiQ_uwcrEm<}H=d<>{%Pi*T1#MQ`dZb8?}aj_-vr5bPAZ$XCA+l_g+2*) zB~Mwk)WN)m>Al9D?m_}%!18Xr2KEmOs?2EyuI(1eVp~u}6hl(GBw}<)Q2N poVU+9`C>N==I2K$O9()LkHGzgyl-%)l7mlPi@B+l3E3E%_%}0TE3E(k literal 0 HcmV?d00001 diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 4f3e8c521..50b3f5d47 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "1.89" +channel = "1.92" diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 094082ec2..41c74408b 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -50,7 +50,7 @@ parts: - libssl-dev override-build: | # Install Rust - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.89 + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.92 export PATH="$HOME/.cargo/bin:$PATH" rustc --version cargo --version diff --git a/src/app.rs b/src/app.rs index 40e42d086..2b7e4207e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,7 @@ use crate::database::Database; use crate::logging::initialize_logger; use crate::model::settings::Settings; use crate::ui::contracts_documents::contracts_documents_screen::DocumentQueryScreen; -use crate::ui::contracts_documents::dashpay_coming_soon_screen::DashpayScreen; +use crate::ui::dashpay::{DashPayScreen, DashPaySubscreen, ProfileSearchScreen}; use crate::ui::dpns::dpns_contested_names_screen::{ DPNSScreen, DPNSSubscreen, ScheduledVoteCastingStatus, }; @@ -19,6 +19,7 @@ use crate::ui::identities::identities_screen::IdentitiesScreen; use crate::ui::network_chooser_screen::NetworkChooserScreen; use crate::ui::theme::ThemeMode; use crate::ui::tokens::tokens_screen::{TokensScreen, TokensSubscreen}; +use crate::ui::tools::address_balance_screen::AddressBalanceScreen; use crate::ui::tools::contract_visualizer_screen::ContractVisualizerScreen; use crate::ui::tools::document_visualizer_screen::DocumentVisualizerScreen; use crate::ui::tools::grovestark_screen::GroveSTARKScreen; @@ -28,6 +29,7 @@ use crate::ui::tools::proof_log_screen::ProofLogScreen; use crate::ui::tools::proof_visualizer_screen::ProofVisualizerScreen; use crate::ui::tools::transition_visualizer_screen::TransitionVisualizerScreen; use crate::ui::wallets::wallets_screen::WalletsBalancesScreen; +use crate::ui::welcome_screen::WelcomeScreen; use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike, ScreenType}; use crate::utils::egui_mpsc::{self, EguiMpscAsync, EguiMpscSync}; use crate::utils::tasks::TaskManager; @@ -68,19 +70,23 @@ pub struct AppState { pub devnet_app_context: Option>, pub local_app_context: Option>, #[allow(dead_code)] // Kept alive for the lifetime of the app - pub mainnet_core_zmq_listener: CoreZMQListener, + pub mainnet_core_zmq_listener: Option, #[allow(dead_code)] // Kept alive for the lifetime of the app - pub testnet_core_zmq_listener: CoreZMQListener, + pub testnet_core_zmq_listener: Option, #[allow(dead_code)] // Kept alive for the lifetime of the app - pub devnet_core_zmq_listener: CoreZMQListener, + pub devnet_core_zmq_listener: Option, #[allow(dead_code)] // Kept alive for the lifetime of the app - pub local_core_zmq_listener: CoreZMQListener, + pub local_core_zmq_listener: Option, pub core_message_receiver: mpsc::Receiver<(ZMQMessage, Network)>, pub task_result_sender: egui_mpsc::SenderAsync, // Channel sender for sending task results pub task_result_receiver: tokiompsc::Receiver, // Channel receiver for receiving task results pub theme_preference: ThemeMode, // Current theme preference last_scheduled_vote_check: Instant, // Last time we checked if there are scheduled masternode votes to cast pub subtasks: Arc, // Subtasks manager for graceful shutdown + /// Whether to show the welcome/onboarding screen + pub show_welcome_screen: bool, + /// The welcome screen instance (only created if needed) + pub welcome_screen: Option, } #[derive(Debug, Clone, PartialEq)] @@ -135,6 +141,13 @@ pub enum AppAction { BackendTask(BackendTask), BackendTasks(Vec, BackendTasksExecutionMode), Custom(String), + /// Mark onboarding as complete, hide welcome screen, and optionally navigate + OnboardingComplete { + /// The main screen to show + main_screen: RootScreenType, + /// Optional sub-screen to push onto the stack + add_screen: Option>, + }, } impl BitOrAssign for AppAction { @@ -166,6 +179,7 @@ impl AppState { let password_info = settings.password_info; let theme_preference = settings.theme_mode; let overwrite_dash_conf = settings.overwrite_dash_conf; + let onboarding_completed = settings.onboarding_completed; let subtasks = Arc::new(TaskManager::new()); let mainnet_app_context = match AppContext::new( @@ -221,6 +235,7 @@ impl AppState { let mut contract_visualizer_screen = ContractVisualizerScreen::new(&mainnet_app_context); let mut proof_log_screen = ProofLogScreen::new(&mainnet_app_context); let mut platform_info_screen = PlatformInfoScreen::new(&mainnet_app_context); + let mut address_balance_screen = AddressBalanceScreen::new(&mainnet_app_context); let mut grovestark_screen = GroveSTARKScreen::new(&mainnet_app_context); let mut document_query_screen = DocumentQueryScreen::new(&mainnet_app_context); let mut tokens_balances_screen = @@ -229,7 +244,18 @@ impl AppState { TokensScreen::new(&mainnet_app_context, TokensSubscreen::SearchTokens); let mut token_creator_screen = TokensScreen::new(&mainnet_app_context, TokensSubscreen::TokenCreator); - let mut contracts_dashpay_screen = DashpayScreen::new(&mainnet_app_context); + let mut contracts_dashpay_screen = + DashPayScreen::new(&mainnet_app_context, DashPaySubscreen::Profile); + + // Create DashPay screens + let mut dashpay_contacts_screen = + DashPayScreen::new(&mainnet_app_context, DashPaySubscreen::Contacts); + let mut dashpay_profile_screen = + DashPayScreen::new(&mainnet_app_context, DashPaySubscreen::Profile); + let mut dashpay_payments_screen = + DashPayScreen::new(&mainnet_app_context, DashPaySubscreen::Payments); + let mut dashpay_profile_search_screen = + ProfileSearchScreen::new(mainnet_app_context.clone()); let mut network_chooser_screen = NetworkChooserScreen::new( &mainnet_app_context, @@ -267,14 +293,23 @@ impl AppState { wallets_balances_screen = WalletsBalancesScreen::new(testnet_app_context); proof_log_screen = ProofLogScreen::new(testnet_app_context); platform_info_screen = PlatformInfoScreen::new(testnet_app_context); + address_balance_screen = AddressBalanceScreen::new(testnet_app_context); masternode_list_diff_screen = MasternodeListDiffScreen::new(testnet_app_context); - contracts_dashpay_screen = DashpayScreen::new(testnet_app_context); + contracts_dashpay_screen = + DashPayScreen::new(testnet_app_context, DashPaySubscreen::Profile); tokens_balances_screen = TokensScreen::new(testnet_app_context, TokensSubscreen::MyTokens); token_search_screen = TokensScreen::new(testnet_app_context, TokensSubscreen::SearchTokens); token_creator_screen = TokensScreen::new(testnet_app_context, TokensSubscreen::TokenCreator); + dashpay_contacts_screen = + DashPayScreen::new(testnet_app_context, DashPaySubscreen::Contacts); + dashpay_profile_screen = + DashPayScreen::new(testnet_app_context, DashPaySubscreen::Profile); + dashpay_payments_screen = + DashPayScreen::new(testnet_app_context, DashPaySubscreen::Payments); + dashpay_profile_search_screen = ProfileSearchScreen::new(testnet_app_context.clone()); } else if let (Network::Devnet, Some(devnet_app_context)) = (chosen_network, devnet_app_context.as_ref()) { @@ -295,12 +330,20 @@ impl AppState { wallets_balances_screen = WalletsBalancesScreen::new(devnet_app_context); proof_log_screen = ProofLogScreen::new(devnet_app_context); platform_info_screen = PlatformInfoScreen::new(devnet_app_context); + address_balance_screen = AddressBalanceScreen::new(devnet_app_context); tokens_balances_screen = TokensScreen::new(devnet_app_context, TokensSubscreen::MyTokens); token_search_screen = TokensScreen::new(devnet_app_context, TokensSubscreen::SearchTokens); token_creator_screen = TokensScreen::new(devnet_app_context, TokensSubscreen::TokenCreator); + dashpay_contacts_screen = + DashPayScreen::new(devnet_app_context, DashPaySubscreen::Contacts); + dashpay_profile_screen = + DashPayScreen::new(devnet_app_context, DashPaySubscreen::Profile); + dashpay_payments_screen = + DashPayScreen::new(devnet_app_context, DashPaySubscreen::Payments); + dashpay_profile_search_screen = ProfileSearchScreen::new(devnet_app_context.clone()); } else if let (Network::Regtest, Some(local_app_context)) = (chosen_network, local_app_context.as_ref()) { @@ -320,13 +363,22 @@ impl AppState { masternode_list_diff_screen = MasternodeListDiffScreen::new(local_app_context); proof_log_screen = ProofLogScreen::new(local_app_context); platform_info_screen = PlatformInfoScreen::new(local_app_context); - contracts_dashpay_screen = DashpayScreen::new(local_app_context); + address_balance_screen = AddressBalanceScreen::new(local_app_context); + contracts_dashpay_screen = + DashPayScreen::new(local_app_context, DashPaySubscreen::Profile); tokens_balances_screen = TokensScreen::new(local_app_context, TokensSubscreen::MyTokens); token_search_screen = TokensScreen::new(local_app_context, TokensSubscreen::SearchTokens); token_creator_screen = TokensScreen::new(local_app_context, TokensSubscreen::TokenCreator); + dashpay_contacts_screen = + DashPayScreen::new(local_app_context, DashPaySubscreen::Contacts); + dashpay_profile_screen = + DashPayScreen::new(local_app_context, DashPaySubscreen::Profile); + dashpay_payments_screen = + DashPayScreen::new(local_app_context, DashPaySubscreen::Payments); + dashpay_profile_search_screen = ProfileSearchScreen::new(local_app_context.clone()); } // // Create a channel with a buffer size of 32 (adjust as needed) @@ -344,13 +396,25 @@ impl AppState { .core_zmq_endpoint .clone() .unwrap_or_else(|| "tcp://127.0.0.1:23708".to_string()); - let mainnet_core_zmq_listener = CoreZMQListener::spawn_listener( - Network::Dash, - &mainnet_core_zmq_endpoint, - core_message_sender.clone(), // Clone the sender for each listener - Some(mainnet_app_context.sx_zmq_status.clone()), - ) - .expect("Failed to create mainnet InstantSend listener"); + let mainnet_disable_zmq = mainnet_app_context + .get_settings() + .ok() + .flatten() + .map(|s| s.disable_zmq) + .unwrap_or(false); + let mainnet_core_zmq_listener = if !mainnet_disable_zmq { + Some( + CoreZMQListener::spawn_listener( + Network::Dash, + &mainnet_core_zmq_endpoint, + core_message_sender.clone(), // Clone the sender for each listener + Some(mainnet_app_context.sx_zmq_status.clone()), + ) + .expect("Failed to create mainnet InstantSend listener"), + ) + } else { + None + }; let testnet_tx_zmq_status_option = testnet_app_context .as_ref() @@ -360,13 +424,24 @@ impl AppState { .as_ref() .and_then(|ctx| ctx.config.read().unwrap().core_zmq_endpoint.clone()) .unwrap_or_else(|| "tcp://127.0.0.1:23709".to_string()); - let testnet_core_zmq_listener = CoreZMQListener::spawn_listener( - Network::Testnet, - &testnet_core_zmq_endpoint, - core_message_sender.clone(), // Use the original sender or create a new one if needed - testnet_tx_zmq_status_option, - ) - .expect("Failed to create testnet InstantSend listener"); + let testnet_disable_zmq = testnet_app_context + .as_ref() + .and_then(|ctx| ctx.get_settings().ok().flatten()) + .map(|s| s.disable_zmq) + .unwrap_or(false); + let testnet_core_zmq_listener = if !testnet_disable_zmq { + Some( + CoreZMQListener::spawn_listener( + Network::Testnet, + &testnet_core_zmq_endpoint, + core_message_sender.clone(), // Use the original sender or create a new one if needed + testnet_tx_zmq_status_option, + ) + .expect("Failed to create testnet InstantSend listener"), + ) + } else { + None + }; let devnet_tx_zmq_status_option = devnet_app_context .as_ref() @@ -376,13 +451,24 @@ impl AppState { .as_ref() .and_then(|ctx| ctx.config.read().unwrap().core_zmq_endpoint.clone()) .unwrap_or_else(|| "tcp://127.0.0.1:23710".to_string()); - let devnet_core_zmq_listener = CoreZMQListener::spawn_listener( - Network::Devnet, - &devnet_core_zmq_endpoint, - core_message_sender.clone(), - devnet_tx_zmq_status_option, - ) - .expect("Failed to create devnet InstantSend listener"); + let devnet_disable_zmq = devnet_app_context + .as_ref() + .and_then(|ctx| ctx.get_settings().ok().flatten()) + .map(|s| s.disable_zmq) + .unwrap_or(false); + let devnet_core_zmq_listener = if !devnet_disable_zmq { + Some( + CoreZMQListener::spawn_listener( + Network::Devnet, + &devnet_core_zmq_endpoint, + core_message_sender.clone(), + devnet_tx_zmq_status_option, + ) + .expect("Failed to create devnet InstantSend listener"), + ) + } else { + None + }; let local_tx_zmq_status_option = local_app_context .as_ref() @@ -392,15 +478,26 @@ impl AppState { .as_ref() .and_then(|ctx| ctx.config.read().unwrap().core_zmq_endpoint.clone()) .unwrap_or_else(|| "tcp://127.0.0.1:20302".to_string()); - let local_core_zmq_listener = CoreZMQListener::spawn_listener( - Network::Regtest, - &local_core_zmq_endpoint, - core_message_sender, - local_tx_zmq_status_option, - ) - .expect("Failed to create local InstantSend listener"); + let local_disable_zmq = local_app_context + .as_ref() + .and_then(|ctx| ctx.get_settings().ok().flatten()) + .map(|s| s.disable_zmq) + .unwrap_or(false); + let local_core_zmq_listener = if !local_disable_zmq { + Some( + CoreZMQListener::spawn_listener( + Network::Regtest, + &local_core_zmq_endpoint, + core_message_sender, + local_tx_zmq_status_option, + ) + .expect("Failed to create local InstantSend listener"), + ) + } else { + None + }; - Self { + let mut app_state = Self { main_screens: [ ( RootScreenType::RootScreenIdentities, @@ -450,6 +547,10 @@ impl AppState { RootScreenType::RootScreenToolsPlatformInfoScreen, Screen::PlatformInfoScreen(platform_info_screen), ), + ( + RootScreenType::RootScreenToolsAddressBalanceScreen, + Screen::AddressBalanceScreen(address_balance_screen), + ), ( RootScreenType::RootScreenToolsGroveSTARKScreen, Screen::GroveSTARKScreen(grovestark_screen), @@ -460,7 +561,7 @@ impl AppState { ), ( RootScreenType::RootScreenDashpay, - Screen::DashpayScreen(contracts_dashpay_screen), + Screen::DashPayScreen(contracts_dashpay_screen), ), ( RootScreenType::RootScreenNetworkChooser, @@ -482,6 +583,22 @@ impl AppState { RootScreenType::RootScreenTokenCreator, Screen::TokensScreen(Box::new(token_creator_screen)), ), + ( + RootScreenType::RootScreenDashPayContacts, + Screen::DashPayScreen(dashpay_contacts_screen), + ), + ( + RootScreenType::RootScreenDashPayProfile, + Screen::DashPayScreen(dashpay_profile_screen), + ), + ( + RootScreenType::RootScreenDashPayPayments, + Screen::DashPayScreen(dashpay_payments_screen), + ), + ( + RootScreenType::RootScreenDashPayProfileSearch, + Screen::DashPayProfileSearchScreen(dashpay_profile_search_screen), + ), ] .into(), selected_main_screen, @@ -501,7 +618,41 @@ impl AppState { theme_preference, last_scheduled_vote_check: Instant::now(), subtasks, + show_welcome_screen: !onboarding_completed, + welcome_screen: None, + }; + + // Initialize welcome screen if needed (after mainnet_app_context is owned by the struct) + if app_state.show_welcome_screen { + app_state.welcome_screen = + Some(WelcomeScreen::new(app_state.mainnet_app_context.clone())); + } else { + // Auto-start SPV sync if onboarding is completed, backend mode is SPV, auto-start is enabled, + // and developer mode is enabled. + // TODO: SPV auto-start is gated behind developer mode while SPV is in development. + // Remove the is_developer_mode() check once SPV is production-ready. + let current_context = app_state.current_app_context(); + let auto_start_spv = db.get_auto_start_spv().unwrap_or(true); + if auto_start_spv + && current_context.is_developer_mode() + && current_context.core_backend_mode() == crate::spv::CoreBackendMode::Spv + { + if let Err(e) = current_context.start_spv() { + tracing::warn!("Failed to auto-start SPV sync: {}", e); + } else { + tracing::info!("SPV sync started automatically for {:?}", chosen_network); + } + } + + // Refresh ALL main screens so they load data properly + // This ensures screens like DashPay Profile have identities loaded + // even if they're not the initially selected screen + for screen in app_state.main_screens.values_mut() { + screen.refresh_on_arrival(); + } } + + app_state } /// Allows enabling or disabling animations globally for the app. @@ -583,6 +734,7 @@ impl AppState { pub fn change_network(&mut self, network: Network) { self.chosen_network = network; let app_context = self.current_app_context().clone(); + for screen in self.main_screens.values_mut() { screen.change_context(app_context.clone()) } @@ -654,9 +806,11 @@ impl App for AppState { BackendTaskSuccessResult::Refresh => { self.visible_screen_mut().refresh(); } - BackendTaskSuccessResult::Message(ref msg) => { + BackendTaskSuccessResult::Message(ref _msg) => { + // Let the screen handle Message via display_task_result + // so it can do custom handling (like clearing spinners) self.visible_screen_mut() - .display_message(msg, MessageType::Success); + .display_task_result(unboxed_message); } BackendTaskSuccessResult::UpdatedThemePreference(new_theme) => { self.theme_preference = new_theme; @@ -829,7 +983,16 @@ impl App for AppState { } } - let action = self.visible_screen_mut().ui(ctx); + // Show welcome screen if onboarding not completed + let action = if self.show_welcome_screen { + if let Some(welcome_screen) = &mut self.welcome_screen { + welcome_screen.ui(ctx) + } else { + AppAction::None + } + } else { + self.visible_screen_mut().ui(ctx) + }; match action { AppAction::AddScreen(screen) => self.screen_stack.push(screen), @@ -900,21 +1063,47 @@ impl App for AppState { .ok(); } AppAction::Custom(_) => {} + AppAction::OnboardingComplete { + main_screen, + add_screen, + } => { + self.show_welcome_screen = false; + self.welcome_screen = None; + self.selected_main_screen = main_screen; + self.active_root_screen_mut().refresh_on_arrival(); + self.current_app_context().update_settings(main_screen).ok(); + // If there's an additional screen to push, create and push it + if let Some(screen_type) = add_screen { + let screen = screen_type.create_screen(self.current_app_context()); + self.screen_stack.push(screen); + } + // Start SPV sync after onboarding completes (if auto-start is enabled and developer mode is on) + // TODO: SPV auto-start is gated behind developer mode while SPV is in development. + // Remove the is_developer_mode() check once SPV is production-ready. + let current_context = self.current_app_context(); + let auto_start_spv = current_context.db.get_auto_start_spv().unwrap_or(true); + if auto_start_spv + && current_context.is_developer_mode() + && current_context.core_backend_mode() == crate::spv::CoreBackendMode::Spv + { + if let Err(e) = current_context.start_spv() { + tracing::warn!("Failed to start SPV sync after onboarding: {}", e); + } else { + tracing::info!("SPV sync started after onboarding"); + } + } + } } } fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { - // Signal all background tasks to cancel - tracing::debug!("App received on_exit event, cancelling all background tasks"); - - // if ctx.input(|i| i.viewport().close_requested()) { - if !self.subtasks.cancellation_token.is_cancelled() { - self.subtasks.shutdown().unwrap_or_else(|e| { - tracing::debug!("Failed to shutdown subtasks: {}", e); - }); - } else { - tracing::debug!("Shutdown already in progress, ignoring close request"); + // Gracefully shutdown all background tasks, waiting for them to complete + // This ensures tasks like the dash-qt handler have time to check their settings + // and decide whether to terminate the process or leave it running + tracing::debug!("App received on_exit event, initiating graceful shutdown"); + if let Err(e) = self.subtasks.shutdown() { + tracing::error!("Error during task shutdown: {}", e); } - // } + tracing::debug!("App shutdown complete"); } } diff --git a/src/backend_task/broadcast_state_transition.rs b/src/backend_task/broadcast_state_transition.rs index 492138805..52b3fd77a 100644 --- a/src/backend_task/broadcast_state_transition.rs +++ b/src/backend_task/broadcast_state_transition.rs @@ -14,9 +14,7 @@ impl AppContext { sdk: &Sdk, ) -> Result { match state_transition.broadcast(sdk, None).await { - Ok(_) => Ok(BackendTaskSuccessResult::Message( - "State transition broadcasted successfully".to_string(), - )), + Ok(_) => Ok(BackendTaskSuccessResult::BroadcastedStateTransition), Err(e) => Err(format!("Error broadcasting state transition: {}", e)), } } diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index 2763aa4cd..dc2b83179 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -90,13 +90,13 @@ impl AppContext { } ContestedResourceTask::ScheduleDPNSVotes(scheduled_votes) => self .insert_scheduled_votes(scheduled_votes) - .map(|_| BackendTaskSuccessResult::Message("Votes scheduled".to_string())) + .map(|_| BackendTaskSuccessResult::ScheduledVotes) .map_err(|e| format!("Error inserting scheduled votes: {}", e)), ContestedResourceTask::CastScheduledVote(scheduled_vote, voter) => self .vote_on_dpns_name( &scheduled_vote.contested_name, scheduled_vote.choice, - &vec![(**voter).clone()], + &[(**voter).clone()], sdk, sender, ) diff --git a/src/backend_task/contested_names/query_dpns_contested_resources.rs b/src/backend_task/contested_names/query_dpns_contested_resources.rs index 6638f34cc..502d01c7b 100644 --- a/src/backend_task/contested_names/query_dpns_contested_resources.rs +++ b/src/backend_task/contested_names/query_dpns_contested_resources.rs @@ -242,9 +242,7 @@ impl AppContext { sender .send(TaskResult::Success(Box::new( - BackendTaskSuccessResult::Message( - "Successfully refreshed DPNS contests".to_string(), - ), + BackendTaskSuccessResult::RefreshedDpnsContests, ))) .await .map_err(|e| { diff --git a/src/backend_task/contract.rs b/src/backend_task/contract.rs index 30d5f3a0b..cc9db1411 100644 --- a/src/backend_task/contract.rs +++ b/src/backend_task/contract.rs @@ -187,12 +187,6 @@ impl AppContext { sender, ) .await - .map(|_| { - BackendTaskSuccessResult::Message( - "Successfully registered contract".to_string(), - ) - }) - .map_err(|e| format!("Error registering contract: {}", e)) } ContractTask::UpdateDataContract(mut data_contract, identity, signing_key) => { AppContext::update_data_contract( @@ -204,16 +198,10 @@ impl AppContext { sender, ) .await - .map(|_| { - BackendTaskSuccessResult::Message("Successfully updated contract".to_string()) - }) - .map_err(|e| format!("Error updating contract: {}", e)) } ContractTask::RemoveContract(identifier) => self .remove_contract(&identifier) - .map(|_| { - BackendTaskSuccessResult::Message("Successfully removed contract".to_string()) - }) + .map(|_| BackendTaskSuccessResult::RemovedContract) .map_err(|e| format!("Error removing contract: {}", e)), ContractTask::SaveDataContract(data_contract, alias, insert_tokens_too) => { self.db @@ -224,9 +212,7 @@ impl AppContext { self, ) .map_err(|e| format!("Error inserting contract into the database: {}", e))?; - Ok(BackendTaskSuccessResult::Message( - "DataContract successfully saved".to_string(), - )) + Ok(BackendTaskSuccessResult::SavedContract) } } } diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 170c61d20..aed28e3b2 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -1,4 +1,7 @@ +mod recover_asset_locks; +mod refresh_single_key_wallet_info; mod refresh_wallet_info; +mod send_single_key_wallet_payment; mod start_dash_qt; use crate::app_dir::core_cookie_path; @@ -6,21 +9,63 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::config::{Config, NetworkConfig}; use crate::context::AppContext; use crate::model::wallet::Wallet; +use crate::model::wallet::single_key::SingleKeyWallet; +use crate::spv::CoreBackendMode; use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dashcore_rpc::{Auth, Client}; +use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1}; +use dash_sdk::dpp::dashcore::sighash::SighashCache; use dash_sdk::dpp::dashcore::{ - Address, Block, ChainLock, InstantLock, Network, OutPoint, Transaction, TxOut, + Address, Block, ChainLock, InstantLock, Network, OutPoint, PrivateKey, Transaction, TxOut, }; +use dash_sdk::dpp::key_wallet::Network as WalletNetwork; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeLevel; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use dash_sdk::dpp::key_wallet_manager::wallet_manager::{WalletError, WalletId, WalletManager}; use std::path::PathBuf; +use std::str::FromStr; use std::sync::{Arc, RwLock}; +const DEFAULT_BIP44_ACCOUNT_INDEX: u32 = 0; + +/// Check if two networks use the same address format. +/// Testnet, Devnet, and Regtest all use testnet-style addresses. +fn networks_address_compatible(a: &Network, b: &Network) -> bool { + matches!( + (a, b), + (Network::Dash, Network::Dash) + | ( + Network::Testnet | Network::Devnet | Network::Regtest, + Network::Testnet | Network::Devnet | Network::Regtest, + ) + ) +} + +use crate::backend_task::wallet::PlatformSyncMode; + #[derive(Debug, Clone)] pub enum CoreTask { #[allow(dead_code)] // May be used for getting single chain lock GetBestChainLock, GetBestChainLocks, - RefreshWalletInfo(Arc>), + /// Refresh wallet info from Core. The optional PlatformSyncMode controls whether + /// and how to sync Platform address balances: + /// - None: Skip Platform sync entirely (Core only) + /// - Some(mode): Sync Platform with the specified mode + RefreshWalletInfo(Arc>, Option), + RefreshSingleKeyWalletInfo(Arc>), StartDashQT(Network, PathBuf, bool), + SendWalletPayment { + wallet: Arc>, + request: WalletPaymentRequest, + }, + SendSingleKeyWalletPayment { + wallet: Arc>, + request: WalletPaymentRequest, + }, + RecoverAssetLocks(Arc>), } impl PartialEq for CoreTask { fn eq(&self, other: &Self) -> bool { @@ -29,17 +74,49 @@ impl PartialEq for CoreTask { (CoreTask::GetBestChainLock, CoreTask::GetBestChainLock) | (CoreTask::GetBestChainLocks, CoreTask::GetBestChainLocks) | ( - CoreTask::RefreshWalletInfo(_), - CoreTask::RefreshWalletInfo(_) + CoreTask::RefreshWalletInfo(_, _), + CoreTask::RefreshWalletInfo(_, _) + ) + | ( + CoreTask::RefreshSingleKeyWalletInfo(_), + CoreTask::RefreshSingleKeyWalletInfo(_) ) | ( CoreTask::StartDashQT(_, _, _), CoreTask::StartDashQT(_, _, _) ) + | ( + CoreTask::SendWalletPayment { .. }, + CoreTask::SendWalletPayment { .. }, + ) + | ( + CoreTask::SendSingleKeyWalletPayment { .. }, + CoreTask::SendSingleKeyWalletPayment { .. }, + ) + | ( + CoreTask::RecoverAssetLocks(_), + CoreTask::RecoverAssetLocks(_), + ) ) } } +/// A single recipient in a payment request +#[derive(Debug, Clone)] +pub struct PaymentRecipient { + pub address: String, + pub amount_duffs: u64, +} + +#[derive(Debug, Clone)] +pub struct WalletPaymentRequest { + pub recipients: Vec, + pub subtract_fee_from_amount: bool, + pub memo: Option, + /// Override fee to use instead of calculated fee (for retry after min relay fee error) + pub override_fee: Option, +} + #[derive(Debug, Clone, PartialEq)] pub enum CoreItem { InstantLockedTransaction(Transaction, Vec<(OutPoint, TxOut, Address)>, InstantLock), @@ -55,7 +132,10 @@ pub enum CoreItem { } impl AppContext { - pub async fn run_core_task(&self, task: CoreTask) -> Result { + pub async fn run_core_task( + self: &Arc, + task: CoreTask, + ) -> Result { match task { CoreTask::GetBestChainLock => self .core_client @@ -110,13 +190,70 @@ impl AppContext { local_chainlock, ))) } - CoreTask::RefreshWalletInfo(wallet) => self - .refresh_wallet_info(wallet) - .map_err(|e| format!("Error refreshing wallet: {}", e)), + CoreTask::RefreshWalletInfo(wallet, platform_sync_mode) => { + // Get wallet seed hash for Platform balance refresh + let seed_hash = { + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + wallet_guard.seed_hash() + }; + + if self.core_backend_mode() == crate::spv::CoreBackendMode::Spv { + self.reconcile_spv_wallets() + .await + .map_err(|e| format!("Error refreshing wallet via SPV: {}", e))?; + } else { + // Run blocking RPC calls on a dedicated thread pool to avoid freezing the UI + let ctx = self.clone(); + tokio::task::spawn_blocking(move || ctx.refresh_wallet_info(wallet)) + .await + .map_err(|e| format!("Task join error: {}", e))? + .map_err(|e| format!("Error refreshing wallet: {}", e))?; + } + + // Also refresh Platform address balances if a sync mode is specified + let warning = if let Some(sync_mode) = platform_sync_mode { + match self + .fetch_platform_address_balances(seed_hash, sync_mode) + .await + { + Ok(_) => None, + Err(e) => { + tracing::warn!("Failed to fetch Platform address balances: {}", e); + Some(format!("Platform sync failed: {}", e)) + } + } + } else { + None + }; + + Ok(BackendTaskSuccessResult::RefreshedWallet { warning }) + } + CoreTask::RefreshSingleKeyWalletInfo(wallet) => { + // Run blocking RPC calls on a dedicated thread pool to avoid freezing the UI + let ctx = self.clone(); + tokio::task::spawn_blocking(move || ctx.refresh_single_key_wallet_info(wallet)) + .await + .map_err(|e| format!("Task join error: {}", e))? + .map_err(|e| format!("Error refreshing wallet: {}", e))?; + Ok(BackendTaskSuccessResult::RefreshedWallet { warning: None }) + } CoreTask::StartDashQT(network, custom_dash_qt, overwrite_dash_conf) => self .start_dash_qt(network, custom_dash_qt, overwrite_dash_conf) .map_err(|e| e.to_string()) .map(|_| BackendTaskSuccessResult::None), + CoreTask::SendWalletPayment { wallet, request } => { + self.send_wallet_payment(wallet, request).await + } + CoreTask::SendSingleKeyWalletPayment { wallet, request } => { + self.send_single_key_wallet_payment(wallet, request).await + } + CoreTask::RecoverAssetLocks(wallet) => { + // Run blocking RPC calls on a dedicated thread pool to avoid freezing the UI + let ctx = self.clone(); + tokio::task::spawn_blocking(move || ctx.recover_asset_locks(wallet)) + .await + .map_err(|e| format!("Task join error: {}", e))? + } } } @@ -159,4 +296,356 @@ impl AppContext { Err(format!("{} config not found", network)) } } + + async fn send_wallet_payment( + &self, + wallet: Arc>, + request: WalletPaymentRequest, + ) -> Result { + match self.core_backend_mode() { + CoreBackendMode::Spv => self.send_wallet_payment_via_spv(wallet, request).await, + CoreBackendMode::Rpc => self.send_wallet_payment_via_rpc(wallet, request).await, + } + } +} + +impl AppContext { + async fn send_wallet_payment_via_rpc( + &self, + wallet: Arc>, + request: WalletPaymentRequest, + ) -> Result { + let parsed_recipients = self.parse_recipients(&request)?; + + const DEFAULT_TX_FEE: u64 = 1_000; + + let tx = { + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + if !wallet_guard.is_open() { + return Err("Wallet must be unlocked".to_string()); + } + wallet_guard.build_multi_recipient_payment_transaction( + self.network, + &parsed_recipients, + DEFAULT_TX_FEE, + request.subtract_fee_from_amount, + Some(self), + )? + }; + + let txid = self + .core_client + .read() + .expect("Core client lock was poisoned") + .send_raw_transaction(&tx) + .map_err(|e| format!("Failed to broadcast transaction: {e}"))?; + + let total_amount: u64 = request.recipients.iter().map(|r| r.amount_duffs).sum(); + let recipients_result: Vec<(String, u64)> = request + .recipients + .iter() + .map(|r| (r.address.clone(), r.amount_duffs)) + .collect(); + + Ok(BackendTaskSuccessResult::WalletPayment { + txid: txid.to_string(), + recipients: recipients_result, + total_amount, + }) + } + + async fn send_wallet_payment_via_spv( + &self, + wallet: Arc>, + request: WalletPaymentRequest, + ) -> Result { + self.reconcile_spv_wallets() + .await + .map_err(|e| format!("Unable to sync wallet before send: {}", e))?; + + let parsed_recipients = self.parse_recipients(&request)?; + let seed_hash = { + let guard = wallet.read().map_err(|e| e.to_string())?; + if !guard.is_open() { + return Err("Wallet must be unlocked".to_string()); + } + guard.seed_hash() + }; + + let wallet_id = self + .spv_manager + .wallet_id_for_seed(seed_hash) + .ok_or_else(|| "Wallet not loaded into SPV".to_string())?; + + let tx = { + let wm_arc = self.spv_manager.wallet(); + let mut wm = wm_arc.write().await; + let unsigned = self.build_spv_unsigned_transaction_multi( + &mut wm, + &wallet_id, + &parsed_recipients, + &request, + )?; + self.sign_spv_transaction(&mut wm, &wallet_id, unsigned)? + }; + + self.spv_manager + .broadcast_transaction(&tx) + .await + .map_err(|e| format!("Broadcast failed: {e}"))?; + + self.reconcile_spv_wallets() + .await + .map_err(|e| format!("Failed to refresh wallet after send: {}", e))?; + + // Calculate actual amounts sent from the transaction outputs + let recipients_result: Vec<(String, u64)> = request + .recipients + .iter() + .zip(parsed_recipients.iter()) + .map(|(req, (addr, _))| { + let actual_amount = Self::sum_outputs_to_script(&tx, &addr.script_pubkey()) + .unwrap_or(req.amount_duffs); + (req.address.clone(), actual_amount) + }) + .collect(); + + let total_amount: u64 = recipients_result.iter().map(|(_, amt)| *amt).sum(); + + Ok(BackendTaskSuccessResult::WalletPayment { + txid: tx.txid().to_string(), + recipients: recipients_result, + total_amount, + }) + } + + fn parse_recipients( + &self, + request: &WalletPaymentRequest, + ) -> Result, String> { + if request.recipients.is_empty() { + return Err("No recipients specified".to_string()); + } + + let mut parsed = Vec::with_capacity(request.recipients.len()); + for recipient in &request.recipients { + if recipient.amount_duffs == 0 { + return Err(format!( + "Amount must be greater than zero for address {}", + recipient.address + )); + } + + let addr = Address::from_str(&recipient.address) + .map_err(|e| format!("Invalid address {}: {e}", recipient.address))? + .assume_checked(); + + if !networks_address_compatible(addr.network(), &self.network) { + return Err(format!( + "Recipient address {} uses {} but wallet network is {}", + recipient.address, + addr.network(), + self.network + )); + } + + parsed.push((addr, recipient.amount_duffs)); + } + + Ok(parsed) + } + + fn build_spv_unsigned_transaction_multi( + &self, + wm: &mut WalletManager, + wallet_id: &WalletId, + recipients: &[(Address, u64)], + request: &WalletPaymentRequest, + ) -> Result { + const FALLBACK_STEP: u64 = 100; + + let network = self.wallet_network_key(); + let current_height = wm.current_height(network); + let total_amount: u64 = recipients.iter().map(|(_, amt)| *amt).sum(); + let mut scale_factor = 1.0f64; + let mut attempted_fallback = false; + + loop { + let scaled_recipients: Vec<(Address, u64)> = recipients + .iter() + .map(|(addr, amt)| (addr.clone(), (*amt as f64 * scale_factor) as u64)) + .collect(); + + match wm.create_unsigned_payment_transaction( + wallet_id, + DEFAULT_BIP44_ACCOUNT_INDEX, + Some(AccountTypePreference::BIP44), + scaled_recipients, + FeeLevel::Normal, + current_height, + ) { + Ok(tx) => return Ok(tx), + Err(WalletError::InsufficientFunds) if request.subtract_fee_from_amount => { + let next_scale = if !attempted_fallback { + attempted_fallback = true; + let fallback_amount = self.estimate_fallback_amount( + wm, + wallet_id, + network, + DEFAULT_BIP44_ACCOUNT_INDEX, + current_height, + )?; + fallback_amount as f64 / total_amount as f64 + } else { + let current_total = (total_amount as f64 * scale_factor) as u64; + let reduced = current_total.saturating_sub(FALLBACK_STEP); + reduced as f64 / total_amount as f64 + }; + + if next_scale <= 0.0 || (next_scale - scale_factor).abs() < 0.0001 { + return Err("Insufficient funds".to_string()); + } + scale_factor = next_scale; + } + Err(err) => { + return Err(format!("Failed to build transaction: {err}")); + } + } + } + } + + fn estimate_fallback_amount( + &self, + wm: &mut WalletManager, + wallet_id: &WalletId, + _network: WalletNetwork, + account_index: u32, + current_height: u32, + ) -> Result { + let managed_info = wm + .get_wallet_info(wallet_id) + .ok_or_else(|| "Wallet info unavailable".to_string())?; + let collection = managed_info.accounts(); + let account = collection + .standard_bip44_accounts + .get(&account_index) + .ok_or_else(|| "BIP44 account missing".to_string())?; + + let mut spendable_total = 0u64; + let mut spendable_inputs = 0usize; + for utxo in account.utxos.values() { + if (*utxo).is_spendable(current_height) { + spendable_total = spendable_total.saturating_add(utxo.value()); + spendable_inputs += 1; + } + } + + if spendable_total == 0 || spendable_inputs == 0 { + return Err("No spendable funds available".to_string()); + } + + let estimated_size = Self::estimate_p2pkh_tx_size(spendable_inputs, 1); + let fee = FeeLevel::Normal.fee_rate().calculate_fee(estimated_size); + Ok(spendable_total.saturating_sub(fee)) + } + + fn sign_spv_transaction( + &self, + wm: &mut WalletManager, + wallet_id: &WalletId, + tx: Transaction, + ) -> Result { + let wallet = wm + .get_wallet(wallet_id) + .ok_or_else(|| "Wallet object not found".to_string())?; + let managed_info = wm + .get_wallet_info(wallet_id) + .ok_or_else(|| "Wallet info unavailable".to_string())?; + let accounts = managed_info.accounts(); + let account = accounts + .standard_bip44_accounts + .get(&DEFAULT_BIP44_ACCOUNT_INDEX) + .ok_or_else(|| "BIP44 account missing".to_string())?; + + let secp = Secp256k1::new(); + let mut tx_signed = tx; + let cache = SighashCache::new(&tx_signed); + + let signing_data = tx_signed + .input + .iter() + .enumerate() + .map(|(index, input)| { + let utxo = account + .utxos + .get(&input.previous_output) + .ok_or_else(|| "Missing UTXO for signing".to_string())?; + let sighash = cache + .legacy_signature_hash(index, &utxo.txout.script_pubkey, 1) + .map_err(|e| format!("Failed to compute signature hash: {e}"))?; + Ok((sighash, utxo.address.clone())) + }) + .collect::, String>>()?; + + for (input, (sighash, address)) in tx_signed.input.iter_mut().zip(signing_data.into_iter()) + { + let digest: [u8; 32] = sighash.into(); + let message = Message::from_digest(digest); + + let addr_info = account + .get_address_info(&address) + .ok_or_else(|| "Address metadata missing".to_string())?; + let secret_key = wallet + .derive_private_key(&addr_info.path) + .map_err(|e| format!("Failed to derive private key: {e}"))?; + let private_key = PrivateKey { + compressed: true, + network: self.network, + inner: secret_key, + }; + + let sig = secp.sign_ecdsa(&message, &private_key.inner); + let mut serialized_sig = sig.serialize_der().to_vec(); + let mut script_sig = vec![serialized_sig.len() as u8 + 1]; + script_sig.append(&mut serialized_sig); + script_sig.push(1); + let mut serialized_pub_key = private_key.public_key(&secp).to_bytes(); + script_sig.push(serialized_pub_key.len() as u8); + script_sig.append(&mut serialized_pub_key); + input.script_sig = dash_sdk::dpp::dashcore::ScriptBuf::from_bytes(script_sig); + } + + Ok(tx_signed) + } + + fn sum_outputs_to_script( + tx: &Transaction, + script: &dash_sdk::dpp::dashcore::ScriptBuf, + ) -> Option { + let mut total = 0u64; + for output in &tx.output { + if &output.script_pubkey == script { + total = total.saturating_add(output.value); + } + } + if total == 0 { None } else { Some(total) } + } + + fn estimate_p2pkh_tx_size(inputs: usize, outputs: usize) -> usize { + fn varint_size(value: usize) -> usize { + match value { + 0..=0xfc => 1, + 0xfd..=0xffff => 3, + 0x1_0000..=0xffff_ffff => 5, + _ => 9, + } + } + + let mut size = 8; // version/type/lock_time + size += varint_size(inputs); + size += varint_size(outputs); + size += inputs * 148; + size += outputs * 34; + size + } } diff --git a/src/backend_task/core/recover_asset_locks.rs b/src/backend_task/core/recover_asset_locks.rs new file mode 100644 index 000000000..6b72b357c --- /dev/null +++ b/src/backend_task/core/recover_asset_locks.rs @@ -0,0 +1,388 @@ +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::wallet::Wallet; +use dash_sdk::dashcore_rpc::RpcApi; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload; +use dash_sdk::dpp::dashcore::{Address, OutPoint}; +use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dash_sdk::dpp::prelude::AssetLockProof; +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; + +impl AppContext { + /// Search for unused asset locks by scanning the Core wallet for asset lock transactions + /// that belong to this wallet but aren't tracked in the database. + pub fn recover_asset_locks( + &self, + wallet: Arc>, + ) -> Result { + let (known_addresses, seed_hash, already_tracked_txids) = { + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + let addresses: Vec

      = wallet_guard.known_addresses.keys().cloned().collect(); + let tracked: HashSet<_> = wallet_guard + .unused_asset_locks + .iter() + .map(|(tx, _, _, _, _)| tx.txid()) + .collect(); + (addresses, wallet_guard.seed_hash(), tracked) + }; + + tracing::info!( + "Searching for unused asset locks. Known addresses: {}, Already tracked: {}", + known_addresses.len(), + already_tracked_txids.len() + ); + + if known_addresses.is_empty() { + tracing::warn!("No known addresses in wallet - cannot search for asset locks"); + return Ok(BackendTaskSuccessResult::RecoveredAssetLocks { + recovered_count: 0, + total_amount: 0, + }); + } + + let client = self + .core_client + .read() + .expect("Core client lock was poisoned"); + + let mut recovered_count = 0; + let mut total_amount = 0u64; + + // First, import all known addresses to Core to ensure it's watching them + for address in &known_addresses { + if let Err(e) = client.import_address(address, None, Some(false)) { + tracing::debug!("import_address for {} returned: {:?}", address, e); + } + } + + // Method 1: Get unspent outputs for all known addresses + let address_refs: Vec<&Address> = known_addresses.iter().collect(); + let unspent = client + .list_unspent(None, None, Some(&address_refs), Some(true), None) + .map_err(|e| format!("Failed to list unspent: {}", e))?; + + tracing::info!( + "Found {} unspent outputs for known addresses", + unspent.len() + ); + + // Check each unspent output to see if it's an asset lock + for utxo in &unspent { + let txid = utxo.txid; + + // Skip if already tracked + if already_tracked_txids.contains(&txid) { + tracing::debug!("Skipping {} - already tracked in wallet", txid); + continue; + } + + // Check if already in database + if let Ok(Some(_)) = self.db.get_asset_lock_transaction(txid.as_byte_array()) { + tracing::debug!("Skipping {} - already in database", txid); + continue; + } + + // Get the raw transaction to check if it's an asset lock + let raw_tx = match client.get_raw_transaction(&txid, None) { + Ok(tx) => tx, + Err(e) => { + tracing::debug!("Failed to get raw transaction {}: {}", txid, e); + continue; + } + }; + + // Check if this is an asset lock transaction + let Some(TransactionPayload::AssetLockPayloadType(payload)) = + &raw_tx.special_transaction_payload + else { + continue; + }; + + tracing::info!("Found asset lock transaction: {}", txid); + + // Find the credit output that belongs to our wallet + let mut credit_address = None; + let mut credit_amount = 0u64; + + for credit_output in &payload.credit_outputs { + if let Ok(addr) = Address::from_script(&credit_output.script_pubkey, self.network) { + tracing::debug!("Asset lock credit output address: {}", addr); + if known_addresses.contains(&addr) { + credit_address = Some(addr); + credit_amount = credit_output.value; + break; + } + } + } + + let Some(addr) = credit_address else { + tracing::debug!("Asset lock {} credit address not in known addresses", txid); + continue; + }; + + // Note: We cannot check if asset lock is "spent" via get_tx_out because + // asset lock transactions use OP_RETURN outputs which are never UTXOs. + // Platform tracks whether asset locks are used, not Core. + // We add the asset lock and let the user try to use it - Platform will + // reject if it's already been consumed. + + // Get transaction info for chain lock status + let tx_info = client.get_raw_transaction_info(&txid, None).ok(); + + // Build the proof + let (chain_locked_height, proof) = if let Some(ref info) = tx_info { + if info.chainlock && info.height.is_some() { + let height = info.height.unwrap() as u32; + ( + Some(height), + Some(AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: height, + out_point: OutPoint::new(txid, 0), + })), + ) + } else { + (None, None) + } + } else { + (None, None) + }; + + // Store the asset lock in the database + if let Err(e) = self.db.store_asset_lock_transaction( + &raw_tx, + credit_amount, + None, + &seed_hash, + self.network, + ) { + tracing::warn!("Failed to store asset lock {}: {}", txid, e); + continue; + } + + // Also store the chain locked height if available + if let Some(height) = chain_locked_height + && let Err(e) = self + .db + .update_asset_lock_chain_locked_height(txid.as_byte_array(), Some(height)) + { + tracing::warn!("Failed to update chain locked height for {}: {}", txid, e); + } + + // Add to wallet's in-memory unused_asset_locks + { + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + + let already_exists = wallet_guard + .unused_asset_locks + .iter() + .any(|(tx, _, _, _, _)| tx.txid() == txid); + + if !already_exists { + wallet_guard.unused_asset_locks.push(( + raw_tx.clone(), + addr, + credit_amount, + None, + proof, + )); + recovered_count += 1; + total_amount += credit_amount; + + tracing::info!( + "Found unused asset lock: txid={}, amount={} duffs", + txid, + credit_amount + ); + } + } + } + + // Method 2: Also check Core's wallet for any transactions we might have missed + // by scanning ALL unspent outputs (not filtered by address) + tracing::info!("Scanning all Core wallet unspent outputs..."); + if let Ok(all_unspent) = client.list_unspent(None, None, None, Some(true), None) { + tracing::info!( + "Core wallet has {} total unspent outputs", + all_unspent.len() + ); + + for utxo in all_unspent { + let txid = utxo.txid; + + // Skip if already processed or tracked + if already_tracked_txids.contains(&txid) { + continue; + } + if let Ok(Some(_)) = self.db.get_asset_lock_transaction(txid.as_byte_array()) { + continue; + } + + // Get the raw transaction + let raw_tx = match client.get_raw_transaction(&txid, None) { + Ok(tx) => tx, + Err(_) => continue, + }; + + // Check if this is an asset lock transaction + let Some(TransactionPayload::AssetLockPayloadType(payload)) = + &raw_tx.special_transaction_payload + else { + continue; + }; + + tracing::info!("Found asset lock in Core wallet scan: {}", txid); + + // Get the credit output address and amount + let Some(credit_output) = payload.credit_outputs.first() else { + continue; + }; + + let Ok(credit_addr) = + Address::from_script(&credit_output.script_pubkey, self.network) + else { + continue; + }; + + // Verify the credit address belongs to our wallet + if !known_addresses.contains(&credit_addr) { + tracing::debug!( + "Asset lock {} credit address {} not in wallet, skipping", + txid, + credit_addr + ); + continue; + } + + let credit_amount = credit_output.value; + + // Note: We cannot check if asset lock is "spent" via get_tx_out because + // asset lock transactions use OP_RETURN outputs which are never UTXOs. + // Platform tracks whether asset locks are used, not Core. + + // Get chain lock info + let tx_info = client.get_raw_transaction_info(&txid, None).ok(); + let (chain_locked_height, proof) = if let Some(ref info) = tx_info { + if info.chainlock && info.height.is_some() { + let height = info.height.unwrap() as u32; + ( + Some(height), + Some(AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: height, + out_point: OutPoint::new(txid, 0), + })), + ) + } else { + (None, None) + } + } else { + (None, None) + }; + + // Store in database + if let Err(e) = self.db.store_asset_lock_transaction( + &raw_tx, + credit_amount, + None, + &seed_hash, + self.network, + ) { + tracing::warn!("Failed to store asset lock {}: {}", txid, e); + continue; + } + + // Also store the chain locked height if available + if let Some(height) = chain_locked_height + && let Err(e) = self + .db + .update_asset_lock_chain_locked_height(txid.as_byte_array(), Some(height)) + { + tracing::warn!("Failed to update chain locked height for {}: {}", txid, e); + } + + // Add to wallet + { + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + + let already_exists = wallet_guard + .unused_asset_locks + .iter() + .any(|(tx, _, _, _, _)| tx.txid() == txid); + + if !already_exists { + wallet_guard.unused_asset_locks.push(( + raw_tx.clone(), + credit_addr, + credit_amount, + None, + proof, + )); + recovered_count += 1; + total_amount += credit_amount; + + tracing::info!( + "Found unused asset lock (full scan): txid={}, amount={} duffs", + txid, + credit_amount + ); + } + } + } + } + + // Clean up: Remove asset locks from wallet that don't belong to it + // (credit address not in known_addresses) + let mut txids_to_remove = Vec::new(); + let removed_count = { + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + let before_count = wallet_guard.unused_asset_locks.len(); + + wallet_guard.unused_asset_locks.retain(|(tx, _, _, _, _)| { + // Get the credit output address from the transaction + if let Some(TransactionPayload::AssetLockPayloadType(payload)) = + &tx.special_transaction_payload + && let Some(credit_output) = payload.credit_outputs.first() + && let Ok(addr) = + Address::from_script(&credit_output.script_pubkey, self.network) + && known_addresses.contains(&addr) + { + return true; // Keep this asset lock + } + tracing::info!( + "Removing asset lock {} - credit address not in wallet", + tx.txid() + ); + txids_to_remove.push(tx.txid()); + false // Remove this asset lock + }); + + before_count - wallet_guard.unused_asset_locks.len() + }; + + // Also delete from database + for txid in &txids_to_remove { + if let Err(e) = self.db.delete_asset_lock_transaction(txid.as_byte_array()) { + tracing::warn!("Failed to delete asset lock {} from database: {}", txid, e); + } + } + + if removed_count > 0 { + tracing::info!( + "Removed {} asset locks that don't belong to this wallet", + removed_count + ); + } + + tracing::info!( + "Asset lock search complete. Found {} unused asset locks worth {} duffs", + recovered_count, + total_amount + ); + + Ok(BackendTaskSuccessResult::RecoveredAssetLocks { + recovered_count, + total_amount, + }) + } +} diff --git a/src/backend_task/core/refresh_single_key_wallet_info.rs b/src/backend_task/core/refresh_single_key_wallet_info.rs new file mode 100644 index 000000000..fb8cc4c54 --- /dev/null +++ b/src/backend_task/core/refresh_single_key_wallet_info.rs @@ -0,0 +1,91 @@ +//! Refresh Single Key Wallet Info - Reload UTXOs and balances for a single key wallet + +use crate::context::AppContext; +use crate::model::wallet::single_key::SingleKeyWallet; +use dash_sdk::dashcore_rpc::RpcApi; +use dash_sdk::dpp::dashcore::{OutPoint, TxOut}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +impl AppContext { + /// Refresh a single key wallet by reloading UTXOs from Core RPC + pub fn refresh_single_key_wallet_info( + &self, + wallet: Arc>, + ) -> Result<(), String> { + // Step 1: Get the address from the wallet + let (address, key_hash) = { + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + (wallet_guard.address.clone(), wallet_guard.key_hash) + }; + + // Step 2: Import address to Core (needed for UTXO queries) + { + let client = self + .core_client + .read() + .expect("Core client lock was poisoned"); + + if let Err(e) = client.import_address(&address, None, Some(false)) { + tracing::debug!(?e, address = %address, "import_address failed during single key refresh"); + } + } + + // Step 3: Get UTXOs for this address + let utxo_map = { + let client = self + .core_client + .read() + .expect("Core client lock was poisoned"); + + let utxos = client + .list_unspent(Some(0), None, Some(&[&address]), None, None) + .map_err(|e| format!("Failed to list UTXOs: {}", e))?; + + let mut map: HashMap = HashMap::new(); + for utxo in utxos { + let outpoint = OutPoint::new(utxo.txid, utxo.vout); + let tx_out = TxOut { + value: utxo.amount.to_sat(), + script_pubkey: utxo.script_pub_key, + }; + map.insert(outpoint, tx_out); + } + map + }; + + // Step 4: Calculate balance from UTXOs + let total_balance: u64 = utxo_map.values().map(|tx_out| tx_out.value).sum(); + + // Step 5: Update wallet with new UTXOs and balance + { + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + wallet_guard.utxos = utxo_map.clone(); + wallet_guard.update_balances(total_balance, 0, total_balance); + } + + // Step 6: Persist to database + if let Err(e) = + self.db + .update_single_key_wallet_balances(&key_hash, total_balance, 0, total_balance) + { + tracing::warn!(error = %e, "Failed to persist single key wallet balances"); + } + + // Step 7: Insert UTXOs into database + for (outpoint, tx_out) in &utxo_map { + self.db + .insert_utxo( + outpoint.txid.as_ref(), + outpoint.vout, + &address, + tx_out.value, + &tx_out.script_pubkey.to_bytes(), + self.network, + ) + .map_err(|e| e.to_string())?; + } + + Ok(()) + } +} diff --git a/src/backend_task/core/refresh_wallet_info.rs b/src/backend_task/core/refresh_wallet_info.rs index aee77644b..f1b7eaa4d 100644 --- a/src/backend_task/core/refresh_wallet_info.rs +++ b/src/backend_task/core/refresh_wallet_info.rs @@ -1,93 +1,265 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::context::AppContext; -use crate::model::wallet::Wallet; -use crate::ui::wallets::wallets_screen::DerivationPathHelpers; +use crate::model::wallet::{DerivationPathHelpers, Wallet}; use dash_sdk::dashcore_rpc::RpcApi; -use dash_sdk::dpp::dashcore::Address; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::dashcore::{Address, OutPoint, Transaction, TxOut}; +use std::collections::HashMap; use std::sync::{Arc, RwLock}; impl AppContext { + /// Refresh wallet info with minimal lock contention to avoid UI freezes. + /// + /// Strategy: Collect data with brief read locks, do all RPC calls without locks, + /// then update wallet with a single brief write lock at the end. pub fn refresh_wallet_info( &self, wallet: Arc>, ) -> Result { - // Step 1: Collect all addresses from the wallet without holding the lock - let addresses = { + // Step 1: Collect data from wallet with brief read lock + let (addresses, asset_lock_txs, seed_hash) = { let wallet_guard = wallet.read().map_err(|e| e.to_string())?; - wallet_guard + let addrs = wallet_guard .known_addresses .iter() - .filter_map(|(address, derivation_path)| { - if derivation_path.is_bip44(self.network) { - Some(address.clone()) - } else { - None - } - }) - .collect::>() + .filter(|(_, path)| !path.is_platform_payment(self.network)) + .map(|(addr, _)| addr.clone()) + .collect::>(); + let asset_locks: Vec = wallet_guard + .unused_asset_locks + .iter() + .map(|(tx, _, _, _, _)| tx.clone()) + .collect(); + let seed = wallet_guard.seed_hash(); + (addrs, asset_locks, seed) }; + // Read lock released here - // Step 2: Iterate over each address and update balances - for address in &addresses { - // Fetch balance for the address from Dash Core - match self + // Step 2: Import addresses to Core (no wallet lock needed) + { + let client = self .core_client .read() - .expect("Core client lock was poisoned") - .get_received_by_address(address, None) - { - Ok(new_balance) => { - // Update the wallet's address_balances and database - { - let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; - wallet_guard.update_address_balance(address, new_balance.to_sat(), self)?; - } + .expect("Core client lock was poisoned"); + + for address in &addresses { + if let Err(e) = client.import_address(address, None, Some(false)) { + tracing::debug!(?e, address = %address, "import_address failed during refresh"); } - Err(e) => { - eprintln!("Error fetching balance for address {}: {}", address, e); + } + } + + // Step 3: Fetch UTXOs from Core RPC (no wallet lock needed) + let utxo_map: HashMap = { + let client = self + .core_client + .read() + .expect("Core client lock was poisoned"); + + // Get UTXOs for all addresses + let utxos = if addresses.is_empty() { + Vec::new() + } else { + client + .list_unspent( + None, + None, + Some(&addresses.iter().collect::>()), + Some(false), + None, + ) + .map_err(|e| format!("Failed to list UTXOs: {}", e))? + }; + + // Build the UTXO map + let mut map = HashMap::new(); + for utxo in utxos { + let outpoint = OutPoint::new(utxo.txid, utxo.vout); + let tx_out = TxOut { + value: utxo.amount.to_sat(), + script_pubkey: utxo.script_pub_key, + }; + map.insert(outpoint, tx_out); + } + map + }; + // No lock was held during RPC call + + // Step 4: Calculate balances from UTXOs (no lock needed) + let mut address_balances: HashMap = HashMap::new(); + for tx_out in utxo_map.values() { + if let Ok(address) = Address::from_script(&tx_out.script_pubkey, self.network) { + *address_balances.entry(address).or_insert(0) += tx_out.value; + } + } + + // Step 5: Fetch total received for each address from Core RPC (no wallet lock) + let mut total_received_map: HashMap = HashMap::new(); + { + let client = self + .core_client + .read() + .expect("Core client lock was poisoned"); + + for address in &addresses { + match client.get_received_by_address(address, None) { + Ok(amount) => { + total_received_map.insert(address.clone(), amount.to_sat()); + } + Err(e) => { + tracing::debug!( + ?e, + address = %address, + "get_received_by_address failed" + ); + } } } } - // Step 3: Reload UTXOs using the wallet's existing method - let utxo_map = { + // Step 6: Check which asset locks are stale (no wallet lock needed) + let stale_txids: Vec<_> = { + let client = self + .core_client + .read() + .expect("Core client lock was poisoned"); + + asset_lock_txs + .iter() + .filter_map(|tx| { + let txid = tx.txid(); + match client.get_tx_out(&txid, 0, Some(true)) { + Ok(Some(_)) => None, // UTXO exists, keep it + Ok(None) => { + tracing::info!( + "Asset lock {} has been used (UTXO spent), removing from unused list", + txid + ); + Some(txid) + } + Err(e) => { + tracing::debug!("Error checking asset lock UTXO {}: {}", txid, e); + None + } + } + }) + .collect() + }; + + // Step 7: Insert UTXOs into database (no wallet lock needed) + for (outpoint, tx_out) in &utxo_map { + if let Ok(address) = Address::from_script(&tx_out.script_pubkey, self.network) { + self.db + .insert_utxo( + outpoint.txid.as_ref(), + outpoint.vout, + &address, + tx_out.value, + &tx_out.script_pubkey.to_bytes(), + self.network, + ) + .map_err(|e| e.to_string())?; + } + } + + // Step 8: Delete stale asset locks from database (no wallet lock needed) + for txid in &stale_txids { + if let Err(e) = self.db.delete_asset_lock_transaction(txid.as_byte_array()) { + tracing::warn!("Failed to delete stale asset lock from database: {}", e); + } + } + + // Step 9: Calculate total balance (no lock needed) + let total_balance: u64 = utxo_map.values().map(|tx_out| tx_out.value).sum(); + + // Step 10: Update wallet IN-MEMORY state only (brief write lock, no I/O) + // Collect which balances actually changed for later database update + let (changed_balances, changed_total_received): (Vec<_>, Vec<_>) = { let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; - match wallet_guard.reload_utxos( - &self - .core_client - .read() - .expect("Core client lock was poisoned"), - self.network, - Some(self), - ) { - Ok(utxo_map) => utxo_map, - Err(e) => { - eprintln!("Error reloading UTXOs: {}", e); - return Err(e); + + // Update wallet's UTXO map + let new_outpoints: std::collections::HashSet<_> = utxo_map.keys().cloned().collect(); + + // Remove UTXOs that are no longer unspent + for utxos in wallet_guard.utxos.values_mut() { + utxos.retain(|outpoint, _| new_outpoints.contains(outpoint)); + } + wallet_guard.utxos.retain(|_, utxos| !utxos.is_empty()); + + // Add new UTXOs + for (outpoint, tx_out) in &utxo_map { + if let Ok(address) = Address::from_script(&tx_out.script_pubkey, self.network) { + wallet_guard + .utxos + .entry(address) + .or_default() + .insert(*outpoint, tx_out.clone()); } } + + // Update address balances IN-MEMORY and collect changes + let mut balance_changes = Vec::new(); + for address in &addresses { + let balance = address_balances.get(address).cloned().unwrap_or(0); + // Only track if balance changed + let current = wallet_guard.address_balances.get(address).cloned(); + if current != Some(balance) { + wallet_guard + .address_balances + .insert(address.clone(), balance); + balance_changes.push((address.clone(), balance)); + } + } + + // Update total received IN-MEMORY and collect changes + let mut received_changes = Vec::new(); + for (address, total_received) in &total_received_map { + // Only track if changed + let current = wallet_guard.address_total_received.get(address).cloned(); + if current != Some(*total_received) { + wallet_guard + .address_total_received + .insert(address.clone(), *total_received); + received_changes.push((address.clone(), *total_received)); + } + } + + // Remove stale asset locks + if !stale_txids.is_empty() { + let stale_count = stale_txids.len(); + wallet_guard + .unused_asset_locks + .retain(|(tx, _, _, _, _)| !stale_txids.contains(&tx.txid())); + tracing::info!("Removed {} stale asset locks", stale_count); + } + + // Update wallet-level balances + wallet_guard.update_spv_balances(total_balance, 0, total_balance); + + (balance_changes, received_changes) }; + // Write lock released here - all I/O happens below without any wallet lock - // Insert updated UTXOs into the database - for (outpoint, tx_out) in &utxo_map { - // You can get the address from the tx_out's script_pubkey - let address = Address::from_script(&tx_out.script_pubkey, self.network) - .map_err(|e| e.to_string())?; + // Step 11: Persist all changes to database (no wallet lock needed) + // Update address balances in database - propagate errors to prevent data loss + for (address, balance) in &changed_balances { self.db - .insert_utxo( - outpoint.txid.as_ref(), // txid: &[u8] - outpoint.vout, // vout: i64 - &address, // address: &str - tx_out.value, // value: i64 - &tx_out.script_pubkey.to_bytes(), // script_pubkey: &[u8] - self.network, // network: &str - ) - .map_err(|e| e.to_string())?; + .update_address_balance(&seed_hash, address, *balance) + .map_err(|e| format!("Failed to persist address balance for {}: {}", address, e))?; } - // Step 5: Return a success result - Ok(BackendTaskSuccessResult::Message( - "Successfully refreshed wallet".to_string(), - )) + // Update total received in database + for (address, total_received) in &changed_total_received { + self.db + .update_address_total_received(&seed_hash, address, *total_received) + .map_err(|e| format!("Failed to persist total received for {}: {}", address, e))?; + } + + // Update wallet-level balances + self.db + .update_wallet_balances(&seed_hash, total_balance, 0, total_balance) + .map_err(|e| format!("Failed to persist wallet balances: {}", e))?; + + Ok(BackendTaskSuccessResult::RefreshedWallet { warning: None }) } } diff --git a/src/backend_task/core/send_single_key_wallet_payment.rs b/src/backend_task/core/send_single_key_wallet_payment.rs new file mode 100644 index 000000000..ab3b409a0 --- /dev/null +++ b/src/backend_task/core/send_single_key_wallet_payment.rs @@ -0,0 +1,255 @@ +//! Send Single Key Wallet Payment - Send funds from a single key wallet + +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::core::WalletPaymentRequest; +use crate::context::AppContext; +use crate::model::wallet::single_key::SingleKeyWallet; +use dash_sdk::dashcore_rpc::RpcApi; +use dash_sdk::dashcore_rpc::dashcore::{Address, OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::dashcore::sighash::SighashCache; +use dash_sdk::dpp::dashcore::{EcdsaSighashType, secp256k1::Secp256k1}; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeLevel; +use std::str::FromStr; +use std::sync::{Arc, RwLock}; + +impl AppContext { + /// Send a payment from a single key wallet + pub async fn send_single_key_wallet_payment( + &self, + wallet: Arc>, + request: WalletPaymentRequest, + ) -> Result { + // Only RPC mode is supported for now + self.send_single_key_wallet_payment_via_rpc(wallet, request) + .await + } + + async fn send_single_key_wallet_payment_via_rpc( + &self, + wallet: Arc>, + request: WalletPaymentRequest, + ) -> Result { + // Parse recipients first to know total output amount + let mut outputs: Vec = Vec::new(); + let mut total_output: u64 = 0; + + for recipient in &request.recipients { + let address = Address::from_str(&recipient.address) + .map_err(|e| format!("Invalid address {}: {}", recipient.address, e))? + .require_network(self.network) + .map_err(|e| format!("Address network mismatch: {}", e))?; + + outputs.push(TxOut { + value: recipient.amount_duffs, + script_pubkey: address.script_pubkey(), + }); + total_output += recipient.amount_duffs; + } + + // Get wallet data and select UTXOs + let (private_key, selected_utxos, change_address) = { + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + + let private_key = wallet_guard + .private_key(self.network) + .ok_or_else(|| "Wallet must be unlocked to send".to_string())?; + + if wallet_guard.utxos.is_empty() { + return Err("No UTXOs available to spend".to_string()); + } + + // Select UTXOs to cover the amount + estimated fee + // Start with an estimate assuming ~10 inputs, then refine + let num_outputs = outputs.len() + 1; // +1 for change + let initial_fee_estimate = Self::estimate_p2pkh_tx_size(10, num_outputs); + let initial_fee = request.override_fee.unwrap_or_else(|| { + FeeLevel::Normal + .fee_rate() + .calculate_fee(initial_fee_estimate) + }); + + let _target_amount = total_output + initial_fee; + + // Sort UTXOs by value descending for efficient selection (use larger UTXOs first) + let mut all_utxos: Vec<(OutPoint, TxOut)> = wallet_guard + .utxos + .iter() + .map(|(op, tx_out)| (*op, tx_out.clone())) + .collect(); + all_utxos.sort_by(|a, b| b.1.value.cmp(&a.1.value)); + + // Select UTXOs until we have enough + let mut selected: Vec<(OutPoint, TxOut)> = Vec::new(); + let mut selected_total: u64 = 0; + + for (outpoint, tx_out) in all_utxos { + selected.push((outpoint, tx_out.clone())); + selected_total += tx_out.value; + + // Recalculate fee with current input count + let current_size = Self::estimate_p2pkh_tx_size(selected.len(), num_outputs); + let current_fee = request + .override_fee + .unwrap_or_else(|| FeeLevel::Normal.fee_rate().calculate_fee(current_size)); + + if selected_total >= total_output + current_fee { + break; + } + } + + // Final check if we have enough + let final_size = Self::estimate_p2pkh_tx_size(selected.len(), num_outputs); + let final_fee = request + .override_fee + .unwrap_or_else(|| FeeLevel::Normal.fee_rate().calculate_fee(final_size)); + + if selected_total < total_output + final_fee { + return Err(format!( + "Insufficient funds: have {} duffs, need {} duffs (including {} fee)", + wallet_guard.total_balance, + total_output + final_fee, + final_fee + )); + } + + let change_address = wallet_guard.address.clone(); + + (private_key, selected, change_address) + }; + + // Calculate final fee with selected UTXOs + let num_outputs_with_change = outputs.len() + 1; + let estimated_size = + Self::estimate_p2pkh_tx_size(selected_utxos.len(), num_outputs_with_change); + let fee = request + .override_fee + .unwrap_or_else(|| FeeLevel::Normal.fee_rate().calculate_fee(estimated_size)); + + let total_input: u64 = selected_utxos.iter().map(|(_, tx_out)| tx_out.value).sum(); + + // Calculate change + let change_amount = if request.subtract_fee_from_amount { + // Subtract fee from the first output + if outputs[0].value <= fee { + return Err(format!( + "Output amount too small to subtract fee of {} duffs", + fee + )); + } + outputs[0].value -= fee; + total_input - total_output + } else { + total_input - total_output - fee + }; + + // Add change output if significant (above dust threshold) + if change_amount > 546 { + outputs.push(TxOut { + value: change_amount, + script_pubkey: change_address.script_pubkey(), + }); + } + + // Build inputs + let inputs: Vec = selected_utxos + .iter() + .map(|(outpoint, _)| TxIn { + previous_output: *outpoint, + ..Default::default() + }) + .collect(); + + // Create unsigned transaction + let mut tx = Transaction { + version: 2, + lock_time: 0, + input: inputs, + output: outputs, + special_transaction_payload: None, + }; + + // Sign all inputs + let secp = Secp256k1::new(); + + for (i, (_, tx_out)) in selected_utxos.iter().enumerate() { + let sighash = SighashCache::new(&tx) + .legacy_signature_hash(i, &tx_out.script_pubkey, EcdsaSighashType::All as u32) + .map_err(|e| format!("Failed to compute sighash: {:?}", e))?; + + let message = + dash_sdk::dpp::dashcore::secp256k1::Message::from_digest(sighash.to_byte_array()); + let sig = secp.sign_ecdsa(&message, &private_key.inner); + + // Build script_sig: + let mut serialized_sig = sig.serialize_der().to_vec(); + let mut script_sig = vec![serialized_sig.len() as u8 + 1]; + script_sig.append(&mut serialized_sig); + script_sig.push(EcdsaSighashType::All as u8); + + let mut serialized_pub_key = private_key.public_key(&secp).to_bytes(); + script_sig.push(serialized_pub_key.len() as u8); + script_sig.append(&mut serialized_pub_key); + + tx.input[i].script_sig = ScriptBuf::from_bytes(script_sig); + } + + // Broadcast transaction + let txid = self + .core_client + .read() + .expect("Core client lock was poisoned") + .send_raw_transaction(&tx) + .map_err(|e| format!("Failed to broadcast transaction: {}", e))?; + + // Update wallet UTXOs - remove spent, add change + { + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + + // Remove spent UTXOs + for (outpoint, _) in &selected_utxos { + wallet_guard.utxos.remove(outpoint); + } + + // Add change UTXO if we created one + let change_output_index = tx.output.len() - 1; + if tx.output[change_output_index].script_pubkey == change_address.script_pubkey() { + let change_outpoint = OutPoint::new(txid, change_output_index as u32); + wallet_guard + .utxos + .insert(change_outpoint, tx.output[change_output_index].clone()); + } + + // Update balance + let new_balance: u64 = wallet_guard.utxos.values().map(|tx| tx.value).sum(); + wallet_guard.update_balances(new_balance, 0, new_balance); + } + + // Update database + let key_hash = wallet.read().map_err(|e| e.to_string())?.key_hash; + + // Remove spent UTXOs from database + for (outpoint, _) in &selected_utxos { + let _ = self.db.drop_utxo(outpoint, &self.network.to_string()); + } + + // Persist new balance + let balance = wallet.read().map_err(|e| e.to_string())?.total_balance; + let _ = self + .db + .update_single_key_wallet_balances(&key_hash, balance, 0, balance); + + let total_sent: u64 = request.recipients.iter().map(|r| r.amount_duffs).sum(); + let recipients_result: Vec<(String, u64)> = request + .recipients + .iter() + .map(|r| (r.address.clone(), r.amount_duffs)) + .collect(); + + Ok(BackendTaskSuccessResult::WalletPayment { + txid: txid.to_string(), + total_amount: total_sent, + recipients: recipients_result, + }) + } +} diff --git a/src/backend_task/core/start_dash_qt.rs b/src/backend_task/core/start_dash_qt.rs index db92ed822..08d54f8f9 100644 --- a/src/backend_task/core/start_dash_qt.rs +++ b/src/backend_task/core/start_dash_qt.rs @@ -3,6 +3,7 @@ use crate::context::AppContext; use crate::utils::path::format_path_for_display; use dash_sdk::dpp::dashcore::Network; use std::path::PathBuf; +use std::sync::Arc; use tokio::process::{Child, Command}; impl AppContext { @@ -53,6 +54,7 @@ impl AppContext { // Spawn a task to wait for the Dash-Qt process to exit let cancel = self.subtasks.cancellation_token.clone(); + let db = Arc::clone(&self.db); self.subtasks.spawn_sync(async move { let mut dash_qt = command .spawn() @@ -76,13 +78,27 @@ impl AppContext { }; }, _ = cancel.cancelled() => { - tracing::debug!("dash-qt process was cancelled, sending SIGTERM"); - signal_term(&dash_qt) - .unwrap_or_else(|e| tracing::error!(error=?e, "Failed to send SIGTERM to dash-qt")); - let status = dash_qt.wait().await - .inspect_err(|e| tracing::error!(error=?e, "Failed to wait for dash-qt process to exit")); - tracing::debug!(?status, "dash-qt process stopped gracefully"); - + // Check the setting to determine if we should close Dash-Qt + let should_close = match db.get_close_dash_qt_on_exit() { + Ok(value) => { + tracing::debug!("close_dash_qt_on_exit setting read successfully: {}", value); + value + } + Err(e) => { + tracing::error!("Failed to read close_dash_qt_on_exit setting: {:?}, defaulting to true", e); + true + } + }; + if should_close { + tracing::debug!("dash-qt process was cancelled, sending SIGTERM"); + signal_term(&dash_qt) + .unwrap_or_else(|e| tracing::error!(error=?e, "Failed to send SIGTERM to dash-qt")); + let status = dash_qt.wait().await + .inspect_err(|e| tracing::error!(error=?e, "Failed to wait for dash-qt process to exit")); + tracing::debug!(?status, "dash-qt process stopped gracefully"); + } else { + tracing::debug!("dash-qt process was cancelled, but close_dash_qt_on_exit is disabled - leaving Dash-Qt running"); + } } } }); diff --git a/src/backend_task/dashpay.rs b/src/backend_task/dashpay.rs new file mode 100644 index 000000000..982c21a53 --- /dev/null +++ b/src/backend_task/dashpay.rs @@ -0,0 +1,238 @@ +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use dash_sdk::Sdk; +use std::sync::Arc; + +pub mod auto_accept_handler; +pub mod auto_accept_proof; +pub mod avatar_processing; +pub mod contact_info; +pub mod contact_requests; +pub mod contacts; +pub mod dip14_derivation; +pub mod encryption; +pub mod encryption_tests; +pub mod errors; +pub mod hd_derivation; +pub mod incoming_payments; +pub mod payments; +pub mod profile; +pub mod validation; + +pub use contacts::ContactData; + +use crate::model::qualified_identity::QualifiedIdentity; +use dash_sdk::platform::{Identifier, IdentityPublicKey}; + +#[derive(Debug, Clone, PartialEq)] +pub enum DashPayTask { + LoadProfile { + identity: QualifiedIdentity, + }, + UpdateProfile { + identity: QualifiedIdentity, + display_name: Option, + bio: Option, + avatar_url: Option, + }, + LoadContacts { + identity: QualifiedIdentity, + }, + LoadContactRequests { + identity: QualifiedIdentity, + }, + FetchContactProfile { + identity: QualifiedIdentity, + contact_id: Identifier, + }, + SearchProfiles { + search_query: String, + }, + SendContactRequest { + identity: QualifiedIdentity, + signing_key: IdentityPublicKey, + to_username: String, + account_label: Option, + }, + SendContactRequestWithProof { + identity: QualifiedIdentity, + signing_key: IdentityPublicKey, + to_identity_id: Identifier, + account_label: Option, + qr_auto_accept: crate::backend_task::dashpay::auto_accept_proof::AutoAcceptProofData, + }, + AcceptContactRequest { + identity: QualifiedIdentity, + request_id: Identifier, + }, + RejectContactRequest { + identity: QualifiedIdentity, + request_id: Identifier, + }, + LoadPaymentHistory { + identity: QualifiedIdentity, + }, + SendPaymentToContact { + identity: QualifiedIdentity, + contact_id: Identifier, + amount_dash: f64, + memo: Option, + }, + UpdateContactInfo { + identity: QualifiedIdentity, + contact_id: Identifier, + nickname: Option, + note: Option, + is_hidden: bool, + accepted_accounts: Vec, + }, + /// Register DashPay receiving addresses for incoming payment detection + RegisterDashPayAddresses { + identity: QualifiedIdentity, + }, +} + +impl AppContext { + pub async fn run_dashpay_task( + self: &Arc, + task: DashPayTask, + sdk: &Sdk, + ) -> Result { + match task { + DashPayTask::LoadProfile { identity } => { + profile::load_profile(self, sdk, identity).await + } + DashPayTask::UpdateProfile { + identity, + display_name, + bio, + avatar_url, + } => profile::update_profile(self, sdk, identity, display_name, bio, avatar_url).await, + DashPayTask::LoadContacts { identity } => { + contacts::load_contacts(self, sdk, identity).await + } + DashPayTask::LoadContactRequests { identity } => { + contact_requests::load_contact_requests(self, sdk, identity).await + } + DashPayTask::FetchContactProfile { + identity, + contact_id, + } => profile::fetch_contact_profile(self, sdk, identity, contact_id).await, + DashPayTask::SearchProfiles { search_query } => { + profile::search_profiles(self, sdk, search_query).await + } + DashPayTask::SendContactRequest { + identity, + signing_key, + to_username, + account_label, + } => { + contact_requests::send_contact_request( + self, + sdk, + identity, + signing_key, + to_username, + account_label, + ) + .await + } + DashPayTask::SendContactRequestWithProof { + identity, + signing_key, + to_identity_id, + account_label, + qr_auto_accept, + } => { + contact_requests::send_contact_request_with_proof( + self, + sdk, + identity, + signing_key, + to_identity_id.to_string( + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, + ), + account_label, + Some(qr_auto_accept), + ) + .await + } + DashPayTask::AcceptContactRequest { + identity, + request_id, + } => contact_requests::accept_contact_request(self, sdk, identity, request_id).await, + DashPayTask::RejectContactRequest { + identity, + request_id, + } => contact_requests::reject_contact_request(self, sdk, identity, request_id).await, + DashPayTask::LoadPaymentHistory { identity: _ } => { + // TODO: Implement payment history loading according to DIP-0015 + // This requires an SPV client to query the blockchain, which is not yet available. + // Once SPV support is added, the implementation would: + // 1. Get all established contacts (bidirectional contact requests) + // 2. For each contact, derive payment addresses from their encrypted extended public key + // 3. Query blockchain via SPV for transactions to/from those addresses + // 4. Build payment history records with amount, timestamp, memo, etc. + // 5. Store in local database for faster access + // + // The derivation path for DashPay addresses is: + // m/9'/5'/15'/account'/(our_identity_id)/(contact_identity_id)/index + // + // For now, return empty payment history until SPV client is available + Ok(BackendTaskSuccessResult::DashPayPaymentHistory(Vec::new())) + } + DashPayTask::SendPaymentToContact { + identity, + contact_id, + amount_dash, + memo, + } => { + payments::send_payment_to_contact_impl( + self, + sdk, + identity, + contact_id, + amount_dash, + memo, + ) + .await + } + DashPayTask::UpdateContactInfo { + identity, + contact_id, + nickname, + note, + is_hidden, + accepted_accounts, + } => { + contact_info::create_or_update_contact_info( + self, + sdk, + identity, + contact_id, + nickname, + note, + is_hidden, + accepted_accounts, + ) + .await + } + DashPayTask::RegisterDashPayAddresses { identity } => { + let result = + incoming_payments::register_dashpay_addresses_for_identity(self, &identity) + .await?; + + Ok(BackendTaskSuccessResult::Message(format!( + "Registered {} DashPay addresses for {} contacts{}", + result.addresses_registered, + result.contacts_processed, + if result.errors.is_empty() { + String::new() + } else { + format!(" ({} errors)", result.errors.len()) + } + ))) + } + } + } +} diff --git a/src/backend_task/dashpay/auto_accept_handler.rs b/src/backend_task/dashpay/auto_accept_handler.rs new file mode 100644 index 000000000..758304620 --- /dev/null +++ b/src/backend_task/dashpay/auto_accept_handler.rs @@ -0,0 +1,121 @@ +use crate::backend_task::dashpay::auto_accept_proof::verify_auto_accept_proof; +use crate::backend_task::dashpay::contact_requests::accept_contact_request; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use dash_sdk::Sdk; +use dash_sdk::dpp::document::DocumentV0Getters; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::Value; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; +use dash_sdk::platform::{Document, DocumentQuery, FetchMany, Identifier}; +use std::sync::Arc; + +/// Process incoming contact requests and check for autoAcceptProof +/// +/// This function checks all incoming contact requests for valid autoAcceptProof +/// and automatically accepts and reciprocates if the proof is valid. +pub async fn process_auto_accept_requests( + app_context: &Arc, + sdk: &Sdk, + identity: QualifiedIdentity, +) -> Result, String> { + let identity_id = identity.identity.id(); + let dashpay_contract = app_context.dashpay_contract.clone(); + + // Query for incoming contact requests + let mut incoming_query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest") + .map_err(|e| format!("Failed to create query: {}", e))?; + + incoming_query = incoming_query.with_where(WhereClause { + field: "toUserId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity_id.to_buffer()), + }); + + // Add orderBy to avoid platform bug + incoming_query = incoming_query.with_order_by(OrderClause { + field: "$createdAt".to_string(), + ascending: true, + }); + incoming_query.limit = 100; + + let incoming_docs = Document::fetch_many(sdk, incoming_query) + .await + .map_err(|e| format!("Error fetching incoming contact requests: {}", e))?; + + // Stateless verification; no stored proofs needed + + let mut auto_accepted_requests = Vec::new(); + + for (request_id, doc) in incoming_docs { + if let Some(doc) = doc { + let from_id = doc.owner_id(); + let props = doc.properties(); + + // Check if this request has an autoAcceptProof + if let Some(Value::Bytes(proof_data)) = props.get("autoAcceptProof") { + eprintln!( + "DEBUG: Found contact request with autoAcceptProof from {}", + from_id.to_string(Encoding::Base58) + ); + + // Extract accountReference for message construction (default to 0 if missing) + let account_reference = match props.get("accountReference") { + Some(Value::U32(v)) => *v, + Some(Value::U64(v)) => *v as u32, + Some(Value::I64(v)) => *v as u32, + Some(Value::U128(v)) => *v as u32, + Some(Value::I128(v)) => *v as u32, + _ => 0u32, + }; + + // Verify the proof per DIP-0015 + match verify_auto_accept_proof( + proof_data, + from_id, + identity.identity.id(), + &identity, + account_reference, + ) { + Ok(true) => { + eprintln!( + "DEBUG: Valid autoAcceptProof! Auto-accepting contact request from {}", + from_id.to_string(Encoding::Base58) + ); + + // Accept the request (which sends a reciprocal request) + match accept_contact_request(app_context, sdk, identity.clone(), request_id) + .await + { + Ok(_) => { + auto_accepted_requests.push((from_id, true)); + + // Stateless: no persistence required + } + Err(e) => { + eprintln!("ERROR: Failed to auto-accept contact request: {}", e); + auto_accepted_requests.push((from_id, false)); + } + } + } + Ok(false) => { + eprintln!( + "DEBUG: Invalid or expired autoAcceptProof from {}", + from_id.to_string(Encoding::Base58) + ); + } + Err(e) => { + eprintln!("ERROR: Failed to verify autoAcceptProof: {}", e); + } + } + } + } + } + + Ok(auto_accepted_requests) +} + +// No DB persistence required + +// Proof creation moved to contact_requests::send_contact_request_with_proof diff --git a/src/backend_task/dashpay/auto_accept_proof.rs b/src/backend_task/dashpay/auto_accept_proof.rs new file mode 100644 index 000000000..76c82c428 --- /dev/null +++ b/src/backend_task/dashpay/auto_accept_proof.rs @@ -0,0 +1,331 @@ +use super::hd_derivation::derive_auto_accept_key; +use crate::model::qualified_identity::QualifiedIdentity; +use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1, SecretKey}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::platform::Identifier; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AutoAcceptProofData { + pub identity_id: Identifier, + pub proof_key: [u8; 32], + pub account_reference: u32, + pub expires_at: u64, // Unix timestamp +} + +impl AutoAcceptProofData { + pub fn to_qr_string(&self) -> String { + // Format according to DIP-0015: dash:?du={username}&dapk={key_data} + // Key data format: key_type (1 byte) + timestamp (4 bytes) + key_size (1 byte) + key (32 bytes) + let mut key_data = Vec::new(); + key_data.push(0u8); // Key type 0 for ECDSA_SECP256K1 + key_data.extend_from_slice(&(self.expires_at as u32).to_be_bytes()); // Timestamp/expiration + key_data.push(32u8); // Key size + key_data.extend_from_slice(&self.proof_key); // The actual key + + // Encode key data in base58 using dashcore's base58 implementation + use dash_sdk::dpp::dashcore::base58; + let key_data_base58 = base58::encode_slice(&key_data); + + // For QR codes without username (identity-based) + format!( + "dash:?di={}&dapk={}", + self.identity_id + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58), + key_data_base58 + ) + } + + pub fn from_qr_string(qr_data: &str) -> Result { + // Parse DIP-0015 format: dash:?du={username}&dapk={key_data} or dash:?di={identity}&dapk={key_data} + if !qr_data.starts_with("dash:?") { + return Err("Invalid QR code format - must start with 'dash:?'".to_string()); + } + + let query_string = &qr_data[6..]; // Skip "dash:?" + let mut identity_id = None; + let mut key_data_base58 = None; + let mut account_reference = 0u32; // Default to account 0 + + // Parse query parameters + for param in query_string.split('&') { + let parts: Vec<&str> = param.split('=').collect(); + if parts.len() != 2 { + continue; + } + + match parts[0] { + "di" => { + identity_id = Some( + Identifier::from_string( + parts[1], + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, + ) + .map_err(|e| format!("Invalid identity ID: {}", e))?, + ) + } + "dapk" => { + key_data_base58 = Some(parts[1].to_string()); + } + "account" => { + account_reference = parts[1] + .parse::() + .map_err(|e| format!("Invalid account reference: {}", e))?; + } + _ => {} // Ignore unknown parameters + } + } + + let identity_id = identity_id.ok_or("Missing identity ID in QR code".to_string())?; + let key_data_base58 = + key_data_base58.ok_or("Missing proof key data in QR code".to_string())?; + + // Decode the key data from base58 + use dash_sdk::dpp::dashcore::base58; + let key_data = base58::decode(&key_data_base58) + .map_err(|e| format!("Invalid base58 key data: {}", e))?; + + // Parse key data format: key_type (1) + timestamp (4) + key_size (1) + key (32-64) + if key_data.len() < 38 { + return Err("Key data too short".to_string()); + } + + let _key_type = key_data[0]; + let expires_at = + u32::from_be_bytes([key_data[1], key_data[2], key_data[3], key_data[4]]) as u64; + let key_size = key_data[5] as usize; + + if key_data.len() < 6 + key_size { + return Err("Invalid key data length".to_string()); + } + + let mut proof_key = [0u8; 32]; + if key_size == 32 { + proof_key.copy_from_slice(&key_data[6..38]); + } else { + return Err(format!("Unsupported key size: {}", key_size)); + } + + Ok(Self { + identity_id, + proof_key, + account_reference, + expires_at, + }) + } +} + +/// Generate an auto-accept proof for QR code sharing +/// +/// According to DIP-0015, the autoAcceptProof is a signature that allows the recipient +/// to automatically accept the contact request and send one back without user interaction. +pub fn generate_auto_accept_proof( + identity: &QualifiedIdentity, + account_reference: u32, + validity_hours: u32, +) -> Result { + // Calculate expiration timestamp + let expires_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| format!("Time error: {}", e))? + .as_secs() + + (validity_hours as u64 * 3600); + + // Get wallet seed for HD derivation - use ENCRYPTION key (ECDSA_SECP256K1) as per DIP-15 + // The auto-accept proof uses HD derivation from the wallet, and ENCRYPTION keys are ECDSA_SECP256K1 + let signing_key = identity + .identity + .get_first_public_key_matching( + Purpose::ENCRYPTION, + HashSet::from([SecurityLevel::MEDIUM]), + HashSet::from([KeyType::ECDSA_SECP256K1]), + false, + ) + .ok_or( + "No suitable key found. This operation requires a MEDIUM security level ECDSA_SECP256K1 ENCRYPTION key.", + )?; + + let wallets: Vec<_> = identity.associated_wallets.values().cloned().collect(); + let wallet_seed = identity + .private_keys + .get_resolve( + &( + crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, + signing_key.id(), + ), + &wallets, + identity.network, + ) + .map_err(|e| format!("Error resolving private key: {}", e))? + .map(|(_, private_key)| private_key) + .ok_or("Private key not found")?; + + // Determine network from the identity + let network = identity.network; + + // Derive the auto-accept key using DIP-0015 path: m/9'/5'/16'/timestamp' + // Using expiration timestamp as the derivation index + let auto_accept_xprv = derive_auto_accept_key( + &wallet_seed, + network, + expires_at as u32, // Truncate to u32 for derivation + ) + .map_err(|e| format!("Failed to derive auto-accept key: {}", e))?; + + // Extract the private key bytes (32 bytes) + let proof_key = auto_accept_xprv.private_key.secret_bytes(); + + Ok(AutoAcceptProofData { + identity_id: identity.identity.id(), + proof_key, + account_reference, + expires_at, + }) +} + +/// Create the autoAcceptProof bytes for inclusion in a contact request +/// +/// Format according to DIP-0015: +/// - key type (1 byte) +/// - key index (4 bytes) - the timestamp used for derivation +/// - signature size (1 byte) +/// - signature (32-96 bytes) +pub fn create_auto_accept_proof_bytes_with_key( + expires_at: u64, + signing_key_bytes: &[u8; 32], + sender_id: &Identifier, + recipient_id: &Identifier, + account_reference: u32, +) -> Result, String> { + // Derive the auto-accept key + // Sign using the provided ephemeral key from the QR + + // Create the message to sign: ownerId + toUserId + accountReference + let mut message_data = Vec::new(); + message_data.extend_from_slice(&sender_id.to_buffer()); + message_data.extend_from_slice(&recipient_id.to_buffer()); + message_data.extend_from_slice(&account_reference.to_le_bytes()); + + // Hash the message + let mut hasher = Sha256::new(); + hasher.update(&message_data); + let message_hash = hasher.finalize(); + + // Create secp256k1 message and sign + let secp = Secp256k1::new(); + let message = Message::from_digest_slice(&message_hash) + .map_err(|e| format!("Failed to create message: {}", e))?; + + let secret_key = SecretKey::from_slice(signing_key_bytes) + .map_err(|e| format!("Failed to create secret key: {}", e))?; + + let signature = secp.sign_ecdsa(&message, &secret_key); + let sig_bytes = signature.serialize_compact(); + + // Build the proof bytes + let mut proof_bytes = Vec::new(); + proof_bytes.push(0u8); // Key type 0 for ECDSA_SECP256K1 + proof_bytes.extend_from_slice(&(expires_at as u32).to_be_bytes()); // Key index (timestamp) + proof_bytes.push(sig_bytes.len() as u8); // Signature size + proof_bytes.extend_from_slice(&sig_bytes); // The signature + + Ok(proof_bytes) +} + +/// Verify an auto-accept proof from a contact request +/// +/// This would be called when receiving a contact request with an autoAcceptProof field +/// to determine if we should automatically accept and reciprocate. +pub fn verify_auto_accept_proof( + proof_data: &[u8], + sender_identity_id: Identifier, + recipient_identity_id: Identifier, + our_identity: &QualifiedIdentity, + account_reference: u32, +) -> Result { + // Parse: key type (1) | key index/timestamp (4) | sig size (1) | signature + if proof_data.len() < 6 { + return Ok(false); + } + let _key_type = proof_data[0]; + let key_index = + u32::from_be_bytes([proof_data[1], proof_data[2], proof_data[3], proof_data[4]]); + let sig_len = proof_data[5] as usize; + // Compact ECDSA signatures are exactly 64 bytes + if sig_len != 64 { + return Ok(false); + } + if proof_data.len() < 6 + sig_len { + return Ok(false); + } + let signature_bytes = &proof_data[6..6 + sig_len]; + + // Expiry check + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| format!("Time error: {}", e))? + .as_secs(); + if now > key_index as u64 { + return Ok(false); + } + + // Message: ownerId + toUserId + accountReference + let mut message_data = Vec::new(); + message_data.extend_from_slice(&sender_identity_id.to_buffer()); + message_data.extend_from_slice(&recipient_identity_id.to_buffer()); + message_data.extend_from_slice(&account_reference.to_le_bytes()); + let mut hasher = Sha256::new(); + hasher.update(&message_data); + let message_hash = hasher.finalize(); + let secp = Secp256k1::new(); + let message = Message::from_digest_slice(&message_hash) + .map_err(|e| format!("Failed to create message: {}", e))?; + + // Derive expected pubkey from our seed and key index (timestamp) + // Use ENCRYPTION key (ECDSA_SECP256K1) for HD derivation as per DIP-15 + let wallets: Vec<_> = our_identity.associated_wallets.values().cloned().collect(); + let signing_key = our_identity + .identity + .get_first_public_key_matching( + Purpose::ENCRYPTION, + HashSet::from([SecurityLevel::MEDIUM]), + HashSet::from([KeyType::ECDSA_SECP256K1]), + false, + ) + .ok_or("No suitable key found. This operation requires a MEDIUM security level ECDSA_SECP256K1 ENCRYPTION key.")?; + let wallet_seed = our_identity + .private_keys + .get_resolve( + &( + crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, + signing_key.id(), + ), + &wallets, + our_identity.network, + ) + .map_err(|e| format!("Error resolving private key: {}", e))? + .map(|(_, private_key)| private_key) + .ok_or("Private key not found")?; + let xprv = derive_auto_accept_key(&wallet_seed, our_identity.network, key_index) + .map_err(|e| format!("Failed to derive auto-accept key: {}", e))?; + let pubkey = dash_sdk::dpp::dashcore::secp256k1::PublicKey::from_secret_key( + &secp, + &dash_sdk::dpp::dashcore::secp256k1::SecretKey::from_slice( + &xprv.private_key.secret_bytes(), + ) + .map_err(|e| format!("Failed to create secret key: {}", e))?, + ); + let sig = dash_sdk::dpp::dashcore::secp256k1::ecdsa::Signature::from_compact(signature_bytes) + .map_err(|e| format!("Invalid signature bytes: {}", e))?; + + match secp.verify_ecdsa(&message, &sig, &pubkey) { + Ok(_) => Ok(true), + Err(_) => Ok(false), + } +} + +// No local persistence required diff --git a/src/backend_task/dashpay/avatar_processing.rs b/src/backend_task/dashpay/avatar_processing.rs new file mode 100644 index 000000000..779f395e8 --- /dev/null +++ b/src/backend_task/dashpay/avatar_processing.rs @@ -0,0 +1,346 @@ +use image::{DynamicImage, GenericImageView}; +use sha2::{Digest, Sha256}; + +/// Maximum allowed size for avatar images (5MB) +const MAX_IMAGE_SIZE: usize = 5 * 1024 * 1024; + +/// Calculate SHA-256 hash of image bytes +pub fn calculate_avatar_hash(image_bytes: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(image_bytes); + let result = hasher.finalize(); + let mut hash = [0u8; 32]; + hash.copy_from_slice(&result); + hash +} + +/// Calculate DHash (Difference Hash) perceptual fingerprint of an image +/// +/// The DHash algorithm: +/// 1. Convert image to grayscale +/// 2. Resize to 9x8 pixels +/// 3. Compare each pixel with its right neighbor +/// 4. Generate 64-bit hash based on comparisons +pub fn calculate_dhash_fingerprint(image_bytes: &[u8]) -> Result<[u8; 8], String> { + // Load the image from bytes + let img = + image::load_from_memory(image_bytes).map_err(|e| format!("Failed to load image: {}", e))?; + + // Convert to grayscale and resize to 9x8 + let grayscale = img.grayscale(); + let resized = grayscale.resize_exact(9, 8, image::imageops::FilterType::Lanczos3); + + // Calculate the difference hash + let mut hash = 0u64; + let mut bit_position = 0; + + for y in 0..8 { + for x in 0..8 { + // Get the luminance values of adjacent pixels + let left_pixel = resized.get_pixel(x, y).0[0]; + let right_pixel = resized.get_pixel(x + 1, y).0[0]; + + // Set bit to 1 if left pixel is brighter than right + if left_pixel > right_pixel { + hash |= 1 << bit_position; + } + bit_position += 1; + } + } + + Ok(hash.to_le_bytes()) +} + +/// DHash calculator for more advanced image processing +pub struct DHashCalculator { + width: usize, + height: usize, +} + +impl Default for DHashCalculator { + fn default() -> Self { + Self { + width: 9, + height: 8, + } + } +} + +impl DHashCalculator { + pub fn new() -> Self { + Self::default() + } + + /// Calculate DHash from a DynamicImage + pub fn calculate_from_image(&self, img: &DynamicImage) -> [u8; 8] { + // Convert to grayscale and resize + let grayscale = img.grayscale(); + let resized = grayscale.resize_exact( + self.width as u32, + self.height as u32, + image::imageops::FilterType::Lanczos3, + ); + + // Calculate differences and build hash + let mut hash = 0u64; + let mut bit_position = 0; + + for y in 0..self.height { + for x in 0..(self.width - 1) { + let left_pixel = resized.get_pixel(x as u32, y as u32).0[0]; + let right_pixel = resized.get_pixel((x + 1) as u32, y as u32).0[0]; + + if left_pixel > right_pixel { + hash |= 1 << bit_position; + } + bit_position += 1; + } + } + + hash.to_le_bytes() + } + + /// Convert RGB pixels to grayscale + #[allow(dead_code)] + fn to_grayscale(&self, rgb: &[u8]) -> Vec { + let mut grayscale = Vec::new(); + for chunk in rgb.chunks(3) { + if chunk.len() == 3 { + // Standard grayscale conversion: 0.299*R + 0.587*G + 0.114*B + let gray = (0.299 * chunk[0] as f32 + + 0.587 * chunk[1] as f32 + + 0.114 * chunk[2] as f32) as u8; + grayscale.push(gray); + } + } + grayscale + } + + /// Simple box filter resize (nearest neighbor) + fn resize(&self, pixels: &[u8], orig_width: usize, orig_height: usize) -> Vec { + let mut resized = Vec::with_capacity(self.width * self.height); + + for y in 0..self.height { + for x in 0..self.width { + let orig_x = (x * orig_width) / self.width; + let orig_y = (y * orig_height) / self.height; + let idx = orig_y * orig_width + orig_x; + + if idx < pixels.len() { + resized.push(pixels[idx]); + } else { + resized.push(0); + } + } + } + + resized + } + + /// Calculate the DHash from grayscale pixels + pub fn calculate(&self, grayscale_pixels: &[u8], width: usize, height: usize) -> [u8; 8] { + // Resize to 9x8 + let resized = self.resize(grayscale_pixels, width, height); + + // Calculate differences and build hash + let mut hash = 0u64; + let mut bit_position = 0; + + for y in 0..self.height { + for x in 0..self.width - 1 { + let idx = y * self.width + x; + if idx + 1 < resized.len() { + // Set bit to 1 if left pixel is brighter than right + if resized[idx] > resized[idx + 1] { + hash |= 1 << bit_position; + } + bit_position += 1; + } + } + } + + hash.to_le_bytes() + } +} + +/// Calculate Hamming distance between two perceptual hashes +/// Used to determine similarity between images +pub fn hamming_distance(hash1: &[u8; 8], hash2: &[u8; 8]) -> u32 { + let mut distance = 0u32; + + for i in 0..8 { + let xor = hash1[i] ^ hash2[i]; + distance += xor.count_ones(); + } + + distance +} + +/// Check if two images are similar based on their perceptual hashes +/// Returns true if Hamming distance is below threshold (typically 10-15) +pub fn are_images_similar(hash1: &[u8; 8], hash2: &[u8; 8], threshold: u32) -> bool { + hamming_distance(hash1, hash2) <= threshold +} + +/// Fetch image from URL and return bytes +pub async fn fetch_image_bytes(url: &str) -> Result, String> { + // Check URL is valid and uses HTTPS + if !url.starts_with("https://") { + return Err("Avatar URL must use HTTPS".to_string()); + } + + // Validate URL length per DIP-0015 (max 2048 characters) + if url.len() > 2048 { + return Err("Avatar URL exceeds maximum length of 2048 characters".to_string()); + } + + // Create HTTP client with timeout + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + // Send GET request + let response = client + .get(url) + .send() + .await + .map_err(|e| format!("Failed to fetch image: {}", e))?; + + // Check status code + if !response.status().is_success() { + return Err(format!("HTTP error: {}", response.status())); + } + + // Check content type + if let Some(content_type) = response.headers().get("content-type") { + let content_type_str = content_type + .to_str() + .map_err(|e| format!("Invalid content-type header: {}", e))?; + + if !content_type_str.starts_with("image/") { + return Err(format!( + "Invalid content type: expected image/*, got {}", + content_type_str + )); + } + } + + // Check content length if provided + if let Some(content_length) = response.headers().get("content-length") { + let length_str = content_length + .to_str() + .map_err(|e| format!("Invalid content-length header: {}", e))?; + + let length: usize = length_str + .parse() + .map_err(|e| format!("Failed to parse content-length: {}", e))?; + + if length > MAX_IMAGE_SIZE { + return Err(format!( + "Image too large: {} bytes (max {} bytes)", + length, MAX_IMAGE_SIZE + )); + } + } + + // Download the image bytes + let bytes = response + .bytes() + .await + .map_err(|e| format!("Failed to download image: {}", e))?; + + // Verify actual size + if bytes.len() > MAX_IMAGE_SIZE { + return Err(format!( + "Image too large: {} bytes (max {} bytes)", + bytes.len(), + MAX_IMAGE_SIZE + )); + } + + // Try to validate it's actually an image by attempting to load it + image::load_from_memory(&bytes).map_err(|e| format!("Invalid image data: {}", e))?; + + Ok(bytes.to_vec()) +} + +/// Process an avatar image: fetch, validate, and calculate hashes +pub async fn process_avatar(url: &str) -> Result<(Vec, [u8; 32], [u8; 8]), String> { + // Fetch the image + let image_bytes = fetch_image_bytes(url).await?; + + // Calculate SHA-256 hash + let hash = calculate_avatar_hash(&image_bytes); + + // Calculate DHash fingerprint + let fingerprint = calculate_dhash_fingerprint(&image_bytes)?; + + Ok((image_bytes, hash, fingerprint)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_avatar_hash() { + let test_data = b"test image data"; + let hash = calculate_avatar_hash(test_data); + assert_eq!(hash.len(), 32); + } + + #[test] + fn test_hamming_distance() { + let hash1 = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; + let hash2 = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + assert_eq!(hamming_distance(&hash1, &hash2), 64); + + let hash3 = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; + assert_eq!(hamming_distance(&hash1, &hash3), 0); + } + + #[test] + fn test_image_similarity() { + let hash1 = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; + let hash2 = [0xFE, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; // 1 bit different + + assert!(are_images_similar(&hash1, &hash2, 10)); + assert!(!are_images_similar(&hash1, &hash2, 0)); + } + + #[test] + fn test_dhash_with_real_image() { + // Create a simple test image (3x3 grayscale) + let pixels = vec![ + 0, 50, 100, // Row 1: increasing brightness + 50, 100, 150, // Row 2: increasing brightness + 100, 150, 200, // Row 3: increasing brightness + ]; + + // Create an image from raw pixels + let img = image::GrayImage::from_raw(3, 3, pixels).unwrap(); + let dynamic_img = DynamicImage::ImageLuma8(img); + + // Calculate DHash + let calculator = DHashCalculator::new(); + let hash = calculator.calculate_from_image(&dynamic_img); + + // Verify we get an 8-byte hash + assert_eq!(hash.len(), 8); + } + + #[tokio::test] + async fn test_url_validation() { + // Test non-HTTPS URL + let result = fetch_image_bytes("http://example.com/image.jpg").await; + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "Avatar URL must use HTTPS"); + + // Test URL that's too long + let long_url = format!("https://example.com/{}", "a".repeat(2100)); + let result = fetch_image_bytes(&long_url).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("exceeds maximum length")); + } +} diff --git a/src/backend_task/dashpay/contact_info.rs b/src/backend_task/dashpay/contact_info.rs new file mode 100644 index 000000000..cd7b875ab --- /dev/null +++ b/src/backend_task/dashpay/contact_info.rs @@ -0,0 +1,524 @@ +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use aes_gcm::aes::Aes256; +use aes_gcm::aes::cipher::{BlockEncrypt, KeyInit}; +use bip39::rand::{SeedableRng, rngs::StdRng}; +use cbc::cipher::{BlockEncryptMut, KeyIvInit}; +use dash_sdk::Sdk; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::document::{ + Document as DppDocument, DocumentV0, DocumentV0Getters, DocumentV0Setters, +}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey}; +use dash_sdk::dpp::platform_value::{Bytes32, Value}; +use dash_sdk::drive::query::{WhereClause, WhereOperator}; +use dash_sdk::platform::documents::transitions::DocumentCreateTransitionBuilder; +use dash_sdk::platform::{Document, DocumentQuery, FetchMany, Identifier}; +use std::collections::{BTreeMap, HashSet}; +use std::str::FromStr; +use std::sync::Arc; + +// ContactInfo private data structure +#[derive(Debug, Clone, Default)] +pub struct ContactInfoPrivateData { + pub version: u32, + pub alias_name: Option, + pub note: Option, + pub display_hidden: bool, + pub accepted_accounts: Vec, +} + +impl ContactInfoPrivateData { + pub fn new() -> Self { + Self::default() + } + + // Serialize to bytes for encryption + pub fn serialize(&self) -> Vec { + let mut bytes = Vec::new(); + + // Version (4 bytes) + bytes.extend_from_slice(&self.version.to_le_bytes()); + + // Alias name (length + string) + if let Some(alias) = &self.alias_name { + let alias_bytes = alias.as_bytes(); + bytes.push(alias_bytes.len() as u8); + bytes.extend_from_slice(alias_bytes); + } else { + bytes.push(0u8); + } + + // Note (length + string) + if let Some(note) = &self.note { + let note_bytes = note.as_bytes(); + bytes.push(note_bytes.len() as u8); + bytes.extend_from_slice(note_bytes); + } else { + bytes.push(0u8); + } + + // Display hidden (1 byte) + bytes.push(if self.display_hidden { 1 } else { 0 }); + + // Accepted accounts (length + array) + bytes.push(self.accepted_accounts.len() as u8); + for account in &self.accepted_accounts { + bytes.extend_from_slice(&account.to_le_bytes()); + } + + bytes + } +} + +/// Derive encryption keys for contactInfo using BIP32 CKDpriv as specified in DIP-0015. +/// +/// DIP-0015 specifies: +/// - Key1 (for encToUserId): rootEncryptionKey/(2^16)'/index' +/// - Key2 (for privateData): rootEncryptionKey/(2^16 + 1)'/index' +/// +/// We use the wallet's master seed to derive a root encryption key, +/// then apply BIP32 hardened derivation for the two encryption keys. +fn derive_contact_info_keys( + identity: &QualifiedIdentity, + derivation_index: u32, +) -> Result<([u8; 32], [u8; 32]), String> { + // Get the wallet seed from the identity's associated wallet + let wallet = identity + .associated_wallets + .values() + .next() + .ok_or("No wallet associated with identity for key derivation")?; + + let (seed, network) = { + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + if !wallet_guard.is_open() { + return Err("Wallet must be unlocked to derive encryption keys".to_string()); + } + let seed = wallet_guard + .seed_bytes() + .map_err(|e| format!("Wallet seed not available: {}", e))? + .to_vec(); + (seed, identity.network) + }; + + // Create master extended private key from seed + let master_xprv = ExtendedPrivKey::new_master(network, &seed) + .map_err(|e| format!("Failed to create master key: {}", e))?; + + // Derive to the root encryption key path: m/9'/5'/15'/0' + // This follows the DashPay derivation structure + let root_path = DerivationPath::from_str("m/9'/5'/15'/0'") + .map_err(|e| format!("Invalid derivation path: {}", e))?; + + let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); + let root_encryption_key = master_xprv + .derive_priv(&secp, &root_path) + .map_err(|e| format!("Failed to derive root encryption key: {}", e))?; + + // Derive Key1 for encToUserId: rootEncryptionKey/(2^16)'/index' + // First derive at hardened index 2^16 (65536) + let key1_level1 = root_encryption_key + .derive_priv( + &secp, + &[ChildNumber::from_hardened_idx(65536) + .map_err(|e| format!("Invalid hardened index: {}", e))?], + ) + .map_err(|e| format!("Failed to derive key1 level1: {}", e))?; + + // Then derive at hardened derivation_index + let key1_final = key1_level1 + .derive_priv( + &secp, + &[ChildNumber::from_hardened_idx(derivation_index) + .map_err(|e| format!("Invalid hardened index: {}", e))?], + ) + .map_err(|e| format!("Failed to derive key1 final: {}", e))?; + + // Derive Key2 for privateData: rootEncryptionKey/(2^16 + 1)'/index' + // First derive at hardened index 2^16 + 1 (65537) + let key2_level1 = root_encryption_key + .derive_priv( + &secp, + &[ChildNumber::from_hardened_idx(65537) + .map_err(|e| format!("Invalid hardened index: {}", e))?], + ) + .map_err(|e| format!("Failed to derive key2 level1: {}", e))?; + + // Then derive at hardened derivation_index + let key2_final = key2_level1 + .derive_priv( + &secp, + &[ChildNumber::from_hardened_idx(derivation_index) + .map_err(|e| format!("Invalid hardened index: {}", e))?], + ) + .map_err(|e| format!("Failed to derive key2 final: {}", e))?; + + // Extract the private key bytes (32 bytes) for encryption + let key1_bytes: [u8; 32] = key1_final.private_key.secret_bytes(); + let key2_bytes: [u8; 32] = key2_final.private_key.secret_bytes(); + + Ok((key1_bytes, key2_bytes)) +} + +/// Encrypt toUserId using AES-256-ECB as specified by DIP-0015. +/// +/// DIP-0015 mandates ECB mode for encToUserId encryption because: +/// 1. The toUserId is derived from SHA256, making it appear random (no patterns) +/// 2. Keys are never reused (unique per contact via hardened BIP32 derivation) +/// 3. The data is fixed-size (32 bytes = exactly 2 AES blocks) +/// +/// These properties eliminate typical ECB vulnerabilities (pattern leakage). +/// See: https://github.com/dashpay/dips/blob/master/dip-0015.md +#[allow(deprecated)] +fn encrypt_to_user_id(user_id: &[u8; 32], key: &[u8; 32]) -> Result<[u8; 32], String> { + use aes_gcm::aead::generic_array::GenericArray; + let cipher = Aes256::new(GenericArray::from_slice(key)); + + // Split the 32-byte ID into two 16-byte blocks for ECB mode + let mut encrypted = [0u8; 32]; + + let mut block1 = GenericArray::clone_from_slice(&user_id[0..16]); + let mut block2 = GenericArray::clone_from_slice(&user_id[16..32]); + + cipher.encrypt_block(&mut block1); + cipher.encrypt_block(&mut block2); + + encrypted[0..16].copy_from_slice(&block1); + encrypted[16..32].copy_from_slice(&block2); + + Ok(encrypted) +} + +/// Decrypt toUserId using AES-256-ECB as specified by DIP-0015. +/// +/// See `encrypt_to_user_id` for the rationale behind ECB mode usage per DIP-0015. +#[allow(deprecated)] +fn decrypt_to_user_id(encrypted: &[u8], key: &[u8; 32]) -> Result<[u8; 32], String> { + use aes_gcm::aead::generic_array::GenericArray; + use aes_gcm::aes::cipher::BlockDecrypt; + + if encrypted.len() != 32 { + return Err("Invalid encrypted user ID length".to_string()); + } + + let cipher = Aes256::new(GenericArray::from_slice(key)); + + // Split the 32-byte encrypted data into two 16-byte blocks for ECB mode + let mut decrypted = [0u8; 32]; + + let mut block1 = GenericArray::clone_from_slice(&encrypted[0..16]); + let mut block2 = GenericArray::clone_from_slice(&encrypted[16..32]); + + cipher.decrypt_block(&mut block1); + cipher.decrypt_block(&mut block2); + + decrypted[0..16].copy_from_slice(&block1); + decrypted[16..32].copy_from_slice(&block2); + + Ok(decrypted) +} + +// Encrypt private data using AES-256-CBC +fn encrypt_private_data(data: &[u8], key: &[u8; 32]) -> Result, String> { + use cbc::cipher::block_padding::Pkcs7; + type Aes256CbcEnc = cbc::Encryptor; + + // Generate random IV (16 bytes) + let mut rng = StdRng::from_entropy(); + let mut iv = [0u8; 16]; + use bip39::rand::RngCore; + rng.fill_bytes(&mut iv); + + // Pad data to multiple of 16 bytes and encrypt + let cipher = Aes256CbcEnc::new(key.into(), &iv.into()); + + // Allocate buffer with padding + let mut buffer = vec![0u8; data.len() + 16]; // Extra space for padding + buffer[..data.len()].copy_from_slice(data); + + let encrypted = cipher + .encrypt_padded_mut::(&mut buffer, data.len()) + .map_err(|e| format!("Encryption failed: {:?}", e))?; + + // Combine IV and encrypted data + let mut result = Vec::with_capacity(16 + encrypted.len()); + result.extend_from_slice(&iv); + result.extend_from_slice(encrypted); + + Ok(result) +} + +// Decrypt private data using AES-256-CBC +#[allow(dead_code)] +fn decrypt_private_data(encrypted_data: &[u8], key: &[u8; 32]) -> Result, String> { + use cbc::cipher::BlockDecryptMut; + use cbc::cipher::block_padding::Pkcs7; + type Aes256CbcDec = cbc::Decryptor; + + if encrypted_data.len() < 16 { + return Err("Encrypted data too short (no IV)".to_string()); + } + + // Extract IV and ciphertext + let iv = &encrypted_data[0..16]; + let ciphertext = &encrypted_data[16..]; + + // Decrypt + let cipher = Aes256CbcDec::new(key.into(), iv.into()); + + let mut buffer = ciphertext.to_vec(); + let decrypted = cipher + .decrypt_padded_mut::(&mut buffer) + .map_err(|e| format!("Decryption failed: {:?}", e))?; + + Ok(decrypted.to_vec()) +} + +#[allow(clippy::too_many_arguments)] +pub async fn create_or_update_contact_info( + app_context: &Arc, + sdk: &Sdk, + identity: QualifiedIdentity, + contact_user_id: Identifier, + nickname: Option, + note: Option, + display_hidden: bool, + accepted_accounts: Vec, +) -> Result { + let dashpay_contract = app_context.dashpay_contract.clone(); + let identity_id = identity.identity.id(); + + // Query for existing contactInfo document + let mut query = DocumentQuery::new(dashpay_contract.clone(), "contactInfo") + .map_err(|e| format!("Failed to create query: {}", e))?; + + query = query.with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity_id.to_buffer()), + }); + query.limit = 100; // Get all contact info documents + + let existing_docs = Document::fetch_many(sdk, query) + .await + .map_err(|e| format!("Error fetching contact info: {}", e))?; + + // Check if we already have a contactInfo for this contact + let mut found_existing_doc = None; + let mut next_derivation_index = 0u32; + + // Try to find existing contactInfo for this contact + for (_doc_id, doc) in existing_docs.iter() { + if let Some(doc) = doc { + let props = doc.properties(); + + // Get the derivation index used for this document + if let Some(Value::U32(deriv_idx)) = props.get("derivationEncryptionKeyIndex") { + // Track the highest derivation index + if *deriv_idx >= next_derivation_index { + next_derivation_index = deriv_idx + 1; + } + + // Get the root key index to derive keys + if let Some(Value::U32(_root_idx)) = props.get("rootEncryptionKeyIndex") { + // Derive keys for this document + let (enc_user_id_key, _) = derive_contact_info_keys(&identity, *deriv_idx)?; + + // Decrypt encToUserId to check if it matches + if let Some(Value::Bytes(enc_user_id)) = props.get("encToUserId") { + match decrypt_to_user_id(enc_user_id, &enc_user_id_key) { + Ok(decrypted_id) if decrypted_id == contact_user_id.to_buffer() => { + // Found existing contactInfo for this contact + found_existing_doc = Some(doc.clone()); + break; + } + _ => {} + } + } + } + } + } + } + + // Use the found derivation index or the next available one + let derivation_index = if found_existing_doc.is_some() { + // Use the same derivation index for updates + found_existing_doc + .as_ref() + .and_then(|doc| doc.properties().get("derivationEncryptionKeyIndex")) + .and_then(|v| { + if let Value::U32(idx) = v { + Some(*idx) + } else { + None + } + }) + .unwrap_or(0) + } else { + next_derivation_index + }; + + // Derive encryption keys + let (enc_user_id_key, private_data_key) = + derive_contact_info_keys(&identity, derivation_index)?; + + // Encrypt toUserId + let encrypted_user_id = encrypt_to_user_id(&contact_user_id.to_buffer(), &enc_user_id_key)?; + + // Create private data + let mut private_data = ContactInfoPrivateData::new(); + private_data.alias_name = nickname; + private_data.note = note; + private_data.display_hidden = display_hidden; + private_data.accepted_accounts = accepted_accounts; + + // Encrypt private data + let encrypted_private_data = + encrypt_private_data(&private_data.serialize(), &private_data_key)?; + + // Get signing key + let signing_key = identity + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ]), + HashSet::from([KeyType::ECDSA_SECP256K1]), + false, + ) + .ok_or("No suitable signing key found. This operation requires a ECDSA_SECP256K1 AUTHENTICATION key.")?; + + // Create document properties + let mut properties = BTreeMap::new(); + properties.insert( + "encToUserId".to_string(), + Value::Bytes(encrypted_user_id.to_vec()), + ); + properties.insert( + "rootEncryptionKeyIndex".to_string(), + Value::U32(signing_key.id()), + ); + properties.insert( + "derivationEncryptionKeyIndex".to_string(), + Value::U32(derivation_index), + ); + properties.insert( + "privateData".to_string(), + Value::Bytes(encrypted_private_data), + ); + + if let Some(existing_doc) = found_existing_doc { + // Update existing document + let mut updated_doc = existing_doc.clone(); + + // Update properties + for (key, value) in properties { + updated_doc.set(&key, value); + } + + // Bump revision + updated_doc.bump_revision(); + + // Create replacement transition + use dash_sdk::platform::documents::transitions::DocumentReplaceTransitionBuilder; + let mut builder = DocumentReplaceTransitionBuilder::new( + dashpay_contract, + "contactInfo".to_string(), + updated_doc, + ); + + // Add state transition options if available + let maybe_options = app_context.state_transition_options(); + if let Some(options) = maybe_options { + builder = builder.with_state_transition_creation_options(options); + } + + let result = sdk + .document_replace(builder, signing_key, &identity) + .await + .map_err(|e| format!("Error updating contact info: {}", e))?; + + // Log the proof-verified document for audit trail + match result { + dash_sdk::platform::documents::transitions::DocumentReplaceResult::Document(doc) => { + tracing::info!( + "Contact info updated: doc_id={}, revision={:?}", + doc.id(), + doc.revision() + ); + } + } + } else { + // Create new contactInfo document + let mut rng = StdRng::from_entropy(); + let entropy = Bytes32::random_with_rng(&mut rng); + + let document_id = Document::generate_document_id_v0( + &dashpay_contract.id(), + &identity_id, + "contactInfo", + entropy.as_slice(), + ); + + let document = DppDocument::V0(DocumentV0 { + id: document_id, + owner_id: identity_id, + creator_id: None, + properties, + revision: Some(1), + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + }); + + let mut builder = DocumentCreateTransitionBuilder::new( + dashpay_contract, + "contactInfo".to_string(), + document, + entropy + .as_slice() + .try_into() + .expect("entropy should be 32 bytes"), + ); + + // Add state transition options if available + let maybe_options = app_context.state_transition_options(); + if let Some(options) = maybe_options { + builder = builder.with_state_transition_creation_options(options); + } + + let result = sdk + .document_create(builder, signing_key, &identity) + .await + .map_err(|e| format!("Error creating contact info: {}", e))?; + + // Log the proof-verified document for audit trail + match result { + dash_sdk::platform::documents::transitions::DocumentCreateResult::Document(doc) => { + tracing::info!( + "Contact info created: doc_id={}, revision={:?}", + doc.id(), + doc.revision() + ); + } + } + } + + Ok(BackendTaskSuccessResult::DashPayContactInfoUpdated( + contact_user_id, + )) +} diff --git a/src/backend_task/dashpay/contact_requests.rs b/src/backend_task/dashpay/contact_requests.rs new file mode 100644 index 000000000..5e623d356 --- /dev/null +++ b/src/backend_task/dashpay/contact_requests.rs @@ -0,0 +1,685 @@ +use super::encryption::{ + encrypt_account_label, encrypt_extended_public_key, generate_ecdh_shared_key, +}; +use super::hd_derivation::{ + calculate_account_reference, derive_dashpay_incoming_xpub, generate_contact_xpub_data, +}; +use super::validation::validate_contact_request_before_send; +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::dashpay::auto_accept_proof::{ + AutoAcceptProofData, create_auto_accept_proof_bytes_with_key, +}; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use bip39::rand::{SeedableRng, rngs::StdRng}; +use dash_sdk::Sdk; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::document::{Document as DppDocument, DocumentV0, DocumentV0Getters}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{Identity, KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::platform_value::{Bytes32, Value}; +use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; +use dash_sdk::platform::documents::transitions::DocumentCreateTransitionBuilder; +use dash_sdk::platform::{ + Document, DocumentQuery, Fetch, FetchMany, FetchUnproved, Identifier, IdentityPublicKey, +}; +use dash_sdk::query_types::{CurrentQuorumsInfo, NoParamQuery}; +use std::collections::{BTreeMap, HashSet}; +use std::sync::Arc; + +pub async fn load_contact_requests( + app_context: &Arc, + sdk: &Sdk, + identity: QualifiedIdentity, +) -> Result { + let identity_id = identity.identity.id(); + let dashpay_contract = app_context.dashpay_contract.clone(); + + tracing::info!( + "Loading contact requests for identity: {}", + identity_id.to_string(Encoding::Base58) + ); + + // Query for incoming contact requests (where toUserId == our identity) + let mut incoming_query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest") + .map_err(|e| format!("Failed to create query: {}", e))?; + + let query_value = Value::Identifier(identity_id.to_buffer()); + + incoming_query = incoming_query.with_where(WhereClause { + field: "toUserId".to_string(), + operator: WhereOperator::Equal, + value: query_value.clone(), + }); + + // Without this orderBy, the query returns 0 results even when documents exist + incoming_query = incoming_query.with_order_by(OrderClause { + field: "$createdAt".to_string(), + ascending: true, + }); + incoming_query.limit = 50; + + // Query for outgoing contact requests (where $ownerId == our identity) + let mut outgoing_query = DocumentQuery::new(dashpay_contract, "contactRequest") + .map_err(|e| format!("Failed to create query: {}", e))?; + + outgoing_query = outgoing_query.with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity_id.to_buffer()), + }); + + // Without this orderBy, the query may return 0 results even when documents exist + outgoing_query = outgoing_query.with_order_by(OrderClause { + field: "$createdAt".to_string(), + ascending: true, + }); + outgoing_query.limit = 50; + + // Fetch both types of requests + tracing::info!("Fetching incoming contact requests..."); + let incoming_docs = Document::fetch_many(sdk, incoming_query) + .await + .map_err(|e| format!("Error fetching incoming requests: {}", e))?; + tracing::info!("Fetched {} incoming documents", incoming_docs.len()); + + tracing::info!("Fetching outgoing contact requests..."); + let outgoing_docs = Document::fetch_many(sdk, outgoing_query) + .await + .map_err(|e| format!("Error fetching outgoing requests: {}", e))?; + tracing::info!("Fetched {} outgoing documents", outgoing_docs.len()); + + // Convert to vec of tuples (id, document) + // TODO: Process autoAcceptProof for incoming requests + // When an incoming request has a valid autoAcceptProof, we should: + // 1. Verify the proof signature + // 2. Automatically send a contact request back if valid + // 3. Mark the contact as auto-accepted + let mut incoming: Vec<(Identifier, Document)> = incoming_docs + .into_iter() + .filter_map(|(id, doc)| doc.map(|d| (id, d))) + .collect(); + + let mut outgoing: Vec<(Identifier, Document)> = outgoing_docs + .into_iter() + .filter_map(|(id, doc)| doc.map(|d| (id, d))) + .collect(); + + // Filter out mutual requests (where both parties have sent requests to each other) + // These are now contacts, not pending requests + let mut contacts_established = HashSet::new(); + + // Check each incoming request + for (_, incoming_doc) in incoming.iter() { + let from_id = incoming_doc.owner_id(); + + // Check if we also sent a request to this person + for (_, outgoing_doc) in outgoing.iter() { + if let Some(Value::Identifier(to_id_bytes)) = outgoing_doc.properties().get("toUserId") + { + // Parse the identifier, skip if invalid + let Ok(to_id) = Identifier::from_bytes(to_id_bytes.as_slice()) else { + tracing::warn!("Invalid toUserId in contact request document, skipping"); + continue; + }; + if to_id == from_id { + // Mutual request found - they are now contacts + contacts_established.insert(from_id); + } + } + } + } + + // Filter out established contacts from both lists + incoming.retain(|(_, doc)| !contacts_established.contains(&doc.owner_id())); + + outgoing.retain(|(_, doc)| { + if let Some(Value::Identifier(to_id_bytes)) = doc.properties().get("toUserId") { + // Parse the identifier, keep the document if we can't parse (defensive) + let Ok(to_id) = Identifier::from_bytes(to_id_bytes.as_slice()) else { + tracing::warn!("Invalid toUserId in outgoing contact request, keeping in list"); + return true; + }; + !contacts_established.contains(&to_id) + } else { + true + } + }); + + tracing::info!( + "After filtering: {} incoming, {} outgoing contact requests", + incoming.len(), + outgoing.len() + ); + + Ok(BackendTaskSuccessResult::DashPayContactRequests { incoming, outgoing }) +} + +pub async fn send_contact_request( + app_context: &Arc, + sdk: &Sdk, + identity: QualifiedIdentity, + signing_key: IdentityPublicKey, + to_username_or_id: String, + account_label: Option, +) -> Result { + send_contact_request_with_proof( + app_context, + sdk, + identity, + signing_key, + to_username_or_id, + account_label, + None, + ) + .await +} + +pub async fn send_contact_request_with_proof( + app_context: &Arc, + sdk: &Sdk, + identity: QualifiedIdentity, + signing_key: IdentityPublicKey, + to_username_or_id: String, + account_label: Option, + qr_auto_accept: Option, +) -> Result { + // Step 1: Resolve the recipient identity + let to_identity = if to_username_or_id.ends_with(".dash") { + // It's a complete username, resolve via DPNS + resolve_username_to_identity(sdk, &to_username_or_id).await? + } else { + // Try to parse as identity ID first + match Identifier::from_string_try_encodings( + &to_username_or_id, + &[Encoding::Base58, Encoding::Hex], + ) { + Ok(to_id) => { + // Successfully parsed as ID, fetch the identity + Identity::fetch(sdk, to_id) + .await + .map_err(|e| format!("Failed to fetch identity: {}", e))? + .ok_or_else(|| format!("Identity {} not found", to_username_or_id))? + } + Err(_) => { + // Not a valid ID format, assume it's a username without .dash suffix + let username_with_suffix = format!("{}.dash", to_username_or_id); + resolve_username_to_identity(sdk, &username_with_suffix).await? + } + } + }; + + let to_identity_id = to_identity.id(); + + // Step 2: Check if a contact request already exists + let dashpay_contract = app_context.dashpay_contract.clone(); + let mut existing_query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest") + .map_err(|e| format!("Failed to create query: {}", e))?; + + existing_query = existing_query + .with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity.identity.id().to_buffer()), + }) + .with_where(WhereClause { + field: "toUserId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(to_identity_id.to_buffer()), + }); + existing_query.limit = 1; + + let existing = Document::fetch_many(sdk, existing_query) + .await + .map_err(|e| format!("Error checking existing requests: {}", e))?; + + if !existing.is_empty() { + return Err(format!( + "Contact request already sent to {}", + to_username_or_id + )); + } + + // Step 3: Get key indices for ECDH + // Per DIP-11/DIP-15: Use ENCRYPTION key for sender (to encrypt outgoing), + // DECRYPTION key for recipient (they will decrypt incoming) + // Note: signing_key is an AUTHENTICATION key used to sign the state transition + // We need a separate ENCRYPTION key for ECDH + let sender_encryption_key = identity + .identity + .get_first_public_key_matching( + Purpose::ENCRYPTION, + HashSet::from([SecurityLevel::MEDIUM]), + HashSet::from([KeyType::ECDSA_SECP256K1]), + false, + ) + .ok_or_else(|| { + "Sender does not have a compatible ECDSA_SECP256K1 ENCRYPTION key for ECDH. Please add a DashPay-compatible encryption key to your identity.".to_string() + })?; + + // Find a recipient DECRYPTION key that supports ECDH (must be ECDSA_SECP256K1) + // Platform enforces MEDIUM security level for ENCRYPTION/DECRYPTION keys + let recipient_key = to_identity + .get_first_public_key_matching( + Purpose::DECRYPTION, + HashSet::from([SecurityLevel::MEDIUM]), + HashSet::from([KeyType::ECDSA_SECP256K1]), + false, + ) + .ok_or_else(|| { + "Recipient does not have a compatible ECDSA_SECP256K1 DECRYPTION key for ECDH. They need to add a DashPay-compatible decryption key to their identity.".to_string() + })?; + + // Step 4: Generate ECDH shared key and encrypt data + let wallets: Vec<_> = identity.associated_wallets.values().cloned().collect(); + let sender_private_key = identity + .private_keys + .get_resolve( + &( + crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, + sender_encryption_key.id(), + ), + &wallets, + identity.network, + ) + .map_err(|e| format!("Error resolving ENCRYPTION private key: {}", e))? + .map(|(_, private_key)| private_key) + .ok_or_else(|| "Sender does not have an ECDSA_SECP256K1 ENCRYPTION private key loaded into Dash Evo Tool.".to_string())?; + + let shared_key = generate_ecdh_shared_key(&sender_private_key, recipient_key) + .map_err(|e| format!("Failed to generate ECDH shared key: {}", e))?; + + // Generate extended public key for this contact using proper HD derivation + // For now, use the sender's private key as seed material + // In production, this would derive from the wallet's HD seed/mnemonic + let wallet_seed = sender_private_key; + + // Get the network from app context + let network = app_context.network; + + // Use account 0 for now (could be made configurable) + let account_index = 0u32; + + // Generate the extended public key data for this contact relationship + let (parent_fingerprint, chain_code, contact_public_key) = generate_contact_xpub_data( + &wallet_seed, + network, + account_index, + &identity.identity.id(), + &to_identity_id, + ) + .map_err(|e| format!("Failed to generate contact extended public key: {}", e))?; + + // Also derive the full xpub for account reference calculation per DIP-0015 + let contact_xpub = derive_dashpay_incoming_xpub( + &wallet_seed, + network, + account_index, + &identity.identity.id(), + &to_identity_id, + ) + .map_err(|e| format!("Failed to derive contact xpub: {}", e))?; + + // Calculate account reference per DIP-0015 (ASK-based shortening) + // Version 0 is the current version + let account_reference = calculate_account_reference( + &sender_private_key, + &contact_xpub, + account_index, + 0, // version + ); + + let encrypted_public_key = encrypt_extended_public_key( + parent_fingerprint, + chain_code, + contact_public_key, + &shared_key, + ) + .map_err(|e| format!("Failed to encrypt extended public key: {}", e))?; + + // Step 5: Get the current core chain height for synchronization + let (core_height, current_height_for_validation) = + match CurrentQuorumsInfo::fetch_unproved(sdk, NoParamQuery {}).await { + Ok(Some(quorum_info)) => ( + quorum_info.last_core_block_height, + Some(quorum_info.last_core_block_height), + ), + Ok(None) => { + (0u32, None) // Fallback if no quorum info available + } + Err(_e) => { + (0u32, None) // Fallback on error + } + }; + + // Step 5.5: Validate the contact request before proceeding + // Note: We validate the ENCRYPTION key (used for ECDH), not the signing key + let validation = validate_contact_request_before_send( + sdk, + &identity, + sender_encryption_key.id(), + to_identity.id(), + recipient_key.id(), + account_reference, + core_height, + current_height_for_validation, + ) + .await + .map_err(|e| format!("Validation failed: {}", e))?; + + // Check if validation passed + if !validation.is_valid { + let error_msg = format!( + "Contact request validation failed: {}", + validation.errors.join("; ") + ); + return Err(error_msg); + } + + // Log any warnings + for _warning in &validation.warnings {} + + // Step 6: Create contact request document + let mut properties = BTreeMap::new(); + properties.insert( + "toUserId".to_string(), + Value::Identifier(to_identity_id.to_buffer()), + ); + properties.insert( + "senderKeyIndex".to_string(), + Value::U32(sender_encryption_key.id()), + ); + properties.insert( + "recipientKeyIndex".to_string(), + Value::U32(recipient_key.id()), + ); + // Account reference calculated per DIP-0015 (ASK-based shortening) + properties.insert( + "accountReference".to_string(), + Value::U32(account_reference), + ); + properties.insert( + "encryptedPublicKey".to_string(), + Value::Bytes(encrypted_public_key), + ); + + // Note: $coreHeightCreatedAt is handled automatically by the platform + + // Add encrypted account label if provided + if let Some(label) = account_label { + let encrypted_label = encrypt_account_label(&label, &shared_key) + .map_err(|e| format!("Failed to encrypt account label: {}", e))?; + properties.insert( + "encryptedAccountLabel".to_string(), + Value::Bytes(encrypted_label), + ); + } + + // If QR auto-accept data is provided, create the proof bytes now to match the final accountReference + if let Some(qr) = qr_auto_accept { + // Ensure the QR target matches the resolved recipient + if qr.identity_id != to_identity_id { + return Err("QR code target identity does not match recipient".to_string()); + } + let proof = create_auto_accept_proof_bytes_with_key( + qr.expires_at, + &qr.proof_key, + &identity.identity.id(), + &to_identity_id, + account_reference, + )?; + eprintln!( + "DEBUG: Including autoAcceptProof in contact request ({} bytes)", + proof.len() + ); + properties.insert("autoAcceptProof".to_string(), Value::Bytes(proof)); + } + // If no proof, don't include the field at all (schema requires 38-102 bytes if present) + + // Generate random entropy for the document transition + let mut rng = StdRng::from_entropy(); + let entropy = Bytes32::random_with_rng(&mut rng); + + // Generate deterministic document ID based on entropy + let document_id = Document::generate_document_id_v0( + &dashpay_contract.id(), + &identity.identity.id(), + "contactRequest", + entropy.as_slice(), + ); + + // Create the document + let document = DppDocument::V0(DocumentV0 { + id: document_id, + owner_id: identity.identity.id(), + creator_id: None, + properties, + revision: Some(1), + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + }); + + // Step 7: Submit the contact request + // Use the selected signing key + let identity_key = &signing_key; + + let mut builder = DocumentCreateTransitionBuilder::new( + dashpay_contract, + "contactRequest".to_string(), + document, + entropy + .as_slice() + .try_into() + .expect("entropy should be 32 bytes"), + ); + + // Add state transition options if available + let maybe_options = app_context.state_transition_options(); + if let Some(options) = maybe_options { + builder = builder.with_state_transition_creation_options(options); + } + + let result = sdk + .document_create(builder, identity_key, &identity) + .await + .map_err(|e| format!("Error creating contact request: {}", e))?; + + // Log the proof-verified document for audit trail + match result { + dash_sdk::platform::documents::transitions::DocumentCreateResult::Document(doc) => { + tracing::info!( + "Contact request created: doc_id={}, revision={:?}", + doc.id(), + doc.revision() + ); + } + } + + Ok(BackendTaskSuccessResult::DashPayContactRequestSent( + to_username_or_id.to_string(), + )) +} + +async fn resolve_username_to_identity(sdk: &Sdk, username: &str) -> Result { + // Parse username (e.g., "alice.dash" -> "alice") + let name = username + .split('.') + .next() + .ok_or_else(|| format!("Invalid username format: {}", username))?; + + // Query DPNS for the username + let dpns_contract_id = Identifier::from_string( + "GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec", + Encoding::Base58, + ) + .map_err(|e| format!("Failed to parse DPNS contract ID: {}", e))?; + + let dpns_contract = dash_sdk::platform::DataContract::fetch(sdk, dpns_contract_id) + .await + .map_err(|e| format!("Failed to fetch DPNS contract: {}", e))? + .ok_or("DPNS contract not found")?; + + let mut query = DocumentQuery::new(Arc::new(dpns_contract), "domain") + .map_err(|e| format!("Failed to create DPNS query: {}", e))?; + + query = query.with_where(WhereClause { + field: "normalizedLabel".to_string(), + operator: WhereOperator::Equal, + value: Value::Text(name.to_lowercase()), + }); + query.limit = 1; + + let results = Document::fetch_many(sdk, query) + .await + .map_err(|e| format!("Failed to query DPNS: {}", e))?; + + let (_, document) = results + .into_iter() + .next() + .ok_or_else(|| format!("Username '{}' not found", username))?; + + let document = document.ok_or_else(|| format!("Invalid DPNS document for '{}'", username))?; + + // Get the identity ID from the DPNS document + let identity_id = document.owner_id(); + + // Fetch the identity + Identity::fetch(sdk, identity_id) + .await + .map_err(|e| format!("Failed to fetch identity for '{}': {}", username, e))? + .ok_or_else(|| format!("Identity not found for username '{}'", username)) +} + +pub async fn accept_contact_request( + app_context: &Arc, + sdk: &Sdk, + identity: QualifiedIdentity, + request_id: Identifier, +) -> Result { + // According to DashPay DIP, accepting means sending a contact request back + // First, we need to fetch the incoming contact request to get the sender's identity + + let dashpay_contract = app_context.dashpay_contract.clone(); + + // Fetch the specific contact request document by creating a query with its ID + let query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest") + .map_err(|e| format!("Failed to create query: {}", e))?; + let query_with_id = DocumentQuery::with_document_id(query, &request_id); + + let doc = Document::fetch(sdk, query_with_id) + .await + .map_err(|e| format!("Failed to fetch contact request: {}", e))? + .ok_or_else(|| format!("Contact request {} not found", request_id))?; + + // Get the sender's identity (the owner of the incoming request) + let from_identity_id = doc.owner_id(); + + // Check if we already sent a contact request to this identity + let mut existing_query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest") + .map_err(|e| format!("Failed to create query: {}", e))?; + + existing_query = existing_query + .with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity.identity.id().to_buffer()), + }) + .with_where(WhereClause { + field: "toUserId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(from_identity_id.to_buffer()), + }); + existing_query.limit = 1; + + let existing = Document::fetch_many(sdk, existing_query) + .await + .map_err(|e| format!("Error checking existing requests: {}", e))?; + + if !existing.is_empty() { + return Ok(BackendTaskSuccessResult::DashPayContactAlreadyEstablished( + from_identity_id, + )); + } + + // Get an AUTHENTICATION key for signing the state transition + // Platform requires CRITICAL or HIGH security level for document creation + let signing_key = identity + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::CRITICAL, SecurityLevel::HIGH]), + KeyType::all_key_types().into(), + false, + ) + .ok_or("Cannot accept contact request: This identity does not have a suitable AUTHENTICATION key. Please add an authentication key to your identity.")? + .clone(); + + let result = send_contact_request( + app_context, + sdk, + identity, + signing_key, + from_identity_id.to_string(Encoding::Base58), + Some("Accepted contact".to_string()), + ) + .await; + + match result { + Ok(_) => Ok(BackendTaskSuccessResult::DashPayContactRequestAccepted( + request_id, + )), + Err(e) => Err(e), + } +} + +pub async fn reject_contact_request( + app_context: &Arc, + sdk: &Sdk, + identity: QualifiedIdentity, + request_id: Identifier, +) -> Result { + // According to DashPay DIP, rejecting doesn't delete the request (they're immutable) + // Instead, we should update our contactInfo document to mark this contact as hidden + + // First, fetch the contact request to get the sender's identity + let dashpay_contract = app_context.dashpay_contract.clone(); + + let query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest") + .map_err(|e| format!("Failed to create query: {}", e))?; + let query_with_id = DocumentQuery::with_document_id(query, &request_id); + + let doc = Document::fetch(sdk, query_with_id) + .await + .map_err(|e| format!("Failed to fetch contact request: {}", e))? + .ok_or_else(|| format!("Contact request {} not found", request_id))?; + + let from_identity_id = doc.owner_id(); + + // Create or update contactInfo to mark this contact as hidden + use super::contact_info::create_or_update_contact_info; + + let _ = create_or_update_contact_info( + app_context, + sdk, + identity, + from_identity_id, + None, // No nickname + None, // No note + true, // display_hidden = true for rejected contacts + Vec::new(), // No accepted accounts + ) + .await?; + + Ok(BackendTaskSuccessResult::DashPayContactRequestRejected( + request_id, + )) +} diff --git a/src/backend_task/dashpay/contacts.rs b/src/backend_task/dashpay/contacts.rs new file mode 100644 index 000000000..3e19a71e3 --- /dev/null +++ b/src/backend_task/dashpay/contacts.rs @@ -0,0 +1,520 @@ +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use dash_sdk::Sdk; +use dash_sdk::dpp::data_contract::DataContract; +use dash_sdk::dpp::document::DocumentV0Getters; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey}; +use dash_sdk::dpp::platform_value::Value; +use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; +use dash_sdk::platform::{Document, DocumentQuery, Fetch, FetchMany, Identifier}; +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; +use std::sync::Arc; + +// DashPay contract ID from the platform repo +pub const DASHPAY_CONTRACT_ID: [u8; 32] = [ + 162, 161, 180, 172, 111, 239, 34, 234, 42, 26, 104, 232, 18, 54, 68, 179, 87, 135, 95, 107, 65, + 44, 24, 16, 146, 129, 193, 70, 231, 178, 113, 188, +]; + +pub async fn get_dashpay_contract(sdk: &Sdk) -> Result, String> { + let contract_id = Identifier::from_bytes(&DASHPAY_CONTRACT_ID).map_err(|e| e.to_string())?; + DataContract::fetch(sdk, contract_id) + .await + .map_err(|e| format!("Failed to fetch DashPay contract: {}", e))? + .ok_or_else(|| "DashPay contract not found".to_string()) + .map(Arc::new) +} + +/// Derive encryption keys for contactInfo using BIP32 CKDpriv as specified in DIP-0015. +/// +/// DIP-0015 specifies: +/// - Key1 (for encToUserId): rootEncryptionKey/(2^16)'/index' +/// - Key2 (for privateData): rootEncryptionKey/(2^16 + 1)'/index' +/// +/// We use the wallet's master seed to derive a root encryption key, +/// then apply BIP32 hardened derivation for the two encryption keys. +fn derive_contact_info_keys( + identity: &QualifiedIdentity, + derivation_index: u32, +) -> Result<([u8; 32], [u8; 32]), String> { + // Get the wallet seed from the identity's associated wallet + let wallet = identity + .associated_wallets + .values() + .next() + .ok_or("No wallet associated with identity for key derivation")?; + + let (seed, network) = { + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + if !wallet_guard.is_open() { + return Err("Wallet must be unlocked to derive encryption keys".to_string()); + } + let seed = wallet_guard + .seed_bytes() + .map_err(|e| format!("Wallet seed not available: {}", e))? + .to_vec(); + (seed, identity.network) + }; + + // Create master extended private key from seed + let master_xprv = ExtendedPrivKey::new_master(network, &seed) + .map_err(|e| format!("Failed to create master key: {}", e))?; + + // Derive to the root encryption key path: m/9'/5'/15'/0' + // This follows the DashPay derivation structure + let root_path = DerivationPath::from_str("m/9'/5'/15'/0'") + .map_err(|e| format!("Invalid derivation path: {}", e))?; + + let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); + let root_encryption_key = master_xprv + .derive_priv(&secp, &root_path) + .map_err(|e| format!("Failed to derive root encryption key: {}", e))?; + + // Derive Key1 for encToUserId: rootEncryptionKey/(2^16)'/index' + // First derive at hardened index 2^16 (65536) + let key1_level1 = root_encryption_key + .derive_priv( + &secp, + &[ChildNumber::from_hardened_idx(65536) + .map_err(|e| format!("Invalid hardened index: {}", e))?], + ) + .map_err(|e| format!("Failed to derive key1 level1: {}", e))?; + + // Then derive at hardened derivation_index + let key1_final = key1_level1 + .derive_priv( + &secp, + &[ChildNumber::from_hardened_idx(derivation_index) + .map_err(|e| format!("Invalid hardened index: {}", e))?], + ) + .map_err(|e| format!("Failed to derive key1 final: {}", e))?; + + // Derive Key2 for privateData: rootEncryptionKey/(2^16 + 1)'/index' + // First derive at hardened index 2^16 + 1 (65537) + let key2_level1 = root_encryption_key + .derive_priv( + &secp, + &[ChildNumber::from_hardened_idx(65537) + .map_err(|e| format!("Invalid hardened index: {}", e))?], + ) + .map_err(|e| format!("Failed to derive key2 level1: {}", e))?; + + // Then derive at hardened derivation_index + let key2_final = key2_level1 + .derive_priv( + &secp, + &[ChildNumber::from_hardened_idx(derivation_index) + .map_err(|e| format!("Invalid hardened index: {}", e))?], + ) + .map_err(|e| format!("Failed to derive key2 final: {}", e))?; + + // Extract the private key bytes (32 bytes) for encryption + let key1_bytes: [u8; 32] = key1_final.private_key.secret_bytes(); + let key2_bytes: [u8; 32] = key2_final.private_key.secret_bytes(); + + Ok((key1_bytes, key2_bytes)) +} + +/// Decrypt toUserId using AES-256-ECB as specified by DIP-0015. +/// +/// DIP-0015 mandates ECB mode for encToUserId encryption because: +/// 1. The toUserId is derived from SHA256, making it appear random (no patterns) +/// 2. Keys are never reused (unique per contact via hardened BIP32 derivation) +/// 3. The data is fixed-size (32 bytes = exactly 2 AES blocks) +/// +/// These properties eliminate typical ECB vulnerabilities (pattern leakage). +/// See: https://github.com/dashpay/dips/blob/master/dip-0015.md +#[allow(deprecated)] +fn decrypt_to_user_id(encrypted: &[u8], key: &[u8; 32]) -> Result<[u8; 32], String> { + use aes_gcm::aead::generic_array::GenericArray; + use aes_gcm::aes::Aes256; + use aes_gcm::aes::cipher::{BlockDecrypt, KeyInit}; + + if encrypted.len() != 32 { + return Err("Invalid encrypted user ID length".to_string()); + } + + let cipher = Aes256::new(GenericArray::from_slice(key)); + + // Split the 32-byte encrypted data into two 16-byte blocks for ECB mode + let mut decrypted = [0u8; 32]; + + let mut block1 = GenericArray::clone_from_slice(&encrypted[0..16]); + let mut block2 = GenericArray::clone_from_slice(&encrypted[16..32]); + + cipher.decrypt_block(&mut block1); + cipher.decrypt_block(&mut block2); + + decrypted[0..16].copy_from_slice(&block1); + decrypted[16..32].copy_from_slice(&block2); + + Ok(decrypted) +} + +// Helper function to decrypt private data using AES-256-CBC +fn decrypt_private_data(encrypted_data: &[u8], key: &[u8; 32]) -> Result, String> { + use cbc::cipher::BlockDecryptMut; + use cbc::cipher::KeyIvInit; + use cbc::cipher::block_padding::Pkcs7; + type Aes256CbcDec = cbc::Decryptor; + + if encrypted_data.len() < 16 { + return Err("Encrypted data too short (no IV)".to_string()); + } + + // Extract IV and ciphertext + let iv = &encrypted_data[0..16]; + let ciphertext = &encrypted_data[16..]; + + // Decrypt + let cipher = Aes256CbcDec::new(key.into(), iv.into()); + + let mut buffer = ciphertext.to_vec(); + let decrypted = cipher + .decrypt_padded_mut::(&mut buffer) + .map_err(|e| format!("Decryption failed: {:?}", e))?; + + Ok(decrypted.to_vec()) +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ContactData { + pub identity_id: Identifier, + pub nickname: Option, + pub note: Option, + pub is_hidden: bool, + pub account_reference: u32, + // Profile data (fetched from Platform) + pub username: Option, + pub display_name: Option, + pub avatar_url: Option, + pub bio: Option, +} + +pub async fn load_contacts( + app_context: &Arc, + sdk: &Sdk, + identity: QualifiedIdentity, +) -> Result { + let identity_id = identity.identity.id(); + let dashpay_contract = app_context.dashpay_contract.clone(); + + // Query for contact requests where we are the sender (ownerId) + let mut outgoing_query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest") + .map_err(|e| format!("Failed to create query: {}", e))?; + + outgoing_query = outgoing_query.with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity_id.to_buffer()), + }); + outgoing_query.limit = 100; + + // Query for contact requests where we are the recipient (toUserId) + let mut incoming_query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest") + .map_err(|e| format!("Failed to create query: {}", e))?; + + incoming_query = incoming_query.with_where(WhereClause { + field: "toUserId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity_id.to_buffer()), + }); + + // Add orderBy workaround for Platform bug + incoming_query = incoming_query.with_order_by(OrderClause { + field: "$createdAt".to_string(), + ascending: true, + }); + incoming_query.limit = 100; + + // Fetch both incoming and outgoing contact requests + let outgoing_docs = Document::fetch_many(sdk, outgoing_query) + .await + .map_err(|e| format!("Error fetching outgoing contacts: {}", e))?; + + let incoming_docs = Document::fetch_many(sdk, incoming_query) + .await + .map_err(|e| format!("Error fetching incoming contacts: {}", e))?; + + // Convert to vectors for easier processing + let outgoing: Vec<(Identifier, Document)> = outgoing_docs + .into_iter() + .filter_map(|(id, doc)| doc.map(|d| (id, d))) + .collect(); + + let incoming: Vec<(Identifier, Document)> = incoming_docs + .into_iter() + .filter_map(|(id, doc)| doc.map(|d| (id, d))) + .collect(); + + // Find mutual contacts (where both parties have sent requests to each other) + let mut contacts = HashSet::new(); + + for (_, incoming_doc) in incoming.iter() { + let from_id = incoming_doc.owner_id(); + + // Check if we also sent a request to this person + for (_, outgoing_doc) in outgoing.iter() { + if let Some(Value::Identifier(to_id_bytes)) = outgoing_doc.properties().get("toUserId") + { + let to_id = Identifier::from_bytes(to_id_bytes.as_slice()).unwrap(); + if to_id == from_id { + // Mutual contact found + contacts.insert(from_id); + } + } + } + } + + // Now query for contact info documents + let mut contact_info_query = DocumentQuery::new(dashpay_contract.clone(), "contactInfo") + .map_err(|e| format!("Failed to create query: {}", e))?; + + contact_info_query = contact_info_query.with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity_id.to_buffer()), + }); + contact_info_query.limit = 100; + + let contact_info_docs = Document::fetch_many(sdk, contact_info_query) + .await + .map_err(|e| format!("Error fetching contact info: {}", e))?; + + // Build a map of contact ID to contact info + let mut contact_info_map: HashMap = HashMap::new(); + + for (_doc_id, doc) in contact_info_docs.iter() { + if let Some(doc) = doc { + let props = doc.properties(); + + // Get the derivation index used for this document + if let Some(Value::U32(deriv_idx)) = props.get("derivationEncryptionKeyIndex") { + // Derive keys for this document + let (enc_user_id_key, private_data_key) = + match derive_contact_info_keys(&identity, *deriv_idx) { + Ok(keys) => keys, + Err(_) => continue, + }; + + // Decrypt encToUserId to find which contact this is for + if let Some(Value::Bytes(enc_user_id)) = props.get("encToUserId") + && let Ok(decrypted_id) = decrypt_to_user_id(enc_user_id, &enc_user_id_key) + { + let contact_id = Identifier::from_bytes(&decrypted_id).unwrap(); + + // Decrypt private data if available + let mut nickname = None; + let mut note = None; + let mut is_hidden = false; + let mut account_reference = 0u32; + + if let Some(Value::Bytes(encrypted_private)) = props.get("privateData") + && let Ok(decrypted_data) = + decrypt_private_data(encrypted_private, &private_data_key) + { + // Parse the decrypted data + // Simple format: version(4) + alias_len(1) + alias + note_len(1) + note + hidden(1) + accounts_len(1) + accounts + if decrypted_data.len() >= 8 { + let mut pos = 4; // Skip version + + // Read alias + if pos < decrypted_data.len() { + let alias_len = decrypted_data[pos] as usize; + pos += 1; + if pos + alias_len <= decrypted_data.len() && alias_len > 0 { + nickname = String::from_utf8( + decrypted_data[pos..pos + alias_len].to_vec(), + ) + .ok(); + pos += alias_len; + } + } + + // Read note + if pos < decrypted_data.len() { + let note_len = decrypted_data[pos] as usize; + pos += 1; + if pos + note_len <= decrypted_data.len() && note_len > 0 { + note = String::from_utf8( + decrypted_data[pos..pos + note_len].to_vec(), + ) + .ok(); + pos += note_len; + } + } + + // Read hidden flag + if pos < decrypted_data.len() { + is_hidden = decrypted_data[pos] != 0; + pos += 1; + } + + // Read accounts (simplified - just take first if available) + if pos < decrypted_data.len() { + let accounts_len = decrypted_data[pos] as usize; + pos += 1; + if accounts_len > 0 && pos + 4 <= decrypted_data.len() { + account_reference = u32::from_le_bytes([ + decrypted_data[pos], + decrypted_data[pos + 1], + decrypted_data[pos + 2], + decrypted_data[pos + 3], + ]); + } + } + } + } + + contact_info_map.insert( + contact_id, + ContactData { + identity_id: contact_id, + nickname, + note, + is_hidden, + account_reference, + username: None, + display_name: None, + avatar_url: None, + bio: None, + }, + ); + } + } + } + } + + // Build enriched contact list with basic data + let mut contact_list: Vec = contacts + .into_iter() + .map(|contact_id| { + contact_info_map + .get(&contact_id) + .cloned() + .unwrap_or(ContactData { + identity_id: contact_id, + nickname: None, + note: None, + is_hidden: false, + account_reference: 0, + username: None, + display_name: None, + avatar_url: None, + bio: None, + }) + }) + .collect(); + + // Fetch profiles and usernames for all contacts + // First, collect all contact IDs + let contact_ids: Vec = contact_list.iter().map(|c| c.identity_id).collect(); + + // Fetch profiles for all contacts (batch query) + if !contact_ids.is_empty() { + // Query profiles for all contacts + for contact_id in &contact_ids { + // Fetch profile + let mut profile_query = DocumentQuery::new(dashpay_contract.clone(), "profile") + .map_err(|e| format!("Failed to create profile query: {}", e))?; + + profile_query = profile_query.with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(contact_id.to_buffer()), + }); + profile_query.limit = 1; + + if let Ok(results) = Document::fetch_many(sdk, profile_query).await + && let Some((_, Some(doc))) = results.into_iter().next() + { + let props = doc.properties(); + + let display_name = props + .get("displayName") + .and_then(|v| v.as_text()) + .map(|s| s.to_string()); + + let avatar_url = props + .get("avatarUrl") + .and_then(|v| v.as_text()) + .map(|s| s.to_string()); + + let bio = props + .get("bio") + .and_then(|v| v.as_text()) + .map(|s| s.to_string()); + + // Update the contact in the list + if let Some(contact) = contact_list + .iter_mut() + .find(|c| c.identity_id == *contact_id) + { + contact.display_name = display_name; + contact.avatar_url = avatar_url; + contact.bio = bio; + } + } + + // Fetch DPNS username + let dpns_contract = app_context.dpns_contract.clone(); + let mut dpns_query = DocumentQuery::new(dpns_contract, "domain") + .map_err(|e| format!("Failed to create DPNS query: {}", e))?; + + dpns_query = dpns_query.with_where(WhereClause { + field: "records.identity".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(contact_id.to_buffer()), + }); + dpns_query.limit = 1; + + if let Ok(results) = Document::fetch_many(sdk, dpns_query).await + && let Some((_, Some(doc))) = results.into_iter().next() + { + let props = doc.properties(); + if let Some(label) = props.get("label").and_then(|v| v.as_text()) { + // Update the contact in the list + if let Some(contact) = contact_list + .iter_mut() + .find(|c| c.identity_id == *contact_id) + { + contact.username = Some(label.to_string()); + } + } + } + } + } + + Ok(BackendTaskSuccessResult::DashPayContactsWithInfo( + contact_list, + )) +} + +pub async fn add_contact( + _app_context: &Arc, + _sdk: &Sdk, + _identity: QualifiedIdentity, + _contact_username: String, + _account_label: Option, +) -> Result { + // TODO: Steps to implement: + // 1. Resolve username to identity ID via DPNS + // 2. Generate encryption keys for this contact relationship + // 3. Create the contactRequest document with encrypted fields + // 4. Broadcast the state transition + Err("Adding contacts via username is not yet implemented. Use the contact request workflow instead.".to_string()) +} + +pub async fn remove_contact( + _app_context: &Arc, + _sdk: &Sdk, + _identity: QualifiedIdentity, + _contact_id: Identifier, +) -> Result { + // TODO: Implement contact removal + // This would involve deleting the contactInfo document if it exists + Err("Contact removal is not yet implemented".to_string()) +} diff --git a/src/backend_task/dashpay/dip14_derivation.rs b/src/backend_task/dashpay/dip14_derivation.rs new file mode 100644 index 000000000..fbe99ecfe --- /dev/null +++ b/src/backend_task/dashpay/dip14_derivation.rs @@ -0,0 +1,377 @@ +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::dashcore::hashes::hmac::{Hmac, HmacEngine}; +use dash_sdk::dpp::dashcore::hashes::sha512; +/// DIP-14 compliant 256-bit HD key derivation implementation +/// +/// This module implements Extended Key Derivation using 256-bit Unsigned Integers +/// as specified in DIP-0014 for DashPay contact relationships. +use dash_sdk::dpp::dashcore::hashes::{Hash, HashEngine}; +use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use dash_sdk::dpp::key_wallet::bip32::{ChainCode, ExtendedPrivKey, ExtendedPubKey, Fingerprint}; +use dash_sdk::platform::Identifier; + +/// Perform DIP-14 compliant 256-bit child key derivation for private keys +/// +/// This implements CKDpriv256 as specified in DIP-0014: +/// - For indices < 2^32, uses standard BIP32 derivation for compatibility +/// - For indices >= 2^32, uses 256-bit derivation with ser_256(i) +pub fn ckd_priv_256( + parent_key: &ExtendedPrivKey, + index: &[u8; 32], // 256-bit index + hardened: bool, +) -> Result { + let secp = Secp256k1::new(); + + // Check if this is a compatibility mode derivation (index < 2^32) + let is_compatibility_mode = is_index_less_than_2_32(index); + + // Prepare HMAC data based on the derivation type + let mut hmac_engine = HmacEngine::::new(&parent_key.chain_code.to_bytes()); + + if hardened { + // Hardened derivation: 0x00 || ser_256(k_par) || ser(i) + hmac_engine.input(&[0x00]); + hmac_engine.input(&parent_key.private_key.secret_bytes()); + + if is_compatibility_mode { + // Use ser_32(i) for compatibility + hmac_engine.input(&index[28..32]); + } else { + // Use ser_256(i) for full 256-bit + hmac_engine.input(index); + } + } else { + // Non-hardened derivation: ser_P(point(k_par)) || ser(i) + let parent_pubkey = parent_key.private_key.public_key(&secp); + hmac_engine.input(&parent_pubkey.serialize()); + + if is_compatibility_mode { + // Use ser_32(i) for compatibility + hmac_engine.input(&index[28..32]); + } else { + // Use ser_256(i) for full 256-bit + hmac_engine.input(index); + } + } + + let hmac_result = Hmac::::from_engine(hmac_engine); + let hmac_bytes = hmac_result.to_byte_array(); + + // Split into I_L (first 32 bytes) and I_R (last 32 bytes) + let (i_l, i_r) = hmac_bytes.split_at(32); + + // Parse I_L as a private key and add to parent key + let i_l_key = SecretKey::from_slice(i_l) + .map_err(|e| format!("Failed to parse I_L as secret key: {}", e))?; + + // k_i = parse_256(I_L) + k_par (mod n) + let child_key = parent_key + .private_key + .add_tweak(&i_l_key.into()) + .map_err(|e| format!("Failed to add tweak to parent key: {}", e))?; + + // Chain code is I_R (32 bytes) + let mut chain_code_bytes = [0u8; 32]; + chain_code_bytes.copy_from_slice(i_r); + let child_chain_code = ChainCode::from(chain_code_bytes); + + // Calculate child fingerprint + let parent_pubkey = parent_key.private_key.public_key(&secp); + let parent_fingerprint = calculate_fingerprint(&parent_pubkey); + + // Create the child extended private key + Ok(ExtendedPrivKey { + network: parent_key.network, + depth: parent_key.depth + 1, + parent_fingerprint, + child_number: index_to_child_number(index, hardened)?, + private_key: child_key, + chain_code: child_chain_code, + }) +} + +/// Perform DIP-14 compliant 256-bit child key derivation for public keys +/// +/// This implements CKDpub256 as specified in DIP-0014: +/// - Only works for non-hardened derivation +/// - For indices < 2^32, uses standard BIP32 derivation for compatibility +/// - For indices >= 2^32, uses 256-bit derivation with ser_256(i) +pub fn ckd_pub_256( + parent_key: &ExtendedPubKey, + index: &[u8; 32], // 256-bit index + hardened: bool, +) -> Result { + if hardened { + return Err("Cannot derive hardened child from extended public key".to_string()); + } + + let secp = Secp256k1::new(); + + // Check if this is a compatibility mode derivation (index < 2^32) + let is_compatibility_mode = is_index_less_than_2_32(index); + + // Prepare HMAC data + let mut hmac_engine = HmacEngine::::new(&parent_key.chain_code.to_bytes()); + + // Non-hardened derivation: ser_P(K_par) || ser(i) + hmac_engine.input(&parent_key.public_key.serialize()); + + if is_compatibility_mode { + // Use ser_32(i) for compatibility + hmac_engine.input(&index[28..32]); + } else { + // Use ser_256(i) for full 256-bit + hmac_engine.input(index); + } + + let hmac_result = Hmac::::from_engine(hmac_engine); + let hmac_bytes = hmac_result.to_byte_array(); + + // Split into I_L (first 32 bytes) and I_R (last 32 bytes) + let (i_l, i_r) = hmac_bytes.split_at(32); + + // Parse I_L as a secret key for the tweak + let i_l_key = SecretKey::from_slice(i_l) + .map_err(|e| format!("Failed to parse I_L as secret key: {}", e))?; + + // K_i = point(parse_256(I_L)) + K_par + let child_pubkey = parent_key + .public_key + .add_exp_tweak(&secp, &i_l_key.into()) + .map_err(|e| format!("Failed to add tweak to parent public key: {}", e))?; + + // Chain code is I_R (32 bytes) + let mut chain_code_bytes = [0u8; 32]; + chain_code_bytes.copy_from_slice(i_r); + let child_chain_code = ChainCode::from(chain_code_bytes); + + // Create the child extended public key + Ok(ExtendedPubKey { + network: parent_key.network, + depth: parent_key.depth + 1, + parent_fingerprint: parent_key.parent_fingerprint, + child_number: index_to_child_number(index, false)?, + public_key: child_pubkey, + chain_code: child_chain_code, + }) +} + +/// Derive DashPay incoming funds extended public key using DIP-14 compliant derivation +/// Path: m/9'/5'/15'/account'/(sender_id)/(recipient_id) +pub fn derive_dashpay_incoming_xpub_dip14( + master_seed: &[u8], + network: Network, + account: u32, + sender_id: &Identifier, + recipient_id: &Identifier, +) -> Result { + use dash_sdk::dpp::key_wallet::bip32::DerivationPath; + use std::str::FromStr; + + // Create extended private key from seed + let master_xprv = ExtendedPrivKey::new_master(network, master_seed) + .map_err(|e| format!("Failed to create master key: {}", e))?; + + // Build derivation path for the base: m/9'/5'/15'/account' + let base_path = DerivationPath::from_str(&format!("m/9'/5'/15'/{}'", account)) + .map_err(|e| format!("Invalid derivation path: {}", e))?; + + // Derive to the account level using standard BIP32 + let secp = Secp256k1::new(); + let account_xprv = master_xprv + .derive_priv(&secp, &base_path) + .map_err(|e| format!("Failed to derive account key: {}", e))?; + + // Now use DIP-14 256-bit derivation for the identity levels + // Derive: account_key/(sender_id) + let sender_index = identifier_to_256bit_index(sender_id); + let sender_level = ckd_priv_256(&account_xprv, &sender_index, false)?; + + // Derive: sender_level/(recipient_id) + let recipient_index = identifier_to_256bit_index(recipient_id); + let contact_xprv = ckd_priv_256(&sender_level, &recipient_index, false)?; + + // Convert to extended public key + Ok(ExtendedPubKey::from_priv(&secp, &contact_xprv)) +} + +/// Convert an Identifier to a 256-bit index for DIP-14 derivation +fn identifier_to_256bit_index(id: &Identifier) -> [u8; 32] { + let mut index = [0u8; 32]; + index.copy_from_slice(&id.to_buffer()); + index +} + +/// Check if a 256-bit index is less than 2^32 (compatibility mode) +fn is_index_less_than_2_32(index: &[u8; 32]) -> bool { + // Check if the first 28 bytes are all zeros + index[0..28].iter().all(|&b| b == 0) +} + +/// Convert a 256-bit index to a ChildNumber for storage +/// This is a simplified representation since ChildNumber only supports 31-bit indices +fn index_to_child_number( + index: &[u8; 32], + hardened: bool, +) -> Result { + use dash_sdk::dpp::key_wallet::bip32::ChildNumber; + + // For compatibility with existing ChildNumber structure, + // we need to ensure the value fits in 31 bits for normal, or set the hardened bit + // We'll use a hash of the full 256-bit index to get a deterministic 31-bit value + use dash_sdk::dpp::dashcore::hashes::Hash; + use dash_sdk::dpp::dashcore::hashes::sha256; + + let hash = sha256::Hash::hash(index); + let hash_bytes = hash.to_byte_array(); + + // Take first 4 bytes and mask to 31 bits + let mut bytes = [0u8; 4]; + bytes.copy_from_slice(&hash_bytes[0..4]); + let mut num = u32::from_be_bytes(bytes); + + if hardened { + // Set the hardened bit (bit 31) + num |= 0x80000000; + Ok(ChildNumber::from(num)) + } else { + // Clear bit 31 to ensure it's within normal range + num &= 0x7FFFFFFF; + Ok(ChildNumber::from(num)) + } +} + +/// Calculate fingerprint for a public key (first 4 bytes of HASH160) +fn calculate_fingerprint(pubkey: &PublicKey) -> Fingerprint { + use dash_sdk::dpp::dashcore::hashes::hash160; + + let hash = hash160::Hash::hash(&pubkey.serialize()); + let mut fingerprint_bytes = [0u8; 4]; + fingerprint_bytes.copy_from_slice(&hash.to_byte_array()[0..4]); + Fingerprint::from(fingerprint_bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::dashcore::Network; + use hex; + + #[test] + fn test_256bit_index_detection() { + // Test index less than 2^32 + let mut small_index = [0u8; 32]; + small_index[31] = 42; + assert!(is_index_less_than_2_32(&small_index)); + + // Test index >= 2^32 + let mut large_index = [0u8; 32]; + large_index[27] = 1; // Set a bit in the upper bytes + assert!(!is_index_less_than_2_32(&large_index)); + } + + #[test] + fn test_identifier_to_index_conversion() { + let id_bytes = [ + 0x77, 0x5d, 0x38, 0x54, 0xc9, 0x10, 0xb7, 0xde, 0xe4, 0x36, 0x86, 0x9c, 0x47, 0x24, + 0xbe, 0xd2, 0xfe, 0x07, 0x84, 0xe1, 0x98, 0xb8, 0xa3, 0x9f, 0x02, 0xbb, 0xb4, 0x9d, + 0x8e, 0xbc, 0xfc, 0x3b, + ]; + let id = Identifier::from_bytes(&id_bytes).unwrap(); + + let index = identifier_to_256bit_index(&id); + assert_eq!(index, id_bytes); + } + + #[test] + fn test_dip14_derivation_compatibility() { + // Test that derivation with index < 2^32 matches standard BIP32 + let seed = [0x42u8; 64]; + let network = Network::Testnet; + + let master = ExtendedPrivKey::new_master(network, &seed).unwrap(); + + // Test with small index (should use compatibility mode) + let mut small_index = [0u8; 32]; + small_index[31] = 1; + + let child = ckd_priv_256(&master, &small_index, false); + assert!(child.is_ok()); + } + + #[test] + fn test_dip14_test_vector_1() { + // Test Vector 1 from DIP-14 + // Mnemonic: birth kingdom trash renew flavor utility donkey gasp regular alert pave layer + let seed_hex = "b16d3782e714da7c55a397d5f19104cfed7ffa8036ac514509bbb50807f8ac598eeb26f0797bd8cc221a6cbff2168d90a5e9ee025a5bd977977b9eccd97894bb"; + let seed = hex::decode(seed_hex).unwrap(); + let network = Network::Testnet; + + // Test derivation path with 256-bit indices + let index1 = + hex::decode("775d3854c910b7dee436869c4724bed2fe0784e198b8a39f02bbb49d8ebcfc3b") + .unwrap(); + let index2 = + hex::decode("f537439f36d04a15474ff7423e4b904a14373fafb37a41db74c84f1dbb5c89a6") + .unwrap(); + + let master = ExtendedPrivKey::new_master(network, &seed).unwrap(); + + // Derive first level (non-hardened) + let mut index1_array = [0u8; 32]; + index1_array.copy_from_slice(&index1); + let level1 = ckd_priv_256(&master, &index1_array, false).unwrap(); + + // Derive second level (hardened) + let mut index2_array = [0u8; 32]; + index2_array.copy_from_slice(&index2); + let level2 = ckd_priv_256(&level1, &index2_array, true).unwrap(); + + // The test passes if we can derive without errors + // Full validation would require checking against expected key values + assert_eq!(level2.depth, 2); + } + + #[test] + fn test_dashpay_identity_derivation() { + // Test DashPay contact relationship derivation + let seed = [0x42u8; 64]; + let network = Network::Testnet; + + // Create two test identity IDs + let sender_bytes = [ + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, + 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, + 0xdd, 0xee, 0xff, 0x11, + ]; + let recipient_bytes = [ + 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, + 0x66, 0x77, 0x88, 0x99, + ]; + + let sender_id = Identifier::from_bytes(&sender_bytes).unwrap(); + let recipient_id = Identifier::from_bytes(&recipient_bytes).unwrap(); + + // Test that we can derive the DashPay contact xpub + let xpub = derive_dashpay_incoming_xpub_dip14( + &seed, + network, + 0, // account + &sender_id, + &recipient_id, + ); + + // Print the error if it fails + if let Err(ref e) = xpub { + eprintln!("DashPay derivation error: {}", e); + } + + assert!(xpub.is_ok()); + let xpub = xpub.unwrap(); + + // Verify the derivation depth is correct (base path + 2 identity levels) + // m/9'/5'/15'/0'/(sender)/(recipient) = depth 6 + assert_eq!(xpub.depth, 6); + } +} diff --git a/src/backend_task/dashpay/encryption.rs b/src/backend_task/dashpay/encryption.rs new file mode 100644 index 000000000..b660a9adf --- /dev/null +++ b/src/backend_task/dashpay/encryption.rs @@ -0,0 +1,272 @@ +use aes_gcm::aes::Aes256; +use bip39::rand::{self, RngCore}; +use cbc; +use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use dash_sdk::dpp::identity::IdentityPublicKey; +use dash_sdk::dpp::identity::KeyType; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use sha2::{Digest, Sha256}; + +/// Generate ECDH shared key according to DashPay DIP-15 +/// Uses libsecp256k1_ecdh method: SHA256((y[31]&0x1|0x2) || x) +pub fn generate_ecdh_shared_key( + private_key: &[u8], + public_key: &IdentityPublicKey, +) -> Result<[u8; 32], String> { + let _secp = Secp256k1::new(); + + // Parse the private key + let secret_key = + SecretKey::from_slice(private_key).map_err(|e| format!("Invalid private key: {}", e))?; + + // Get the public key data - only works for full secp256k1 keys + match public_key.key_type() { + KeyType::ECDSA_SECP256K1 => { + let public_key_data = public_key.data(); + let public_key = PublicKey::from_slice(public_key_data.as_slice()) + .map_err(|e| format!("Invalid public key: {}", e))?; + + // Perform ECDH to get shared secret + let shared_secret = dash_sdk::dpp::dashcore::secp256k1::ecdh::shared_secret_point(&public_key, &secret_key); + + // Extract x and y coordinates (64 bytes total: 32 + 32) + let x = &shared_secret[..32]; + let y = &shared_secret[32..]; + + // Determine the prefix based on y coordinate parity + let prefix = if y[31] & 0x1 == 1 { 0x03u8 } else { 0x02u8 }; + + // Create the input for SHA256: prefix || x + let mut hasher = Sha256::new(); + hasher.update([prefix]); + hasher.update(x); + + let result = hasher.finalize(); + let mut shared_key = [0u8; 32]; + shared_key.copy_from_slice(&result); + + Ok(shared_key) + } + KeyType::ECDSA_HASH160 => { + Err("Cannot perform ECDH with ECDSA_HASH160 key type - only hash is available, not full public key".to_string()) + } + _ => { + Err(format!("Unsupported key type for ECDH: {:?}", public_key.key_type())) + } + } +} + +/// Create encrypted extended public key according to DashPay DIP-15 +/// Format: IV (16 bytes) + Encrypted Data (80 bytes) = 96 bytes total +/// Uses CBC-AES-256 as specified in the DIP +pub fn encrypt_extended_public_key( + parent_fingerprint: [u8; 4], + chain_code: [u8; 32], + public_key: [u8; 33], + shared_key: &[u8; 32], +) -> Result, String> { + use cbc::cipher::{BlockEncryptMut, KeyIvInit, block_padding::Pkcs7}; + + // Create the extended public key data (69 bytes) + let mut xpub_data = Vec::with_capacity(69); + xpub_data.extend_from_slice(&parent_fingerprint); + xpub_data.extend_from_slice(&chain_code); + xpub_data.extend_from_slice(&public_key); + + // Generate random IV (16 bytes for CBC) + let mut iv = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut iv); + + // Encrypt using CBC-AES-256 with PKCS7 padding + type Aes256CbcEnc = cbc::Encryptor; + let cipher = Aes256CbcEnc::new(shared_key.into(), &iv.into()); + + // The xpub_data is 69 bytes, which will be padded to 80 bytes (next multiple of 16) + // We need to create a buffer with room for padding + let mut buffer = vec![0u8; 80]; // 69 bytes padded to 80 (next multiple of 16) + buffer[..xpub_data.len()].copy_from_slice(&xpub_data); + + let ciphertext = cipher + .encrypt_padded_mut::(&mut buffer, xpub_data.len()) + .map_err(|e| format!("Encryption failed: {:?}", e))?; + + // Verify the ciphertext is exactly 80 bytes + if ciphertext.len() != 80 { + return Err(format!( + "Unexpected ciphertext length: {} (expected 80)", + ciphertext.len() + )); + } + + // Combine IV and ciphertext (16 + 80 = 96 bytes total) + let mut result = Vec::with_capacity(96); + result.extend_from_slice(&iv); + result.extend_from_slice(ciphertext); + + Ok(result) +} + +/// Encrypt account label according to DashPay DIP-15 +/// Format: IV (16 bytes) + Encrypted Data (32-64 bytes) = 48-80 bytes total +/// Uses CBC-AES-256 as specified in the DIP +pub fn encrypt_account_label(label: &str, shared_key: &[u8; 32]) -> Result, String> { + use cbc::cipher::{BlockEncryptMut, KeyIvInit, block_padding::Pkcs7}; + + let label_bytes = label.as_bytes(); + + // Label length check + if label_bytes.is_empty() { + return Err("Account label cannot be empty".to_string()); + } + if label_bytes.len() > 63 { + return Err("Account label too long (max 63 characters)".to_string()); + } + + // To ensure minimum ciphertext size of 32 bytes, pad the label to at least 16 bytes + // This way, with PKCS7 padding, we'll get at least 32 bytes of ciphertext + // We use a simple length prefix approach: [len][label][zeros...] + let min_label_len = 16; + let padded_label = if label_bytes.len() < min_label_len { + let mut padded = vec![label_bytes.len() as u8]; // Store original length as first byte + padded.extend_from_slice(label_bytes); + // Pad with zeros to reach min_label_len + padded.resize(min_label_len, 0); + padded + } else { + // For longer labels, just prepend the length + let mut padded = vec![label_bytes.len() as u8]; + padded.extend_from_slice(label_bytes); + padded + }; + + // Generate random IV (16 bytes for CBC) + let mut iv = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut iv); + + // Encrypt using CBC-AES-256 with PKCS7 padding + type Aes256CbcEnc = cbc::Encryptor; + let cipher = Aes256CbcEnc::new(shared_key.into(), &iv.into()); + + // Calculate buffer size for PKCS7 padding + let padded_len = if padded_label.len() % 16 == 0 { + padded_label.len() + 16 // Add full padding block + } else { + ((padded_label.len() / 16) + 1) * 16 // Round up to next multiple of 16 + }; + + let mut buffer = vec![0u8; padded_len]; + buffer[..padded_label.len()].copy_from_slice(&padded_label); + + // Encrypt with PKCS7 padding + let ciphertext = cipher + .encrypt_padded_mut::(&mut buffer, padded_label.len()) + .map_err(|e| format!("Encryption failed: {:?}", e))?; + + // Combine IV and ciphertext + let mut result = Vec::with_capacity(16 + ciphertext.len()); + result.extend_from_slice(&iv); + result.extend_from_slice(ciphertext); + + // Verify the final result is within expected range (48-80 bytes as per validation) + // IV: 16 bytes + ciphertext: 32-64 bytes = 48-80 bytes total + if result.len() < 48 || result.len() > 80 { + return Err(format!( + "Unexpected encrypted result length: {} (expected 48-80)", + result.len() + )); + } + + Ok(result) +} + +/// Decrypt extended public key using CBC-AES-256 +#[allow(clippy::type_complexity)] +pub fn decrypt_extended_public_key( + encrypted_data: &[u8], + shared_key: &[u8; 32], +) -> Result<(Vec, [u8; 32], [u8; 33]), String> { + use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::Pkcs7}; + + // Expected format: IV (16 bytes) + Encrypted Data (80 bytes) = 96 bytes + if encrypted_data.len() != 96 { + return Err(format!( + "Invalid encrypted public key length: {} (expected 96)", + encrypted_data.len() + )); + } + + // Extract IV and ciphertext + let iv = &encrypted_data[..16]; + let ciphertext = &encrypted_data[16..]; + + // Decrypt using CBC-AES-256 with PKCS7 padding + type Aes256CbcDec = cbc::Decryptor; + let cipher = Aes256CbcDec::new(shared_key.into(), iv.into()); + + let mut buffer = ciphertext.to_vec(); + let decrypted = cipher + .decrypt_padded_mut::(&mut buffer) + .map_err(|e| format!("Decryption failed: {:?}", e))?; + + // Should decrypt to exactly 69 bytes after removing padding + if decrypted.len() != 69 { + return Err(format!( + "Invalid decrypted data length: {} (expected 69)", + decrypted.len() + )); + } + + let parent_fingerprint = decrypted[..4].to_vec(); + let mut chain_code = [0u8; 32]; + chain_code.copy_from_slice(&decrypted[4..36]); + let mut public_key = [0u8; 33]; + public_key.copy_from_slice(&decrypted[36..69]); + + Ok((parent_fingerprint, chain_code, public_key)) +} + +/// Decrypt account label using CBC-AES-256 +pub fn decrypt_account_label( + encrypted_data: &[u8], + shared_key: &[u8; 32], +) -> Result { + use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::Pkcs7}; + + // Expected format: IV (16 bytes) + Encrypted Data (32-64 bytes) = 48-80 bytes + if encrypted_data.len() < 48 || encrypted_data.len() > 80 { + return Err(format!( + "Invalid encrypted label length: {} (expected 48-80)", + encrypted_data.len() + )); + } + + // Extract IV and ciphertext + let iv = &encrypted_data[..16]; + let ciphertext = &encrypted_data[16..]; + + // Decrypt using CBC-AES-256 with PKCS7 padding + type Aes256CbcDec = cbc::Decryptor; + let cipher = Aes256CbcDec::new(shared_key.into(), iv.into()); + + let mut buffer = ciphertext.to_vec(); + let decrypted = cipher + .decrypt_padded_mut::(&mut buffer) + .map_err(|e| format!("Decryption failed: {:?}", e))?; + + // Extract the actual label from our custom format: [len][label][padding...] + if decrypted.is_empty() { + return Err("Decrypted data is empty".to_string()); + } + + let label_len = decrypted[0] as usize; + if label_len == 0 || label_len > decrypted.len() - 1 { + return Err(format!("Invalid label length: {}", label_len)); + } + + // Extract the actual label bytes + let label_bytes = &decrypted[1..=label_len]; + + // Convert to string + String::from_utf8(label_bytes.to_vec()) + .map_err(|e| format!("Invalid UTF-8 in decrypted label: {}", e)) +} diff --git a/src/backend_task/dashpay/encryption_tests.rs b/src/backend_task/dashpay/encryption_tests.rs new file mode 100644 index 000000000..7db45f757 --- /dev/null +++ b/src/backend_task/dashpay/encryption_tests.rs @@ -0,0 +1,174 @@ +use crate::backend_task::dashpay::encryption::{ + decrypt_account_label, decrypt_extended_public_key, encrypt_account_label, + encrypt_extended_public_key, +}; +use bip39::rand::{self, RngCore}; +use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; + +/// Test encryption and decryption of extended public keys +pub fn test_extended_public_key_encryption() -> Result<(), String> { + println!("Testing extended public key encryption/decryption..."); + + // Generate test data + let parent_fingerprint = [0x12, 0x34, 0x56, 0x78]; + let mut chain_code = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut chain_code); + + // Generate a test key pair + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, + 0x1F, 0x20, + ]) + .unwrap(); + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + let public_key_bytes = public_key.serialize(); + + // Generate a shared key for encryption + let mut shared_key = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut shared_key); + + // Test encryption + let encrypted = encrypt_extended_public_key( + parent_fingerprint, + chain_code, + public_key_bytes, + &shared_key, + )?; + + // Verify encrypted data length is 96 bytes (16 IV + 80 encrypted) + if encrypted.len() != 96 { + return Err(format!( + "Invalid encrypted length: {} (expected 96)", + encrypted.len() + )); + } + + println!("✓ Encryption produced 96 bytes as expected"); + + // Test decryption + let (decrypted_fingerprint, decrypted_chain_code, decrypted_public_key) = + decrypt_extended_public_key(&encrypted, &shared_key)?; + + // Verify decrypted data matches original + if decrypted_fingerprint != parent_fingerprint.to_vec() { + return Err("Parent fingerprint mismatch after decryption".to_string()); + } + + if decrypted_chain_code != chain_code { + return Err("Chain code mismatch after decryption".to_string()); + } + + if decrypted_public_key != public_key_bytes { + return Err("Public key mismatch after decryption".to_string()); + } + + println!("✓ Decryption successfully recovered original data"); + + // Test with wrong key fails + let mut wrong_key = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut wrong_key); + + match decrypt_extended_public_key(&encrypted, &wrong_key) { + Ok(_) => return Err("Decryption should have failed with wrong key".to_string()), + Err(_) => println!("✓ Decryption correctly failed with wrong key"), + } + + Ok(()) +} + +/// Test encryption and decryption of account labels +pub fn test_account_label_encryption() -> Result<(), String> { + println!("\nTesting account label encryption/decryption..."); + + // Generate a shared key + let mut shared_key = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut shared_key); + + // Test various label lengths + let test_labels = vec![ + "Personal", + "Business Account", + "Savings - Long Term Investment Fund 2024", + "Test with special chars: 你好世界 🚀", + ]; + + for label in test_labels { + println!(" Testing label: '{}'", label); + + // Encrypt + let encrypted = encrypt_account_label(label, &shared_key)?; + + // Verify encrypted length is in expected range (48-80 bytes) + if encrypted.len() < 48 || encrypted.len() > 80 { + return Err(format!( + "Invalid encrypted label length: {} (expected 48-80)", + encrypted.len() + )); + } + + // Decrypt + let decrypted = decrypt_account_label(&encrypted, &shared_key)?; + + // Verify match + if decrypted != label { + return Err(format!( + "Label mismatch after decryption: '{}' != '{}'", + decrypted, label + )); + } + + println!( + " ✓ Successfully encrypted/decrypted ({} bytes encrypted)", + encrypted.len() + ); + } + + // Test label that's too long + let long_label = "x".repeat(65); + match encrypt_account_label(&long_label, &shared_key) { + Ok(_) => return Err("Should have rejected label > 64 chars".to_string()), + Err(_) => println!(" ✓ Correctly rejected label > 64 characters"), + } + + Ok(()) +} + +/// Test ECDH shared key generation +pub fn test_ecdh_shared_key_generation() -> Result<(), String> { + println!("\nTesting ECDH shared key generation..."); + + // Skip the actual ECDH test for now due to IdentityPublicKey structure complexities + + // TODO: Complete ECDH test once we have proper IdentityPublicKey mock + // The issue is that IdentityPublicKey stores ECDSA keys differently than BLS keys + // and we need to properly mock the .data() method to return the right bytes + // For ECDSA_SECP256K1 keys, the data field is the raw 33-byte compressed public key + // but the IdentityPublicKey structure expects a BLS PublicKey type in the data field + + println!("✓ ECDH test skipped (needs proper mock implementation)"); + + // For now, let's test that the basic encryption/decryption functions work + // which is demonstrated in the other tests above + + Ok(()) +} + +/// Run all encryption tests +pub fn run_all_encryption_tests() -> Result<(), String> { + println!("=== Running DashPay Encryption Tests ===\n"); + + test_extended_public_key_encryption()?; + test_account_label_encryption()?; + test_ecdh_shared_key_generation()?; + + println!("\n=== All encryption tests passed! ==="); + + Ok(()) +} + +/// Create a test task to run encryption verification +pub fn create_encryption_test_task() -> crate::backend_task::BackendTask { + crate::backend_task::BackendTask::None +} diff --git a/src/backend_task/dashpay/errors.rs b/src/backend_task/dashpay/errors.rs new file mode 100644 index 000000000..6ea0bc4bf --- /dev/null +++ b/src/backend_task/dashpay/errors.rs @@ -0,0 +1,258 @@ +use dash_sdk::platform::Identifier; +use thiserror::Error; + +/// Comprehensive error types for DashPay operations +#[derive(Error, Debug, Clone, PartialEq)] +pub enum DashPayError { + // Contact Request Errors + #[error("Identity not found: {identity_id}")] + IdentityNotFound { identity_id: Identifier }, + + #[error("Username '{username}' could not be resolved via DPNS")] + UsernameResolutionFailed { username: String }, + + #[error("Key index {key_id} not found in identity {identity_id}")] + KeyNotFound { + key_id: u32, + identity_id: Identifier, + }, + + #[error("Key index {key_id} is disabled in identity {identity_id}")] + KeyDisabled { + key_id: u32, + identity_id: Identifier, + }, + + #[error("Key index {key_id} has unsuitable type {key_type:?} for {operation}")] + UnsuitableKeyType { + key_id: u32, + key_type: String, + operation: String, + }, + + #[error("Missing ENCRYPTION key required for DashPay")] + MissingEncryptionKey, + + #[error("Missing DECRYPTION key required for DashPay")] + MissingDecryptionKey, + + #[error("ECDH key generation failed: {reason}")] + EcdhFailed { reason: String }, + + #[error("Encryption failed: {reason}")] + EncryptionFailed { reason: String }, + + #[error("Decryption failed: {reason}")] + DecryptionFailed { reason: String }, + + // Document/Platform Errors + #[error("Failed to create contact request document: {reason}")] + DocumentCreationFailed { reason: String }, + + #[error("Failed to broadcast state transition: {reason}")] + BroadcastFailed { reason: String }, + + #[error("Document query failed: {reason}")] + QueryFailed { reason: String }, + + #[error("Invalid document structure: {reason}")] + InvalidDocument { reason: String }, + + // Validation Errors + #[error("Core height {height} is invalid (current: {current:?}): {reason}")] + InvalidCoreHeight { + height: u32, + current: Option, + reason: String, + }, + + #[error("Account reference {account} is invalid: {reason}")] + InvalidAccountReference { account: u32, reason: String }, + + #[error("Contact request validation failed: {errors:?}")] + ValidationFailed { errors: Vec }, + + // Auto Accept Proof Errors + #[error("Invalid QR code format: {reason}")] + InvalidQrCode { reason: String }, + + #[error("QR code expired at {expired_at}, current time: {current_time}")] + QrCodeExpired { expired_at: u64, current_time: u64 }, + + #[error("Auto-accept proof verification failed: {reason}")] + ProofVerificationFailed { reason: String }, + + // Network/SDK Errors + #[error("Platform query failed: {reason}")] + PlatformError { reason: String }, + + #[error("Network connection failed: {reason}")] + NetworkError { reason: String }, + + #[error("SDK operation failed: {reason}")] + SdkError { reason: String }, + + // User Input Errors + #[error("Invalid username format: {username}")] + InvalidUsername { username: String }, + + #[error("Account label too long: {length} chars (max: {max})")] + AccountLabelTooLong { length: usize, max: usize }, + + #[error("Missing required field: {field}")] + MissingField { field: String }, + + // Contact Info Errors + #[error("Contact info not found for contact {contact_id}")] + ContactInfoNotFound { contact_id: Identifier }, + + #[error("Contact info decryption failed for contact {contact_id}: {reason}")] + ContactInfoDecryptionFailed { + contact_id: Identifier, + reason: String, + }, + + // General Errors + #[error("Internal error: {message}")] + Internal { message: String }, + + #[error("Operation not supported: {operation}")] + NotSupported { operation: String }, + + #[error("Rate limit exceeded for operation: {operation}")] + RateLimited { operation: String }, +} + +impl DashPayError { + /// Convert to user-friendly error message + pub fn user_message(&self) -> String { + match self { + DashPayError::UsernameResolutionFailed { username } => { + format!( + "Username '{}' not found. Please check the spelling.", + username + ) + } + DashPayError::IdentityNotFound { .. } => { + "Contact not found. They may not be registered on Dash Platform.".to_string() + } + DashPayError::InvalidQrCode { .. } => { + "Invalid QR code. Please scan a valid DashPay contact QR code.".to_string() + } + DashPayError::QrCodeExpired { .. } => { + "QR code has expired. Please ask for a new one.".to_string() + } + DashPayError::NetworkError { .. } => { + "Network connection error. Please check your internet connection.".to_string() + } + DashPayError::ValidationFailed { errors } => { + if errors.len() == 1 { + format!("Validation error: {}", errors[0]) + } else { + format!("Multiple validation errors: {}", errors.join(", ")) + } + } + DashPayError::AccountLabelTooLong { max, .. } => { + format!( + "Account label too long. Maximum {} characters allowed.", + max + ) + } + DashPayError::InvalidUsername { .. } => { + "Invalid username format. Usernames must end with '.dash'.".to_string() + } + DashPayError::RateLimited { .. } => { + "Too many requests. Please wait a moment before trying again.".to_string() + } + DashPayError::Internal { message } => { + // Show the actual internal error message + message.clone() + } + DashPayError::MissingEncryptionKey => { + "Your identity is missing an ENCRYPTION key required for DashPay. Please add a DashPay-compatible encryption key.".to_string() + } + DashPayError::MissingDecryptionKey => { + "Your identity is missing a DECRYPTION key required for DashPay. Please add a DashPay-compatible decryption key.".to_string() + } + _ => "An error occurred. Please try again.".to_string(), + } + } + + /// Check if error is recoverable (user can retry) + pub fn is_recoverable(&self) -> bool { + matches!( + self, + DashPayError::NetworkError { .. } + | DashPayError::PlatformError { .. } + | DashPayError::RateLimited { .. } + | DashPayError::BroadcastFailed { .. } + | DashPayError::QueryFailed { .. } + ) + } + + /// Check if error requires user action (not a system error) + pub fn requires_user_action(&self) -> bool { + matches!( + self, + DashPayError::UsernameResolutionFailed { .. } + | DashPayError::InvalidQrCode { .. } + | DashPayError::QrCodeExpired { .. } + | DashPayError::ValidationFailed { .. } + | DashPayError::AccountLabelTooLong { .. } + | DashPayError::InvalidUsername { .. } + | DashPayError::MissingField { .. } + | DashPayError::MissingEncryptionKey + | DashPayError::MissingDecryptionKey + ) + } +} + +/// Result type for DashPay operations +pub type DashPayResult = Result; + +/// Helper to convert string errors to DashPayError +impl From for DashPayError { + fn from(error: String) -> Self { + DashPayError::Internal { message: error } + } +} + +/// Trait for converting various SDK errors to DashPayError +pub trait ToDashPayError { + fn to_dashpay_error(self, context: &str) -> DashPayResult; +} + +impl ToDashPayError for Result { + fn to_dashpay_error(self, context: &str) -> DashPayResult { + self.map_err(|e| DashPayError::SdkError { + reason: format!("{}: {}", context, e), + }) + } +} + +impl ToDashPayError for Result { + fn to_dashpay_error(self, context: &str) -> DashPayResult { + self.map_err(|e| DashPayError::Internal { + message: format!("{}: {}", context, e), + }) + } +} + +/// Helper to create validation errors +pub fn validation_error(errors: Vec) -> DashPayError { + DashPayError::ValidationFailed { errors } +} + +/// Helper to create network errors +pub fn network_error(reason: impl Into) -> DashPayError { + DashPayError::NetworkError { + reason: reason.into(), + } +} + +/// Helper to create platform errors +pub fn platform_error(reason: impl Into) -> DashPayError { + DashPayError::PlatformError { + reason: reason.into(), + } +} diff --git a/src/backend_task/dashpay/hd_derivation.rs b/src/backend_task/dashpay/hd_derivation.rs new file mode 100644 index 000000000..9b2e55c3b --- /dev/null +++ b/src/backend_task/dashpay/hd_derivation.rs @@ -0,0 +1,188 @@ +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::dashcore::hashes::{Hash, HashEngine}; +use dash_sdk::dpp::key_wallet::bip32::{ + ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey, +}; +use dash_sdk::platform::Identifier; +use std::str::FromStr; + +// Import our DIP-14 compliant derivation functions +use super::dip14_derivation::derive_dashpay_incoming_xpub_dip14; + +/// DashPay auto-accept proof feature index - use the constant from dip9 if available +const DASHPAY_AUTO_ACCEPT_FEATURE: u32 = 16; + +/// Derive the DashPay incoming funds extended public key for a contact relationship +/// Path: m/9'/5'/15'/account'/(sender_id)/(recipient_id) +/// +/// This creates a unique derivation path for each contact relationship, +/// allowing for unique payment addresses between any two identities. +/// +/// This function now uses DIP-14 compliant 256-bit derivation for identity IDs. +pub fn derive_dashpay_incoming_xpub( + master_seed: &[u8], + network: Network, + account: u32, + sender_id: &Identifier, + recipient_id: &Identifier, +) -> Result { + // Use the DIP-14 compliant implementation + derive_dashpay_incoming_xpub_dip14(master_seed, network, account, sender_id, recipient_id) +} + +/// Derive a specific payment address for a contact +/// Path: ..../index (where index is the address index) +pub fn derive_payment_address( + contact_xpub: &ExtendedPubKey, + index: u32, +) -> Result { + let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); + + // Derive the specific address key + let address_key = contact_xpub + .derive_pub( + &secp, + &[ChildNumber::from_normal_idx(index).map_err(|e| format!("Invalid index: {}", e))?], + ) + .map_err(|e| format!("Failed to derive address key: {}", e))?; + + // Convert to Dash address + // The ExtendedPubKey's public_key is a secp256k1::PublicKey + // We need to convert it to dashcore::PublicKey + let secp_pubkey = address_key.public_key; + let pubkey = dash_sdk::dpp::dashcore::PublicKey::new(secp_pubkey); + let address = dash_sdk::dpp::dashcore::Address::p2pkh(&pubkey, contact_xpub.network); + + Ok(address) +} + +/// Convert an Identifier to a ChildNumber for compatibility with existing code +/// Note: This is only used for backwards compatibility. The actual DIP-14 +/// compliant derivation is handled in the dip14_derivation module. +#[allow(dead_code)] +fn identity_to_child_number(id: &Identifier, hardened: bool) -> Result { + let id_bytes = id.to_buffer(); + + // Take last 4 bytes for ChildNumber representation + // This is just for storage/display purposes, actual derivation uses full 256-bit + let mut index_bytes = [0u8; 4]; + index_bytes.copy_from_slice(&id_bytes[28..32]); + let index = u32::from_be_bytes(index_bytes); + + if hardened { + ChildNumber::from_hardened_idx(index).map_err(|e| format!("Invalid hardened index: {}", e)) + } else { + ChildNumber::from_normal_idx(index).map_err(|e| format!("Invalid normal index: {}", e)) + } +} + +/// Generate the extended public key data for a contact request +/// Returns (parent_fingerprint, chain_code, public_key_bytes) +#[allow(clippy::type_complexity)] +pub fn generate_contact_xpub_data( + master_seed: &[u8], + network: Network, + account: u32, + sender_id: &Identifier, + recipient_id: &Identifier, +) -> Result<([u8; 4], [u8; 32], [u8; 33]), String> { + // Derive the extended public key for this contact + let xpub = + derive_dashpay_incoming_xpub(master_seed, network, account, sender_id, recipient_id)?; + + // Extract the components needed for the contact request + let parent_fingerprint = xpub.parent_fingerprint.to_bytes(); + let chain_code = xpub.chain_code.to_bytes(); + + // Get the public key bytes (33 bytes compressed) + let public_key_bytes = xpub.public_key.serialize(); + + Ok((parent_fingerprint, chain_code, public_key_bytes)) +} + +/// Derive auto-accept proof key according to DIP-0015 +/// Path: m/9'/5'/16'/timestamp' +pub fn derive_auto_accept_key( + master_seed: &[u8], + network: Network, + timestamp: u32, +) -> Result { + // Create extended private key from seed + let master_xprv = ExtendedPrivKey::new_master(network, master_seed) + .map_err(|e| format!("Failed to create master key: {}", e))?; + + // Build derivation path: m/9'/5'/16'/timestamp' + let path = DerivationPath::from_str(&format!( + "m/9'/5'/{}'/{}'", + DASHPAY_AUTO_ACCEPT_FEATURE, timestamp + )) + .map_err(|e| format!("Invalid derivation path: {}", e))?; + + // Derive the key + let auto_accept_key = master_xprv + .derive_priv(&dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(), &path) + .map_err(|e| format!("Failed to derive auto-accept key: {}", e))?; + + Ok(auto_accept_key) +} + +/// Calculate account reference as specified in DIP-0015 +pub fn calculate_account_reference( + sender_secret_key: &[u8], + extended_public_key: &ExtendedPubKey, + account: u32, + version: u32, +) -> u32 { + use dash_sdk::dpp::dashcore::hashes::hmac::{Hmac, HmacEngine}; + use dash_sdk::dpp::dashcore::hashes::sha256; + + // Serialize the extended public key + let xpub_bytes = extended_public_key.encode(); + + // Create HMAC-SHA256(senderSecretKey, extendedPublicKey) + let mut engine = HmacEngine::::new(sender_secret_key); + engine.input(&xpub_bytes); + let ask = Hmac::::from_engine(engine); + + // Take the 28 most significant bits + let ask_bytes = ask.to_byte_array(); + let ask28 = u32::from_be_bytes([ask_bytes[0], ask_bytes[1], ask_bytes[2], ask_bytes[3]]) >> 4; + + // Prepare account reference + let shortened_account_bits = account & 0x0FFFFFFF; + let version_bits = version << 28; + + // Combine: Version | (ASK28 XOR ShortenedAccountBits) + version_bits | (ask28 ^ shortened_account_bits) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dashpay_derivation_path() { + // Test that we can create valid derivation paths + let path = DerivationPath::from_str("m/9'/5'/15'/0'").unwrap(); + assert_eq!(path.len(), 4); + } + + #[test] + fn test_account_reference_calculation() { + // Test account reference calculation + let secret_key = [1u8; 32]; + let network = Network::Testnet; + let master_seed = [2u8; 64]; + + let master_xprv = ExtendedPrivKey::new_master(network, &master_seed).unwrap(); + let xpub = ExtendedPubKey::from_priv( + &dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(), + &master_xprv, + ); + + let account_ref = calculate_account_reference(&secret_key, &xpub, 0, 0); + + // Verify version bits are in the right place + assert_eq!(account_ref >> 28, 0); + } +} diff --git a/src/backend_task/dashpay/incoming_payments.rs b/src/backend_task/dashpay/incoming_payments.rs new file mode 100644 index 000000000..5390fc112 --- /dev/null +++ b/src/backend_task/dashpay/incoming_payments.rs @@ -0,0 +1,359 @@ +use super::hd_derivation::{derive_dashpay_incoming_xpub, derive_payment_address}; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use dash_sdk::dpp::dashcore::{Address, Network}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::Identifier; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Default gap limit for DashPay address derivation +const DASHPAY_GAP_LIMIT: u32 = 20; + +/// Information about a DashPay receiving address +#[derive(Debug, Clone)] +pub struct DashPayReceivingAddress { + pub address: Address, + pub contact_id: Identifier, + pub owner_id: Identifier, + pub address_index: u32, +} + +/// Result of registering DashPay addresses +#[derive(Debug, Default)] +pub struct DashPayAddressRegistrationResult { + pub addresses_registered: usize, + pub contacts_processed: usize, + pub errors: Vec, +} + +/// Derive the receiving addresses for a contact relationship +/// These are the addresses the CONTACT will use to pay US +/// Path: m/9'/5'/15'/account'/(our_id)/(contact_id)/index +pub fn derive_receiving_addresses_for_contact( + master_seed: &[u8], + network: Network, + our_identity_id: &Identifier, + contact_id: &Identifier, + start_index: u32, + count: u32, +) -> Result, String> { + // For receiving payments, we derive from OUR xpub + // Path: m/9'/5'/15'/0'/(our_id)/(contact_id) + // This is the key we sent to the contact in our contact request + let xpub = derive_dashpay_incoming_xpub( + master_seed, + network, + 0, // account 0 + our_identity_id, + contact_id, + )?; + + let mut addresses = Vec::with_capacity(count as usize); + for i in start_index..(start_index + count) { + let address = derive_payment_address(&xpub, i)?; + addresses.push(DashPayReceivingAddress { + address, + contact_id: *contact_id, + owner_id: *our_identity_id, + address_index: i, + }); + } + + Ok(addresses) +} + +/// Register DashPay receiving addresses for all contacts of an identity +/// This derives addresses up to the gap limit for each contact and registers them +/// with the wallet for transaction detection +pub async fn register_dashpay_addresses_for_identity( + app_context: &Arc, + identity: &QualifiedIdentity, +) -> Result { + let mut result = DashPayAddressRegistrationResult::default(); + let our_identity_id = identity.identity.id(); + + // Get the wallet seed + let wallet = identity + .associated_wallets + .values() + .next() + .ok_or("No wallet associated with identity")?; + + let seed = { + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + if !wallet_guard.is_open() { + return Err("Wallet must be unlocked to register DashPay addresses".to_string()); + } + wallet_guard + .seed_bytes() + .map_err(|e| format!("Wallet seed not available: {}", e))? + .to_vec() + }; + + // Load all contacts for this identity from the database + let network_str = app_context.network.to_string(); + let contacts = app_context + .db + .load_dashpay_contacts(&our_identity_id, &network_str) + .map_err(|e| format!("Failed to load contacts: {}", e))?; + + if contacts.is_empty() { + return Ok(result); + } + + // Load address indices for all contacts + let address_indices = app_context + .db + .get_all_contact_address_indices(&our_identity_id) + .map_err(|e| format!("Failed to load address indices: {}", e))?; + + // Create a map for quick lookup + let indices_map: BTreeMap, _> = address_indices + .into_iter() + .map(|idx| (idx.contact_identity_id.clone(), idx)) + .collect(); + + let network = app_context.network; + + for contact in contacts { + let contact_id = match Identifier::from_bytes(&contact.contact_identity_id) { + Ok(id) => id, + Err(e) => { + result.errors.push(format!("Invalid contact ID: {}", e)); + continue; + } + }; + + // Get the current highest receive index for this contact + let highest_receive_index = indices_map + .get(&contact.contact_identity_id) + .map(|idx| idx.highest_receive_index) + .unwrap_or(0); + + // Get how many addresses are already registered with bloom filter + let bloom_registered = indices_map + .get(&contact.contact_identity_id) + .map(|idx| idx.bloom_registered_count) + .unwrap_or(0); + + // Calculate how many new addresses we need to derive + // We want addresses from 0 to (highest_receive_index + GAP_LIMIT) + let target_count = highest_receive_index.saturating_add(DASHPAY_GAP_LIMIT); + + // Only derive new addresses if we need more than what's registered + if target_count <= bloom_registered { + result.contacts_processed += 1; + continue; + } + + let start_index = bloom_registered; + let count = target_count - bloom_registered; + + // Derive the receiving addresses + match derive_receiving_addresses_for_contact( + &seed, + network, + &our_identity_id, + &contact_id, + start_index, + count, + ) { + Ok(addresses) => { + // Register each address with the wallet + for addr_info in &addresses { + if let Err(e) = register_dashpay_address( + app_context, + wallet, + &addr_info.address, + &our_identity_id, + &contact_id, + addr_info.address_index, + ) { + result.errors.push(format!( + "Failed to register address for contact {}: {}", + contact_id.to_string(Encoding::Base58), + e + )); + } else { + result.addresses_registered += 1; + } + } + + // Update the bloom_registered_count in database + if let Err(e) = app_context.db.update_bloom_registered_count( + &our_identity_id, + &contact_id, + target_count, + ) { + result.errors.push(format!( + "Failed to update bloom count for contact {}: {}", + contact_id.to_string(Encoding::Base58), + e + )); + } + + result.contacts_processed += 1; + } + Err(e) => { + result.errors.push(format!( + "Failed to derive addresses for contact {}: {}", + contact_id.to_string(Encoding::Base58), + e + )); + } + } + } + + Ok(result) +} + +/// Register a single DashPay address with the wallet +fn register_dashpay_address( + app_context: &AppContext, + wallet: &Arc>, + address: &Address, + owner_id: &Identifier, + contact_id: &Identifier, + address_index: u32, +) -> Result<(), String> { + use crate::model::wallet::{DerivationPathReference, DerivationPathType}; + use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; + + // Create a derivation path representation for DashPay addresses + // m/9'/5'/15'/0'/// + // Note: We use a simplified representation since full 256-bit paths don't fit in standard BIP32 + let path = DerivationPath::from(vec![ + ChildNumber::from_hardened_idx(9).unwrap(), // Feature purpose + ChildNumber::from_hardened_idx(5).unwrap(), // Coin type (Dash) + ChildNumber::from_hardened_idx(15).unwrap(), // DashPay feature + ChildNumber::from_hardened_idx(0).unwrap(), // Account + // For the identity indices, we use a hash to fit in u32 + ChildNumber::from_normal_idx(hash_identifier_to_u32(owner_id)).unwrap(), + ChildNumber::from_normal_idx(hash_identifier_to_u32(contact_id)).unwrap(), + ChildNumber::from_normal_idx(address_index).unwrap(), + ]); + + // Store the DashPay address mapping in the database + app_context + .db + .save_dashpay_address_mapping(owner_id, contact_id, address, address_index) + .map_err(|e| format!("Failed to save address mapping: {}", e))?; + + // Register with the wallet's known addresses + let mut guard = wallet.write().map_err(|e| e.to_string())?; + + if guard.known_addresses.contains_key(address) { + return Ok(()); // Already registered + } + + guard.known_addresses.insert(address.clone(), path.clone()); + guard.watched_addresses.insert( + path, + crate::model::wallet::AddressInfo { + address: address.clone(), + path_type: DerivationPathType::DASHPAY, + path_reference: DerivationPathReference::ContactBasedFunds, + }, + ); + + Ok(()) +} + +/// Hash an identifier to a u32 for use in derivation path representation +fn hash_identifier_to_u32(id: &Identifier) -> u32 { + use dash_sdk::dpp::dashcore::hashes::{Hash, sha256}; + let hash = sha256::Hash::hash(&id.to_buffer()); + let bytes = hash.to_byte_array(); + u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) & 0x7FFFFFFF +} + +/// Match a received transaction to a DashPay contact +/// Returns the contact ID and payment details if the address belongs to a contact relationship +pub fn match_transaction_to_contact( + app_context: &AppContext, + address: &Address, +) -> Result, String> { + // Look up the address in the DashPay address mapping + app_context + .db + .get_dashpay_address_mapping(address) + .map_err(|e| format!("Failed to lookup address: {}", e)) +} + +/// Process an incoming transaction that was detected by SPV +/// This should be called when SpvEvent::TransactionDetected is received +pub async fn process_incoming_payment( + app_context: &Arc, + tx_id: &str, + address: &Address, + amount_duffs: u64, +) -> Result, String> { + // Check if this address belongs to a DashPay contact relationship + let mapping = match match_transaction_to_contact(app_context, address)? { + Some(m) => m, + None => return Ok(None), // Not a DashPay address + }; + + let (owner_id, contact_id, address_index) = mapping; + + // Update the highest receive index if needed + let current_indices = app_context + .db + .get_contact_address_indices(&owner_id, &contact_id) + .map_err(|e| format!("Failed to get address indices: {}", e))?; + + if address_index >= current_indices.highest_receive_index { + app_context + .db + .update_highest_receive_index(&owner_id, &contact_id, address_index + 1) + .map_err(|e| format!("Failed to update receive index: {}", e))?; + } + + // Save the payment record + app_context + .db + .save_payment( + tx_id, + &contact_id, // from contact + &owner_id, // to us + amount_duffs as i64, + None, // memo - not available for incoming + "received", + ) + .map_err(|e| format!("Failed to save payment: {}", e))?; + + Ok(Some(IncomingPaymentInfo { + tx_id: tx_id.to_string(), + from_contact_id: contact_id, + to_identity_id: owner_id, + address: address.clone(), + amount_duffs, + address_index, + })) +} + +/// Information about an incoming DashPay payment +#[derive(Debug, Clone)] +pub struct IncomingPaymentInfo { + pub tx_id: String, + pub from_contact_id: Identifier, + pub to_identity_id: Identifier, + pub address: Address, + pub amount_duffs: u64, + pub address_index: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_identifier_to_u32() { + let id = Identifier::random(); + let hash = hash_identifier_to_u32(&id); + // Should be less than 2^31 (non-hardened range) + assert!(hash < 0x80000000); + } +} diff --git a/src/backend_task/dashpay/payments.rs b/src/backend_task/dashpay/payments.rs new file mode 100644 index 000000000..20a1b2647 --- /dev/null +++ b/src/backend_task/dashpay/payments.rs @@ -0,0 +1,422 @@ +use super::encryption::decrypt_extended_public_key; +use super::hd_derivation::derive_payment_address; +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use dash_sdk::Sdk; +use dash_sdk::dpp::dashcore::Address; +use dash_sdk::dpp::document::DocumentV0Getters; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::{Value, string_encoding::Encoding}; +use dash_sdk::drive::query::{WhereClause, WhereOperator}; +use dash_sdk::platform::{Document, DocumentQuery, FetchMany, Identifier}; +use std::sync::Arc; + +/// Payment record for local storage +#[derive(Debug, Clone)] +pub struct PaymentRecord { + pub id: String, + pub from_identity: Identifier, + pub to_identity: Identifier, + pub from_address: Option
      , + pub to_address: Address, + pub amount: u64, + pub tx_id: Option, + pub memo: Option, + pub timestamp: u64, + pub status: PaymentStatus, + pub address_index: u32, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PaymentStatus { + Pending, + Broadcast, + Confirmed(u32), // Number of confirmations + Failed(String), +} + +/// Get the next unused address index for a contact and increment it +/// Uses the database to track address indices per contact relationship +async fn get_next_address_index( + app_context: &Arc, + identity_id: &Identifier, + contact_id: &Identifier, +) -> Result { + // Get and increment the send index from database + app_context + .db + .get_and_increment_send_index(identity_id, contact_id) + .map_err(|e| format!("Failed to get address index from database: {}", e)) +} + +/// Derive a payment address for a contact from their encrypted extended public key +pub async fn derive_contact_payment_address( + app_context: &Arc, + sdk: &Sdk, + our_identity: &QualifiedIdentity, + contact_id: Identifier, +) -> Result<(Address, u32), String> { + // Fetch the contact request from the contact to us (they sent us their encrypted xpub) + let dashpay_contract = app_context.dashpay_contract.clone(); + + let mut query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest") + .map_err(|e| format!("Failed to create query: {}", e))?; + + query = query + .with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(contact_id.to_buffer()), + }) + .with_where(WhereClause { + field: "toUserId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(our_identity.identity.id().to_buffer()), + }); + query.limit = 1; + + let results = Document::fetch_many(sdk, query) + .await + .map_err(|e| format!("Failed to fetch contact request: {}", e))?; + + let (_doc_id, doc) = results.into_iter().next().ok_or_else(|| { + format!( + "No contact request found from {}", + contact_id.to_string(Encoding::Base58) + ) + })?; + + let doc = doc.ok_or_else(|| "Contact request document is null".to_string())?; + + // Get properties from the document - handle the Document enum properly + let props = match &doc { + Document::V0(doc_v0) => doc_v0.properties(), + }; + + // Get the encrypted extended public key + let encrypted_xpub = props + .get("encryptedPublicKey") + .and_then(|v| v.as_bytes()) + .ok_or("Missing encryptedPublicKey in contact request".to_string())?; + + // Get key indices for decryption + let sender_key_index = props + .get("senderKeyIndex") + .and_then(|v| match v { + Value::U32(idx) => Some(*idx), + _ => None, + }) + .ok_or("Missing senderKeyIndex".to_string())?; + + let recipient_key_index = props + .get("recipientKeyIndex") + .and_then(|v| match v { + Value::U32(idx) => Some(*idx), + _ => None, + }) + .ok_or("Missing recipientKeyIndex".to_string())?; + + // Get our private key for decryption + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + + let our_key = our_identity + .identity + .public_keys() + .values() + .find(|k| k.id() == recipient_key_index) + .ok_or_else(|| format!("Key with index {} not found", recipient_key_index))?; + + // Get the contact's public key + use dash_sdk::platform::Fetch; + + let contact_identity = dash_sdk::dpp::identity::Identity::fetch(sdk, contact_id) + .await + .map_err(|e| format!("Failed to fetch contact identity: {}", e))? + .ok_or("Contact identity not found".to_string())?; + + let contact_key = contact_identity + .public_keys() + .values() + .find(|k| k.id() == sender_key_index) + .ok_or_else(|| format!("Contact key with index {} not found", sender_key_index))?; + + // Get our private key + let wallets: Vec<_> = our_identity.associated_wallets.values().cloned().collect(); + let our_private_key = our_identity + .private_keys + .get_resolve( + &( + crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, + our_key.id(), + ), + &wallets, + our_identity.network, + ) + .map_err(|e| format!("Error resolving private key: {}", e))? + .map(|(_, private_key)| private_key) + .ok_or("Private key not found".to_string())?; + + // Generate ECDH shared key for decryption + use super::encryption::generate_ecdh_shared_key; + let shared_key = generate_ecdh_shared_key(&our_private_key, contact_key) + .map_err(|e| format!("Failed to generate shared key: {}", e))?; + + // Decrypt the extended public key + let (_parent_fingerprint, chain_code, public_key) = + decrypt_extended_public_key(encrypted_xpub, &shared_key) + .map_err(|e| format!("Failed to decrypt extended public key: {}", e))?; + + // Reconstruct the ExtendedPubKey + let network = app_context.network; + + // Create extended public key from components + // This is simplified - in production you'd properly reconstruct with all fields + use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1}; + use dash_sdk::dpp::key_wallet::bip32::{ChainCode, ChildNumber, ExtendedPubKey, Fingerprint}; + + let _secp = Secp256k1::new(); + let pubkey = + PublicKey::from_slice(&public_key).map_err(|e| format!("Invalid public key: {}", e))?; + + // Note: This is a simplified reconstruction - proper implementation would preserve all fields + let xpub = ExtendedPubKey { + network, + depth: 0, + parent_fingerprint: Fingerprint::default(), + child_number: ChildNumber::from_normal_idx(0).unwrap(), + public_key: pubkey, + chain_code: ChainCode::from(chain_code), + }; + + // Get the next unused address index for this contact + let address_index = + get_next_address_index(app_context, &our_identity.identity.id(), &contact_id).await?; + + // Derive the payment address + let address = derive_payment_address(&xpub, address_index) + .map_err(|e| format!("Failed to derive payment address: {}", e))?; + + Ok((address, address_index)) +} + +/// Send a payment to a contact using the wallet's SPV capabilities +/// (Legacy function - preserved for reference) +#[allow(dead_code)] +pub async fn send_payment_to_contact( + app_context: &Arc, + sdk: &Sdk, + from_identity: QualifiedIdentity, + to_contact_id: Identifier, + amount_dash: f64, + memo: Option, +) -> Result { + send_payment_to_contact_impl( + app_context, + sdk, + from_identity, + to_contact_id, + amount_dash, + memo, + ) + .await +} + +/// Send a payment to a contact using the wallet's SPV capabilities +/// This is the main implementation called from the DashPay task handler +pub async fn send_payment_to_contact_impl( + app_context: &Arc, + sdk: &Sdk, + from_identity: QualifiedIdentity, + to_contact_id: Identifier, + amount_dash: f64, + memo: Option, +) -> Result { + use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + + // Convert Dash to duffs (1 Dash = 100,000,000 duffs) + let amount_duffs = (amount_dash * 100_000_000.0).round() as u64; + + // Get a wallet from the identity's associated wallets + let wallet = from_identity + .associated_wallets + .values() + .next() + .ok_or_else(|| "No wallet associated with this identity".to_string())? + .clone(); + + // Check wallet is unlocked + { + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + if !wallet_guard.is_open() { + return Err("Wallet must be unlocked to send a payment".to_string()); + } + } + + // Derive the payment address for the contact from their encrypted extended public key + let (to_address, address_index) = + derive_contact_payment_address(app_context, sdk, &from_identity, to_contact_id).await?; + + tracing::info!( + "Derived DashPay payment address {} (index {}) for contact {}", + to_address, + address_index, + to_contact_id.to_string(Encoding::Base58) + ); + + // Build the payment request + let request = WalletPaymentRequest { + recipients: vec![PaymentRecipient { + address: to_address.to_string(), + amount_duffs, + }], + subtract_fee_from_amount: false, + memo: memo.clone(), + override_fee: None, + }; + + // Send the payment using the existing wallet infrastructure + let result = app_context + .run_core_task(CoreTask::SendWalletPayment { + wallet: wallet.clone(), + request, + }) + .await?; + + // Extract txid from result + let txid = match &result { + BackendTaskSuccessResult::WalletPayment { txid, .. } => txid.clone(), + _ => "unknown".to_string(), + }; + + // Store payment record in local database + let payment = PaymentRecord { + id: format!( + "{}_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(), + to_contact_id.to_string(Encoding::Base58) + ), + from_identity: from_identity.identity.id(), + to_identity: to_contact_id, + from_address: None, + to_address: to_address.clone(), + amount: amount_duffs, + tx_id: Some(txid.clone()), + memo: memo.clone(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + status: PaymentStatus::Broadcast, + address_index, + }; + + // Log payment details for debugging + tracing::debug!( + "Storing DashPay payment record: id={}, from={}, to={}, amount={}", + payment.id, + payment.from_identity.to_string(Encoding::Base58), + payment.to_identity.to_string(Encoding::Base58), + payment.amount + ); + + // Save to database using the db interface - propagate errors + app_context + .db + .save_payment( + &txid, + &from_identity.identity.id(), + &to_contact_id, + amount_duffs as i64, + memo.as_deref(), + "sent", + ) + .map_err(|e| format!("Failed to save payment record to database: {}", e))?; + + // Convert to Dash for display + let amount_dash = amount_duffs as f64 / 100_000_000.0; + + Ok(BackendTaskSuccessResult::DashPayPaymentSent( + to_contact_id.to_string(Encoding::Base58), + to_address.to_string(), + amount_dash, + )) +} + +/// Load payment history from local database +pub async fn load_payment_history( + _app_context: &Arc, + identity_id: &Identifier, + contact_id: Option<&Identifier>, +) -> Result, String> { + // TODO: Query local database for payment records + // Filter by identity_id and optionally by contact_id + + eprintln!( + "DEBUG: Would load payment history for identity {} with contact filter: {:?}", + identity_id.to_string(Encoding::Base58), + contact_id.map(|id| id.to_string(Encoding::Base58)) + ); + + Ok(Vec::new()) +} + +/// Update payment status after broadcast or confirmation +pub async fn update_payment_status( + _app_context: &Arc, + payment_id: &str, + status: PaymentStatus, + tx_id: Option, +) -> Result<(), String> { + // TODO: Update payment record in database + eprintln!( + "DEBUG: Would update payment {} status to {:?} with tx_id {:?}", + payment_id, status, tx_id + ); + Ok(()) +} + +/// Check if addresses have been used (for gap limit calculation) +pub async fn check_address_usage( + _app_context: &Arc, + addresses: Vec
      , +) -> Result, String> { + // TODO: This would need to query Core or check transaction history + // For now, return all as unused + Ok(vec![false; addresses.len()]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_payment_record_creation() { + let from_id = Identifier::random(); + let to_id = Identifier::random(); + + let payment = PaymentRecord { + id: "test_payment".to_string(), + from_identity: from_id, + to_identity: to_id, + from_address: None, + to_address: Address::p2pkh( + &dash_sdk::dpp::dashcore::PublicKey::from_slice(&[0x02; 33]).unwrap(), + dash_sdk::dpp::dashcore::Network::Testnet, + ), + amount: 100_000_000, // 1 Dash + tx_id: None, + memo: Some("Test payment".to_string()), + timestamp: 0, + status: PaymentStatus::Pending, + address_index: 0, + }; + + assert_eq!(payment.amount, 100_000_000); + assert_eq!(payment.status, PaymentStatus::Pending); + } +} diff --git a/src/backend_task/dashpay/profile.rs b/src/backend_task/dashpay/profile.rs new file mode 100644 index 000000000..66d387781 --- /dev/null +++ b/src/backend_task/dashpay/profile.rs @@ -0,0 +1,532 @@ +use super::avatar_processing::{calculate_avatar_hash, calculate_dhash_fingerprint}; +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use dash_sdk::Sdk; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::document::{DocumentV0, DocumentV0Getters, DocumentV0Setters}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::platform_value::{Value, string_encoding::Encoding}; +use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; +use dash_sdk::platform::documents::transitions::{ + DocumentCreateTransitionBuilder, DocumentReplaceTransitionBuilder, +}; +use dash_sdk::platform::{Document, DocumentQuery, FetchMany, Identifier}; +use rand::RngCore; +use std::collections::{BTreeMap, HashSet}; +use std::sync::Arc; + +pub async fn load_profile( + app_context: &Arc, + sdk: &Sdk, + identity: QualifiedIdentity, +) -> Result { + let identity_id = identity.identity.id(); + let dashpay_contract = app_context.dashpay_contract.clone(); + + // Query for profile document owned by this identity + let mut profile_query = DocumentQuery::new(dashpay_contract, "profile") + .map_err(|e| format!("Failed to create query: {}", e))?; + + profile_query = profile_query.with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: identity_id.to_buffer().into(), + }); + profile_query.limit = 1; + + let profile_docs = Document::fetch_many(sdk, profile_query) + .await + .map_err(|e| format!("Error fetching profile: {}", e))?; + + if let Some((_, Some(doc))) = profile_docs.iter().next() { + // Extract profile fields from the document + let display_name = doc + .get("displayName") + .and_then(|v| v.as_text()) + .unwrap_or_default(); + // The "publicMessage" field in the DashPay contract is actually the bio + let bio = doc + .get("publicMessage") + .and_then(|v| v.as_text()) + .unwrap_or_default(); + let avatar_url = doc + .get("avatarUrl") + .and_then(|v| v.as_text()) + .unwrap_or_default(); + + // Save to local database for caching + let network_str = app_context.network.to_string(); + if let Err(e) = app_context.db.save_dashpay_profile( + &identity_id, + &network_str, + if display_name.is_empty() { + None + } else { + Some(display_name) + }, + if bio.is_empty() { None } else { Some(bio) }, + if avatar_url.is_empty() { + None + } else { + Some(avatar_url) + }, + None, + ) { + tracing::error!("Failed to cache loaded profile in database: {}", e); + } else { + tracing::info!( + "Loaded profile cached in database for identity {}", + identity_id + ); + } + + Ok(BackendTaskSuccessResult::DashPayProfile(Some(( + display_name.to_string(), + bio.to_string(), + avatar_url.to_string(), + )))) + } else { + // No profile found - cache this fact to avoid repeated network queries + let network_str = app_context.network.to_string(); + if let Err(e) = + app_context + .db + .save_dashpay_profile(&identity_id, &network_str, None, None, None, None) + { + tracing::error!("Failed to cache 'no profile' state in database: {}", e); + } + + Ok(BackendTaskSuccessResult::DashPayProfile(None)) + } +} + +pub async fn update_profile( + app_context: &Arc, + sdk: &Sdk, + identity: QualifiedIdentity, + display_name: Option, + bio: Option, + avatar_url: Option, +) -> Result { + let identity_id = identity.identity.id(); + let dashpay_contract = app_context.dashpay_contract.clone(); + + // Get the appropriate identity key for signing + let identity_key = identity + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ) + .ok_or("No suitable authentication key found for identity")?; + + // Check if profile already exists + let mut profile_query = DocumentQuery::new(dashpay_contract.clone(), "profile") + .map_err(|e| format!("Failed to create query: {}", e))?; + + profile_query = profile_query.with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: identity_id.to_buffer().into(), + }); + profile_query.limit = 1; + + let existing_profile = Document::fetch_many(sdk, profile_query) + .await + .map_err(|e| format!("Error checking for existing profile: {}", e))?; + + // Prepare profile data + let mut profile_data = BTreeMap::new(); + + // Keep copies for database save later + let display_name_for_db = display_name.clone(); + let bio_for_db = bio.clone(); + let avatar_url_for_db = avatar_url.clone(); + + // Only add non-empty fields according to DashPay DIP + if let Some(name) = display_name.filter(|name| !name.is_empty()) { + profile_data.insert("displayName".to_string(), Value::Text(name)); + } + if let Some(bio_text) = bio.filter(|bio| !bio.is_empty()) { + profile_data.insert("publicMessage".to_string(), Value::Text(bio_text)); + } + if let Some(url) = avatar_url.as_ref().filter(|url| !url.is_empty()) { + profile_data.insert("avatarUrl".to_string(), Value::Text(url.clone())); + + // Try to fetch and process the avatar image + // Note: This requires an HTTP client which may not be available + // In production, this should be done asynchronously + match super::avatar_processing::fetch_image_bytes(url).await { + Ok(image_bytes) => { + // Calculate SHA-256 hash of the image + let avatar_hash = calculate_avatar_hash(&image_bytes); + profile_data.insert("avatarHash".to_string(), Value::Bytes(avatar_hash.to_vec())); + + // Calculate DHash perceptual fingerprint + match calculate_dhash_fingerprint(&image_bytes) { + Ok(fingerprint) => { + profile_data.insert( + "avatarFingerprint".to_string(), + Value::Bytes(fingerprint.to_vec()), + ); + } + Err(e) => { + eprintln!("Warning: Could not calculate avatar fingerprint: {}", e); + // Continue without fingerprint - it's optional + } + } + } + Err(e) => { + // If we can't fetch the image, just set the URL without hash/fingerprint + // These fields are optional according to DIP-0015 + eprintln!( + "Warning: Could not fetch avatar image for processing: {}", + e + ); + } + } + } + + if let Some((_, Some(existing_doc))) = existing_profile.iter().next() { + // Update existing profile using DocumentReplaceTransitionBuilder + let mut updated_document = existing_doc.clone(); + + // Update the document's properties + for (key, value) in profile_data { + updated_document.set(&key, value); + } + + // Handle avatar removal: if avatar_url is None or empty, remove avatar-related fields + if avatar_url.as_ref().is_none_or(|url| url.is_empty()) { + // Remove avatar-related fields from the document + let Document::V0(ref mut doc_v0) = updated_document; + doc_v0.properties_mut().remove("avatarUrl"); + doc_v0.properties_mut().remove("avatarHash"); + doc_v0.properties_mut().remove("avatarFingerprint"); + } + + // Bump revision for replacement + updated_document.bump_revision(); + + let mut builder = DocumentReplaceTransitionBuilder::new( + dashpay_contract, + "profile".to_string(), + updated_document, + ); + + // Add state transition options if available + let maybe_options = app_context.state_transition_options(); + if let Some(options) = maybe_options { + builder = builder.with_state_transition_creation_options(options); + } + + let result = sdk + .document_replace(builder, identity_key, &identity) + .await + .map_err(|e| format!("Error replacing profile: {}", e))?; + + // Log the proof-verified document for audit trail + match result { + dash_sdk::platform::documents::transitions::DocumentReplaceResult::Document(doc) => { + tracing::info!( + "Profile updated: doc_id={}, revision={:?}", + doc.id(), + doc.revision() + ); + } + } + + // Save to local database for caching + let network_str = app_context.network.to_string(); + if let Err(e) = app_context.db.save_dashpay_profile( + &identity_id, + &network_str, + display_name_for_db.as_deref(), + bio_for_db.as_deref(), + avatar_url_for_db.as_deref(), + None, + ) { + tracing::error!("Failed to cache updated profile in database: {}", e); + } else { + tracing::info!("Profile cached in database for identity {}", identity_id); + } + + Ok(BackendTaskSuccessResult::DashPayProfileUpdated( + identity.identity.id(), + )) + } else { + // Create new profile using DocumentCreateTransitionBuilder + // Generate random entropy for document ID (security: prevents predictable IDs) + let mut entropy = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut entropy); + + let profile_doc_id = Document::generate_document_id_v0( + &dashpay_contract.id(), + &identity_id, + "profile", + &entropy, + ); + + let document = Document::V0(DocumentV0 { + id: profile_doc_id, + owner_id: identity_id, + creator_id: None, + properties: profile_data, + revision: None, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + }); + + let mut builder = DocumentCreateTransitionBuilder::new( + dashpay_contract, + "profile".to_string(), + document, + entropy, // Use same entropy as document ID generation + ); + + // Add state transition options if available + let maybe_options = app_context.state_transition_options(); + if let Some(options) = maybe_options { + builder = builder.with_state_transition_creation_options(options); + } + + let result = sdk + .document_create(builder, identity_key, &identity) + .await + .map_err(|e| format!("Error creating profile: {}", e))?; + + // Log the proof-verified document for audit trail + match result { + dash_sdk::platform::documents::transitions::DocumentCreateResult::Document(doc) => { + tracing::info!( + "Profile created: doc_id={}, revision={:?}", + doc.id(), + doc.revision() + ); + } + } + + // Save to local database for caching + let network_str = app_context.network.to_string(); + if let Err(e) = app_context.db.save_dashpay_profile( + &identity_id, + &network_str, + display_name_for_db.as_deref(), + bio_for_db.as_deref(), + avatar_url_for_db.as_deref(), + None, + ) { + tracing::error!("Failed to cache new profile in database: {}", e); + } else { + tracing::info!( + "New profile cached in database for identity {}", + identity_id + ); + } + + Ok(BackendTaskSuccessResult::DashPayProfileUpdated( + identity.identity.id(), + )) + } +} + +pub async fn send_payment( + app_context: &Arc, + sdk: &Sdk, + from_identity: QualifiedIdentity, + to_contact_id: Identifier, + amount_dash: f64, + memo: Option, +) -> Result { + // Use the new payments module to send payment + super::payments::send_payment_to_contact( + app_context, + sdk, + from_identity, + to_contact_id, + amount_dash, + memo, + ) + .await +} + +pub async fn load_payment_history( + app_context: &Arc, + _sdk: &Sdk, + identity: QualifiedIdentity, + contact_id: Option, +) -> Result { + // Load payment history from local database + let history = super::payments::load_payment_history( + app_context, + &identity.identity.id(), + contact_id.as_ref(), + ) + .await?; + + // Format the results + if history.is_empty() { + let filter_msg = if let Some(cid) = contact_id { + format!(" with contact {}", cid.to_string(Encoding::Base58)) + } else { + String::new() + }; + + Ok(BackendTaskSuccessResult::Message(format!( + "No payment history found for {}{}", + identity.identity.id().to_string(Encoding::Base58), + filter_msg + ))) + } else { + // In production, this would return a structured result + Ok(BackendTaskSuccessResult::Message(format!( + "Found {} payment records", + history.len() + ))) + } +} + +/// Fetch a contact's public profile from the Platform +pub async fn fetch_contact_profile( + app_context: &Arc, + sdk: &Sdk, + _identity: QualifiedIdentity, // May be needed for future privacy features + contact_id: Identifier, +) -> Result { + let dashpay_contract = app_context.dashpay_contract.clone(); + + // Query for the contact's profile document + let mut query = DocumentQuery::new(dashpay_contract, "profile") + .map_err(|e| format!("Failed to create profile query: {}", e))?; + + query = query.with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(contact_id.to_buffer()), + }); + query.limit = 1; + + match Document::fetch_many(sdk, query).await { + Ok(results) => { + // Extract the profile document if found + let profile_doc = results.into_iter().next().and_then(|(_, doc)| doc); + Ok(BackendTaskSuccessResult::DashPayContactProfile(profile_doc)) + } + Err(e) => { + // Return a more helpful error message + Err(format!( + "Failed to fetch profile for identity {}: {}. This identity may not have a public profile yet.", + contact_id.to_string(Encoding::Base58), + e + )) + } + } +} + +/// Search for users on the Platform by DPNS username (per DIP-12/DIP-15) +/// +/// Per the DIPs, search should: +/// 1. Query DPNS for username prefix matches +/// 2. Get the identity IDs from those results +/// 3. Fetch profiles for display info (avatar, displayName) +/// 4. Return the DPNS username prominently (it's the verified identifier) +pub async fn search_profiles( + app_context: &Arc, + sdk: &Sdk, + search_query: String, +) -> Result { + let dpns_contract = app_context.dpns_contract.clone(); + let dashpay_contract = app_context.dashpay_contract.clone(); + let mut results: Vec<(Identifier, Option, String)> = Vec::new(); + + let query_trimmed = search_query.trim(); + if query_trimmed.is_empty() { + return Ok(BackendTaskSuccessResult::DashPayProfileSearchResults( + results, + )); + } + + // Normalize the search query (DPNS uses lowercase normalized labels) + let normalized_query = query_trimmed.to_lowercase(); + + // Search DPNS for usernames starting with the query + let mut dpns_query = DocumentQuery::new(dpns_contract, "domain") + .map_err(|e| format!("Failed to create DPNS query: {}", e))?; + + dpns_query = dpns_query + .with_where(WhereClause { + field: "normalizedParentDomainName".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("dash".to_string()), + }) + .with_where(WhereClause { + field: "normalizedLabel".to_string(), + operator: WhereOperator::StartsWith, + value: Value::Text(normalized_query.clone()), + }) + .with_order_by(OrderClause { + field: "normalizedLabel".to_string(), + ascending: true, + }); // Required for StartsWith range query + dpns_query.limit = 20; // Limit results + + let dpns_results = Document::fetch_many(sdk, dpns_query) + .await + .map_err(|e| format!("Failed to search DPNS: {}", e))?; + + // Collect identity IDs and usernames from DPNS results + let mut identity_usernames: Vec<(Identifier, String)> = Vec::new(); + for (_, doc) in dpns_results { + if let Some(document) = doc { + let identity_id = document.owner_id(); + + // Get the label (username) from the document + let username = document + .get("normalizedLabel") + .and_then(|v| v.as_text()) + .map(|s| format!("{}.dash", s)) + .unwrap_or_else(|| format!("{}.dash", identity_id.to_string(Encoding::Base58))); + + identity_usernames.push((identity_id, username)); + } + } + + // Fetch profiles for each identity + for (identity_id, username) in identity_usernames { + // Query for profile document owned by this identity + let mut profile_query = DocumentQuery::new(dashpay_contract.clone(), "profile") + .map_err(|e| format!("Failed to create profile query: {}", e))?; + + profile_query = profile_query.with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity_id.to_buffer()), + }); + profile_query.limit = 1; + + let profile_results = Document::fetch_many(sdk, profile_query).await; + + // Get the profile document if it exists (profile is optional) + let profile_doc = match profile_results { + Ok(docs) => docs.into_iter().next().and_then(|(_, doc)| doc), + Err(_) => None, // Profile fetch failed, but user exists + }; + + results.push((identity_id, profile_doc, username)); + } + + Ok(BackendTaskSuccessResult::DashPayProfileSearchResults( + results, + )) +} diff --git a/src/backend_task/dashpay/validation.rs b/src/backend_task/dashpay/validation.rs new file mode 100644 index 000000000..354f06aa0 --- /dev/null +++ b/src/backend_task/dashpay/validation.rs @@ -0,0 +1,415 @@ +use crate::model::qualified_identity::QualifiedIdentity; +use dash_sdk::Sdk; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::platform::Identifier; + +/// Validation result for contact request fields +#[derive(Debug, Clone)] +pub struct ContactRequestValidation { + pub is_valid: bool, + pub errors: Vec, + pub warnings: Vec, +} + +impl Default for ContactRequestValidation { + fn default() -> Self { + Self { + is_valid: true, + errors: Vec::new(), + warnings: Vec::new(), + } + } +} + +impl ContactRequestValidation { + pub fn new() -> Self { + Self::default() + } + + pub fn add_error(&mut self, error: String) { + self.errors.push(error); + self.is_valid = false; + } + + pub fn add_warning(&mut self, warning: String) { + self.warnings.push(warning); + } + + pub fn merge(&mut self, other: ContactRequestValidation) { + self.errors.extend(other.errors); + self.warnings.extend(other.warnings); + if !other.is_valid { + self.is_valid = false; + } + } +} + +/// Validate sender key index exists and is suitable for contact requests +pub fn validate_sender_key_index( + identity: &QualifiedIdentity, + key_index: u32, +) -> ContactRequestValidation { + let mut validation = ContactRequestValidation::new(); + + // Find the key by ID + match identity.identity.get_public_key_by_id(key_index) { + Some(key) => { + // Verify key type is suitable for signing + match key.key_type() { + KeyType::ECDSA_SECP256K1 => { + // This is the expected key type for contact requests + } + KeyType::ECDSA_HASH160 => { + validation.add_error(format!( + "Sender key {} is ECDSA_HASH160 type, cannot be used for signing contact requests", + key_index + )); + } + _ => { + validation.add_warning(format!( + "Sender key {} has unusual type {:?} for contact requests", + key_index, + key.key_type() + )); + } + } + + // Verify purpose is suitable + // Contact requests use ENCRYPTION keys for ECDH key exchange per DIP-15 + match key.purpose() { + Purpose::ENCRYPTION => { + // Perfect for contact requests - ENCRYPTION keys are used for ECDH + } + Purpose::AUTHENTICATION => { + validation.add_warning(format!( + "Sender key {} has AUTHENTICATION purpose, contact requests typically use ENCRYPTION keys for ECDH", + key_index + )); + } + _ => { + validation.add_warning(format!( + "Sender key {} has unusual purpose {:?} for contact requests", + key_index, + key.purpose() + )); + } + } + + // Verify security level + match key.security_level() { + SecurityLevel::MASTER + | SecurityLevel::CRITICAL + | SecurityLevel::HIGH + | SecurityLevel::MEDIUM => { + // Acceptable security levels + } + } + + // Check if key is disabled + if let Some(disabled_at) = key.disabled_at() { + validation.add_error(format!( + "Sender key {} is disabled (at timestamp {})", + key_index, disabled_at + )); + } + } + None => { + validation.add_error(format!( + "Sender key index {} not found in identity {}", + key_index, + identity.identity.id() + )); + } + } + + validation +} + +/// Validate recipient key index exists and is suitable for encryption +pub async fn validate_recipient_key_index( + _sdk: &Sdk, + _recipient_identity_id: Identifier, + key_index: u32, +) -> Result { + let mut validation = ContactRequestValidation::new(); + + // For now, skip recipient key validation since we don't have a direct SDK method + // In a real implementation, we would query the identity from the platform + validation.add_warning(format!( + "Cannot validate recipient key {} - identity validation skipped", + key_index + )); + + Ok(validation) +} + +/// Validate that a contact request's core height is reasonable +pub fn validate_core_height_created_at( + core_height: u32, + current_core_height: Option, +) -> ContactRequestValidation { + let mut validation = ContactRequestValidation::new(); + + if let Some(current_height) = current_core_height { + // Check if the height is too far in the future (max 10 blocks ahead) + if core_height > current_height + 10 { + validation.add_error(format!( + "Core height {} is too far in the future (current: {})", + core_height, current_height + )); + } + + // Check if the height is too far in the past (max 200 blocks / ~1.5 hours behind) + if current_height > core_height + 200 { + validation.add_warning(format!( + "Core height {} is quite old (current: {}, {} blocks behind)", + core_height, + current_height, + current_height - core_height + )); + } + } else { + validation + .add_warning("Cannot validate core height - current height unavailable".to_string()); + } + + validation +} + +/// Validate account reference is within reasonable bounds +pub fn validate_account_reference(account_reference: u32) -> ContactRequestValidation { + let mut validation = ContactRequestValidation::new(); + + // DashPay typically uses accounts 0-2147483647 (2^31 - 1) + if account_reference >= 2147483648 { + validation.add_warning(format!( + "Account reference {} is very high (using hardened derivation)", + account_reference + )); + } + + // Warn about unusually high account numbers + if account_reference > 1000 { + validation.add_warning(format!( + "Account reference {} is unusually high for typical usage", + account_reference + )); + } + + validation +} + +/// Validate toUserId matches the recipient identity +pub fn validate_to_user_id( + to_user_id: Identifier, + expected_recipient: Identifier, +) -> ContactRequestValidation { + let mut validation = ContactRequestValidation::new(); + + if to_user_id != expected_recipient { + validation.add_error(format!( + "toUserId {} does not match expected recipient {}", + to_user_id, expected_recipient + )); + } + + validation +} + +/// Validate field sizes according to DIP-0015 specifications +pub fn validate_contact_request_field_sizes( + encrypted_public_key: &[u8], + encrypted_account_label: Option<&[u8]>, + auto_accept_proof: Option<&[u8]>, +) -> ContactRequestValidation { + let mut validation = ContactRequestValidation::new(); + + // Validate encryptedPublicKey size (must be exactly 96 bytes) + if encrypted_public_key.len() != 96 { + validation.add_error(format!( + "encryptedPublicKey must be exactly 96 bytes, got {}", + encrypted_public_key.len() + )); + } + + // Validate encryptedAccountLabel size (48-80 bytes if present) + if let Some(label) = + encrypted_account_label.filter(|label| label.len() < 48 || label.len() > 80) + { + validation.add_error(format!( + "encryptedAccountLabel must be 48-80 bytes, got {}", + label.len() + )); + } + + // Validate autoAcceptProof size (38-102 bytes if present and not empty) + if let Some(proof) = auto_accept_proof + .filter(|proof| !proof.is_empty() && (proof.len() < 38 || proof.len() > 102)) + { + validation.add_error(format!( + "autoAcceptProof must be 38-102 bytes when present, got {}", + proof.len() + )); + } + + validation +} + +/// Validate profile field sizes according to DIP-0015 +pub fn validate_profile_field_sizes( + display_name: Option<&str>, + public_message: Option<&str>, + avatar_url: Option<&str>, + avatar_hash: Option<&[u8]>, + avatar_fingerprint: Option<&[u8]>, +) -> ContactRequestValidation { + let mut validation = ContactRequestValidation::new(); + + // Validate displayName (0-25 characters) + if let Some(name) = display_name.filter(|name| name.chars().count() > 25) { + validation.add_error(format!( + "displayName must be 0-25 characters, got {}", + name.chars().count() + )); + } + + // Validate publicMessage (0-140 characters) + if let Some(msg) = public_message.filter(|msg| msg.chars().count() > 140) { + validation.add_error(format!( + "publicMessage must be 0-140 characters, got {}", + msg.chars().count() + )); + } + + // Validate avatarUrl (0-2048 characters) + if let Some(url) = avatar_url.filter(|url| url.chars().count() > 2048) { + validation.add_error(format!( + "avatarUrl must be 0-2048 characters, got {}", + url.chars().count() + )); + } + + if avatar_url.is_some_and(|url| { + !url.is_empty() && !url.starts_with("https://") && !url.starts_with("http://") + }) { + validation.add_warning("avatarUrl should use HTTPS protocol".to_string()); + } + + // Validate avatarHash (exactly 32 bytes if present) + if let Some(hash) = avatar_hash.filter(|hash| hash.len() != 32) { + validation.add_error(format!( + "avatarHash must be exactly 32 bytes, got {}", + hash.len() + )); + } + + // Validate avatarFingerprint (exactly 8 bytes if present) + if let Some(fingerprint) = avatar_fingerprint.filter(|fingerprint| fingerprint.len() != 8) { + validation.add_error(format!( + "avatarFingerprint must be exactly 8 bytes, got {}", + fingerprint.len() + )); + } + + validation +} + +/// Validate contactInfo field sizes according to DIP-0015 +pub fn validate_contact_info_field_sizes( + enc_to_user_id: &[u8], + private_data: &[u8], +) -> ContactRequestValidation { + let mut validation = ContactRequestValidation::new(); + + // Validate encToUserId (exactly 32 bytes) + if enc_to_user_id.len() != 32 { + validation.add_error(format!( + "encToUserId must be exactly 32 bytes, got {}", + enc_to_user_id.len() + )); + } + + // Validate privateData (48-2048 bytes) + if private_data.len() < 48 || private_data.len() > 2048 { + validation.add_error(format!( + "privateData must be 48-2048 bytes, got {}", + private_data.len() + )); + } + + validation +} + +/// Comprehensive validation of a contact request before sending +#[allow(clippy::too_many_arguments)] +pub async fn validate_contact_request_before_send( + sdk: &Sdk, + sender_identity: &QualifiedIdentity, + sender_key_index: u32, + recipient_identity_id: Identifier, + recipient_key_index: u32, + account_reference: u32, + core_height: u32, + current_core_height: Option, +) -> Result { + let mut validation = ContactRequestValidation::new(); + + // Validate sender key + let sender_validation = validate_sender_key_index(sender_identity, sender_key_index); + validation.merge(sender_validation); + + // Validate recipient key + let recipient_validation = + validate_recipient_key_index(sdk, recipient_identity_id, recipient_key_index).await?; + validation.merge(recipient_validation); + + // Validate core height + let height_validation = validate_core_height_created_at(core_height, current_core_height); + validation.merge(height_validation); + + // Validate account reference + let account_validation = validate_account_reference(account_reference); + validation.merge(account_validation); + + // Validate toUserId matches recipient + let user_id_validation = validate_to_user_id(recipient_identity_id, recipient_identity_id); + validation.merge(user_id_validation); + + Ok(validation) +} + +/// Validate an incoming contact request +#[allow(clippy::too_many_arguments)] +pub async fn validate_incoming_contact_request( + sdk: &Sdk, + our_identity: &QualifiedIdentity, + sender_identity_id: Identifier, + sender_key_index: u32, + our_key_index: u32, + account_reference: u32, + core_height: u32, + current_core_height: Option, +) -> Result { + let mut validation = ContactRequestValidation::new(); + + // Validate sender key exists (fetch their identity) + let sender_validation = + validate_recipient_key_index(sdk, sender_identity_id, sender_key_index).await?; + validation.merge(sender_validation); + + // Validate our key for decryption + let our_key_validation = validate_sender_key_index(our_identity, our_key_index); + validation.merge(our_key_validation); + + // Validate core height + let height_validation = validate_core_height_created_at(core_height, current_core_height); + validation.merge(height_validation); + + // Validate account reference + let account_validation = validate_account_reference(account_reference); + validation.merge(account_validation); + + Ok(validation) +} diff --git a/src/backend_task/document.rs b/src/backend_task/document.rs index 955ef057d..24cda28f6 100644 --- a/src/backend_task/document.rs +++ b/src/backend_task/document.rs @@ -1,5 +1,6 @@ -use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; +use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::proof_log_item::{ProofLogItem, RequestType}; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::data_contract::document_type::DocumentType; @@ -241,13 +242,12 @@ impl AppContext { })?; // Handle the result - DocumentDeleteResult contains the deleted document ID + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); match result { - DocumentDeleteResult::Deleted(deleted_id) => { - Ok(BackendTaskSuccessResult::Message(format!( - "Document {} deleted successfully", - deleted_id - ))) - } + DocumentDeleteResult::Deleted(deleted_id) => Ok( + BackendTaskSuccessResult::DeletedDocument(deleted_id, fee_result), + ), } } DocumentTask::ReplaceDocument( @@ -298,13 +298,12 @@ impl AppContext { })?; // Handle the result - DocumentReplaceResult contains the replaced document + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); match result { - DocumentReplaceResult::Document(document) => { - Ok(BackendTaskSuccessResult::Message(format!( - "Document {} replaced successfully", - document.id() - ))) - } + DocumentReplaceResult::Document(document) => Ok( + BackendTaskSuccessResult::ReplacedDocument(document.id(), fee_result), + ), } } DocumentTask::TransferDocument( @@ -373,14 +372,12 @@ impl AppContext { })?; // Handle the result - DocumentTransferResult contains the transferred document + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); match result { - DocumentTransferResult::Document(document) => { - Ok(BackendTaskSuccessResult::Message(format!( - "Document {} transferred to {} successfully", - document.id(), - new_owner_id - ))) - } + DocumentTransferResult::Document(document) => Ok( + BackendTaskSuccessResult::TransferredDocument(document.id(), fee_result), + ), } } DocumentTask::PurchaseDocument( @@ -450,14 +447,12 @@ impl AppContext { })?; // Handle the result - DocumentPurchaseResult contains the purchased document + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); match result { - DocumentPurchaseResult::Document(document) => { - Ok(BackendTaskSuccessResult::Message(format!( - "Document {} purchased for {} credits", - document.id(), - price - ))) - } + DocumentPurchaseResult::Document(document) => Ok( + BackendTaskSuccessResult::PurchasedDocument(document.id(), fee_result), + ), } } DocumentTask::SetDocumentPrice( @@ -526,14 +521,12 @@ impl AppContext { })?; // Handle the result - DocumentSetPriceResult contains the document with updated price + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); match result { - DocumentSetPriceResult::Document(document) => { - Ok(BackendTaskSuccessResult::Message(format!( - "Document {} price set to {} credits", - document.id(), - price - ))) - } + DocumentSetPriceResult::Document(document) => Ok( + BackendTaskSuccessResult::SetDocumentPrice(document.id(), fee_result), + ), } } } diff --git a/src/backend_task/identity/add_key_to_identity.rs b/src/backend_task/identity/add_key_to_identity.rs index b6ab80f9d..117353b94 100644 --- a/src/backend_task/identity/add_key_to_identity.rs +++ b/src/backend_task/identity/add_key_to_identity.rs @@ -1,5 +1,7 @@ use super::BackendTaskSuccessResult; +use crate::backend_task::FeeResult; use crate::context::AppContext; +use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; @@ -47,6 +49,10 @@ impl AppContext { ), (public_key_to_add.clone(), private_key), ); + // Track balance before operation for fee calculation + let balance_before = qualified_identity.identity.balance(); + let estimated_fee = PlatformFeeEstimator::new().estimate_identity_update(); + let state_transition = IdentityUpdateTransition::try_from_identity_with_signer( &qualified_identity.identity, &master_key_id, @@ -65,16 +71,58 @@ impl AppContext { .await .map_err(|e| format!("Broadcasting error: {}", e))?; - if let StateTransitionProofResult::VerifiedPartialIdentity(identity) = result { - for public_key in identity.loaded_public_keys.into_values() { - qualified_identity.identity.add_public_key(public_key); + // Log and handle the proof result + tracing::info!("AddKeyToIdentity proof result: {}", result); + + let new_balance = match result { + StateTransitionProofResult::VerifiedPartialIdentity(identity) => { + // Update the identity with proof-verified public keys + let balance = identity.balance; + for public_key in identity.loaded_public_keys.into_values() { + qualified_identity.identity.add_public_key(public_key); + } + balance + } + other => { + tracing::warn!( + "Unexpected proof result type for add key to identity: {}", + other + ); + // Still add the key we tried to add, since the broadcast succeeded + qualified_identity + .identity + .add_public_key(public_key_to_add.identity_public_key.clone()); + None + } + }; + + // Calculate and log actual fee paid + let actual_fee = if let Some(balance_after) = new_balance { + let fee = balance_before.saturating_sub(balance_after); + tracing::info!( + "AddKeyToIdentity complete: estimated fee {} credits, actual fee {} credits", + estimated_fee, + fee + ); + if fee != estimated_fee { + tracing::warn!( + "Fee mismatch: estimated {} vs actual {} (diff: {})", + estimated_fee, + fee, + fee as i64 - estimated_fee as i64 + ); } - } + qualified_identity.identity.set_balance(balance_after); + fee + } else { + // If we couldn't determine the balance, use the estimate + estimated_fee + }; + + let fee_result = FeeResult::new(estimated_fee, actual_fee); self.update_local_qualified_identity(&qualified_identity) - .map(|_| { - BackendTaskSuccessResult::Message("Successfully added key to identity".to_string()) - }) + .map(|_| BackendTaskSuccessResult::AddedKeyToIdentity(fee_result)) .map_err(|e| format!("Database error: {}", e)) } } diff --git a/src/backend_task/identity/discover_identities.rs b/src/backend_task/identity/discover_identities.rs new file mode 100644 index 000000000..9d77af0eb --- /dev/null +++ b/src/backend_task/identity/discover_identities.rs @@ -0,0 +1,318 @@ +use crate::context::AppContext; +use crate::model::qualified_identity::DPNSNameInfo; +use crate::model::wallet::Wallet; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use std::sync::{Arc, RwLock}; + +impl AppContext { + /// Discover and load identities derived from a wallet by checking the network. + /// This is called automatically on wallet unlock to find any identities that + /// were registered using keys from the wallet. + pub(crate) async fn discover_identities_from_wallet( + self: &Arc, + wallet: &Arc>, + max_identity_index: u32, + ) -> Result<(), String> { + use dash_sdk::platform::Fetch; + use dash_sdk::platform::types::identity::NonUniquePublicKeyHashQuery; + + const AUTH_KEY_LOOKUP_WINDOW: u32 = 12; + + let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); + let seed_hash = wallet.read().map_err(|e| e.to_string())?.seed_hash(); + + tracing::info!( + seed = %hex::encode(seed_hash), + "Starting identity discovery for wallet (checking indices 0..{})", + max_identity_index + ); + + let mut found_count = 0; + + for identity_index in 0..=max_identity_index { + // Try to find an identity at this index by checking authentication keys + let mut fetched_identity = None; + let mut matched_key_index = None; + + for key_index in 0..AUTH_KEY_LOOKUP_WINDOW { + let public_key = { + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + match wallet_guard.identity_authentication_ecdsa_public_key( + self.network, + identity_index, + key_index, + ) { + Ok(key) => key, + Err(e) => { + tracing::debug!( + "Could not derive key at index {}/{}: {}", + identity_index, + key_index, + e + ); + continue; + } + } + }; + + let key_hash = public_key.pubkey_hash().into(); + let query = NonUniquePublicKeyHashQuery { + key_hash, + after: None, + }; + + match dash_sdk::platform::Identity::fetch(&sdk, query).await { + Ok(Some(identity)) => { + fetched_identity = Some(identity); + matched_key_index = Some(key_index); + break; + } + Ok(None) => continue, + Err(e) => { + tracing::debug!( + "Error querying identity at index {}/{}: {}", + identity_index, + key_index, + e + ); + continue; + } + } + } + + // If we found an identity, process and store it + if let Some(identity) = fetched_identity { + let identity_id = identity.id(); + tracing::info!( + identity_id = %identity_id, + identity_index, + key_index = ?matched_key_index, + "Discovered identity from wallet" + ); + + // Check if we already have this identity stored + let already_exists = { + let wallets = self.wallets.read().map_err(|e| e.to_string())?; + let existing = self.db.get_identity_by_id(&identity_id, self, &wallets); + existing.is_ok() && existing.unwrap().is_some() + }; + + if already_exists { + tracing::info!( + identity_id = %identity_id, + "Identity already loaded, skipping" + ); + continue; + } + + // Build qualified identity with wallet key derivation paths + match self + .build_qualified_identity_from_wallet(&sdk, identity, wallet, identity_index) + .await + { + Ok(qualified_identity) => { + // Store the identity + if let Err(e) = self.insert_local_qualified_identity( + &qualified_identity, + &Some((seed_hash, identity_index)), + ) { + tracing::warn!( + identity_id = %identity_id, + error = %e, + "Failed to store discovered identity" + ); + } else { + // Add to wallet's identities map + if let Ok(mut wallet_guard) = wallet.write() { + wallet_guard + .identities + .insert(identity_index, qualified_identity.identity.clone()); + } + found_count += 1; + tracing::info!( + identity_id = %identity_id, + "Successfully loaded discovered identity" + ); + } + } + Err(e) => { + tracing::warn!( + identity_id = %identity_id, + error = %e, + "Failed to build qualified identity" + ); + } + } + } + } + + tracing::info!( + seed = %hex::encode(seed_hash), + found_count, + "Identity discovery complete" + ); + + Ok(()) + } + + /// Build a QualifiedIdentity from a fetched Identity with wallet key derivation paths. + /// This matches identity public keys to wallet-derived keys and fetches DPNS names. + async fn build_qualified_identity_from_wallet( + &self, + sdk: &dash_sdk::Sdk, + identity: dash_sdk::platform::Identity, + wallet: &Arc>, + identity_index: u32, + ) -> Result { + use crate::model::qualified_identity::encrypted_key_storage::{ + PrivateKeyData, WalletDerivationPath, + }; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use crate::model::qualified_identity::{ + IdentityStatus, IdentityType, PrivateKeyTarget, QualifiedIdentity, + }; + use dash_sdk::dpp::identity::KeyType; + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, KeyDerivationType}; + + let seed_hash = wallet.read().map_err(|e| e.to_string())?.seed_hash(); + + // Get the highest key ID in the identity to know how many keys to derive + let highest_key_id = identity.public_keys().keys().max().copied().unwrap_or(0); + let derive_up_to = highest_key_id.saturating_add(6); // Add buffer for future keys + + // Derive authentication keys from wallet and build lookup maps + let mut public_key_to_index: std::collections::BTreeMap, u32> = + std::collections::BTreeMap::new(); + let mut public_key_hash_to_index: std::collections::BTreeMap<[u8; 20], u32> = + std::collections::BTreeMap::new(); + + { + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + for key_index in 0..=derive_up_to { + if let Ok(public_key) = wallet_guard.identity_authentication_ecdsa_public_key( + self.network, + identity_index, + key_index, + ) { + public_key_to_index.insert(public_key.to_bytes().to_vec(), key_index); + public_key_hash_to_index.insert(public_key.pubkey_hash().into(), key_index); + } + } + } + + // Match identity keys with wallet derivation paths + let private_keys_map: std::collections::BTreeMap<_, _> = identity + .public_keys() + .iter() + .filter_map(|(key_id, identity_key)| { + // Try to match by full public key or by hash + let matched_index = match identity_key.key_type() { + KeyType::ECDSA_SECP256K1 => public_key_to_index + .get(identity_key.data().as_slice()) + .copied(), + KeyType::ECDSA_HASH160 => { + let hash: [u8; 20] = identity_key.data().as_slice().try_into().ok()?; + public_key_hash_to_index.get(&hash).copied() + } + _ => None, + }?; + + let derivation_path = DerivationPath::identity_authentication_path( + self.network, + KeyDerivationType::ECDSA, + identity_index, + matched_index, + ); + + let wallet_derivation_path = WalletDerivationPath { + wallet_seed_hash: seed_hash, + derivation_path, + }; + + Some(( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, *key_id), + ( + QualifiedIdentityPublicKey::from_identity_public_key_in_wallet( + identity_key.clone(), + Some(wallet_derivation_path.clone()), + ), + PrivateKeyData::AtWalletDerivationPath(wallet_derivation_path), + ), + )) + }) + .collect(); + + // Fetch DPNS names for this identity + let dpns_names = { + use dash_sdk::dpp::document::DocumentV0Getters; + use dash_sdk::dpp::platform_value::Value; + use dash_sdk::drive::query::{WhereClause, WhereOperator}; + use dash_sdk::platform::{Document, DocumentQuery, FetchMany}; + + let query = DocumentQuery { + data_contract: self.dpns_contract.clone(), + document_type_name: "domain".to_string(), + where_clauses: vec![WhereClause { + field: "records.identity".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity.id().into()), + }], + order_by_clauses: vec![], + limit: 100, + start: None, + }; + + match Document::fetch_many(sdk, query).await { + Ok(document_map) => document_map + .values() + .filter_map(|maybe_doc| { + maybe_doc.as_ref().and_then(|doc| { + let name = doc + .get("label") + .map(|label| label.to_str().unwrap_or_default()); + let acquired_at = doc + .created_at() + .into_iter() + .chain(doc.transferred_at()) + .max(); + + match (name, acquired_at) { + (Some(name), Some(acquired_at)) => Some(DPNSNameInfo { + name: name.to_string(), + acquired_at, + }), + _ => None, + } + }) + }) + .collect::>(), + Err(e) => { + tracing::warn!("Failed to fetch DPNS names for identity: {}", e); + Vec::new() + } + } + }; + + // Build the qualified identity + let mut associated_wallets = std::collections::BTreeMap::new(); + associated_wallets.insert(seed_hash, Arc::clone(wallet)); + + Ok(QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: private_keys_map.into(), + dpns_names, + associated_wallets, + wallet_index: Some(identity_index), + top_ups: Default::default(), + status: IdentityStatus::Unknown, + network: self.network, + }) + } +} diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index c5f63dd43..c5b332f37 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -319,17 +319,22 @@ impl AppContext { }) .map_err(|e| format!("Error fetching DPNS names: {}", e))?; + // Determine alias: use user input, or fall back to first DPNS name if available + let alias = if !alias_input.is_empty() { + Some(alias_input) + } else if !maybe_owned_dpns_names.is_empty() { + Some(format!("{}.dash", maybe_owned_dpns_names[0].name)) + } else { + None + }; + let qualified_identity = QualifiedIdentity { identity, associated_voter_identity, associated_operator_identity: None, associated_owner_key_id: None, identity_type, - alias: if alias_input.is_empty() { - None - } else { - Some(alias_input) - }, + alias, private_keys: encrypted_private_keys.into(), dpns_names: maybe_owned_dpns_names, associated_wallets: wallets @@ -356,12 +361,10 @@ impl AppContext { .insert(identity_index, qualified_identity.identity.clone()); } - Ok(BackendTaskSuccessResult::Message( - "Successfully loaded identity".to_string(), - )) + Ok(BackendTaskSuccessResult::LoadedIdentity(qualified_identity)) } - fn match_user_identity_keys_with_wallet( + pub(super) fn match_user_identity_keys_with_wallet( &self, identity: &Identity, wallets: &BTreeMap>>, diff --git a/src/backend_task/identity/load_identity_by_dpns_name.rs b/src/backend_task/identity/load_identity_by_dpns_name.rs new file mode 100644 index 000000000..3c153f9fd --- /dev/null +++ b/src/backend_task/identity/load_identity_by_dpns_name.rs @@ -0,0 +1,180 @@ +use super::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::qualified_identity::{ + DPNSNameInfo, IdentityStatus, IdentityType, QualifiedIdentity, +}; +use crate::model::wallet::WalletSeedHash; +use dash_sdk::Sdk; +use dash_sdk::dpp::document::DocumentV0Getters; +use dash_sdk::dpp::platform_value::Value; +use dash_sdk::dpp::util::strings::convert_to_homograph_safe_chars; +use dash_sdk::drive::query::{WhereClause, WhereOperator}; +use dash_sdk::platform::{Document, DocumentQuery, Fetch, FetchMany, Identifier, Identity}; + +impl AppContext { + /// Load an identity by its DPNS name + pub(super) async fn load_identity_by_dpns_name( + &self, + sdk: &Sdk, + dpns_name: String, + selected_wallet_seed_hash: Option, + ) -> Result { + // Normalize the name (convert to lowercase and handle homoglyphs) + let normalized_name = convert_to_homograph_safe_chars(&dpns_name); + + // Query the DPNS contract for the domain document + let domain_query = DocumentQuery { + data_contract: self.dpns_contract.clone(), + document_type_name: "domain".to_string(), + where_clauses: vec![ + WhereClause { + field: "normalizedParentDomainName".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("dash".to_string()), + }, + WhereClause { + field: "normalizedLabel".to_string(), + operator: WhereOperator::Equal, + value: Value::Text(normalized_name.clone()), + }, + ], + order_by_clauses: vec![], + limit: 1, + start: None, + }; + + let documents = Document::fetch_many(sdk, domain_query) + .await + .map_err(|e| format!("Error querying DPNS: {}", e))?; + + // Get the first (and should be only) document + let domain_doc = documents + .values() + .filter_map(|maybe_doc| maybe_doc.as_ref()) + .next() + .ok_or_else(|| format!("No identity found with DPNS name '{}.dash'", dpns_name))?; + + // Extract the identity ID from the records.identity field + let identity_id = domain_doc + .get("records") + .and_then(|records| { + if let Value::Map(map) = records { + map.iter() + .find(|(k, _)| { + if let Value::Text(key) = k { + key == "identity" + } else { + false + } + }) + .map(|(_, v)| v.clone()) + } else { + None + } + }) + .and_then(|id_value| { + if let Value::Identifier(id_bytes) = id_value { + Some(Identifier::from(id_bytes)) + } else { + None + } + }) + .ok_or_else(|| { + "DPNS domain document does not contain a valid identity reference".to_string() + })?; + + // Fetch the identity + let identity = match Identity::fetch_by_identifier(sdk, identity_id).await { + Ok(Some(identity)) => identity, + Ok(None) => return Err("Identity referenced by DPNS name not found".to_string()), + Err(e) => return Err(format!("Error fetching identity: {}", e)), + }; + + // Get the label from the document for display + let label = domain_doc + .get("label") + .and_then(|l| l.to_str().ok()) + .unwrap_or(&dpns_name) + .to_string(); + + // Fetch all DPNS names owned by this identity + let dpns_names_document_query = DocumentQuery { + data_contract: self.dpns_contract.clone(), + document_type_name: "domain".to_string(), + where_clauses: vec![WhereClause { + field: "records.identity".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity_id.into()), + }], + order_by_clauses: vec![], + limit: 100, + start: None, + }; + + let owned_dpns_names = Document::fetch_many(sdk, dpns_names_document_query) + .await + .map(|document_map| { + document_map + .values() + .filter_map(|maybe_doc| { + maybe_doc.as_ref().and_then(|doc| { + let name = doc.get("label").map(|l| l.to_str().unwrap_or_default()); + let acquired_at = doc + .created_at() + .into_iter() + .chain(doc.transferred_at()) + .max(); + + match (name, acquired_at) { + (Some(name), Some(acquired_at)) => Some(DPNSNameInfo { + name: name.to_string(), + acquired_at, + }), + _ => None, + } + }) + }) + .collect::>() + }) + .map_err(|e| format!("Error fetching DPNS names: {}", e))?; + + let wallets = self.wallets.read().unwrap().clone(); + + // Try to derive keys from wallets if requested + let mut encrypted_private_keys = std::collections::BTreeMap::new(); + + if let Some((_, _, wallet_private_keys)) = self.match_user_identity_keys_with_wallet( + &identity, + &wallets, + selected_wallet_seed_hash, + )? { + encrypted_private_keys.extend(wallet_private_keys); + } + + let qualified_identity = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: Some(format!("{}.dash", label)), + private_keys: encrypted_private_keys.into(), + dpns_names: owned_dpns_names, + associated_wallets: wallets + .values() + .map(|wallet| (wallet.read().unwrap().seed_hash(), wallet.clone())) + .collect(), + wallet_index: None, + top_ups: Default::default(), + status: IdentityStatus::Active, + network: self.network, + }; + let wallet_info = qualified_identity.determine_wallet_info()?; + + // Insert qualified identity into the database + self.insert_local_qualified_identity(&qualified_identity, &wallet_info) + .map_err(|e| format!("Database error: {}", e))?; + + Ok(BackendTaskSuccessResult::LoadedIdentity(qualified_identity)) + } +} diff --git a/src/backend_task/identity/load_identity_from_wallet.rs b/src/backend_task/identity/load_identity_from_wallet.rs index 0acf3ae15..8ccc3ba4c 100644 --- a/src/backend_task/identity/load_identity_from_wallet.rs +++ b/src/backend_task/identity/load_identity_from_wallet.rs @@ -28,7 +28,7 @@ impl AppContext { sdk: &Sdk, wallet_arc_ref: WalletArcRef, identity_index: IdentityIndex, - sender: crate::utils::egui_mpsc::SenderAsync, + _sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { const AUTH_KEY_LOOKUP_WINDOW: u32 = 12; @@ -52,15 +52,8 @@ impl AppContext { after: None, }; - sender - .send(TaskResult::Success(Box::new( - BackendTaskSuccessResult::Message(format!( - "Searching for identity at index {} using key at index {}...", - identity_index, key_index - )), - ))) - .await - .map_err(|e| e.to_string())?; + // Only send detailed key index messages for single identity searches (not batch) + // The batch search (load_user_identities_up_to_index) sends its own simpler messages match Identity::fetch(sdk, query).await { Ok(Some(identity)) => { fetched_identity = Some(identity); @@ -287,9 +280,19 @@ impl AppContext { let wallet_ref = wallet_arc_ref; let mut loaded_indices = Vec::new(); - let mut missing_indices = Vec::new(); for identity_index in 0..=max_identity_index { + // Send progress update before starting search for this index + sender + .send(TaskResult::Success(Box::new( + BackendTaskSuccessResult::Message(format!( + "Searching index {} of {}...", + identity_index, max_identity_index + )), + ))) + .await + .map_err(|e| e.to_string())?; + match self .load_user_identity_from_wallet( sdk, @@ -301,29 +304,10 @@ impl AppContext { { Ok(_) => { loaded_indices.push(identity_index); - sender - .send(TaskResult::Success(Box::new( - BackendTaskSuccessResult::Message(format!( - "Loaded identity at index {}.", - identity_index - )), - ))) - .await - .map_err(|e| e.to_string())?; } Err(error) => { - if error.starts_with("No identity found for wallet identity index") { - missing_indices.push(identity_index); - sender - .send(TaskResult::Success(Box::new( - BackendTaskSuccessResult::Message(format!( - "No identity found at index {}.", - identity_index - )), - ))) - .await - .map_err(|e| e.to_string())?; - } else { + // Ignore "not found" errors - just means no identity at this index + if !error.starts_with("No identity found for wallet identity index") { return Err(error); } } @@ -337,33 +321,21 @@ impl AppContext { )); } - let summary = if missing_indices.is_empty() { + let summary = if loaded_indices.len() == 1 { format!( - "Successfully loaded {} identit{} up to index {}.", - loaded_indices.len(), - if loaded_indices.len() == 1 { - "y" - } else { - "ies" - }, - max_identity_index + "Successfully loaded 1 identity at index {}.", + loaded_indices[0] ) } else { - let missing_display = missing_indices + let loaded_display = loaded_indices .iter() .map(|idx| idx.to_string()) .collect::>() .join(", "); format!( - "Finished loading identities up to index {}. Loaded {} identit{}; no identity found at index(es): {}.", - max_identity_index, + "Successfully loaded {} identities at indexes {}.", loaded_indices.len(), - if loaded_indices.len() == 1 { - "y" - } else { - "ies" - }, - missing_display + loaded_display ) }; diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index e9611f899..c9ceb3e6a 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -1,5 +1,7 @@ mod add_key_to_identity; +mod discover_identities; mod load_identity; +mod load_identity_by_dpns_name; mod load_identity_from_wallet; mod refresh_identity; mod refresh_loaded_identities_dpns_names; @@ -9,7 +11,7 @@ mod top_up_identity; mod transfer; mod withdraw_from_identity; -use super::BackendTaskSuccessResult; +use super::{BackendTaskSuccessResult, FeeResult}; use crate::app::TaskResult; use crate::context::AppContext; use crate::model::qualified_identity::encrypted_key_storage::{KeyStorage, WalletDerivationPath}; @@ -24,8 +26,9 @@ use dash_sdk::dpp::balances::credits::Duffs; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::{OutPoint, Transaction}; use dash_sdk::dpp::fee::Credits; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dash_sdk::dpp::identity::{KeyID, KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; @@ -47,16 +50,21 @@ pub struct IdentityInputToLoad { pub selected_wallet_seed_hash: Option, } +/// A key input tuple containing the private key with derivation path, key type, purpose, +/// security level, and optional contract bounds. +pub type KeyInput = ( + (PrivateKey, DerivationPath), + KeyType, + Purpose, + SecurityLevel, + Option, +); + #[derive(Debug, Clone, PartialEq)] pub struct IdentityKeys { pub(crate) master_private_key: Option<(PrivateKey, DerivationPath)>, pub(crate) master_private_key_type: KeyType, - pub(crate) keys_input: Vec<( - (PrivateKey, DerivationPath), - KeyType, - Purpose, - SecurityLevel, - )>, + pub(crate) keys_input: Vec, } impl IdentityKeys { @@ -97,13 +105,22 @@ impl IdentityKeys { } key_map.extend(keys_input.iter().enumerate().map( - |(i, ((private_key, derivation_path), key_type, purpose, security_level))| { + |( + i, + ( + (private_key, derivation_path), + key_type, + purpose, + security_level, + contract_bounds, + ), + )| { let id = (i + 1) as KeyID; let identity_public_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { id, purpose: *purpose, security_level: *security_level, - contract_bounds: None, + contract_bounds: contract_bounds.clone(), key_type: *key_type, read_only: false, data: private_key.public_key(&secp).to_bytes().into(), @@ -163,7 +180,7 @@ impl IdentityKeys { key_map.insert(0, key); } key_map.extend(keys_input.iter().enumerate().map( - |(i, ((private_key, _), key_type, purpose, security_level))| { + |(i, ((private_key, _), key_type, purpose, security_level, contract_bounds))| { let id = (i + 1) as KeyID; let data = match key_type { KeyType::ECDSA_SECP256K1 => private_key.public_key(&secp).to_bytes().into(), @@ -179,7 +196,7 @@ impl IdentityKeys { id, purpose: *purpose, security_level: *security_level, - contract_bounds: None, + contract_bounds: contract_bounds.clone(), key_type: *key_type, read_only: false, data, @@ -200,6 +217,13 @@ pub enum RegisterIdentityFundingMethod { UseAssetLock(Address, Box, Box), FundWithUtxo(OutPoint, TxOut, Address, IdentityIndex), FundWithWallet(Duffs, IdentityIndex), + /// Fund identity creation from Platform addresses + FundWithPlatformAddresses { + /// Platform addresses and credits to use + inputs: BTreeMap, + /// Wallet seed hash for signing + wallet_seed_hash: WalletSeedHash, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -252,11 +276,30 @@ pub enum IdentityTask { #[allow(dead_code)] // May be used for finding identities in wallets SearchIdentityFromWallet(WalletArcRef, IdentityIndex), SearchIdentitiesUpToIndex(WalletArcRef, IdentityIndex), + /// Search for an identity by its DPNS name (without .dash suffix) + /// Second parameter is optional wallet seed hash for key derivation + SearchIdentityByDpnsName(String, Option), RegisterIdentity(IdentityRegistrationInfo), TopUpIdentity(IdentityTopUpInfo), + /// Top up an identity from Platform addresses + TopUpIdentityFromPlatformAddresses { + identity: QualifiedIdentity, + /// Platform addresses and amounts to use for top-up + inputs: BTreeMap, + /// Wallet seed hash for signing + wallet_seed_hash: WalletSeedHash, + }, AddKeyToIdentity(QualifiedIdentity, QualifiedIdentityPublicKey, [u8; 32]), WithdrawFromIdentity(QualifiedIdentity, Option
      , Credits, Option), Transfer(QualifiedIdentity, Identifier, Credits, Option), + /// Transfer credits from identity to Platform addresses + TransferToAddresses { + identity: QualifiedIdentity, + /// Platform addresses and amounts to receive credits + outputs: BTreeMap, + /// Key ID to use for signing (if any) + key_id: Option, + }, RegisterDpnsName(RegisterDpnsNameInput), RefreshIdentity(QualifiedIdentity), RefreshLoadedIdentitiesOwnedDPNSNames, @@ -474,10 +517,197 @@ impl AppContext { 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::TopUpIdentity(top_up_info) => self.top_up_identity(top_up_info).await, + IdentityTask::TopUpIdentityFromPlatformAddresses { + identity, + inputs, + wallet_seed_hash, + } => { + self.top_up_identity_from_platform_addresses( + sdk, + identity, + inputs, + wallet_seed_hash, + ) + .await + } + IdentityTask::TransferToAddresses { + identity, + outputs, + key_id, + } => { + self.transfer_to_addresses(sdk, identity, outputs, key_id) + .await + } IdentityTask::RefreshLoadedIdentitiesOwnedDPNSNames => { self.refresh_loaded_identities_dpns_names(sender).await } } } + + /// Top up an identity using credits from Platform addresses + async fn top_up_identity_from_platform_addresses( + &self, + sdk: &Sdk, + qualified_identity: QualifiedIdentity, + inputs: BTreeMap, + wallet_seed_hash: WalletSeedHash, + ) -> Result { + use crate::model::fee_estimation::PlatformFeeEstimator; + use dash_sdk::platform::transition::top_up_identity_from_addresses::TopUpIdentityFromAddresses; + + // Estimate fee for top-up from platform addresses + let estimated_fee = PlatformFeeEstimator::new().estimate_identity_topup(); + + tracing::info!( + "top_up_identity_from_platform_addresses: identity={}, inputs={:?}", + qualified_identity.identity.id(), + inputs + ); + + // Get the wallet for signing - clone it to avoid holding guard across await + let wallet_clone = { + let wallet = { + let wallets = self.wallets.read().unwrap(); + wallets + .get(&wallet_seed_hash) + .cloned() + .ok_or_else(|| "Wallet not found".to_string())? + }; + + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + + // Ensure wallet is open + if !wallet_guard.is_open() { + return Err("Wallet must be unlocked to sign Platform transactions".to_string()); + } + + wallet_guard.clone() + }; + + tracing::info!("Wallet loaded and open, calling top_up_from_addresses..."); + + // Get the identity + let identity = qualified_identity.identity.clone(); + + // 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) + })?; + + tracing::info!( + "top_up_from_addresses succeeded, new_balance={}", + new_balance + ); + + // Update source address balances using proof-verified data from SDK response + if let Err(e) = + self.update_wallet_platform_address_info_from_sdk(wallet_seed_hash, &address_infos) + { + tracing::warn!("Failed to update wallet platform address info: {}", e); + } + + // Update the identity balance in memory + let mut updated_identity = qualified_identity.clone(); + updated_identity.identity.set_balance(new_balance); + + // 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))?; + + let fee_result = FeeResult::new(estimated_fee, estimated_fee); + Ok(BackendTaskSuccessResult::ToppedUpIdentity( + updated_identity, + fee_result, + )) + } + + /// Transfer credits from an identity to Platform addresses + async fn transfer_to_addresses( + &self, + sdk: &Sdk, + qualified_identity: QualifiedIdentity, + outputs: BTreeMap, + key_id: Option, + ) -> Result { + use crate::model::fee_estimation::PlatformFeeEstimator; + use dash_sdk::platform::transition::transfer_to_addresses::TransferToAddresses; + + // Get the identity + let identity = qualified_identity.identity.clone(); + + // Get the signing key if specified + let signing_key = key_id.and_then(|id| identity.get_public_key_by_id(id)); + + // Track balance before transfer for fee calculation + let balance_before = identity.balance(); + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_credit_transfer_to_addresses(outputs.len()); + + // Execute the transfer - qualified_identity is consumed here as the signer + let (address_infos, new_balance) = identity + .transfer_credits_to_addresses( + sdk, + outputs.clone(), + signing_key, + &qualified_identity, + None, + ) + .await + .map_err(|e| format!("Failed to transfer credits to Platform addresses: {}", e))?; + + // Update destination address balances in any wallets that contain them + // (using proof-verified data from the SDK response) + { + let wallets = self.wallets.read().unwrap(); + for (seed_hash, wallet_arc) in wallets.iter() { + if let Err(e) = + self.update_wallet_platform_address_info_from_sdk(*seed_hash, &address_infos) + { + tracing::warn!("Failed to update wallet platform address info: {}", e); + } + // Break early since all wallets share the same network addresses + let _ = wallet_arc; // silence unused warning + } + } + + // Update the identity balance in memory + let mut updated_identity = qualified_identity; + updated_identity.identity.set_balance(new_balance); + + // Calculate actual fee + let total_outputs: Credits = outputs.values().sum(); + let actual_fee = balance_before + .saturating_sub(new_balance) + .saturating_sub(total_outputs); + + tracing::info!( + "Credit transfer to addresses complete: estimated fee {} credits, actual fee {} credits", + estimated_fee, + actual_fee + ); + if actual_fee != estimated_fee { + tracing::warn!( + "Fee mismatch: estimated {} vs actual {} (diff: {})", + estimated_fee, + actual_fee, + actual_fee as i64 - estimated_fee as i64 + ); + } + + // 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))?; + + 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 729520e87..ab52f2482 100644 --- a/src/backend_task/identity/refresh_identity.rs +++ b/src/backend_task/identity/refresh_identity.rs @@ -67,8 +67,8 @@ impl AppContext { .await .map_err(|e| e.to_string())?; - Ok(BackendTaskSuccessResult::Message( - "Successfully refreshed identity".to_string(), + Ok(BackendTaskSuccessResult::RefreshedIdentity( + qualified_identity, )) } } diff --git a/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs b/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs index c2f2188d3..2f480d085 100644 --- a/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs +++ b/src/backend_task/identity/refresh_loaded_identities_dpns_names.rs @@ -70,6 +70,12 @@ impl AppContext { qualified_identity.dpns_names = owned_dpns_names; + // If alias is not set and we have DPNS names, set alias to the first DPNS name + if qualified_identity.alias.is_none() && !qualified_identity.dpns_names.is_empty() { + let dpns_name = &qualified_identity.dpns_names[0].name; + qualified_identity.alias = Some(format!("{}.dash", dpns_name)); + } + // Update qualified identity in the database self.update_local_qualified_identity(&qualified_identity) .map_err(|e| format!("Error refreshing owned DPNS names: Database error: {}", e))?; @@ -82,8 +88,6 @@ impl AppContext { ) })?; - Ok(BackendTaskSuccessResult::Message( - "Successfully refreshed loaded identities dpns names".to_string(), - )) + Ok(BackendTaskSuccessResult::RefreshedOwnedDpnsNames) } } diff --git a/src/backend_task/identity/register_dpns_name.rs b/src/backend_task/identity/register_dpns_name.rs index 888b2ae25..1520aef09 100644 --- a/src/backend_task/identity/register_dpns_name.rs +++ b/src/backend_task/identity/register_dpns_name.rs @@ -1,5 +1,7 @@ use std::collections::BTreeMap; +use crate::backend_task::FeeResult; +use crate::model::fee_estimation::PlatformFeeEstimator; use crate::{context::AppContext, model::qualified_identity::DPNSNameInfo}; use bip39::rand::{Rng, SeedableRng, rngs::StdRng}; use dash_sdk::{ @@ -14,6 +16,7 @@ use dash_sdk::{ util::{hash::hash_double, strings::convert_to_homograph_safe_chars}, }, drive::query::{WhereClause, WhereOperator}, + platform::Fetch, platform::{Document, DocumentQuery, FetchMany, transition::put_document::PutDocument}, }; @@ -127,6 +130,13 @@ impl AppContext { .to_string(), )?; + // Estimate fees for DPNS registration (2 document batch transitions) + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_document_batch(2); + + // Track balance before registration + let balance_before = qualified_identity.identity.balance(); + let _ = preorder_document .put_to_platform_and_wait_for_response( sdk, @@ -204,12 +214,46 @@ impl AppContext { qualified_identity.dpns_names = owned_dpns_names; + // If alias is not set, set it to the newly registered DPNS name + if qualified_identity.alias.is_none() { + qualified_identity.alias = Some(format!("{}.dash", input.name_input)); + } + + // Calculate actual fee paid + // Note: We need to re-fetch the identity to get the updated balance + let refreshed_identity = dash_sdk::platform::Identity::fetch_by_identifier( + &sdk_guard, + qualified_identity.identity.id(), + ) + .await + .map_err(|e| format!("Failed to fetch identity balance: {}", e))? + .ok_or_else(|| "Identity not found".to_string())?; + + let balance_after = refreshed_identity.balance(); + let actual_fee = balance_before.saturating_sub(balance_after); + + tracing::info!( + "DPNS registration complete: estimated fee {} credits, actual fee {} credits", + estimated_fee, + actual_fee + ); + if actual_fee != estimated_fee { + tracing::warn!( + "Fee mismatch: estimated {} vs actual {} (diff: {})", + estimated_fee, + actual_fee, + actual_fee as i64 - estimated_fee as i64 + ); + } + + // Update qualified identity with new balance + qualified_identity.identity = refreshed_identity; + // Update local qualified identity in the database self.update_local_qualified_identity(&qualified_identity) .map_err(|e| format!("Database error: {}", e))?; - Ok(BackendTaskSuccessResult::Message( - "Successfully registered dpns name".to_string(), - )) + let fee_result = FeeResult::new(estimated_fee, actual_fee); + Ok(BackendTaskSuccessResult::RegisteredDpnsName(fee_result)) } } diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index a3505ecce..3674d1990 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -1,6 +1,8 @@ -use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::identity::{IdentityRegistrationInfo, RegisterIdentityFundingMethod}; +use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; +use crate::model::fee_estimation::PlatformFeeEstimator; +use crate::model::proof_log_item::{ProofLogItem, RequestType}; use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dpp::ProtocolError; @@ -69,11 +71,24 @@ impl AppContext { && raw_transaction_info.confirmations.is_some() && raw_transaction_info.confirmations.unwrap() > 8 { - // we should use a chain lock instead - AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: metadata.core_chain_locked_height, - out_point: OutPoint::new(tx_id, 0), - }) + // Transaction is old enough that instant lock may have expired + let tx_block_height = raw_transaction_info.height.unwrap() as u32; + + if tx_block_height <= metadata.core_chain_locked_height { + // Platform has verified this Core block, use chain lock proof + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: tx_block_height, + out_point: OutPoint::new(tx_id, 0), + }) + } else { + // Platform hasn't verified this Core block yet + return Err(format!( + "Cannot use this asset lock yet. The instant lock proof has expired (quorum rotated), \ + and Platform hasn't verified Core block {} yet (Platform has verified up to Core block {}). \ + Please wait for Platform to sync with Core chain.", + tx_block_height, metadata.core_chain_locked_height + )); + } } else { AssetLockProof::Instant(instant_asset_lock_proof.clone()) } @@ -130,6 +145,24 @@ impl AppContext { .send_raw_transaction(&asset_lock_transaction) .map_err(|e| e.to_string())?; + // Store the asset lock transaction in the database immediately after sending. + // This ensures it's tracked even if the proof times out or identity creation fails. + // SPV will update the instant_lock_data when it detects the transaction. + self.db + .store_asset_lock_transaction( + &asset_lock_transaction, + amount, + None, // No islock yet - SPV will update this + &wallet_id, + self.network, + ) + .map_err(|e| format!("Failed to store asset lock transaction: {}", e))?; + + // TODO: UTXO removal timing issue - UTXOs are removed here BEFORE the asset + // lock proof is confirmed below. If the transaction fails or times out after + // this point, the UTXOs will be "lost" from wallet tracking even though they + // weren't actually spent. This should be refactored to remove UTXOs only AFTER + // successful proof confirmation. See Phase 2.2 in PR review plan. { let mut wallet = wallet.write().unwrap(); wallet.utxos.retain(|_, utxo_map| { @@ -141,23 +174,67 @@ impl AppContext { .drop_utxo(utxo, &self.network.to_string()) .map_err(|e| e.to_string())?; } - } - let asset_lock_proof; + // Update address_balances for affected addresses + let affected_addresses: std::collections::BTreeSet<_> = + used_utxos.values().map(|(_, addr)| addr.clone()).collect(); + for address in affected_addresses { + // Recalculate balance from remaining UTXOs for this address + let new_balance = wallet + .utxos + .get(&address) + .map(|utxo_map| utxo_map.values().map(|tx_out| tx_out.value).sum()) + .unwrap_or(0); + let _ = wallet.update_address_balance(&address, new_balance, self); + } + } - loop { - { - let proofs = self.transactions_waiting_for_finality.lock().unwrap(); - if let Some(Some(proof)) = proofs.get(&tx_id) { - asset_lock_proof = proof.clone(); - break; + // Wait for asset lock proof with timeout (2 minutes) + const ASSET_LOCK_PROOF_TIMEOUT: Duration = Duration::from_secs(120); + let asset_lock_proof = match tokio::time::timeout(ASSET_LOCK_PROOF_TIMEOUT, async { + loop { + { + let proofs = self.transactions_waiting_for_finality.lock().unwrap(); + if let Some(Some(proof)) = proofs.get(&tx_id) { + return proof.clone(); + } } + tokio::time::sleep(Duration::from_millis(200)).await; } - tokio::time::sleep(Duration::from_millis(200)).await; - } + }) + .await + { + Ok(proof) => proof, + Err(_) => { + // Clean up on timeout + let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); + proofs.remove(&tx_id); + return Err(format!( + "Timeout waiting for asset lock proof after {} seconds. \ + The transaction may not have been confirmed by the network.", + ASSET_LOCK_PROOF_TIMEOUT.as_secs() + )); + } + }; (asset_lock_proof, asset_lock_proof_private_key, tx_id) } + RegisterIdentityFundingMethod::FundWithPlatformAddresses { + inputs, + wallet_seed_hash, + } => { + // This is a separate flow - we call a dedicated function for Platform address funding + return self + .register_identity_from_platform_addresses( + alias_input, + keys, + wallet, + wallet_identity_index, + inputs, + wallet_seed_hash, + ) + .await; + } RegisterIdentityFundingMethod::FundWithUtxo( utxo, tx_out, @@ -191,6 +268,20 @@ impl AppContext { .send_raw_transaction(&asset_lock_transaction) .map_err(|e| e.to_string())?; + // Store the asset lock transaction in the database immediately after sending. + // This ensures it's tracked even if the proof times out or identity creation fails. + // SPV will update the instant_lock_data when it detects the transaction. + self.db + .store_asset_lock_transaction( + &asset_lock_transaction, + tx_out.value, + None, // No islock yet - SPV will update this + &wallet_id, + self.network, + ) + .map_err(|e| format!("Failed to store asset lock transaction: {}", e))?; + + // TODO: UTXO removal timing issue - see comment above for FundWithWallet case. { let mut wallet = wallet.write().unwrap(); wallet.utxos.retain(|_, utxo_map| { @@ -200,20 +291,43 @@ impl AppContext { self.db .drop_utxo(&utxo, &self.network.to_string()) .map_err(|e| e.to_string())?; - } - let asset_lock_proof; + // Update address_balance for the affected address + let new_balance = wallet + .utxos + .get(&input_address) + .map(|utxo_map| utxo_map.values().map(|tx_out| tx_out.value).sum()) + .unwrap_or(0); + let _ = wallet.update_address_balance(&input_address, new_balance, self); + } - loop { - { - let proofs = self.transactions_waiting_for_finality.lock().unwrap(); - if let Some(Some(proof)) = proofs.get(&tx_id) { - asset_lock_proof = proof.clone(); - break; + // Wait for asset lock proof with timeout (2 minutes) + const ASSET_LOCK_PROOF_TIMEOUT: Duration = Duration::from_secs(120); + let asset_lock_proof = match tokio::time::timeout(ASSET_LOCK_PROOF_TIMEOUT, async { + loop { + { + let proofs = self.transactions_waiting_for_finality.lock().unwrap(); + if let Some(Some(proof)) = proofs.get(&tx_id) { + return proof.clone(); + } } + tokio::time::sleep(Duration::from_millis(200)).await; } - tokio::time::sleep(Duration::from_millis(200)).await; - } + }) + .await + { + Ok(proof) => proof, + Err(_) => { + // Clean up on timeout + let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); + proofs.remove(&tx_id); + return Err(format!( + "Timeout waiting for asset lock proof after {} seconds. \ + The transaction may not have been confirmed by the network.", + ASSET_LOCK_PROOF_TIMEOUT.as_secs() + )); + } + }; (asset_lock_proof, asset_lock_proof_private_key, tx_id) } @@ -225,6 +339,26 @@ impl AppContext { let public_keys = keys.to_public_keys_map(); + // Debug: Log the keys being registered to verify contract bounds are set + for (key_id, key) in &public_keys { + match key { + dash_sdk::dpp::identity::IdentityPublicKey::V0(key_v0) => { + tracing::info!( + "Identity key {}: purpose={:?}, security_level={:?}, key_type={:?}, contract_bounds={:?}", + key_id, + key_v0.purpose, + key_v0.security_level, + key_v0.key_type, + key_v0.contract_bounds + ); + } + } + } + + // Calculate fee estimate for identity creation + let key_count = public_keys.len(); + let estimated_fee = PlatformFeeEstimator::new().estimate_identity_create(key_count); + let existing_identity = match Identity::fetch_by_identifier(&sdk, identity_id).await { Ok(result) => result, Err(e) => return Err(format!("Error fetching identity: {}", e)), @@ -283,8 +417,10 @@ impl AppContext { .set_asset_lock_identity_id(tx_id.as_byte_array(), identity_id.as_bytes()) .map_err(|e| e.to_string())?; + let fee_result = FeeResult::new(estimated_fee, estimated_fee); return Ok(BackendTaskSuccessResult::RegisteredIdentity( qualified_identity, + fee_result, )); } @@ -304,7 +440,7 @@ impl AppContext { .put_new_identity_to_platform( &sdk, &identity, - asset_lock_proof, + asset_lock_proof.clone(), &asset_lock_proof_private_key, qualified_identity.clone(), ) @@ -315,18 +451,104 @@ impl AppContext { qualified_identity.status = IdentityStatus::Unknown; // force refresh of the status } Err(e) => { - // we failed, set the status accordingly and terminate the process - qualified_identity - .status - .update(IdentityStatus::FailedCreation); + // Check if this is an instant lock proof expiration error + if e.contains("Instant lock proof signature is invalid") + || e.contains("wasn't created recently") + { + // Try to use chain asset lock proof instead + let raw_transaction_info = self + .core_client + .read() + .expect("Core client lock was poisoned") + .get_raw_transaction_info(&tx_id, None) + .map_err(|e| e.to_string())?; - self.insert_local_qualified_identity( - &qualified_identity, - &Some((wallet_id, wallet_identity_index)), - ) - .map_err(|e| e.to_string())?; + if raw_transaction_info.chainlock && raw_transaction_info.height.is_some() { + let tx_block_height = raw_transaction_info.height.unwrap() as u32; + + if tx_block_height <= metadata.core_chain_locked_height { + // Platform has verified this Core block, use chain lock proof + let chain_asset_lock_proof = + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: tx_block_height, + out_point: OutPoint::new(tx_id, 0), + }); + + // Retry with chain asset lock proof + match self + .put_new_identity_to_platform( + &sdk, + &identity, + chain_asset_lock_proof, + &asset_lock_proof_private_key, + qualified_identity.clone(), + ) + .await + { + Ok(updated_identity) => { + qualified_identity.identity = updated_identity; + qualified_identity.status = IdentityStatus::Unknown; + } + Err(retry_err) => { + qualified_identity + .status + .update(IdentityStatus::FailedCreation); + + self.insert_local_qualified_identity( + &qualified_identity, + &Some((wallet_id, wallet_identity_index)), + ) + .map_err(|e| e.to_string())?; + + return Err(retry_err); + } + } + } else { + qualified_identity + .status + .update(IdentityStatus::FailedCreation); + + self.insert_local_qualified_identity( + &qualified_identity, + &Some((wallet_id, wallet_identity_index)), + ) + .map_err(|e| e.to_string())?; - return Err(e); + return Err(format!( + "Cannot use this asset lock yet. The instant lock proof has expired (quorum rotated), \ + and Platform hasn't verified Core block {} yet (Platform has verified up to Core block {}). \ + Please wait for Platform to sync with Core chain.", + tx_block_height, metadata.core_chain_locked_height + )); + } + } else { + qualified_identity + .status + .update(IdentityStatus::FailedCreation); + + self.insert_local_qualified_identity( + &qualified_identity, + &Some((wallet_id, wallet_identity_index)), + ) + .map_err(|e| e.to_string())?; + + return Err("Cannot use this asset lock. The instant lock proof has expired and the transaction \ + is not yet chainlocked. Please wait for the transaction to be chainlocked.".to_string()); + } + } else { + // we failed, set the status accordingly and terminate the process + qualified_identity + .status + .update(IdentityStatus::FailedCreation); + + self.insert_local_qualified_identity( + &qualified_identity, + &Some((wallet_id, wallet_identity_index)), + ) + .map_err(|e| e.to_string())?; + + return Err(e); + } } } @@ -347,8 +569,10 @@ impl AppContext { .set_asset_lock_identity_id(tx_id.as_byte_array(), identity_id.as_bytes()) .map_err(|e| e.to_string())?; + let fee_result = FeeResult::new(estimated_fee, estimated_fee); Ok(BackendTaskSuccessResult::RegisteredIdentity( qualified_identity, + fee_result, )) } @@ -372,6 +596,26 @@ impl AppContext { { Ok(updated_identity) => Ok(updated_identity), Err(e) => { + // Log proof errors first + if let Error::DriveProofError(ref proof_error, ref proof_bytes, ref block_info) = e + { + if let Err(e) = self.db.insert_proof_log_item(ProofLogItem { + request_type: RequestType::BroadcastStateTransition, + request_bytes: vec![], + verification_path_query_bytes: vec![], + height: block_info.height, + time_ms: block_info.time_ms, + proof_bytes: proof_bytes.clone(), + error: Some(proof_error.to_string()), + }) { + tracing::warn!("Failed to persist proof log: {}", e); + } + return Err(format!( + "Error registering identity: {}, proof error logged", + proof_error + )); + } + if matches!(e, Error::Protocol(ProtocolError::UnknownVersionError(_))) { identity .put_to_platform_and_wait_for_response( @@ -383,6 +627,30 @@ impl AppContext { ) .await .map_err(|e| { + // Log proof errors from retry + if let Error::DriveProofError( + ref proof_error, + ref proof_bytes, + ref block_info, + ) = e + { + if let Err(e) = self.db.insert_proof_log_item(ProofLogItem { + request_type: RequestType::BroadcastStateTransition, + request_bytes: vec![], + verification_path_query_bytes: vec![], + height: block_info.height, + time_ms: block_info.time_ms, + proof_bytes: proof_bytes.clone(), + error: Some(proof_error.to_string()), + }) { + tracing::warn!("Failed to persist proof log: {}", e); + } + return format!( + "Error registering identity: {}, proof error logged", + proof_error + ); + } + let identity_create_transition = IdentityCreateTransition::try_from_identity_with_signer( identity, @@ -405,4 +673,156 @@ impl AppContext { } } } + + /// Register a new identity funded by Platform addresses + async fn register_identity_from_platform_addresses( + &self, + alias_input: String, + keys: super::IdentityKeys, + wallet: std::sync::Arc>, + wallet_identity_index: u32, + inputs: BTreeMap< + dash_sdk::dpp::address_funds::PlatformAddress, + dash_sdk::dpp::fee::Credits, + >, + wallet_seed_hash: super::WalletSeedHash, + ) -> Result { + use dash_sdk::platform::transition::put_identity::PutIdentity; + + let sdk = { + let guard = self.sdk.read().unwrap(); + guard.clone() + }; + + let public_keys = keys.to_public_keys_map(); + + // Calculate fee estimate for identity creation from platform addresses + let key_count = public_keys.len(); + let input_count = inputs.len(); + let estimated_fee = PlatformFeeEstimator::new().estimate_identity_create_from_addresses( + input_count, + false, + key_count, + ); + + // Clone the wallet for use as the address signer (needed across async boundary) + let wallet_clone = { wallet.read().map_err(|e| e.to_string())?.clone() }; + + // For Platform address funding, we need to compute the identity ID from the inputs + // The SDK will handle this internally when creating the identity + // We create a temporary identity with a placeholder ID, which will be computed correctly + // during the state transition creation + + // Create a temporary identity ID - will be replaced by the actual one from Platform + let temp_identity_id = dash_sdk::platform::Identifier::random(); + + let identity = + Identity::new_with_id_and_keys(temp_identity_id, public_keys.clone(), sdk.version()) + .map_err(|e| format!("Failed to create identity: {}", e))?; + + let wallet_seed_hash_actual = { wallet.read().unwrap().seed_hash() }; + let mut qualified_identity = QualifiedIdentity { + identity: identity.clone(), + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: keys.to_key_storage(wallet_seed_hash_actual), + dpns_names: vec![], + associated_wallets: BTreeMap::from([(wallet_seed_hash_actual, wallet.clone())]), + wallet_index: Some(wallet_identity_index), + top_ups: Default::default(), + status: IdentityStatus::PendingCreation, + network: self.network, + }; + + if !alias_input.is_empty() { + qualified_identity.alias = Some(alias_input); + } + + // Send to Platform using address funding and wait for response + match identity + .put_with_address_funding(&sdk, inputs, None, &qualified_identity, &wallet_clone, None) + .await + { + Ok((updated_identity, address_infos)) => { + qualified_identity.identity = updated_identity; + qualified_identity.status = IdentityStatus::Unknown; // Force refresh + + // Update source address balances using proof-verified data from SDK response + if let Err(e) = self + .update_wallet_platform_address_info_from_sdk(wallet_seed_hash, &address_infos) + { + tracing::warn!("Failed to update wallet platform address info: {}", e); + } + + self.insert_local_qualified_identity( + &qualified_identity, + &Some((wallet_seed_hash, wallet_identity_index)), + ) + .map_err(|e| e.to_string())?; + + { + let mut wallet_guard = wallet.write().unwrap(); + wallet_guard + .identities + .insert(wallet_identity_index, qualified_identity.identity.clone()); + } + + let fee_result = FeeResult::new(estimated_fee, estimated_fee); + Ok(BackendTaskSuccessResult::RegisteredIdentity( + qualified_identity, + fee_result, + )) + } + Err(e) => { + // Log proof errors + if let Error::DriveProofError(ref proof_error, ref proof_bytes, ref block_info) = e + { + if let Err(e) = self.db.insert_proof_log_item(ProofLogItem { + request_type: RequestType::BroadcastStateTransition, + request_bytes: vec![], + verification_path_query_bytes: vec![], + height: block_info.height, + time_ms: block_info.time_ms, + proof_bytes: proof_bytes.clone(), + error: Some(proof_error.to_string()), + }) { + tracing::warn!("Failed to persist proof log: {}", e); + } + + qualified_identity + .status + .update(IdentityStatus::FailedCreation); + + self.insert_local_qualified_identity( + &qualified_identity, + &Some((wallet_seed_hash, wallet_identity_index)), + ) + .map_err(|e| e.to_string())?; + + return Err(format!( + "Failed to create identity from Platform addresses: {}, proof error logged", + proof_error + )); + } + + qualified_identity + .status + .update(IdentityStatus::FailedCreation); + + self.insert_local_qualified_identity( + &qualified_identity, + &Some((wallet_seed_hash, wallet_identity_index)), + ) + .map_err(|e| e.to_string())?; + + Err(format!( + "Failed to create identity from Platform addresses: {}", + e + )) + } + } + } } diff --git a/src/backend_task/identity/top_up_identity.rs b/src/backend_task/identity/top_up_identity.rs index 3743b4d47..6e068f43b 100644 --- a/src/backend_task/identity/top_up_identity.rs +++ b/src/backend_task/identity/top_up_identity.rs @@ -1,6 +1,8 @@ -use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::identity::{IdentityTopUpInfo, TopUpIdentityFundingMethod}; +use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; +use crate::model::fee_estimation::PlatformFeeEstimator; +use crate::model::proof_log_item::{ProofLogItem, RequestType}; use dash_sdk::Error; use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dpp::ProtocolError; @@ -50,34 +52,47 @@ impl AppContext { let private_key = wallet .private_key_for_address(&address, self.network)? .ok_or("Asset Lock not valid for wallet")?; - let asset_lock_proof = - if let AssetLockProof::Instant(instant_asset_lock_proof) = - asset_lock_proof.as_ref() + let asset_lock_proof = if let AssetLockProof::Instant( + instant_asset_lock_proof, + ) = asset_lock_proof.as_ref() + { + // we need to make sure the instant send asset lock is recent + let raw_transaction_info = self + .core_client + .read() + .expect("Core client lock was poisoned") + .get_raw_transaction_info(&tx_id, None) + .map_err(|e| e.to_string())?; + + if raw_transaction_info.chainlock + && raw_transaction_info.height.is_some() + && raw_transaction_info.confirmations.is_some() + && raw_transaction_info.confirmations.unwrap() > 8 { - // we need to make sure the instant send asset lock is recent - let raw_transaction_info = self - .core_client - .read() - .expect("Core client lock was poisoned") - .get_raw_transaction_info(&tx_id, None) - .map_err(|e| e.to_string())?; + // Transaction is old enough that instant lock may have expired + let tx_block_height = raw_transaction_info.height.unwrap() as u32; - if raw_transaction_info.chainlock - && raw_transaction_info.height.is_some() - && raw_transaction_info.confirmations.is_some() - && raw_transaction_info.confirmations.unwrap() > 8 - { - // we should use a chain lock instead + if tx_block_height <= metadata.core_chain_locked_height { + // Platform has verified this Core block, use chain lock proof AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: metadata.core_chain_locked_height, + core_chain_locked_height: tx_block_height, out_point: OutPoint::new(tx_id, 0), }) } else { - AssetLockProof::Instant(instant_asset_lock_proof.clone()) + // Platform hasn't verified this Core block yet + return Err(format!( + "Cannot use this asset lock yet. The instant lock proof has expired (quorum rotated), \ + and Platform hasn't verified Core block {} yet (Platform has verified up to Core block {}). \ + Please wait for Platform to sync with Core chain.", + tx_block_height, metadata.core_chain_locked_height + )); } } else { - asset_lock_proof.as_ref().clone() - }; + AssetLockProof::Instant(instant_asset_lock_proof.clone()) + } + } else { + asset_lock_proof.as_ref().clone() + }; (asset_lock_proof, private_key, tx_id, None) } TopUpIdentityFundingMethod::FundWithWallet( @@ -86,9 +101,16 @@ impl AppContext { top_up_index, ) => { // Scope the write lock to avoid holding it across an await. - let (asset_lock_transaction, asset_lock_proof_private_key, _, used_utxos) = { + let ( + asset_lock_transaction, + asset_lock_proof_private_key, + _, + used_utxos, + wallet_seed_hash, + ) = { let mut wallet = wallet.write().unwrap(); - match wallet.top_up_asset_lock_transaction( + let seed_hash = wallet.seed_hash(); + let tx_result = match wallet.top_up_asset_lock_transaction( sdk.network, amount, true, @@ -117,7 +139,14 @@ impl AppContext { Some(self), )? } - } + }; + ( + tx_result.0, + tx_result.1, + tx_result.2, + tx_result.3, + seed_hash, + ) }; let tx_id = asset_lock_transaction.txid(); @@ -139,6 +168,19 @@ impl AppContext { .send_raw_transaction(&asset_lock_transaction) .map_err(|e| e.to_string())?; + // Store the asset lock transaction in the database immediately after sending. + // This ensures it's tracked even if the proof times out or top-up fails. + // SPV will update the instant_lock_data when it detects the transaction. + self.db + .store_asset_lock_transaction( + &asset_lock_transaction, + amount, + None, // No islock yet - SPV will update this + &wallet_seed_hash, + self.network, + ) + .map_err(|e| format!("Failed to store asset lock transaction: {}", e))?; + { let mut wallet = wallet.write().unwrap(); wallet.utxos.retain(|_, utxo_map| { @@ -150,20 +192,51 @@ impl AppContext { .drop_utxo(utxo, &self.network.to_string()) .map_err(|e| e.to_string())?; } - } - let asset_lock_proof; + // Update address_balances for affected addresses + let affected_addresses: std::collections::BTreeSet<_> = + used_utxos.values().map(|(_, addr)| addr.clone()).collect(); + for address in affected_addresses { + // Recalculate balance from remaining UTXOs for this address + let new_balance = wallet + .utxos + .get(&address) + .map(|utxo_map| utxo_map.values().map(|tx_out| tx_out.value).sum()) + .unwrap_or(0); + let _ = wallet.update_address_balance(&address, new_balance, self); + } + } - loop { + // Wait for asset lock proof with timeout (2 minutes) + const ASSET_LOCK_PROOF_TIMEOUT: Duration = Duration::from_secs(120); + let asset_lock_proof = + match tokio::time::timeout(ASSET_LOCK_PROOF_TIMEOUT, async { + loop { + { + let proofs = + self.transactions_waiting_for_finality.lock().unwrap(); + if let Some(Some(proof)) = proofs.get(&tx_id) { + return proof.clone(); + } + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + }) + .await { - let proofs = self.transactions_waiting_for_finality.lock().unwrap(); - if let Some(Some(proof)) = proofs.get(&tx_id) { - asset_lock_proof = proof.clone(); - break; + Ok(proof) => proof, + Err(_) => { + // Clean up on timeout + let mut proofs = + self.transactions_waiting_for_finality.lock().unwrap(); + proofs.remove(&tx_id); + return Err(format!( + "Timeout waiting for asset lock proof after {} seconds. \ + The transaction may not have been confirmed by the network.", + ASSET_LOCK_PROOF_TIMEOUT.as_secs() + )); } - } - tokio::time::sleep(Duration::from_millis(200)).await; - } + }; ( asset_lock_proof, @@ -180,9 +253,10 @@ impl AppContext { top_up_index, ) => { // Scope the write lock to avoid holding it across an await. - let (asset_lock_transaction, asset_lock_proof_private_key) = { + let (asset_lock_transaction, asset_lock_proof_private_key, wallet_seed_hash) = { let mut wallet = wallet.write().unwrap(); - wallet.top_up_asset_lock_transaction_for_utxo( + let seed_hash = wallet.seed_hash(); + let tx_result = wallet.top_up_asset_lock_transaction_for_utxo( sdk.network, utxo, tx_out.clone(), @@ -190,7 +264,8 @@ impl AppContext { identity_index, top_up_index, Some(self), - )? + )?; + (tx_result.0, tx_result.1, seed_hash) }; let tx_id = asset_lock_transaction.txid(); @@ -212,6 +287,19 @@ impl AppContext { .send_raw_transaction(&asset_lock_transaction) .map_err(|e| e.to_string())?; + // Store the asset lock transaction in the database immediately after sending. + // This ensures it's tracked even if the proof times out or top-up fails. + // SPV will update the instant_lock_data when it detects the transaction. + self.db + .store_asset_lock_transaction( + &asset_lock_transaction, + tx_out.value, + None, // No islock yet - SPV will update this + &wallet_seed_hash, + self.network, + ) + .map_err(|e| format!("Failed to store asset lock transaction: {}", e))?; + { let mut wallet = wallet.write().unwrap(); wallet.utxos.retain(|_, utxo_map| { @@ -221,20 +309,46 @@ impl AppContext { self.db .drop_utxo(&utxo, &self.network.to_string()) .map_err(|e| e.to_string())?; - } - let asset_lock_proof; + // Update address_balance for the affected address + let new_balance = wallet + .utxos + .get(&input_address) + .map(|utxo_map| utxo_map.values().map(|tx_out| tx_out.value).sum()) + .unwrap_or(0); + let _ = wallet.update_address_balance(&input_address, new_balance, self); + } - loop { + // Wait for asset lock proof with timeout (2 minutes) + const ASSET_LOCK_PROOF_TIMEOUT: Duration = Duration::from_secs(120); + let asset_lock_proof = + match tokio::time::timeout(ASSET_LOCK_PROOF_TIMEOUT, async { + loop { + { + let proofs = + self.transactions_waiting_for_finality.lock().unwrap(); + if let Some(Some(proof)) = proofs.get(&tx_id) { + return proof.clone(); + } + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + }) + .await { - let proofs = self.transactions_waiting_for_finality.lock().unwrap(); - if let Some(Some(proof)) = proofs.get(&tx_id) { - asset_lock_proof = proof.clone(); - break; + Ok(proof) => proof, + Err(_) => { + // Clean up on timeout + let mut proofs = + self.transactions_waiting_for_finality.lock().unwrap(); + proofs.remove(&tx_id); + return Err(format!( + "Timeout waiting for asset lock proof after {} seconds. \ + The transaction may not have been confirmed by the network.", + ASSET_LOCK_PROOF_TIMEOUT.as_secs() + )); } - } - tokio::time::sleep(Duration::from_millis(200)).await; - } + }; ( asset_lock_proof, @@ -252,6 +366,10 @@ impl AppContext { ) .map_err(|e| e.to_string())?; + // Track balance before top-up for fee calculation + let balance_before = qualified_identity.identity.balance(); + let estimated_fee = PlatformFeeEstimator::new().estimate_identity_topup(); + let updated_identity_balance = match qualified_identity .identity .top_up_identity( @@ -265,7 +383,103 @@ impl AppContext { { Ok(updated_identity) => updated_identity, Err(e) => { - if matches!(e, Error::Protocol(ProtocolError::UnknownVersionError(_))) { + // Log proof errors first + if let Error::DriveProofError(ref proof_error, ref proof_bytes, ref block_info) = e + { + if let Err(e) = self.db.insert_proof_log_item(ProofLogItem { + request_type: RequestType::BroadcastStateTransition, + request_bytes: vec![], + verification_path_query_bytes: vec![], + height: block_info.height, + time_ms: block_info.time_ms, + proof_bytes: proof_bytes.clone(), + error: Some(proof_error.to_string()), + }) { + tracing::warn!("Failed to persist proof log: {}", e); + } + return Err(format!( + "Error topping up identity: {}, proof error logged", + proof_error + )); + } + + let error_string = e.to_string(); + + // Check if this is an instant lock proof expiration error + if error_string.contains("Instant lock proof signature is invalid") + || error_string.contains("wasn't created recently") + { + // Try to use chain asset lock proof instead + let raw_transaction_info = self + .core_client + .read() + .expect("Core client lock was poisoned") + .get_raw_transaction_info(&tx_id, None) + .map_err(|e| e.to_string())?; + + if raw_transaction_info.chainlock && raw_transaction_info.height.is_some() { + let tx_block_height = raw_transaction_info.height.unwrap() as u32; + + if tx_block_height <= metadata.core_chain_locked_height { + // Platform has verified this Core block, use chain lock proof + let chain_asset_lock_proof = + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: tx_block_height, + out_point: OutPoint::new(tx_id, 0), + }); + + // Retry with chain asset lock proof + qualified_identity + .identity + .top_up_identity( + &sdk, + chain_asset_lock_proof, + &asset_lock_proof_private_key, + None, + None, + ) + .await + .map_err(|e| { + // Log proof errors from retry + if let Error::DriveProofError( + ref proof_error, + ref proof_bytes, + ref block_info, + ) = e + { + if let Err(e) = + self.db.insert_proof_log_item(ProofLogItem { + request_type: RequestType::BroadcastStateTransition, + request_bytes: vec![], + verification_path_query_bytes: vec![], + height: block_info.height, + time_ms: block_info.time_ms, + proof_bytes: proof_bytes.clone(), + error: Some(proof_error.to_string()), + }) + { + tracing::warn!("Failed to persist proof log: {}", e); + } + return format!( + "Error topping up identity: {}, proof error logged", + proof_error + ); + } + e.to_string() + })? + } else { + return Err(format!( + "Cannot use this asset lock yet. The instant lock proof has expired (quorum rotated), \ + and Platform hasn't verified Core block {} yet (Platform has verified up to Core block {}). \ + Please wait for Platform to sync with Core chain.", + tx_block_height, metadata.core_chain_locked_height + )); + } + } else { + return Err("Cannot use this asset lock. The instant lock proof has expired and the transaction \ + is not yet chainlocked. Please wait for the transaction to be chainlocked.".to_string()); + } + } else if matches!(e, Error::Protocol(ProtocolError::UnknownVersionError(_))) { qualified_identity .identity .top_up_identity( @@ -277,6 +491,30 @@ impl AppContext { ) .await .map_err(|e| { + // Log proof errors from retry + if let Error::DriveProofError( + ref proof_error, + ref proof_bytes, + ref block_info, + ) = e + { + if let Err(e) = self.db.insert_proof_log_item(ProofLogItem { + request_type: RequestType::BroadcastStateTransition, + request_bytes: vec![], + verification_path_query_bytes: vec![], + height: block_info.height, + time_ms: block_info.time_ms, + proof_bytes: proof_bytes.clone(), + error: Some(proof_error.to_string()), + }) { + tracing::warn!("Failed to persist proof log: {}", e); + } + return format!( + "Error topping up identity: {}, proof error logged", + proof_error + ); + } + let identity_create_transition = IdentityTopUpTransition::try_from_identity( &qualified_identity.identity, @@ -293,7 +531,7 @@ impl AppContext { ) })? } else { - return Err(e.to_string()); + return Err(error_string); } } }; @@ -302,6 +540,43 @@ impl AppContext { .identity .set_balance(updated_identity_balance); + // Calculate and log actual fee paid + // For top-ups, the "fee" is the difference between expected new balance and actual + let expected_credits_from_topup = if let Some((amount, _)) = top_up_index { + // amount is in duffs, 1 duff = 1000 credits + amount * 1000 + } else { + // For asset lock method, calculate from the asset lock amount + 0 // Can't easily determine without more info + }; + + if expected_credits_from_topup > 0 { + let balance_increase = updated_identity_balance.saturating_sub(balance_before); + let actual_fee = expected_credits_from_topup.saturating_sub(balance_increase); + tracing::info!( + "Identity top-up complete: topped up {} credits (from {} duffs), estimated fee {} credits, actual fee {} credits, balance increased by {} credits", + expected_credits_from_topup, + expected_credits_from_topup / 1000, + estimated_fee, + actual_fee, + balance_increase + ); + if actual_fee != estimated_fee { + tracing::warn!( + "Top-up fee mismatch: estimated {} vs actual {} (diff: {})", + estimated_fee, + actual_fee, + actual_fee as i64 - estimated_fee as i64 + ); + } + } else { + tracing::info!( + "Identity top-up complete: balance before {} credits, balance after {} credits", + balance_before, + updated_identity_balance + ); + } + self.update_local_qualified_identity(&qualified_identity) .map_err(|e| e.to_string())?; @@ -329,8 +604,18 @@ impl AppContext { .map_err(|e| e.to_string())?; } + // Calculate actual fee for the FeeResult + let actual_fee = if expected_credits_from_topup > 0 { + let balance_increase = updated_identity_balance.saturating_sub(balance_before); + expected_credits_from_topup.saturating_sub(balance_increase) + } else { + estimated_fee // Fall back to estimated when we can't calculate actual + }; + let fee_result = FeeResult::new(estimated_fee, actual_fee); + Ok(BackendTaskSuccessResult::ToppedUpIdentity( qualified_identity, + fee_result, )) } } diff --git a/src/backend_task/identity/transfer.rs b/src/backend_task/identity/transfer.rs index 985efc52d..84c58af0b 100644 --- a/src/backend_task/identity/transfer.rs +++ b/src/backend_task/identity/transfer.rs @@ -1,4 +1,6 @@ +use crate::backend_task::FeeResult; use crate::context::AppContext; +use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::KeyID; @@ -21,6 +23,10 @@ impl AppContext { guard.clone() }; + // Track balance before transfer for fee calculation + let balance_before = qualified_identity.identity.balance(); + let estimated_fee = PlatformFeeEstimator::new().estimate_credit_transfer(); + let (sender_balance, receiver_balance) = qualified_identity .identity .clone() @@ -34,6 +40,26 @@ impl AppContext { ) .await .map_err(|e| format!("Transfer error: {}", e))?; + + // Calculate and log actual fee paid + let actual_fee = balance_before + .saturating_sub(sender_balance) + .saturating_sub(credits); + tracing::info!( + "Credit transfer complete: sent {} credits, estimated fee {} credits, actual fee {} credits", + credits, + estimated_fee, + actual_fee + ); + if actual_fee != estimated_fee { + tracing::warn!( + "Fee mismatch: estimated {} vs actual {} (diff: {})", + estimated_fee, + actual_fee, + actual_fee as i64 - estimated_fee as i64 + ); + } + qualified_identity.identity.set_balance(sender_balance); // If the receiver is a local qualified identity, update its balance too @@ -48,10 +74,10 @@ impl AppContext { .map_err(|e| format!("Transfer error: {}", e))?; } + let fee_result = FeeResult::new(estimated_fee, actual_fee); + self.update_local_qualified_identity(&qualified_identity) - .map(|_| { - BackendTaskSuccessResult::Message("Successfully transferred credits".to_string()) - }) + .map(|_| BackendTaskSuccessResult::TransferredCredits(fee_result)) .map_err(|e| e.to_string()) } } diff --git a/src/backend_task/identity/withdraw_from_identity.rs b/src/backend_task/identity/withdraw_from_identity.rs index 0ec33ab4a..1370e3ae5 100644 --- a/src/backend_task/identity/withdraw_from_identity.rs +++ b/src/backend_task/identity/withdraw_from_identity.rs @@ -1,10 +1,15 @@ +use crate::backend_task::FeeResult; use crate::context::AppContext; +use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::dashcore::Address; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::KeyID; use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::transition::withdraw_from_identity::WithdrawFromIdentity; +use dash_sdk::platform::{Fetch, Identity}; use super::BackendTaskSuccessResult; @@ -21,6 +26,65 @@ impl AppContext { guard.clone() }; + // First, refresh the identity from Platform to get the latest revision and balance + tracing::info!( + identity_id = %qualified_identity.identity.id().to_string(Encoding::Base58), + local_revision = qualified_identity.identity.revision(), + "Refreshing identity from Platform before withdrawal" + ); + + let refreshed_identity = + Identity::fetch_by_identifier(&sdk_guard, qualified_identity.identity.id()) + .await + .map_err(|e| format!("Failed to fetch identity from Platform: {}", e))? + .ok_or_else(|| "Identity not found on Platform".to_string())?; + + tracing::info!( + platform_revision = refreshed_identity.revision(), + platform_balance = refreshed_identity.balance(), + "Fetched identity from Platform" + ); + + // Update the qualified identity with the refreshed identity data + qualified_identity.identity = refreshed_identity; + + // Log withdrawal attempt details + tracing::info!( + identity_id = %qualified_identity.identity.id().to_string(Encoding::Base58), + to_address = ?to_address, + credits = credits, + key_id = ?id, + identity_balance = qualified_identity.identity.balance(), + identity_revision = qualified_identity.identity.revision(), + "Starting withdrawal from identity" + ); + + // Log the key being used + let signing_key = + id.and_then(|key_id| qualified_identity.identity.get_public_key_by_id(key_id)); + if let Some(key) = &signing_key { + tracing::info!( + key_id = key.id(), + key_purpose = ?key.purpose(), + key_type = ?key.key_type(), + key_security_level = ?key.security_level(), + "Using signing key for withdrawal" + ); + } else { + tracing::warn!("No signing key specified for withdrawal"); + } + + // Log available private keys in the qualified identity + tracing::debug!( + num_private_keys = qualified_identity.private_keys.private_keys.len(), + num_wallets = qualified_identity.associated_wallets.len(), + "Qualified identity key info" + ); + + // Track balance before withdrawal for fee calculation + let balance_before = qualified_identity.identity.balance(); + let estimated_fee = PlatformFeeEstimator::new().estimate_credit_withdrawal(); + let remaining_balance = qualified_identity .identity .clone() @@ -29,17 +93,41 @@ impl AppContext { to_address, credits, Some(1), - id.and_then(|key_id| qualified_identity.identity.get_public_key_by_id(key_id)), + signing_key, qualified_identity.clone(), None, ) .await - .map_err(|e| format!("Withdrawal error: {}", e))?; + .map_err(|e| { + tracing::error!(error = %e, "Withdrawal failed"); + format!("Withdrawal error: {}", e) + })?; + + // Calculate and log actual fee paid + let actual_fee = balance_before + .saturating_sub(remaining_balance) + .saturating_sub(credits); + tracing::info!( + "Withdrawal complete: withdrew {} credits, estimated fee {} credits, actual fee {} credits", + credits, + estimated_fee, + actual_fee + ); + if actual_fee != estimated_fee { + tracing::warn!( + "Fee mismatch: estimated {} vs actual {} (diff: {})", + estimated_fee, + actual_fee, + actual_fee as i64 - estimated_fee as i64 + ); + } + qualified_identity.identity.set_balance(remaining_balance); + + let fee_result = FeeResult::new(estimated_fee, actual_fee); + self.update_local_qualified_identity(&qualified_identity) - .map(|_| { - BackendTaskSuccessResult::Message("Successfully withdrew from identity".to_string()) - }) + .map(|_| BackendTaskSuccessResult::WithdrewFromIdentity(fee_result)) .map_err(|e| format!("Database error: {}", e)) } } diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 32b801e36..07428534e 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -2,15 +2,18 @@ use crate::app::TaskResult; use crate::backend_task::contested_names::ContestedResourceTask; use crate::backend_task::contract::ContractTask; use crate::backend_task::core::{CoreItem, CoreTask}; +use crate::backend_task::dashpay::{DashPayTask, ContactData}; use crate::backend_task::document::DocumentTask; use crate::backend_task::identity::IdentityTask; use crate::backend_task::platform_info::{PlatformInfoTaskRequestType, PlatformInfoTaskResult}; use crate::backend_task::system_task::SystemTask; +use crate::backend_task::wallet::WalletTask; use crate::context::AppContext; use dash_sdk::dpp::dashcore::bls_sig_utils::BLSSignature; use dash_sdk::dpp::dashcore::network::message_qrinfo::QRInfo; use dash_sdk::dpp::dashcore::BlockHash; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::WalletSeedHash; use crate::model::grovestark_prover::ProofDataOutput; use crate::ui::tokens::tokens_screen::{ ContractDescriptionInfo, IdentityTokenIdentifier, TokenInfo, @@ -39,6 +42,7 @@ pub mod broadcast_state_transition; pub mod contested_names; pub mod contract; pub mod core; +pub mod dashpay; pub mod document; pub mod grovestark; pub mod identity; @@ -48,10 +52,29 @@ pub mod register_contract; pub mod system_task; pub mod tokens; pub mod update_data_contract; +pub mod wallet; // TODO: Refactor how we handle errors and messages, and remove it from here pub(crate) const NO_IDENTITIES_FOUND: &str = "No identities found"; +/// Information about fees paid for a platform state transition +#[derive(Debug, Clone, PartialEq)] +pub struct FeeResult { + /// The fee that was estimated before the operation + pub estimated_fee: u64, + /// The actual fee that was paid (in credits) + pub actual_fee: u64, +} + +impl FeeResult { + pub fn new(estimated_fee: u64, actual_fee: u64) -> Self { + Self { + estimated_fee, + actual_fee, + } + } +} + #[derive(Debug, Clone, PartialEq)] pub enum BackendTask { IdentityTask(IdentityTask), @@ -59,28 +82,40 @@ pub enum BackendTask { ContractTask(Box), ContestedResourceTask(ContestedResourceTask), CoreTask(CoreTask), + DashPayTask(Box), BroadcastStateTransition(StateTransition), TokenTask(Box), SystemTask(SystemTask), MnListTask(mnlist::MnListTask), PlatformInfo(PlatformInfoTaskRequestType), GroveSTARKTask(GroveSTARKTask), + WalletTask(WalletTask), None, } #[derive(Debug, Clone, PartialEq)] #[allow(clippy::large_enum_variant)] pub enum BackendTaskSuccessResult { + // General results None, Refresh, - Message(String), + Message(String), // Used for: progress messages during long operations, placeholder messages for + // not-yet-implemented functionality, and DashPay operations that would need their own typed variants. + WalletPayment { + txid: String, + /// List of (address, amount) pairs for each recipient + recipients: Vec<(String, u64)>, + total_amount: u64, + }, + + // Specific results #[allow(dead_code)] // May be used for individual document operations Document(Document), Documents(Documents), BroadcastedDocument(Document), CoreItem(CoreItem), - RegisteredIdentity(QualifiedIdentity), - ToppedUpIdentity(QualifiedIdentity), + RegisteredIdentity(QualifiedIdentity, FeeResult), + ToppedUpIdentity(QualifiedIdentity, FeeResult), #[allow(dead_code)] // May be used for reporting successful votes SuccessfulVotes(Vec), DPNSVoteResults(Vec<(String, ResourceVoteChoice, Result<(), String>)>), @@ -110,8 +145,50 @@ pub enum BackendTaskSuccessResult { }, UpdatedThemePreference(crate::ui::theme::ThemeMode), PlatformInfo(PlatformInfoTaskResult), + + // DashPay related results + DashPayProfile(Option<(String, String, String)>), // (display_name, bio, avatar_url) + DashPayContactProfile(Option), // Contact's public profile document + DashPayProfileSearchResults(Vec<(Identifier, Option, String)>), // Search results: (identity_id, profile_document, username) + DashPayContactRequests { + incoming: Vec<(Identifier, Document)>, // (request_id, document) + outgoing: Vec<(Identifier, Document)>, // (request_id, document) + }, + DashPayContacts(Vec), // List of contact identity IDs + DashPayContactsWithInfo(Vec), // List of contacts with metadata + DashPayPaymentHistory(Vec<(String, String, u64, bool, String)>), // (tx_id, contact_name, amount, is_incoming, memo) + DashPayProfileUpdated(Identifier), // Identity ID of updated profile + DashPayContactRequestSent(String), // Username or ID of recipient + DashPayContactRequestAccepted(Identifier), // Request ID that was accepted + DashPayContactRequestRejected(Identifier), // Request ID that was rejected + DashPayContactAlreadyEstablished(Identifier), // Contact ID that already exists + DashPayContactInfoUpdated(Identifier), // Contact ID whose info was updated + DashPayPaymentSent(String, String, f64), // (recipient, address, amount) GeneratedZKProof(ProofDataOutput), VerifiedZKProof(bool, ProofDataOutput), + GeneratedReceiveAddress { + seed_hash: WalletSeedHash, + address: String, + }, + /// Platform address balances fetched from Platform + PlatformAddressBalances { + seed_hash: WalletSeedHash, + /// Map of address string to (balance, nonce) + balances: BTreeMap, + }, + /// Platform credits transferred between addresses + PlatformCreditsTransferred { + seed_hash: WalletSeedHash, + }, + /// Platform address funded from asset lock + PlatformAddressFunded { + seed_hash: WalletSeedHash, + }, + /// Withdrawal from Platform address to Core initiated + PlatformAddressWithdrawal { + seed_hash: WalletSeedHash, + }, + // MNList-specific results MnListFetchedDiff { base_height: u32, @@ -127,6 +204,66 @@ pub enum BackendTaskSuccessResult { MnListFetchedDiffs { items: Vec<((u32, u32), MnListDiff)>, }, + + // Token operation results (replacing string messages) + PausedTokens(FeeResult), + ResumedTokens(FeeResult), + MintedTokens(FeeResult), + BurnedTokens(FeeResult), + FrozeTokens(FeeResult), + UnfrozeTokens(FeeResult), + TransferredTokens(FeeResult), + PurchasedTokens(FeeResult), + SetTokenPrice(FeeResult), + DestroyedFrozenFunds(FeeResult), + ClaimedTokens(FeeResult), + UpdatedTokenConfig(String, FeeResult), // The config item that was updated + FetchedTokenBalances, + SavedToken, + + // Identity operation results (replacing string messages) + AddedKeyToIdentity(FeeResult), + TransferredCredits(FeeResult), + WithdrewFromIdentity(FeeResult), + RegisteredDpnsName(FeeResult), + RefreshedIdentity(QualifiedIdentity), + LoadedIdentity(QualifiedIdentity), + + // Document operation results (replacing string messages) + DeletedDocument(Identifier, FeeResult), + ReplacedDocument(Identifier, FeeResult), + TransferredDocument(Identifier, FeeResult), + PurchasedDocument(Identifier, FeeResult), + SetDocumentPrice(Identifier, FeeResult), + + // Contract operation results (replacing string messages) + UpdatedContract(FeeResult), + RemovedContract, + FetchedNonce, + RegisteredContract(FeeResult), + RegisteredTokenContract, + SavedContract, + ContractNotFound, + TokenNotFound, + ProofErrorLogged, + + // Wallet operation results (replacing string messages) + RefreshedWallet { + /// Optional warning message (e.g., Platform sync failed but Core refresh succeeded) + warning: Option, + }, + RecoveredAssetLocks { + recovered_count: usize, + total_amount: u64, + }, + + // DPNS operation results (replacing string messages) + ScheduledVotes, + RefreshedDpnsContests, + RefreshedOwnedDpnsNames, + + // Broadcast results + BroadcastedStateTransition, } impl BackendTaskSuccessResult {} @@ -191,6 +328,9 @@ impl AppContext { self.run_document_task(*document_task, &sdk).await } BackendTask::CoreTask(core_task) => self.run_core_task(core_task).await, + BackendTask::DashPayTask(dashpay_task) => { + self.run_dashpay_task(*dashpay_task, &sdk).await + } BackendTask::BroadcastStateTransition(state_transition) => { self.broadcast_state_transition(state_transition, &sdk) .await @@ -208,7 +348,76 @@ impl AppContext { BackendTask::GroveSTARKTask(grovestark_task) => { grovestark::run_grovestark_task(grovestark_task, &sdk).await } + BackendTask::WalletTask(wallet_task) => self.run_wallet_task(wallet_task).await, BackendTask::None => Ok(BackendTaskSuccessResult::None), } } + + async fn run_wallet_task( + self: &Arc, + task: WalletTask, + ) -> Result { + match task { + WalletTask::GenerateReceiveAddress { seed_hash } => { + self.generate_receive_address(seed_hash).await + } + WalletTask::FetchPlatformAddressBalances { + seed_hash, + sync_mode, + } => { + self.fetch_platform_address_balances(seed_hash, sync_mode) + .await + } + WalletTask::TransferPlatformCredits { + seed_hash, + inputs, + outputs, + } => { + self.transfer_platform_credits(seed_hash, inputs, outputs) + .await + } + WalletTask::FundPlatformAddressFromAssetLock { + seed_hash, + asset_lock_proof, + asset_lock_address, + outputs, + } => { + self.fund_platform_address_from_asset_lock( + seed_hash, + *asset_lock_proof, + asset_lock_address, + outputs, + ) + .await + } + WalletTask::WithdrawFromPlatformAddress { + seed_hash, + inputs, + output_script, + core_fee_per_byte, + } => { + self.withdraw_from_platform_address( + seed_hash, + inputs, + output_script, + core_fee_per_byte, + ) + .await + } + WalletTask::FundPlatformAddressFromWalletUtxos { + seed_hash, + amount, + destination, + fee_deduct_from_output, + } => { + self.fund_platform_address_from_wallet_utxos( + seed_hash, + amount, + destination, + fee_deduct_from_output, + ) + .await + } + } + } } diff --git a/src/backend_task/platform_info.rs b/src/backend_task/platform_info.rs index b7e7ddee4..38014eb87 100644 --- a/src/backend_task/platform_info.rs +++ b/src/backend_task/platform_info.rs @@ -20,11 +20,13 @@ use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::dpp::withdrawal::daily_withdrawal_limit::daily_withdrawal_limit; use dash_sdk::dpp::{dash_to_credits, version::ProtocolVersionVoteCount}; use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; +use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; use dash_sdk::platform::{DocumentQuery, FetchMany, FetchUnproved}; use dash_sdk::query_types::{ CurrentQuorumsInfo, NoParamQuery, ProtocolVersionUpgrades, TotalCreditsInPlatform, }; +use dash_sdk::query_types::AddressInfo; use itertools::Itertools; use std::sync::Arc; use chrono::{prelude::*, LocalResult}; @@ -39,6 +41,7 @@ pub enum PlatformInfoTaskRequestType { CurrentWithdrawalsInQueue, RecentlyCompletedWithdrawals, BasicPlatformInfo, + FetchAddressBalance(String), } #[derive(Debug, Clone)] @@ -49,6 +52,11 @@ pub enum PlatformInfoTaskResult { network: dash_sdk::dpp::dashcore::Network, }, TextResult(String), + AddressBalance { + address: String, + balance: u64, + nonce: u32, + }, } impl PartialEq for PlatformInfoTaskResult { @@ -70,6 +78,18 @@ impl PartialEq for PlatformInfoTaskResult { PlatformInfoTaskResult::TextResult(text1), PlatformInfoTaskResult::TextResult(text2), ) => text1 == text2, + ( + PlatformInfoTaskResult::AddressBalance { + address: addr1, + balance: bal1, + nonce: n1, + }, + PlatformInfoTaskResult::AddressBalance { + address: addr2, + balance: bal2, + nonce: n2, + }, + ) => addr1 == addr2 && bal1 == bal2 && n1 == n2, _ => false, } } @@ -595,6 +615,42 @@ impl AppContext { )), } } + PlatformInfoTaskRequestType::FetchAddressBalance(address_string) => { + // Parse the address string into a PlatformAddress + let platform_address: PlatformAddress = address_string + .parse() + .map_err(|e| format!("Invalid Platform address '{}': {}", address_string, e))?; + + // Fetch the address info using FetchMany with BTreeSet + let mut addresses = std::collections::BTreeSet::new(); + addresses.insert(platform_address); + match AddressInfo::fetch_many(&sdk, addresses).await { + Ok(address_infos) => { + // The result is a map of PlatformAddress -> Option + let result: Option<&Option> = + address_infos.get(&platform_address); + if let Some(Some(info)) = result { + Ok(BackendTaskSuccessResult::PlatformInfo( + PlatformInfoTaskResult::AddressBalance { + address: address_string, + balance: info.balance, + nonce: info.nonce, + }, + )) + } else { + // Address not found on Platform (zero balance) + Ok(BackendTaskSuccessResult::PlatformInfo( + PlatformInfoTaskResult::AddressBalance { + address: address_string, + balance: 0, + nonce: 0, + }, + )) + } + } + Err(e) => Err(format!("Failed to fetch address balance: {}", e)), + } + } } } } diff --git a/src/backend_task/register_contract.rs b/src/backend_task/register_contract.rs index 09ae2e45f..2f4429d4b 100644 --- a/src/backend_task/register_contract.rs +++ b/src/backend_task/register_contract.rs @@ -7,8 +7,9 @@ use dash_sdk::{ }; use tokio::time::sleep; -use super::BackendTaskSuccessResult; +use super::{BackendTaskSuccessResult, FeeResult}; use crate::backend_task::update_data_contract::extract_contract_id_from_error; +use crate::model::fee_estimation::PlatformFeeEstimator; use crate::{ app::TaskResult, context::AppContext, @@ -29,6 +30,9 @@ impl AppContext { sdk: &Sdk, sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { + // Estimate fee for contract creation + let estimated_fee = PlatformFeeEstimator::new().estimate_contract_create_base(); + match data_contract .put_to_platform_and_wait_for_response(sdk, signing_key.clone(), &identity, None) .await @@ -46,65 +50,12 @@ impl AppContext { self, ) .map_err(|e| format!("Error inserting contract into the database: {}", e))?; - Ok(BackendTaskSuccessResult::Message( - "DataContract successfully registered".to_string(), - )) + let fee_result = FeeResult::new(estimated_fee, estimated_fee); + Ok(BackendTaskSuccessResult::RegisteredContract(fee_result)) } Err(e) => match e { Error::DriveProofError(proof_error, proof_bytes, block_info) => { - sender - .send(TaskResult::Success(Box::new( - BackendTaskSuccessResult::Message( - "Transaction returned proof error".to_string(), - ), - ))) - .await - .map_err(|e| format!("Failed to send message: {}", e))?; - match self.network { - Network::Regtest => sleep(Duration::from_secs(3)).await, - _ => sleep(Duration::from_secs(10)).await, - } - let id = match extract_contract_id_from_error(proof_error.to_string().as_str()) - { - Ok(id) => id, - Err(e) => { - return Err(format!("Failed to extract id from error message: {}", e)); - } - }; - let maybe_contract = match DataContract::fetch(sdk, id).await { - Ok(contract) => contract, - Err(e) => { - return Err(format!( - "Failed to fetch contract from Platform state: {}", - e - )); - } - }; - if let Some(contract) = maybe_contract { - let optional_alias = self - .get_contract_by_id(&contract.id()) - .map(|contract| { - if let Some(contract) = contract { - contract.alias - } else { - None - } - }) - .map_err(|e| { - format!("Failed to get contract by ID from database: {}", e) - })?; - - self.db - .insert_contract_if_not_exists( - &contract, - optional_alias.as_deref(), - AllTokensShouldBeAdded, - self, - ) - .map_err(|e| { - format!("Error inserting contract into the database: {}", e) - })?; - } + // Log the proof error first, before any other operations self.db .insert_proof_log_item(ProofLogItem { request_type: RequestType::BroadcastStateTransition, @@ -116,8 +67,47 @@ impl AppContext { error: Some(proof_error.to_string()), }) .ok(); + + sender + .send(TaskResult::Success(Box::new( + BackendTaskSuccessResult::ProofErrorLogged, + ))) + .await + .map_err(|e| format!("Failed to send message: {}", e))?; + + // Try to extract contract ID and fetch the contract if it exists + // This handles the case where the contract was actually created despite the proof error + if let Ok(id) = extract_contract_id_from_error(proof_error.to_string().as_str()) + { + match self.network { + Network::Regtest => sleep(Duration::from_secs(3)).await, + _ => sleep(Duration::from_secs(10)).await, + } + if let Ok(Some(contract)) = DataContract::fetch(sdk, id).await { + let optional_alias = self + .get_contract_by_id(&contract.id()) + .ok() + .flatten() + .and_then(|c| c.alias); + + self.db + .insert_contract_if_not_exists( + &contract, + optional_alias.as_deref(), + AllTokensShouldBeAdded, + self, + ) + .ok(); + + return Err(format!( + "Error broadcasting Register Contract transition: {}, proof error logged, contract inserted into the database", + proof_error + )); + } + } + Err(format!( - "Error broadcasting Register Contract transition: {}, proof error logged, contract inserted into the database", + "Error broadcasting Register Contract transition: {}, proof error logged", proof_error )) } diff --git a/src/backend_task/tokens/burn_tokens.rs b/src/backend_task/tokens/burn_tokens.rs index bb7163056..20cd8becf 100644 --- a/src/backend_task/tokens/burn_tokens.rs +++ b/src/backend_task/tokens/burn_tokens.rs @@ -136,7 +136,13 @@ impl AppContext { } } - // Return success - Ok(BackendTaskSuccessResult::Message("BurnTokens".to_string())) + // Return success with fee result + // For token operations, we use the estimated fee as a placeholder + // TODO: Add proper fee tracking when SDK provides this information + use crate::backend_task::FeeResult; + use crate::model::fee_estimation::PlatformFeeEstimator; + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); + Ok(BackendTaskSuccessResult::BurnedTokens(fee_result)) } } diff --git a/src/backend_task/tokens/claim_tokens.rs b/src/backend_task/tokens/claim_tokens.rs index ec22b2fff..b0bd20453 100644 --- a/src/backend_task/tokens/claim_tokens.rs +++ b/src/backend_task/tokens/claim_tokens.rs @@ -102,7 +102,11 @@ impl AppContext { } } - // Return success - Ok(BackendTaskSuccessResult::Message("ClaimTokens".to_string())) + // Return success with fee result + use crate::backend_task::FeeResult; + use crate::model::fee_estimation::PlatformFeeEstimator; + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); + Ok(BackendTaskSuccessResult::ClaimedTokens(fee_result)) } } diff --git a/src/backend_task/tokens/destroy_frozen_funds.rs b/src/backend_task/tokens/destroy_frozen_funds.rs index 302246989..ce08cd724 100644 --- a/src/backend_task/tokens/destroy_frozen_funds.rs +++ b/src/backend_task/tokens/destroy_frozen_funds.rs @@ -51,7 +51,7 @@ impl AppContext { .map_err(|e| format!("Error signing DestroyFrozenFunds transition: {}", e))?; // Broadcast - let _proof_result = state_transition + let proof_result = state_transition .broadcast_and_wait::(sdk, None) .await .map_err(|e| match e { @@ -75,9 +75,14 @@ impl AppContext { e => format!("Error broadcasting Destroy Frozen funds transition: {}", e), })?; - // Return success - Ok(BackendTaskSuccessResult::Message( - "DestroyFrozenFunds".to_string(), - )) + // Log proof result for audit trail + tracing::info!("DestroyFrozenFunds proof result: {}", proof_result); + + // Return success with fee result + use crate::backend_task::FeeResult; + use crate::model::fee_estimation::PlatformFeeEstimator; + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); + Ok(BackendTaskSuccessResult::DestroyedFrozenFunds(fee_result)) } } diff --git a/src/backend_task/tokens/freeze_tokens.rs b/src/backend_task/tokens/freeze_tokens.rs index 29d4df4b3..664812246 100644 --- a/src/backend_task/tokens/freeze_tokens.rs +++ b/src/backend_task/tokens/freeze_tokens.rs @@ -3,11 +3,12 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::context::AppContext; use crate::model::proof_log_item::{ProofLogItem, RequestType}; use crate::model::qualified_identity::QualifiedIdentity; +use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; +use dash_sdk::dpp::tokens::info::v0::IdentityTokenInfoV0Accessors; use dash_sdk::platform::tokens::builders::freeze::TokenFreezeTransitionBuilder; -use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::tokens::transitions::FreezeResult; use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; use dash_sdk::{Error, Sdk}; use std::sync::Arc; @@ -45,14 +46,8 @@ impl AppContext { builder = builder.with_state_transition_creation_options(options); } - let state_transition = builder - .sign(sdk, &signing_key, actor_identity, self.platform_version()) - .await - .map_err(|e| format!("Error signing Freeze Tokens transition: {}", e))?; - - // Broadcast - let _proof_result = state_transition - .broadcast_and_wait::(sdk, None) + let result = sdk + .token_freeze(builder, &signing_key, actor_identity) .await .map_err(|e| match e { Error::DriveProofError(proof_error, proof_bytes, block_info) => { @@ -75,9 +70,39 @@ impl AppContext { e => format!("Error broadcasting Freeze Tokens transition: {}", e), })?; - // Return success - Ok(BackendTaskSuccessResult::Message( - "FreezeTokens".to_string(), - )) + // Log the proof-verified freeze result + match result { + FreezeResult::IdentityInfo(identity_id, info) => { + tracing::info!( + "FreezeTokens: identity {} frozen={}", + identity_id, + info.frozen() + ); + } + FreezeResult::HistoricalDocument(document) => { + tracing::info!("FreezeTokens: historical document id={}", document.id()); + } + FreezeResult::GroupActionWithDocument(power, doc) => { + tracing::info!( + "FreezeTokens: group action power={}, has_doc={}", + power, + doc.is_some() + ); + } + FreezeResult::GroupActionWithIdentityInfo(power, info) => { + tracing::info!( + "FreezeTokens: group action power={}, frozen={}", + power, + info.frozen() + ); + } + } + + // Return success with fee result + use crate::backend_task::FeeResult; + use crate::model::fee_estimation::PlatformFeeEstimator; + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); + Ok(BackendTaskSuccessResult::FrozeTokens(fee_result)) } } diff --git a/src/backend_task/tokens/mint_tokens.rs b/src/backend_task/tokens/mint_tokens.rs index 86a806745..3273fd317 100644 --- a/src/backend_task/tokens/mint_tokens.rs +++ b/src/backend_task/tokens/mint_tokens.rs @@ -145,7 +145,11 @@ impl AppContext { } } - // Return success - Ok(BackendTaskSuccessResult::Message("MintTokens".to_string())) + // Return success with fee result + use crate::backend_task::FeeResult; + use crate::model::fee_estimation::PlatformFeeEstimator; + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); + Ok(BackendTaskSuccessResult::MintedTokens(fee_result)) } } diff --git a/src/backend_task/tokens/mod.rs b/src/backend_task/tokens/mod.rs index aa8c40ddb..6ff94cb45 100644 --- a/src/backend_task/tokens/mod.rs +++ b/src/backend_task/tokens/mod.rs @@ -291,11 +291,7 @@ impl AppContext { sender, ) .await - .map(|_| { - BackendTaskSuccessResult::Message( - "Successfully registered token contract".to_string(), - ) - }) + .map(|_| BackendTaskSuccessResult::RegisteredTokenContract) .map_err(|e| format!("Failed to register token contract: {e}")) } TokenTask::QueryMyTokenBalances => self @@ -524,9 +520,7 @@ impl AppContext { Ok(Some(data_contract)) => { Ok(BackendTaskSuccessResult::FetchedContract(data_contract)) } - Ok(None) => Ok(BackendTaskSuccessResult::Message( - "Contract not found".to_string(), - )), + Ok(None) => Ok(BackendTaskSuccessResult::ContractNotFound), Err(e) => Err(format!("Error fetching contracts: {}", e)), } } @@ -552,15 +546,11 @@ impl AppContext { token_position, )) } - Ok(None) => Ok(BackendTaskSuccessResult::Message( - "Contract not found for token".to_string(), - )), + Ok(None) => Ok(BackendTaskSuccessResult::ContractNotFound), Err(e) => Err(format!("Error fetching contract for token: {}", e)), } } - Ok(None) => Ok(BackendTaskSuccessResult::Message( - "Token not found".to_string(), - )), + Ok(None) => Ok(BackendTaskSuccessResult::TokenNotFound), Err(e) => Err(format!("Error fetching token info: {}", e)), } } @@ -582,9 +572,7 @@ impl AppContext { ) .map_err(|e| format!("error saving token: {}", e))?; - Ok(BackendTaskSuccessResult::Message( - "Saved token to db".to_string(), - )) + Ok(BackendTaskSuccessResult::SavedToken) } TokenTask::UpdateTokenConfig { identity_token_info, diff --git a/src/backend_task/tokens/pause_tokens.rs b/src/backend_task/tokens/pause_tokens.rs index 521f1fd2d..0527759ca 100644 --- a/src/backend_task/tokens/pause_tokens.rs +++ b/src/backend_task/tokens/pause_tokens.rs @@ -50,7 +50,7 @@ impl AppContext { .map_err(|e| format!("Error signing Pause Tokens transition: {}", e))?; // Broadcast - let _proof_result = state_transition + let proof_result = state_transition .broadcast_and_wait::(sdk, None) .await .map_err(|e| match e { @@ -74,7 +74,14 @@ impl AppContext { e => format!("Error broadcasting Pause Tokens transition: {}", e), })?; - // Return success - Ok(BackendTaskSuccessResult::Message("PauseTokens".to_string())) + // Log proof result for audit trail + tracing::info!("PauseTokens proof result: {}", proof_result); + + // Return success with fee result + use crate::backend_task::FeeResult; + use crate::model::fee_estimation::PlatformFeeEstimator; + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); + Ok(BackendTaskSuccessResult::PausedTokens(fee_result)) } } diff --git a/src/backend_task/tokens/purchase_tokens.rs b/src/backend_task/tokens/purchase_tokens.rs index 4379bc416..bdcd27015 100644 --- a/src/backend_task/tokens/purchase_tokens.rs +++ b/src/backend_task/tokens/purchase_tokens.rs @@ -4,12 +4,14 @@ use crate::context::AppContext; use crate::model::proof_log_item::{ProofLogItem, RequestType}; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::balances::credits::TokenAmount; +use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; +use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; +use dash_sdk::dpp::platform_value::Value; use dash_sdk::platform::tokens::builders::purchase::TokenDirectPurchaseTransitionBuilder; -use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; -use dash_sdk::platform::{DataContract, IdentityPublicKey}; +use dash_sdk::platform::tokens::transitions::DirectPurchaseResult; +use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; use dash_sdk::{Error, Sdk}; use std::sync::Arc; @@ -38,14 +40,8 @@ impl AppContext { builder = builder.with_state_transition_creation_options(options); } - let state_transition = builder - .sign(sdk, &signing_key, sending_identity, self.platform_version()) - .await - .map_err(|e| format!("Error signing Purchase Tokens state transition: {}", e))?; - - // broadcast and wait - let _proof_result = state_transition - .broadcast_and_wait::(sdk, None) + let result = sdk + .token_purchase(builder, &signing_key, sending_identity) .await .map_err(|e| match e { Error::DriveProofError(proof_error, proof_bytes, block_info) => { @@ -68,9 +64,75 @@ impl AppContext { e => format!("Error broadcasting Purchase Tokens transition: {}", e), })?; - // Return success - Ok(BackendTaskSuccessResult::Message( - "PurchaseTokens".to_string(), - )) + // Update token balance from the proof-verified result + if let Some(token_id) = data_contract.token_id(token_position) { + match result { + // Standard purchase result - update purchaser's balance + DirectPurchaseResult::TokenBalance(identity_id, balance) => { + tracing::info!( + "PurchaseTokens: identity {} new balance {}", + identity_id, + balance + ); + if let Err(e) = + self.insert_token_identity_balance(&token_id, &identity_id, balance) + { + tracing::warn!("Failed to update token balance: {}", e); + } + } + + // Historical document - extract purchaser and balance from document + DirectPurchaseResult::HistoricalDocument(document) => { + tracing::info!("PurchaseTokens: historical document id={}", document.id()); + if let (Some(purchaser_value), Some(balance_value)) = + (document.get("purchaserId"), document.get("balance")) + && let (Value::Identifier(purchaser_bytes), Value::U64(balance)) = + (purchaser_value, balance_value) + && let Ok(purchaser_id) = Identifier::from_bytes(purchaser_bytes) + && let Err(e) = + self.insert_token_identity_balance(&token_id, &purchaser_id, *balance) + { + tracing::warn!( + "Failed to update token balance from historical document: {}", + e + ); + } + } + + // Group action with document + DirectPurchaseResult::GroupActionWithDocument(power, Some(document)) => { + tracing::info!( + "PurchaseTokens: group action power={}, doc_id={}", + power, + document.id() + ); + if let (Some(purchaser_value), Some(balance_value)) = + (document.get("purchaserId"), document.get("balance")) + && let (Value::Identifier(purchaser_bytes), Value::U64(balance)) = + (purchaser_value, balance_value) + && let Ok(purchaser_id) = Identifier::from_bytes(purchaser_bytes) + && let Err(e) = + self.insert_token_identity_balance(&token_id, &purchaser_id, *balance) + { + tracing::warn!( + "Failed to update token balance from group action document: {}", + e + ); + } + } + + // Group action without document - no balance to update + DirectPurchaseResult::GroupActionWithDocument(power, None) => { + tracing::info!("PurchaseTokens: group action power={}, no document", power); + } + } + } + + // Return success with fee result + use crate::backend_task::FeeResult; + use crate::model::fee_estimation::PlatformFeeEstimator; + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); + Ok(BackendTaskSuccessResult::PurchasedTokens(fee_result)) } } diff --git a/src/backend_task/tokens/query_my_token_balances.rs b/src/backend_task/tokens/query_my_token_balances.rs index bd4c65b98..eea3728bc 100644 --- a/src/backend_task/tokens/query_my_token_balances.rs +++ b/src/backend_task/tokens/query_my_token_balances.rs @@ -85,9 +85,7 @@ impl AppContext { } } - Ok(BackendTaskSuccessResult::Message( - "Successfully fetched token balances".to_string(), - )) + Ok(BackendTaskSuccessResult::FetchedTokenBalances) } pub async fn query_token_balance( @@ -135,8 +133,6 @@ impl AppContext { } } - Ok(BackendTaskSuccessResult::Message( - "Successfully fetched token balances".to_string(), - )) + Ok(BackendTaskSuccessResult::FetchedTokenBalances) } } diff --git a/src/backend_task/tokens/resume_tokens.rs b/src/backend_task/tokens/resume_tokens.rs index 094f83ed6..bede9dec8 100644 --- a/src/backend_task/tokens/resume_tokens.rs +++ b/src/backend_task/tokens/resume_tokens.rs @@ -50,7 +50,7 @@ impl AppContext { .map_err(|e| format!("Error signing Resume Tokens transition: {}", e))?; // Broadcast - let _proof_result = state_transition + let proof_result = state_transition .broadcast_and_wait::(sdk, None) .await .map_err(|e| match e { @@ -74,9 +74,14 @@ impl AppContext { e => format!("Error broadcasting Resume Tokens transition: {}", e), })?; - // Return success - Ok(BackendTaskSuccessResult::Message( - "ResumeTokens".to_string(), - )) + // Log proof result for audit trail + tracing::info!("ResumeTokens proof result: {}", proof_result); + + // Return success with fee result + use crate::backend_task::FeeResult; + use crate::model::fee_estimation::PlatformFeeEstimator; + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); + Ok(BackendTaskSuccessResult::ResumedTokens(fee_result)) } } diff --git a/src/backend_task/tokens/set_token_price.rs b/src/backend_task/tokens/set_token_price.rs index 7b8643e2e..db26577d1 100644 --- a/src/backend_task/tokens/set_token_price.rs +++ b/src/backend_task/tokens/set_token_price.rs @@ -3,12 +3,12 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::context::AppContext; use crate::model::proof_log_item::{ProofLogItem, RequestType}; use crate::model::qualified_identity::QualifiedIdentity; +use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; use dash_sdk::dpp::tokens::token_pricing_schedule::TokenPricingSchedule; use dash_sdk::platform::tokens::builders::set_price::TokenChangeDirectPurchasePriceTransitionBuilder; -use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::tokens::transitions::SetPriceResult; use dash_sdk::platform::{DataContract, IdentityPublicKey}; use dash_sdk::{Error, Sdk}; use std::sync::Arc; @@ -49,14 +49,8 @@ impl AppContext { builder = builder.with_state_transition_creation_options(options); } - let state_transition = builder - .sign(sdk, &signing_key, sending_identity, self.platform_version()) - .await - .map_err(|e| format!("Error signing SetPrice state transition: {}", e))?; - - // broadcast and wait - let _proof_result = state_transition - .broadcast_and_wait::(sdk, None) + let result = sdk + .token_set_price_for_direct_purchase(builder, &signing_key, sending_identity) .await .map_err(|e| match e { Error::DriveProofError(proof_error, proof_bytes, block_info) => { @@ -79,9 +73,43 @@ impl AppContext { e => format!("Error broadcasting SetPrice Tokens transition: {}", e), })?; - // Return success - Ok(BackendTaskSuccessResult::Message( - "SetDirectPurchasePrice".to_string(), - )) + // Log the proof-verified set price result + match result { + SetPriceResult::PricingSchedule(owner_id, schedule) => { + tracing::info!( + "SetDirectPurchasePrice: owner {} has_schedule={}", + owner_id, + schedule.is_some() + ); + } + SetPriceResult::HistoricalDocument(document) => { + tracing::info!( + "SetDirectPurchasePrice: historical document id={}", + document.id() + ); + } + SetPriceResult::GroupActionWithDocument(power, doc) => { + tracing::info!( + "SetDirectPurchasePrice: group action power={}, has_doc={}", + power, + doc.is_some() + ); + } + SetPriceResult::GroupActionWithPricingSchedule(power, status, schedule) => { + tracing::info!( + "SetDirectPurchasePrice: group action power={}, status={:?}, has_schedule={}", + power, + status, + schedule.is_some() + ); + } + } + + // Return success with fee result + use crate::backend_task::FeeResult; + use crate::model::fee_estimation::PlatformFeeEstimator; + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); + Ok(BackendTaskSuccessResult::SetTokenPrice(fee_result)) } } diff --git a/src/backend_task/tokens/transfer_tokens.rs b/src/backend_task/tokens/transfer_tokens.rs index 93a7a2b8e..872b55ded 100644 --- a/src/backend_task/tokens/transfer_tokens.rs +++ b/src/backend_task/tokens/transfer_tokens.rs @@ -190,8 +190,11 @@ impl AppContext { } } - Ok(BackendTaskSuccessResult::Message( - "TransferTokens".to_string(), - )) + // Return success with fee result + use crate::backend_task::FeeResult; + use crate::model::fee_estimation::PlatformFeeEstimator; + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); + Ok(BackendTaskSuccessResult::TransferredTokens(fee_result)) } } diff --git a/src/backend_task/tokens/unfreeze_tokens.rs b/src/backend_task/tokens/unfreeze_tokens.rs index 7a2cc3616..3622c26c9 100644 --- a/src/backend_task/tokens/unfreeze_tokens.rs +++ b/src/backend_task/tokens/unfreeze_tokens.rs @@ -3,11 +3,12 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::context::AppContext; use crate::model::proof_log_item::{ProofLogItem, RequestType}; use crate::model::qualified_identity::QualifiedIdentity; +use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; +use dash_sdk::dpp::tokens::info::v0::IdentityTokenInfoV0Accessors; use dash_sdk::platform::tokens::builders::unfreeze::TokenUnfreezeTransitionBuilder; -use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::tokens::transitions::UnfreezeResult; use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; use dash_sdk::{Error, Sdk}; use std::sync::Arc; @@ -45,14 +46,8 @@ impl AppContext { builder = builder.with_state_transition_creation_options(options); } - let state_transition = builder - .sign(sdk, &signing_key, actor_identity, self.platform_version()) - .await - .map_err(|e| format!("Error signing Unfreeze Tokens transition: {}", e))?; - - // Broadcast - let _proof_result = state_transition - .broadcast_and_wait::(sdk, None) + let result = sdk + .token_unfreeze_identity(builder, &signing_key, actor_identity) .await .map_err(|e| match e { Error::DriveProofError(proof_error, proof_bytes, block_info) => { @@ -75,9 +70,39 @@ impl AppContext { e => format!("Error broadcasting Unfreeze Tokens transition: {}", e), })?; - // Return success - Ok(BackendTaskSuccessResult::Message( - "UnfreezeTokens".to_string(), - )) + // Log the proof-verified unfreeze result + match result { + UnfreezeResult::IdentityInfo(identity_id, info) => { + tracing::info!( + "UnfreezeTokens: identity {} frozen={}", + identity_id, + info.frozen() + ); + } + UnfreezeResult::HistoricalDocument(document) => { + tracing::info!("UnfreezeTokens: historical document id={}", document.id()); + } + UnfreezeResult::GroupActionWithDocument(power, doc) => { + tracing::info!( + "UnfreezeTokens: group action power={}, has_doc={}", + power, + doc.is_some() + ); + } + UnfreezeResult::GroupActionWithIdentityInfo(power, info) => { + tracing::info!( + "UnfreezeTokens: group action power={}, frozen={}", + power, + info.frozen() + ); + } + } + + // Return success with fee result + use crate::backend_task::FeeResult; + use crate::model::fee_estimation::PlatformFeeEstimator; + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); + Ok(BackendTaskSuccessResult::UnfrozeTokens(fee_result)) } } diff --git a/src/backend_task/tokens/update_token_config.rs b/src/backend_task/tokens/update_token_config.rs index a13ef02d6..a547ef744 100644 --- a/src/backend_task/tokens/update_token_config.rs +++ b/src/backend_task/tokens/update_token_config.rs @@ -98,7 +98,7 @@ impl AppContext { .map_err(|e| format!("Error signing Token Config Update transition: {}", e))?; // Broadcast the state transition - let _proof_result = state_transition + let proof_result = state_transition .broadcast_and_wait::(sdk, None) .await .map_err(|e| match e { @@ -122,8 +122,12 @@ impl AppContext { e => format!("Error broadcasting Update token config transition: {}", e), })?; + // Log proof result for audit trail + tracing::info!("TokenConfigUpdate proof result: {}", proof_result); + // Now update the data contract in the local database - // First, fetch the updated contract from the platform + // The proof result contains an action document, not the updated contract, + // so we need to fetch the updated contract from the platform let data_contract = DataContract::fetch(sdk, identity_token_info.data_contract.contract.id()) .await @@ -164,10 +168,14 @@ impl AppContext { ) .map_err(|e| format!("Error inserting token into local database: {}", e))?; - // Return success - Ok(BackendTaskSuccessResult::Message(format!( - "Successfully updated token config item: {}", - change_item - ))) + // Return success with fee result + use crate::backend_task::FeeResult; + use crate::model::fee_estimation::PlatformFeeEstimator; + let estimated_fee = PlatformFeeEstimator::new().estimate_document_batch(1); + let fee_result = FeeResult::new(estimated_fee, estimated_fee); + Ok(BackendTaskSuccessResult::UpdatedTokenConfig( + change_item.to_string(), + fee_result, + )) } } diff --git a/src/backend_task/update_data_contract.rs b/src/backend_task/update_data_contract.rs index 4d005ad9b..80b1453a6 100644 --- a/src/backend_task/update_data_contract.rs +++ b/src/backend_task/update_data_contract.rs @@ -1,8 +1,9 @@ -use super::BackendTaskSuccessResult; +use super::{BackendTaskSuccessResult, FeeResult}; use crate::{ app::TaskResult, context::AppContext, model::{ + fee_estimation::PlatformFeeEstimator, proof_log_item::{ProofLogItem, RequestType}, qualified_identity::QualifiedIdentity, }, @@ -61,6 +62,9 @@ impl AppContext { sdk: &Sdk, sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { + // Estimate fee for contract update + let estimated_fee = PlatformFeeEstimator::new().estimate_contract_update(); + // Increment the version of the data contract data_contract.increment_version(); @@ -73,7 +77,7 @@ impl AppContext { // Update UI sender .send(TaskResult::Success(Box::new( - BackendTaskSuccessResult::Message("Nonce fetched successfully".to_string()), + BackendTaskSuccessResult::FetchedNonce, ))) .await .map_err(|e| format!("Failed to send message: {}", e))?; @@ -110,62 +114,53 @@ impl AppContext { self.db .replace_contract(data_contract.id(), &returned_contract, self) .map_err(|e| format!("Error inserting contract into the database: {}", e))?; - Ok(BackendTaskSuccessResult::Message( - "DataContract successfully updated".to_string(), - )) + let fee_result = FeeResult::new(estimated_fee, estimated_fee); + Ok(BackendTaskSuccessResult::UpdatedContract(fee_result)) } Err(e) => match e { Error::DriveProofError(proof_error, proof_bytes, block_info) => { + // Log the proof error first, before any other operations + self.db + .insert_proof_log_item(ProofLogItem { + request_type: RequestType::BroadcastStateTransition, + request_bytes: vec![], + verification_path_query_bytes: vec![], + height: block_info.height, + time_ms: block_info.time_ms, + proof_bytes, + error: Some(proof_error.to_string()), + }) + .ok(); + sender .send(TaskResult::Success(Box::new( - BackendTaskSuccessResult::Message( - "Transaction returned proof error".to_string(), - ), + BackendTaskSuccessResult::ProofErrorLogged, ))) .await .map_err(|e| format!("Failed to send message: {}", e))?; - match self.network { - Network::Regtest => sleep(Duration::from_secs(3)).await, - _ => sleep(Duration::from_secs(10)).await, - } - let id = match extract_contract_id_from_error(proof_error.to_string().as_str()) + // Try to extract contract ID and fetch the contract if it exists + // This handles the case where the contract was actually updated despite the proof error + if let Ok(id) = extract_contract_id_from_error(proof_error.to_string().as_str()) { - Ok(id) => id, - Err(e) => { - return Err(format!("Failed to extract id from error message: {}", e)); + match self.network { + Network::Regtest => sleep(Duration::from_secs(3)).await, + _ => sleep(Duration::from_secs(10)).await, } - }; + if let Ok(Some(contract)) = DataContract::fetch(sdk, id).await { + self.db + .replace_contract(contract.id(), &contract, self) + .ok(); - let maybe_contract = match DataContract::fetch(sdk, id).await { - Ok(contract) => contract, - Err(e) => { return Err(format!( - "Failed to fetch contract from Platform state: {}", - e + "Error broadcasting Contract Update transition: {}, proof error logged, contract inserted into the database", + proof_error )); } - }; - if let Some(contract) = maybe_contract { - self.db - .replace_contract(contract.id(), &contract, self) - .map_err(|e| { - format!("Error inserting contract into the database: {}", e) - })?; } - self.db - .insert_proof_log_item(ProofLogItem { - request_type: RequestType::BroadcastStateTransition, - request_bytes: vec![], - verification_path_query_bytes: vec![], - height: block_info.height, - time_ms: block_info.time_ms, - proof_bytes, - error: Some(proof_error.to_string()), - }) - .ok(); + Err(format!( - "Error broadcasting Contract Update transition: {}, proof error logged, contract inserted into the database", + "Error broadcasting Contract Update transition: {}, proof error logged", proof_error )) } diff --git a/src/backend_task/wallet/fetch_platform_address_balances.rs b/src/backend_task/wallet/fetch_platform_address_balances.rs new file mode 100644 index 000000000..b95c48426 --- /dev/null +++ b/src/backend_task/wallet/fetch_platform_address_balances.rs @@ -0,0 +1,548 @@ +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::wallet::PlatformSyncMode; +use crate::context::AppContext; +use crate::model::wallet::{ + DerivationPathHelpers, DerivationPathReference, DerivationPathType, Wallet, + WalletAddressProvider, WalletSeedHash, +}; +use dash_sdk::RequestSettings; +use dash_sdk::Sdk; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::platform::address_sync::AddressSyncConfig; +use dash_sdk::platform::address_sync::AddressSyncResult; +use std::sync::{Arc, RwLock}; + +impl AppContext { + pub(crate) async fn fetch_platform_address_balances( + self: &Arc, + seed_hash: WalletSeedHash, + sync_mode: PlatformSyncMode, + ) -> Result { + // 6 days and 20 hours in seconds (to be safe before 7 days) + const FULL_SYNC_INTERVAL_SECS: u64 = 6 * 24 * 60 * 60 + 20 * 60 * 60; // 590400 seconds + + tracing::info!("Platform address sync start (mode: {:?})", sync_mode); + let start_time = std::time::Instant::now(); + + let wallet_arc = { + let wallets = self.wallets.read().unwrap(); + wallets + .get(&seed_hash) + .cloned() + .ok_or_else(|| "Wallet not found".to_string())? + }; + + // Check last full sync time and terminal block from database + let (last_full_sync, stored_checkpoint, last_terminal_block) = self + .db + .get_platform_sync_info(&seed_hash) + .unwrap_or((0, 0, 0)); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + // Determine if we need a full sync based on mode + let needs_full_sync = match sync_mode { + PlatformSyncMode::ForceFull => true, + PlatformSyncMode::TerminalOnly => { + if stored_checkpoint == 0 { + return Err( + "Terminal-only sync requested but no checkpoint exists. Run a full sync first." + .to_string(), + ); + } + false + } + PlatformSyncMode::Auto => { + last_full_sync == 0 + || stored_checkpoint == 0 + || now.saturating_sub(last_full_sync) >= FULL_SYNC_INTERVAL_SECS + } + }; + + // Create provider (requires wallet to be open for address derivation) + let mut provider = { + let wallet = wallet_arc.read().map_err(|e| e.to_string())?; + match WalletAddressProvider::new(&wallet, self.network) { + Ok(provider) => provider, + Err(_) if !wallet.is_open() => { + return Err("Wallet is locked. Please unlock it first to refresh.".to_string()); + } + Err(e) => return Err(e), + } + }; + + // Sync using SDK's privacy-preserving method + let sdk = { + let guard = self.sdk.read().map_err(|e| e.to_string())?; + guard.clone() + }; + + let checkpoint_height = if needs_full_sync { + tracing::info!( + "Performing full platform address sync (last sync: {} seconds ago)", + now.saturating_sub(last_full_sync) + ); + + // trunk state query is failing if tree is empty with internal error + // this happens when we don't have any balances yet + // this case most often happens for local network + // so we do not ban addresses in case of failure + // and return empty `AddressSyncResult` + let config = if sdk.network == Network::Regtest { + Some(AddressSyncConfig { + request_settings: RequestSettings { + ban_failed_address: Some(false), + ..Default::default() + }, + ..Default::default() + }) + } else { + None + }; + + // Perform the base sync + let base_start = std::time::Instant::now(); + let result = match sdk + .sync_address_balances(&mut provider, config.clone()) + .await + { + Ok(res) => res, + Err(e) if e.to_string().contains("empty tree") => { + tracing::debug!( + "Platform address balance tree is empty. Returning empty sync result." + ); + AddressSyncResult::default() + } + Err(e) => return Err(format!("Failed to sync Platform addresses: {}", e)), + }; + let base_duration = base_start.elapsed(); + + tracing::info!( + "Base sync complete: duration={:?}, found={}, absent={}, checkpoint={}", + base_duration, + result.found.len(), + result.absent.len(), + result.checkpoint_height + ); + + // Apply terminal updates + let terminal_start_height = result.checkpoint_height.max(last_terminal_block); + self.apply_recent_balance_changes( + &sdk, + &wallet_arc, + &mut provider, + terminal_start_height, + ) + .await?; + + tracing::info!( + "Full sync complete: duration={:?}, found={}, absent={}, highest_index={:?}, checkpoint_height={}", + start_time.elapsed(), + result.found.len(), + result.absent.len(), + result.highest_found_index, + result.checkpoint_height + ); + + // Log the found balances from provider + for (addr, balance) in provider.found_balances() { + use dash_sdk::dpp::address_funds::PlatformAddress; + let platform_addr_str = PlatformAddress::try_from(addr.clone()) + .map(|p| p.to_bech32m_string(self.network)) + .unwrap_or_else(|_| addr.to_string()); + tracing::info!( + "Sync found address: {} with balance: {}", + platform_addr_str, + balance + ); + } + + // Save the new full sync timestamp and checkpoint + if let Err(e) = + self.db + .set_platform_sync_info(&seed_hash, now, result.checkpoint_height) + { + tracing::warn!("Failed to save platform sync info: {}", e); + } + + result.checkpoint_height + } else { + let terminal_only_start = std::time::Instant::now(); + tracing::info!( + "Performing terminal-only platform address sync (last full sync: {} seconds ago, checkpoint={}, last_terminal_block={})", + now.saturating_sub(last_full_sync), + stored_checkpoint, + last_terminal_block + ); + + // Pre-populate provider with LAST SYNCED balances (not current balances) + // This prevents double-counting when proof-verified updates happened after last sync + let mut pre_populated_count = 0; + { + let wallet = wallet_arc.read().map_err(|e| e.to_string())?; + for (core_addr, platform_addr) in wallet.platform_addresses(self.network) { + if let Some(info) = wallet.get_platform_address_info(&core_addr) { + // Only pre-populate if we have a last_synced_balance + // (meaning this address was found in a previous full sync) + if let Some(synced_balance) = info.last_synced_balance { + let lookup_addr = platform_addr.to_address_with_network(self.network); + provider.update_balance(&lookup_addr, synced_balance); + pre_populated_count += 1; + tracing::debug!( + "Pre-populated balance for {}: {} (last synced)", + platform_addr.to_bech32m_string(self.network), + synced_balance + ); + } else { + tracing::debug!( + "Skipping pre-population for {} (no last_synced_balance, likely from proof)", + platform_addr.to_bech32m_string(self.network) + ); + } + } + } + } + tracing::info!( + "Terminal-only sync setup complete: duration={:?}, pre_populated={} addresses", + terminal_only_start.elapsed(), + pre_populated_count + ); + + stored_checkpoint + }; + + // Fetch recent balance changes (terminal updates after checkpoint) + // This catches any balance changes that happened after the checkpoint. + // Use the higher of checkpoint_height or last_terminal_block to avoid + // re-applying changes we've already processed. + let terminal_start_height = checkpoint_height.max(last_terminal_block); + let terminal_sync_start = std::time::Instant::now(); + let highest_block_processed = self + .apply_recent_balance_changes(&sdk, &wallet_arc, &mut provider, terminal_start_height) + .await?; + let terminal_sync_duration = terminal_sync_start.elapsed(); + tracing::info!( + "Terminal balance updates complete: duration={:?}, start_height={}, end_height={}", + terminal_sync_duration, + terminal_start_height, + highest_block_processed + ); + + // Save the highest block we've processed to avoid re-applying the same changes + if highest_block_processed > last_terminal_block + && let Err(e) = self + .db + .set_last_terminal_block(&seed_hash, highest_block_processed) + { + tracing::warn!("Failed to save last terminal block: {}", e); + } + + // Apply results to wallet and persist + let balances = { + let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; + + provider.apply_results_to_wallet(&mut wallet); + + // Persist addresses and balances to database + for (index, (address, balance)) in provider.found_balances_with_indices() { + // Persist the address to wallet_addresses table if not already there + let derivation_path = DerivationPath::platform_payment_path( + self.network, + 0, // account + 0, // key_class + index, + ); + if let Err(e) = self.db.add_address_if_not_exists( + &seed_hash, + address, + &self.network, + &derivation_path, + DerivationPathReference::PlatformPayment, + DerivationPathType::CLEAR_FUNDS, + None, + ) { + tracing::warn!("Failed to persist Platform address: {}", e); + } + + // Persist balance to platform_address_balances table + let nonce = wallet + .platform_address_info + .get(address) + .map(|info| info.nonce) + .unwrap_or(0); + if let Err(e) = self.db.set_platform_address_info( + &seed_hash, + address, + *balance, + nonce, + &self.network, + ) { + tracing::warn!("Failed to persist Platform address info: {}", e); + } + } + + // Return balances for result (nonce preserved from existing info or 0) + provider + .found_balances() + .iter() + .map(|(addr, bal)| { + let nonce = wallet + .platform_address_info + .get(addr) + .map(|info| info.nonce) + .unwrap_or(0); + (addr.to_string(), (*bal, nonce)) + }) + .collect() + }; + + let addresses_with_balance = provider.found_balances().len(); + let total_duration = start_time.elapsed(); + tracing::info!( + "Platform address sync complete: total_duration={:?}, mode={:?}, addresses_with_balance={}", + total_duration, + sync_mode, + addresses_with_balance + ); + + Ok(BackendTaskSuccessResult::PlatformAddressBalances { + seed_hash, + balances, + }) + } + + /// Apply recent balance changes (terminal updates) to catch changes after a starting block. + /// + /// The trunk/branch sync provides balances as of a checkpoint (every ~10 minutes). + /// This function fetches balance changes since the starting block to provide + /// more up-to-date balances. + /// + /// Two queries are performed in sequence: + /// 1. RecentCompactedAddressBalanceChanges - merged changes for ranges of blocks + /// 2. RecentAddressBalanceChanges - individual per-block changes for most recent blocks + /// + /// Returns the highest block height processed, or an error if network requests failed. + async fn apply_recent_balance_changes( + &self, + sdk: &Sdk, + wallet_arc: &Arc>, + provider: &mut WalletAddressProvider, + start_height: u64, + ) -> Result { + use dash_sdk::dpp::address_funds::PlatformAddress; + use dash_sdk::dpp::balances::credits::{BlockAwareCreditOperation, CreditOperation}; + use dash_sdk::platform::{ + Fetch, RecentAddressBalanceChangesQuery, RecentCompactedAddressBalanceChangesQuery, + }; + use dash_sdk::query_types::{ + RecentAddressBalanceChanges, RecentCompactedAddressBalanceChanges, + }; + + // The trunk/branch sync provides balances as of the checkpoint height. + // We query for compacted changes starting from that start height, + // then query recent non-compacted changes starting from where compacted ends. + + tracing::debug!( + "Fetching terminal balance updates from height {}", + start_height + ); + + // Get the wallet's platform addresses to filter relevant changes + let wallet_platform_addresses: std::collections::HashSet = { + let wallet = match wallet_arc.read() { + Ok(w) => w, + Err(e) => return Err(format!("Failed to read wallet: {}", e)), + }; + wallet + .platform_addresses(self.network) + .into_iter() + .map(|(_, platform_addr)| platform_addr) + .collect() + }; + + let mut updates_applied = 0; + let mut highest_block_seen = start_height; + + // Step 1: Fetch compacted balance changes (merged changes for ranges of blocks) + // Start from start_height to get changes since the last sync + let compacted_fetch_start = std::time::Instant::now(); + let compacted_query = RecentCompactedAddressBalanceChangesQuery::new(start_height); + let compacted_result = tokio::time::timeout( + std::time::Duration::from_secs(30), + RecentCompactedAddressBalanceChanges::fetch(sdk, compacted_query), + ) + .await; + let compacted_duration = compacted_fetch_start.elapsed(); + tracing::info!( + "Compacted balance changes fetch: duration={:?}, from_height={}", + compacted_duration, + start_height + ); + let compacted_result = match compacted_result { + Ok(result) => result, + Err(_) => { + return Err("Compacted balance changes fetch timed out after 30s".to_string()); + } + }; + let compacted_changes = match compacted_result { + Ok(Some(changes)) => Some(changes), + Ok(None) => None, + Err(e) => { + return Err(format!("Failed to fetch compacted balance changes: {}", e)); + } + }; + if let Some(compacted_changes) = compacted_changes { + for block_changes in compacted_changes.into_inner() { + // Track the highest block height we've processed + if block_changes.end_block_height > highest_block_seen { + highest_block_seen = block_changes.end_block_height; + } + + for (platform_addr, credit_op) in block_changes.changes { + if wallet_platform_addresses.contains(&platform_addr) { + let core_addr = platform_addr.to_address_with_network(self.network); + let current_balance = provider + .found_balances() + .get(&core_addr) + .copied() + .unwrap_or(0); + + let new_balance = match credit_op { + BlockAwareCreditOperation::SetCredits(credits) => { + tracing::debug!( + "Compacted SetCredits: {} = {}", + platform_addr.to_bech32m_string(self.network), + credits + ); + credits + } + BlockAwareCreditOperation::AddToCreditsOperations(operations) => { + // Only apply credits from blocks AFTER our start height + let total_to_add: u64 = operations + .iter() + .filter(|(height, _)| **height > start_height) + .map(|(_, credits)| *credits) + .sum(); + tracing::debug!( + "Compacted AddToCredits: {} current={} + add={} = {}", + platform_addr.to_bech32m_string(self.network), + current_balance, + total_to_add, + current_balance.saturating_add(total_to_add) + ); + current_balance.saturating_add(total_to_add) + } + }; + + if new_balance != current_balance { + provider.update_balance(&core_addr, new_balance); + let addr_str = platform_addr.to_bech32m_string(self.network); + tracing::info!( + "Compacted update: {} balance {} -> {}", + addr_str, + current_balance, + new_balance + ); + updates_applied += 1; + } + } + } + } + } + + // Step 2: Fetch non-compacted balance changes (individual per-block changes) + // Use the highest block height from compacted changes + 1 as the start + let recent_fetch_start = std::time::Instant::now(); + let recent_query = RecentAddressBalanceChangesQuery::new(highest_block_seen + 1); + let recent_result = tokio::time::timeout( + std::time::Duration::from_secs(30), + RecentAddressBalanceChanges::fetch(sdk, recent_query), + ) + .await; + let recent_duration = recent_fetch_start.elapsed(); + tracing::info!( + "Recent balance changes fetch: duration={:?}, from_height={}", + recent_duration, + highest_block_seen + 1 + ); + let recent_result = match recent_result { + Ok(result) => result, + Err(_) => { + return Err("Recent balance changes fetch timed out after 30s".to_string()); + } + }; + let recent_changes = match recent_result { + Ok(Some(changes)) => Some(changes), + Ok(None) => None, + Err(e) => { + return Err(format!("Failed to fetch recent balance changes: {}", e)); + } + }; + if let Some(recent_changes) = recent_changes { + for block_changes in recent_changes.into_inner() { + // Track the block height from non-compacted changes + if block_changes.block_height > highest_block_seen { + highest_block_seen = block_changes.block_height; + } + + for (platform_addr, credit_op) in block_changes.changes { + if wallet_platform_addresses.contains(&platform_addr) { + let core_addr = platform_addr.to_address_with_network(self.network); + let current_balance = provider + .found_balances() + .get(&core_addr) + .copied() + .unwrap_or(0); + + let new_balance = match credit_op { + CreditOperation::SetCredits(credits) => { + tracing::debug!( + "Recent SetCredits: {} = {}", + platform_addr.to_bech32m_string(self.network), + credits + ); + credits + } + CreditOperation::AddToCredits(credits) => { + tracing::debug!( + "Recent AddToCredits: {} current={} + add={} = {}", + platform_addr.to_bech32m_string(self.network), + current_balance, + credits, + current_balance.saturating_add(credits) + ); + current_balance.saturating_add(credits) + } + }; + + if new_balance != current_balance { + provider.update_balance(&core_addr, new_balance); + let addr_str = platform_addr.to_bech32m_string(self.network); + tracing::info!( + "Recent update: {} balance {} -> {}", + addr_str, + current_balance, + new_balance + ); + updates_applied += 1; + } + } + } + } + } + + if updates_applied > 0 { + tracing::info!( + "Applied {} terminal balance updates from recent blocks (up to block {})", + updates_applied, + highest_block_seen + ); + } + + Ok(highest_block_seen) + } +} diff --git a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs new file mode 100644 index 000000000..ea593fa36 --- /dev/null +++ b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs @@ -0,0 +1,154 @@ +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::wallet::PlatformSyncMode; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::balances::credits::Credits; +use dash_sdk::dpp::dashcore::Address; +use dash_sdk::dpp::dashcore::hashes::Hash; +use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dash_sdk::dpp::prelude::AssetLockProof; +use std::collections::BTreeMap; +use std::sync::Arc; + +impl AppContext { + /// Fund Platform addresses from an asset lock + pub(crate) async fn fund_platform_address_from_asset_lock( + self: &Arc, + seed_hash: WalletSeedHash, + asset_lock_proof: AssetLockProof, + asset_lock_address: Address, + outputs: BTreeMap>, + ) -> Result { + use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; + use dash_sdk::dpp::dashcore::OutPoint; + use dash_sdk::platform::transition::top_up_address::TopUpAddress; + + // Clone wallet and SDK before the async operation to avoid holding guards across await + let (wallet, sdk, asset_lock_private_key) = { + let wallet_arc = { + let wallets = self.wallets.read().unwrap(); + wallets + .get(&seed_hash) + .cloned() + .ok_or_else(|| "Wallet not found".to_string())? + }; + let wallet = wallet_arc.read().map_err(|e| e.to_string())?.clone(); + let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); + + // Get the private key for the asset lock address + let private_key = wallet + .private_key_for_address(&asset_lock_address, self.network) + .map_err(|e| format!("Failed to get private key: {}", e))? + .ok_or_else(|| "Asset lock address not found in wallet".to_string())?; + + (wallet, sdk, private_key) + }; + + // Check if we need to convert an old instant lock proof to a chain lock proof + use dash_sdk::dashcore_rpc::RpcApi; + use dash_sdk::dpp::block::extended_epoch_info::ExtendedEpochInfo; + use dash_sdk::platform::Fetch; + + let asset_lock_proof = if let AssetLockProof::Instant(instant_asset_lock_proof) = + &asset_lock_proof + { + // Get the transaction ID from the instant lock proof + let tx_id = instant_asset_lock_proof.transaction().txid(); + + // Query the core client to check if the transaction has been chain-locked + let raw_transaction_info = self + .core_client + .read() + .expect("Core client lock was poisoned") + .get_raw_transaction_info(&tx_id, None) + .map_err(|e| format!("Failed to get transaction info: {}", e))?; + + if raw_transaction_info.chainlock + && raw_transaction_info.height.is_some() + && raw_transaction_info.confirmations.is_some() + && raw_transaction_info.confirmations.unwrap() > 8 + { + // Transaction has been chain-locked with sufficient confirmations + let tx_block_height = raw_transaction_info.height.unwrap() as u32; + + // Check if the platform has caught up to this block height + let (_, metadata) = ExtendedEpochInfo::fetch_with_metadata(&sdk, 0, None) + .await + .map_err(|e| format!("Failed to get platform metadata: {}", e))?; + + if tx_block_height <= metadata.core_chain_locked_height { + // Platform has synced past this block, use chain lock proof + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: tx_block_height, + out_point: OutPoint::new(tx_id, 0), + }) + } else { + // Platform hasn't verified this Core block yet - can't use chain lock proof + // and instant lock is stale. User needs to wait. + return Err(format!( + "Cannot use this asset lock yet. The instant lock proof has expired (quorum rotated), \ + and Platform hasn't verified Core block {} yet (Platform has verified up to Core block {}). \ + Please wait for Platform to sync with Core chain.", + tx_block_height, metadata.core_chain_locked_height + )); + } + } else { + // Use the instant lock proof as-is (transaction is recent) + asset_lock_proof + } + } else { + // Already a chain lock proof, use as-is + asset_lock_proof + }; + + // Simple fee strategy: reduce from first output + let fee_strategy = vec![AddressFundsFeeStrategyStep::ReduceOutput(0)]; + + // Get the transaction ID before consuming the asset lock proof + let tx_id = match &asset_lock_proof { + AssetLockProof::Instant(instant) => instant.transaction().txid(), + AssetLockProof::Chain(chain) => chain.out_point.txid, + }; + + // Use the SDK to top up Platform addresses from asset lock + let _result = outputs + .top_up( + &sdk, + asset_lock_proof, + asset_lock_private_key, + fee_strategy, + &wallet, + None, + ) + .await + .map_err(|e| format!("Failed to fund Platform address from asset lock: {}", e))?; + + // Remove the used asset lock from the wallet and database + { + let wallet_arc = { + let wallets = self.wallets.read().unwrap(); + wallets.get(&seed_hash).cloned() + }; + if let Some(wallet_arc) = wallet_arc { + let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; + wallet + .unused_asset_locks + .retain(|(tx, _, _, _, _)| tx.txid() != tx_id); + } + // Also remove from database + if let Err(e) = self + .db + .delete_asset_lock_transaction(&tx_id.to_byte_array()) + { + tracing::warn!("Failed to delete asset lock from database: {}", e); + } + } + + // Trigger a balance refresh + self.fetch_platform_address_balances(seed_hash, PlatformSyncMode::Auto) + .await?; + + Ok(BackendTaskSuccessResult::PlatformAddressFunded { seed_hash }) + } +} diff --git a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs new file mode 100644 index 000000000..12edbfa43 --- /dev/null +++ b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs @@ -0,0 +1,194 @@ +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::wallet::PlatformSyncMode; +use crate::context::AppContext; +use crate::model::fee_estimation::PlatformFeeEstimator; +use crate::model::wallet::WalletSeedHash; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::prelude::AssetLockProof; +use std::sync::Arc; +use std::time::Duration; + +impl AppContext { + /// Fund a platform address directly from wallet UTXOs. + /// Creates an asset lock, broadcasts it, waits for confirmation, then funds the destination. + /// + /// If `fee_deduct_from_output` is true, fees are deducted from the amount (recipient receives less). + /// If `fee_deduct_from_output` is false, fees are paid from extra wallet balance (recipient receives exact amount). + pub(crate) async fn fund_platform_address_from_wallet_utxos( + self: &Arc, + seed_hash: WalletSeedHash, + amount: u64, + destination: PlatformAddress, + fee_deduct_from_output: bool, + ) -> Result { + use dash_sdk::dashcore_rpc::RpcApi; + use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; + use dash_sdk::platform::transition::top_up_address::TopUpAddress; + + // When fee_deduct_from_output is false, we need to create a larger asset lock + // that includes the estimated platform fee, so the recipient receives the exact amount. + let (asset_lock_amount, allow_take_fee_from_amount) = if fee_deduct_from_output { + // Fees deducted from output: use the requested amount, allow core fee to be taken from it + (amount, true) + } else { + // Fees paid from wallet: add estimated platform fee to asset lock amount + let estimated_platform_fee_duffs = + PlatformFeeEstimator::new().estimate_address_funding_from_asset_lock_duffs(1); + let asset_lock_amount = amount.saturating_add(estimated_platform_fee_duffs); + (asset_lock_amount, false) + }; + + // Step 1: Create the asset lock transaction + let (asset_lock_transaction, asset_lock_private_key, _asset_lock_address, used_utxos) = { + let wallet_arc = { + let wallets = self.wallets.read().unwrap(); + wallets + .get(&seed_hash) + .cloned() + .ok_or_else(|| "Wallet not found".to_string())? + }; + + let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; + + // Try to create the asset lock transaction, reload UTXOs if needed + match wallet.generic_asset_lock_transaction( + self.network, + asset_lock_amount, + allow_take_fee_from_amount, + Some(self), + ) { + Ok((tx, private_key, address, _change, utxos)) => (tx, private_key, address, utxos), + Err(_) => { + // Reload UTXOs and try again + wallet + .reload_utxos( + &self + .core_client + .read() + .expect("Core client lock was poisoned"), + self.network, + Some(self), + ) + .map_err(|e| e.to_string())?; + + let (tx, private_key, address, _change, utxos) = wallet + .generic_asset_lock_transaction( + self.network, + asset_lock_amount, + allow_take_fee_from_amount, + Some(self), + )?; + (tx, private_key, address, utxos) + } + } + }; + + let tx_id = asset_lock_transaction.txid(); + + // Step 2: Register this transaction as waiting for finality + { + let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); + proofs.insert(tx_id, None); + } + + // Step 3: Broadcast the transaction + self.core_client + .read() + .expect("Core client lock was poisoned") + .send_raw_transaction(&asset_lock_transaction) + .map_err(|e| format!("Failed to broadcast asset lock transaction: {}", e))?; + + // Step 4: Remove used UTXOs from wallet + { + let wallet_arc = { + let wallets = self.wallets.read().unwrap(); + wallets + .get(&seed_hash) + .cloned() + .ok_or_else(|| "Wallet not found".to_string())? + }; + + let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; + wallet.utxos.retain(|_, utxo_map| { + utxo_map.retain(|outpoint, _| !used_utxos.contains_key(outpoint)); + !utxo_map.is_empty() + }); + + for utxo in used_utxos.keys() { + self.db + .drop_utxo(utxo, &self.network.to_string()) + .map_err(|e| e.to_string())?; + } + + // Update address_balances for affected addresses + let affected_addresses: std::collections::BTreeSet<_> = + used_utxos.values().map(|(_, addr)| addr.clone()).collect(); + for address in affected_addresses { + // Recalculate balance from remaining UTXOs for this address + let new_balance = wallet + .utxos + .get(&address) + .map(|utxo_map| utxo_map.values().map(|tx_out| tx_out.value).sum()) + .unwrap_or(0); + let _ = wallet.update_address_balance(&address, new_balance, self); + } + } + + // Step 5: Wait for asset lock proof (InstantLock or ChainLock) + let asset_lock_proof: AssetLockProof; + loop { + { + let proofs = self.transactions_waiting_for_finality.lock().unwrap(); + if let Some(Some(proof)) = proofs.get(&tx_id) { + asset_lock_proof = proof.clone(); + break; + } + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + + // Step 6: Clean up the finality tracking + { + let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); + proofs.remove(&tx_id); + } + + // Step 7: Get wallet and SDK for the platform funding operation + let (wallet, sdk) = { + let wallet_arc = { + let wallets = self.wallets.read().unwrap(); + wallets + .get(&seed_hash) + .cloned() + .ok_or_else(|| "Wallet not found".to_string())? + }; + let wallet = wallet_arc.read().map_err(|e| e.to_string())?.clone(); + let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); + (wallet, sdk) + }; + + // Step 8: Fund the destination platform address + let mut outputs = std::collections::BTreeMap::new(); + outputs.insert(destination, None); // None means use all available funds + + let fee_strategy = vec![AddressFundsFeeStrategyStep::ReduceOutput(0)]; + + outputs + .top_up( + &sdk, + asset_lock_proof, + asset_lock_private_key, + fee_strategy, + &wallet, + None, + ) + .await + .map_err(|e| format!("Failed to fund platform address: {}", e))?; + + // Step 9: Refresh platform address balances + self.fetch_platform_address_balances(seed_hash, PlatformSyncMode::Auto) + .await?; + + Ok(BackendTaskSuccessResult::PlatformAddressFunded { seed_hash }) + } +} diff --git a/src/backend_task/wallet/generate_receive_address.rs b/src/backend_task/wallet/generate_receive_address.rs new file mode 100644 index 000000000..90b3c3b07 --- /dev/null +++ b/src/backend_task/wallet/generate_receive_address.rs @@ -0,0 +1,47 @@ +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::wallet::{DerivationPathReference, DerivationPathType, WalletSeedHash}; +use crate::spv::CoreBackendMode; +use std::sync::Arc; + +impl AppContext { + pub(crate) async fn generate_receive_address( + self: &Arc, + seed_hash: WalletSeedHash, + ) -> Result { + let wallet_arc = { + let wallets = self.wallets.read().unwrap(); + wallets + .get(&seed_hash) + .cloned() + .ok_or_else(|| "Wallet not found".to_string())? + }; + + let address_string = if self.core_backend_mode() == CoreBackendMode::Spv { + let derived = self + .spv_manager + .next_bip44_receive_address(seed_hash, 0) + .await?; + + let _ = self.register_spv_address( + &wallet_arc, + derived.address.clone(), + derived.derivation_path.clone(), + DerivationPathType::CLEAR_FUNDS, + DerivationPathReference::BIP44, + )?; + + derived.address.to_string() + } else { + let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; + wallet + .receive_address(self.network, false, Some(self))? + .to_string() + }; + + Ok(BackendTaskSuccessResult::GeneratedReceiveAddress { + seed_hash, + address: address_string, + }) + } +} diff --git a/src/backend_task/wallet/mod.rs b/src/backend_task/wallet/mod.rs new file mode 100644 index 000000000..649c12f85 --- /dev/null +++ b/src/backend_task/wallet/mod.rs @@ -0,0 +1,78 @@ +mod fetch_platform_address_balances; +mod fund_platform_address_from_asset_lock; +mod fund_platform_address_from_wallet_utxos; +mod generate_receive_address; +mod transfer_platform_credits; +mod withdraw_from_platform_address; + +use crate::model::wallet::WalletSeedHash; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::balances::credits::Credits; +use dash_sdk::dpp::dashcore::Address; +use dash_sdk::dpp::identity::core_script::CoreScript; +use dash_sdk::dpp::prelude::AssetLockProof; +use std::collections::BTreeMap; + +/// Controls how Platform address balance sync is performed +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PlatformSyncMode { + /// Automatically decide based on time since last full sync + #[default] + Auto, + /// Force a full sync (queries all addresses) + ForceFull, + /// Only do terminal sync using stored checkpoint (fails if no checkpoint exists) + TerminalOnly, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum WalletTask { + GenerateReceiveAddress { + seed_hash: WalletSeedHash, + }, + /// Fetch Platform address balances and nonces from Platform for a wallet + FetchPlatformAddressBalances { + seed_hash: WalletSeedHash, + sync_mode: PlatformSyncMode, + }, + /// Transfer credits between Platform addresses + TransferPlatformCredits { + seed_hash: WalletSeedHash, + /// Source addresses with amounts to transfer + inputs: BTreeMap, + /// Destination addresses with amounts + outputs: BTreeMap, + }, + /// Fund Platform addresses from an asset lock + FundPlatformAddressFromAssetLock { + seed_hash: WalletSeedHash, + /// Asset lock proof + asset_lock_proof: Box, + /// Address to fund (the asset lock address is the source) + asset_lock_address: Address, + /// Platform addresses and optional amounts to fund (None = distribute evenly) + outputs: BTreeMap>, + }, + /// Withdraw from Platform addresses to Core + WithdrawFromPlatformAddress { + seed_hash: WalletSeedHash, + /// Platform addresses and amounts to withdraw + inputs: BTreeMap, + /// Core script to receive the withdrawal (e.g., P2PKH script) + output_script: CoreScript, + /// Core fee per byte + core_fee_per_byte: u32, + }, + /// Fund a platform address directly from wallet UTXOs + /// Creates asset lock, broadcasts, waits for proof, then funds platform address + FundPlatformAddressFromWalletUtxos { + seed_hash: WalletSeedHash, + /// Amount in duffs to lock + amount: u64, + /// Destination platform address to fund + destination: PlatformAddress, + /// If true, fees are deducted from the output amount (recipient receives less). + /// If false, fees are paid from extra wallet balance (recipient receives exact amount). + fee_deduct_from_output: bool, + }, +} diff --git a/src/backend_task/wallet/transfer_platform_credits.rs b/src/backend_task/wallet/transfer_platform_credits.rs new file mode 100644 index 000000000..f9762f25a --- /dev/null +++ b/src/backend_task/wallet/transfer_platform_credits.rs @@ -0,0 +1,48 @@ +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::balances::credits::Credits; +use std::collections::BTreeMap; +use std::sync::Arc; + +impl AppContext { + /// Transfer credits between Platform addresses + pub(crate) async fn transfer_platform_credits( + self: &Arc, + seed_hash: WalletSeedHash, + inputs: BTreeMap, + outputs: BTreeMap, + ) -> Result { + use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; + use dash_sdk::platform::transition::transfer_address_funds::TransferAddressFunds; + + // Clone wallet and SDK before the async operation to avoid holding guards across await + let (wallet, sdk) = { + let wallet_arc = { + let wallets = self.wallets.read().unwrap(); + wallets + .get(&seed_hash) + .cloned() + .ok_or_else(|| "Wallet not found".to_string())? + }; + let wallet = wallet_arc.read().map_err(|e| e.to_string())?.clone(); + let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); + (wallet, sdk) + }; + + // Deduct fee from the first input address (not output, which may be too small) + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + // Use the SDK to transfer - returns proof-verified updated address infos + let address_infos = sdk + .transfer_address_funds(inputs, outputs, fee_strategy, &wallet, None) + .await + .map_err(|e| format!("Failed to transfer Platform credits: {}", e))?; + + // Update wallet balances from the proof-verified response (no extra fetch needed) + self.update_wallet_platform_address_info_from_sdk(seed_hash, &address_infos)?; + + Ok(BackendTaskSuccessResult::PlatformCreditsTransferred { seed_hash }) + } +} diff --git a/src/backend_task/wallet/withdraw_from_platform_address.rs b/src/backend_task/wallet/withdraw_from_platform_address.rs new file mode 100644 index 000000000..65268ff66 --- /dev/null +++ b/src/backend_task/wallet/withdraw_from_platform_address.rs @@ -0,0 +1,62 @@ +use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::wallet::PlatformSyncMode; +use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::balances::credits::Credits; +use dash_sdk::dpp::identity::core_script::CoreScript; +use std::collections::BTreeMap; +use std::sync::Arc; + +impl AppContext { + /// Withdraw from Platform addresses to Core + pub(crate) async fn withdraw_from_platform_address( + self: &Arc, + seed_hash: WalletSeedHash, + inputs: BTreeMap, + output_script: CoreScript, + core_fee_per_byte: u32, + ) -> Result { + use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; + use dash_sdk::dpp::withdrawal::Pooling; + use dash_sdk::platform::transition::address_credit_withdrawal::WithdrawAddressFunds; + + // Clone wallet and SDK before the async operation to avoid holding guards across await + let (wallet, sdk) = { + let wallet_arc = { + let wallets = self.wallets.read().unwrap(); + wallets + .get(&seed_hash) + .cloned() + .ok_or_else(|| "Wallet not found".to_string())? + }; + let wallet = wallet_arc.read().map_err(|e| e.to_string())?.clone(); + let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); + (wallet, sdk) + }; + + // Simple fee strategy: deduct from first input + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + // Use the SDK to withdraw + let _result = sdk + .withdraw_address_funds( + inputs, + None, // No change output + fee_strategy, + core_fee_per_byte, + Pooling::Never, + output_script, + &wallet, + None, + ) + .await + .map_err(|e| format!("Failed to withdraw from Platform address: {}", e))?; + + // Trigger a balance refresh + self.fetch_platform_address_balances(seed_hash, PlatformSyncMode::Auto) + .await?; + + Ok(BackendTaskSuccessResult::PlatformAddressWithdrawal { seed_hash }) + } +} diff --git a/src/config.rs b/src/config.rs index bb3452bb9..16b05c04e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,7 +28,7 @@ pub enum ConfigError { #[derive(Debug, Deserialize, Clone)] pub struct NetworkConfig { - /// Hostname of the Dash Platform node to connect to + /// Hostname of Dash Platform node to connect to pub dapi_addresses: String, /// Host of the Dash Core RPC interface pub core_host: String, diff --git a/src/context.rs b/src/context.rs index dc0d35288..ed0179693 100644 --- a/src/context.rs +++ b/src/context.rs @@ -2,15 +2,21 @@ use crate::app_dir::core_cookie_path; use crate::backend_task::contested_names::ScheduledDPNSVote; use crate::components::core_zmq_listener::ZMQConnectionEvent; use crate::config::{Config, NetworkConfig}; -use crate::context_provider::Provider; +use crate::context_provider::Provider as RpcProvider; +use crate::context_provider_spv::SpvProvider; use crate::database::Database; use crate::model::contested_name::ContestedName; use crate::model::password_info::PasswordInfo; use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::{DPNSNameInfo, QualifiedIdentity}; use crate::model::settings::Settings; -use crate::model::wallet::{Wallet, WalletSeedHash}; +use crate::model::wallet::single_key::{SingleKeyHash, SingleKeyWallet}; +use crate::model::wallet::{ + AddressInfo as WalletAddressInfo, DerivationPathReference, DerivationPathType, Wallet, + WalletSeedHash, WalletTransaction, +}; use crate::sdk_wrapper::initialize_sdk; +use crate::spv::{CoreBackendMode, SpvManager}; use crate::ui::RootScreenType; use crate::ui::tokens::tokens_screen::{IdentityTokenBalance, IdentityTokenIdentifier}; use crate::utils::tasks::TaskManager; @@ -26,18 +32,24 @@ use dash_sdk::dpp::data_contract::TokenConfiguration; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; +use dash_sdk::dpp::key_wallet::Network as WalletNetwork; +use dash_sdk::dpp::key_wallet::account::AccountType; +use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::{ + ManagedWalletInfo, wallet_info_interface::WalletInfoInterface, +}; use dash_sdk::dpp::prelude::{AssetLockProof, CoreBlockHeight}; use dash_sdk::dpp::state_transition::StateTransitionSigningOptions; use dash_sdk::dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions; use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; use dash_sdk::dpp::version::PlatformVersion; -use dash_sdk::dpp::version::v10::PLATFORM_V10; +use dash_sdk::dpp::version::v11::PLATFORM_V11; use dash_sdk::platform::{DataContract, Identifier}; use dash_sdk::query_types::IndexMap; use egui::Context; use rusqlite::Result; use std::collections::{BTreeMap, HashMap}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::{Arc, Mutex, RwLock, RwLockWriteGuard}; const ANIMATION_REFRESH_TIME: std::time::Duration = std::time::Duration::from_millis(100); @@ -56,17 +68,22 @@ pub struct AppContext { pub(crate) devnet_name: Option, pub(crate) db: Arc, pub(crate) sdk: RwLock, - pub(crate) config: RwLock, + // Context providers for SDK, so we can switch when backend mode changes + spv_context_provider: RwLock, + rpc_context_provider: RwLock, + pub(crate) config: Arc>, pub(crate) rx_zmq_status: Receiver, pub(crate) sx_zmq_status: Sender, pub(crate) zmq_connection_status: Mutex, pub(crate) dpns_contract: Arc, pub(crate) withdraws_contract: Arc, + pub(crate) dashpay_contract: Arc, pub(crate) token_history_contract: Arc, pub(crate) keyword_search_contract: Arc, pub(crate) core_client: RwLock, pub(crate) has_wallet: AtomicBool, pub(crate) wallets: RwLock>>>, + pub(crate) single_key_wallets: RwLock>>>, #[allow(dead_code)] // May be used for password validation pub(crate) password_info: Option, pub(crate) transactions_waiting_for_finality: Mutex>>, @@ -80,6 +97,15 @@ pub struct AppContext { cached_settings: RwLock>, // subtasks started by the app context, used for graceful shutdown pub(crate) subtasks: Arc, + pub(crate) spv_manager: Arc, + core_backend_mode: AtomicU8, + /// Pending wallet selection - set after creating/importing a wallet + /// so the wallet screen can auto-select the new wallet + pub(crate) pending_wallet_selection: Mutex>, + /// Currently selected HD wallet (persisted across screen navigation) + pub(crate) selected_wallet_hash: Mutex>, + /// Currently selected single key wallet (persisted across screen navigation) + pub(crate) selected_single_key_hash: Mutex>, } impl AppContext { @@ -98,13 +124,17 @@ impl AppContext { }; let network_config = config.config_for_network(network).clone()?; + let config_lock = Arc::new(RwLock::new(network_config.clone())); let (sx_zmq_status, rx_zmq_status) = crossbeam_channel::unbounded(); - // we create provider, but we need to set app context to it later, as we have a circular dependency - let provider = - Provider::new(db.clone(), network, &network_config).expect("Failed to initialize SDK"); + // Create both providers; bind to app context later (post construction) due to circularity + let spv_provider = + SpvProvider::new(db.clone(), network).expect("Failed to initialize SPV provider"); + let rpc_provider = RpcProvider::new(db.clone(), network, &network_config) + .expect("Failed to initialize RPC provider"); - let sdk = initialize_sdk(&network_config, network, provider.clone()); + // Default to SPV provider initially; UI can switch backend after + let sdk = initialize_sdk(&network_config, network, spv_provider.clone()); let platform_version = sdk.version(); let dpns_contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) @@ -122,6 +152,10 @@ impl AppContext { load_system_data_contract(SystemDataContract::KeywordSearch, platform_version) .expect("expected to get keyword search contract"); + let dashpay_contract = + load_system_data_contract(SystemDataContract::Dashpay, platform_version) + .expect("expected to get dashpay contract"); + let addr = format!( "http://{}:{}", network_config.core_host, network_config.core_rpc_port @@ -156,6 +190,13 @@ impl AppContext { .map(|w| (w.seed_hash(), Arc::new(RwLock::new(w)))) .collect(); + let single_key_wallets: BTreeMap<_, _> = db + .get_single_key_wallets(network) + .expect("expected to get single key wallets") + .into_iter() + .map(|w| (w.key_hash(), Arc::new(RwLock::new(w)))) + .collect(); + let developer_mode_enabled = config.developer_mode.unwrap_or(false); let animate = match developer_mode_enabled { @@ -166,32 +207,113 @@ impl AppContext { false => AtomicBool::new(true), // Animations are enabled by default }; + let spv_manager = match SpvManager::new(network, Arc::clone(&config_lock), subtasks.clone()) + { + Ok(manager) => manager, + Err(err) => { + tracing::error!(?err, ?network, "Failed to initialize SPV manager"); + return None; + } + }; + + // Load the use_local_spv_node setting and apply to SPV manager + let use_local_spv_node = db.get_use_local_spv_node().unwrap_or(false); + spv_manager.set_use_local_node(use_local_spv_node); + + // Load the core backend mode from settings, defaulting to SPV if not set + let saved_core_backend_mode = db + .get_settings() + .ok() + .flatten() + .map(|s| s.7) // core_backend_mode is the 8th element (index 7) + .unwrap_or(CoreBackendMode::Spv.as_u8()); + + // Load saved wallet selection, validating that the wallets still exist + let (saved_wallet_hash, saved_single_key_hash) = + db.get_selected_wallet_hashes().unwrap_or((None, None)); + + // Only use the saved hash if the wallet still exists + let selected_wallet_hash = saved_wallet_hash.filter(|h| wallets.contains_key(h)); + let selected_single_key_hash = + saved_single_key_hash.filter(|h| single_key_wallets.contains_key(h)); + let app_context = AppContext { network, developer_mode: AtomicBool::new(developer_mode_enabled), devnet_name: None, db, sdk: sdk.into(), - config: network_config.into(), + spv_context_provider: spv_provider.into(), + rpc_context_provider: rpc_provider.into(), + config: config_lock, sx_zmq_status, rx_zmq_status, dpns_contract: Arc::new(dpns_contract), withdraws_contract: Arc::new(withdrawal_contract), + dashpay_contract: Arc::new(dashpay_contract), token_history_contract: Arc::new(token_history_contract), keyword_search_contract: Arc::new(keyword_search_contract), core_client: core_client.into(), - has_wallet: (!wallets.is_empty()).into(), + has_wallet: (!wallets.is_empty() || !single_key_wallets.is_empty()).into(), wallets: RwLock::new(wallets), + single_key_wallets: RwLock::new(single_key_wallets), password_info, transactions_waiting_for_finality: Mutex::new(BTreeMap::new()), zmq_connection_status: Mutex::new(ZMQConnectionEvent::Disconnected), animate, cached_settings: RwLock::new(None), subtasks, + spv_manager, + core_backend_mode: AtomicU8::new(saved_core_backend_mode), + pending_wallet_selection: Mutex::new(None), + selected_wallet_hash: Mutex::new(selected_wallet_hash), + selected_single_key_hash: Mutex::new(selected_single_key_hash), }; let app_context = Arc::new(app_context); - provider.bind_app_context(app_context.clone()); + // Bind providers to the newly created app_context. + // Only the active provider is registered with the SDK here (SPV by default). + if let Err(e) = app_context + .spv_context_provider + .read() + .map_err(|_| "SPV provider lock poisoned".to_string()) + .and_then(|provider| provider.bind_app_context(app_context.clone())) + { + tracing::error!("Failed to bind SPV provider: {}", e); + return None; + } + + // If defaulting to RPC is desired, swap provider after binding. + if app_context.core_backend_mode() == CoreBackendMode::Rpc { + if let Err(e) = app_context + .rpc_context_provider + .read() + .map_err(|_| "RPC provider lock poisoned".to_string()) + .and_then(|provider| provider.bind_app_context(app_context.clone())) + { + tracing::error!("Failed to bind RPC provider: {}", e); + return None; + } + } else { + // Ensure SDK uses the SPV provider + let sdk_lock = match app_context.sdk.write() { + Ok(lock) => lock, + Err(_) => { + tracing::error!("SDK lock poisoned"); + return None; + } + }; + let provider = match app_context.spv_context_provider.read() { + Ok(p) => p.clone(), + Err(_) => { + tracing::error!("SPV provider lock poisoned"); + return None; + } + }; + sdk_lock.set_context_provider(provider); + } + + app_context.bootstrap_loaded_wallets(); Some(app_context) } @@ -209,6 +331,579 @@ impl AppContext { self.enable_animations(!enable); } + pub fn core_backend_mode(&self) -> CoreBackendMode { + self.core_backend_mode.load(Ordering::Relaxed).into() + } + + pub fn set_core_backend_mode(self: &Arc, mode: CoreBackendMode) { + self.core_backend_mode + .store(mode.as_u8(), Ordering::Relaxed); + + // Persist the mode to the database (hold the guard to ensure cache invalidation) + let _guard = self.invalidate_settings_cache(); + if let Err(e) = self.db.update_core_backend_mode(mode.as_u8()) { + tracing::error!("Failed to persist core backend mode: {}", e); + } + + // Switch SDK context provider to match the selected backend + match mode { + CoreBackendMode::Spv => { + // Make sure SPV provider knows about the app context + if let Err(e) = self + .spv_context_provider + .read() + .map_err(|_| "SPV provider lock poisoned".to_string()) + .and_then(|provider| provider.bind_app_context(Arc::clone(self))) + { + tracing::error!("Failed to bind SPV provider: {}", e); + return; + } + let sdk = match self.sdk.write() { + Ok(lock) => lock, + Err(_) => { + tracing::error!("SDK lock poisoned in set_core_backend_mode"); + return; + } + }; + let provider = match self.spv_context_provider.read() { + Ok(p) => p.clone(), + Err(_) => { + tracing::error!("SPV provider lock poisoned"); + return; + } + }; + sdk.set_context_provider(provider); + } + CoreBackendMode::Rpc => { + // RPC provider binding also sets itself on the SDK + if let Err(e) = self + .rpc_context_provider + .read() + .map_err(|_| "RPC provider lock poisoned".to_string()) + .and_then(|provider| provider.bind_app_context(Arc::clone(self))) + { + tracing::error!("Failed to bind RPC provider: {}", e); + } + } + } + } + + pub fn spv_manager(&self) -> &Arc { + &self.spv_manager + } + + pub fn clear_spv_data(&self) -> Result<(), String> { + self.spv_manager.clear_data_dir() + } + + pub fn clear_network_database(&self) -> Result<(), String> { + self.db + .clear_network_data(self.network) + .map_err(|e| e.to_string())?; + + if let Ok(mut wallets) = self.wallets.write() { + wallets.clear(); + } + + if let Ok(mut single_key_wallets) = self.single_key_wallets.write() { + single_key_wallets.clear(); + } + + self.has_wallet.store(false, Ordering::Relaxed); + + Ok(()) + } + + pub fn start_spv(self: &Arc) -> Result<(), String> { + self.spv_manager.start()?; + self.spv_setup_reconcile_listener(); + Ok(()) + } + + pub fn bootstrap_wallet_addresses(&self, wallet: &Arc>) { + if let Ok(mut guard) = wallet.write() + && guard.known_addresses.is_empty() + { + tracing::info!(wallet = %hex::encode(guard.seed_hash()), "Bootstrapping wallet addresses"); + guard.bootstrap_known_addresses(self); + } + } + + pub fn handle_wallet_unlocked(self: &Arc, wallet: &Arc>) { + if let Some((seed_hash, seed_bytes)) = Self::wallet_seed_snapshot(wallet) { + self.queue_spv_wallet_load(seed_hash, seed_bytes); + // Note: Platform address sync and Core UTXO refresh are NOT done automatically on unlock. + // User must explicitly click Refresh to update balances. + } + } + + pub fn handle_wallet_locked(self: &Arc, wallet: &Arc>) { + let seed_hash = match wallet.read() { + Ok(guard) => guard.seed_hash(), + Err(err) => { + tracing::warn!(error = %err, "Unable to read wallet during lock handling"); + return; + } + }; + self.queue_spv_wallet_unload(seed_hash); + } + + fn wallet_seed_snapshot(wallet: &Arc>) -> Option<(WalletSeedHash, [u8; 64])> { + let guard = wallet.read().ok()?; + if !guard.is_open() { + return None; + } + let seed_bytes = match guard.seed_bytes() { + Ok(bytes) => *bytes, + Err(err) => { + tracing::warn!(error = %err, wallet = %hex::encode(guard.seed_hash()), "Unable to snapshot wallet seed for SPV load"); + return None; + } + }; + Some((guard.seed_hash(), seed_bytes)) + } + + fn queue_spv_wallet_load(self: &Arc, seed_hash: WalletSeedHash, seed_bytes: [u8; 64]) { + let spv = Arc::clone(&self.spv_manager); + self.subtasks.spawn_sync(async move { + if let Err(error) = spv.load_wallet_from_seed(seed_hash, seed_bytes).await { + tracing::error!(seed = %hex::encode(seed_hash), %error, "Failed to load SPV wallet from seed"); + } + }); + } + + fn queue_spv_wallet_unload(self: &Arc, seed_hash: WalletSeedHash) { + let spv = Arc::clone(&self.spv_manager); + self.subtasks.spawn_sync(async move { + if let Err(error) = spv.unload_wallet(seed_hash).await { + tracing::error!(seed = %hex::encode(seed_hash), %error, "Failed to unload SPV wallet"); + } + }); + } + + /// Queue automatic discovery of identities derived from a wallet. + /// Checks identity indices 0 through max_identity_index for existing identities on the network. + pub fn queue_wallet_identity_discovery( + self: &Arc, + wallet: &Arc>, + max_identity_index: u32, + ) { + let ctx = Arc::clone(self); + let wallet_clone = Arc::clone(wallet); + self.subtasks.spawn_sync(async move { + if let Err(error) = ctx + .discover_identities_from_wallet(&wallet_clone, max_identity_index) + .await + { + tracing::warn!( + %error, + "Failed to discover identities from wallet" + ); + } + }); + } + + pub fn bootstrap_loaded_wallets(self: &Arc) { + let wallets: Vec<_> = { + let guard = self.wallets.read().unwrap(); + guard.values().cloned().collect() + }; + + for wallet in wallets { + self.bootstrap_wallet_addresses(&wallet); + self.handle_wallet_unlocked(&wallet); + } + } + + /// Update wallet platform address info from SDK-returned AddressInfos. + /// This uses the proof-verified data from SDK operations rather than fetching. + pub(crate) fn update_wallet_platform_address_info_from_sdk( + &self, + seed_hash: WalletSeedHash, + address_infos: &dash_sdk::query_types::AddressInfos, + ) -> Result<(), String> { + let wallet_arc = { + let wallets = self.wallets.read().unwrap(); + wallets + .get(&seed_hash) + .cloned() + .ok_or_else(|| "Wallet not found".to_string())? + }; + + let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; + + for (platform_addr, maybe_info) in address_infos.iter() { + if let Some(info) = maybe_info { + // Convert PlatformAddress to core Address using the network + let core_addr = platform_addr.to_address_with_network(self.network); + + // Update in-memory wallet state + wallet.set_platform_address_info(core_addr.clone(), info.balance, info.nonce); + + // Update database + if let Err(e) = self.db.set_platform_address_info( + &seed_hash, + &core_addr, + info.balance, + info.nonce, + &self.network, + ) { + tracing::warn!("Failed to store Platform address info in database: {}", e); + } + + tracing::debug!( + "Updated platform address {} balance={} nonce={} from SDK response", + core_addr, + info.balance, + info.nonce + ); + } + } + + Ok(()) + } + + pub(crate) fn register_spv_address( + &self, + wallet: &Arc>, + address: Address, + derivation_path: DerivationPath, + path_type: DerivationPathType, + path_reference: DerivationPathReference, + ) -> Result { + let mut guard = wallet.write().map_err(|e| e.to_string())?; + if guard.known_addresses.contains_key(&address) { + return Ok(false); + } + + let (path_reference, path_type) = + self.classify_derivation_metadata(&derivation_path, path_reference, path_type); + + let seed_hash = guard.seed_hash(); + + self.db + .add_address_if_not_exists( + &seed_hash, + &address, + &self.network, + &derivation_path, + path_reference, + path_type, + None, + ) + .map_err(|e| e.to_string())?; + + guard + .known_addresses + .insert(address.clone(), derivation_path.clone()); + guard.watched_addresses.insert( + derivation_path, + WalletAddressInfo { + address, + path_type, + path_reference, + }, + ); + + Ok(true) + } + + pub(crate) fn wallet_network_key(&self) -> WalletNetwork { + match self.network { + Network::Dash => WalletNetwork::Dash, + Network::Testnet => WalletNetwork::Testnet, + Network::Devnet => WalletNetwork::Devnet, + Network::Regtest => WalletNetwork::Regtest, + _ => WalletNetwork::Dash, + } + } + + fn sync_spv_account_addresses( + &self, + wallet_info: &ManagedWalletInfo, + wallet_arc: &Arc>, + ) { + let collection = wallet_info.accounts(); + + let mut inserted = 0u32; + for account in collection.all_accounts() { + let account_type = account.account_type.to_account_type(); + if matches!(account_type, AccountType::Standard { .. }) { + continue; + } + let Some((path_reference, path_type)) = Self::spv_account_metadata(&account_type) + else { + continue; + }; + + for address in account.account_type.all_addresses() { + if let Some(info) = account.get_address_info(&address) + && let Ok(true) = self.register_spv_address( + wallet_arc, + address.clone(), + info.path.clone(), + path_type, + path_reference, + ) + { + inserted += 1; + } + } + } + + if inserted > 0 { + tracing::debug!(added = inserted, "Registered SPV-managed addresses"); + } + } + + fn spv_account_metadata( + account_type: &AccountType, + ) -> Option<(DerivationPathReference, DerivationPathType)> { + match account_type { + AccountType::IdentityRegistration => Some(( + DerivationPathReference::BlockchainIdentityCreditRegistrationFunding, + DerivationPathType::CREDIT_FUNDING, + )), + AccountType::IdentityInvitation => Some(( + DerivationPathReference::BlockchainIdentityCreditInvitationFunding, + DerivationPathType::CREDIT_FUNDING, + )), + AccountType::IdentityTopUp { .. } | AccountType::IdentityTopUpNotBoundToIdentity => { + Some(( + DerivationPathReference::BlockchainIdentityCreditTopupFunding, + DerivationPathType::CREDIT_FUNDING, + )) + } + AccountType::Standard { .. } => Some(( + DerivationPathReference::BIP44, + DerivationPathType::CLEAR_FUNDS, + )), + _ => None, + } + } + + fn classify_derivation_metadata( + &self, + derivation_path: &DerivationPath, + default_ref: DerivationPathReference, + default_type: DerivationPathType, + ) -> (DerivationPathReference, DerivationPathType) { + let components = derivation_path.as_ref(); + if components.len() >= 5 + && matches!(components[0], ChildNumber::Hardened { index: 9 }) + && matches!(components[2], ChildNumber::Hardened { index: 5 }) + && matches!(components[3], ChildNumber::Hardened { .. }) + { + let hardened_leaf = matches!(components.last(), Some(ChildNumber::Hardened { .. })); + if !hardened_leaf { + return ( + DerivationPathReference::BlockchainIdentities, + DerivationPathType::SINGLE_USER_AUTHENTICATION, + ); + } + } + + (default_ref, default_type) + } + + /// Subscribe to SPV reconcile signals and debounce updates. + pub fn spv_setup_reconcile_listener(self: &Arc) { + use tokio::time::{Duration, Instant, sleep}; + let rx = self.spv_manager.register_reconcile_channel(); + let ctx = Arc::clone(self); + self.subtasks.spawn_sync(async move { + tokio::pin!(rx); + let mut last = Instant::now(); + loop { + tokio::select! { + maybe = rx.recv() => { + if maybe.is_none() { break; } + // simple debounce window + if last.elapsed() > Duration::from_millis(300) { + if let Err(e) = ctx.reconcile_spv_wallets().await { tracing::debug!("SPV reconcile error: {}", e); } + last = Instant::now(); + } else { + sleep(Duration::from_millis(300)).await; + if let Err(e) = ctx.reconcile_spv_wallets().await { tracing::debug!("SPV reconcile error: {}", e); } + last = Instant::now(); + } + } + } + } + }); + } + + /// Reconcile SPV wallet state into DET. + pub async fn reconcile_spv_wallets(&self) -> Result<(), String> { + let wm_arc = self.spv_manager.wallet(); + let wm = wm_arc.read().await; + let mapping = self.spv_manager.det_wallets_snapshot(); + + // Take a snapshot of known addresses per wallet so we can scope DB updates + let wallets_guard = self.wallets.read().unwrap(); + + for (seed_hash, wallet_id) in mapping.iter() { + // Log total balance for visibility + let balance = wm + .get_wallet_balance(wallet_id) + .map_err(|e| format!("get_wallet_balance failed: {e}"))?; + tracing::debug!(wallet = %hex::encode(seed_hash), confirmed = balance.confirmed, unconfirmed = balance.unconfirmed, total = balance.total, "SPV balance snapshot"); + + let Some(wallet_info) = wm.get_wallet_info(wallet_id) else { + continue; + }; + + let Some(wallet_arc) = wallets_guard.get(seed_hash).cloned() else { + continue; + }; + + self.sync_spv_account_addresses(wallet_info, &wallet_arc); + + if let Ok(mut wallet) = wallet_arc.write() { + wallet.update_spv_balances(balance.confirmed, balance.unconfirmed, balance.total); + // Persist balances to database + if let Err(e) = self.db.update_wallet_balances( + seed_hash, + balance.confirmed, + balance.unconfirmed, + balance.total, + ) { + tracing::warn!(wallet = %hex::encode(seed_hash), error = %e, "Failed to persist wallet balances"); + } + } + + // Get the wallet's known addresses (only update those to avoid cross-wallet churn) + let mut known_addresses: std::collections::BTreeSet = { + let w = wallet_arc.read().unwrap(); + w.known_addresses.keys().cloned().collect() + }; + + // Clear existing UTXOs for these addresses in this network + for addr in &known_addresses { + let _ = self.db.execute( + "DELETE FROM utxos WHERE address = ? AND network = ?", + rusqlite::params![addr.to_string(), self.network.to_string()], + ); + } + + // Read current UTXOs from SPV and re-insert, registering unknown addresses if derivation metadata is available + let utxos = wm + .wallet_utxos(wallet_id) + .map_err(|e| format!("wallet_utxos failed: {e}"))?; + + use dash_sdk::dpp::dashcore::Address as CoreAddress; + // no-op + + let mut per_address_sum: std::collections::BTreeMap = + Default::default(); + + for u in utxos { + // Best-effort accessors for outpoint/txout; adjust if API differs + // Try field access (common struct layout): `outpoint` + `txout` + let outpoint = u.outpoint; + let tx_out = u.txout.clone(); + + // Derive address from script + let address = match CoreAddress::from_script(&tx_out.script_pubkey, self.network) { + Ok(a) => a, + Err(_) => continue, + }; + + // If address unknown to DET, try to register using SPV metadata + if !known_addresses.contains(&address) { + let collection = wallet_info.accounts(); + let mut registered = false; + for acc in collection.all_accounts() { + if let Some(ai) = acc.get_address_info(&address) { + let account_type = acc.account_type.to_account_type(); + let (path_reference, path_type) = + Self::spv_account_metadata(&account_type).unwrap_or(( + DerivationPathReference::BIP44, + DerivationPathType::CLEAR_FUNDS, + )); + + if let Ok(inserted) = self.register_spv_address( + &wallet_arc, + address.clone(), + ai.path.clone(), + path_type, + path_reference, + ) { + if inserted { + known_addresses.insert(address.clone()); + } + registered = true; + } + break; + } + } + if !registered { + continue; + } + } + + // Insert UTXO row + self.db + .insert_utxo( + outpoint.txid.as_ref(), + outpoint.vout, + &address, + tx_out.value, + &tx_out.script_pubkey.to_bytes(), + self.network, + ) + .map_err(|e| e.to_string())?; + + // Sum per address for balance update + *per_address_sum.entry(address).or_default() += tx_out.value; + } + + // Write per-address balances into DB and wallet model + if let Some(wref) = wallets_guard.get(seed_hash) + && let Ok(mut w) = wref.write() + { + for (addr, sum) in per_address_sum.into_iter() { + // Update wallet and DB through model helper + let _ = w.update_address_balance(&addr, sum, self); + } + } + + let history = wm + .wallet_transaction_history(wallet_id) + .map_err(|e| format!("wallet_transaction_history failed: {e}"))?; + let wallet_transactions: Vec = history + .into_iter() + .map(|record| WalletTransaction { + txid: record.txid, + transaction: record.transaction.clone(), + timestamp: record.timestamp, + height: record.height, + block_hash: record.block_hash, + net_amount: record.net_amount, + fee: record.fee, + label: record.label.clone(), + is_ours: record.is_ours, + }) + .collect(); + + self.db + .replace_wallet_transactions(seed_hash, &self.network, &wallet_transactions) + .map_err(|e| e.to_string())?; + + if let Some(wref) = wallets_guard.get(seed_hash) + && let Ok(mut wallet) = wref.write() + { + wallet.set_transactions(wallet_transactions.clone()); + } + } + + Ok(()) + } + + pub fn stop_spv(&self) { + self.spv_manager.stop(); + } + pub fn is_developer_mode(&self) -> bool { self.developer_mode.load(Ordering::Relaxed) } @@ -248,7 +943,10 @@ impl AppContext { pub fn reinit_core_client_and_sdk(self: Arc) -> Result<(), String> { // 1. Grab a fresh snapshot of your NetworkConfig let cfg = { - let cfg_lock = self.config.read().unwrap(); + let cfg_lock = self + .config + .read() + .map_err(|_| "Config lock poisoned".to_string())?; cfg_lock.clone() }; @@ -262,26 +960,71 @@ impl AppContext { ) .map_err(|e| format!("Failed to create new Core RPC client: {e}"))?; - // 3. Rebuild the Sdk with the updated config - let provider = Provider::new(self.db.clone(), self.network, &cfg) - .map_err(|e| format!("Failed to init provider: {e}"))?; - let new_sdk = initialize_sdk(&cfg, self.network, provider.clone()); + // 3. Rebuild the Sdk with the updated config and current backend mode + let new_sdk = match self.core_backend_mode() { + CoreBackendMode::Spv => { + // Reuse existing SPV provider (rebinding below to ensure context is set) + let provider = self + .spv_context_provider + .read() + .map_err(|_| "SPV provider lock poisoned".to_string())? + .clone(); + initialize_sdk(&cfg, self.network, provider) + } + CoreBackendMode::Rpc => { + // Create a fresh RPC provider with the new config + let rpc_provider = RpcProvider::new(self.db.clone(), self.network, &cfg) + .map_err(|e| format!("Failed to init RPC provider: {e}"))?; + // Swap in the updated RPC provider for future switches + { + let mut guard = self + .rpc_context_provider + .write() + .map_err(|_| "RPC provider lock poisoned".to_string())?; + *guard = rpc_provider.clone(); + } + initialize_sdk(&cfg, self.network, rpc_provider) + } + }; // 4. Swap them in { let mut client_lock = self .core_client .write() - .expect("Core client lock was poisoned"); + .map_err(|_| "Core client lock poisoned".to_string())?; *client_lock = new_client; } { - let mut sdk_lock = self.sdk.write().unwrap(); + let mut sdk_lock = self + .sdk + .write() + .map_err(|_| "SDK lock poisoned".to_string())?; *sdk_lock = new_sdk; } - // Rebind the provider to the new app context - provider.bind_app_context(self.clone()); + // Rebind providers to ensure they hold the new AppContext reference + self.spv_context_provider + .read() + .map_err(|_| "SPV provider lock poisoned".to_string())? + .bind_app_context(self.clone())?; + if self.core_backend_mode() == CoreBackendMode::Rpc { + self.rpc_context_provider + .read() + .map_err(|_| "RPC provider lock poisoned".to_string())? + .bind_app_context(self.clone())?; + } else { + let sdk_lock = self + .sdk + .write() + .map_err(|_| "SDK lock poisoned".to_string())?; + let provider = self + .spv_context_provider + .read() + .map_err(|_| "SPV provider lock poisoned".to_string())? + .clone(); + sdk_lock.set_context_provider(provider); + } Ok(()) } @@ -513,6 +1256,12 @@ impl AppContext { .update_dash_core_execution_settings(custom_dash_qt_path, overwrite_dash_conf) } + /// Updates the disable_zmq flag in settings + pub fn update_disable_zmq(&self, disable: bool) -> Result<()> { + let _guard = self.invalidate_settings_cache(); + self.db.update_disable_zmq(disable) + } + /// Invalidates the settings cache and returns a guard /// /// The cache is invalidated immediately and the guard prevents concurrent access @@ -598,6 +1347,15 @@ impl AppContext { // Insert the keyword search contract at 3 contracts.insert(3, keyword_search_contract); + // Add the DashPay contract to the list + let dashpay_contract = QualifiedContract { + contract: Arc::clone(&self.dashpay_contract).as_ref().clone(), + alias: Some("dashpay".to_string()), + }; + + // Insert the DashPay contract at 4 + contracts.insert(4, dashpay_contract); + Ok(contracts) } @@ -679,9 +1437,45 @@ impl AppContext { wallet .address_balances - .entry(address) + .entry(address.clone()) .and_modify(|balance| *balance += tx_out.value) .or_insert(tx_out.value); + + // Check if this is a DashPay contact payment + if let Ok(Some((owner_id, contact_id, address_index))) = + self.db.get_dashpay_address_mapping(&address) + { + // Update the highest receive index if needed + if let Ok(indices) = self.db.get_contact_address_indices(&owner_id, &contact_id) + && address_index >= indices.highest_receive_index + { + let _ = self.db.update_highest_receive_index( + &owner_id, + &contact_id, + address_index + 1, + ); + } + + // Save the payment record + let _ = self.db.save_payment( + &tx.txid().to_string(), + &contact_id, // from contact + &owner_id, // to us + tx_out.value as i64, + None, // memo not available for incoming + "received", + ); + + tracing::info!( + "DashPay payment received: {} duffs from contact {} to address {} (index {})", + tx_out.value, + contact_id.to_string( + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58 + ), + address, + address_index + ); + } } } if matches!( @@ -884,10 +1678,10 @@ impl AppContext { pub(crate) const fn default_platform_version(network: &Network) -> &'static PlatformVersion { // TODO: Use self.sdk.read().unwrap().version() instead of hardcoding match network { - Network::Dash => &PLATFORM_V10, - Network::Testnet => &PLATFORM_V10, - Network::Devnet => &PLATFORM_V10, - Network::Regtest => &PLATFORM_V10, + Network::Dash => &PLATFORM_V11, + Network::Testnet => &PLATFORM_V11, + Network::Devnet => &PLATFORM_V11, + Network::Regtest => &PLATFORM_V11, _ => panic!("unsupported network"), } } diff --git a/src/context_provider.rs b/src/context_provider.rs index c100a6424..483ae7469 100644 --- a/src/context_provider.rs +++ b/src/context_provider.rs @@ -56,15 +56,24 @@ impl Provider { }) } /// Set app context to the provider. - pub fn bind_app_context(&self, app_context: Arc) { + /// + /// Returns an error if any lock is poisoned (indicates a prior panic). + pub fn bind_app_context(&self, app_context: Arc) -> Result<(), String> { // order matters - can cause deadlock let cloned = app_context.clone(); - let mut ac = self.app_context.lock().expect("lock poisoned"); + let mut ac = self + .app_context + .lock() + .map_err(|_| "Provider app_context lock poisoned".to_string())?; ac.replace(cloned); drop(ac); - let sdk = app_context.sdk.write().expect("lock poisoned"); + let sdk = app_context + .sdk + .write() + .map_err(|_| "SDK lock poisoned".to_string())?; sdk.set_context_provider(self.clone()); + Ok(()) } } @@ -74,13 +83,18 @@ impl ContextProvider for Provider { data_contract_id: &dash_sdk::platform::Identifier, _platform_version: &PlatformVersion, ) -> Result>, dash_sdk::error::ContextProviderError> { - let app_ctx_guard = self.app_context.lock().expect("lock poisoned"); + let app_ctx_guard = self + .app_context + .lock() + .map_err(|_| ContextProviderError::Config("Provider lock poisoned".to_string()))?; let app_ctx = app_ctx_guard .as_ref() .ok_or(ContextProviderError::Config("no app context".to_string()))?; if data_contract_id == &app_ctx.dpns_contract.id() { Ok(Some(app_ctx.dpns_contract.clone())) + } else if data_contract_id == &app_ctx.dashpay_contract.id() { + Ok(Some(app_ctx.dashpay_contract.clone())) } else if data_contract_id == &app_ctx.token_history_contract.id() { Ok(Some(app_ctx.token_history_contract.clone())) } else if data_contract_id == &app_ctx.withdraws_contract.id() { @@ -104,7 +118,10 @@ impl ContextProvider for Provider { token_id: &dash_sdk::platform::Identifier, ) -> Result, ContextProviderError> { - let app_ctx_guard = self.app_context.lock().expect("lock poisoned"); + let app_ctx_guard = self + .app_context + .lock() + .map_err(|_| ContextProviderError::Config("Provider lock poisoned".to_string()))?; let app_ctx = app_ctx_guard .as_ref() .ok_or(ContextProviderError::Config("no app context".to_string()))?; @@ -117,12 +134,12 @@ impl ContextProvider for Provider { fn get_quorum_public_key( &self, quorum_type: u32, - quorum_hash: [u8; 32], // quorum hash is 32 bytes + quorum_hash: [u8; 32], _core_chain_locked_height: u32, - ) -> std::result::Result<[u8; 48], dash_sdk::error::ContextProviderError> { - let key = self.core.get_quorum_public_key(quorum_type, quorum_hash)?; - - Ok(key) + ) -> std::result::Result<[u8; 48], ContextProviderError> { + self.core + .get_quorum_public_key(quorum_type, quorum_hash) + .map_err(|e| ContextProviderError::Generic(e.to_string())) } fn get_platform_activation_height( @@ -137,11 +154,26 @@ impl ContextProvider for Provider { impl Clone for Provider { fn clone(&self) -> Self { - let app_guard = self.app_context.lock().expect("lock poisoned"); + // Clone trait doesn't allow returning Result, so we use a fallback + // If the lock is poisoned, clone with None app_context (will require rebinding) + let app_context_clone = self + .app_context + .lock() + .map(|guard| guard.clone()) + .unwrap_or_else(|poisoned| { + tracing::warn!("Provider lock poisoned during clone, using fallback"); + poisoned.into_inner().clone() + }); Self { core: self.core.clone(), db: self.db.clone(), - app_context: Mutex::new(app_guard.clone()), + app_context: Mutex::new(app_context_clone), } } } + +impl std::fmt::Debug for Provider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Provider").finish() + } +} diff --git a/src/context_provider_spv.rs b/src/context_provider_spv.rs new file mode 100644 index 000000000..a75555269 --- /dev/null +++ b/src/context_provider_spv.rs @@ -0,0 +1,142 @@ +use crate::context::AppContext; +use crate::database::Database; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::error::ContextProviderError; +use dash_sdk::platform::{ContextProvider, DataContract}; +use std::sync::{Arc, Mutex}; + +/// SPV-based ContextProvider for the Dash SDK. +/// +/// - DataContract and TokenConfiguration are served from the local DB (same as RPC provider) +/// - Quorum public keys are resolved via dash-spv (through SpvManager) when in SPV mode +#[derive(Debug)] +pub(crate) struct SpvProvider { + db: Arc, + app_context: Mutex>>, + _network: Network, +} + +impl SpvProvider { + pub fn new(db: Arc, network: Network) -> Result { + Ok(Self { + db, + app_context: Default::default(), + _network: network, + }) + } + + /// Attach the `AppContext` so we can access SpvManager and settings. + /// + /// Returns an error if the lock is poisoned (indicates a prior panic). + pub fn bind_app_context(&self, app_context: Arc) -> Result<(), String> { + let mut ac = self + .app_context + .lock() + .map_err(|_| "SpvProvider app_context lock poisoned".to_string())?; + ac.replace(app_context); + Ok(()) + } +} + +impl ContextProvider for SpvProvider { + fn get_data_contract( + &self, + data_contract_id: &dash_sdk::platform::Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + let app_ctx_guard = self + .app_context + .lock() + .map_err(|_| ContextProviderError::Config("SpvProvider lock poisoned".to_string()))?; + let app_ctx = app_ctx_guard + .as_ref() + .ok_or(ContextProviderError::Config("no app context".to_string()))?; + + if data_contract_id == &app_ctx.dpns_contract.id() { + Ok(Some(app_ctx.dpns_contract.clone())) + } else if data_contract_id == &app_ctx.token_history_contract.id() { + Ok(Some(app_ctx.token_history_contract.clone())) + } else if data_contract_id == &app_ctx.withdraws_contract.id() { + Ok(Some(app_ctx.withdraws_contract.clone())) + } else if data_contract_id == &app_ctx.keyword_search_contract.id() { + Ok(Some(app_ctx.keyword_search_contract.clone())) + } else { + let dc = self + .db + .get_contract_by_id(*data_contract_id, app_ctx.as_ref()) + .map_err(|e| ContextProviderError::Generic(e.to_string()))?; + + drop(app_ctx_guard); + + Ok(dc.map(|qc| Arc::new(qc.contract))) + } + } + + fn get_token_configuration( + &self, + token_id: &dash_sdk::platform::Identifier, + ) -> Result, ContextProviderError> + { + let app_ctx_guard = self + .app_context + .lock() + .map_err(|_| ContextProviderError::Config("SpvProvider lock poisoned".to_string()))?; + let app_ctx = app_ctx_guard + .as_ref() + .ok_or(ContextProviderError::Config("no app context".to_string()))?; + + self.db + .get_token_config_for_id(token_id, app_ctx) + .map_err(|e| ContextProviderError::Generic(e.to_string())) + } + + fn get_quorum_public_key( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + let app_ctx_guard = self + .app_context + .lock() + .map_err(|_| ContextProviderError::Config("SpvProvider lock poisoned".to_string()))?; + let app_ctx = app_ctx_guard + .as_ref() + .ok_or(ContextProviderError::Config("no app context".to_string()))?; + + let spv_manager = app_ctx.spv_manager(); + + spv_manager + .get_quorum_public_key(quorum_type, quorum_hash, core_chain_locked_height) + .map_err(ContextProviderError::Generic) + } + + fn get_platform_activation_height( + &self, + ) -> Result { + // TODO: wire actual activation height if needed + Ok(1) + } +} + +impl Clone for SpvProvider { + fn clone(&self) -> Self { + // Clone trait doesn't allow returning Result, so we use a fallback + // If the lock is poisoned, clone with None app_context (will require rebinding) + let app_context_clone = self + .app_context + .lock() + .map(|guard| guard.clone()) + .unwrap_or_else(|poisoned| { + tracing::warn!("SpvProvider lock poisoned during clone, using fallback"); + poisoned.into_inner().clone() + }); + Self { + db: self.db.clone(), + app_context: Mutex::new(app_context_clone), + _network: self._network, + } + } +} diff --git a/src/database/asset_lock_transaction.rs b/src/database/asset_lock_transaction.rs index f93692e72..f3d6f44c8 100644 --- a/src/database/asset_lock_transaction.rs +++ b/src/database/asset_lock_transaction.rs @@ -184,9 +184,8 @@ impl Database { Ok(()) } - /// Deletes an asset lock transaction by its transaction ID. - #[allow(dead_code)] // May be used for manual cleanup or testing purposes - pub fn delete_asset_lock_transaction(&self, txid: &str) -> rusqlite::Result<()> { + /// Deletes an asset lock transaction by its transaction ID (as bytes). + pub fn delete_asset_lock_transaction(&self, txid: &[u8; 32]) -> rusqlite::Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( diff --git a/src/database/contacts.rs b/src/database/contacts.rs new file mode 100644 index 000000000..6966577ee --- /dev/null +++ b/src/database/contacts.rs @@ -0,0 +1,153 @@ +use dash_sdk::platform::Identifier; +use rusqlite::{Connection, params}; + +#[derive(Debug, Clone)] +pub struct ContactPrivateInfo { + pub owner_identity_id: Vec, + pub contact_identity_id: Vec, + pub nickname: String, + pub notes: String, + pub is_hidden: bool, +} + +impl crate::database::Database { + pub fn init_contacts_tables(&self, conn: &Connection) -> rusqlite::Result<()> { + let sql = " + CREATE TABLE IF NOT EXISTS contact_private_info ( + owner_identity_id BLOB NOT NULL, + contact_identity_id BLOB NOT NULL, + nickname TEXT, + notes TEXT, + is_hidden INTEGER DEFAULT 0, + created_at INTEGER DEFAULT (unixepoch()), + updated_at INTEGER DEFAULT (unixepoch()), + PRIMARY KEY (owner_identity_id, contact_identity_id) + ); + "; + conn.execute(sql, [])?; + Ok(()) + } + + pub fn save_contact_private_info( + &self, + owner_identity_id: &Identifier, + contact_identity_id: &Identifier, + nickname: &str, + notes: &str, + is_hidden: bool, + ) -> rusqlite::Result<()> { + let sql = " + INSERT OR REPLACE INTO contact_private_info + (owner_identity_id, contact_identity_id, nickname, notes, is_hidden, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, unixepoch()) + "; + + self.execute( + sql, + params![ + owner_identity_id.to_buffer().to_vec(), + contact_identity_id.to_buffer().to_vec(), + nickname, + notes, + is_hidden as i32, + ], + )?; + Ok(()) + } + + pub fn load_contact_private_info( + &self, + owner_identity_id: &Identifier, + contact_identity_id: &Identifier, + ) -> rusqlite::Result<(String, String, bool)> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT nickname, notes, is_hidden FROM contact_private_info + WHERE owner_identity_id = ?1 AND contact_identity_id = ?2", + )?; + + let result = stmt.query_row( + params![ + owner_identity_id.to_buffer().to_vec(), + contact_identity_id.to_buffer().to_vec(), + ], + |row| { + Ok(( + row.get::<_, String>(0).unwrap_or_default(), + row.get::<_, String>(1).unwrap_or_default(), + row.get::<_, i32>(2).unwrap_or(0) != 0, + )) + }, + ); + + match result { + Ok(data) => Ok(data), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok((String::new(), String::new(), false)), + Err(e) => Err(e), + } + } + + pub fn load_all_contact_private_info( + &self, + owner_identity_id: &Identifier, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT owner_identity_id, contact_identity_id, nickname, notes, is_hidden + FROM contact_private_info + WHERE owner_identity_id = ?1", + )?; + + let infos = stmt + .query_map(params![owner_identity_id.to_buffer().to_vec()], |row| { + Ok(ContactPrivateInfo { + owner_identity_id: row.get(0)?, + contact_identity_id: row.get(1)?, + nickname: row.get(2)?, + notes: row.get(3)?, + is_hidden: row.get::<_, i32>(4)? != 0, + }) + })? + .collect::, _>>()?; + + Ok(infos) + } + + pub fn delete_contact_private_info( + &self, + owner_identity_id: &Identifier, + contact_identity_id: &Identifier, + ) -> rusqlite::Result<()> { + let sql = "DELETE FROM contact_private_info WHERE owner_identity_id = ?1 AND contact_identity_id = ?2"; + self.execute( + sql, + params![ + owner_identity_id.to_buffer().to_vec(), + contact_identity_id.to_buffer().to_vec(), + ], + )?; + Ok(()) + } + + /// Toggle or set the hidden status for a contact + /// Creates a new entry if one doesn't exist + pub fn set_contact_hidden( + &self, + owner_identity_id: &Identifier, + contact_identity_id: &Identifier, + is_hidden: bool, + ) -> rusqlite::Result<()> { + // First try to load existing info to preserve nickname and notes + let (nickname, notes, _) = + self.load_contact_private_info(owner_identity_id, contact_identity_id)?; + + // Save with updated hidden status + self.save_contact_private_info( + owner_identity_id, + contact_identity_id, + &nickname, + ¬es, + is_hidden, + ) + } +} diff --git a/src/database/dashpay.rs b/src/database/dashpay.rs new file mode 100644 index 000000000..84625e23f --- /dev/null +++ b/src/database/dashpay.rs @@ -0,0 +1,934 @@ +use dash_sdk::platform::Identifier; +use rusqlite::params; +use serde::{Deserialize, Serialize}; + +/// DashPay profile data stored locally +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredProfile { + pub identity_id: Vec, + pub display_name: Option, + pub bio: Option, + pub avatar_url: Option, + pub avatar_hash: Option>, + pub avatar_fingerprint: Option>, + pub avatar_bytes: Option>, + pub public_message: Option, + pub created_at: i64, + pub updated_at: i64, +} + +/// DashPay contact information stored locally +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredContact { + pub owner_identity_id: Vec, + pub contact_identity_id: Vec, + pub username: Option, + pub display_name: Option, + pub avatar_url: Option, + pub public_message: Option, + pub contact_status: String, // "pending", "accepted", "blocked" + pub created_at: i64, + pub updated_at: i64, + pub last_seen: Option, +} + +/// DashPay contact request stored locally +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredContactRequest { + pub id: i64, + pub from_identity_id: Vec, + pub to_identity_id: Vec, + pub to_username: Option, + pub account_label: Option, + pub request_type: String, // "sent", "received" + pub status: String, // "pending", "accepted", "rejected", "expired" + pub created_at: i64, + pub responded_at: Option, + pub expires_at: Option, +} + +/// DashPay payment/transaction record +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredPayment { + pub id: i64, + pub tx_id: String, + pub from_identity_id: Vec, + pub to_identity_id: Vec, + pub amount: i64, // in credits + pub memo: Option, + pub payment_type: String, // "sent", "received" + pub status: String, // "pending", "confirmed", "failed" + pub created_at: i64, + pub confirmed_at: Option, +} + +/// DashPay contact address index tracking per DIP-0015 +/// Tracks address indices used for sending/receiving payments per contact relationship +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContactAddressIndex { + pub owner_identity_id: Vec, + pub contact_identity_id: Vec, + /// Next address index to use when sending TO this contact + pub next_send_index: u32, + /// Highest address index seen when receiving FROM this contact (for bloom filter) + pub highest_receive_index: u32, + /// Number of addresses registered in bloom filter for this contact + pub bloom_registered_count: u32, +} + +impl crate::database::Database { + /// Initialize all DashPay-related database tables using a transaction + pub fn init_dashpay_tables_in_tx(&self, tx: &rusqlite::Connection) -> rusqlite::Result<()> { + // Profiles table + tx.execute( + "CREATE TABLE IF NOT EXISTS dashpay_profiles ( + identity_id BLOB NOT NULL, + network TEXT NOT NULL, + display_name TEXT, + bio TEXT, + avatar_url TEXT, + avatar_hash BLOB, + avatar_fingerprint BLOB, + avatar_bytes BLOB, + public_message TEXT, + created_at INTEGER DEFAULT (unixepoch()), + updated_at INTEGER DEFAULT (unixepoch()), + PRIMARY KEY (identity_id, network) + )", + [], + )?; + + // Contacts table (extends the existing contact_private_info) + tx.execute( + "CREATE TABLE IF NOT EXISTS dashpay_contacts ( + owner_identity_id BLOB NOT NULL, + contact_identity_id BLOB NOT NULL, + network TEXT NOT NULL, + username TEXT, + display_name TEXT, + avatar_url TEXT, + public_message TEXT, + contact_status TEXT DEFAULT 'pending', + created_at INTEGER DEFAULT (unixepoch()), + updated_at INTEGER DEFAULT (unixepoch()), + last_seen INTEGER, + PRIMARY KEY (owner_identity_id, contact_identity_id, network) + )", + [], + )?; + + // Contact requests table + tx.execute( + "CREATE TABLE IF NOT EXISTS dashpay_contact_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_identity_id BLOB NOT NULL, + to_identity_id BLOB NOT NULL, + network TEXT NOT NULL, + to_username TEXT, + account_label TEXT, + request_type TEXT NOT NULL CHECK (request_type IN ('sent', 'received')), + status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'expired')), + created_at INTEGER DEFAULT (unixepoch()), + responded_at INTEGER, + expires_at INTEGER + )", + [], + )?; + + // Create index for faster queries + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_contact_requests_from + ON dashpay_contact_requests(from_identity_id)", + [], + )?; + + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_contact_requests_to + ON dashpay_contact_requests(to_identity_id)", + [], + )?; + + // Payments/transactions table + tx.execute( + "CREATE TABLE IF NOT EXISTS dashpay_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tx_id TEXT UNIQUE NOT NULL, + from_identity_id BLOB NOT NULL, + to_identity_id BLOB NOT NULL, + amount INTEGER NOT NULL, + memo TEXT, + payment_type TEXT NOT NULL CHECK (payment_type IN ('sent', 'received')), + status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'failed')), + created_at INTEGER DEFAULT (unixepoch()), + confirmed_at INTEGER + )", + [], + )?; + + // Create index for faster queries + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_payments_from + ON dashpay_payments(from_identity_id)", + [], + )?; + + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_payments_to + ON dashpay_payments(to_identity_id)", + [], + )?; + + // Contact address index tracking table (DIP-0015) + // Tracks address indices per contact for payment derivation + tx.execute( + "CREATE TABLE IF NOT EXISTS dashpay_contact_address_indices ( + owner_identity_id BLOB NOT NULL, + contact_identity_id BLOB NOT NULL, + next_send_index INTEGER DEFAULT 0, + highest_receive_index INTEGER DEFAULT 0, + bloom_registered_count INTEGER DEFAULT 0, + PRIMARY KEY (owner_identity_id, contact_identity_id) + )", + [], + )?; + + // DashPay address mappings for incoming payment detection + // Maps addresses to contact relationships for transaction matching + tx.execute( + "CREATE TABLE IF NOT EXISTS dashpay_address_mappings ( + address TEXT PRIMARY KEY, + owner_identity_id BLOB NOT NULL, + contact_identity_id BLOB NOT NULL, + address_index INTEGER NOT NULL, + created_at INTEGER DEFAULT (unixepoch()) + )", + [], + )?; + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_dashpay_address_mappings_owner + ON dashpay_address_mappings(owner_identity_id)", + [], + )?; + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_dashpay_address_mappings_contact + ON dashpay_address_mappings(owner_identity_id, contact_identity_id)", + [], + )?; + + Ok(()) + } + + // Profile operations + + pub fn save_dashpay_profile( + &self, + identity_id: &Identifier, + network: &str, + display_name: Option<&str>, + bio: Option<&str>, + avatar_url: Option<&str>, + public_message: Option<&str>, + ) -> rusqlite::Result<()> { + // Use INSERT ... ON CONFLICT to preserve avatar_bytes when updating + let sql = " + INSERT INTO dashpay_profiles + (identity_id, network, display_name, bio, avatar_url, public_message, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, unixepoch()) + ON CONFLICT(identity_id, network) DO UPDATE SET + display_name = excluded.display_name, + bio = excluded.bio, + avatar_url = excluded.avatar_url, + public_message = excluded.public_message, + updated_at = unixepoch() + "; + + let result = self.execute( + sql, + params![ + identity_id.to_buffer().to_vec(), + network, + display_name, + bio, + avatar_url, + public_message, + ], + ); + + result?; + Ok(()) + } + + /// Save avatar bytes for a profile (called after fetching avatar from network) + pub fn save_dashpay_profile_avatar_bytes( + &self, + identity_id: &Identifier, + network: &str, + avatar_bytes: Option<&[u8]>, + ) -> rusqlite::Result<()> { + let sql = " + UPDATE dashpay_profiles + SET avatar_bytes = ?1, updated_at = unixepoch() + WHERE identity_id = ?2 AND network = ?3 + "; + + self.execute( + sql, + params![avatar_bytes, identity_id.to_buffer().to_vec(), network,], + )?; + Ok(()) + } + + pub fn load_dashpay_profile( + &self, + identity_id: &Identifier, + network: &str, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + + let mut stmt = conn.prepare( + "SELECT identity_id, display_name, bio, avatar_url, avatar_hash, + avatar_fingerprint, avatar_bytes, public_message, created_at, updated_at + FROM dashpay_profiles + WHERE identity_id = ?1 AND network = ?2", + )?; + + let result = stmt.query_row(params![identity_id.to_buffer().to_vec(), network], |row| { + Ok(StoredProfile { + identity_id: row.get(0)?, + display_name: row.get(1)?, + bio: row.get(2)?, + avatar_url: row.get(3)?, + avatar_hash: row.get(4)?, + avatar_fingerprint: row.get(5)?, + avatar_bytes: row.get(6)?, + public_message: row.get(7)?, + created_at: row.get(8)?, + updated_at: row.get(9)?, + }) + }); + + match result { + Ok(profile) => Ok(Some(profile)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } + + // Contact operations + + #[allow(clippy::too_many_arguments)] + pub fn save_dashpay_contact( + &self, + owner_identity_id: &Identifier, + contact_identity_id: &Identifier, + network: &str, + username: Option<&str>, + display_name: Option<&str>, + avatar_url: Option<&str>, + public_message: Option<&str>, + contact_status: &str, + ) -> rusqlite::Result<()> { + let sql = " + INSERT OR REPLACE INTO dashpay_contacts + (owner_identity_id, contact_identity_id, network, username, display_name, + avatar_url, public_message, contact_status, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, unixepoch()) + "; + + self.execute( + sql, + params![ + owner_identity_id.to_buffer().to_vec(), + contact_identity_id.to_buffer().to_vec(), + network, + username, + display_name, + avatar_url, + public_message, + contact_status, + ], + )?; + Ok(()) + } + + pub fn load_dashpay_contacts( + &self, + owner_identity_id: &Identifier, + network: &str, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT owner_identity_id, contact_identity_id, username, display_name, + avatar_url, public_message, contact_status, created_at, updated_at, last_seen + FROM dashpay_contacts + WHERE owner_identity_id = ?1 AND network = ?2 + ORDER BY updated_at DESC", + )?; + + let contacts = stmt + .query_map( + params![owner_identity_id.to_buffer().to_vec(), network], + |row| { + Ok(StoredContact { + owner_identity_id: row.get(0)?, + contact_identity_id: row.get(1)?, + username: row.get(2)?, + display_name: row.get(3)?, + avatar_url: row.get(4)?, + public_message: row.get(5)?, + contact_status: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + last_seen: row.get(9)?, + }) + }, + )? + .collect::, _>>()?; + + Ok(contacts) + } + + pub fn update_contact_last_seen( + &self, + owner_identity_id: &Identifier, + contact_identity_id: &Identifier, + network: &str, + ) -> rusqlite::Result<()> { + let sql = " + UPDATE dashpay_contacts + SET last_seen = unixepoch(), updated_at = unixepoch() + WHERE owner_identity_id = ?1 AND contact_identity_id = ?2 AND network = ?3 + "; + + self.execute( + sql, + params![ + owner_identity_id.to_buffer().to_vec(), + contact_identity_id.to_buffer().to_vec(), + network, + ], + )?; + Ok(()) + } + + /// Clear all contacts for a specific owner identity on a specific network + pub fn clear_dashpay_contacts( + &self, + owner_identity_id: &Identifier, + network: &str, + ) -> rusqlite::Result<()> { + let sql = "DELETE FROM dashpay_contacts WHERE owner_identity_id = ?1 AND network = ?2"; + + self.execute( + sql, + params![owner_identity_id.to_buffer().to_vec(), network], + )?; + Ok(()) + } + + // Contact request operations + + pub fn save_contact_request( + &self, + from_identity_id: &Identifier, + to_identity_id: &Identifier, + network: &str, + to_username: Option<&str>, + account_label: Option<&str>, + request_type: &str, + ) -> rusqlite::Result { + let sql = " + INSERT INTO dashpay_contact_requests + (from_identity_id, to_identity_id, network, to_username, account_label, request_type) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + "; + + let conn = self.conn.lock().unwrap(); + conn.execute( + sql, + params![ + from_identity_id.to_buffer().to_vec(), + to_identity_id.to_buffer().to_vec(), + network, + to_username, + account_label, + request_type, + ], + )?; + + Ok(conn.last_insert_rowid()) + } + + pub fn update_contact_request_status( + &self, + request_id: i64, + status: &str, + ) -> rusqlite::Result<()> { + let sql = " + UPDATE dashpay_contact_requests + SET status = ?1, responded_at = unixepoch() + WHERE id = ?2 + "; + + self.execute(sql, params![status, request_id])?; + Ok(()) + } + + pub fn load_pending_contact_requests( + &self, + identity_id: &Identifier, + network: &str, + request_type: &str, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let sql = if request_type == "sent" { + "SELECT id, from_identity_id, to_identity_id, to_username, account_label, + request_type, status, created_at, responded_at, expires_at + FROM dashpay_contact_requests + WHERE from_identity_id = ?1 AND network = ?2 AND request_type = 'sent' AND status = 'pending' + ORDER BY created_at DESC" + } else { + "SELECT id, from_identity_id, to_identity_id, to_username, account_label, + request_type, status, created_at, responded_at, expires_at + FROM dashpay_contact_requests + WHERE to_identity_id = ?1 AND network = ?2 AND request_type = 'received' AND status = 'pending' + ORDER BY created_at DESC" + }; + + let mut stmt = conn.prepare(sql)?; + let requests = stmt + .query_map(params![identity_id.to_buffer().to_vec(), network], |row| { + Ok(StoredContactRequest { + id: row.get(0)?, + from_identity_id: row.get(1)?, + to_identity_id: row.get(2)?, + to_username: row.get(3)?, + account_label: row.get(4)?, + request_type: row.get(5)?, + status: row.get(6)?, + created_at: row.get(7)?, + responded_at: row.get(8)?, + expires_at: row.get(9)?, + }) + })? + .collect::, _>>()?; + + Ok(requests) + } + + // Payment operations + + pub fn save_payment( + &self, + tx_id: &str, + from_identity_id: &Identifier, + to_identity_id: &Identifier, + amount: i64, + memo: Option<&str>, + payment_type: &str, + ) -> rusqlite::Result { + let sql = " + INSERT INTO dashpay_payments + (tx_id, from_identity_id, to_identity_id, amount, memo, payment_type) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + "; + + let conn = self.conn.lock().unwrap(); + conn.execute( + sql, + params![ + tx_id, + from_identity_id.to_buffer().to_vec(), + to_identity_id.to_buffer().to_vec(), + amount, + memo, + payment_type, + ], + )?; + + Ok(conn.last_insert_rowid()) + } + + pub fn update_payment_status(&self, payment_id: i64, status: &str) -> rusqlite::Result<()> { + let sql = if status == "confirmed" { + "UPDATE dashpay_payments + SET status = ?1, confirmed_at = unixepoch() + WHERE id = ?2" + } else { + "UPDATE dashpay_payments + SET status = ?1 + WHERE id = ?2" + }; + + self.execute(sql, params![status, payment_id])?; + Ok(()) + } + + pub fn load_payment_history( + &self, + identity_id: &Identifier, + limit: u32, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, tx_id, from_identity_id, to_identity_id, amount, memo, + payment_type, status, created_at, confirmed_at + FROM dashpay_payments + WHERE from_identity_id = ?1 OR to_identity_id = ?1 + ORDER BY created_at DESC + LIMIT ?2", + )?; + + let identity_bytes = identity_id.to_buffer().to_vec(); + let payments = stmt + .query_map(params![identity_bytes, limit], |row| { + Ok(StoredPayment { + id: row.get(0)?, + tx_id: row.get(1)?, + from_identity_id: row.get(2)?, + to_identity_id: row.get(3)?, + amount: row.get(4)?, + memo: row.get(5)?, + payment_type: row.get(6)?, + status: row.get(7)?, + created_at: row.get(8)?, + confirmed_at: row.get(9)?, + }) + })? + .collect::, _>>()?; + + Ok(payments) + } + + /// Delete all DashPay data for a specific identity + pub fn delete_dashpay_data_for_identity( + &self, + identity_id: &Identifier, + ) -> rusqlite::Result<()> { + let identity_bytes = identity_id.to_buffer().to_vec(); + + // Delete profile + self.execute( + "DELETE FROM dashpay_profiles WHERE identity_id = ?1", + params![&identity_bytes], + )?; + + // Delete contacts + self.execute( + "DELETE FROM dashpay_contacts WHERE owner_identity_id = ?1", + params![&identity_bytes], + )?; + + // Delete contact requests + self.execute( + "DELETE FROM dashpay_contact_requests + WHERE from_identity_id = ?1 OR to_identity_id = ?1", + params![&identity_bytes], + )?; + + // Delete payments + self.execute( + "DELETE FROM dashpay_payments + WHERE from_identity_id = ?1 OR to_identity_id = ?1", + params![&identity_bytes], + )?; + + // Delete contact address indices + self.execute( + "DELETE FROM dashpay_contact_address_indices WHERE owner_identity_id = ?1", + params![&identity_bytes], + )?; + + Ok(()) + } + + // Contact address index operations (DIP-0015) + + /// Get or create contact address index entry + /// Returns (next_send_index, highest_receive_index, bloom_registered_count) + pub fn get_contact_address_indices( + &self, + owner_identity_id: &Identifier, + contact_identity_id: &Identifier, + ) -> rusqlite::Result { + let conn = self.conn.lock().unwrap(); + + // Try to get existing entry + let mut stmt = conn.prepare( + "SELECT owner_identity_id, contact_identity_id, next_send_index, + highest_receive_index, bloom_registered_count + FROM dashpay_contact_address_indices + WHERE owner_identity_id = ?1 AND contact_identity_id = ?2", + )?; + + let result = stmt.query_row( + params![ + owner_identity_id.to_buffer().to_vec(), + contact_identity_id.to_buffer().to_vec() + ], + |row| { + Ok(ContactAddressIndex { + owner_identity_id: row.get(0)?, + contact_identity_id: row.get(1)?, + next_send_index: row.get(2)?, + highest_receive_index: row.get(3)?, + bloom_registered_count: row.get(4)?, + }) + }, + ); + + match result { + Ok(indices) => Ok(indices), + Err(rusqlite::Error::QueryReturnedNoRows) => { + // Create new entry with defaults + Ok(ContactAddressIndex { + owner_identity_id: owner_identity_id.to_buffer().to_vec(), + contact_identity_id: contact_identity_id.to_buffer().to_vec(), + next_send_index: 0, + highest_receive_index: 0, + bloom_registered_count: 0, + }) + } + Err(e) => Err(e), + } + } + + /// Get the next send address index for a contact and increment it atomically. + /// This is used when sending a payment to ensure unique addresses. + /// Uses an atomic INSERT/UPDATE with RETURNING to prevent race conditions. + pub fn get_and_increment_send_index( + &self, + owner_identity_id: &Identifier, + contact_identity_id: &Identifier, + ) -> rusqlite::Result { + let conn = self.conn.lock().unwrap(); + + // First, ensure the row exists with default values if it doesn't + let init_sql = " + INSERT OR IGNORE INTO dashpay_contact_address_indices + (owner_identity_id, contact_identity_id, next_send_index, highest_receive_index) + VALUES (?1, ?2, 0, 0) + "; + conn.execute( + init_sql, + params![ + owner_identity_id.to_buffer().to_vec(), + contact_identity_id.to_buffer().to_vec(), + ], + )?; + + // Now atomically increment and return the old value + // We update next_send_index = next_send_index + 1 and return the old value + let update_sql = " + UPDATE dashpay_contact_address_indices + SET next_send_index = next_send_index + 1 + WHERE owner_identity_id = ?1 AND contact_identity_id = ?2 + RETURNING next_send_index - 1 + "; + + conn.query_row( + update_sql, + params![ + owner_identity_id.to_buffer().to_vec(), + contact_identity_id.to_buffer().to_vec(), + ], + |row| row.get(0), + ) + } + + /// Update the highest receive index seen for a contact + /// Called when we detect an incoming payment at a higher index + pub fn update_highest_receive_index( + &self, + owner_identity_id: &Identifier, + contact_identity_id: &Identifier, + index: u32, + ) -> rusqlite::Result<()> { + let sql = " + INSERT INTO dashpay_contact_address_indices + (owner_identity_id, contact_identity_id, highest_receive_index) + VALUES (?1, ?2, ?3) + ON CONFLICT(owner_identity_id, contact_identity_id) + DO UPDATE SET highest_receive_index = MAX(highest_receive_index, ?3) + "; + + self.execute( + sql, + params![ + owner_identity_id.to_buffer().to_vec(), + contact_identity_id.to_buffer().to_vec(), + index, + ], + )?; + + Ok(()) + } + + /// Update the bloom registered count for a contact + /// Called after registering addresses in bloom filter + pub fn update_bloom_registered_count( + &self, + owner_identity_id: &Identifier, + contact_identity_id: &Identifier, + count: u32, + ) -> rusqlite::Result<()> { + let sql = " + INSERT INTO dashpay_contact_address_indices + (owner_identity_id, contact_identity_id, bloom_registered_count) + VALUES (?1, ?2, ?3) + ON CONFLICT(owner_identity_id, contact_identity_id) + DO UPDATE SET bloom_registered_count = ?3 + "; + + self.execute( + sql, + params![ + owner_identity_id.to_buffer().to_vec(), + contact_identity_id.to_buffer().to_vec(), + count, + ], + )?; + + Ok(()) + } + + /// Get all contact address indices for an identity + /// Useful for registering bloom filters on startup + pub fn get_all_contact_address_indices( + &self, + owner_identity_id: &Identifier, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT owner_identity_id, contact_identity_id, next_send_index, + highest_receive_index, bloom_registered_count + FROM dashpay_contact_address_indices + WHERE owner_identity_id = ?1", + )?; + + let indices = stmt + .query_map(params![owner_identity_id.to_buffer().to_vec()], |row| { + Ok(ContactAddressIndex { + owner_identity_id: row.get(0)?, + contact_identity_id: row.get(1)?, + next_send_index: row.get(2)?, + highest_receive_index: row.get(3)?, + bloom_registered_count: row.get(4)?, + }) + })? + .collect::, _>>()?; + + Ok(indices) + } + + // DashPay address mapping operations + + /// Save a DashPay address mapping for incoming payment detection + pub fn save_dashpay_address_mapping( + &self, + owner_identity_id: &Identifier, + contact_identity_id: &Identifier, + address: &dash_sdk::dpp::dashcore::Address, + address_index: u32, + ) -> rusqlite::Result<()> { + let sql = " + INSERT OR REPLACE INTO dashpay_address_mappings + (address, owner_identity_id, contact_identity_id, address_index, created_at) + VALUES (?1, ?2, ?3, ?4, unixepoch()) + "; + + self.execute( + sql, + params![ + address.to_string(), + owner_identity_id.to_buffer().to_vec(), + contact_identity_id.to_buffer().to_vec(), + address_index, + ], + )?; + + Ok(()) + } + + /// Look up a DashPay address mapping to find which contact relationship it belongs to + /// Returns (owner_identity_id, contact_identity_id, address_index) if found + pub fn get_dashpay_address_mapping( + &self, + address: &dash_sdk::dpp::dashcore::Address, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT owner_identity_id, contact_identity_id, address_index + FROM dashpay_address_mappings + WHERE address = ?1", + )?; + + let result = stmt.query_row(params![address.to_string()], |row| { + let owner_bytes: Vec = row.get(0)?; + let contact_bytes: Vec = row.get(1)?; + let address_index: u32 = row.get(2)?; + Ok((owner_bytes, contact_bytes, address_index)) + }); + + match result { + Ok((owner_bytes, contact_bytes, address_index)) => { + let owner_id = Identifier::from_bytes(&owner_bytes) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let contact_id = Identifier::from_bytes(&contact_bytes) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + Ok(Some((owner_id, contact_id, address_index))) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } + + /// Get all DashPay address mappings for an identity + pub fn get_all_dashpay_address_mappings( + &self, + owner_identity_id: &Identifier, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT address, contact_identity_id, address_index + FROM dashpay_address_mappings + WHERE owner_identity_id = ?1 + ORDER BY contact_identity_id, address_index", + )?; + + let mappings = stmt + .query_map(params![owner_identity_id.to_buffer().to_vec()], |row| { + let address: String = row.get(0)?; + let contact_bytes: Vec = row.get(1)?; + let address_index: u32 = row.get(2)?; + Ok((address, contact_bytes, address_index)) + })? + .filter_map(|r| { + r.ok().and_then(|(address, contact_bytes, address_index)| { + Identifier::from_bytes(&contact_bytes) + .ok() + .map(|contact_id| (address, contact_id, address_index)) + }) + }) + .collect(); + + Ok(mappings) + } + + /// Delete all address mappings for a contact relationship + pub fn delete_dashpay_address_mappings_for_contact( + &self, + owner_identity_id: &Identifier, + contact_identity_id: &Identifier, + ) -> rusqlite::Result<()> { + self.execute( + "DELETE FROM dashpay_address_mappings + WHERE owner_identity_id = ?1 AND contact_identity_id = ?2", + params![ + owner_identity_id.to_buffer().to_vec(), + contact_identity_id.to_buffer().to_vec(), + ], + )?; + Ok(()) + } +} diff --git a/src/database/identities.rs b/src/database/identities.rs index ac5c86f2c..524075f4f 100644 --- a/src/database/identities.rs +++ b/src/database/identities.rs @@ -78,9 +78,9 @@ impl Database { // If wallet information is not provided, insert without wallet and wallet_index self.execute( "INSERT OR REPLACE INTO identity - (id, data, is_local, alias, identity_type, network) - VALUES (?, ?, 1, ?, ?, ?)", - params![id, data, alias, identity_type, network], + (id, data, is_local, alias, identity_type, network, status) + VALUES (?, ?, 1, ?, ?, ?, ?)", + params![id, data, alias, identity_type, network, status], )?; } @@ -170,13 +170,14 @@ impl Database { let data: Vec = row.get(0)?; let alias: Option = row.get(1)?; let wallet_index: Option = row.get(2)?; - let status: u8 = row.get(3)?; + // Handle NULL status values from older database entries by defaulting to Active (2) + let status: Option = row.get(3)?; let mut identity: QualifiedIdentity = QualifiedIdentity::from_bytes(&data); identity.alias = alias; identity.wallet_index = wallet_index; - identity.status = IdentityStatus::from_u8(status); + identity.status = IdentityStatus::from_u8(status.unwrap_or(2)); identity.network = app_context.network; // Associate wallets diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 68b285cc8..2cefcecce 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -4,12 +4,29 @@ use rusqlite::{Connection, params}; use std::fs; use std::path::Path; -pub const DEFAULT_DB_VERSION: u16 = 11; +pub const DEFAULT_DB_VERSION: u16 = 25; pub const DEFAULT_NETWORK: &str = "dash"; impl Database { pub fn initialize(&self, db_file_path: &Path) -> rusqlite::Result<()> { + // First, ensure all required columns exist in tables that may have been + // created with an older schema. This must happen before any queries that + // depend on these columns (like db_schema_version which needs database_version). + { + let conn = self.conn.lock().unwrap(); + // Check if settings table exists before trying to ensure columns + let settings_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='settings'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + if settings_exists { + self.ensure_settings_columns_exist(&conn)?; + } + self.ensure_wallet_columns_exist(&conn)?; + } + // Check if this is the first time setup by looking for entries in the settings table. if self.is_first_time_setup()? { self.create_tables()?; @@ -34,6 +51,48 @@ impl Database { fn apply_version_changes(&self, version: u16, tx: &Connection) -> rusqlite::Result<()> { match version { + 25 => { + self.add_avatar_bytes_column(tx)?; + } + 24 => { + self.add_selected_wallet_columns(tx)?; + } + 23 => { + self.add_last_terminal_block_column(tx)?; + } + 22 => { + self.add_network_column_to_dashpay_contact_requests(tx)?; + self.add_network_column_to_dashpay_contacts(tx)?; + } + 21 => { + self.add_network_column_to_dashpay_profiles(tx)?; + } + 20 => { + self.add_platform_sync_columns(tx)?; + } + 19 => { + self.initialize_platform_address_balances_table(tx)?; + } + 18 => { + self.initialize_single_key_wallet_table(tx)?; + } + 17 => { + self.add_address_total_received_column(tx)?; + } + 16 => { + self.add_wallet_balance_columns(tx)?; + } + 15 => { + self.add_core_backend_mode_column(tx)?; + } + 14 => { + self.initialize_wallet_transactions_table(tx)?; + } + 13 => { + // Add DashPay tables in version 12 + self.init_dashpay_tables_in_tx(tx)?; + } + 12 => self.add_disable_zmq_column(tx)?, 11 => self.rename_identity_column_is_in_creation_to_status(tx)?, 10 => { self.add_theme_preference_column(tx)?; @@ -215,8 +274,16 @@ impl Database { start_root_screen INTEGER NOT NULL, custom_dash_qt_path TEXT, overwrite_dash_conf INTEGER, + disable_zmq INTEGER DEFAULT 0, theme_preference TEXT DEFAULT 'System', - database_version INTEGER NOT NULL + core_backend_mode INTEGER DEFAULT 1, + database_version INTEGER NOT NULL, + onboarding_completed INTEGER DEFAULT 0, + show_evonode_tools INTEGER DEFAULT 0, + user_mode TEXT DEFAULT 'Advanced', + use_local_spv_node INTEGER DEFAULT 0, + auto_start_spv INTEGER DEFAULT 1, + close_dash_qt_on_exit INTEGER DEFAULT 1 )", [], )?; @@ -233,7 +300,12 @@ impl Database { is_main INTEGER, uses_password INTEGER NOT NULL, password_hint TEXT, - network TEXT NOT NULL + network TEXT NOT NULL, + confirmed_balance INTEGER DEFAULT 0, + unconfirmed_balance INTEGER DEFAULT 0, + total_balance INTEGER DEFAULT 0, + last_platform_full_sync INTEGER DEFAULT 0, + last_platform_sync_checkpoint INTEGER DEFAULT 0 )", [], )?; @@ -247,6 +319,7 @@ impl Database { balance INTEGER, path_reference INTEGER NOT NULL, path_type INTEGER NOT NULL, + total_received INTEGER DEFAULT 0, PRIMARY KEY (seed_hash, address), FOREIGN KEY (seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE )", @@ -257,6 +330,21 @@ impl Database { conn.execute("CREATE INDEX IF NOT EXISTS idx_wallet_addresses_path_reference ON wallet_addresses (path_reference)", [])?; conn.execute("CREATE INDEX IF NOT EXISTS idx_wallet_addresses_path_type ON wallet_addresses (path_type)", [])?; + // Create Platform address balances table + conn.execute( + "CREATE TABLE IF NOT EXISTS platform_address_balances ( + seed_hash BLOB NOT NULL, + address TEXT NOT NULL, + balance INTEGER NOT NULL DEFAULT 0, + nonce INTEGER NOT NULL DEFAULT 0, + network TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (seed_hash, address, network), + FOREIGN KEY (seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE + )", + [], + )?; + // Create the utxos table conn.execute( "CREATE TABLE IF NOT EXISTS utxos ( @@ -281,6 +369,9 @@ impl Database { [], )?; + // Create wallet transactions table for SPV history + self.initialize_wallet_transactions_table(&conn)?; + // Create asset lock transaction table conn.execute( "CREATE TABLE IF NOT EXISTS asset_lock_transaction ( @@ -386,6 +477,13 @@ impl Database { self.initialize_token_order_table(&conn)?; self.initialize_identity_token_balances_table(&conn)?; + // Initialize contacts and DashPay tables while holding the same connection lock + self.init_contacts_tables(&conn)?; + self.init_dashpay_tables_in_tx(&conn)?; + + // Initialize single key wallet table + self.initialize_single_key_wallet_table(&conn)?; + Ok(()) } @@ -399,14 +497,290 @@ impl Database { self.set_db_version(DEFAULT_DB_VERSION) } fn set_db_version(&self, version: u16) -> rusqlite::Result<()> { + // Default start_root_screen to 20 (RootScreenDashPayProfile) self.execute( "INSERT INTO settings (id, network, start_root_screen, database_version) - VALUES (1, ?, 0, ?) + VALUES (1, ?, 20, ?) ON CONFLICT(id) DO UPDATE SET database_version = excluded.database_version", params![DEFAULT_NETWORK, version], )?; Ok(()) } + + /// Migration: Create platform_address_balances table (version 19). + fn initialize_platform_address_balances_table( + &self, + conn: &Connection, + ) -> rusqlite::Result<()> { + conn.execute( + "CREATE TABLE IF NOT EXISTS platform_address_balances ( + seed_hash BLOB NOT NULL, + address TEXT NOT NULL, + balance INTEGER NOT NULL DEFAULT 0, + nonce INTEGER NOT NULL DEFAULT 0, + network TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (seed_hash, address, network), + FOREIGN KEY (seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE + )", + [], + )?; + Ok(()) + } + + /// Migration: Add platform sync columns to wallet table (version 20). + /// - last_platform_full_sync: Unix timestamp of last full platform address sync + /// - last_platform_sync_checkpoint: Block height checkpoint from last full sync + fn add_platform_sync_columns(&self, conn: &Connection) -> rusqlite::Result<()> { + conn.execute( + "ALTER TABLE wallet ADD COLUMN last_platform_full_sync INTEGER DEFAULT 0", + [], + )?; + conn.execute( + "ALTER TABLE wallet ADD COLUMN last_platform_sync_checkpoint INTEGER DEFAULT 0", + [], + )?; + Ok(()) + } + + /// Migration: Add last_terminal_block column to wallet table (version 23). + /// Tracks the highest block height processed by terminal balance updates to avoid + /// re-applying the same balance changes on subsequent terminal-only syncs. + fn add_last_terminal_block_column(&self, conn: &Connection) -> rusqlite::Result<()> { + conn.execute( + "ALTER TABLE wallet ADD COLUMN last_terminal_block INTEGER DEFAULT 0", + [], + )?; + Ok(()) + } + + /// Migration: Add selected wallet hash columns to settings table (version 24). + /// Persists the user's selected wallet across app restarts. + fn add_selected_wallet_columns(&self, conn: &Connection) -> rusqlite::Result<()> { + // Check if selected_wallet_hash column exists + let wallet_hash_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='selected_wallet_hash'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if !wallet_hash_exists { + conn.execute( + "ALTER TABLE settings ADD COLUMN selected_wallet_hash BLOB DEFAULT NULL", + [], + )?; + } + + // Check if selected_single_key_hash column exists + let single_key_hash_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='selected_single_key_hash'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if !single_key_hash_exists { + conn.execute( + "ALTER TABLE settings ADD COLUMN selected_single_key_hash BLOB DEFAULT NULL", + [], + )?; + } + + Ok(()) + } + + fn add_network_column_to_dashpay_profiles(&self, conn: &Connection) -> rusqlite::Result<()> { + // Check if dashpay_profiles table exists + let table_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='dashpay_profiles'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if table_exists { + // Check if network column already exists + let has_network_column: bool = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('dashpay_profiles') WHERE name='network'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + ) + .unwrap_or(false); + + if !has_network_column { + // Add network column with default value + conn.execute( + "ALTER TABLE dashpay_profiles ADD COLUMN network TEXT NOT NULL DEFAULT 'dash'", + [], + )?; + + // Drop the old primary key and recreate with composite key + // SQLite doesn't support dropping primary key, so we need to recreate the table + conn.execute( + "CREATE TABLE IF NOT EXISTS dashpay_profiles_new ( + identity_id BLOB NOT NULL, + network TEXT NOT NULL, + display_name TEXT, + bio TEXT, + avatar_url TEXT, + avatar_hash BLOB, + avatar_fingerprint BLOB, + public_message TEXT, + created_at INTEGER DEFAULT (unixepoch()), + updated_at INTEGER DEFAULT (unixepoch()), + PRIMARY KEY (identity_id, network) + )", + [], + )?; + + // Copy data from old table + conn.execute( + "INSERT OR REPLACE INTO dashpay_profiles_new + SELECT identity_id, network, display_name, bio, avatar_url, + avatar_hash, avatar_fingerprint, public_message, created_at, updated_at + FROM dashpay_profiles", + [], + )?; + + // Drop old table and rename new one + conn.execute("DROP TABLE dashpay_profiles", [])?; + conn.execute( + "ALTER TABLE dashpay_profiles_new RENAME TO dashpay_profiles", + [], + )?; + } + } + + Ok(()) + } + + fn add_network_column_to_dashpay_contact_requests( + &self, + conn: &Connection, + ) -> rusqlite::Result<()> { + // Check if dashpay_contact_requests table exists + let table_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='dashpay_contact_requests'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if table_exists { + // Check if network column already exists + let has_network_column: bool = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('dashpay_contact_requests') WHERE name='network'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + ) + .unwrap_or(false); + + if !has_network_column { + // Add network column with default value + conn.execute( + "ALTER TABLE dashpay_contact_requests ADD COLUMN network TEXT NOT NULL DEFAULT 'dash'", + [], + )?; + } + } + + Ok(()) + } + + fn add_network_column_to_dashpay_contacts(&self, conn: &Connection) -> rusqlite::Result<()> { + // Check if dashpay_contacts table exists + let table_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='dashpay_contacts'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if table_exists { + // Check if network column already exists + let has_network_column: bool = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('dashpay_contacts') WHERE name='network'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + ) + .unwrap_or(false); + + if !has_network_column { + // Add network column with default value + conn.execute( + "ALTER TABLE dashpay_contacts ADD COLUMN network TEXT NOT NULL DEFAULT 'dash'", + [], + )?; + + // Recreate the table with composite primary key + conn.execute( + "CREATE TABLE IF NOT EXISTS dashpay_contacts_new ( + owner_identity_id BLOB NOT NULL, + contact_identity_id BLOB NOT NULL, + network TEXT NOT NULL, + username TEXT, + display_name TEXT, + avatar_url TEXT, + public_message TEXT, + contact_status TEXT DEFAULT 'pending', + created_at INTEGER DEFAULT (unixepoch()), + updated_at INTEGER DEFAULT (unixepoch()), + last_seen INTEGER, + PRIMARY KEY (owner_identity_id, contact_identity_id, network) + )", + [], + )?; + + // Copy data from old table + conn.execute( + "INSERT OR REPLACE INTO dashpay_contacts_new + SELECT owner_identity_id, contact_identity_id, network, username, display_name, + avatar_url, public_message, contact_status, created_at, updated_at, last_seen + FROM dashpay_contacts", + [], + )?; + + // Drop old table and rename new one + conn.execute("DROP TABLE dashpay_contacts", [])?; + conn.execute( + "ALTER TABLE dashpay_contacts_new RENAME TO dashpay_contacts", + [], + )?; + } + } + + Ok(()) + } + + /// Migration: Add avatar_bytes column to dashpay_profiles table (version 25). + /// Stores the actual avatar image bytes to avoid re-fetching from network on every app start. + fn add_avatar_bytes_column(&self, conn: &Connection) -> rusqlite::Result<()> { + // Check if dashpay_profiles table exists + let table_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='dashpay_profiles'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if table_exists { + // Check if avatar_bytes column already exists + let has_avatar_bytes_column: bool = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('dashpay_profiles') WHERE name='avatar_bytes'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + ) + .unwrap_or(false); + + if !has_avatar_bytes_column { + conn.execute( + "ALTER TABLE dashpay_profiles ADD COLUMN avatar_bytes BLOB DEFAULT NULL", + [], + )?; + } + } + + Ok(()) + } } #[cfg(test)] diff --git a/src/database/mod.rs b/src/database/mod.rs index 0810bb760..745b2ed64 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,16 +1,20 @@ mod asset_lock_transaction; +pub(crate) mod contacts; mod contested_names; pub(crate) mod contracts; +mod dashpay; mod identities; mod initialization; mod proof_log; mod scheduled_votes; mod settings; +mod single_key_wallet; mod tokens; mod top_ups; mod utxo; mod wallet; +use dash_sdk::dpp::dashcore::Network; use rusqlite::{Connection, Params}; use std::sync::Mutex; @@ -31,4 +35,108 @@ impl Database { let conn = self.conn.lock().unwrap(); conn.execute(sql, params) } + + /// Removes all application data tied to a specific Dash network. + pub fn clear_network_data(&self, network: Network) -> rusqlite::Result<()> { + let network_str = network.to_string(); + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + + // Remove DashPay/contact data referencing identities from this network. + tx.execute( + "DELETE FROM dashpay_payments + WHERE from_identity_id IN (SELECT id FROM identity WHERE network = ?1) + OR to_identity_id IN (SELECT id FROM identity WHERE network = ?1)", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM dashpay_contact_requests + WHERE from_identity_id IN (SELECT id FROM identity WHERE network = ?1) + OR to_identity_id IN (SELECT id FROM identity WHERE network = ?1)", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM dashpay_contacts + WHERE owner_identity_id IN (SELECT id FROM identity WHERE network = ?1) + OR contact_identity_id IN (SELECT id FROM identity WHERE network = ?1)", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM contact_private_info + WHERE owner_identity_id IN (SELECT id FROM identity WHERE network = ?1) + OR contact_identity_id IN (SELECT id FROM identity WHERE network = ?1)", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM dashpay_profiles + WHERE identity_id IN (SELECT id FROM identity WHERE network = ?1)", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM identity_token_balances WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM token WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM contract WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM scheduled_votes WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM wallet_transactions WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM utxos WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM asset_lock_transaction WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM contestant WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM contested_name WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM identity WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM wallet WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.execute( + "DELETE FROM single_key_wallet WHERE network = ?1", + rusqlite::params![&network_str], + )?; + + tx.commit() + } } diff --git a/src/database/settings.rs b/src/database/settings.rs index eaa47bad2..c78cfce48 100644 --- a/src/database/settings.rs +++ b/src/database/settings.rs @@ -1,12 +1,16 @@ use crate::database::Database; use crate::database::initialization::DEFAULT_DB_VERSION; use crate::model::password_info::PasswordInfo; +use crate::model::settings::UserMode; use crate::ui::RootScreenType; use crate::ui::theme::ThemeMode; use dash_sdk::dpp::dashcore::Network; use rusqlite::{Connection, Result, params}; use std::{path::PathBuf, str::FromStr}; +/// Selected wallet hash and single key hash tuple for database storage. +pub type SelectedWalletHashes = (Option<[u8; 32]>, Option<[u8; 32]>); + impl Database { /// Inserts or updates the settings in the database. This method ensures that only one row exists. /// @@ -119,6 +123,24 @@ impl Database { Ok(()) } + + pub fn add_disable_zmq_column(&self, conn: &rusqlite::Connection) -> Result<()> { + // Check if disable_zmq column exists + let disable_zmq_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='disable_zmq'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if !disable_zmq_exists { + conn.execute( + "ALTER TABLE settings ADD COLUMN disable_zmq INTEGER DEFAULT 0;", + (), + )?; + } + + Ok(()) + } /// Updates the theme preference in the settings table. /// /// Don't call this method directly, use `AppContext` methods instead to ensure proper caching behavior. @@ -139,6 +161,120 @@ impl Database { Ok(()) } + /// Updates the disable_zmq flag in the settings table. + pub fn update_disable_zmq(&self, disable: bool) -> Result<()> { + self.execute( + "UPDATE settings SET disable_zmq = ? WHERE id = 1", + rusqlite::params![disable], + )?; + Ok(()) + } + + /// Adds the core_backend_mode column to the settings table (migration for version 15). + pub fn add_core_backend_mode_column(&self, conn: &rusqlite::Connection) -> Result<()> { + // Check if core_backend_mode column exists + let column_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='core_backend_mode'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if !column_exists { + // Default to 1 (SPV mode) to match current app behavior + conn.execute( + "ALTER TABLE settings ADD COLUMN core_backend_mode INTEGER DEFAULT 1;", + (), + )?; + } + + Ok(()) + } + + /// Updates the core backend mode (SPV=1, RPC=0) in the settings table. + /// + /// Don't call this method directly, use `AppContext` methods instead to ensure proper caching behavior. + pub fn update_core_backend_mode(&self, mode: u8) -> Result<()> { + self.execute( + "UPDATE settings SET core_backend_mode = ? WHERE id = 1", + rusqlite::params![mode], + )?; + Ok(()) + } + + /// Adds onboarding-related columns to the settings table. + pub fn add_onboarding_columns(&self, conn: &rusqlite::Connection) -> Result<()> { + // Check and add onboarding_completed column + let onboarding_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='onboarding_completed'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if !onboarding_exists { + conn.execute( + "ALTER TABLE settings ADD COLUMN onboarding_completed INTEGER DEFAULT 0;", + (), + )?; + } + + // Check and add show_evonode_tools column + let evonode_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='show_evonode_tools'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if !evonode_exists { + conn.execute( + "ALTER TABLE settings ADD COLUMN show_evonode_tools INTEGER DEFAULT 0;", + (), + )?; + } + + // Check and add user_mode column (Beginner or Advanced) + let user_mode_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='user_mode'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if !user_mode_exists { + conn.execute( + "ALTER TABLE settings ADD COLUMN user_mode TEXT DEFAULT 'Advanced';", + (), + )?; + } + + Ok(()) + } + + /// Updates the onboarding completed flag in the settings table. + pub fn update_onboarding_completed(&self, completed: bool) -> Result<()> { + self.execute( + "UPDATE settings SET onboarding_completed = ? WHERE id = 1", + rusqlite::params![completed], + )?; + Ok(()) + } + + /// Updates the show_evonode_tools flag in the settings table. + pub fn update_show_evonode_tools(&self, show: bool) -> Result<()> { + self.execute( + "UPDATE settings SET show_evonode_tools = ? WHERE id = 1", + rusqlite::params![show], + )?; + Ok(()) + } + + /// Updates the user mode (Beginner/Advanced) in the settings table. + pub fn update_user_mode(&self, mode: &str) -> Result<()> { + self.execute( + "UPDATE settings SET user_mode = ? WHERE id = 1", + rusqlite::params![mode], + )?; + Ok(()) + } + /// Updates the database version in the settings table. pub fn update_database_version(&self, new_version: u16, conn: &Connection) -> Result<()> { // Ensure the database version is updated @@ -152,6 +288,245 @@ impl Database { Ok(()) } + /// Adds the use_local_spv_node column to the settings table. + pub fn add_use_local_spv_node_column(&self, conn: &rusqlite::Connection) -> Result<()> { + let column_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='use_local_spv_node'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if !column_exists { + // Default to false - use DNS seed discovery by default + conn.execute( + "ALTER TABLE settings ADD COLUMN use_local_spv_node INTEGER DEFAULT 0;", + (), + )?; + } + + Ok(()) + } + + /// Adds the auto_start_spv column to the settings table. + pub fn add_auto_start_spv_column(&self, conn: &rusqlite::Connection) -> Result<()> { + let column_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='auto_start_spv'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if !column_exists { + // Default to true - auto-start SPV on startup + conn.execute( + "ALTER TABLE settings ADD COLUMN auto_start_spv INTEGER DEFAULT 1;", + (), + )?; + } + + Ok(()) + } + + /// Updates the use_local_spv_node flag in the settings table. + pub fn update_use_local_spv_node(&self, use_local: bool) -> Result<()> { + self.execute( + "UPDATE settings SET use_local_spv_node = ? WHERE id = 1", + rusqlite::params![use_local], + )?; + Ok(()) + } + + /// Gets the use_local_spv_node flag from the settings table. + pub fn get_use_local_spv_node(&self) -> Result { + let conn = self.conn.lock().unwrap(); + let result: Option = conn.query_row( + "SELECT use_local_spv_node FROM settings WHERE id = 1", + [], + |row| row.get(0), + )?; + Ok(result.unwrap_or(false)) + } + + /// Updates the auto_start_spv flag in the settings table. + pub fn update_auto_start_spv(&self, auto_start: bool) -> Result<()> { + self.execute( + "UPDATE settings SET auto_start_spv = ? WHERE id = 1", + rusqlite::params![auto_start], + )?; + Ok(()) + } + + /// Gets the auto_start_spv flag from the settings table. + pub fn get_auto_start_spv(&self) -> Result { + let conn = self.conn.lock().unwrap(); + let result: Option = conn.query_row( + "SELECT auto_start_spv FROM settings WHERE id = 1", + [], + |row| row.get(0), + )?; + Ok(result.unwrap_or(true)) // Default to true + } + + /// Adds the close_dash_qt_on_exit column to the settings table. + pub fn add_close_dash_qt_on_exit_column(&self, conn: &rusqlite::Connection) -> Result<()> { + let column_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='close_dash_qt_on_exit'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if !column_exists { + // Default to true - close Dash-Qt on exit by default + conn.execute( + "ALTER TABLE settings ADD COLUMN close_dash_qt_on_exit INTEGER DEFAULT 1;", + (), + )?; + } + + Ok(()) + } + + /// Updates the close_dash_qt_on_exit flag in the settings table. + pub fn update_close_dash_qt_on_exit(&self, close_on_exit: bool) -> Result<()> { + self.execute( + "UPDATE settings SET close_dash_qt_on_exit = ? WHERE id = 1", + rusqlite::params![close_on_exit], + )?; + Ok(()) + } + + /// Gets the close_dash_qt_on_exit flag from the settings table. + pub fn get_close_dash_qt_on_exit(&self) -> Result { + let conn = self.conn.lock().unwrap(); + let result: Option = conn.query_row( + "SELECT close_dash_qt_on_exit FROM settings WHERE id = 1", + [], + |row| row.get(0), + )?; + Ok(result.unwrap_or(true)) // Default to true + } + + /// Ensures all required columns exist in the settings table. + /// This handles the case where an old database has a settings table with missing columns. + pub fn ensure_settings_columns_exist(&self, conn: &Connection) -> Result<()> { + self.add_custom_dash_qt_columns(conn)?; + self.add_theme_preference_column(conn)?; + self.add_disable_zmq_column(conn)?; + self.add_core_backend_mode_column(conn)?; + self.add_onboarding_columns(conn)?; + self.add_use_local_spv_node_column(conn)?; + self.add_auto_start_spv_column(conn)?; + self.add_close_dash_qt_on_exit_column(conn)?; + self.add_selected_wallet_columns_if_missing(conn)?; + + // Ensure database_version column exists + let version_column_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='database_version'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if !version_column_exists { + conn.execute( + "ALTER TABLE settings ADD COLUMN database_version INTEGER DEFAULT 0;", + (), + )?; + } + + Ok(()) + } + + /// Adds selected wallet hash columns if they don't exist. + pub fn add_selected_wallet_columns_if_missing(&self, conn: &Connection) -> Result<()> { + let wallet_hash_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='selected_wallet_hash'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if !wallet_hash_exists { + conn.execute( + "ALTER TABLE settings ADD COLUMN selected_wallet_hash BLOB DEFAULT NULL;", + (), + )?; + } + + let single_key_hash_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='selected_single_key_hash'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if !single_key_hash_exists { + conn.execute( + "ALTER TABLE settings ADD COLUMN selected_single_key_hash BLOB DEFAULT NULL;", + (), + )?; + } + + Ok(()) + } + + /// Gets the selected wallet hashes from the settings table. + /// Returns (selected_wallet_hash, selected_single_key_hash). + pub fn get_selected_wallet_hashes(&self) -> Result { + let conn = self.conn.lock().unwrap(); + let result = conn.query_row( + "SELECT selected_wallet_hash, selected_single_key_hash FROM settings WHERE id = 1", + [], + |row| { + let wallet_hash: Option> = row.get(0)?; + let single_key_hash: Option> = row.get(1)?; + + // Convert Vec to [u8; 32] if present and valid length + let wallet_hash_arr = wallet_hash.and_then(|v| { + if v.len() == 32 { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&v); + Some(arr) + } else { + None + } + }); + + let single_key_hash_arr = single_key_hash.and_then(|v| { + if v.len() == 32 { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&v); + Some(arr) + } else { + None + } + }); + + Ok((wallet_hash_arr, single_key_hash_arr)) + }, + ); + + match result { + Ok(hashes) => Ok(hashes), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok((None, None)), + Err(e) => Err(e), + } + } + + /// Updates the selected wallet hash in the settings table. + pub fn update_selected_wallet_hash(&self, hash: Option<&[u8; 32]>) -> Result<()> { + self.execute( + "UPDATE settings SET selected_wallet_hash = ? WHERE id = 1", + params![hash.map(|h| h.as_slice())], + )?; + Ok(()) + } + + /// Updates the selected single key hash in the settings table. + pub fn update_selected_single_key_hash(&self, hash: Option<&[u8; 32]>) -> Result<()> { + self.execute( + "UPDATE settings SET selected_single_key_hash = ? WHERE id = 1", + params![hash.map(|h| h.as_slice())], + )?; + Ok(()) + } + /// Retrieves the settings from the database. /// /// Don't call this method directly, use `AppContext` methods instead to ensure proper caching behavior. @@ -165,13 +540,20 @@ impl Database { Option, Option, bool, + bool, ThemeMode, + u8, + bool, // onboarding_completed + bool, // show_evonode_tools + UserMode, // user_mode + bool, // close_dash_qt_on_exit )>, > { // Query the settings row let conn = self.conn.lock().unwrap(); - let mut stmt = - conn.prepare("SELECT network, start_root_screen, password_check, main_password_salt, main_password_nonce, custom_dash_qt_path, overwrite_dash_conf, theme_preference FROM settings WHERE id = 1")?; + let mut stmt = conn.prepare( + "SELECT network, start_root_screen, password_check, main_password_salt, main_password_nonce, custom_dash_qt_path, overwrite_dash_conf, disable_zmq, theme_preference, core_backend_mode, onboarding_completed, show_evonode_tools, user_mode, close_dash_qt_on_exit FROM settings WHERE id = 1", + )?; let result = stmt.query_row([], |row| { let network: String = row.get(0)?; @@ -181,7 +563,13 @@ impl Database { let main_password_nonce: Option> = row.get(4)?; let custom_dash_qt_path: Option = row.get(5)?; let overwrite_dash_conf: Option = row.get(6)?; - let theme_preference: Option = row.get(7)?; + let disable_zmq: Option = row.get(7)?; + let theme_preference: Option = row.get(8)?; + let core_backend_mode: Option = row.get(9)?; + let onboarding_completed: Option = row.get(10)?; + let show_evonode_tools: Option = row.get(11)?; + let user_mode: Option = row.get(12)?; + let close_dash_qt_on_exit: Option = row.get(13)?; // Combine the password-related fields if all are present, otherwise set to None let password_data = match (password_check, main_password_salt, main_password_nonce) { @@ -209,13 +597,26 @@ impl Database { _ => ThemeMode::System, // Default to System for unknown values }; + // Parse user mode + let user_mode = match user_mode.as_deref() { + Some("Beginner") => UserMode::Beginner, + Some("Advanced") | None => UserMode::Advanced, // Default to Advanced + _ => UserMode::Advanced, + }; + Ok(( parsed_network, root_screen_type, password_data, custom_dash_qt_path.map(PathBuf::from), overwrite_dash_conf.unwrap_or(true), + disable_zmq.unwrap_or(false), theme_mode, + core_backend_mode.unwrap_or(1), // Default to SPV (1) + onboarding_completed.unwrap_or(false), + show_evonode_tools.unwrap_or(false), + user_mode, + close_dash_qt_on_exit.unwrap_or(true), // Default to true )) }); diff --git a/src/database/single_key_wallet.rs b/src/database/single_key_wallet.rs new file mode 100644 index 000000000..372bcfdb9 --- /dev/null +++ b/src/database/single_key_wallet.rs @@ -0,0 +1,265 @@ +//! Database operations for single key wallets + +use crate::database::Database; +use crate::model::wallet::single_key::{ + ClosedSingleKey, SingleKeyData, SingleKeyHash, SingleKeyWallet, +}; +use dash_sdk::dpp::dashcore::{Address, Network, PublicKey}; +use rusqlite::{Connection, params}; +use std::collections::HashMap; + +impl Database { + /// Initialize the single key wallet table + pub fn initialize_single_key_wallet_table(&self, conn: &Connection) -> rusqlite::Result<()> { + conn.execute( + "CREATE TABLE IF NOT EXISTS single_key_wallet ( + key_hash BLOB NOT NULL PRIMARY KEY, + encrypted_private_key BLOB NOT NULL, + salt BLOB NOT NULL, + nonce BLOB NOT NULL, + public_key BLOB NOT NULL, + address TEXT NOT NULL, + alias TEXT, + uses_password INTEGER NOT NULL, + network TEXT NOT NULL, + confirmed_balance INTEGER DEFAULT 0, + unconfirmed_balance INTEGER DEFAULT 0, + total_balance INTEGER DEFAULT 0 + )", + [], + )?; + + // Create index for network lookups + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_single_key_wallet_network ON single_key_wallet (network)", + [], + )?; + + Ok(()) + } + + /// Store a single key wallet in the database + pub fn store_single_key_wallet( + &self, + wallet: &SingleKeyWallet, + network: Network, + ) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT OR REPLACE INTO single_key_wallet ( + key_hash, + encrypted_private_key, + salt, + nonce, + public_key, + address, + alias, + uses_password, + network, + confirmed_balance, + unconfirmed_balance, + total_balance + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + params![ + wallet.key_hash.as_slice(), + wallet.encrypted_private_key(), + wallet.salt(), + wallet.nonce(), + wallet.public_key.to_bytes().as_slice(), + wallet.address.to_string(), + wallet.alias.as_deref(), + wallet.uses_password as i32, + network.to_string(), + wallet.confirmed_balance as i64, + wallet.unconfirmed_balance as i64, + wallet.total_balance as i64, + ], + )?; + Ok(()) + } + + /// Get all single key wallets for a network + pub fn get_single_key_wallets( + &self, + network: Network, + ) -> rusqlite::Result> { + let mut wallets = { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT + key_hash, + encrypted_private_key, + salt, + nonce, + public_key, + address, + alias, + uses_password, + confirmed_balance, + unconfirmed_balance, + total_balance + FROM single_key_wallet + WHERE network = ?1", + )?; + + let rows = stmt.query_map(params![network.to_string()], |row| { + let key_hash_vec: Vec = row.get(0)?; + let encrypted_private_key: Vec = row.get(1)?; + let salt: Vec = row.get(2)?; + let nonce: Vec = row.get(3)?; + let public_key_bytes: Vec = row.get(4)?; + let address_str: String = row.get(5)?; + let alias: Option = row.get(6)?; + let uses_password: i32 = row.get(7)?; + let confirmed_balance: i64 = row.get(8)?; + let unconfirmed_balance: i64 = row.get(9)?; + let total_balance: i64 = row.get(10)?; + + Ok(( + key_hash_vec, + encrypted_private_key, + salt, + nonce, + public_key_bytes, + address_str, + alias, + uses_password, + confirmed_balance, + unconfirmed_balance, + total_balance, + )) + })?; + + let mut wallets = Vec::new(); + + for row_result in rows { + let ( + key_hash_vec, + encrypted_private_key, + salt, + nonce, + public_key_bytes, + address_str, + alias, + uses_password, + confirmed_balance, + unconfirmed_balance, + total_balance, + ) = row_result?; + + // Parse key hash + let key_hash: SingleKeyHash = key_hash_vec.try_into().map_err(|_| { + rusqlite::Error::InvalidParameterName("Invalid key hash length".to_string()) + })?; + + // Parse public key + let public_key = PublicKey::from_slice(&public_key_bytes).map_err(|e| { + rusqlite::Error::InvalidParameterName(format!("Invalid public key: {}", e)) + })?; + + // Parse address + let address = address_str + .parse::>() + .map_err(|e| { + rusqlite::Error::InvalidParameterName(format!("Invalid address: {}", e)) + })? + .require_network(network) + .map_err(|e| { + rusqlite::Error::InvalidParameterName(format!( + "Wrong network for address: {}", + e + )) + })?; + + let closed_key = ClosedSingleKey { + key_hash, + encrypted_private_key, + salt, + nonce, + }; + + let wallet = SingleKeyWallet { + private_key_data: SingleKeyData::Closed(closed_key), + uses_password: uses_password != 0, + public_key, + address, + alias, + key_hash, + confirmed_balance: confirmed_balance as u64, + unconfirmed_balance: unconfirmed_balance as u64, + total_balance: total_balance as u64, + utxos: HashMap::new(), + }; + + wallets.push(wallet); + } + + wallets + }; // conn and stmt dropped here + + // Load UTXOs for each wallet + let network_str = network.to_string(); + for wallet in &mut wallets { + if let Ok(utxo_list) = + self.get_utxos_by_address(&wallet.address.to_string(), &network_str) + { + wallet.utxos = utxo_list.into_iter().collect(); + } + } + + Ok(wallets) + } + + /// Remove a single key wallet from the database + pub fn remove_single_key_wallet( + &self, + key_hash: &SingleKeyHash, + network: Network, + ) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM single_key_wallet WHERE key_hash = ?1 AND network = ?2", + params![key_hash.as_slice(), network.to_string()], + )?; + Ok(()) + } + + /// Update balances for a single key wallet + pub fn update_single_key_wallet_balances( + &self, + key_hash: &SingleKeyHash, + confirmed_balance: u64, + unconfirmed_balance: u64, + total_balance: u64, + ) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE single_key_wallet SET + confirmed_balance = ?1, + unconfirmed_balance = ?2, + total_balance = ?3 + WHERE key_hash = ?4", + params![ + confirmed_balance as i64, + unconfirmed_balance as i64, + total_balance as i64, + key_hash.as_slice(), + ], + )?; + Ok(()) + } + + /// Update alias for a single key wallet + pub fn update_single_key_wallet_alias( + &self, + key_hash: &SingleKeyHash, + alias: Option<&str>, + ) -> rusqlite::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE single_key_wallet SET alias = ?1 WHERE key_hash = ?2", + params![alias, key_hash.as_slice()], + )?; + Ok(()) + } +} diff --git a/src/database/utxo.rs b/src/database/utxo.rs index 91fc4236a..526635b0b 100644 --- a/src/database/utxo.rs +++ b/src/database/utxo.rs @@ -42,8 +42,8 @@ impl Database { Ok(()) } - #[allow(dead_code)] // May be used for address-specific UTXO queries - fn get_utxos_by_address( + /// Get UTXOs for a specific address + pub fn get_utxos_by_address( &self, address: &str, network: &str, diff --git a/src/database/wallet.rs b/src/database/wallet.rs index 9d8ee8099..220ff6c6f 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -2,16 +2,16 @@ use crate::database::Database; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::{ AddressInfo, ClosedKeyItem, DerivationPathReference, DerivationPathType, OpenWalletSeed, - Wallet, WalletSeed, + Wallet, WalletSeed, WalletTransaction, }; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::transaction::special_transaction::TransactionPayload; use dash_sdk::dpp::balances::credits::Duffs; use dash_sdk::dpp::dashcore::address::{NetworkChecked, NetworkUnchecked}; -use dash_sdk::dpp::dashcore::consensus::deserialize; +use dash_sdk::dpp::dashcore::consensus::{deserialize, serialize}; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::{ - self, InstantLock, Network, OutPoint, ScriptBuf, Transaction, TxOut, Txid, + self, BlockHash, InstantLock, Network, OutPoint, ScriptBuf, Transaction, TxOut, Txid, }; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; @@ -19,7 +19,7 @@ use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAss use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, ExtendedPubKey}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::{AssetLockProof, CoreBlockHeight}; -use rusqlite::params; +use rusqlite::{Connection, params}; use std::collections::{BTreeMap, HashMap}; use std::str::FromStr; @@ -33,8 +33,8 @@ impl Database { wallet.master_bip44_ecdsa_extended_public_key.encode(); self.execute( - "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, alias, is_main, uses_password, password_hint, network) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, alias, is_main, uses_password, password_hint, network, confirmed_balance, unconfirmed_balance, total_balance) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", params![ wallet.seed_hash(), wallet.encrypted_seed_slice(), @@ -45,7 +45,10 @@ impl Database { wallet.is_main as i32, wallet.uses_password, wallet.password_hint().clone(), - network_str + network_str, + wallet.confirmed_balance as i64, + wallet.unconfirmed_balance as i64, + wallet.total_balance as i64 ], )?; Ok(()) @@ -209,6 +212,202 @@ impl Database { } } + /// Migration: Add balance columns to wallet table (version 16). + pub fn add_wallet_balance_columns(&self, conn: &Connection) -> rusqlite::Result<()> { + // Check if confirmed_balance column exists + let column_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('wallet') WHERE name='confirmed_balance'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if !column_exists { + conn.execute( + "ALTER TABLE wallet ADD COLUMN confirmed_balance INTEGER DEFAULT 0;", + (), + )?; + conn.execute( + "ALTER TABLE wallet ADD COLUMN unconfirmed_balance INTEGER DEFAULT 0;", + (), + )?; + conn.execute( + "ALTER TABLE wallet ADD COLUMN total_balance INTEGER DEFAULT 0;", + (), + )?; + } + + Ok(()) + } + + /// Update the wallet's balance fields in the database. + pub fn update_wallet_balances( + &self, + seed_hash: &[u8; 32], + confirmed_balance: u64, + unconfirmed_balance: u64, + total_balance: u64, + ) -> rusqlite::Result<()> { + self.execute( + "UPDATE wallet SET confirmed_balance = ?, unconfirmed_balance = ?, total_balance = ? WHERE seed_hash = ?", + params![confirmed_balance as i64, unconfirmed_balance as i64, total_balance as i64, seed_hash], + )?; + Ok(()) + } + + /// Migration: Add total_received column to wallet_addresses table. + pub fn add_address_total_received_column(&self, conn: &Connection) -> rusqlite::Result<()> { + // Check if total_received column exists + let column_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info('wallet_addresses') WHERE name='total_received'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if !column_exists { + conn.execute( + "ALTER TABLE wallet_addresses ADD COLUMN total_received INTEGER DEFAULT 0;", + (), + )?; + } + + Ok(()) + } + + /// Ensures all required columns exist in wallet-related tables. + /// This handles the case where old tables exist with missing columns. + pub fn ensure_wallet_columns_exist(&self, conn: &Connection) -> rusqlite::Result<()> { + // Check if wallet_addresses table exists before trying to add columns + let wallet_addresses_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='wallet_addresses'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if wallet_addresses_exists { + self.add_address_total_received_column(conn)?; + } + + // Check if wallet table exists and add balance columns if needed + let wallet_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='wallet'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if wallet_exists { + self.add_wallet_balance_columns(conn)?; + } + + Ok(()) + } + + /// Update the total_received for an address. + pub fn update_address_total_received( + &self, + seed_hash: &[u8; 32], + address: &Address, + total_received: u64, + ) -> rusqlite::Result<()> { + self.execute( + "UPDATE wallet_addresses SET total_received = ? WHERE seed_hash = ? AND address = ?", + params![total_received as i64, seed_hash, address.to_string()], + )?; + Ok(()) + } + + pub fn initialize_wallet_transactions_table(&self, conn: &Connection) -> rusqlite::Result<()> { + conn.execute( + "CREATE TABLE IF NOT EXISTS wallet_transactions ( + seed_hash BLOB NOT NULL, + txid BLOB NOT NULL, + network TEXT NOT NULL, + timestamp INTEGER NOT NULL, + height INTEGER, + block_hash BLOB, + net_amount INTEGER NOT NULL, + fee INTEGER, + label TEXT, + is_ours INTEGER NOT NULL, + raw_transaction BLOB NOT NULL, + PRIMARY KEY (seed_hash, txid, network), + FOREIGN KEY (seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_wallet_transactions_network_ts + ON wallet_transactions (network, timestamp DESC)", + [], + )?; + + Ok(()) + } + + /// Replace all persisted transactions for a wallet+network with the provided set. + pub fn replace_wallet_transactions( + &self, + seed_hash: &[u8; 32], + network: &Network, + transactions: &[WalletTransaction], + ) -> rusqlite::Result<()> { + let mut conn = self.conn.lock().unwrap(); + let tx = conn.transaction()?; + let network_str = network.to_string(); + + tx.execute( + "DELETE FROM wallet_transactions WHERE seed_hash = ?1 AND network = ?2", + params![seed_hash, &network_str], + )?; + + if transactions.is_empty() { + tx.commit()?; + return Ok(()); + } + + { + let mut insert_stmt = tx.prepare( + "INSERT INTO wallet_transactions ( + seed_hash, + txid, + network, + timestamp, + height, + block_hash, + net_amount, + fee, + label, + is_ours, + raw_transaction + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + )?; + + for transaction in transactions { + let tx_bytes = serialize(&transaction.transaction); + let block_hash_bytes: Option> = transaction + .block_hash + .as_ref() + .map(|hash| hash.as_raw_hash().as_byte_array().to_vec()); + let fee = transaction.fee.map(|f| f as i64); + insert_stmt.execute(params![ + seed_hash, + >::as_ref(&transaction.txid), + &network_str, + transaction.timestamp as i64, + transaction.height.map(|h| h as i64), + block_hash_bytes.as_deref(), + transaction.net_amount, + fee, + transaction.label.as_deref(), + transaction.is_ours, + tx_bytes, + ])?; + } + } + + tx.commit() + } + /// Retrieve all wallets for a specific network, including their addresses, balances, and known addresses. pub fn get_wallets(&self, network: &Network) -> rusqlite::Result> { let network_str = network.to_string(); @@ -216,7 +415,7 @@ impl Database { tracing::trace!("step 1: retrieve all wallets for the given network"); let mut stmt = conn.prepare( - "SELECT seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, alias, is_main, uses_password, password_hint FROM wallet WHERE network = ?", + "SELECT seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, alias, is_main, uses_password, password_hint, confirmed_balance, unconfirmed_balance, total_balance FROM wallet WHERE network = ?", )?; let mut wallets_map: BTreeMap<[u8; 32], Wallet> = BTreeMap::new(); @@ -231,6 +430,9 @@ impl Database { let is_main: bool = row.get(6)?; let uses_password: bool = row.get(7)?; let password_hint: Option = row.get(8)?; + let confirmed_balance: i64 = row.get::<_, Option>(9)?.unwrap_or(0); + let unconfirmed_balance: i64 = row.get::<_, Option>(10)?.unwrap_or(0); + let total_balance: i64 = row.get::<_, Option>(11)?.unwrap_or(0); // Reconstruct the extended public keys let master_ecdsa_extended_public_key = @@ -272,13 +474,19 @@ impl Database { uses_password, master_bip44_ecdsa_extended_public_key: master_ecdsa_extended_public_key, address_balances: BTreeMap::new(), + address_total_received: BTreeMap::new(), known_addresses: BTreeMap::new(), watched_addresses: BTreeMap::new(), unused_asset_locks: vec![], alias, identities: HashMap::new(), utxos: HashMap::new(), + transactions: Vec::new(), is_main, + confirmed_balance: confirmed_balance as u64, + unconfirmed_balance: unconfirmed_balance as u64, + total_balance: total_balance as u64, + platform_address_info: BTreeMap::new(), }, ); @@ -294,24 +502,20 @@ impl Database { "step 2: retrieve all addresses, balances, and derivation paths associated with the wallets" ); let mut address_stmt = conn.prepare( - "SELECT seed_hash, address, derivation_path, balance, path_reference, path_type FROM wallet_addresses WHERE seed_hash IN (SELECT seed_hash FROM wallet WHERE network = ?)", + "SELECT seed_hash, address, derivation_path, balance, path_reference, path_type, total_received FROM wallet_addresses WHERE seed_hash IN (SELECT seed_hash FROM wallet WHERE network = ?)", )?; let address_rows = address_stmt.query_map([network_str.clone()], |row| { let seed_hash: Vec = row.get(0)?; - let address: String = row.get(1)?; + let address_str: String = row.get(1)?; let derivation_path: String = row.get(2)?; let balance: Option = row.get(3)?; let path_reference: u32 = row.get(4)?; let path_type: u32 = row.get(5)?; + let total_received: Option = row.get(6)?; let seed_hash_array: [u8; 32] = seed_hash.try_into().expect("Seed hash should be 32 bytes"); - let address_unchecked = Address::from_str(&address).expect("Invalid address format"); - let address = check_address_for_network(address_unchecked, network)?; - - let derivation_path = DerivationPath::from_str(&derivation_path) - .expect("Expected to convert to derivation path"); // Convert u32 to DerivationPathReference safely let path_reference = @@ -323,6 +527,34 @@ impl Database { ) })?; + // Parse address - Platform addresses (DIP-17/18) use Bech32m encoding with dashevo/tdashevo prefix + // and need special handling when stored (we store as Core address format internally) + let address = if path_reference == DerivationPathReference::PlatformPayment { + // Platform addresses are stored as Core P2PKH format for efficient internal lookup. + // We use assume_checked() here because: + // 1. Network validation was already performed at insertion time + // 2. Platform addresses (bech32m) map to Core P2PKH addresses internally + // 3. The stored address format doesn't have the same network version byte rules + Address::from_str(&address_str) + .map(|a| a.assume_checked()) + .map_err(|e| { + tracing::error!(address = %address_str, error = ?e, "Failed to parse Platform address"); + rusqlite::Error::FromSqlConversionFailure( + 1, + rusqlite::types::Type::Text, + Box::new(std::fmt::Error), + ) + })? + } else { + // Standard Core addresses - validate network + let address_unchecked = + Address::from_str(&address_str).expect("Invalid address format"); + check_address_for_network(address_unchecked, network)? + }; + + let derivation_path = DerivationPath::from_str(&derivation_path) + .expect("Expected to convert to derivation path"); + let path_type = DerivationPathType::from_bits_truncate(path_type); Ok(( @@ -332,6 +564,7 @@ impl Database { balance, path_reference, path_type, + total_received, )) })?; @@ -340,12 +573,26 @@ impl Database { if row.is_err() { continue; } - let (seed_array, address, derivation_path, balance, path_reference, path_type) = row?; + let ( + seed_array, + address, + derivation_path, + balance, + path_reference, + path_type, + total_received, + ) = row?; if let Some(wallet) = wallets_map.get_mut(&seed_array) { // Update the address balance if available. if let Some(balance) = balance { wallet.address_balances.insert(address.clone(), balance); } + // Update total received if available. + if let Some(total_received) = total_received { + wallet + .address_total_received + .insert(address.clone(), total_received); + } // Add the address to the `known_addresses` map. wallet @@ -480,6 +727,58 @@ impl Database { } } + tracing::trace!("step 7: load wallet transactions for each wallet"); + let mut tx_stmt = conn.prepare( + "SELECT seed_hash, txid, timestamp, height, block_hash, net_amount, fee, label, is_ours, raw_transaction + FROM wallet_transactions WHERE network = ? ORDER BY timestamp DESC", + )?; + + let tx_rows = tx_stmt.query_map([network_str.clone()], |row| { + let seed_hash: Vec = row.get(0)?; + let txid_bytes: Vec = row.get(1)?; + let timestamp: i64 = row.get(2)?; + let height: Option = row.get(3)?; + let block_hash_bytes: Option> = row.get(4)?; + let net_amount: i64 = row.get(5)?; + let fee: Option = row.get(6)?; + let label: Option = row.get(7)?; + let is_ours: bool = row.get(8)?; + let raw_transaction: Vec = row.get(9)?; + + let seed_hash_array: [u8; 32] = + seed_hash.try_into().expect("Seed hash should be 32 bytes"); + let txid = Txid::from_slice(&txid_bytes).expect("Invalid txid bytes"); + let transaction: Transaction = + deserialize(&raw_transaction).expect("Failed to deserialize transaction"); + let block_hash = block_hash_bytes + .as_ref() + .map(|bytes| BlockHash::from_slice(bytes).expect("Invalid block hash")); + let fee = fee.map(|f| f as u64); + let height = height.map(|h| h as u32); + + Ok(( + seed_hash_array, + WalletTransaction { + txid, + transaction, + timestamp: timestamp as u64, + height, + block_hash, + net_amount, + fee, + label, + is_ours, + }, + )) + })?; + + for row in tx_rows { + let (seed_hash, transaction) = row?; + if let Some(wallet) = wallets_map.get_mut(&seed_hash) { + wallet.transactions.push(transaction); + } + } + tracing::trace!( network = network_str, "step 8: retrieve identities for wallets" @@ -521,9 +820,233 @@ impl Database { } } + tracing::trace!( + network = network_str, + "step 9: retrieve platform address info for wallets" + ); + // Load platform address info for each wallet (using existing connection to avoid deadlock) + let mut platform_stmt = conn.prepare( + "SELECT seed_hash, address, balance, nonce FROM platform_address_balances WHERE network = ?", + )?; + let platform_rows = platform_stmt.query_map([network_str.clone()], |row| { + let seed_hash: Vec = row.get(0)?; + let address_str: String = row.get(1)?; + let balance: i64 = row.get(2)?; + let nonce: i64 = row.get(3)?; + let seed_hash_array: [u8; 32] = + seed_hash.try_into().expect("Seed hash should be 32 bytes"); + Ok((seed_hash_array, address_str, balance as u64, nonce as u32)) + })?; + + for row in platform_rows { + if let Ok((seed_hash, address_str, balance, nonce)) = row + && let Some(wallet) = wallets_map.get_mut(&seed_hash) + && let Ok(address) = Address::::from_str(&address_str) + { + let address = address.assume_checked(); + wallet.platform_address_info.insert( + address, + crate::model::wallet::PlatformAddressInfo { + balance, + nonce, + // Assume database balance is from sync (safe default) + last_synced_balance: Some(balance), + }, + ); + } + } + // Convert the BTreeMap into a Vec of Wallets. Ok(wallets_map.into_values().collect()) } + + /// Store or update Platform address balance and nonce + pub fn set_platform_address_info( + &self, + seed_hash: &[u8; 32], + address: &Address, + balance: u64, + nonce: u32, + network: &Network, + ) -> rusqlite::Result<()> { + let network_str = network.to_string(); + let address_str = address.to_string(); + let updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + self.execute( + "INSERT OR REPLACE INTO platform_address_balances + (seed_hash, address, balance, nonce, network, updated_at) + VALUES (?, ?, ?, ?, ?, ?)", + params![ + seed_hash, + address_str, + balance as i64, + nonce as i64, + network_str, + updated_at + ], + )?; + Ok(()) + } + + /// Get Platform address balance and nonce for a specific address + pub fn get_platform_address_info( + &self, + seed_hash: &[u8; 32], + address: &Address, + network: &Network, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let network_str = network.to_string(); + let address_str = address.to_string(); + + let mut stmt = conn.prepare( + "SELECT balance, nonce FROM platform_address_balances + WHERE seed_hash = ? AND address = ? AND network = ?", + )?; + + let result = stmt.query_row(params![seed_hash, address_str, network_str], |row| { + let balance: i64 = row.get(0)?; + let nonce: i64 = row.get(1)?; + Ok((balance as u64, nonce as u32)) + }); + + match result { + Ok(info) => Ok(Some(info)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } + + /// Get all Platform address balances for a wallet + pub fn get_all_platform_address_info( + &self, + seed_hash: &[u8; 32], + network: &Network, + ) -> rusqlite::Result> { + let conn = self.conn.lock().unwrap(); + let network_str = network.to_string(); + + let mut stmt = conn.prepare( + "SELECT address, balance, nonce FROM platform_address_balances + WHERE seed_hash = ? AND network = ?", + )?; + + let rows = stmt.query_map(params![seed_hash, network_str], |row| { + let address_str: String = row.get(0)?; + let balance: i64 = row.get(1)?; + let nonce: i64 = row.get(2)?; + Ok((address_str, balance as u64, nonce as u32)) + })?; + + let mut results = Vec::new(); + for row in rows { + let (address_str, balance, nonce) = row?; + if let Ok(address) = Address::::from_str(&address_str) { + let address = address.assume_checked(); + results.push((address, balance, nonce)); + } + } + + Ok(results) + } + + /// Delete Platform address balances for a wallet (used when removing wallet) + pub fn delete_platform_address_info( + &self, + seed_hash: &[u8; 32], + network: &Network, + ) -> rusqlite::Result<()> { + let network_str = network.to_string(); + self.execute( + "DELETE FROM platform_address_balances WHERE seed_hash = ? AND network = ?", + params![seed_hash, network_str], + )?; + Ok(()) + } + + /// Clear ALL Platform address balances for a network (developer tool) + pub fn clear_all_platform_address_info(&self, network: &Network) -> rusqlite::Result { + let network_str = network.to_string(); + self.execute( + "DELETE FROM platform_address_balances WHERE network = ?", + params![network_str], + ) + } + + /// Clear ALL Platform addresses entirely for a network (developer tool) + /// This removes both the addresses from wallet_addresses and their balances from platform_address_balances + pub fn clear_all_platform_addresses(&self, network: &Network) -> rusqlite::Result { + let network_str = network.to_string(); + let conn = self.conn.lock().unwrap(); + + // Delete from platform_address_balances + conn.execute( + "DELETE FROM platform_address_balances WHERE network = ?", + params![network_str], + )?; + + // Delete platform addresses from wallet_addresses (path_reference = 16 is PlatformPayment) + // We need to join with wallet table to filter by network + let deleted = conn.execute( + "DELETE FROM wallet_addresses + WHERE path_reference = 16 + AND seed_hash IN (SELECT seed_hash FROM wallet WHERE network = ?)", + params![network_str], + )?; + + Ok(deleted) + } + + /// Get the last platform full sync timestamp, checkpoint height, and last terminal block for a wallet + /// Returns (last_sync_timestamp, checkpoint_height, last_terminal_block) or (0, 0, 0) if not set + pub fn get_platform_sync_info( + &self, + seed_hash: &[u8; 32], + ) -> rusqlite::Result<(u64, u64, u64)> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT last_platform_full_sync, last_platform_sync_checkpoint, COALESCE(last_terminal_block, 0) FROM wallet WHERE seed_hash = ?", + params![seed_hash], + |row| { + let last_sync: i64 = row.get(0)?; + let checkpoint: i64 = row.get(1)?; + let last_terminal: i64 = row.get(2)?; + Ok((last_sync as u64, checkpoint as u64, last_terminal as u64)) + }, + ) + } + + /// Set the last platform full sync timestamp and checkpoint height for a wallet + /// Also resets last_terminal_block to 0 since a new full sync was performed + pub fn set_platform_sync_info( + &self, + seed_hash: &[u8; 32], + last_sync_timestamp: u64, + checkpoint_height: u64, + ) -> rusqlite::Result<()> { + self.execute( + "UPDATE wallet SET last_platform_full_sync = ?, last_platform_sync_checkpoint = ?, last_terminal_block = 0 WHERE seed_hash = ?", + params![last_sync_timestamp as i64, checkpoint_height as i64, seed_hash], + )?; + Ok(()) + } + + /// Update the last terminal block height after processing terminal balance updates + pub fn set_last_terminal_block( + &self, + seed_hash: &[u8; 32], + last_terminal_block: u64, + ) -> rusqlite::Result<()> { + self.execute( + "UPDATE wallet SET last_terminal_block = ? WHERE seed_hash = ?", + params![last_terminal_block as i64, seed_hash], + )?; + Ok(()) + } } /// Ensure the address is valid for the given network and diff --git a/src/lib.rs b/src/lib.rs index 2206c1812..3ff4f42c8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,11 +6,13 @@ pub mod components; pub mod config; pub mod context; pub mod context_provider; +pub mod context_provider_spv; pub mod cpu_compatibility; pub mod database; pub mod logging; pub mod model; pub mod sdk_wrapper; +pub mod spv; pub mod ui; pub mod utils; diff --git a/src/logging.rs b/src/logging.rs index 633e4b5f1..68e44e1a7 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -12,7 +12,7 @@ pub fn initialize_logger() { }; let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { EnvFilter::try_new( - "info,dash_evo_tool=trace,dash_sdk=debug,dash_sdk::platform::transition=trace,tenderdash_abci=debug,drive=debug,drive_proof_verifier=debug,rs_dapi_client=debug,h2=warn", + "info,dash_evo_tool=trace,dash_sdk=debug,dash_sdk::platform::transition=trace,tenderdash_abci=debug,drive=debug,drive_proof_verifier=debug,rs_dapi_client=debug,h2=warn,dash_spv=debug", ) .unwrap_or_else(|e| panic!("Failed to create EnvFilter: {:?}", e)) }); diff --git a/src/main.rs b/src/main.rs index 13df84cb6..b26319f3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,7 @@ fn main() -> eframe::Result<()> { check_cpu_compatibility(); // Initialize the Tokio runtime let runtime = tokio::runtime::Builder::new_multi_thread() - .worker_threads(40) + .worker_threads(12) .enable_all() .build() .expect("multi-threading runtime cannot be initialized"); diff --git a/src/model/amount.rs b/src/model/amount.rs index 8230f7c9b..fdb5e9888 100644 --- a/src/model/amount.rs +++ b/src/model/amount.rs @@ -197,7 +197,7 @@ impl Amount { /// Converts the Amount to a f64 representation with the specified decimal places. /// - /// Note this is a non-precise conversion, as f64 cannot represent all decimal values exactly. + /// Note this is a non-precise conversion, as f64 cannot represent all decimal values exactly. pub fn to_f64(&self) -> f64 { (self.value as f64) / 10u64.pow(self.decimal_places as u32) as f64 } diff --git a/src/model/fee_estimation.rs b/src/model/fee_estimation.rs new file mode 100644 index 000000000..1f079c5a7 --- /dev/null +++ b/src/model/fee_estimation.rs @@ -0,0 +1,607 @@ +//! Fee estimation utilities for Dash Platform state transitions. +//! +//! This module provides fee estimation for various state transition types, +//! using the fee structure from the platform version. +//! +//! Fee calculation is based on: +//! - Storage fees: Bytes stored × storage_disk_usage_credit_per_byte (27,000) +//! - Processing fees: Bytes processed × storage_processing_credit_per_byte (400) +//! - Seek costs: Number of tree operations × storage_seek_cost (2,000) +//! +//! Note: These are estimates. Actual fees depend on exact storage operations +//! performed by Platform. For accurate fees, use Platform's EstimateStateTransitionFee +//! endpoint (when available). + +use dash_sdk::dpp::version::PlatformVersion; + +/// Storage fee constants from FEE_STORAGE_VERSION1 in rs-platform-version. +/// These determine the cost of storing and processing data on Platform. +#[derive(Debug, Clone, Copy)] +pub struct StorageFeeConstants { + /// Credits charged per byte of permanent storage (27,000 credits/byte = 0.00027 DASH/byte) + pub storage_disk_usage_credit_per_byte: u64, + /// Credits charged per byte for write processing + pub storage_processing_credit_per_byte: u64, + /// Credits charged per byte for read processing + pub storage_load_credit_per_byte: u64, + /// Credits charged per seek/tree operation + pub storage_seek_cost: u64, +} + +impl Default for StorageFeeConstants { + fn default() -> Self { + // Values from FEE_STORAGE_VERSION1 in rs-platform-version + Self { + storage_disk_usage_credit_per_byte: 27_000, + storage_processing_credit_per_byte: 400, + storage_load_credit_per_byte: 20, + storage_seek_cost: 2_000, + } + } +} + +/// Data contract registration fees from FEE_DATA_CONTRACT_REGISTRATION_VERSION2. +/// These are fixed fees charged for registering contracts and their components. +#[derive(Debug, Clone, Copy)] +pub struct DataContractRegistrationFees { + /// Base fee for registering any contract (0.1 DASH) + pub base_contract_registration_fee: u64, + /// Fee per document type in the contract (0.02 DASH) + pub document_type_registration_fee: u64, + /// Fee per non-unique index (0.01 DASH) + pub document_type_base_non_unique_index_registration_fee: u64, + /// Fee per unique index (0.01 DASH) + pub document_type_base_unique_index_registration_fee: u64, + /// Fee per contested index (1 DASH) + pub document_type_base_contested_index_registration_fee: u64, + /// Fee for token registration (0.1 DASH) + pub token_registration_fee: u64, + /// Fee for perpetual distribution feature (0.1 DASH) + pub token_uses_perpetual_distribution_fee: u64, + /// Fee for pre-programmed distribution feature (0.1 DASH) + pub token_uses_pre_programmed_distribution_fee: u64, + /// Fee per search keyword (0.1 DASH) + pub search_keyword_fee: u64, +} + +impl Default for DataContractRegistrationFees { + fn default() -> Self { + // Values from FEE_DATA_CONTRACT_REGISTRATION_VERSION2 + Self { + base_contract_registration_fee: 10_000_000_000, // 0.1 DASH + document_type_registration_fee: 2_000_000_000, // 0.02 DASH + document_type_base_non_unique_index_registration_fee: 1_000_000_000, // 0.01 DASH + document_type_base_unique_index_registration_fee: 1_000_000_000, // 0.01 DASH + document_type_base_contested_index_registration_fee: 100_000_000_000, // 1 DASH + token_registration_fee: 10_000_000_000, // 0.1 DASH + token_uses_perpetual_distribution_fee: 10_000_000_000, // 0.1 DASH + token_uses_pre_programmed_distribution_fee: 10_000_000_000, // 0.1 DASH + search_keyword_fee: 10_000_000_000, // 0.1 DASH + } + } +} + +/// Minimum fees for state transitions (in credits). +/// Based on STATE_TRANSITION_MIN_FEES_VERSION1 from rs-platform-version. +#[derive(Debug, Clone, Copy)] +pub struct StateTransitionMinFees { + pub credit_transfer: u64, + pub credit_transfer_to_addresses: u64, + pub credit_withdrawal: u64, + pub identity_update: u64, + pub document_batch_sub_transition: u64, + pub contract_create: u64, + pub contract_update: u64, + pub masternode_vote: u64, + pub address_credit_withdrawal: u64, + pub address_funds_transfer_input_cost: u64, + pub address_funds_transfer_output_cost: u64, + pub identity_create_base_cost: u64, + pub identity_topup_base_cost: u64, + pub identity_key_in_creation_cost: u64, + /// Asset lock cost for identity creation (200,000 duffs × 1000 credits/duff) + pub identity_create_asset_lock_cost: u64, + /// Asset lock cost for identity top-up (50,000 duffs × 1000 credits/duff) + pub identity_topup_asset_lock_cost: u64, + /// Asset lock cost for address funding (50,000 duffs × 1000 credits/duff) + pub address_funding_asset_lock_cost: u64, +} + +impl Default for StateTransitionMinFees { + fn default() -> Self { + // Values from STATE_TRANSITION_MIN_FEES_VERSION1 + // Asset lock costs from IdentityTransitionAssetLockVersions (duffs × CREDITS_PER_DUFF) + // CREDITS_PER_DUFF = 1000 + Self { + credit_transfer: 100_000, + credit_transfer_to_addresses: 500_000, + credit_withdrawal: 400_000_000, + identity_update: 100_000, + document_batch_sub_transition: 100_000, + contract_create: 100_000, + contract_update: 100_000, + masternode_vote: 100_000, + address_credit_withdrawal: 400_000_000, + address_funds_transfer_input_cost: 500_000, + address_funds_transfer_output_cost: 6_000_000, + identity_create_base_cost: 2_000_000, + identity_topup_base_cost: 500_000, + identity_key_in_creation_cost: 6_500_000, + // Asset lock costs (duffs × 1000) + identity_create_asset_lock_cost: 200_000_000, // 200,000 duffs × 1000 = 0.002 DASH + identity_topup_asset_lock_cost: 50_000_000, // 50,000 duffs × 1000 = 0.0005 DASH + address_funding_asset_lock_cost: 50_000_000, // 50,000 duffs × 1000 = 0.0005 DASH + } + } +} + +/// Fee estimator for platform state transitions. +#[derive(Debug, Clone)] +pub struct PlatformFeeEstimator { + min_fees: StateTransitionMinFees, + storage_fees: StorageFeeConstants, + registration_fees: DataContractRegistrationFees, +} + +impl Default for PlatformFeeEstimator { + fn default() -> Self { + Self::new() + } +} + +impl PlatformFeeEstimator { + pub fn new() -> Self { + Self { + min_fees: StateTransitionMinFees::default(), + storage_fees: StorageFeeConstants::default(), + registration_fees: DataContractRegistrationFees::default(), + } + } + + /// Try to create from platform version (for future dynamic fee support) + pub fn from_platform_version(_platform_version: &PlatformVersion) -> Self { + // For now, use default fees. In future, could read from platform_version + Self::new() + } + + /// Calculate storage fee for a given number of bytes. + /// This is the main cost component for storing data on Platform. + pub fn calculate_storage_fee(&self, bytes: usize) -> u64 { + (bytes as u64).saturating_mul(self.storage_fees.storage_disk_usage_credit_per_byte) + } + + /// Calculate processing fee for writing data. + pub fn calculate_processing_fee(&self, bytes: usize) -> u64 { + (bytes as u64).saturating_mul(self.storage_fees.storage_processing_credit_per_byte) + } + + /// Calculate fee for tree seek operations. + /// Contracts and documents require multiple seeks for tree traversal. + pub fn calculate_seek_fee(&self, seek_count: usize) -> u64 { + (seek_count as u64).saturating_mul(self.storage_fees.storage_seek_cost) + } + + /// Estimate total storage-based fee for storing data. + /// Includes storage, processing, and estimated seek costs. + pub fn estimate_storage_based_fee(&self, bytes: usize, estimated_seeks: usize) -> u64 { + self.calculate_storage_fee(bytes) + .saturating_add(self.calculate_processing_fee(bytes)) + .saturating_add(self.calculate_seek_fee(estimated_seeks)) + } + + /// Estimate fee for credit transfer between identities + pub fn estimate_credit_transfer(&self) -> u64 { + self.min_fees.credit_transfer + } + + /// Estimate fee for credit transfer to platform addresses + pub fn estimate_credit_transfer_to_addresses(&self, output_count: usize) -> u64 { + self.min_fees.credit_transfer_to_addresses.saturating_add( + self.min_fees + .address_funds_transfer_output_cost + .saturating_mul(output_count as u64), + ) + } + + /// Estimate fee for credit withdrawal to core chain + pub fn estimate_credit_withdrawal(&self) -> u64 { + self.min_fees.credit_withdrawal + } + + /// Estimate fee for address-based credit withdrawal + pub fn estimate_address_credit_withdrawal(&self) -> u64 { + self.min_fees.address_credit_withdrawal + } + + /// Estimate fee for funding a platform address from an asset lock. + /// This includes the asset lock processing cost and transfer costs. + /// Returns fee in duffs (not credits). + pub fn estimate_address_funding_from_asset_lock_duffs(&self, output_count: usize) -> u64 { + // The fee includes: + // - Base transfer cost to addresses + // - Per-output costs + // We add a 50% buffer to account for any additional costs + let base_fee_credits = self.estimate_credit_transfer_to_addresses(output_count); + let fee_duffs = base_fee_credits / 1000; // Convert credits to duffs + // Add 50% buffer and ensure minimum of 10,000 duffs based on observed behavior + fee_duffs.saturating_add(fee_duffs / 2).max(10_000) + } + + /// Estimate fee for identity update (adding/disabling keys) + pub fn estimate_identity_update(&self) -> u64 { + self.min_fees.identity_update + } + + /// Estimate fee for identity creation. + /// This includes base cost, asset lock cost, and per-key costs. + pub fn estimate_identity_create(&self, key_count: usize) -> u64 { + self.min_fees + .identity_create_base_cost + .saturating_add(self.min_fees.identity_create_asset_lock_cost) + .saturating_add( + self.min_fees + .identity_key_in_creation_cost + .saturating_mul(key_count as u64), + ) + } + + /// Estimate fee for identity creation from addresses (asset lock). + /// This includes base cost, asset lock cost, input/output costs, and per-key costs. + pub fn estimate_identity_create_from_addresses( + &self, + input_count: usize, + has_output: bool, + key_count: usize, + ) -> u64 { + let output_count = if has_output { 1 } else { 0 }; + self.min_fees + .identity_create_base_cost + .saturating_add(self.min_fees.address_funding_asset_lock_cost) + .saturating_add( + self.min_fees + .address_funds_transfer_input_cost + .saturating_mul(input_count as u64), + ) + .saturating_add( + self.min_fees + .address_funds_transfer_output_cost + .saturating_mul(output_count), + ) + .saturating_add( + self.min_fees + .identity_key_in_creation_cost + .saturating_mul(key_count as u64), + ) + } + + /// Estimate fee for identity top-up. + /// This includes base cost and asset lock cost. + pub fn estimate_identity_topup(&self) -> u64 { + self.min_fees + .identity_topup_base_cost + .saturating_add(self.min_fees.identity_topup_asset_lock_cost) + } + + /// Estimate fee for document batch transition + pub fn estimate_document_batch(&self, transition_count: usize) -> u64 { + self.min_fees + .document_batch_sub_transition + .saturating_mul(transition_count.max(1) as u64) + } + + /// Estimate fee for document creation with known size. + /// Documents are stored in the contract's document tree. + /// Estimated seeks: ~10 for tree traversal and insertion. + pub fn estimate_document_create_with_size(&self, document_bytes: usize) -> u64 { + const ESTIMATED_SEEKS: usize = 10; + self.min_fees + .document_batch_sub_transition + .saturating_add(self.estimate_storage_based_fee(document_bytes, ESTIMATED_SEEKS)) + } + + /// Estimate fee for document creation (uses default estimate of ~200 bytes). + pub fn estimate_document_create(&self) -> u64 { + self.estimate_document_create_with_size(200) + } + + /// Estimate fee for document deletion. + /// Deletion is cheaper - mainly processing, no new storage. + pub fn estimate_document_delete(&self) -> u64 { + // Deletion involves seeks but no storage addition + const ESTIMATED_SEEKS: usize = 8; + self.min_fees + .document_batch_sub_transition + .saturating_add(self.calculate_seek_fee(ESTIMATED_SEEKS)) + } + + /// Estimate fee for document replacement with known size. + pub fn estimate_document_replace_with_size(&self, document_bytes: usize) -> u64 { + const ESTIMATED_SEEKS: usize = 10; + self.min_fees + .document_batch_sub_transition + .saturating_add(self.estimate_storage_based_fee(document_bytes, ESTIMATED_SEEKS)) + } + + /// Estimate fee for document replacement (uses default estimate of ~200 bytes). + pub fn estimate_document_replace(&self) -> u64 { + self.estimate_document_replace_with_size(200) + } + + /// Estimate fee for document transfer. + /// Transfer updates ownership, minimal storage change. + pub fn estimate_document_transfer(&self) -> u64 { + const ESTIMATED_SEEKS: usize = 8; + const OWNERSHIP_UPDATE_BYTES: usize = 64; + self.min_fees.document_batch_sub_transition.saturating_add( + self.estimate_storage_based_fee(OWNERSHIP_UPDATE_BYTES, ESTIMATED_SEEKS), + ) + } + + /// Estimate fee for document purchase. + pub fn estimate_document_purchase(&self) -> u64 { + const ESTIMATED_SEEKS: usize = 10; + const PURCHASE_UPDATE_BYTES: usize = 100; + self.min_fees + .document_batch_sub_transition + .saturating_add(self.estimate_storage_based_fee(PURCHASE_UPDATE_BYTES, ESTIMATED_SEEKS)) + } + + /// Estimate fee for document set price. + pub fn estimate_document_set_price(&self) -> u64 { + const ESTIMATED_SEEKS: usize = 8; + const PRICE_UPDATE_BYTES: usize = 32; + self.min_fees + .document_batch_sub_transition + .saturating_add(self.estimate_storage_based_fee(PRICE_UPDATE_BYTES, ESTIMATED_SEEKS)) + } + + /// Estimate fee for token transition (mint, burn, transfer, freeze, etc.). + /// Token operations are relatively small - mainly balance updates. + pub fn estimate_token_transition(&self) -> u64 { + const ESTIMATED_SEEKS: usize = 8; + const TOKEN_OP_BYTES: usize = 100; + self.min_fees + .document_batch_sub_transition + .saturating_add(self.estimate_storage_based_fee(TOKEN_OP_BYTES, ESTIMATED_SEEKS)) + } + + /// Estimate fee for data contract creation with known size. + /// Includes base registration fee (0.1 DASH) plus storage costs. + /// For contracts with tokens, document types, or indexes, use the detailed method. + pub fn estimate_contract_create_with_size(&self, contract_bytes: usize) -> u64 { + const ESTIMATED_SEEKS: usize = 20; + self.registration_fees + .base_contract_registration_fee + .saturating_add(self.min_fees.contract_create) + .saturating_add(self.estimate_storage_based_fee(contract_bytes, ESTIMATED_SEEKS)) + } + + /// Estimate fee for data contract creation with detailed component counts. + /// This provides the most accurate estimate by accounting for all registration fees. + #[allow(clippy::too_many_arguments)] + pub fn estimate_contract_create_detailed( + &self, + contract_bytes: usize, + document_type_count: usize, + non_unique_index_count: usize, + unique_index_count: usize, + contested_index_count: usize, + has_token: bool, + has_perpetual_distribution: bool, + has_pre_programmed_distribution: bool, + search_keyword_count: usize, + ) -> u64 { + const ESTIMATED_SEEKS: usize = 20; + + let mut fee = self.registration_fees.base_contract_registration_fee; + + // Document type fees + fee = fee.saturating_add( + self.registration_fees + .document_type_registration_fee + .saturating_mul(document_type_count as u64), + ); + + // Index fees + fee = fee.saturating_add( + self.registration_fees + .document_type_base_non_unique_index_registration_fee + .saturating_mul(non_unique_index_count as u64), + ); + fee = fee.saturating_add( + self.registration_fees + .document_type_base_unique_index_registration_fee + .saturating_mul(unique_index_count as u64), + ); + fee = fee.saturating_add( + self.registration_fees + .document_type_base_contested_index_registration_fee + .saturating_mul(contested_index_count as u64), + ); + + // Token fees + if has_token { + fee = fee.saturating_add(self.registration_fees.token_registration_fee); + } + if has_perpetual_distribution { + fee = fee.saturating_add(self.registration_fees.token_uses_perpetual_distribution_fee); + } + if has_pre_programmed_distribution { + fee = fee.saturating_add( + self.registration_fees + .token_uses_pre_programmed_distribution_fee, + ); + } + + // Search keyword fees + fee = fee.saturating_add( + self.registration_fees + .search_keyword_fee + .saturating_mul(search_keyword_count as u64), + ); + + // Add state transition minimum and storage fees + fee = fee.saturating_add(self.min_fees.contract_create); + fee = fee.saturating_add(self.estimate_storage_based_fee(contract_bytes, ESTIMATED_SEEKS)); + + fee + } + + /// Estimate fee for data contract creation (uses base registration fee only). + /// For more accurate estimates, use estimate_contract_create_with_size or + /// estimate_contract_create_detailed. + pub fn estimate_contract_create_base(&self) -> u64 { + // Base registration fee (0.1 DASH) + minimal storage estimate + self.estimate_contract_create_with_size(500) + } + + /// Estimate fee for data contract update with known size of changes. + pub fn estimate_contract_update_with_size(&self, update_bytes: usize) -> u64 { + const ESTIMATED_SEEKS: usize = 15; + self.min_fees + .contract_update + .saturating_add(self.estimate_storage_based_fee(update_bytes, ESTIMATED_SEEKS)) + } + + /// Estimate fee for data contract update (uses default estimate). + pub fn estimate_contract_update(&self) -> u64 { + self.estimate_contract_update_with_size(300) + } + + /// Get the registration fees structure + pub fn registration_fees(&self) -> &DataContractRegistrationFees { + &self.registration_fees + } + + /// Estimate fee for masternode vote + pub fn estimate_masternode_vote(&self) -> u64 { + self.min_fees.masternode_vote + } + + /// Estimate fee for address funds transfer + pub fn estimate_address_funds_transfer(&self, input_count: usize, output_count: usize) -> u64 { + self.min_fees + .address_funds_transfer_input_cost + .saturating_mul(input_count as u64) + .saturating_add( + self.min_fees + .address_funds_transfer_output_cost + .saturating_mul(output_count.max(1) as u64), + ) + } + + /// Get the raw minimum fees structure + pub fn min_fees(&self) -> &StateTransitionMinFees { + &self.min_fees + } + + /// Get the storage fee constants + pub fn storage_fees(&self) -> &StorageFeeConstants { + &self.storage_fees + } +} + +/// Credits per DASH constant +/// 1 DASH = 100,000,000,000 credits (100 billion) +pub const CREDITS_PER_DASH: u64 = 100_000_000_000; + +/// Format credits as DASH for display +pub fn format_credits_as_dash(credits: u64) -> String { + let dash = credits as f64 / CREDITS_PER_DASH as f64; + format!("{:.8} DASH", dash) +} + +/// Format credits for display (with both credits and DASH) +pub fn format_credits(credits: u64) -> String { + let dash = credits as f64 / CREDITS_PER_DASH as f64; + if credits >= 1_000_000_000 { + format!("{} credits ({:.8} DASH)", credits, dash) + } else { + format!("{} credits ({:.10} DASH)", credits, dash) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_credit_transfer_estimate() { + let estimator = PlatformFeeEstimator::new(); + assert_eq!(estimator.estimate_credit_transfer(), 100_000); + } + + #[test] + fn test_identity_create_estimate() { + let estimator = PlatformFeeEstimator::new(); + // Base cost + asset lock cost + 2 keys + let fee = estimator.estimate_identity_create(2); + assert_eq!(fee, 2_000_000 + 200_000_000 + 2 * 6_500_000); + } + + #[test] + fn test_document_batch_estimate() { + let estimator = PlatformFeeEstimator::new(); + // 3 documents - base fee only + let fee = estimator.estimate_document_batch(3); + assert_eq!(fee, 3 * 100_000); + } + + #[test] + fn test_storage_fee_calculation() { + let estimator = PlatformFeeEstimator::new(); + // 500 bytes at 27,000 credits/byte = 13,500,000 credits + let fee = estimator.calculate_storage_fee(500); + assert_eq!(fee, 500 * 27_000); + // 13,500,000 credits = 0.000135 DASH (at 100 billion credits per DASH) + assert_eq!(format_credits_as_dash(fee), "0.00013500 DASH"); + } + + #[test] + fn test_contract_create_with_size() { + let estimator = PlatformFeeEstimator::new(); + // 500 byte contract + let fee = estimator.estimate_contract_create_with_size(500); + // Should be: base_registration_fee + min_fee + storage + processing + seeks + // 10,000,000,000 + 100,000 + (500 * 27,000) + (500 * 400) + (20 * 2,000) + // = 10,000,000,000 + 100,000 + 13,500,000 + 200,000 + 40,000 + // = 10,013,840,000 credits = ~0.1 DASH + let base_registration = 10_000_000_000u64; // 0.1 DASH + let min_fee = 100_000u64; + let storage = 500 * 27_000; + let processing = 500 * 400; + let seeks = 20 * 2_000; + let expected = base_registration + min_fee + storage + processing + seeks; + assert_eq!(fee, expected); + // ~0.1 DASH for a simple contract (base registration fee dominates) + } + + #[test] + fn test_contract_create_detailed_with_token() { + let estimator = PlatformFeeEstimator::new(); + // Contract with a token + let fee = estimator.estimate_contract_create_detailed( + 500, // contract bytes + 1, // 1 document type + 1, // 1 non-unique index + 0, // 0 unique indexes + 0, // 0 contested indexes + true, // has token + false, // no perpetual distribution + false, // no pre-programmed distribution + 0, // 0 search keywords + ); + // Base: 0.1 DASH + Document type: 0.02 DASH + Index: 0.01 DASH + Token: 0.1 DASH + // = 0.23 DASH + storage fees + let expected_registration = 10_000_000_000 + 2_000_000_000 + 1_000_000_000 + 10_000_000_000; + assert!(fee >= expected_registration); + } + + #[test] + fn test_format_credits() { + // 1 DASH = 100,000,000,000 credits + assert_eq!(format_credits_as_dash(100_000_000_000), "1.00000000 DASH"); + assert_eq!(format_credits_as_dash(100_000_000), "0.00100000 DASH"); + assert_eq!(format_credits_as_dash(100_000), "0.00000100 DASH"); + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index ef4ce0794..de9df9441 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,5 +1,6 @@ pub mod amount; pub mod contested_name; +pub mod fee_estimation; pub mod grovestark_prover; pub mod password_info; pub mod proof_log_item; diff --git a/src/model/qualified_identity/encrypted_key_storage.rs b/src/model/qualified_identity/encrypted_key_storage.rs index 8bfe92ca1..eb0f0c7f3 100644 --- a/src/model/qualified_identity/encrypted_key_storage.rs +++ b/src/model/qualified_identity/encrypted_key_storage.rs @@ -294,6 +294,24 @@ impl KeyStorage { wallet_seed_hash, derivation_path, }) => { + tracing::debug!( + stored_wallet_seed_hash = %hex::encode(wallet_seed_hash), + derivation_path = %derivation_path, + num_wallets = wallets.len(), + "Looking up wallet for key derivation" + ); + + // Log available wallet seed hashes + for wallet in wallets { + if let Ok(wallet_ref) = wallet.read() { + tracing::debug!( + wallet_seed_hash = %hex::encode(wallet_ref.seed_hash()), + matches = (wallet_ref.seed_hash() == *wallet_seed_hash), + "Available wallet" + ); + } + } + let derived_key = Wallet::derive_private_key_in_arc_rw_lock_slice( wallets, *wallet_seed_hash, diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index a09d8cbf8..70838e467 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -292,19 +292,51 @@ impl Decode for QualifiedIdentity { } } -impl Signer for QualifiedIdentity { +impl Display for QualifiedIdentity { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if let Some(alias) = &self.alias { + write!(f, "{}", alias) + } else if !self.dpns_names.is_empty() { + write!(f, "{}", self.dpns_names[0].name) + } else { + write!(f, "{}", self.identity.id()) + } + } +} + +impl Signer for QualifiedIdentity { fn sign( &self, identity_public_key: &IdentityPublicKey, data: &[u8], ) -> Result { + let target: PrivateKeyTarget = identity_public_key.purpose().into(); + let key_id = identity_public_key.id(); + + tracing::debug!( + identity_id = %self.identity.id().to_string(Encoding::Base58), + key_id = key_id, + key_purpose = ?identity_public_key.purpose(), + key_type = ?identity_public_key.key_type(), + target = ?target, + "Attempting to sign with key" + ); + + // Log available keys + for ((t, id), (pub_key, _)) in self.private_keys.private_keys.iter() { + tracing::debug!( + target = ?t, + key_id = id, + purpose = ?pub_key.identity_public_key.purpose(), + key_type = ?pub_key.identity_public_key.key_type(), + "Available key in identity" + ); + } + let (_, private_key) = self .private_keys .get_resolve( - &( - identity_public_key.purpose().into(), - identity_public_key.id(), - ), + &(target.clone(), key_id), self.associated_wallets .values() .cloned() @@ -312,15 +344,100 @@ impl Signer for QualifiedIdentity { .as_slice(), self.network, ) - .map_err(ProtocolError::Generic)? - .ok_or(ProtocolError::Generic(format!( - "Key {} ({}) not found in identity {:?}", - identity_public_key.id(), - identity_public_key.purpose(), - self.identity.id().to_string(Encoding::Base58) - )))?; + .map_err(|e| { + tracing::error!(error = %e, "Failed to resolve private key"); + ProtocolError::Generic(e) + })? + .ok_or_else(|| { + tracing::error!( + key_id = key_id, + purpose = ?identity_public_key.purpose(), + target = ?target, + "Key not found in identity" + ); + ProtocolError::Generic(format!( + "Key {} ({}) not found in identity {:?}", + identity_public_key.id(), + identity_public_key.purpose(), + self.identity.id().to_string(Encoding::Base58) + )) + })?; + + tracing::debug!("Successfully resolved private key, proceeding to sign"); match identity_public_key.key_type() { KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => { + // For ECDSA_HASH160, verify that the private key matches the public key hash on Platform + // If there's a mismatch (due to incorrect stored derivation path), regenerate the correct path + if identity_public_key.key_type() == KeyType::ECDSA_HASH160 { + use dash_sdk::dpp::dashcore::PublicKey; + use dash_sdk::dpp::dashcore::hashes::{Hash, ripemd160, sha256}; + use dash_sdk::dpp::dashcore::secp256k1::{Secp256k1, SecretKey}; + + let platform_key_data = identity_public_key.data().as_slice(); + + if let Ok(secret_key) = SecretKey::from_slice(&private_key) { + let secp = Secp256k1::new(); + let derived_pubkey = PublicKey::new(secret_key.public_key(&secp)); + let pubkey_bytes = derived_pubkey.to_bytes(); + let sha256_hash = sha256::Hash::hash(&pubkey_bytes); + let hash160 = ripemd160::Hash::hash(sha256_hash.as_byte_array()); + + if hash160.as_byte_array() != platform_key_data { + // Mismatch detected - scan identity indices to find the correct derivation path + use dash_sdk::dpp::key_wallet::bip32::{ + DerivationPath as DP, KeyDerivationType, + }; + + if let Some(wallet) = self.associated_wallets.values().next() + && let Ok(wallet_ref) = wallet.read() + && let Ok(seed) = wallet_ref.seed_bytes() + { + // Scan identity indices 0-9 to find matching key + for identity_index in 0..10u32 { + let correct_path = DP::identity_authentication_path( + self.network, + KeyDerivationType::ECDSA, + identity_index, + key_id, + ); + + if let Ok(extended_key) = correct_path + .derive_priv_ecdsa_for_master_seed(seed, self.network) + { + let correct_pubkey = PublicKey::new( + extended_key.private_key.public_key(&secp), + ); + let correct_hash = ripemd160::Hash::hash( + sha256::Hash::hash(&correct_pubkey.to_bytes()) + .as_byte_array(), + ); + + if correct_hash.as_byte_array() == platform_key_data { + tracing::info!( + identity_index = identity_index, + key_id = key_id, + path = %correct_path, + "Using corrected derivation path for signing (found via scan)" + ); + let signature = signer::sign( + data, + &extended_key.private_key.secret_bytes(), + )?; + return Ok(signature.to_vec().into()); + } + } + } + } + + tracing::error!( + derived = %hex::encode(hash160.as_byte_array()), + platform = %hex::encode(platform_key_data), + "Key mismatch and could not find correct derivation path after scanning" + ); + } + } + } + let signature = signer::sign(data, &private_key)?; Ok(signature.to_vec().into()) } @@ -363,6 +480,44 @@ impl Signer for QualifiedIdentity { identity_public_key.id(), )) } + + fn sign_create_witness( + &self, + identity_public_key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + use dash_sdk::dpp::address_funds::AddressWitness; + + // First, sign the data to get the signature (compact recoverable signature) + // The public key will be recovered from the signature during verification + let signature = self.sign(identity_public_key, data)?; + + // Create the appropriate AddressWitness based on the key type + match identity_public_key.key_type() { + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => { + // P2PKH witness only needs the recoverable signature + Ok(AddressWitness::P2pkh { signature }) + } + KeyType::EDDSA_25519_HASH160 => { + // Ed25519 keys are not supported for address witnesses (P2PKH requires ECDSA) + Err(ProtocolError::InvalidIdentityPublicKeyTypeError( + InvalidIdentityPublicKeyTypeError::new(identity_public_key.key_type()), + )) + } + KeyType::BIP13_SCRIPT_HASH => { + // For script hash, we would need the redeem script which isn't available from just the key + Err(ProtocolError::InvalidIdentityPublicKeyTypeError( + InvalidIdentityPublicKeyTypeError::new(identity_public_key.key_type()), + )) + } + KeyType::BLS12_381 => { + // BLS keys are not supported for address witnesses + Err(ProtocolError::InvalidIdentityPublicKeyTypeError( + InvalidIdentityPublicKeyTypeError::new(identity_public_key.key_type()), + )) + } + } + } } impl QualifiedIdentity { diff --git a/src/model/settings.rs b/src/model/settings.rs index 37b203bc7..a593e7571 100644 --- a/src/model/settings.rs +++ b/src/model/settings.rs @@ -1,9 +1,27 @@ use crate::model::password_info::PasswordInfo; +use crate::spv::CoreBackendMode; use crate::ui::RootScreenType; use crate::ui::theme::ThemeMode; use dash_sdk::dpp::dashcore::Network; use std::path::PathBuf; +/// User experience mode +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum UserMode { + Beginner, + #[default] + Advanced, +} + +impl UserMode { + pub fn as_str(&self) -> &'static str { + match self { + UserMode::Beginner => "Beginner", + UserMode::Advanced => "Advanced", + } + } +} + /// Application settings structure #[derive(Debug, Clone)] pub struct Settings { @@ -14,7 +32,17 @@ pub struct Settings { /// Empty value (`""`) means path deliberately not set, autodetect will not be performed. pub dash_qt_path: Option, pub overwrite_dash_conf: bool, + pub disable_zmq: bool, pub theme_mode: ThemeMode, + pub core_backend_mode: CoreBackendMode, + /// Whether the user has completed the initial onboarding + pub onboarding_completed: bool, + /// Whether to show Evonode-related tools + pub show_evonode_tools: bool, + /// User experience mode (Beginner or Advanced) + pub user_mode: UserMode, + /// Whether to automatically close Dash-Qt when DET exits + pub close_dash_qt_on_exit: bool, } impl @@ -24,7 +52,13 @@ impl Option, Option, bool, + bool, ThemeMode, + u8, + bool, // onboarding_completed + bool, // show_evonode_tools + UserMode, // user_mode + bool, // close_dash_qt_on_exit )> for Settings { /// Converts a tuple into a Settings instance @@ -37,10 +71,29 @@ impl Option, Option, bool, + bool, ThemeMode, + u8, + bool, + bool, + UserMode, + bool, ), ) -> Self { - Self::new(tuple.0, tuple.1, tuple.2, tuple.3, tuple.4, tuple.5) + Self::new( + tuple.0, + tuple.1, + tuple.2, + tuple.3, + tuple.4, + tuple.5, + tuple.6, + CoreBackendMode::from(tuple.7), + tuple.8, + tuple.9, + tuple.10, + tuple.11, + ) } } @@ -49,24 +102,37 @@ impl Default for Settings { fn default() -> Self { Self::new( Network::Dash, - RootScreenType::RootScreenIdentities, + RootScreenType::RootScreenDashpay, None, None, // autodetect true, + false, ThemeMode::System, + CoreBackendMode::Spv, // Default to SPV mode + false, // onboarding not completed + false, // don't show evonode tools by default + UserMode::Advanced, // default to advanced mode + true, // close Dash-Qt on exit by default ) } } impl Settings { /// Creates a new Settings instance + #[allow(clippy::too_many_arguments)] pub fn new( network: Network, root_screen_type: RootScreenType, password_info: Option, dash_qt_path: Option, overwrite_dash_conf: bool, + disable_zmq: bool, theme_mode: ThemeMode, + core_backend_mode: CoreBackendMode, + onboarding_completed: bool, + show_evonode_tools: bool, + user_mode: UserMode, + close_dash_qt_on_exit: bool, ) -> Self { Self { network, @@ -74,7 +140,13 @@ impl Settings { password_info, dash_qt_path: dash_qt_path.or_else(detect_dash_qt_path), overwrite_dash_conf, + disable_zmq, theme_mode, + core_backend_mode, + onboarding_completed, + show_evonode_tools, + user_mode, + close_dash_qt_on_exit, } } } diff --git a/src/model/wallet/asset_lock_transaction.rs b/src/model/wallet/asset_lock_transaction.rs index ce8e13099..55cf609be 100644 --- a/src/model/wallet/asset_lock_transaction.rs +++ b/src/model/wallet/asset_lock_transaction.rs @@ -76,6 +76,54 @@ impl Wallet { ) } + /// Create an asset lock transaction with a randomly generated one-time key. + /// This is used for generic platform address funding (not identity-specific). + #[allow(clippy::type_complexity)] + pub fn generic_asset_lock_transaction( + &mut self, + network: Network, + amount: u64, + allow_take_fee_from_amount: bool, + register_addresses: Option<&AppContext>, + ) -> Result< + ( + Transaction, + PrivateKey, + Address, + Option
      , + BTreeMap, + ), + String, + > { + use rand::rngs::OsRng; + + // Generate a random private key for the asset lock + let secp = Secp256k1::new(); + let (secret_key, _) = secp.generate_keypair(&mut OsRng); + let private_key = PrivateKey::new(secret_key, network); + let public_key = private_key.public_key(&secp); + + // The asset lock address is where the proof will be tied to + let asset_lock_address = Address::p2pkh(&public_key, network); + + let (tx, returned_private_key, change_address, used_utxos) = self + .asset_lock_transaction_from_private_key( + network, + amount, + allow_take_fee_from_amount, + private_key, + register_addresses, + )?; + + Ok(( + tx, + returned_private_key, + asset_lock_address, + change_address, + used_utxos, + )) + } + #[allow(clippy::type_complexity)] fn asset_lock_transaction_from_private_key( &mut self, diff --git a/src/model/wallet/encryption.rs b/src/model/wallet/encryption.rs index 6a75a7ca0..9e2009ca1 100644 --- a/src/model/wallet/encryption.rs +++ b/src/model/wallet/encryption.rs @@ -1,5 +1,5 @@ use aes_gcm::aead::Aead; -use aes_gcm::{Aes256Gcm, KeyInit}; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; use argon2::{self, Argon2}; use bip39::rand::{RngCore, rngs::OsRng}; @@ -30,6 +30,7 @@ pub fn derive_password_key(password: &str, salt: &[u8]) -> Result, Strin /// Encrypt the seed using AES-256-GCM. #[allow(clippy::type_complexity)] +#[allow(deprecated)] pub fn encrypt_message( message: &[u8], password: &str, @@ -49,8 +50,9 @@ pub fn encrypt_message( let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| e.to_string())?; // Encrypt the seed + let nonce_arr = Nonce::from_slice(&nonce); let encrypted_seed = cipher - .encrypt(nonce.as_slice().into(), message) + .encrypt(nonce_arr, message) .map_err(|e| e.to_string())?; Ok((encrypted_seed, salt, nonce)) @@ -76,6 +78,7 @@ impl ClosedKeyItem { } /// Decrypt the seed using AES-256-GCM. + #[allow(deprecated)] pub fn decrypt_seed(&self, password: &str) -> Result<[u8; 64], String> { // Derive the key let key = derive_password_key(password, &self.salt)?; @@ -84,8 +87,9 @@ impl ClosedKeyItem { let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| e.to_string())?; // Decrypt the seed + let nonce_arr = Nonce::from_slice(&self.nonce); let seed = cipher - .decrypt(self.nonce.as_slice().into(), self.encrypted_seed.as_slice()) + .decrypt(nonce_arr, self.encrypted_seed.as_slice()) .map_err(|e| e.to_string())?; let sized_seed = seed.try_into().map_err(|e: Vec| { diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 52a1929c2..2efd99089 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -1,18 +1,45 @@ mod asset_lock_transaction; pub mod encryption; +pub mod single_key; mod utxos; -use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, ExtendedPubKey, KeyDerivationType}; +use dash_sdk::dpp::ProtocolError; +use dash_sdk::dpp::address_funds::{AddressWitness, PlatformAddress}; +use dash_sdk::dpp::identity::signer::Signer; +use dash_sdk::dpp::key_wallet::account::AccountType; +use dash_sdk::dpp::key_wallet::bip32::{ + ChildNumber, DerivationPath, ExtendedPubKey, KeyDerivationType, +}; +use dash_sdk::dpp::key_wallet::psbt::serialize::Serialize; +use dash_sdk::dpp::prelude::AddressNonce; +use dash_sdk::platform::address_sync::{AddressIndex, AddressKey, AddressProvider}; +use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1}; +use dash_sdk::dpp::dashcore::sighash::SighashCache; use dash_sdk::dpp::dashcore::{ - Address, InstantLock, Network, OutPoint, PrivateKey, PublicKey, Transaction, TxOut, + Address, BlockHash, InstantLock, Network, OutPoint, PrivateKey, PublicKey, ScriptBuf, + Transaction, TxIn, TxOut, Txid, }; -use dash_sdk::dpp::key_wallet::bip32::DerivationPath; -use std::collections::{BTreeMap, HashMap}; +use dash_sdk::dpp::platform_value::BinaryData; +use std::cmp; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt::Debug; use std::ops::Range; use std::sync::{Arc, RwLock}; +/// Check if two networks use the same address format. +/// Testnet, Devnet, and Regtest all use testnet-style addresses. +fn networks_address_compatible(a: &Network, b: &Network) -> bool { + matches!( + (a, b), + (Network::Dash, Network::Dash) + | ( + Network::Testnet | Network::Devnet | Network::Regtest, + Network::Testnet | Network::Devnet | Network::Regtest, + ) + ) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] pub enum DerivationPathReference { Unknown = 0, @@ -30,6 +57,9 @@ pub enum DerivationPathReference { BlockchainIdentityCreditTopupFunding = 12, BlockchainIdentityCreditInvitationFunding = 13, ProviderPlatformNodeKeys = 14, + CoinJoin = 15, + /// DIP-17: Platform Payment Addresses + PlatformPayment = 16, Root = 255, } @@ -53,6 +83,8 @@ impl TryFrom for DerivationPathReference { 12 => Ok(DerivationPathReference::BlockchainIdentityCreditTopupFunding), 13 => Ok(DerivationPathReference::BlockchainIdentityCreditInvitationFunding), 14 => Ok(DerivationPathReference::ProviderPlatformNodeKeys), + 15 => Ok(DerivationPathReference::CoinJoin), + 16 => Ok(DerivationPathReference::PlatformPayment), 255 => Ok(DerivationPathReference::Root), value => Err(format!( "value {} not convertable to a DerivationPathReference", @@ -62,10 +94,118 @@ impl TryFrom for DerivationPathReference { } } +/// Helper methods for working with derivation paths we care about when presenting wallet data. +pub trait DerivationPathHelpers { + fn is_bip44(&self, network: Network) -> bool; + fn is_bip44_external(&self, network: Network) -> bool; + fn is_bip44_change(&self, network: Network) -> bool; + fn is_asset_lock_funding(&self, network: Network) -> bool; + fn is_platform_payment(&self, network: Network) -> bool; + fn bip44_account_index(&self) -> Option; + fn bip44_address_index(&self) -> Option; + fn platform_payment_path( + network: Network, + account: u32, + key_class: u32, + index: u32, + ) -> DerivationPath; +} + +impl DerivationPathHelpers for DerivationPath { + fn is_bip44(&self, network: Network) -> bool { + let coin_type = match network { + Network::Dash => 5, + _ => 1, + }; + let components = self.as_ref(); + components.len() >= 4 + && components[0] == ChildNumber::Hardened { index: 44 } + && components[1] == ChildNumber::Hardened { index: coin_type } + } + + fn is_bip44_external(&self, network: Network) -> bool { + if !self.is_bip44(network) { + return false; + } + let components = self.as_ref(); + components.len() >= 5 && components[3] == ChildNumber::Normal { index: 0 } + } + + fn is_bip44_change(&self, network: Network) -> bool { + if !self.is_bip44(network) { + return false; + } + let components = self.as_ref(); + components.len() >= 5 && components[3] == ChildNumber::Normal { index: 1 } + } + + fn is_asset_lock_funding(&self, network: Network) -> bool { + let coin_type = match network { + Network::Dash => 5, + _ => 1, + }; + let components = self.as_ref(); + components.len() == 5 + && components[0] == ChildNumber::Hardened { index: 9 } + && components[1] == ChildNumber::Hardened { index: coin_type } + && components[2] == ChildNumber::Hardened { index: 5 } + && components[3] == ChildNumber::Hardened { index: 1 } + } + + fn bip44_account_index(&self) -> Option { + self.as_ref().get(2).and_then(|child| match child { + ChildNumber::Hardened { index } => Some(*index), + _ => None, + }) + } + + fn bip44_address_index(&self) -> Option { + self.as_ref().last().and_then(|child| match child { + ChildNumber::Normal { index } => Some(*index), + ChildNumber::Hardened { index } => Some(*index), + ChildNumber::Normal256 { .. } | ChildNumber::Hardened256 { .. } => None, + }) + } + + /// Check if this path is a DIP-17 Platform payment path: m/9'/coin_type'/17'/account'/key_class'/index + fn is_platform_payment(&self, network: Network) -> bool { + let coin_type = match network { + Network::Dash => 5, + _ => 1, + }; + let components = self.as_ref(); + // DIP-17: m/9'/coin_type'/17'/account'/key_class'/index + components.len() == 6 + && components[0] == ChildNumber::Hardened { index: 9 } + && components[1] == ChildNumber::Hardened { index: coin_type } + && components[2] == ChildNumber::Hardened { index: 17 } + } + + /// Create a DIP-17 Platform payment derivation path: m/9'/coin_type'/17'/account'/key_class'/index + fn platform_payment_path( + network: Network, + account: u32, + key_class: u32, + index: u32, + ) -> DerivationPath { + let coin_type = match network { + Network::Dash => 5, + _ => 1, + }; + DerivationPath::from(vec![ + ChildNumber::Hardened { index: 9 }, + ChildNumber::Hardened { index: coin_type }, + ChildNumber::Hardened { index: 17 }, + ChildNumber::Hardened { index: account }, + ChildNumber::Hardened { index: key_class }, + ChildNumber::Normal { index }, + ]) + } +} + use crate::context::AppContext; use bitflags::bitflags; use dash_sdk::dashcore_rpc::RpcApi; -use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; use dash_sdk::dpp::balances::credits::Duffs; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::fee::Credits; @@ -73,6 +213,20 @@ use dash_sdk::dpp::prelude::AssetLockProof; use dash_sdk::platform::Identity; use zeroize::Zeroize; +const BOOTSTRAP_BIP44_EXTERNAL_COUNT: u32 = 32; +const BOOTSTRAP_BIP44_CHANGE_COUNT: u32 = 16; +const BOOTSTRAP_BIP32_ACCOUNT_COUNT: u32 = 1; +const BOOTSTRAP_BIP32_ADDRESS_COUNT: u32 = 16; +const BOOTSTRAP_COINJOIN_ACCOUNT_COUNT: u32 = 1; +const BOOTSTRAP_COINJOIN_ADDRESS_COUNT: u32 = 16; +const BOOTSTRAP_IDENTITY_REGISTRATION_FALLBACK: u32 = 8; +const BOOTSTRAP_IDENTITY_INVITATION_COUNT: u32 = 8; +const BOOTSTRAP_IDENTITY_TOPUP_PER_REGISTRATION: u32 = 4; +const BOOTSTRAP_IDENTITY_TOPUP_NOT_BOUND_COUNT: u32 = 8; +const BOOTSTRAP_PROVIDER_ADDRESS_COUNT: u32 = 4; +/// DIP-17: Number of Platform payment addresses to bootstrap per key class +const BOOTSTRAP_PLATFORM_PAYMENT_ADDRESS_COUNT: u32 = 20; + bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)] pub struct DerivationPathType: u32 { @@ -85,13 +239,15 @@ bitflags! { const PARTIAL_PATH = 1 << 5; const PROTECTED_FUNDS = 1 << 6; const CREDIT_FUNDING = 1 << 7; + const DASHPAY = 1 << 8; // Composite flags const IS_FOR_AUTHENTICATION = Self::SINGLE_USER_AUTHENTICATION.bits() | Self::MULTIPLE_USER_AUTHENTICATION.bits(); const IS_FOR_FUNDS = Self::CLEAR_FUNDS.bits() | Self::ANONYMOUS_FUNDS.bits() | Self::VIEW_ONLY_FUNDS.bits() - | Self::PROTECTED_FUNDS.bits(); + | Self::PROTECTED_FUNDS.bits() + | Self::DASHPAY.bits(); } } #[derive(Debug, Clone, PartialEq)] @@ -109,7 +265,14 @@ pub struct WalletArcRef { impl From>> for WalletArcRef { fn from(wallet: Arc>) -> Self { - let seed_hash = { wallet.read().unwrap().seed_hash() }; + // From trait doesn't allow returning Result, so use a fallback for poisoned locks + let seed_hash = wallet + .read() + .map(|w| w.seed_hash()) + .unwrap_or_else(|poisoned| { + tracing::warn!("Wallet lock poisoned during WalletArcRef conversion"); + poisoned.into_inner().seed_hash() + }); Self { wallet, seed_hash } } } @@ -120,12 +283,24 @@ impl PartialEq for WalletArcRef { } } +/// Information about a Platform address balance and nonce +#[derive(Debug, Clone, PartialEq, Default)] +pub struct PlatformAddressInfo { + pub balance: Credits, + pub nonce: AddressNonce, + /// Balance as of last full sync (used for terminal-only sync pre-population) + /// This prevents double-counting when proof-verified updates happen between syncs + pub last_synced_balance: Option, +} + #[derive(Debug, Clone, PartialEq)] pub struct Wallet { pub wallet_seed: WalletSeed, pub uses_password: bool, pub master_bip44_ecdsa_extended_public_key: ExtendedPubKey, pub address_balances: BTreeMap, + /// Historical total received per address (not just current UTXOs) + pub address_total_received: BTreeMap, pub known_addresses: BTreeMap, pub watched_addresses: BTreeMap, #[allow(clippy::type_complexity)] @@ -139,7 +314,44 @@ pub struct Wallet { pub alias: Option, pub identities: HashMap, pub utxos: HashMap>, + pub transactions: Vec, pub is_main: bool, + pub confirmed_balance: u64, + pub unconfirmed_balance: u64, + pub total_balance: u64, + /// DIP-17: Platform address balances and nonces (keyed by Core Address for lookup) + pub platform_address_info: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct WalletTransaction { + pub txid: Txid, + pub transaction: Transaction, + pub timestamp: u64, + pub height: Option, + pub block_hash: Option, + pub net_amount: i64, + pub fee: Option, + pub label: Option, + pub is_ours: bool, +} + +impl WalletTransaction { + pub fn is_incoming(&self) -> bool { + self.net_amount > 0 + } + + pub fn is_outgoing(&self) -> bool { + self.net_amount < 0 + } + + pub fn is_confirmed(&self) -> bool { + self.height.is_some() + } + + pub fn amount_abs(&self) -> u64 { + self.net_amount.unsigned_abs() + } } pub type WalletSeedHash = [u8; 32]; @@ -211,7 +423,7 @@ impl WalletSeed { OpenWalletSeed { seed: closed_seed.encrypted_seed.clone().try_into().map_err( |e: Vec| { - format!("incorred seed size, expected 64 bytes, got {}", e.len()) + format!("incorrect seed size, expected 64 bytes, got {}", e.len()) }, )?, wallet_info: closed_seed.clone(), @@ -256,7 +468,7 @@ impl Wallet { matches!(self.wallet_seed, WalletSeed::Open(_)) } pub fn has_balance(&self) -> bool { - self.max_balance() > 0 + self.confirmed_balance_duffs() > 0 || self.unconfirmed_balance > 0 } pub fn has_unused_asset_lock(&self) -> bool { @@ -270,7 +482,70 @@ impl Wallet { .sum::() } - fn seed_bytes(&self) -> Result<&[u8; 64], String> { + pub fn confirmed_balance_duffs(&self) -> u64 { + if self.total_balance > 0 || self.confirmed_balance > 0 || self.unconfirmed_balance > 0 { + self.confirmed_balance + } else { + self.max_balance() + } + } + + pub fn unconfirmed_balance_duffs(&self) -> u64 { + self.unconfirmed_balance + } + + pub fn total_balance_duffs(&self) -> u64 { + if self.total_balance > 0 { + self.total_balance + } else { + self.max_balance() + } + } + + pub fn update_spv_balances(&mut self, confirmed: u64, unconfirmed: u64, total: u64) { + self.confirmed_balance = confirmed; + self.unconfirmed_balance = unconfirmed; + self.total_balance = total; + } + + pub fn bootstrap_known_addresses(&mut self, app_context: &AppContext) { + if !self.is_open() { + tracing::debug!("Skipping address bootstrap for locked wallet"); + return; + } + + let network = app_context.network; + + if let Err(err) = self.bootstrap_bip44_addresses(network, app_context) { + tracing::warn!("Failed to bootstrap BIP44 addresses: {}", err); + } + + if let Err(err) = self.bootstrap_bip32_addresses(network, app_context) { + tracing::warn!("Failed to bootstrap BIP32 addresses: {}", err); + } + + if let Err(err) = self.bootstrap_coinjoin_addresses(network, app_context) { + tracing::warn!("Failed to bootstrap CoinJoin addresses: {}", err); + } + + if let Err(err) = self.bootstrap_identity_addresses(network, app_context) { + tracing::warn!("Failed to bootstrap identity addresses: {}", err); + } + + if let Err(err) = self.bootstrap_provider_addresses(network, app_context) { + tracing::warn!("Failed to bootstrap provider addresses: {}", err); + } + + if let Err(err) = self.bootstrap_platform_payment_addresses(network, app_context) { + tracing::warn!("Failed to bootstrap Platform payment addresses: {}", err); + } + } + + pub fn set_transactions(&mut self, transactions: Vec) { + self.transactions = transactions; + } + + pub(crate) fn seed_bytes(&self) -> Result<&[u8; 64], String> { match &self.wallet_seed { WalletSeed::Open(opened) => Ok(&opened.seed), WalletSeed::Closed(_) => Err("Wallet is closed, please decrypt it first".to_string()), @@ -552,6 +827,12 @@ impl Wallet { identity_index, key_index, ); + tracing::debug!( + identity_index = identity_index, + key_index = key_index, + path = %derivation_path, + "Generated identity authentication ECDSA derivation path" + ); let extended_public_key = derivation_path .derive_priv_ecdsa_for_master_seed(self.seed_bytes()?, network) .expect("derivation should not be able to fail"); @@ -646,6 +927,12 @@ impl Wallet { }, ); + if app_context.core_backend_mode() == crate::spv::CoreBackendMode::Rpc + && let Ok(client) = app_context.core_client.read() + { + let _ = client.import_address(&address, None, Some(false)); + } + tracing::trace!( address = ?&address, network = &address.network().to_string(), @@ -654,21 +941,136 @@ impl Wallet { Ok(()) } - pub fn identity_top_up_ecdsa_private_key( + fn bootstrap_bip44_addresses( &mut self, network: Network, - identity_index: u32, - top_up_index: u32, - register_addresses: Option<&AppContext>, - ) -> Result { - let derivation_path = - DerivationPath::identity_top_up_path(network, identity_index, top_up_index); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(self.seed_bytes()?, network) - .expect("derivation should not be able to fail"); - let private_key = extended_private_key.to_priv(); + app_context: &AppContext, + ) -> Result<(), String> { + let coin_type = Self::coin_type(network); + let secp = Secp256k1::new(); + for (change_flag, max) in [ + (false, BOOTSTRAP_BIP44_EXTERNAL_COUNT), + (true, BOOTSTRAP_BIP44_CHANGE_COUNT), + ] { + for index in 0..max { + let child_path = [ + ChildNumber::Normal { + index: change_flag as u32, + }, + ChildNumber::Normal { index }, + ]; + let derived = self + .master_bip44_ecdsa_extended_public_key + .derive_pub(&secp, &child_path) + .map_err(|e| e.to_string())?; + let dash_public_key = PublicKey::from_slice(&derived.public_key.serialize()) + .map_err(|e| e.to_string())?; + let derivation_path = DerivationPath::from(vec![ + ChildNumber::Hardened { index: 44 }, + ChildNumber::Hardened { index: coin_type }, + ChildNumber::Hardened { index: 0 }, + ChildNumber::Normal { + index: change_flag as u32, + }, + ChildNumber::Normal { index }, + ]); + self.register_address_from_public_key( + &dash_public_key, + &derivation_path, + DerivationPathType::CLEAR_FUNDS, + DerivationPathReference::BIP44, + app_context, + )?; + } + } + Ok(()) + } - if let Some(app_context) = register_addresses { + fn bootstrap_bip32_addresses( + &mut self, + network: Network, + app_context: &AppContext, + ) -> Result<(), String> { + let seed = *self.seed_bytes()?; + for account in 0..BOOTSTRAP_BIP32_ACCOUNT_COUNT { + for index in 0..BOOTSTRAP_BIP32_ADDRESS_COUNT { + let derivation_path = DerivationPath::from(vec![ + ChildNumber::Hardened { index: account }, + ChildNumber::Normal { index }, + ]); + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .map_err(|e| e.to_string())?; + let private_key = extended_private_key.to_priv(); + self.register_address_from_private_key( + &private_key, + &derivation_path, + DerivationPathType::CLEAR_FUNDS, + DerivationPathReference::BIP32, + app_context, + )?; + } + } + Ok(()) + } + + fn bootstrap_coinjoin_addresses( + &mut self, + network: Network, + app_context: &AppContext, + ) -> Result<(), String> { + let seed = *self.seed_bytes()?; + for account in 0..BOOTSTRAP_COINJOIN_ACCOUNT_COUNT { + let base_path = DerivationPath::coinjoin_path(network, account); + for index in 0..BOOTSTRAP_COINJOIN_ADDRESS_COUNT { + let mut components = base_path.as_ref().to_vec(); + components.push(ChildNumber::Normal { index }); + let derivation_path = DerivationPath::from(components); + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .map_err(|e| e.to_string())?; + let private_key = extended_private_key.to_priv(); + self.register_address_from_private_key( + &private_key, + &derivation_path, + DerivationPathType::ANONYMOUS_FUNDS, + DerivationPathReference::ProviderFunds, + app_context, + )?; + } + } + Ok(()) + } + + fn bootstrap_identity_addresses( + &mut self, + network: Network, + app_context: &AppContext, + ) -> Result<(), String> { + let registration_indices = self.identity_registration_indices(); + self.bootstrap_identity_registration_addresses( + network, + app_context, + ®istration_indices, + )?; + self.bootstrap_identity_invitation_addresses(network, app_context)?; + self.bootstrap_identity_topup_addresses(network, app_context, ®istration_indices)?; + Ok(()) + } + + fn bootstrap_identity_registration_addresses( + &mut self, + network: Network, + app_context: &AppContext, + registration_indices: &BTreeSet, + ) -> Result<(), String> { + let seed = *self.seed_bytes()?; + for &index in registration_indices { + let derivation_path = DerivationPath::identity_registration_path(network, index); + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .map_err(|e| e.to_string())?; + let private_key = extended_private_key.to_priv(); self.register_address_from_private_key( &private_key, &derivation_path, @@ -677,101 +1079,700 @@ impl Wallet { app_context, )?; } - Ok(private_key) + Ok(()) } - /// Generate Core key for identity registration - pub fn identity_registration_ecdsa_private_key( + fn bootstrap_identity_invitation_addresses( &mut self, network: Network, - index: u32, - register_addresses: Option<&AppContext>, - ) -> Result { - let derivation_path = DerivationPath::identity_registration_path(network, index); - let extended_private_key = derivation_path - .derive_priv_ecdsa_for_master_seed(self.seed_bytes()?, network) - .expect("derivation should not be able to fail"); - let private_key = extended_private_key.to_priv(); - - if let Some(app_context) = register_addresses { + app_context: &AppContext, + ) -> Result<(), String> { + let seed = *self.seed_bytes()?; + for index in 0..BOOTSTRAP_IDENTITY_INVITATION_COUNT { + let derivation_path = DerivationPath::identity_invitation_path(network, index); + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .map_err(|e| e.to_string())?; + let private_key = extended_private_key.to_priv(); self.register_address_from_private_key( &private_key, &derivation_path, DerivationPathType::CREDIT_FUNDING, - DerivationPathReference::BlockchainIdentityCreditRegistrationFunding, + DerivationPathReference::BlockchainIdentityCreditInvitationFunding, app_context, )?; } - Ok(private_key) + Ok(()) } - pub fn receive_address( + fn bootstrap_identity_topup_addresses( &mut self, network: Network, - skip_known_addresses_with_no_funds: bool, - register: Option<&AppContext>, - ) -> Result { - Ok(Address::p2pkh( - &self - .unused_bip_44_public_key( - network, - skip_known_addresses_with_no_funds, - false, - register, - )? - .0, - network, - )) + app_context: &AppContext, + registration_indices: &BTreeSet, + ) -> Result<(), String> { + let seed = *self.seed_bytes()?; + for ®istration_index in registration_indices { + for top_up_index in 0..BOOTSTRAP_IDENTITY_TOPUP_PER_REGISTRATION { + let derivation_path = + DerivationPath::identity_top_up_path(network, registration_index, top_up_index); + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .map_err(|e| e.to_string())?; + let private_key = extended_private_key.to_priv(); + self.register_address_from_private_key( + &private_key, + &derivation_path, + DerivationPathType::CREDIT_FUNDING, + DerivationPathReference::BlockchainIdentityCreditTopupFunding, + app_context, + )?; + } + } + self.bootstrap_identity_topup_not_bound_addresses(network, app_context, &seed) } - // Allow dead_code: This method provides receive addresses with derivation paths, - // useful for advanced address management and BIP44 path tracking - #[allow(dead_code)] - pub fn receive_address_with_derivation_path( + fn bootstrap_identity_topup_not_bound_addresses( &mut self, network: Network, - register: Option<&AppContext>, - ) -> Result<(Address, DerivationPath), String> { - let (receive_public_key, derivation_path) = - self.unused_bip_44_public_key(network, false, false, register)?; - Ok(( - Address::p2pkh(&receive_public_key, network), - derivation_path, - )) + app_context: &AppContext, + seed: &[u8; 64], + ) -> Result<(), String> { + let base_path = AccountType::IdentityTopUpNotBoundToIdentity + .derivation_path(network) + .map_err(|e| e.to_string())?; + for index in 0..BOOTSTRAP_IDENTITY_TOPUP_NOT_BOUND_COUNT { + let mut components = base_path.as_ref().to_vec(); + components.push(ChildNumber::Normal { index }); + let derivation_path = DerivationPath::from(components); + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(seed, network) + .map_err(|e| e.to_string())?; + let private_key = extended_private_key.to_priv(); + self.register_address_from_private_key( + &private_key, + &derivation_path, + DerivationPathType::CREDIT_FUNDING, + DerivationPathReference::BlockchainIdentityCreditTopupFunding, + app_context, + )?; + } + Ok(()) } - pub fn change_address( + fn identity_registration_indices(&self) -> BTreeSet { + let mut indices: BTreeSet = self.identities.keys().copied().collect(); + let fallback_limit = BOOTSTRAP_IDENTITY_REGISTRATION_FALLBACK; + let max_existing = indices.iter().copied().max().unwrap_or(0); + let target = cmp::max(max_existing.saturating_add(2), fallback_limit); + indices.extend(0..target); + indices + } + + fn bootstrap_provider_addresses( &mut self, network: Network, - register: Option<&AppContext>, - ) -> Result { - Ok(Address::p2pkh( - &self - .unused_bip_44_public_key(network, false, true, register)? - .0, - network, - )) + app_context: &AppContext, + ) -> Result<(), String> { + self.bootstrap_provider_account(network, app_context, AccountType::ProviderVotingKeys)?; + self.bootstrap_provider_account(network, app_context, AccountType::ProviderOwnerKeys)?; + Ok(()) } - // Allow dead_code: This method provides change addresses with derivation paths, - // useful for advanced address management and BIP44 path tracking - #[allow(dead_code)] - pub fn change_address_with_derivation_path( + fn bootstrap_provider_account( &mut self, network: Network, - register: Option<&AppContext>, - ) -> Result<(Address, DerivationPath), String> { - let (receive_public_key, derivation_path) = - self.unused_bip_44_public_key(network, false, true, register)?; - Ok(( - Address::p2pkh(&receive_public_key, network), - derivation_path, - )) + app_context: &AppContext, + account_type: AccountType, + ) -> Result<(), String> { + let seed = *self.seed_bytes()?; + let base_path = account_type + .derivation_path(network) + .map_err(|e| e.to_string())?; + let key_wallet_reference = account_type.derivation_path_reference(); + let path_reference = DerivationPathReference::try_from(key_wallet_reference as u32) + .unwrap_or(DerivationPathReference::Unknown); + for provider_index in 0..BOOTSTRAP_PROVIDER_ADDRESS_COUNT { + let mut components = base_path.as_ref().to_vec(); + components.push(ChildNumber::Hardened { + index: provider_index, + }); + let derivation_path = DerivationPath::from(components); + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .map_err(|e| e.to_string())?; + let private_key = extended_private_key.to_priv(); + self.register_address_from_private_key( + &private_key, + &derivation_path, + DerivationPathType::CLEAR_FUNDS, + path_reference, + app_context, + )?; + } + Ok(()) } - pub fn update_address_balance( + /// Bootstrap DIP-17 Platform payment addresses (dashevo/tdashevo Bech32m prefix per DIP-18) + /// These addresses are for receiving Dash Credits on Platform, independent of identities. + fn bootstrap_platform_payment_addresses( &mut self, - address: &Address, + network: Network, + app_context: &AppContext, + ) -> Result<(), String> { + let seed = *self.seed_bytes()?; + // Default account 0', default key_class 0' (as per DIP-17) + let account = 0u32; + let key_class = 0u32; + + for index in 0..BOOTSTRAP_PLATFORM_PAYMENT_ADDRESS_COUNT { + let derivation_path = + DerivationPath::platform_payment_path(network, account, key_class, index); + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .map_err(|e| e.to_string())?; + let private_key = extended_private_key.to_priv(); + + // Create a P2PKH address for platform payment + let secp = Secp256k1::new(); + let public_key = private_key.public_key(&secp); + let platform_address = Address::p2pkh(&public_key, network); + + // Register the Platform address + self.register_platform_address( + platform_address, + &derivation_path, + DerivationPathType::CLEAR_FUNDS, + DerivationPathReference::PlatformPayment, + app_context, + )?; + } + Ok(()) + } + + /// Register a Platform payment address (DIP-17/18). + /// Platform addresses use different version bytes and are NOT valid on Core chain. + fn register_platform_address( + &mut self, + address: Address, + derivation_path: &DerivationPath, + path_type: DerivationPathType, + path_reference: DerivationPathReference, + app_context: &AppContext, + ) -> Result<(), String> { + // Store the address in known_addresses and watched_addresses + // Note: We don't import to Core wallet since Platform addresses are not valid there + app_context + .db + .add_address_if_not_exists( + &self.seed_hash(), + &address, + &app_context.network, + derivation_path, + path_reference, + path_type, + None, + ) + .map_err(|e| e.to_string())?; + + self.known_addresses + .insert(address.clone(), derivation_path.clone()); + self.watched_addresses.insert( + derivation_path.clone(), + AddressInfo { + address: address.clone(), + path_type, + path_reference, + }, + ); + + tracing::trace!( + address = ?&address, + network = &app_context.network.to_string(), + "registered new Platform payment address" + ); + Ok(()) + } + + fn coin_type(network: Network) -> u32 { + match network { + Network::Dash => 5, + _ => 1, + } + } + + pub fn identity_top_up_ecdsa_private_key( + &mut self, + network: Network, + identity_index: u32, + top_up_index: u32, + register_addresses: Option<&AppContext>, + ) -> Result { + let derivation_path = + DerivationPath::identity_top_up_path(network, identity_index, top_up_index); + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(self.seed_bytes()?, network) + .expect("derivation should not be able to fail"); + let private_key = extended_private_key.to_priv(); + + if let Some(app_context) = register_addresses { + self.register_address_from_private_key( + &private_key, + &derivation_path, + DerivationPathType::CREDIT_FUNDING, + DerivationPathReference::BlockchainIdentityCreditRegistrationFunding, + app_context, + )?; + } + Ok(private_key) + } + + /// Generate Core key for identity registration + pub fn identity_registration_ecdsa_private_key( + &mut self, + network: Network, + index: u32, + register_addresses: Option<&AppContext>, + ) -> Result { + let derivation_path = DerivationPath::identity_registration_path(network, index); + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(self.seed_bytes()?, network) + .expect("derivation should not be able to fail"); + let private_key = extended_private_key.to_priv(); + + if let Some(app_context) = register_addresses { + self.register_address_from_private_key( + &private_key, + &derivation_path, + DerivationPathType::CREDIT_FUNDING, + DerivationPathReference::BlockchainIdentityCreditRegistrationFunding, + app_context, + )?; + } + Ok(private_key) + } + + pub fn receive_address( + &mut self, + network: Network, + skip_known_addresses_with_no_funds: bool, + register: Option<&AppContext>, + ) -> Result { + Ok(Address::p2pkh( + &self + .unused_bip_44_public_key( + network, + skip_known_addresses_with_no_funds, + false, + register, + )? + .0, + network, + )) + } + + // Allow dead_code: This method provides receive addresses with derivation paths, + // useful for advanced address management and BIP44 path tracking + #[allow(dead_code)] + pub fn receive_address_with_derivation_path( + &mut self, + network: Network, + register: Option<&AppContext>, + ) -> Result<(Address, DerivationPath), String> { + let (receive_public_key, derivation_path) = + self.unused_bip_44_public_key(network, false, false, register)?; + Ok(( + Address::p2pkh(&receive_public_key, network), + derivation_path, + )) + } + + pub fn change_address( + &mut self, + network: Network, + register: Option<&AppContext>, + ) -> Result { + Ok(Address::p2pkh( + &self + .unused_bip_44_public_key(network, false, true, register)? + .0, + network, + )) + } + + // Allow dead_code: This method provides change addresses with derivation paths, + // useful for advanced address management and BIP44 path tracking + #[allow(dead_code)] + pub fn change_address_with_derivation_path( + &mut self, + network: Network, + register: Option<&AppContext>, + ) -> Result<(Address, DerivationPath), String> { + let (receive_public_key, derivation_path) = + self.unused_bip_44_public_key(network, false, true, register)?; + Ok(( + Address::p2pkh(&receive_public_key, network), + derivation_path, + )) + } + + /// Generate a Platform receive address. + /// Either returns an existing Platform address or generates a new one. + pub fn platform_receive_address( + &mut self, + network: Network, + skip_known_addresses: bool, + register: Option<&AppContext>, + ) -> Result { + // If not skipping known addresses, return first existing one + // This doesn't require the wallet to be unlocked + if !skip_known_addresses { + for (path, info) in &self.watched_addresses { + if path.is_platform_payment(network) { + return Ok(info.address.clone()); + } + } + } + + // Need to generate a new address - this requires the wallet to be unlocked + let seed = *self.seed_bytes()?; + let secp = Secp256k1::new(); + let account = 0u32; + let key_class = 0u32; + + // Find the highest index in existing Platform payment addresses + let existing_indices: Vec = self + .watched_addresses + .iter() + .filter(|(path, _)| path.is_platform_payment(network)) + .filter_map(|(path, _)| { + // Extract the index from the path (last component) + path.into_iter().last().and_then(|child| match child { + ChildNumber::Normal { index } | ChildNumber::Hardened { index } => Some(*index), + _ => None, + }) + }) + .collect(); + + // Generate a new Platform address at the next index + let next_index = existing_indices.iter().max().map(|m| m + 1).unwrap_or(0); + + let derivation_path = + DerivationPath::platform_payment_path(network, account, key_class, next_index); + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .map_err(|e| e.to_string())?; + let private_key = extended_private_key.to_priv(); + let public_key = private_key.public_key(&secp); + + // Create a P2PKH address for platform payment + let platform_address = Address::p2pkh(&public_key, network); + + // Register the new address + if let Some(app_context) = register { + self.register_platform_address( + platform_address.clone(), + &derivation_path, + DerivationPathType::CLEAR_FUNDS, + DerivationPathReference::PlatformPayment, + app_context, + )?; + } else { + // Just update local state without persisting + self.known_addresses + .insert(platform_address.clone(), derivation_path.clone()); + self.watched_addresses.insert( + derivation_path, + AddressInfo { + address: platform_address.clone(), + path_type: DerivationPathType::CLEAR_FUNDS, + path_reference: DerivationPathReference::PlatformPayment, + }, + ); + } + + Ok(platform_address) + } + + pub fn derive_bip44_address( + &self, + network: Network, + change: bool, + address_index: u32, + ) -> Result { + let secp = Secp256k1::new(); + let path_extension = [ + ChildNumber::Normal { + index: change as u32, + }, + ChildNumber::Normal { + index: address_index, + }, + ]; + let public_key = self + .master_bip44_ecdsa_extended_public_key + .derive_pub(&secp, &path_extension) + .map_err(|e| e.to_string())? + .to_pub(); + Ok(Address::p2pkh(&public_key, network)) + } + + pub fn build_standard_payment_transaction( + &mut self, + network: Network, + recipient: &Address, + amount: u64, + fee: u64, + subtract_fee_from_amount: bool, + register_addresses: Option<&AppContext>, + ) -> Result { + if !networks_address_compatible(recipient.network(), &network) { + return Err(format!( + "Recipient address network ({}) does not match wallet network ({})", + recipient.network(), + network + )); + } + + let (utxos, change_option) = self + .take_unspent_utxos_for(amount, fee, subtract_fee_from_amount) + .ok_or_else(|| "Insufficient funds".to_string())?; + + let send_value = if change_option.is_none() && subtract_fee_from_amount { + let total_input: u64 = utxos.values().map(|(tx_out, _)| tx_out.value).sum(); + total_input + .checked_sub(fee) + .ok_or_else(|| "Fee exceeds available amount".to_string())? + } else { + amount + }; + + if send_value == 0 { + return Err("Amount is zero after subtracting fee".to_string()); + } + + let mut outputs = vec![TxOut { + value: send_value, + script_pubkey: recipient.script_pubkey(), + }]; + + if let Some(change) = change_option { + let change_address = self.change_address(network, register_addresses)?; + outputs.push(TxOut { + value: change, + script_pubkey: change_address.script_pubkey(), + }); + } + + let mut tx = Transaction { + version: 2, + lock_time: 0, + input: utxos + .keys() + .map(|outpoint| TxIn { + previous_output: *outpoint, + ..Default::default() + }) + .collect(), + output: outputs, + special_transaction_payload: None, + }; + + let sighash_flag = 1u32; + let cache = SighashCache::new(&tx); + let sighashes: Vec<_> = tx + .input + .iter() + .enumerate() + .map(|(i, input)| { + let script_pubkey = utxos + .get(&input.previous_output) + .ok_or_else(|| { + format!("missing utxo for outpoint {:?}", input.previous_output) + })? + .0 + .script_pubkey + .clone(); + cache + .legacy_signature_hash(i, &script_pubkey, sighash_flag) + .map_err(|e| format!("failed to compute sighash: {}", e)) + }) + .collect::, String>>()?; + + let secp = Secp256k1::new(); + let mut utxo_lookup = utxos.clone(); + + tx.input + .iter_mut() + .zip(sighashes.into_iter()) + .try_for_each(|(input, sighash)| { + let (_, input_address) = + utxo_lookup.remove(&input.previous_output).ok_or_else(|| { + format!("utxo missing for outpoint {:?}", input.previous_output) + })?; + let private_key = self + .private_key_for_address(&input_address, network)? + .ok_or_else(|| format!("Address {} not managed by wallet", input_address))?; + let message = Message::from_digest(sighash.into()); + let sig = secp.sign_ecdsa(&message, &private_key.inner); + let mut serialized_sig = sig.serialize_der().to_vec(); + let mut script_sig = vec![serialized_sig.len() as u8 + 1]; + script_sig.append(&mut serialized_sig); + script_sig.push(1); + let mut serialized_pub_key = private_key.public_key(&secp).serialize(); + script_sig.push(serialized_pub_key.len() as u8); + script_sig.append(&mut serialized_pub_key); + input.script_sig = ScriptBuf::from_bytes(script_sig); + Ok::<(), String>(()) + })?; + + Ok(tx) + } + + /// Build a transaction with multiple recipients + pub fn build_multi_recipient_payment_transaction( + &mut self, + network: Network, + recipients: &[(Address, u64)], + fee: u64, + subtract_fee_from_amount: bool, + register_addresses: Option<&AppContext>, + ) -> Result { + if recipients.is_empty() { + return Err("No recipients specified".to_string()); + } + + // Validate all recipients are on the correct network + for (recipient, _) in recipients { + if !networks_address_compatible(recipient.network(), &network) { + return Err(format!( + "Recipient address network ({}) does not match wallet network ({})", + recipient.network(), + network + )); + } + } + + // Calculate total amount needed + let total_amount: u64 = recipients.iter().map(|(_, amount)| *amount).sum(); + + let (utxos, change_option) = self + .take_unspent_utxos_for(total_amount, fee, subtract_fee_from_amount) + .ok_or_else(|| "Insufficient funds".to_string())?; + + // Build outputs for each recipient + let mut outputs: Vec = if change_option.is_none() && subtract_fee_from_amount { + // If we're subtracting fee and using all funds, we need to reduce recipient amounts proportionally + let total_input: u64 = utxos.values().map(|(tx_out, _)| tx_out.value).sum(); + let available_after_fee = total_input + .checked_sub(fee) + .ok_or_else(|| "Fee exceeds available amount".to_string())?; + + // Distribute the reduction proportionally across recipients + let reduction_ratio = available_after_fee as f64 / total_amount as f64; + + recipients + .iter() + .map(|(recipient, amount)| { + let adjusted_amount = (*amount as f64 * reduction_ratio) as u64; + TxOut { + value: adjusted_amount, + script_pubkey: recipient.script_pubkey(), + } + }) + .collect() + } else { + recipients + .iter() + .map(|(recipient, amount)| TxOut { + value: *amount, + script_pubkey: recipient.script_pubkey(), + }) + .collect() + }; + + // Check that no output is zero + if outputs.iter().any(|o| o.value == 0) { + return Err("One or more amounts are zero after subtracting fee".to_string()); + } + + // Add change output if needed + if let Some(change) = change_option { + let change_address = self.change_address(network, register_addresses)?; + outputs.push(TxOut { + value: change, + script_pubkey: change_address.script_pubkey(), + }); + } + + let mut tx = Transaction { + version: 2, + lock_time: 0, + input: utxos + .keys() + .map(|outpoint| TxIn { + previous_output: *outpoint, + ..Default::default() + }) + .collect(), + output: outputs, + special_transaction_payload: None, + }; + + let sighash_flag = 1u32; + let cache = SighashCache::new(&tx); + let sighashes: Vec<_> = tx + .input + .iter() + .enumerate() + .map(|(i, input)| { + let script_pubkey = utxos + .get(&input.previous_output) + .ok_or_else(|| { + format!("missing utxo for outpoint {:?}", input.previous_output) + })? + .0 + .script_pubkey + .clone(); + cache + .legacy_signature_hash(i, &script_pubkey, sighash_flag) + .map_err(|e| format!("failed to compute sighash: {}", e)) + }) + .collect::, String>>()?; + + let secp = Secp256k1::new(); + let mut utxo_lookup = utxos.clone(); + + tx.input + .iter_mut() + .zip(sighashes.into_iter()) + .try_for_each(|(input, sighash)| { + let (_, input_address) = + utxo_lookup.remove(&input.previous_output).ok_or_else(|| { + format!("utxo missing for outpoint {:?}", input.previous_output) + })?; + let private_key = self + .private_key_for_address(&input_address, network)? + .ok_or_else(|| format!("Address {} not managed by wallet", input_address))?; + let message = Message::from_digest(sighash.into()); + let sig = secp.sign_ecdsa(&message, &private_key.inner); + let mut serialized_sig = sig.serialize_der().to_vec(); + let mut script_sig = vec![serialized_sig.len() as u8 + 1]; + script_sig.append(&mut serialized_sig); + script_sig.push(1); + let mut serialized_pub_key = private_key.public_key(&secp).serialize(); + script_sig.push(serialized_pub_key.len() as u8); + script_sig.append(&mut serialized_pub_key); + input.script_sig = ScriptBuf::from_bytes(script_sig); + Ok::<(), String>(()) + })?; + + Ok(tx) + } + + pub fn update_address_balance( + &mut self, + address: &Address, new_balance: Duffs, context: &AppContext, ) -> Result<(), String> { @@ -792,4 +1793,560 @@ impl Wallet { .update_address_balance(&self.seed_hash(), address, new_balance) .map_err(|e| e.to_string()) } + + pub fn update_address_total_received( + &mut self, + address: &Address, + total_received: Duffs, + context: &AppContext, + ) -> Result<(), String> { + // Check if the total received differs from the current value + if let Some(current_total) = self.address_total_received.get(address) + && *current_total == total_received + { + // If the total received hasn't changed, skip the update. + return Ok(()); + } + + // Update in memory + self.address_total_received + .insert(address.clone(), total_received); + + // Update the database + context + .db + .update_address_total_received(&self.seed_hash(), address, total_received) + .map_err(|e| e.to_string()) + } + + /// Get all Platform payment addresses from this wallet + pub fn platform_addresses(&self, network: Network) -> Vec<(Address, PlatformAddress)> { + self.watched_addresses + .iter() + .filter(|(path, _)| path.is_platform_payment(network)) + .filter_map(|(_, info)| { + PlatformAddress::try_from(info.address.clone()) + .ok() + .map(|platform_addr| (info.address.clone(), platform_addr)) + }) + .collect() + } + + /// Get the total Platform balance (sum of all Platform address balances) + pub fn total_platform_balance(&self) -> Credits { + self.platform_address_info + .values() + .map(|info| info.balance) + .sum() + } + + /// Get Platform address info by canonical address comparison. + /// + /// This method handles the case where the same platform address may be represented + /// by different Address objects. It normalizes by comparing PlatformAddress bytes + /// to find a matching entry. + pub fn get_platform_address_info(&self, address: &Address) -> Option<&PlatformAddressInfo> { + // First try direct lookup + if let Some(info) = self.platform_address_info.get(address) { + return Some(info); + } + + // If direct lookup fails, try canonical comparison via PlatformAddress bytes + if let Ok(platform_addr) = PlatformAddress::try_from(address.clone()) { + let canonical_bytes = platform_addr.to_bytes(); + for (existing_addr, info) in &self.platform_address_info { + if let Ok(existing_platform) = PlatformAddress::try_from(existing_addr.clone()) + && existing_platform.to_bytes() == canonical_bytes + { + return Some(info); + } + } + } + + None + } + + /// Update Platform address info (balance and nonce) + /// + /// This method handles the case where the same platform address may be represented + /// by different Address objects. It normalizes by comparing PlatformAddress bytes + /// and removes any duplicate entries before inserting. + pub fn set_platform_address_info( + &mut self, + address: Address, + balance: Credits, + nonce: AddressNonce, + ) { + // Convert the incoming address to PlatformAddress for canonical comparison + if let Ok(platform_addr) = PlatformAddress::try_from(address.clone()) { + let canonical_bytes = platform_addr.to_bytes(); + + // Find and remove any existing entry that represents the same platform address + // but might have a different Address representation + let keys_to_remove: Vec
      = self + .platform_address_info + .keys() + .filter(|existing_addr| { + if let Ok(existing_platform) = + PlatformAddress::try_from((*existing_addr).clone()) + { + existing_platform.to_bytes() == canonical_bytes + && *existing_addr != &address + } else { + false + } + }) + .cloned() + .collect(); + + for key in keys_to_remove { + self.platform_address_info.remove(&key); + } + } + + // Preserve last_synced_balance if it exists + let last_synced_balance = self + .platform_address_info + .get(&address) + .and_then(|info| info.last_synced_balance); + + self.platform_address_info.insert( + address, + PlatformAddressInfo { + balance, + nonce, + last_synced_balance, + }, + ); + } + + /// Set platform address info from a sync operation (updates last_synced_balance) + pub fn set_platform_address_info_from_sync( + &mut self, + address: Address, + balance: Credits, + nonce: AddressNonce, + ) { + self.platform_address_info.insert( + address, + PlatformAddressInfo { + balance, + nonce, + last_synced_balance: Some(balance), + }, + ); + } + + /// Get the private key for a Platform address + #[allow(clippy::result_large_err)] + pub fn get_platform_address_private_key( + &self, + platform_address: &PlatformAddress, + network: Network, + ) -> Result { + // Find the derivation path by looking through watched_addresses + // and matching the PlatformAddress + let derivation_path = self + .watched_addresses + .iter() + .filter(|(path, _)| path.is_platform_payment(network)) + .find_map(|(path, info)| { + // Try to convert the stored address to a PlatformAddress and compare + PlatformAddress::try_from(info.address.clone()) + .ok() + .filter(|addr| addr == platform_address) + .map(|_| path.clone()) + }) + .ok_or_else(|| { + ProtocolError::Generic(format!( + "Platform address {:?} not found in wallet", + platform_address + )) + })?; + + // Get the seed bytes + let seed = *self.seed_bytes().map_err(ProtocolError::Generic)?; + + // Derive the private key + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .map_err(|e| ProtocolError::Generic(e.to_string()))?; + + Ok(extended_private_key.to_priv()) + } +} + +/// Signer implementation for Platform addresses +/// Allows the wallet to sign transactions that spend from Platform addresses +impl Signer for Wallet { + fn sign( + &self, + platform_address: &PlatformAddress, + data: &[u8], + ) -> Result { + // Only P2PKH addresses are supported for now + if !platform_address.is_p2pkh() { + return Err(ProtocolError::Generic( + "Only P2PKH Platform addresses are currently supported for signing".to_string(), + )); + } + + // The Signer trait doesn't pass network info, so we try each network. + // This is safe because: + // 1. A wallet instance only stores keys for ONE network (set at creation) + // 2. Platform addresses encode their network in the bech32m prefix (dashevo/tdashevo) + // 3. get_platform_address_private_key will only succeed for the correct network + // 4. Only one network's derivation will match the wallet's seed + let private_key = self + .get_platform_address_private_key(platform_address, Network::Dash) + .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Testnet)) + .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Devnet)) + .or_else(|_| { + self.get_platform_address_private_key(platform_address, Network::Regtest) + })?; + + // Sign the data + let signature = dash_sdk::dpp::dashcore::signer::sign(data, private_key.inner.as_ref()) + .map_err(|e| ProtocolError::Generic(format!("Failed to sign: {}", e)))?; + + Ok(BinaryData::new(signature.to_vec())) + } + + fn sign_create_witness( + &self, + platform_address: &PlatformAddress, + data: &[u8], + ) -> Result { + // Only P2PKH addresses are supported for now + if !platform_address.is_p2pkh() { + return Err(ProtocolError::Generic( + "Only P2PKH Platform addresses are currently supported for signing".to_string(), + )); + } + + // The Signer trait doesn't pass network info, so we try each network. + // This is safe - see comment in sign() above for explanation. + let private_key = self + .get_platform_address_private_key(platform_address, Network::Dash) + .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Testnet)) + .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Devnet)) + .or_else(|_| { + self.get_platform_address_private_key(platform_address, Network::Regtest) + })?; + + // Sign the data - produces a compact recoverable signature + // The public key will be recovered from the signature during verification + let signature = dash_sdk::dpp::dashcore::signer::sign(data, private_key.inner.as_ref()) + .map_err(|e| ProtocolError::Generic(format!("Failed to sign: {}", e)))?; + + Ok(AddressWitness::P2pkh { + signature: BinaryData::new(signature.to_vec()), + }) + } + + fn can_sign_with(&self, platform_address: &PlatformAddress) -> bool { + // Only P2PKH addresses are supported + if !platform_address.is_p2pkh() { + return false; + } + + // Check if we have the private key for this address + self.get_platform_address_private_key(platform_address, Network::Dash) + .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Testnet)) + .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Devnet)) + .or_else(|_| self.get_platform_address_private_key(platform_address, Network::Regtest)) + .is_ok() + } +} + +/// Default gap limit for HD wallet address scanning +const DEFAULT_GAP_LIMIT: AddressIndex = 20; + +/// Provider for wallet Platform addresses that implements AddressProvider for SDK address sync. +/// +/// This struct tracks the state needed for the SDK's privacy-preserving address balance +/// synchronization. It can derive new Platform addresses on-demand to support HD wallet +/// gap limit behavior. +/// +/// # Usage +/// ```ignore +/// let mut provider = WalletAddressProvider::new(&wallet, network)?; +/// let result = sdk.sync_address_balances(&mut provider, None).await?; +/// provider.apply_results_to_wallet(&mut wallet); +/// ``` +pub struct WalletAddressProvider { + /// Network for address derivation + network: Network, + /// Gap limit for HD wallet scanning + gap_limit: AddressIndex, + /// Seed bytes for deriving new addresses (64 bytes) + seed: [u8; 64], + /// Account index for Platform payment addresses (default 0) + account: u32, + /// Key class for Platform payment addresses (default 0) + key_class: u32, + /// Map of index to (AddressKey, CoreAddress) for pending addresses + pending: BTreeMap, + /// Set of indices that have been resolved (found or absent) + resolved: BTreeSet, + /// Highest index found with a non-zero balance + highest_found: Option, + /// Results: address -> balance for addresses found with balance + found_balances: BTreeMap, +} + +impl WalletAddressProvider { + /// Create a new WalletAddressProvider from a wallet. + /// + /// This initializes the provider with Platform payment addresses up to the gap limit. + /// The wallet must be open (unlocked) to access the seed for address derivation. + /// + /// # Errors + /// Returns an error if the wallet is closed/locked. + pub fn new(wallet: &Wallet, network: Network) -> Result { + Self::with_gap_limit(wallet, network, DEFAULT_GAP_LIMIT) + } + + /// Create a new WalletAddressProvider with a custom gap limit. + /// + /// # Errors + /// Returns an error if the wallet is closed/locked. + pub fn with_gap_limit( + wallet: &Wallet, + network: Network, + gap_limit: AddressIndex, + ) -> Result { + let seed = *wallet.seed_bytes()?; + + let mut provider = Self { + network, + gap_limit, + seed, + account: 0, + key_class: 0, + pending: BTreeMap::new(), + resolved: BTreeSet::new(), + highest_found: None, + found_balances: BTreeMap::new(), + }; + + // Bootstrap initial addresses (0 to gap_limit - 1) + provider.ensure_addresses_up_to(gap_limit.saturating_sub(1))?; + + Ok(provider) + } + + /// Get the network this provider was created for. + pub fn network(&self) -> Network { + self.network + } + + /// Get the found balances after sync is complete. + /// + /// Returns a map of Core Address -> balance (in credits). + pub fn found_balances(&self) -> &BTreeMap { + &self.found_balances + } + + /// Get the found balances with their indices after sync is complete. + /// + /// Returns an iterator of (index, (&Address, &balance)) for addresses that were found with balance. + /// The index can be used to reconstruct the derivation path. + pub fn found_balances_with_indices( + &self, + ) -> impl Iterator { + // Build a reverse lookup from address to index + let address_to_index: BTreeMap<&Address, AddressIndex> = self + .pending + .iter() + .map(|(idx, (_, addr))| (addr, *idx)) + .collect(); + + self.found_balances + .iter() + .filter_map(move |(addr, balance)| { + address_to_index + .get(addr) + .map(|&idx| (idx, (addr, balance))) + }) + } + + /// Update a balance for an address (used for terminal balance updates). + /// + /// This allows applying balance changes discovered after the initial sync. + pub fn update_balance(&mut self, address: &Address, balance: u64) { + self.found_balances.insert(address.clone(), balance); + } + + /// Apply the sync results to a wallet, updating Platform address info. + /// + /// This updates the wallet's `platform_address_info` with the balances found during sync. + /// Also ensures addresses are registered in `known_addresses` and `watched_addresses` + /// so they appear in the UI. + /// Note: This does not update nonces - those should be fetched separately if needed. + pub fn apply_results_to_wallet(&self, wallet: &mut Wallet) { + // Build a reverse lookup from address to index + let address_to_index: BTreeMap<&Address, AddressIndex> = self + .pending + .iter() + .map(|(idx, (_, addr))| (addr, *idx)) + .collect(); + + for (address, balance) in &self.found_balances { + // Get existing nonce or default to 0 + let nonce = wallet + .platform_address_info + .get(address) + .map(|info| info.nonce) + .unwrap_or(0); + + // Use sync-specific method that also updates last_synced_balance + wallet.set_platform_address_info_from_sync(address.clone(), *balance, nonce); + + // Also register in known_addresses and watched_addresses if not already present + if !wallet.known_addresses.contains_key(address) + && let Some(&index) = address_to_index.get(address) + { + let derivation_path = DerivationPath::platform_payment_path( + self.network, + self.account, + self.key_class, + index, + ); + + wallet + .known_addresses + .insert(address.clone(), derivation_path.clone()); + + wallet.watched_addresses.insert( + derivation_path, + AddressInfo { + address: address.clone(), + path_type: DerivationPathType::CLEAR_FUNDS, + path_reference: DerivationPathReference::PlatformPayment, + }, + ); + } + } + } + + /// Derive a Platform address at the given index. + fn derive_address_at_index( + &self, + index: AddressIndex, + ) -> Result<(AddressKey, Address), String> { + let derivation_path = DerivationPath::platform_payment_path( + self.network, + self.account, + self.key_class, + index, + ); + + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(&self.seed, self.network) + .map_err(|e| e.to_string())?; + + let secp = Secp256k1::new(); + let private_key = extended_private_key.to_priv(); + let public_key = private_key.public_key(&secp); + + // Create P2PKH address + let address = Address::p2pkh(&public_key, self.network); + + // Convert to PlatformAddress to get the key + let platform_addr = PlatformAddress::try_from(address.clone()) + .map_err(|e| format!("Failed to convert to PlatformAddress: {}", e))?; + let key = platform_addr.to_bytes(); + + Ok((key, address)) + } + + /// Ensure we have addresses derived up to and including the given index. + fn ensure_addresses_up_to(&mut self, max_index: AddressIndex) -> Result<(), String> { + let current_max = self.pending.keys().max().copied(); + + let start = current_max.map(|m| m + 1).unwrap_or(0); + for index in start..=max_index { + if !self.pending.contains_key(&index) && !self.resolved.contains(&index) { + let (key, address) = self.derive_address_at_index(index)?; + self.pending.insert(index, (key, address)); + } + } + + Ok(()) + } + + /// Extend pending addresses based on gap limit after finding an address. + fn extend_for_gap_limit(&mut self, found_index: AddressIndex) -> Result<(), String> { + let new_end = found_index.saturating_add(self.gap_limit); + self.ensure_addresses_up_to(new_end) + } +} + +impl AddressProvider for WalletAddressProvider { + fn gap_limit(&self) -> AddressIndex { + self.gap_limit + } + + fn pending_addresses(&self) -> Vec<(AddressIndex, AddressKey)> { + self.pending + .iter() + .filter(|(index, _)| !self.resolved.contains(index)) + .map(|(index, (key, _))| (*index, key.clone())) + .collect() + } + + fn on_address_found(&mut self, index: AddressIndex, _key: &[u8], balance: u64) { + self.resolved.insert(index); + + // Log what the SDK is returning + if let Some((_, core_address)) = self.pending.get(&index) { + // Also show Platform address format for comparison + let platform_addr_str = PlatformAddress::try_from(core_address.clone()) + .map(|p| p.to_bech32m_string(self.network)) + .unwrap_or_else(|_| "conversion failed".to_string()); + tracing::info!( + "on_address_found: index={}, core_address={}, platform_address={}, balance={}", + index, + core_address, + platform_addr_str, + balance + ); + } else { + tracing::warn!( + "on_address_found: index={} not in pending! balance={}", + index, + balance + ); + } + + if balance > 0 { + // Update highest found + self.highest_found = Some(self.highest_found.map(|h| h.max(index)).unwrap_or(index)); + + // Store the balance result + if let Some((_, core_address)) = self.pending.get(&index) { + self.found_balances.insert(core_address.clone(), balance); + } + + // Extend the address range based on gap limit + if let Err(e) = self.extend_for_gap_limit(index) { + tracing::warn!("Failed to extend addresses for gap limit: {}", e); + } + } + } + + fn on_address_absent(&mut self, index: AddressIndex, _key: &[u8]) { + self.resolved.insert(index); + } + + fn has_pending(&self) -> bool { + self.pending + .keys() + .any(|index| !self.resolved.contains(index)) + } + + fn highest_found_index(&self) -> Option { + self.highest_found + } } diff --git a/src/model/wallet/single_key.rs b/src/model/wallet/single_key.rs new file mode 100644 index 000000000..1d5745d7c --- /dev/null +++ b/src/model/wallet/single_key.rs @@ -0,0 +1,427 @@ +//! Single Key Wallet - A wallet backed by a single private key (not HD derived) +//! +//! This module provides support for importing and using individual private keys +//! as wallets, similar to the functionality in platform-tui. + +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; +use dash_sdk::dpp::dashcore::{Address, Network, OutPoint, PrivateKey, PublicKey, TxOut}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use zeroize::Zeroize; + +use super::encryption::derive_password_key; + +/// Hash of the private key, used as a unique identifier +pub type SingleKeyHash = [u8; 32]; + +/// A wallet backed by a single private key +#[derive(Debug, Clone, PartialEq)] +pub struct SingleKeyWallet { + /// The private key data (open or closed/encrypted) + pub private_key_data: SingleKeyData, + /// Whether a password is required to access the private key + pub uses_password: bool, + /// The public key derived from the private key + pub public_key: PublicKey, + /// The P2PKH address derived from the public key + pub address: Address, + /// Optional alias/name for this wallet + pub alias: Option, + /// SHA-256 hash of the private key (used as identifier) + pub key_hash: SingleKeyHash, + /// Confirmed balance in duffs + pub confirmed_balance: u64, + /// Unconfirmed balance in duffs + pub unconfirmed_balance: u64, + /// Total balance in duffs + pub total_balance: u64, + /// UTXOs for this address + pub utxos: HashMap, +} + +/// Private key data - either open (decrypted) or closed (encrypted) +#[derive(Debug, Clone, PartialEq)] +pub enum SingleKeyData { + Open(OpenSingleKey), + Closed(ClosedSingleKey), +} + +/// An open (decrypted) single key +#[derive(Clone, PartialEq)] +pub struct OpenSingleKey { + /// The raw 32-byte private key + pub private_key: [u8; 32], + /// The closed key info for re-encryption + pub key_info: ClosedSingleKey, +} + +impl std::fmt::Debug for OpenSingleKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OpenSingleKey") + .field("key_hash", &hex::encode(self.key_info.key_hash)) + .finish() + } +} + +/// A closed (encrypted) single key +#[derive(Debug, Clone, PartialEq)] +pub struct ClosedSingleKey { + /// SHA-256 hash of the private key + pub key_hash: SingleKeyHash, + /// The encrypted private key + pub encrypted_private_key: Vec, + /// Salt used for key derivation + pub salt: Vec, + /// Nonce used for encryption + pub nonce: Vec, +} + +impl SingleKeyData { + /// Opens the key by decrypting it using the provided password + pub fn open(&mut self, password: &str) -> Result<(), String> { + match self { + SingleKeyData::Open(_) => Ok(()), + SingleKeyData::Closed(closed) => { + let private_key = closed.decrypt_private_key(password)?; + let open_key = OpenSingleKey { + private_key, + key_info: closed.clone(), + }; + *self = SingleKeyData::Open(open_key); + Ok(()) + } + } + } + + /// Opens the key without a password (for keys stored without encryption) + pub fn open_no_password(&mut self) -> Result<(), String> { + match self { + SingleKeyData::Open(_) => Ok(()), + SingleKeyData::Closed(closed) => { + let private_key: [u8; 32] = closed + .encrypted_private_key + .clone() + .try_into() + .map_err(|e: Vec| { + format!("incorrect key size, expected 32 bytes, got {}", e.len()) + })?; + let open_key = OpenSingleKey { + private_key, + key_info: closed.clone(), + }; + *self = SingleKeyData::Open(open_key); + Ok(()) + } + } + } + + /// Closes the key by securely erasing the decrypted data + #[allow(dead_code)] + pub fn close(&mut self) { + if let SingleKeyData::Open(open_key) = self { + let key_info = open_key.key_info.clone(); + open_key.private_key.zeroize(); + *self = SingleKeyData::Closed(key_info); + } + } + + /// Returns true if the key is open (decrypted) + pub fn is_open(&self) -> bool { + matches!(self, SingleKeyData::Open(_)) + } + + /// Get the key hash + pub fn key_hash(&self) -> SingleKeyHash { + match self { + SingleKeyData::Open(open) => open.key_info.key_hash, + SingleKeyData::Closed(closed) => closed.key_hash, + } + } +} + +impl Drop for SingleKeyData { + fn drop(&mut self) { + if let SingleKeyData::Open(open_key) = self { + open_key.private_key.zeroize(); + } + } +} + +impl ClosedSingleKey { + /// Compute the hash of a private key + pub fn compute_key_hash(private_key: &[u8; 32]) -> SingleKeyHash { + let mut hasher = Sha256::new(); + hasher.update(private_key); + let result = hasher.finalize(); + let mut key_hash = [0u8; 32]; + key_hash.copy_from_slice(&result); + key_hash + } + + /// Encrypt a private key with a password + #[allow(clippy::type_complexity)] + pub fn encrypt_private_key( + private_key: &[u8; 32], + password: &str, + ) -> Result<(Vec, Vec, Vec), String> { + use super::encryption::encrypt_message; + encrypt_message(private_key, password) + } + + /// Decrypt the private key using a password + #[allow(deprecated)] + pub fn decrypt_private_key(&self, password: &str) -> Result<[u8; 32], String> { + let key = derive_password_key(password, &self.salt)?; + let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| e.to_string())?; + let nonce_arr = Nonce::from_slice(&self.nonce); + let decrypted = cipher + .decrypt(nonce_arr, self.encrypted_private_key.as_slice()) + .map_err(|e| e.to_string())?; + + decrypted.try_into().map_err(|e: Vec| { + format!( + "invalid private key length, expected 32 bytes, got {} bytes", + e.len() + ) + }) + } +} + +impl SingleKeyWallet { + /// Create a new SingleKeyWallet from a private key + /// + /// # Arguments + /// * `private_key_bytes` - The 32-byte private key + /// * `network` - The network (mainnet, testnet, etc.) + /// * `password` - Optional password to encrypt the key + /// * `alias` - Optional alias for the wallet + pub fn new( + private_key_bytes: [u8; 32], + network: Network, + password: Option<&str>, + alias: Option, + ) -> Result { + let secp = Secp256k1::new(); + + // Create PrivateKey and derive public key and address + let private_key = + PrivateKey::from_byte_array(&private_key_bytes, network).map_err(|e| e.to_string())?; + let public_key = private_key.public_key(&secp); + let address = Address::p2pkh(&public_key, network); + + let key_hash = ClosedSingleKey::compute_key_hash(&private_key_bytes); + + let (private_key_data, uses_password) = if let Some(pwd) = password { + let (encrypted, salt, nonce) = + ClosedSingleKey::encrypt_private_key(&private_key_bytes, pwd)?; + let closed = ClosedSingleKey { + key_hash, + encrypted_private_key: encrypted, + salt, + nonce, + }; + // Keep it open after creation + ( + SingleKeyData::Open(OpenSingleKey { + private_key: private_key_bytes, + key_info: closed, + }), + true, + ) + } else { + // No password - store raw bytes as "encrypted" + let closed = ClosedSingleKey { + key_hash, + encrypted_private_key: private_key_bytes.to_vec(), + salt: vec![], + nonce: vec![], + }; + ( + SingleKeyData::Open(OpenSingleKey { + private_key: private_key_bytes, + key_info: closed, + }), + false, + ) + }; + + Ok(Self { + private_key_data, + uses_password, + public_key, + address, + alias, + key_hash, + confirmed_balance: 0, + unconfirmed_balance: 0, + total_balance: 0, + utxos: HashMap::new(), + }) + } + + /// Create from a WIF-encoded private key string + pub fn from_wif( + wif: &str, + password: Option<&str>, + alias: Option, + ) -> Result { + let private_key = PrivateKey::from_wif(wif).map_err(|e| e.to_string())?; + let network = private_key.network; + let mut key_bytes = [0u8; 32]; + key_bytes.copy_from_slice(&private_key.inner[..]); + Self::new(key_bytes, network, password, alias) + } + + /// Create from a hex-encoded private key string + pub fn from_hex( + hex_str: &str, + network: Network, + password: Option<&str>, + alias: Option, + ) -> Result { + let bytes = hex::decode(hex_str).map_err(|e| e.to_string())?; + if bytes.len() != 32 { + return Err(format!( + "Invalid private key length: expected 32 bytes, got {}", + bytes.len() + )); + } + let mut key_bytes = [0u8; 32]; + key_bytes.copy_from_slice(&bytes); + Self::new(key_bytes, network, password, alias) + } + + /// Returns true if the wallet is open (private key is decrypted) + pub fn is_open(&self) -> bool { + self.private_key_data.is_open() + } + + /// Open the wallet with a password + pub fn open(&mut self, password: &str) -> Result<(), String> { + self.private_key_data.open(password) + } + + /// Open the wallet without a password + pub fn open_no_password(&mut self) -> Result<(), String> { + self.private_key_data.open_no_password() + } + + /// Get the key hash (identifier) + pub fn key_hash(&self) -> SingleKeyHash { + self.key_hash + } + + /// Get the encrypted private key bytes + pub fn encrypted_private_key(&self) -> &[u8] { + match &self.private_key_data { + SingleKeyData::Open(open) => &open.key_info.encrypted_private_key, + SingleKeyData::Closed(closed) => &closed.encrypted_private_key, + } + } + + /// Get the salt + pub fn salt(&self) -> &[u8] { + match &self.private_key_data { + SingleKeyData::Open(open) => &open.key_info.salt, + SingleKeyData::Closed(closed) => &closed.salt, + } + } + + /// Get the nonce + pub fn nonce(&self) -> &[u8] { + match &self.private_key_data { + SingleKeyData::Open(open) => &open.key_info.nonce, + SingleKeyData::Closed(closed) => &closed.nonce, + } + } + + /// Get the private key if the wallet is open + pub fn private_key(&self, network: Network) -> Option { + match &self.private_key_data { + SingleKeyData::Open(open) => { + PrivateKey::from_byte_array(&open.private_key, network).ok() + } + SingleKeyData::Closed(_) => None, + } + } + + /// Calculate balance from UTXOs + pub fn utxo_balance(&self) -> u64 { + self.utxos.values().map(|tx_out| tx_out.value).sum() + } + + /// Get the confirmed balance + pub fn confirmed_balance_duffs(&self) -> u64 { + if self.total_balance > 0 || self.confirmed_balance > 0 || self.unconfirmed_balance > 0 { + self.confirmed_balance + } else { + self.utxo_balance() + } + } + + /// Get the total balance + pub fn total_balance_duffs(&self) -> u64 { + if self.total_balance > 0 { + self.total_balance + } else { + self.utxo_balance() + } + } + + /// Update balances + pub fn update_balances(&mut self, confirmed: u64, unconfirmed: u64, total: u64) { + self.confirmed_balance = confirmed; + self.unconfirmed_balance = unconfirmed; + self.total_balance = total; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_single_key_wallet_no_password() { + let private_key = [42u8; 32]; + let wallet = SingleKeyWallet::new( + private_key, + Network::Testnet, + None, + Some("Test".to_string()), + ) + .expect("Failed to create wallet"); + + assert!(wallet.is_open()); + assert!(!wallet.uses_password); + assert_eq!(wallet.alias, Some("Test".to_string())); + assert!(wallet.private_key(Network::Testnet).is_some()); + } + + #[test] + fn test_create_single_key_wallet_with_password() { + let private_key = [42u8; 32]; + let password = "secret123"; + let wallet = SingleKeyWallet::new( + private_key, + Network::Testnet, + Some(password), + Some("Encrypted".to_string()), + ) + .expect("Failed to create wallet"); + + assert!(wallet.is_open()); + assert!(wallet.uses_password); + } + + #[test] + fn test_from_hex() { + let hex_key = "0000000000000000000000000000000000000000000000000000000000000001"; + let wallet = SingleKeyWallet::from_hex(hex_key, Network::Testnet, None, None) + .expect("Failed to create from hex"); + + assert!(wallet.is_open()); + assert!(!wallet.address.to_string().is_empty()); + } +} diff --git a/src/model/wallet/utxos.rs b/src/model/wallet/utxos.rs index dce8f6658..55f07f0b8 100644 --- a/src/model/wallet/utxos.rs +++ b/src/model/wallet/utxos.rs @@ -1,5 +1,5 @@ use crate::context::AppContext; -use crate::model::wallet::Wallet; +use crate::model::wallet::{DerivationPathHelpers, Wallet}; use dash_sdk::dashcore_rpc::json::ListUnspentResultEntry; use dash_sdk::dashcore_rpc::{Client, RpcApi}; use dash_sdk::dpp::dashcore::{Address, Network, OutPoint, TxOut}; @@ -95,8 +95,14 @@ impl Wallet { network: Network, save: Option<&AppContext>, ) -> Result, String> { - // Collect the addresses for which we want to load UTXOs. - let addresses: Vec<_> = self.known_addresses.keys().collect(); + // Collect Core chain addresses for which we want to load UTXOs. + // Platform addresses are NOT valid on Core chain and must be excluded. + let addresses: Vec<_> = self + .known_addresses + .iter() + .filter(|(_, path)| !path.is_platform_payment(network)) + .map(|(addr, _)| addr) + .collect(); if tracing::enabled!(tracing::Level::TRACE) { for addr in addresses.iter() { let (net, payload) = (*addr).clone().into_parts(); @@ -199,4 +205,16 @@ impl Wallet { // Return the new UTXO map Ok(new_utxo_map) } + + /// Get all addresses with their total UTXO balances + pub fn utxos_by_address(&self) -> Vec<(Address, u64)> { + self.utxos + .iter() + .map(|(address, utxos)| { + let total_balance: u64 = utxos.values().map(|tx_out| tx_out.value).sum(); + (address.clone(), total_balance) + }) + .filter(|(_, balance)| *balance > 0) + .collect() + } } diff --git a/src/spv/error.rs b/src/spv/error.rs new file mode 100644 index 000000000..a4b72a5e0 --- /dev/null +++ b/src/spv/error.rs @@ -0,0 +1,58 @@ +//! Error types for SPV operations. + +use thiserror::Error; + +/// Errors that can occur during SPV operations. +#[derive(Debug, Error, Clone)] +pub enum SpvError { + /// A lock was poisoned (another thread panicked while holding it) + #[error("SPV lock poisoned: {0}")] + LockPoisoned(String), + + /// SPV client is not initialized + #[error("SPV client not initialized")] + ClientNotInitialized, + + /// SPV client is not running + #[error("SPV client not running")] + NotRunning, + + /// Sync operation failed + #[error("SPV sync failed: {0}")] + SyncFailed(String), + + /// Network operation failed + #[error("SPV network error: {0}")] + NetworkError(String), + + /// Wallet operation failed + #[error("SPV wallet error: {0}")] + WalletError(String), + + /// Configuration error + #[error("SPV configuration error: {0}")] + ConfigError(String), + + /// Channel communication error + #[error("SPV channel error: {0}")] + ChannelError(String), + + /// Generic error + #[error("{0}")] + Other(String), +} + +impl From for SpvError { + fn from(s: String) -> Self { + SpvError::Other(s) + } +} + +impl From<&str> for SpvError { + fn from(s: &str) -> Self { + SpvError::Other(s.to_string()) + } +} + +/// Result type for SPV operations. +pub type SpvResult = Result; diff --git a/src/spv/manager.rs b/src/spv/manager.rs new file mode 100644 index 000000000..f5fa76935 --- /dev/null +++ b/src/spv/manager.rs @@ -0,0 +1,1190 @@ +use super::error::{SpvError, SpvResult}; +use crate::app_dir::app_user_data_dir_path; +use crate::config::NetworkConfig; +use crate::model::wallet::WalletSeedHash; +use crate::utils::tasks::TaskManager; +use dash_sdk::dash_spv::client::interface::{DashSpvClientCommand, DashSpvClientInterface}; +use dash_sdk::dash_spv::network::PeerNetworkManager; +use dash_sdk::dash_spv::storage::DiskStorageManager; +use dash_sdk::dash_spv::types::{ + DetailedSyncProgress, SpvEvent, SyncProgress, SyncStage, ValidationMode, +}; +use dash_sdk::dash_spv::{ClientConfig, DashSpvClient, Hash, LLMQType, QuorumHash}; +use dash_sdk::dpp::dashcore::{Address, Network, Transaction}; +use dash_sdk::dpp::key_wallet; +use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; +use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::{ + ManagedWalletInfo, transaction_building::AccountTypePreference, + wallet_info_interface::WalletInfoInterface, +}; +use dash_sdk::dpp::key_wallet_manager::wallet_manager::{WalletError, WalletId, WalletManager}; +// use dash_sdk::dpp::key_wallet::bip32::ExtendedPubKey; // not needed directly here +use std::fmt; +use std::fs; +use std::net::ToSocketAddrs; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::{Duration, SystemTime}; +use tokio::sync::RwLock as AsyncRwLock; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use zeroize::Zeroize; + +/// Preferred backend for Core-level operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CoreBackendMode { + #[default] + Rpc = 0, + Spv = 1, +} + +impl CoreBackendMode { + pub fn as_u8(self) -> u8 { + self as u8 + } +} + +impl From for CoreBackendMode { + fn from(value: u8) -> Self { + match value { + 1 => CoreBackendMode::Spv, + _ => CoreBackendMode::Rpc, + } + } +} + +/// High-level status of the SPV client runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SpvStatus { + #[default] + Idle, + Starting, + Syncing, + Running, + Stopping, + Stopped, + Error, +} + +impl SpvStatus { + pub fn is_active(self) -> bool { + matches!( + self, + SpvStatus::Starting | SpvStatus::Syncing | SpvStatus::Running | SpvStatus::Stopping + ) + } +} + +/// Snapshot of the SPV runtime state for UI consumption. +/// Uses dash-spv's built-in progress types directly instead of duplicating. +#[derive(Debug, Clone, Default)] +pub struct SpvStatusSnapshot { + pub status: SpvStatus, + pub sync_progress: Option, + pub detailed_progress: Option, + pub last_error: Option, + pub started_at: Option, + pub last_updated: Option, +} + +/// Type alias for the SPV client with our specific configuration +type SpvClient = + DashSpvClient, PeerNetworkManager, DiskStorageManager>; + +/// Manages SPV client lifecycle and exposes status updates. +/// Uses dash-spv's built-in state management while maintaining a dedicated runtime for performance. +/// +/// The client itself is owned by the background runtime thread and accessed through +/// its internally-shared components (wallet, storage, etc.) rather than through additional locking. +pub struct SpvManager { + network: Network, + data_dir: PathBuf, + config: Arc>, + subtasks: Arc, + wallet: Arc>>, + // Storage manager for direct access to SPV data (shared component from client) + storage: Arc>>>>, + // Interface for sending commands to the running SPV client (quorum lookups, etc.) + client_interface: Arc>>, + status: Arc>, + last_error: Arc>>, + started_at: Arc>>, + sync_progress_state: Arc>>, + detailed_progress_state: Arc>>, + progress_updated_at: Arc>>, + // mapping DET wallet seed_hash -> SPV wallet identifier (if created) + det_wallets: Arc>>, + // signal channel to trigger external reconcile on wallet-related events + reconcile_tx: Mutex>>, + // Whether to use local Dash Core node instead of DNS seed discovery + use_local_node: Arc, + // Cancellation token for clean shutdown + stop_token: Mutex>, + // Channel to send requests to the SPV runtime thread + request_tx: Mutex>>, + // Network manager clone for broadcasting transactions (set when client is running) + network_manager: Arc>>, +} + +/// Requests that can be sent to the SPV runtime thread +/// +/// Note: These requests are handled in the same async context where the client lives, +/// allowing direct access to client methods without additional locking overhead. +enum SpvRequest { + BroadcastTransaction { + tx: Box, + response_tx: tokio::sync::oneshot::Sender>, + }, +} + +#[derive(Debug, Clone)] +pub struct SpvDerivedAddress { + pub address: Address, + pub derivation_path: DerivationPath, +} + +impl SpvManager { + // ==================== Lock Helper Methods ==================== + // These methods provide safe access to locks with proper error handling + // instead of panicking on lock poisoning. + + fn read_status(&self) -> SpvResult { + self.status + .read() + .map(|g| *g) + .map_err(|_| SpvError::LockPoisoned("status".into())) + } + + fn write_status(&self, value: SpvStatus) -> SpvResult<()> { + let mut guard = self + .status + .write() + .map_err(|_| SpvError::LockPoisoned("status".into()))?; + *guard = value; + Ok(()) + } + + fn read_last_error(&self) -> SpvResult> { + self.last_error + .read() + .map(|g| g.clone()) + .map_err(|_| SpvError::LockPoisoned("last_error".into())) + } + + fn write_last_error(&self, value: Option) -> SpvResult<()> { + let mut guard = self + .last_error + .write() + .map_err(|_| SpvError::LockPoisoned("last_error".into()))?; + *guard = value; + Ok(()) + } + + fn read_started_at(&self) -> SpvResult> { + self.started_at + .read() + .map(|g| *g) + .map_err(|_| SpvError::LockPoisoned("started_at".into())) + } + + fn write_started_at(&self, value: Option) -> SpvResult<()> { + let mut guard = self + .started_at + .write() + .map_err(|_| SpvError::LockPoisoned("started_at".into()))?; + *guard = value; + Ok(()) + } + + fn read_sync_progress(&self) -> SpvResult> { + self.sync_progress_state + .read() + .map(|g| g.clone()) + .map_err(|_| SpvError::LockPoisoned("sync_progress".into())) + } + + fn write_sync_progress(&self, value: Option) -> SpvResult<()> { + let mut guard = self + .sync_progress_state + .write() + .map_err(|_| SpvError::LockPoisoned("sync_progress".into()))?; + *guard = value; + Ok(()) + } + + fn read_detailed_progress(&self) -> SpvResult> { + self.detailed_progress_state + .read() + .map(|g| g.clone()) + .map_err(|_| SpvError::LockPoisoned("detailed_progress".into())) + } + + fn write_detailed_progress(&self, value: Option) -> SpvResult<()> { + let mut guard = self + .detailed_progress_state + .write() + .map_err(|_| SpvError::LockPoisoned("detailed_progress".into()))?; + *guard = value; + Ok(()) + } + + fn read_progress_updated_at(&self) -> SpvResult> { + self.progress_updated_at + .read() + .map(|g| *g) + .map_err(|_| SpvError::LockPoisoned("progress_updated_at".into())) + } + + fn write_progress_updated_at(&self, value: Option) -> SpvResult<()> { + let mut guard = self + .progress_updated_at + .write() + .map_err(|_| SpvError::LockPoisoned("progress_updated_at".into()))?; + *guard = value; + Ok(()) + } + + // ==================== Public API ==================== + + pub fn new( + network: Network, + config: Arc>, + subtasks: Arc, + ) -> Result, String> { + let cfg = config.read().map_err(|e| e.to_string())?; + let data_dir = build_spv_data_dir(network, &cfg)?; + drop(cfg); + fs::create_dir_all(&data_dir).map_err(|e| format!("Failed to create SPV data dir: {e}"))?; + + let manager = Arc::new(Self { + network, + data_dir, + config, + subtasks, + wallet: Arc::new(AsyncRwLock::new(WalletManager::::new())), + storage: Arc::new(Mutex::new(None)), + client_interface: Arc::new(RwLock::new(None)), + status: Arc::new(RwLock::new(SpvStatus::Idle)), + last_error: Arc::new(RwLock::new(None)), + started_at: Arc::new(RwLock::new(None)), + sync_progress_state: Arc::new(RwLock::new(None)), + detailed_progress_state: Arc::new(RwLock::new(None)), + progress_updated_at: Arc::new(RwLock::new(None)), + det_wallets: Arc::new(RwLock::new(std::collections::BTreeMap::new())), + reconcile_tx: Mutex::new(None), + use_local_node: Arc::new(AtomicBool::new(false)), + stop_token: Mutex::new(None), + request_tx: Mutex::new(None), + network_manager: Arc::new(AsyncRwLock::new(None)), + }); + + Ok(manager) + } + + /// Set whether to use local Dash Core node for SPV sync instead of DNS seed discovery. + /// Note: This only takes effect when starting a new SPV sync session. + pub fn set_use_local_node(&self, use_local: bool) { + self.use_local_node.store(use_local, Ordering::SeqCst); + } + + /// Get whether to use local Dash Core node for SPV sync. + pub fn use_local_node(&self) -> bool { + self.use_local_node.load(Ordering::SeqCst) + } + + /// Async status method for getting full details including progress. + /// Returns default snapshot on lock errors to avoid panics. + pub async fn status_async(&self) -> SpvStatusSnapshot { + let status = self.read_status().unwrap_or(SpvStatus::Idle); + let last_error = self.read_last_error().unwrap_or(None); + let started_at = self.read_started_at().unwrap_or(None); + let sync_progress = self.read_sync_progress().unwrap_or(None); + let detailed_progress = self.read_detailed_progress().unwrap_or(None); + let last_updated = self + .read_progress_updated_at() + .unwrap_or(None) + .or(Some(SystemTime::now())); + + SpvStatusSnapshot { + status, + sync_progress, + detailed_progress, + last_error, + started_at, + last_updated, + } + } + + /// Sync status method for UI updates (doesn't fetch detailed progress). + /// Returns default snapshot on lock errors to avoid panics. + pub fn status(&self) -> SpvStatusSnapshot { + let status = self.read_status().unwrap_or(SpvStatus::Idle); + let last_error = self.read_last_error().unwrap_or(None); + let started_at = self.read_started_at().unwrap_or(None); + let sync_progress = self.read_sync_progress().unwrap_or(None); + let detailed_progress = self.read_detailed_progress().unwrap_or(None); + let last_updated = self + .read_progress_updated_at() + .unwrap_or(None) + .or(Some(SystemTime::now())); + + SpvStatusSnapshot { + status, + sync_progress, + detailed_progress, + last_error, + started_at, + last_updated, + } + } + + pub fn start(self: &Arc) -> Result<(), String> { + // Check if already running + { + let stop_token_guard = self + .stop_token + .lock() + .map_err(|_| "SPV stop_token lock poisoned")?; + if stop_token_guard.is_some() { + return Ok(()); + } + } + + self.write_status(SpvStatus::Starting) + .map_err(|e| e.to_string())?; + self.write_last_error(None).map_err(|e| e.to_string())?; + self.write_started_at(Some(SystemTime::now())) + .map_err(|e| e.to_string())?; + self.write_sync_progress(None).map_err(|e| e.to_string())?; + self.write_detailed_progress(None) + .map_err(|e| e.to_string())?; + self.write_progress_updated_at(None) + .map_err(|e| e.to_string())?; + + let stop_token = CancellationToken::new(); + { + let mut guard = self + .stop_token + .lock() + .map_err(|_| "SPV stop_token lock poisoned")?; + *guard = Some(stop_token.clone()); + } + + let manager = Arc::clone(self); + let global_cancel = self.subtasks.cancellation_token.clone(); + + // Spawn a dedicated OS thread with a multi-thread Tokio runtime for SPV operations + // This ensures SPV sync doesn't compete with UI thread resources + std::thread::Builder::new() + .name("spv".to_string()) + .spawn(move || { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(4) + .enable_all() + .thread_name("spv-rt") + .build() + .expect("Failed to create SPV runtime"); + + rt.block_on(async move { + let manager_for_loop = Arc::clone(&manager); + if let Err(err) = manager_for_loop.run_spv_loop(stop_token, global_cancel).await { + tracing::error!(error = %err, network = ?manager.network, "SPV runtime failed"); + if let Err(e) = manager.write_last_error(Some(err.clone())) { + tracing::error!("Failed to write SPV error: {}", e); + } + if let Err(e) = manager.write_status(SpvStatus::Error) { + tracing::error!("Failed to write SPV status: {}", e); + } + } + + // Clean up on exit + if let Ok(mut guard) = manager.stop_token.lock() { + *guard = None; + } + }); + }) + .map_err(|e| format!("Failed to spawn SPV thread: {e}"))?; + + Ok(()) + } + + pub fn stop(&self) { + let maybe_token = self.stop_token.lock().ok().and_then(|g| g.clone()); + + if let Some(token) = maybe_token { + let _ = self.write_status(SpvStatus::Stopping); + token.cancel(); + } else { + let _ = self.write_status(SpvStatus::Stopped); + } + } + + pub fn wallet(&self) -> Arc>> { + Arc::clone(&self.wallet) + } + + pub fn det_wallets_snapshot(&self) -> std::collections::BTreeMap<[u8; 32], WalletId> { + self.det_wallets + .read() + .map(|m| m.clone()) + .unwrap_or_default() + } + + pub fn wallet_id_for_seed(&self, seed_hash: WalletSeedHash) -> Option { + self.det_wallets + .read() + .ok() + .and_then(|map| map.get(&seed_hash).copied()) + } + + pub async fn unload_wallet(&self, seed_hash: WalletSeedHash) -> Result<(), String> { + let wallet_id = { + let map = self.det_wallets.read().map_err(|e| e.to_string())?; + map.get(&seed_hash).copied() + }; + + let Some(wallet_id) = wallet_id else { + return Ok(()); + }; + + let mut wm = self.wallet.write().await; + match wm.remove_wallet(&wallet_id) { + Ok((_wallet, _info)) => { + drop(wm); + let mut map = self.det_wallets.write().map_err(|e| e.to_string())?; + map.remove(&seed_hash); + Ok(()) + } + Err(WalletError::WalletNotFound(_)) => Ok(()), + Err(err) => Err(format!("Failed to unload SPV wallet: {err}")), + } + } + + pub async fn broadcast_transaction(&self, tx: &Transaction) -> Result<(), String> { + let request_tx = self + .request_tx + .lock() + .map_err(|_| "SPV request_tx lock poisoned")? + .clone() + .ok_or_else(|| "SPV client not running".to_string())?; + + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + + request_tx + .send(SpvRequest::BroadcastTransaction { + tx: Box::new(tx.clone()), + response_tx, + }) + .await + .map_err(|_| "SPV runtime channel closed".to_string())?; + + response_rx + .await + .map_err(|_| "SPV request cancelled".to_string())? + } + + /// Create a reconciliation signal channel for external listeners. + /// Returns a receiver that will get a signal when SPV wallet state likely changed. + pub fn register_reconcile_channel(&self) -> mpsc::Receiver<()> { + let (tx, rx) = mpsc::channel(64); + if let Ok(mut guard) = self.reconcile_tx.lock() { + *guard = Some(tx); + } + rx + } + + /// Remove all cached SPV data on disk for the current network. + /// + /// This requires the SPV runtime to be stopped first; otherwise the + /// on-disk files could be re-created immediately by the running client. + pub fn clear_data_dir(&self) -> Result<(), String> { + let status = self.read_status().map_err(|e| e.to_string())?; + if status.is_active() { + return Err("Stop the SPV client before clearing its data".to_string()); + } + + if let Ok(mut storage_guard) = self.storage.lock() { + *storage_guard = None; + } + + if let Ok(mut interface_guard) = self.client_interface.write() { + *interface_guard = None; + } + + if let Ok(mut request_guard) = self.request_tx.lock() { + *request_guard = None; + } + + if let Ok(mut wallet_map) = self.det_wallets.write() { + wallet_map.clear(); + } + + self.write_sync_progress(None).map_err(|e| e.to_string())?; + self.write_detailed_progress(None) + .map_err(|e| e.to_string())?; + self.write_progress_updated_at(None) + .map_err(|e| e.to_string())?; + self.write_started_at(None).map_err(|e| e.to_string())?; + self.write_last_error(None).map_err(|e| e.to_string())?; + self.write_status(SpvStatus::Idle) + .map_err(|e| e.to_string())?; + + if self.data_dir.exists() { + fs::remove_dir_all(&self.data_dir).map_err(|e| { + format!( + "Failed to clear SPV data directory {}: {e}", + self.data_dir.display() + ) + })?; + } + + fs::create_dir_all(&self.data_dir).map_err(|e| { + format!( + "Failed to re-create SPV data directory {}: {e}", + self.data_dir.display() + ) + })?; + + Ok(()) + } + + /// Attempt to resolve a quorum public key via the SPV client's masternode/quorum state. + /// + /// This method sends a request through the DashSpvClientInterface to query the running + /// SPV client. If SPV is not running or the key is not known, an error is returned. + pub fn get_quorum_public_key( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + core_chain_locked_height: u32, + ) -> Result<[u8; 48], String> { + tracing::debug!( + "get_quorum_public_key called: type={}, hash={}, height={}", + quorum_type, + hex::encode(quorum_hash), + core_chain_locked_height + ); + + let interface = { + let guard = self + .client_interface + .read() + .map_err(|e| format!("client_interface lock poisoned: {e}"))?; + guard + .clone() + .ok_or_else(|| "SPV client not initialized".to_string())? + }; + + let llmq_type = LLMQType::from(quorum_type as u8); + let qh = QuorumHash::from_byte_array(quorum_hash).reverse(); + + tracing::debug!( + "SPV quorum public key lookup in progress: type={}, hash={}, height={}", + quorum_type, + hex::encode(quorum_hash), + core_chain_locked_height + ); + + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { + interface + .get_quorum_by_height(core_chain_locked_height, llmq_type, qh) + .await + .map(|q| { + tracing::debug!( + "Quorum public key found: type={}, hash={}, height={}", + quorum_type, + hex::encode(quorum_hash), + core_chain_locked_height + ); + *q.quorum_entry.quorum_public_key.as_ref() + }) + .map_err(|e| { + tracing::warn!( + "Quorum lookup failed at height {} for llmq_type={} hash=0x{}: {}", + core_chain_locked_height, + quorum_type, + hex::encode(quorum_hash), + e + ); + e.to_string() + }) + }) + }) + } + + pub async fn load_wallet_from_seed( + &self, + seed_hash: WalletSeedHash, + mut seed_bytes: [u8; 64], + ) -> Result { + let wallet_network = Self::wallet_network(self.network); + + let existing_wallet_id = { + let map = self.det_wallets.read().map_err(|e| e.to_string())?; + map.get(&seed_hash).copied() + }; + + let mut wm = self.wallet.write().await; + + if let Some(wallet_id) = existing_wallet_id { + if let Some(wallet) = wm.get_wallet(&wallet_id) + && wallet.can_sign() + { + seed_bytes.zeroize(); + return Ok(wallet_id); + } + + if let Err(err) = wm.remove_wallet(&wallet_id) { + tracing::warn!(wallet = %hex::encode(wallet_id), ?err, "Failed to remove existing SPV wallet before upgrade"); + } else { + tracing::info!(wallet = %hex::encode(wallet_id), "Upgrading SPV wallet from watch-only to full access"); + } + } + + let xprv = ExtendedPrivKey::new_master(self.network, &seed_bytes).map_err(|e| { + seed_bytes.zeroize(); + format!("ExtendedPrivKey::new_master failed: {e}") + })?; + seed_bytes.zeroize(); + let xprv_str = xprv.to_string(); + + let account_options = Self::default_account_creation_options(); + + let wallet_id = match wm.import_wallet_from_extended_priv_key( + &xprv_str, + wallet_network, + account_options, + ) { + Ok(id) => id, + Err(WalletError::WalletExists(id)) => id, + Err(err) => { + return Err(format!( + "import_wallet_from_extended_priv_key failed: {err}" + )); + } + }; + + drop(wm); + + let mut map = self.det_wallets.write().map_err(|e| e.to_string())?; + map.insert(seed_hash, wallet_id); + + Ok(wallet_id) + } + + pub async fn next_bip44_receive_address( + &self, + seed_hash: WalletSeedHash, + account_index: u32, + ) -> Result { + let wallet_id = { + let map = self.det_wallets.read().map_err(|e| e.to_string())?; + map.get(&seed_hash) + .copied() + .ok_or_else(|| "Wallet seed not loaded into SPV".to_string())? + }; + + let mut wm = self.wallet.write().await; + + let result = wm + .get_receive_address( + &wallet_id, + account_index, + AccountTypePreference::BIP44, + true, + ) + .map_err(|e| format!("get_receive_address failed: {e}"))?; + + let address = result + .address + .ok_or_else(|| "Wallet manager did not return an address".to_string())?; + + let derivation_path = { + let info = wm + .get_wallet_info(&wallet_id) + .ok_or_else(|| "wallet info missing".to_string())?; + let collection = info.accounts(); + let account = collection + .standard_bip44_accounts + .get(&account_index) + .ok_or_else(|| "BIP44 account missing".to_string())?; + let metadata = account + .get_address_info(&address) + .ok_or_else(|| "Address metadata unavailable".to_string())?; + metadata.path + }; + + Ok(SpvDerivedAddress { + address, + derivation_path, + }) + } + + fn wallet_network(network: Network) -> key_wallet::Network { + match network { + Network::Dash => key_wallet::Network::Dash, + Network::Testnet => key_wallet::Network::Testnet, + Network::Devnet => key_wallet::Network::Devnet, + Network::Regtest => key_wallet::Network::Regtest, + other => { + tracing::warn!( + ?other, + "Unknown dashcore::Network; defaulting to Dash for wallet mapping" + ); + key_wallet::Network::Dash + } + } + } + + fn default_account_creation_options() -> WalletAccountCreationOptions { + WalletAccountCreationOptions::Default + } + + async fn run_spv_loop( + self: Arc, + stop_token: CancellationToken, + global_cancel: CancellationToken, + ) -> Result<(), String> { + // Build and start the client + let mut client = self.build_client().await?; + client + .start() + .await + .map_err(|e| format!("SPV start failed: {e}"))?; + + // Store the shared storage reference for later access + { + let storage = client.storage(); + if let Ok(mut storage_guard) = self.storage.lock() { + *storage_guard = Some(storage); + } + } + + // Set up progress handler + if let Some(progress_rx) = client.take_progress_receiver() { + self.spawn_progress_handler(progress_rx); + } + + // Set up event handler + if let Some(event_rx) = client.take_event_receiver() { + self.spawn_event_handler(event_rx); + } + + // Set up request handler with access to shared components + let (request_tx, request_rx) = mpsc::channel(32); + { + if let Ok(mut guard) = self.request_tx.lock() { + *guard = Some(request_tx); + } + } + + // Spawn request handler in a separate task + self.spawn_request_handler(request_rx, stop_token.clone()); + + // Create command channel for the DashSpvClientInterface + // Note: Unbounded channel is required by SDK's DashSpvClientInterface API. + // Memory usage is bounded in practice by SPV command processing speed. + let (command_tx, command_receiver) = tokio::sync::mpsc::unbounded_channel(); + + // Store the interface for external queries (quorum lookups, etc.) + { + let interface = DashSpvClientInterface::new(command_tx); + let mut guard = self + .client_interface + .write() + .map_err(|e| format!("client_interface lock poisoned: {e}"))?; + *guard = Some(interface); + } + + let _ = self.write_status(SpvStatus::Syncing); + + // Run sync and monitor with the client owned in this scope + let result = self + .clone() + .run_sync_and_monitor(client, command_receiver, stop_token, global_cancel) + .await; + + // Clear the interface and network manager since the client is done + { + if let Ok(mut guard) = self.client_interface.write() { + *guard = None; + } + } + { + let mut nm_guard = self.network_manager.write().await; + *nm_guard = None; + } + + result + } + + async fn run_sync_and_monitor( + self: Arc, + mut client: SpvClient, + command_receiver: mpsc::UnboundedReceiver, + stop_token: CancellationToken, + global_cancel: CancellationToken, + ) -> Result<(), String> { + // Wait for at least one peer to connect + let mut waited_ms: u64 = 0; + loop { + // Check for cancellation + if stop_token.is_cancelled() || global_cancel.is_cancelled() { + let _ = client.stop().await; + let _ = self.write_status(SpvStatus::Stopped); + return Ok(()); + } + + let peers = client.get_peer_count().await; + if peers > 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + waited_ms = waited_ms.saturating_add(200); + if waited_ms.is_multiple_of(5000) { + tracing::info!("SPV waiting for peers... {}s elapsed", waited_ms / 1000); + } + } + + // Sync to tip with timeout to prevent indefinite hangs + const SYNC_TIMEOUT_SECS: u64 = 300; // 5 minutes + match tokio::time::timeout(Duration::from_secs(SYNC_TIMEOUT_SECS), client.sync_to_tip()) + .await + { + Ok(Ok(progress)) => { + tracing::info!("Initial sync progress snapshot: {:?}", progress); + let _ = self.write_sync_progress(Some(progress.clone())); + let _ = self.write_progress_updated_at(Some(SystemTime::now())); + // Stay in Syncing mode until detailed progress reports completion. + let _ = self.write_status(SpvStatus::Syncing); + } + Ok(Err(err)) => { + tracing::error!("Initial sync failed: {}", err); + let _ = client.stop().await; + let _ = self.write_last_error(Some(format!("Initial sync failed: {err}"))); + let _ = self.write_status(SpvStatus::Error); + return Err(format!("Initial sync failed: {err}")); + } + Err(_) => { + tracing::error!("Initial sync timed out after {} seconds", SYNC_TIMEOUT_SECS); + let _ = client.stop().await; + let _ = self.write_last_error(Some(format!( + "Initial sync timed out after {} seconds", + SYNC_TIMEOUT_SECS + ))); + let _ = self.write_status(SpvStatus::Error); + return Err(format!( + "Initial sync timed out after {} seconds", + SYNC_TIMEOUT_SECS + )); + } + } + + // Monitor network continuously - this is designed to run once and keep running + // Requests are handled through the DashSpvClientInterface command channel + enum Outcome { + MonitorCompleted(Result<(), dash_sdk::dash_spv::SpvError>), + StopRequested, + GlobalCancelled, + } + + let outcome = { + let monitor_cancel = CancellationToken::new(); + let monitor_future = client.monitor_network(command_receiver, monitor_cancel.clone()); + tokio::pin!(monitor_future); + + tokio::select! { + result = &mut monitor_future => Outcome::MonitorCompleted(result), + _ = stop_token.cancelled() => { + monitor_cancel.cancel(); + Outcome::StopRequested + }, + _ = global_cancel.cancelled() => { + monitor_cancel.cancel(); + Outcome::GlobalCancelled + }, + } + }; // monitor_future is dropped here, releasing the mutable borrow + + // Stop the client after monitoring completes or is cancelled + let _ = client.stop().await; + + match outcome { + Outcome::MonitorCompleted(Ok(())) => { + let _ = self.write_status(SpvStatus::Stopped); + Ok(()) + } + Outcome::MonitorCompleted(Err(err)) => { + let message = format!("monitor_network failed: {err}"); + let _ = self.write_last_error(Some(message.clone())); + let _ = self.write_status(SpvStatus::Error); + Err(message) + } + Outcome::StopRequested | Outcome::GlobalCancelled => { + let _ = self.write_status(SpvStatus::Stopped); + Ok(()) + } + } + } + + fn spawn_request_handler( + &self, + mut request_rx: mpsc::Receiver, + cancel: CancellationToken, + ) { + tracing::info!("SPV request handler started"); + let network_manager = Arc::clone(&self.network_manager); + self.subtasks.spawn_sync(async move { + loop { + tokio::select! { + _ = cancel.cancelled() => { + tracing::info!("SPV request handler cancelled"); + break; + } + request = request_rx.recv() => { + match request { + Some(SpvRequest::BroadcastTransaction { tx, response_tx }) => { + tracing::debug!("Received BroadcastTransaction request"); + let result = { + let nm_guard = network_manager.read().await; + if let Some(ref nm) = *nm_guard { + // Broadcast the transaction to all connected peers + let message = dash_sdk::dpp::dashcore::network::message::NetworkMessage::Tx((*tx).clone()); + let results = nm.broadcast(message).await; + // Check if at least one broadcast succeeded + let mut success = false; + let mut errors = Vec::new(); + for res in results { + match res { + Ok(_) => success = true, + Err(e) => errors.push(e.to_string()), + } + } + if success { + tracing::info!("Transaction {} broadcast successfully", tx.txid()); + Ok(()) + } else if errors.is_empty() { + Err("No peers connected to broadcast transaction".to_string()) + } else { + Err(format!("Broadcast failed: {}", errors.join(", "))) + } + } else { + Err("SPV network manager not available".to_string()) + } + }; + let _ = response_tx.send(result); + } + None => { + tracing::warn!("SPV request channel closed"); + break; + } + } + } + } + } + tracing::info!("SPV request handler exiting"); + }); + } + + fn spawn_progress_handler( + &self, + mut progress_rx: tokio::sync::mpsc::UnboundedReceiver, + ) { + let status = Arc::clone(&self.status); + let last_error = Arc::clone(&self.last_error); + let sync_progress_state = Arc::clone(&self.sync_progress_state); + let detailed_progress_state = Arc::clone(&self.detailed_progress_state); + let progress_updated_at = Arc::clone(&self.progress_updated_at); + let cancel = self.subtasks.cancellation_token.clone(); + + self.subtasks.spawn_sync(async move { + let mut last_update = std::time::Instant::now(); + let min_interval = std::time::Duration::from_millis(500); + + loop { + tokio::select! { + _ = cancel.cancelled() => break, + msg = progress_rx.recv() => { + match msg { + Some(detailed) => { + if let Ok(mut stored_detailed) = detailed_progress_state.write() { + *stored_detailed = Some(detailed.clone()); + } + if let Ok(mut stored_sync) = sync_progress_state.write() { + *stored_sync = Some(detailed.sync_progress.clone()); + } + if let Ok(mut updated_at) = progress_updated_at.write() { + *updated_at = Some(detailed.last_update_time); + } + + if last_update.elapsed() >= min_interval { + // Update status based on progress stage and completeness + if let Ok(mut status_guard) = status.write() { + let current = *status_guard; + match &detailed.sync_stage { + SyncStage::Complete => { + *status_guard = SpvStatus::Running; + } + SyncStage::Failed(message) => { + *status_guard = SpvStatus::Error; + if let Ok(mut err_guard) = last_error.write() { + *err_guard = Some(format!("SPV sync failed: {message}")); + } + } + _ => { + if !matches!( + current, + SpvStatus::Stopping | SpvStatus::Stopped | SpvStatus::Error + ) { + *status_guard = SpvStatus::Syncing; + } + } + } + } + last_update = std::time::Instant::now(); + } + } + None => break, + } + } + } + } + }); + } + + fn spawn_event_handler(&self, mut event_rx: tokio::sync::mpsc::UnboundedReceiver) { + let reconcile_tx = self.reconcile_tx.lock().ok().and_then(|g| g.clone()); + let cancel = self.subtasks.cancellation_token.clone(); + + self.subtasks.spawn_sync(async move { + loop { + tokio::select! { + _ = cancel.cancelled() => break, + evt = event_rx.recv() => { + match evt { + Some(event) => { + // Push reconcile signal for wallet-related updates + let should_signal = matches!(event, + SpvEvent::TransactionDetected { .. } | + SpvEvent::BalanceUpdate { .. } | + SpvEvent::BlockProcessed { .. } + ); + if should_signal + && let Some(ref tx) = reconcile_tx { + let _ = tx.try_send(()); + } + } + None => break, + } + } + } + } + }); + } + + async fn build_client( + &self, + ) -> Result< + DashSpvClient, PeerNetworkManager, DiskStorageManager>, + String, + > { + let start_height = { + let guard = self.wallet.read().await; + if guard.wallet_count() == 0 { + u32::MAX + } else { + 0 + } + }; + let mut config = ClientConfig::new(self.network) + .with_storage_path(self.data_dir.clone()) + .with_validation_mode(ValidationMode::Full) + .with_start_height(start_height); + + // Configure peer discovery based on network type and user preference. + // Devnet/Regtest always need explicit peers since they're local networks. + // Mainnet/Testnet can use DNS seed discovery (default) or local node. + if self.network == Network::Devnet || self.network == Network::Regtest { + // Local networks always need explicit peer configuration + if let Some(peer) = self.primary_peer_socket() { + config.add_peer(peer); + } + } else if self.use_local_node() { + // User has chosen to use their local Dash Core node + if let Some(peer) = self.primary_peer_socket() { + config.add_peer(peer); + } + } + // Otherwise, no peers are added and SPV will use DNS seed discovery + + let network_manager = PeerNetworkManager::new(&config) + .await + .map_err(|e| format!("Failed to initialize SPV network manager: {e}"))?; + + // Store a clone of the network manager for broadcasting transactions + { + let mut nm_guard = self.network_manager.write().await; + *nm_guard = Some(network_manager.clone()); + } + + let storage_manager = DiskStorageManager::new(self.data_dir.clone()) + .await + .map_err(|e| format!("Failed to initialize SPV storage: {e}"))?; + + DashSpvClient::new( + config, + network_manager, + storage_manager, + Arc::clone(&self.wallet), + ) + .await + .map_err(|e| format!("Failed to create SPV client: {e}")) + } + + fn primary_peer_socket(&self) -> Option { + let config = self.config.read().ok()?; + + let host = config.core_host.as_str(); + let port = match self.network { + Network::Dash => 9999, + Network::Testnet => 19999, + Network::Devnet => 20001, + Network::Regtest => 19899, + _ => 9999, + }; + + let addr = format!("{}:{}", host, port); + addr.to_socket_addrs().ok()?.next() + } +} + +fn build_spv_data_dir(network: Network, config: &NetworkConfig) -> Result { + let mut base = app_user_data_dir_path().map_err(|e| e.to_string())?; + base.push("spv"); + fs::create_dir_all(&base).map_err(|e| format!("Failed to create SPV base dir: {e}"))?; + + let network_dir = match network { + Network::Dash => "mainnet".to_string(), + Network::Testnet => "testnet".to_string(), + Network::Devnet => config + .devnet_name + .clone() + .unwrap_or_else(|| "devnet".to_string()), + Network::Regtest => "regtest".to_string(), + other => format!("{other:?}"), + }; + + Ok(base.join(network_dir)) +} + +impl fmt::Debug for SpvManager { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SpvManager") + .field("network", &self.network) + .field("data_dir", &self.data_dir) + .finish() + } +} diff --git a/src/spv/mod.rs b/src/spv/mod.rs new file mode 100644 index 000000000..3eebea869 --- /dev/null +++ b/src/spv/mod.rs @@ -0,0 +1,5 @@ +mod error; +mod manager; + +pub use error::{SpvError, SpvResult}; +pub use manager::{CoreBackendMode, SpvDerivedAddress, SpvManager, SpvStatus, SpvStatusSnapshot}; diff --git a/src/ui/components/amount_input.rs b/src/ui/components/amount_input.rs index abc071145..31d5d09d2 100644 --- a/src/ui/components/amount_input.rs +++ b/src/ui/components/amount_input.rs @@ -2,7 +2,7 @@ use crate::model::amount::Amount; use crate::ui::components::{Component, ComponentResponse}; use dash_sdk::dpp::balances::credits::MAX_CREDITS; use dash_sdk::dpp::fee::Credits; -use egui::{InnerResponse, Response, TextEdit, Ui, Vec2, WidgetText}; +use egui::{Color32, InnerResponse, Response, TextEdit, Ui, WidgetText}; /// Response from the amount input widget #[derive(Clone)] @@ -83,7 +83,7 @@ pub struct AmountInput { decimal_places: u8, unit_name: Option, label: Option, - hint_text: Option, + hint_text: Option, max_amount: Option, min_amount: Option, show_max_button: bool, @@ -189,13 +189,13 @@ impl AmountInput { } /// Sets the hint text for the input field. - pub fn with_hint_text>(mut self, hint_text: T) -> Self { + pub fn with_hint_text(mut self, hint_text: impl Into) -> Self { self.hint_text = Some(hint_text.into()); self } /// Sets the hint text for the input field (mutable reference version). - pub fn set_hint_text>(&mut self, hint_text: T) -> &mut Self { + pub fn set_hint_text(&mut self, hint_text: impl Into) -> &mut Self { self.hint_text = Some(hint_text.into()); self } @@ -308,7 +308,7 @@ impl AmountInput { if self.show_max_button { // ensure we have height predefined to correctly vertically align the input field; // see StyledButton::show() to see how y is calculated - ui.allocate_space(Vec2::new(0.0, 30.0)); + ui.set_min_height(30.0); } // Show label if provided if let Some(label) = &self.label { @@ -318,7 +318,9 @@ impl AmountInput { let mut text_edit = TextEdit::singleline(&mut self.amount_str); if let Some(hint) = &self.hint_text { - text_edit = text_edit.hint_text(hint.clone()); + // Use RichText with gray color for proper hint text styling + let hint_text = egui::RichText::new(hint).color(Color32::GRAY); + text_edit = text_edit.hint_text(hint_text); } if let Some(width) = self.desired_width { @@ -398,7 +400,7 @@ mod tests { #[test] fn test_initialization_with_non_zero_amount_and_unit() { // Test that AmountInput correctly initializes from an existing amount - let amount = Amount::new_dash(1.5); // 1.5 DASH + let amount = Amount::new_dash(1.5); // 1.5 DASH assert_eq!(amount.unit_name(), Some("DASH")); assert_eq!(format!("{}", amount), "1.5 DASH"); diff --git a/src/ui/components/contract_chooser_panel.rs b/src/ui/components/contract_chooser_panel.rs index b5c8add4a..1048009de 100644 --- a/src/ui/components/contract_chooser_panel.rs +++ b/src/ui/components/contract_chooser_panel.rs @@ -225,6 +225,7 @@ pub fn add_contract_chooser_panel( Some("keyword_search") => "Keyword Search".to_string(), Some("token_history") => "Token History".to_string(), Some("withdrawals") => "Withdrawals".to_string(), + Some("dashpay") => "DashPay".to_string(), Some(alias) => alias.to_string(), None => contract_id.clone(), }; diff --git a/src/ui/components/contracts_subscreen_chooser_panel.rs b/src/ui/components/dashpay_subscreen_chooser_panel.rs similarity index 50% rename from src/ui/components/contracts_subscreen_chooser_panel.rs rename to src/ui/components/dashpay_subscreen_chooser_panel.rs index 8b6768932..a594bf22b 100644 --- a/src/ui/components/contracts_subscreen_chooser_panel.rs +++ b/src/ui/components/dashpay_subscreen_chooser_panel.rs @@ -1,62 +1,43 @@ use crate::app::AppAction; use crate::context::AppContext; +use crate::ui::RootScreenType; +use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; use crate::ui::theme::{DashColors, Shadow, Shape, Spacing, Typography}; -use crate::ui::{self, RootScreenType}; use egui::{Context, Frame, Margin, RichText, SidePanel}; +use std::sync::Arc; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ContractsSubscreen { - Contracts, - DPNS, - Dashpay, -} - -impl ContractsSubscreen { - pub fn display_name(&self) -> &'static str { - match self { - ContractsSubscreen::Contracts => "All Contracts", - ContractsSubscreen::DPNS => "DPNS", - ContractsSubscreen::Dashpay => "Dashpay", - } - } -} - -pub fn add_contracts_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext) -> AppAction { +pub fn add_dashpay_subscreen_chooser_panel( + ctx: &Context, + app_context: &Arc, + current_subscreen: DashPaySubscreen, +) -> AppAction { let mut action = AppAction::None; + let dark_mode = ctx.style().visuals.dark_mode; - let subscreens = vec![ - ContractsSubscreen::Contracts, - ContractsSubscreen::DPNS, - ContractsSubscreen::Dashpay, - ]; + // Build subscreens list - Payment History requires SPV which is dev mode only + let mut subscreens = vec![DashPaySubscreen::Profile, DashPaySubscreen::Contacts]; - // Determine active selection from settings; default to Contracts - let active_screen = match app_context.get_settings() { - Ok(Some(settings)) => match settings.root_screen_type { - ui::RootScreenType::RootScreenDocumentQuery => ContractsSubscreen::Contracts, - ui::RootScreenType::RootScreenDPNSActiveContests - | ui::RootScreenType::RootScreenDPNSPastContests - | ui::RootScreenType::RootScreenDPNSOwnedNames - | ui::RootScreenType::RootScreenDPNSScheduledVotes => ContractsSubscreen::DPNS, - ui::RootScreenType::RootScreenDashpay => ContractsSubscreen::Dashpay, - _ => ContractsSubscreen::Contracts, - }, - _ => ContractsSubscreen::Contracts, - }; + // Only show Payment History in developer mode (requires SPV) + if app_context.is_developer_mode() { + subscreens.push(DashPaySubscreen::Payments); + } - let dark_mode = ctx.style().visuals.dark_mode; + subscreens.push(DashPaySubscreen::ProfileSearch); + + let active_screen = current_subscreen; - SidePanel::left("contracts_subscreen_chooser_panel") - .resizable(false) + SidePanel::left("dashpay_subscreen_chooser_panel") .default_width(270.0) .frame( Frame::new() - .fill(DashColors::background(dark_mode)) - .inner_margin(Margin::symmetric(10, 10)), + .fill(DashColors::background(dark_mode)) // Light background instead of transparent + .inner_margin(Margin::symmetric(10, 10)), // Add margins for island effect ) .show(ctx, |ui| { + // Fill the entire available height let available_height = ui.available_height(); + // Create an island panel with rounded edges that fills the height Frame::new() .fill(DashColors::surface(dark_mode)) .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) @@ -64,21 +45,25 @@ pub fn add_contracts_subscreen_chooser_panel(ctx: &Context, app_context: &AppCon .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) .shadow(Shadow::elevated()) .show(ui, |ui| { + // Account for both outer margin (10px * 2) and inner margin ui.set_min_height(available_height - 2.0 - (Spacing::XL * 2.0)); + // Display subscreen names ui.vertical(|ui| { - ui.label( - RichText::new("Contracts") - .font(Typography::heading_small()) - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(Spacing::MD); + ui.add_space(Spacing::SM); for subscreen in subscreens { let is_active = active_screen == subscreen; + let display_name = match subscreen { + DashPaySubscreen::Contacts => "Contacts", + DashPaySubscreen::Profile => "My Profile", + DashPaySubscreen::Payments => "Payment History", + DashPaySubscreen::ProfileSearch => "Search Profiles", + }; + let button = if is_active { egui::Button::new( - RichText::new(subscreen.display_name()) + RichText::new(display_name) .color(DashColors::WHITE) .size(Typography::SCALE_SM), ) @@ -88,7 +73,7 @@ pub fn add_contracts_subscreen_chooser_panel(ctx: &Context, app_context: &AppCon .min_size(egui::Vec2::new(150.0, 28.0)) } else { egui::Button::new( - RichText::new(subscreen.display_name()) + RichText::new(display_name) .color(DashColors::text_primary(dark_mode)) .size(Typography::SCALE_SM), ) @@ -98,30 +83,37 @@ pub fn add_contracts_subscreen_chooser_panel(ctx: &Context, app_context: &AppCon .min_size(egui::Vec2::new(150.0, 28.0)) }; + // Show the subscreen name as a clickable option if ui.add(button).clicked() { - action = match subscreen { - ContractsSubscreen::Contracts => { - AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenDocumentQuery, + // Handle navigation based on which subscreen is selected + match subscreen { + DashPaySubscreen::Contacts => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenDashPayContacts, + ) + } + DashPaySubscreen::Profile => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenDashPayProfile, ) } - ContractsSubscreen::DPNS => { - AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenDPNSActiveContests, + DashPaySubscreen::Payments => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenDashPayPayments, ) } - ContractsSubscreen::Dashpay => { - AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenDashpay, + DashPaySubscreen::ProfileSearch => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenDashPayProfileSearch, ) } - }; + } } ui.add_space(Spacing::SM); } }); - }); + }); // Close the island frame }); action diff --git a/src/ui/components/dpns_subscreen_chooser_panel.rs b/src/ui/components/dpns_subscreen_chooser_panel.rs index 34210c79d..be3249308 100644 --- a/src/ui/components/dpns_subscreen_chooser_panel.rs +++ b/src/ui/components/dpns_subscreen_chooser_panel.rs @@ -47,12 +47,7 @@ pub fn add_dpns_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext) .show(ui, |ui| { ui.set_min_height(available_height - 2.0 - (Spacing::XL * 2.0)); ui.vertical(|ui| { - ui.label( - RichText::new("DPNS Subscreens") - .font(Typography::heading_small()) - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(Spacing::MD); + ui.add_space(Spacing::SM); for subscreen in subscreens { let is_active = active_screen == subscreen; diff --git a/src/ui/components/entropy_grid.rs b/src/ui/components/entropy_grid.rs index c70b99301..8f7b2012d 100644 --- a/src/ui/components/entropy_grid.rs +++ b/src/ui/components/entropy_grid.rs @@ -1,3 +1,4 @@ +use crate::ui::theme::DashColors; use bip39::rand::{self, Rng}; use egui::{Button, Color32, Grid, Ui, Vec2}; @@ -27,7 +28,7 @@ impl U256EntropyGrid { /// Render the UI and allow users to modify bits pub fn ui(&mut self, ui: &mut Ui) -> [u8; 32] { - ui.heading("1. Hover over this view to create extra randomness for the seed phrase."); + ui.heading("1. Move your cursor over this grid to create extra randomness for your wallet's seed phrase."); // Add padding around the grid ui.add_space(10.0); // Top padding @@ -58,13 +59,24 @@ impl U256EntropyGrid { let byte_index = (bit_position / 8) as usize; let bit_in_byte = (bit_position % 8) as usize; - // Determine the bit value (1 = Black, 0 = White). + // Determine the bit value and colors based on theme let bit_value = (self.random_number[byte_index] >> bit_in_byte) & 1 == 1; + let dark_mode = ui.ctx().style().visuals.dark_mode; let color = if bit_value { - Color32::BLACK + // On squares: Deep Blue in light mode, muted Dash Blue in dark mode + if dark_mode { + DashColors::DASH_BLUE.gamma_multiply(0.85) + } else { + DashColors::DEEP_BLUE + } } else { - Color32::WHITE + // Off squares: gray in dark mode, white in light mode + if dark_mode { + Color32::from_rgb(80, 80, 80) + } else { + Color32::WHITE + } }; // Create a button with the appropriate size and color. @@ -86,14 +98,6 @@ impl U256EntropyGrid { ui.add_space(10.0); // Right padding }); - ui.add_space(10.0); // Bottom padding - - // Display the current random number in hex. - ui.label(format!( - "User number is [{}], this will be added to a random number to add extra entropy and ensure security.", - hex::encode(self.random_number) - )); - self.random_number } diff --git a/src/ui/components/identity_selector.rs b/src/ui/components/identity_selector.rs index a30227dd3..9b72d0e44 100644 --- a/src/ui/components/identity_selector.rs +++ b/src/ui/components/identity_selector.rs @@ -155,13 +155,8 @@ impl<'a> IdentitySelector<'a> { if let Some(self_identity) = &mut self.identity { if let Some(new_identity) = selected_identity { self_identity.replace(new_identity.clone()); - tracing::trace!( - "updating selected identity: {:?} {:?}", - new_identity, - self.identity, - ); } else { - self_identity.take(); // Clear the existing identity reference if it was None + self_identity.take(); }; } } @@ -253,10 +248,17 @@ impl<'a> Widget for IdentitySelector<'a> { combo_changed }); - // Text edit field for manual entry - let text_response = TextEdit::singleline(self.identity_str) - .interactive(self.other_option) - .ui(ui); + // Text edit field for manual entry (only show if other_option is enabled) + let text_response = if self.other_option { + ui.vertical(|ui| { + ui.add_space(13.0); + TextEdit::singleline(self.identity_str).ui(ui) + }) + .inner + } else { + // Create a dummy response that never changes when other_option is disabled + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()) + }; // Handle identity selection updates after combo box and text input let combo_changed = combo_response.inner.unwrap_or(false); diff --git a/src/ui/components/info_popup.rs b/src/ui/components/info_popup.rs new file mode 100644 index 000000000..8fc393afd --- /dev/null +++ b/src/ui/components/info_popup.rs @@ -0,0 +1,195 @@ +use crate::ui::theme::{ComponentStyles, DashColors, Shape}; +use egui::{InnerResponse, Ui, WidgetText}; +use egui_commonmark::{CommonMarkCache, CommonMarkViewer}; + +/// A simple info popup that displays information with a close button +/// Similar to ConfirmationDialog but for showing informational content only +/// Supports both plain text and markdown rendering +pub struct InfoPopup { + title: WidgetText, + message: String, + close_text: WidgetText, + is_open: bool, + markdown: bool, +} + +impl InfoPopup { + /// Create a new info popup with the given title and message + pub fn new(title: impl Into, message: impl Into) -> Self { + Self { + title: title.into(), + message: message.into(), + close_text: "Close".into(), + is_open: true, + markdown: false, + } + } + + /// Set the text for the close button + pub fn close_text(mut self, text: impl Into) -> Self { + self.close_text = text.into(); + self + } + + /// Set whether the popup is open + pub fn open(mut self, open: bool) -> Self { + self.is_open = open; + self + } + + /// Enable markdown rendering for the message content + pub fn markdown(mut self, enable: bool) -> Self { + self.markdown = enable; + self + } + + /// Show the popup and return whether it was closed + /// Returns true if the popup was closed (user clicked Close, X button, or Escape) + pub fn show(&mut self, ui: &mut Ui) -> InnerResponse { + let mut is_open = self.is_open; + + if !is_open { + return InnerResponse::new( + false, + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), + ); + } + + // Draw dark overlay behind the popup for better visibility + let screen_rect = ui.ctx().screen_rect(); + let painter = ui.ctx().layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("info_popup_overlay"), + )); + painter.rect_filled( + screen_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), + ); + + let mut was_closed = false; + let is_markdown = self.markdown; + let message = self.message.clone(); + + let window_response = egui::Window::new(self.title.clone()) + .collapsible(false) + .resizable(is_markdown) // Allow resizing for markdown content + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .open(&mut is_open) + .frame(egui::Frame { + inner_margin: egui::Margin::same(16), + outer_margin: egui::Margin::same(0), + corner_radius: egui::CornerRadius::same(8), + shadow: egui::epaint::Shadow { + offset: [0, 8], + blur: 16, + spread: 0, + color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), + }, + fill: ui.style().visuals.window_fill, + stroke: egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), + ), + }) + .show(ui.ctx(), |ui| { + // Set minimum and maximum width for the popup + ui.set_min_width(300.0); + if is_markdown { + ui.set_max_width(600.0); + } else { + ui.set_max_width(500.0); + } + + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Message content + ui.add_space(10.0); + + if is_markdown { + // Render markdown content with scroll area + egui::ScrollArea::vertical() + .max_height(400.0) + .show(ui, |ui| { + let mut cache = CommonMarkCache::default(); + CommonMarkViewer::new().show(ui, &mut cache, &message); + }); + } else { + // Render plain text with tight spacing + // Reduce item spacing for tighter layout + ui.spacing_mut().item_spacing.y = 2.0; + + // Split on double newlines (paragraphs) and render with controlled spacing + let paragraphs: Vec<&str> = message.split("\n\n").collect(); + for (i, paragraph) in paragraphs.iter().enumerate() { + // Replace single newlines with spaces for proper wrapping within paragraphs + let text = paragraph.replace('\n', " "); + ui.label( + egui::RichText::new(text).color(DashColors::text_primary(dark_mode)), + ); + // Add small space between paragraphs (but not after the last one) + if i < paragraphs.len() - 1 { + ui.add_space(4.0); + } + } + } + + ui.add_space(20.0); + + // Close button + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let close_label = if let WidgetText::RichText(rich_text) = &self.close_text + { + rich_text.clone() + } else { + egui::RichText::new(self.close_text.text()) + .color(ComponentStyles::primary_button_text()) + .into() + }; + + let close_button = egui::Button::new(close_label) + .fill(ComponentStyles::primary_button_fill()) + .stroke(ComponentStyles::primary_button_stroke()) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_SM)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui + .add(close_button) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + was_closed = true; + } + }); + }); + }); + + // Handle window being closed via X button + if !is_open { + was_closed = true; + } + + // Handle Escape key press + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + was_closed = true; + } + + // Update the popup's state + self.is_open = !was_closed; + + if let Some(window_response) = window_response { + InnerResponse::new(was_closed, window_response.response) + } else { + InnerResponse::new( + was_closed, + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), + ) + } + } + + /// Check if the popup is currently open + pub fn is_open(&self) -> bool { + self.is_open + } +} diff --git a/src/ui/components/left_panel.rs b/src/ui/components/left_panel.rs index 7e084c0a4..235677d67 100644 --- a/src/ui/components/left_panel.rs +++ b/src/ui/components/left_panel.rs @@ -48,6 +48,65 @@ fn load_icon(ctx: &Context, path: &str) -> Option { }) } +// Function to load an SVG as a texture with specified dimensions +pub fn load_svg_icon(ctx: &Context, path: &str, width: u32, height: u32) -> Option { + let cache_key = format!("{}_{}_{}", path, width, height); + // Use ctx.data_mut to check if texture is already cached + ctx.data_mut(|d| d.get_temp::(egui::Id::new(&cache_key))) + .or_else(|| { + // Only do expensive operations if texture is not cached + if let Some(content) = Assets::get(path) { + // Parse SVG + let options = resvg::usvg::Options::default(); + let tree = match resvg::usvg::Tree::from_data(&content.data, &options) { + Ok(tree) => tree, + Err(e) => { + eprintln!("Failed to parse SVG at {}: {}", path, e); + return None; + } + }; + + // Create a pixmap to render into + let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)?; + + // Calculate scale to fit the SVG into the desired dimensions + let svg_size = tree.size(); + let scale_x = width as f32 / svg_size.width(); + let scale_y = height as f32 / svg_size.height(); + let scale = scale_x.min(scale_y); + + // Center the SVG + let offset_x = (width as f32 - svg_size.width() * scale) / 2.0; + let offset_y = (height as f32 - svg_size.height() * scale) / 2.0; + + let transform = resvg::tiny_skia::Transform::from_scale(scale, scale) + .post_translate(offset_x, offset_y); + + // Render the SVG + resvg::render(&tree, transform, &mut pixmap.as_mut()); + + // Convert to egui texture + let pixels = pixmap.data().to_vec(); + let texture = ctx.load_texture( + &cache_key, + egui::ColorImage::from_rgba_unmultiplied( + [width as usize, height as usize], + &pixels, + ), + egui::TextureOptions::LINEAR, + ); + + // Cache the texture + ctx.data_mut(|d| d.insert_temp(egui::Id::new(&cache_key), texture.clone())); + + Some(texture) + } else { + eprintln!("SVG not found in embedded assets at path: {}", path); + None + } + }) +} + pub fn add_left_panel( ctx: &Context, app_context: &Arc, @@ -57,6 +116,11 @@ pub fn add_left_panel( // Define the button details directly in this function let buttons = [ + ( + "Dashpay", + RootScreenType::RootScreenDashPayProfile, + "dashpay.png", + ), ( "Identities", RootScreenType::RootScreenIdentities, @@ -89,12 +153,11 @@ pub fn add_left_panel( ), ]; - let panel_width = 60.0 + (Spacing::MD * 2.0); // Button width + margins - let dark_mode = ctx.style().visuals.dark_mode; SidePanel::left("left_panel") - .default_width(panel_width + 20.0) // Add extra width for margins + .min_width(140.0) + .max_width(140.0) .resizable(false) .frame( Frame::new() @@ -117,7 +180,7 @@ pub fn add_left_panel( bottom_reserved += 22.0; // network label + spacing } if app_context.is_developer_mode() { - bottom_reserved += Spacing::MD + 16.0; // dev label area + bottom_reserved += 2.0 + 16.0; // dev label area (spacing + label height) } StripBuilder::new(ui) @@ -133,7 +196,49 @@ pub fn add_left_panel( ui.vertical_centered(|ui| { for (label, screen_type, icon_path) in buttons.iter() { let texture: Option = load_icon(ctx, icon_path); - let is_selected = selected_screen == *screen_type; + // Check if this button's category is selected + let is_selected = match *screen_type { + // DashPay: check if any DashPay subscreen is selected + RootScreenType::RootScreenDashPayProfile => matches!( + selected_screen, + RootScreenType::RootScreenDashpay + | RootScreenType::RootScreenDashPayProfile + | RootScreenType::RootScreenDashPayContacts + | RootScreenType::RootScreenDashPayPayments + | RootScreenType::RootScreenDashPayProfileSearch + ), + // Tokens: check if any Tokens subscreen is selected + RootScreenType::RootScreenMyTokenBalances => matches!( + selected_screen, + RootScreenType::RootScreenMyTokenBalances + | RootScreenType::RootScreenTokenSearch + | RootScreenType::RootScreenTokenCreator + ), + // Tools: check if any Tools subscreen is selected + RootScreenType::RootScreenToolsPlatformInfoScreen => matches!( + selected_screen, + RootScreenType::RootScreenToolsPlatformInfoScreen + | RootScreenType::RootScreenToolsProofLogScreen + | RootScreenType::RootScreenToolsTransitionVisualizerScreen + | RootScreenType::RootScreenToolsDocumentVisualizerScreen + | RootScreenType::RootScreenToolsProofVisualizerScreen + | RootScreenType::RootScreenToolsMasternodeListDiffScreen + | RootScreenType::RootScreenToolsContractVisualizerScreen + | RootScreenType::RootScreenToolsGroveSTARKScreen + | RootScreenType::RootScreenToolsAddressBalanceScreen + ), + // Contracts: check if any Contracts/DPNS subscreen is selected + RootScreenType::RootScreenDocumentQuery => matches!( + selected_screen, + RootScreenType::RootScreenDocumentQuery + | RootScreenType::RootScreenDPNSActiveContests + | RootScreenType::RootScreenDPNSPastContests + | RootScreenType::RootScreenDPNSOwnedNames + | RootScreenType::RootScreenDPNSScheduledVotes + ), + // All other screens: exact match + _ => selected_screen == *screen_type, + }; let button_color = if is_selected { Color32::WHITE @@ -208,7 +313,8 @@ pub fn add_left_panel( egui::Layout::bottom_up(egui::Align::Center), |ui| { // Dash logo at the very bottom - if let Some(dash_texture) = load_icon(ctx, "dash.png") { + // Use 100x40 for rendering (2x for crisp display), then scale down + if let Some(dash_texture) = load_svg_icon(ctx, "dashlogo.svg", 100, 40) { if app_context.network == Network::Dash { ui.add_space(Spacing::SM); } @@ -258,10 +364,10 @@ pub fn add_left_panel( ); } - // Dev mode label (above network label if present) + // Dev mode label (below network label if present) if app_context.is_developer_mode() { - ui.add_space(Spacing::MD); - let dev_label = egui::RichText::new("🔧 Dev mode") + ui.add_space(2.0); + let dev_label = egui::RichText::new("🔧 Dev Mode") .color(DashColors::GRADIENT_PURPLE) .size(12.0); if ui.label(dev_label).clicked() { diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index ac63e7f56..d25b6bdcc 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -2,10 +2,11 @@ pub mod amount_input; pub mod component_trait; pub mod confirmation_dialog; pub mod contract_chooser_panel; -pub mod contracts_subscreen_chooser_panel; +pub mod dashpay_subscreen_chooser_panel; pub mod dpns_subscreen_chooser_panel; pub mod entropy_grid; pub mod identity_selector; +pub mod info_popup; pub mod left_panel; pub mod left_wallet_panel; pub mod styled; @@ -13,6 +14,7 @@ pub mod tokens_subscreen_chooser_panel; pub mod tools_subscreen_chooser_panel; pub mod top_panel; pub mod wallet_unlock; +pub mod wallet_unlock_popup; // Re-export the main traits for easy access pub use component_trait::{Component, ComponentResponse}; diff --git a/src/ui/components/tokens_subscreen_chooser_panel.rs b/src/ui/components/tokens_subscreen_chooser_panel.rs index add77bd30..c2f2b99b5 100644 --- a/src/ui/components/tokens_subscreen_chooser_panel.rs +++ b/src/ui/components/tokens_subscreen_chooser_panel.rs @@ -46,12 +46,7 @@ pub fn add_tokens_subscreen_chooser_panel(ctx: &Context, app_context: &AppContex ui.set_min_height(available_height - 2.0 - (Spacing::XL * 2.0)); // Display subscreen names ui.vertical(|ui| { - ui.label( - RichText::new("Tokens") - .font(Typography::heading_small()) - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(Spacing::MD); + ui.add_space(Spacing::SM); for subscreen in subscreens { let is_active = active_screen == subscreen; diff --git a/src/ui/components/tools_subscreen_chooser_panel.rs b/src/ui/components/tools_subscreen_chooser_panel.rs index 05a9db9b6..ca2b9b130 100644 --- a/src/ui/components/tools_subscreen_chooser_panel.rs +++ b/src/ui/components/tools_subscreen_chooser_panel.rs @@ -2,11 +2,12 @@ use crate::context::AppContext; use crate::ui::RootScreenType; use crate::ui::theme::{DashColors, Shadow, Shape, Spacing, Typography}; use crate::{app::AppAction, ui}; -use egui::{Context, Frame, Margin, RichText, SidePanel}; +use egui::{Context, Frame, Margin, RichText, ScrollArea, SidePanel}; #[derive(PartialEq)] pub enum ToolsSubscreen { PlatformInfo, + AddressBalance, ProofLog, TransactionViewer, DocumentViewer, @@ -14,12 +15,14 @@ pub enum ToolsSubscreen { ContractViewer, GroveSTARK, MasternodeListDiff, + DPNS, } impl ToolsSubscreen { pub fn display_name(&self) -> &'static str { match self { Self::PlatformInfo => "Platform info", + Self::AddressBalance => "Address balance", Self::ProofLog => "Proof logs", Self::TransactionViewer => "Transaction deserializer", Self::ProofViewer => "Proof deserializer", @@ -27,6 +30,7 @@ impl ToolsSubscreen { Self::ContractViewer => "Contract deserializer", Self::GroveSTARK => "ZK Proofs", Self::MasternodeListDiff => "Masternode list diff inspector", + Self::DPNS => "DPNS", } } } @@ -37,6 +41,7 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext let subscreens = vec![ ToolsSubscreen::PlatformInfo, + ToolsSubscreen::AddressBalance, ToolsSubscreen::ProofLog, ToolsSubscreen::ProofViewer, ToolsSubscreen::TransactionViewer, @@ -44,11 +49,15 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext ToolsSubscreen::ContractViewer, ToolsSubscreen::GroveSTARK, ToolsSubscreen::MasternodeListDiff, + ToolsSubscreen::DPNS, ]; let active_screen = match app_context.get_settings() { Ok(Some(settings)) => match settings.root_screen_type { ui::RootScreenType::RootScreenToolsPlatformInfoScreen => ToolsSubscreen::PlatformInfo, + ui::RootScreenType::RootScreenToolsAddressBalanceScreen => { + ToolsSubscreen::AddressBalance + } ui::RootScreenType::RootScreenToolsProofLogScreen => ToolsSubscreen::ProofLog, ui::RootScreenType::RootScreenToolsTransitionVisualizerScreen => { ToolsSubscreen::TransactionViewer @@ -64,6 +73,10 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext ToolsSubscreen::MasternodeListDiff } ui::RootScreenType::RootScreenToolsGroveSTARKScreen => ToolsSubscreen::GroveSTARK, + ui::RootScreenType::RootScreenDPNSActiveContests + | ui::RootScreenType::RootScreenDPNSPastContests + | ui::RootScreenType::RootScreenDPNSOwnedNames + | ui::RootScreenType::RootScreenDPNSScheduledVotes => ToolsSubscreen::DPNS, _ => ToolsSubscreen::PlatformInfo, }, _ => ToolsSubscreen::PlatformInfo, // Fallback to Active screen if settings unavailable @@ -87,13 +100,8 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext .shadow(Shadow::elevated()) .show(ui, |ui| { ui.set_min_height(available_height - 2.0 - (Spacing::XL * 2.0)); - ui.vertical(|ui| { - ui.label( - RichText::new("Tools") - .font(Typography::heading_small()) - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(Spacing::MD); + ScrollArea::vertical().show(ui, |ui| { + ui.add_space(Spacing::SM); for subscreen in subscreens { let is_active = active_screen == subscreen; @@ -129,6 +137,11 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext RootScreenType::RootScreenToolsPlatformInfoScreen, ) } + ToolsSubscreen::AddressBalance => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenToolsAddressBalanceScreen, + ) + } ToolsSubscreen::ProofLog => { action = AppAction::SetMainScreen( RootScreenType::RootScreenToolsProofLogScreen, @@ -162,6 +175,10 @@ pub fn add_tools_subscreen_chooser_panel(ctx: &Context, app_context: &AppContext action = AppAction::SetMainScreen( RootScreenType::RootScreenToolsGroveSTARKScreen) } + ToolsSubscreen::DPNS => { + action = AppAction::SetMainScreen( + RootScreenType::RootScreenDPNSActiveContests) + } } } ui.add_space(Spacing::SM); diff --git a/src/ui/components/top_panel.rs b/src/ui/components/top_panel.rs index 84e606194..031817d92 100644 --- a/src/ui/components/top_panel.rs +++ b/src/ui/components/top_panel.rs @@ -6,9 +6,7 @@ use crate::context::AppContext; use crate::ui::ScreenType; use crate::ui::theme::{DashColors, Shadow, Shape}; use dash_sdk::dashcore_rpc::dashcore::Network; -use egui::{ - Align, Color32, Context, Frame, Margin, RichText, Stroke, TextureHandle, TopBottomPanel, Ui, -}; +use egui::{Color32, Context, Frame, Margin, RichText, Stroke, TextureHandle, TopBottomPanel, Ui}; use rust_embed::RustEmbed; use std::sync::Arc; @@ -231,14 +229,8 @@ pub fn add_top_panel( .frame( Frame::new() .fill(DashColors::background(dark_mode)) - .inner_margin(Margin { - left: 10, - right: 10, - top: 10, - bottom: 10, - }), + .inner_margin(Margin::same(10)), // 10px margin on all sides ) - .exact_height(76.0) .show(ctx, |ui| { // Create an island panel with rounded edges Frame::new() @@ -253,33 +245,20 @@ pub fn add_top_panel( .corner_radius(egui::CornerRadius::same(Shape::RADIUS_LG)) .shadow(Shadow::elevated()) .show(ui, |ui| { - // Load Dash logo - // let dash_logo_texture: Option = load_icon(ctx, "dash.png"); - - ui.columns(3, |columns| { + // Use columns for better control over layout + ui.columns(2, |columns| { // Left column: connection indicator and location columns[0].with_layout( - egui::Layout::left_to_right(egui::Align::Center) - .with_cross_align(Align::Center), + egui::Layout::left_to_right(egui::Align::Center), |ui| { action |= add_connection_indicator(ui, app_context); action |= add_location_view(ui, location, dark_mode); }, ); - // Center column: Placeholder for future logo placement + // Right column: buttons (right-aligned) columns[1].with_layout( - egui::Layout::centered_and_justified(egui::Direction::TopDown), - |ui| { - // Placeholder - logo moved back to left panel for now - ui.label(""); - }, - ); - - // Right column: action buttons (right-aligned) - columns[2].with_layout( - egui::Layout::right_to_left(egui::Align::Center) - .with_cross_align(Align::Center), + egui::Layout::right_to_left(egui::Align::Center), |ui| { // Separate contract and document-related actions let mut contract_actions = Vec::new(); @@ -331,6 +310,7 @@ pub fn add_top_panel( let resp = ui.add(docs_btn); let popup_id = ui.make_persistent_id("docs_popup"); + let dark_mode = ui.ctx().style().visuals.dark_mode; egui::Popup::new( popup_id, ui.ctx().clone(), @@ -341,12 +321,23 @@ pub fn add_top_panel( resp.clicked().then_some(egui::SetOpenCommand::Toggle), ) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .frame(egui::Frame::popup(ui.style()).fill(if dark_mode { + Color32::from_rgb(40, 40, 40) + } else { + Color32::WHITE + })) .show(|ui| { ui.set_min_width(150.0); for (text, da) in doc_actions { - if ui.button(text).clicked() { + if ui + .add_sized( + [ui.available_width(), 0.0], + egui::Button::new(text), + ) + .clicked() + { action = da.create_action(app_context); - // ui.close(); + ui.close(); } } }); @@ -368,6 +359,7 @@ pub fn add_top_panel( let popup_id = ui.auto_id_with("contracts_popup"); let resp = ui.add(contracts_btn); + let dark_mode = ui.ctx().style().visuals.dark_mode; egui::Popup::new( popup_id, ui.ctx().clone(), @@ -378,10 +370,21 @@ pub fn add_top_panel( resp.clicked().then_some(egui::SetOpenCommand::Toggle), ) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .frame(egui::Frame::popup(ui.style()).fill(if dark_mode { + Color32::from_rgb(40, 40, 40) + } else { + Color32::WHITE + })) .show(|ui| { ui.set_min_width(150.0); for (text, ca) in contract_actions { - if ui.button(text).clicked() { + if ui + .add_sized( + [ui.available_width(), 0.0], + egui::Button::new(text), + ) + .clicked() + { action = ca.create_action(app_context); ui.close(); } diff --git a/src/ui/components/wallet_unlock.rs b/src/ui/components/wallet_unlock.rs index 5650673ac..d1d999a26 100644 --- a/src/ui/components/wallet_unlock.rs +++ b/src/ui/components/wallet_unlock.rs @@ -1,7 +1,8 @@ +use crate::context::AppContext; use crate::model::wallet::Wallet; use crate::ui::components::styled::StyledCheckbox; use eframe::epaint::Color32; -use egui::Ui; +use egui::{Frame, Margin, RichText, Ui}; use std::sync::{Arc, RwLock}; use zeroize::Zeroize; @@ -18,6 +19,8 @@ pub trait ScreenWithWalletUnlock { fn error_message(&self) -> Option<&String>; + fn app_context(&self) -> Arc; + fn should_ask_for_password(&mut self) -> bool { if let Some(wallet_guard) = self.selected_wallet_ref().clone() { let mut wallet = wallet_guard.write().unwrap(); @@ -43,6 +46,8 @@ pub trait ScreenWithWalletUnlock { } fn render_wallet_unlock(&mut self, ui: &mut Ui) -> bool { + let mut unlocked_wallet: Option>> = None; + if let Some(wallet_guard) = self.selected_wallet_ref().clone() { let mut wallet = wallet_guard.write().unwrap(); @@ -59,8 +64,6 @@ pub trait ScreenWithWalletUnlock { ui.add_space(5.0); - let mut unlocked = false; - // Capture necessary values before the closure let show_password = self.show_password(); let mut local_show_password = show_password; // Local copy of show_password @@ -107,7 +110,7 @@ pub trait ScreenWithWalletUnlock { match unlock_result { Ok(_) => { local_error_message = None; - unlocked = true; + unlocked_wallet = Some(wallet_guard.clone()); } Err(_) => { if let Some(hint) = wallet.password_hint() { @@ -129,14 +132,36 @@ pub trait ScreenWithWalletUnlock { self.set_error_message(local_error_message); // Display error message if the password was incorrect - if let Some(error_message) = self.error_message() { + if let Some(error_message) = self.error_message().cloned() { ui.add_space(5.0); - ui.colored_label(Color32::RED, error_message); + let error_color = Color32::from_rgb(255, 100, 100); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", error_message)) + .color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.set_error_message(None); + } + }); + }); } - - return unlocked; } } + + if let Some(wallet_arc) = unlocked_wallet { + let app_context = self.app_context(); + app_context.handle_wallet_unlocked(&wallet_arc); + return true; + } + false } } diff --git a/src/ui/components/wallet_unlock_popup.rs b/src/ui/components/wallet_unlock_popup.rs new file mode 100644 index 000000000..afdf00464 --- /dev/null +++ b/src/ui/components/wallet_unlock_popup.rs @@ -0,0 +1,270 @@ +use crate::context::AppContext; +use crate::model::wallet::Wallet; +use crate::ui::components::styled::StyledCheckbox; +use crate::ui::theme::{ComponentStyles, DashColors, Shape}; +use egui; +use std::sync::{Arc, RwLock}; +use zeroize::Zeroize; + +/// Result of showing the wallet unlock popup +#[derive(Debug, Clone, PartialEq)] +pub enum WalletUnlockResult { + /// Popup is still open, no action taken yet + Pending, + /// User successfully unlocked the wallet + Unlocked, + /// User cancelled the unlock + Cancelled, +} + +/// A popup dialog for unlocking a wallet with password +/// Similar to InfoPopup and ConfirmationDialog but specialized for wallet unlock flow +pub struct WalletUnlockPopup { + is_open: bool, + password: String, + show_password: bool, + error_message: Option, +} + +impl Default for WalletUnlockPopup { + fn default() -> Self { + Self::new() + } +} + +impl WalletUnlockPopup { + /// Create a new wallet unlock popup + pub fn new() -> Self { + Self { + is_open: false, + password: String::new(), + show_password: false, + error_message: None, + } + } + + /// Open the popup + pub fn open(&mut self) { + self.is_open = true; + self.password.clear(); + self.error_message = None; + } + + /// Close the popup + pub fn close(&mut self) { + self.is_open = false; + self.password.zeroize(); + self.error_message = None; + } + + /// Check if the popup is currently open + pub fn is_open(&self) -> bool { + self.is_open + } + + /// Show the popup and handle wallet unlock + /// Returns the result of the unlock attempt + pub fn show( + &mut self, + ctx: &egui::Context, + wallet: &Arc>, + app_context: &Arc, + ) -> WalletUnlockResult { + if !self.is_open { + return WalletUnlockResult::Pending; + } + + // Draw dark overlay behind the popup + let screen_rect = ctx.screen_rect(); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("wallet_unlock_popup_overlay"), + )); + painter.rect_filled( + screen_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), + ); + + let mut result = WalletUnlockResult::Pending; + + // Get wallet alias for display + let wallet_alias = wallet + .read() + .ok() + .and_then(|w| w.alias.clone()) + .unwrap_or_else(|| "Wallet".to_string()); + + let mut is_open = true; + + egui::Window::new("Unlock Wallet") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .open(&mut is_open) + .frame(egui::Frame { + inner_margin: egui::Margin::same(20), + outer_margin: egui::Margin::same(0), + corner_radius: egui::CornerRadius::same(8), + shadow: egui::epaint::Shadow { + offset: [0, 8], + blur: 16, + spread: 0, + color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), + }, + fill: ctx.style().visuals.window_fill, + stroke: egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), + ), + }) + .show(ctx, |ui| { + ui.set_min_width(350.0); + ui.set_max_width(400.0); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Title/description + ui.label( + egui::RichText::new(format!("Enter password to unlock \"{}\":", wallet_alias)) + .color(DashColors::text_primary(dark_mode)), + ); + + ui.add_space(12.0); + + // Password input + let mut attempt_unlock = false; + + let password_response = ui.add( + egui::TextEdit::singleline(&mut self.password) + .password(!self.show_password) + .hint_text("Enter password") + .desired_width(f32::INFINITY) + .text_color(DashColors::text_primary(dark_mode)) + .background_color(DashColors::input_background(dark_mode)), + ); + + // Focus the password field when popup opens + if password_response.gained_focus() || self.password.is_empty() { + password_response.request_focus(); + } + + // Check for Enter key + if password_response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + attempt_unlock = true; + } + + ui.add_space(8.0); + + // Show password checkbox + ui.horizontal(|ui| { + StyledCheckbox::new(&mut self.show_password, "Show password").show(ui); + }); + + // Error message + if let Some(error) = &self.error_message { + ui.add_space(8.0); + ui.colored_label(egui::Color32::from_rgb(220, 80, 80), error); + } + + ui.add_space(16.0); + + // Buttons + ui.horizontal(|ui| { + // Cancel button + let cancel_button = egui::Button::new( + egui::RichText::new("Cancel").color(DashColors::text_primary(dark_mode)), + ) + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::new( + 1.0, + DashColors::text_secondary(dark_mode), + )) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_SM)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui + .add(cancel_button) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + result = WalletUnlockResult::Cancelled; + self.close(); + } + + ui.add_space(8.0); + + // Unlock button + let unlock_button = egui::Button::new( + egui::RichText::new("Unlock").color(ComponentStyles::primary_button_text()), + ) + .fill(ComponentStyles::primary_button_fill()) + .stroke(ComponentStyles::primary_button_stroke()) + .corner_radius(egui::CornerRadius::same(Shape::RADIUS_SM)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui + .add(unlock_button) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + attempt_unlock = true; + } + }); + + // Attempt unlock if requested + if attempt_unlock { + let mut wallet_guard = wallet.write().unwrap(); + match wallet_guard.wallet_seed.open(&self.password) { + Ok(_) => { + // Notify app context that wallet was unlocked + drop(wallet_guard); // Release write lock before calling handle_wallet_unlocked + app_context.handle_wallet_unlocked(wallet); + result = WalletUnlockResult::Unlocked; + self.close(); + } + Err(_) => { + // Show error with hint if available + if let Some(hint) = wallet_guard.password_hint() { + self.error_message = + Some(format!("Incorrect password. Hint: {}", hint)); + } else { + self.error_message = Some("Incorrect password".to_string()); + } + self.password.zeroize(); + } + } + } + }); + + // Handle window being closed via X button + if !is_open { + result = WalletUnlockResult::Cancelled; + self.close(); + } + + // Handle Escape key + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + result = WalletUnlockResult::Cancelled; + self.close(); + } + + result + } +} + +/// Helper function to check if a wallet needs unlocking +pub fn wallet_needs_unlock(wallet: &Arc>) -> bool { + let wallet_guard = wallet.read().unwrap(); + wallet_guard.uses_password && !wallet_guard.is_open() +} + +/// Helper function to try opening a wallet without password (for wallets that don't use passwords) +pub fn try_open_wallet_no_password(wallet: &Arc>) -> Result<(), String> { + let mut wallet_guard = wallet.write().unwrap(); + if !wallet_guard.uses_password { + wallet_guard.wallet_seed.open_no_password() + } else { + Ok(()) + } +} diff --git a/src/ui/contracts_documents/add_contracts_screen.rs b/src/ui/contracts_documents/add_contracts_screen.rs index af2879347..924df137c 100644 --- a/src/ui/contracts_documents/add_contracts_screen.rs +++ b/src/ui/contracts_documents/add_contracts_screen.rs @@ -10,7 +10,7 @@ use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identifier::Identifier; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::TimestampMillis; -use eframe::egui::{self, Color32, Context, RichText, Ui}; +use eframe::egui::{self, Color32, Context, Frame, Margin, RichText, Ui}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -142,37 +142,37 @@ impl AddContractsScreen { // Clone the options to avoid borrowing self.add_contracts_status during the UI closure let options = self.maybe_found_contracts.clone(); - use egui::{Grid, vec2}; + use egui::vec2; - let mut clicked_idx: Option = None; // remember which row’s button was hit + let mut clicked_idx: Option = None; // remember which row's button was hit - Grid::new("found_contracts_grid") - .striped(false) - .num_columns(3) - .min_col_width(150.0) - .spacing(vec2(12.0, 6.0)) // [horiz, vert] spacing between cells - .show(ui, |ui| { - for (idx, id_string) in self.contract_ids_input.iter().enumerate() { - let trimmed = id_string.trim().to_string(); + for (idx, id_string) in self.contract_ids_input.iter().enumerate() { + let trimmed = id_string.trim().to_string(); - if options.contains(&trimmed) { - // ─ column 1: contract ID label ─────────────────────────────── - ui.colored_label(Color32::DARK_GREEN, &trimmed); + if options.contains(&trimmed) { + ui.horizontal(|ui| { + // ─ column 1: contract ID label ─────────────────────────────── + ui.colored_label(Color32::DARK_GREEN, &trimmed); - // ─ column 2: editable alias field ─────────────────────────── - ui.text_edit_singleline(&mut alias_inputs[idx]); + ui.add_space(12.0); - // ─ column 3: action button ────────────────────────────────── - if ui.button("Set Alias").clicked() { - clicked_idx = Some(idx); - } + // ─ column 2: editable alias field ─────────────────────────── + ui.add_sized( + vec2(150.0, 20.0), + egui::TextEdit::singleline(&mut alias_inputs[idx]), + ); + + ui.add_space(12.0); - ui.end_row(); // ← tells the grid we’ve finished this row - } else { - not_found.push(trimmed); + // ─ column 3: action button ────────────────────────────────── + if ui.button("Set Alias").clicked() { + clicked_idx = Some(idx); } - } - }); + }); + } else { + not_found.push(trimmed); + } + } // ─ handle the button click AFTER the grid so we can borrow &mut self safely ── if let Some(idx) = clicked_idx { @@ -323,12 +323,6 @@ impl ScreenLike for AddContractsScreen { crate::ui::RootScreenType::RootScreenDocumentQuery, ); - // Contracts sub-left panel - action |= crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel( - ctx, - &self.app_context, - ); - action |= island_central_panel(ctx, |ui| { ui.heading("Add Contracts"); ui.add_space(10.0); @@ -336,7 +330,24 @@ impl ScreenLike for AddContractsScreen { match &self.add_contracts_status { AddContractsStatus::NotStarted | AddContractsStatus::ErrorMessage(_) => { if let AddContractsStatus::ErrorMessage(msg) = &self.add_contracts_status { - ui.colored_label(Color32::RED, format!("Error: {}", msg)); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = msg.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", msg)).color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.add_contracts_status = AddContractsStatus::NotStarted; + } + }); + }); ui.add_space(10.0); } diff --git a/src/ui/contracts_documents/contracts_documents_screen.rs b/src/ui/contracts_documents/contracts_documents_screen.rs index f293b8621..5ecdf73a3 100644 --- a/src/ui/contracts_documents/contracts_documents_screen.rs +++ b/src/ui/contracts_documents/contracts_documents_screen.rs @@ -354,10 +354,7 @@ impl DocumentQueryScreen { "Fetching documents... Time taken so far: {} seconds", time_elapsed )); - ui.add( - egui::widgets::Spinner::default() - .color(Color32::from_rgb(0, 128, 255)), - ); + ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); }); } DocumentQueryStatus::Complete => match self.document_display_mode { @@ -685,12 +682,6 @@ impl ScreenLike for DocumentQueryScreen { RootScreenType::RootScreenDocumentQuery, ); - // Contracts sub-left panel: DPNS / Dashpay / Contracts (default) - action |= crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel( - ctx, - &self.app_context, - ); - action |= add_contract_chooser_panel( ctx, &mut self.contract_search_term, diff --git a/src/ui/contracts_documents/dashpay_coming_soon_screen.rs b/src/ui/contracts_documents/dashpay_coming_soon_screen.rs deleted file mode 100644 index d561ede58..000000000 --- a/src/ui/contracts_documents/dashpay_coming_soon_screen.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::sync::Arc; - -use eframe::egui::Context; - -use crate::app::AppAction; -use crate::context::AppContext; -use crate::ui::components::left_panel::add_left_panel; -use crate::ui::components::styled::island_central_panel; -use crate::ui::components::top_panel::add_top_panel; -use crate::ui::{RootScreenType, ScreenLike}; - -pub struct DashpayScreen { - pub app_context: Arc, -} - -impl DashpayScreen { - pub fn new(app_context: &Arc) -> Self { - Self { - app_context: app_context.clone(), - } - } -} - -impl ScreenLike for DashpayScreen { - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Contracts", AppAction::GoToMainScreen), - ("Dashpay", AppAction::None), - ], - vec![], - ); - - // Keep Contracts highlighted in the main left panel - action |= add_left_panel( - ctx, - &self.app_context, - RootScreenType::RootScreenDocumentQuery, - ); - - // Contracts sub-left panel - action |= crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel( - ctx, - &self.app_context, - ); - - action |= island_central_panel(ctx, |ui| { - ui.vertical_centered(|ui| { - ui.add_space(40.0); - ui.heading("Coming Soon"); - ui.add_space(20.0); - }); - AppAction::None - }); - - action - } -} diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index 585822dd3..fab65ae25 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -1,18 +1,23 @@ use crate::app::AppAction; use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::FeeResult; use crate::backend_task::{BackendTask, document::DocumentTask}; use crate::context::AppContext; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::ScreenLike; +use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::{island_central_panel, styled_text_edit_singleline}; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; use crate::ui::helpers::{ - TransactionType, add_contract_doc_type_chooser_with_filtering, - add_identity_key_chooser_with_doc_type, show_success_screen, + TransactionType, add_contract_doc_type_chooser_with_filtering, add_key_chooser_with_doc_type, + show_success_screen_with_info, }; use crate::ui::identities::get_selected_wallet; use crate::ui::theme::DashColors; @@ -43,7 +48,7 @@ use dash_sdk::drive::query::WhereClause; use dash_sdk::platform::{DocumentQuery, Identifier, IdentityPublicKey}; use dash_sdk::query_types::IndexMap; use eframe::epaint::Color32; -use egui::{Context, RichText, Ui}; +use egui::{Context, Frame, Margin, RichText, Ui}; use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -87,11 +92,12 @@ pub struct DocumentActionScreen { // Common fields pub backend_message: Option, pub selected_identity: Option, + selected_identity_string: String, pub selected_key: Option, + show_advanced_options: bool, pub wallet: Option>>, - pub wallet_password: String, + pub wallet_unlock_popup: WalletUnlockPopup, pub wallet_failure: Option, - pub show_password: bool, pub broadcast_status: BroadcastStatus, pub selected_contract: Option, pub selected_document_type: Option, @@ -118,6 +124,9 @@ pub struct DocumentActionScreen { // Delete-specific pub fetched_documents: IndexMap>, + + // Fee tracking + pub completed_fee_result: Option, } impl DocumentActionScreen { @@ -142,16 +151,22 @@ impl DocumentActionScreen { let selected_contract = known_contracts.into_iter().next(); + let selected_identity_string = selected_identity + .as_ref() + .map(|qi| qi.identity.id().to_string(Encoding::Base58)) + .unwrap_or_default(); + Self { app_context, action_type, backend_message: None, selected_identity, + selected_identity_string, selected_key: None, + show_advanced_options: false, wallet: None, - wallet_password: String::new(), + wallet_unlock_popup: WalletUnlockPopup::new(), wallet_failure: None, - show_password: false, broadcast_status: BroadcastStatus::NotBroadcasted, selected_contract, selected_document_type: None, @@ -164,17 +179,19 @@ impl DocumentActionScreen { identities_map, recipient_id_input: String::new(), fetched_documents: IndexMap::new(), + completed_fee_result: None, } } fn reset_screen(&mut self) { self.backend_message = None; self.selected_identity = None; + self.selected_identity_string = String::new(); self.selected_key = None; + self.show_advanced_options = false; self.wallet = None; - self.wallet_password.clear(); + self.wallet_unlock_popup = WalletUnlockPopup::new(); self.wallet_failure = None; - self.show_password = false; self.broadcast_status = BroadcastStatus::NotBroadcasted; self.selected_contract = None; self.selected_document_type = None; @@ -203,19 +220,73 @@ impl DocumentActionScreen { } fn render_identity_and_key_selection(&mut self, ui: &mut Ui) { - ui.heading("2. Select an identity and key:"); + ui.horizontal(|ui| { + ui.heading("2. Select an identity:"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); ui.add_space(10.0); let identities_vec: Vec<_> = self.identities_map.values().cloned().collect(); - add_identity_key_chooser_with_doc_type( - ui, - &self.app_context, - identities_vec.iter(), - &mut self.selected_identity, - &mut self.selected_key, - TransactionType::DocumentAction, - self.selected_document_type.as_ref(), + + // Identity selector + let response = ui.add( + IdentitySelector::new( + "document_action_identity_selector", + &mut self.selected_identity_string, + &identities_vec, + ) + .selected_identity(&mut self.selected_identity) + .unwrap() + .width(300.0) + .label("Identity:") + .other_option(false), ); + + // Handle identity change - auto-select key and update wallet + if response.changed() { + if let Some(identity) = &self.selected_identity { + // Auto-select a suitable key for document actions + use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; + self.selected_key = identity + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + SecurityLevel::full_range().into(), + KeyType::all_key_types().into(), + false, + ) + .cloned(); + + // Update wallet + self.wallet = get_selected_wallet( + identity, + Some(&self.app_context), + None, + &mut self.backend_message, + ); + } else { + self.selected_key = None; + self.wallet = None; + } + } + + // Key selector (only shown in advanced mode) + if self.show_advanced_options { + ui.add_space(10.0); + if let Some(identity) = &self.selected_identity { + add_key_chooser_with_doc_type( + ui, + &self.app_context, + identity, + &mut self.selected_key, + TransactionType::DocumentAction, + self.selected_document_type.as_ref(), + ); + } + } + ui.add_space(10.0); } @@ -241,18 +312,14 @@ impl DocumentActionScreen { let contract_id = contract.contract.id(); let doc_type = doc_type.clone(); - egui::ScrollArea::vertical() - .max_height(ui.available_height() - 100.0) - .show(ui, |ui| { - self.ui_field_inputs(ui, &doc_type, contract_id); + self.ui_field_inputs(ui, &doc_type, contract_id); - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); - self.render_token_cost_info(ui, &doc_type); - action |= self.render_broadcast_button(ui); - }); + self.render_token_cost_info(ui, &doc_type); + action |= self.render_broadcast_button(ui); } action } @@ -534,21 +601,27 @@ impl DocumentActionScreen { let contract_id = contract.contract.id(); let doc_type = doc_type.clone(); - egui::ScrollArea::vertical() - .max_height(ui.available_height() - 100.0) - .show(ui, |ui| { - self.ui_field_inputs(ui, &doc_type, contract_id); + self.ui_field_inputs(ui, &doc_type, contract_id); - ui.add_space(10.0); - if let Some(doc_type) = &self.selected_document_type { - self.render_token_cost_info(ui, &doc_type.clone()); - } - action |= self.render_broadcast_button(ui); - }); + ui.add_space(10.0); + if let Some(doc_type) = &self.selected_document_type { + self.render_token_cost_info(ui, &doc_type.clone()); + } + action |= self.render_broadcast_button(ui); } } else if self.broadcast_status == BroadcastStatus::Fetched { ui.add_space(10.0); - ui.colored_label(Color32::DARK_RED, "No document found with the provided ID"); + let error_color = Color32::from_rgb(255, 100, 100); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.label( + RichText::new("No document found with the provided ID").color(error_color), + ); + }); } action } @@ -790,6 +863,38 @@ impl DocumentActionScreen { fn render_broadcast_button(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = match self.action_type { + DocumentActionType::Create => fee_estimator.estimate_document_create(), + DocumentActionType::Delete => fee_estimator.estimate_document_delete(), + DocumentActionType::Replace => fee_estimator.estimate_document_replace(), + DocumentActionType::Transfer => fee_estimator.estimate_document_transfer(), + DocumentActionType::Purchase => fee_estimator.estimate_document_purchase(), + DocumentActionType::SetPrice => fee_estimator.estimate_document_set_price(), + }; + + ui.add_space(10.0); + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + ui.add_space(10.0); let button_text = match self.action_type { DocumentActionType::Create => "Broadcast document", @@ -1472,12 +1577,6 @@ impl ScreenLike for DocumentActionScreen { crate::ui::RootScreenType::RootScreenDocumentQuery, ); - // Contracts sub-left panel - action |= crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel( - ctx, - &self.app_context, - ); - action |= island_central_panel(ctx, |ui| match &self.broadcast_status { BroadcastStatus::Broadcasted => { let success_message = format!("{} successful!", self.action_type.display_name()); @@ -1487,11 +1586,16 @@ impl ScreenLike for DocumentActionScreen { AppAction::Custom("Reset".to_string()), ); - let inner_action = - show_success_screen(ui, success_message, vec![back_button, reset_button]); + let inner_action = show_success_screen_with_info( + ui, + success_message, + vec![back_button, reset_button], + None, + ); if inner_action == AppAction::Custom("Reset".to_string()) { self.reset_screen(); + self.completed_fee_result = None; } inner_action @@ -1499,6 +1603,18 @@ impl ScreenLike for DocumentActionScreen { _ => self.render_main_content(ui), }); + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } + action } @@ -1507,17 +1623,8 @@ impl ScreenLike for DocumentActionScreen { } fn display_message(&mut self, message: &str, _message_type: crate::ui::MessageType) { - if message.contains("deleted successfully") - || message.contains("replaced successfully") - || message.contains("transferred successfully") - || message.contains("purchased successfully") - || message.contains("price set successfully") - { - self.broadcast_status = BroadcastStatus::Broadcasted; - } else { - self.backend_message = Some(message.to_string()); - self.broadcast_status = BroadcastStatus::NotBroadcasted; - } + self.backend_message = Some(message.to_string()); + self.broadcast_status = BroadcastStatus::NotBroadcasted; } fn display_task_result(&mut self, result: crate::ui::BackendTaskSuccessResult) { @@ -1525,6 +1632,14 @@ impl ScreenLike for DocumentActionScreen { BackendTaskSuccessResult::BroadcastedDocument(_) => { self.broadcast_status = BroadcastStatus::Broadcasted; } + BackendTaskSuccessResult::DeletedDocument(_, fee_result) + | BackendTaskSuccessResult::ReplacedDocument(_, fee_result) + | BackendTaskSuccessResult::TransferredDocument(_, fee_result) + | BackendTaskSuccessResult::PurchasedDocument(_, fee_result) + | BackendTaskSuccessResult::SetDocumentPrice(_, fee_result) => { + self.completed_fee_result = Some(fee_result); + self.broadcast_status = BroadcastStatus::Broadcasted; + } BackendTaskSuccessResult::Documents(documents) => { self.broadcast_status = BroadcastStatus::Fetched; @@ -1617,85 +1732,85 @@ impl ScreenLike for DocumentActionScreen { impl DocumentActionScreen { fn render_main_content(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - - // Step 1: Contract and Document Type Selection - self.render_contract_and_type_selection(ui); - - if self.selected_contract.is_none() || self.selected_document_type.is_none() { - return action; - } - - ui.separator(); - ui.add_space(10.0); - - // Step 2: Identity and Key Selection - self.render_identity_and_key_selection(ui); - - if self.selected_identity.is_none() || self.selected_key.is_none() { - return action; - } - - ui.separator(); - ui.add_space(10.0); - - // Wallet unlock - if let Some(selected_identity) = &self.selected_identity { - self.wallet = get_selected_wallet( - selected_identity, - Some(&self.app_context), - None, - &mut self.backend_message, - ); - } - if self.wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - if needed_unlock && !just_unlocked { - return action; - } - } + egui::ScrollArea::vertical() + .show(ui, |ui| { + let mut action = AppAction::None; - // Step 3: Action-specific inputs and broadcast - action |= match self.action_type { - DocumentActionType::Create => self.render_create_inputs(ui), - _ => self.render_action_specific_inputs(ui), - }; + // Step 1: Contract and Document Type Selection + self.render_contract_and_type_selection(ui); - if let Some(ref msg) = self.backend_message { - ui.add_space(10.0); - ui.colored_label(Color32::DARK_RED, msg); - } + if self.selected_contract.is_none() || self.selected_document_type.is_none() { + return action; + } - action - } -} + ui.separator(); + ui.add_space(10.0); -impl ScreenWithWalletUnlock for DocumentActionScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.wallet - } + // Step 2: Identity and Key Selection + self.render_identity_and_key_selection(ui); - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } + if self.selected_identity.is_none() || self.selected_key.is_none() { + return action; + } - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } + ui.separator(); + ui.add_space(10.0); - fn show_password(&self) -> bool { - self.show_password - } + // Wallet unlock + if let Some(selected_identity) = &self.selected_identity { + self.wallet = get_selected_wallet( + selected_identity, + Some(&self.app_context), + None, + &mut self.backend_message, + ); + } + if let Some(wallet) = &self.wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.backend_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + return action; + } + } - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } + // Step 3: Action-specific inputs and broadcast + action |= match self.action_type { + DocumentActionType::Create => self.render_create_inputs(ui), + _ => self.render_action_specific_inputs(ui), + }; - fn set_error_message(&mut self, error_message: Option) { - self.wallet_failure = error_message; - } + if let Some(ref msg) = self.backend_message { + ui.add_space(10.0); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = msg.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(&msg).color(error_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.backend_message = None; + } + }); + }); + } - fn error_message(&self) -> Option<&String> { - self.wallet_failure.as_ref() + action + }) + .inner } } diff --git a/src/ui/contracts_documents/group_actions_screen.rs b/src/ui/contracts_documents/group_actions_screen.rs index 3c1f3e7c5..2c4e1eb52 100644 --- a/src/ui/contracts_documents/group_actions_screen.rs +++ b/src/ui/contracts_documents/group_actions_screen.rs @@ -48,7 +48,7 @@ use dash_sdk::dpp::tokens::emergency_action::TokenEmergencyAction; use dash_sdk::dpp::tokens::token_event::TokenEvent; use dash_sdk::platform::Identifier; use dash_sdk::query_types::IndexMap; -use eframe::egui::{self, Color32, Context, RichText}; +use eframe::egui::{self, Color32, Context, Frame, Margin, RichText}; use egui::{ScrollArea, TextStyle}; use egui_extras::{Column, TableBuilder}; use std::collections::BTreeMap; @@ -491,12 +491,6 @@ impl ScreenLike for GroupActionsScreen { RootScreenType::RootScreenDocumentQuery, ); - // Contracts sub-left panel - action |= crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel( - ctx, - &self.app_context, - ); - let central_panel_action = island_central_panel(ctx, |ui| { ui.heading("Active Group Actions"); @@ -579,7 +573,25 @@ impl ScreenLike for GroupActionsScreen { match &self.fetch_group_actions_status { FetchGroupActionsStatus::ErrorMessage(msg) => { ui.add_space(10.0); - ui.colored_label(Color32::RED, format!("Error: {}", msg)); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = msg.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", msg)).color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.fetch_group_actions_status = + FetchGroupActionsStatus::NotStarted; + } + }); + }); } FetchGroupActionsStatus::WaitingForResult(start_time) => { diff --git a/src/ui/contracts_documents/mod.rs b/src/ui/contracts_documents/mod.rs index 07011f768..47ce18225 100644 --- a/src/ui/contracts_documents/mod.rs +++ b/src/ui/contracts_documents/mod.rs @@ -1,6 +1,5 @@ pub mod add_contracts_screen; pub mod contracts_documents_screen; -pub mod dashpay_coming_soon_screen; pub mod document_action_screen; pub mod group_actions_screen; pub mod register_contract_screen; diff --git a/src/ui/contracts_documents/register_contract_screen.rs b/src/ui/contracts_documents/register_contract_screen.rs index b8386ffb6..10bdd2648 100644 --- a/src/ui/contracts_documents/register_contract_screen.rs +++ b/src/ui/contracts_documents/register_contract_screen.rs @@ -1,14 +1,19 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; +use crate::backend_task::FeeResult; use crate::backend_task::contract::ContractTask; use crate::context::AppContext; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser}; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::get_selected_wallet; use crate::ui::{BackendTaskSuccessResult, MessageType, ScreenLike}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Setters; @@ -17,7 +22,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; use dash_sdk::platform::{DataContract, IdentityPublicKey}; -use eframe::egui::{self, Color32, Context, TextEdit}; +use eframe::egui::{self, Color32, Context, Frame, Margin, TextEdit}; use egui::{RichText, ScrollArea, Ui}; use std::sync::{Arc, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -41,12 +46,14 @@ pub struct RegisterDataContractScreen { pub qualified_identities: Vec, pub selected_qualified_identity: Option, + selected_identity_string: String, pub selected_key: Option, + show_advanced_options: bool, pub selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, error_message: Option, + completed_fee_result: Option, } impl RegisterDataContractScreen { @@ -63,6 +70,29 @@ impl RegisterDataContractScreen { None }; + // Auto-select a suitable key for contract registration + use dash_sdk::dpp::identity::KeyType; + let selected_key = selected_qualified_identity.as_ref().and_then(|identity| { + identity + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), + KeyType::all_key_types().into(), + false, + ) + .cloned() + }); + + let selected_identity_string = selected_qualified_identity + .as_ref() + .map(|qi| { + qi.identity + .id() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) + }) + .unwrap_or_default(); + Self { app_context: app_context.clone(), contract_json_input: String::new(), @@ -71,12 +101,14 @@ impl RegisterDataContractScreen { qualified_identities, selected_qualified_identity, - selected_key: None, + selected_identity_string, + selected_key, + show_advanced_options: false, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), error_message: None, + completed_fee_result: None, } } @@ -122,22 +154,46 @@ impl RegisterDataContractScreen { } fn ui_input_field(&mut self, ui: &mut egui::Ui) { - ScrollArea::vertical() - .max_height(ui.available_height() - 100.0) - .show(ui, |ui| { - let dark_mode = ui.ctx().style().visuals.dark_mode; - let response = ui.add( - TextEdit::multiline(&mut self.contract_json_input) - .desired_rows(6) - .desired_width(ui.available_width()) - .text_color(crate::ui::theme::DashColors::text_primary(dark_mode)) - .background_color(crate::ui::theme::DashColors::input_background(dark_mode)) - .code_editor(), - ); - if response.changed() { - self.parse_contract(); - } - }); + let dark_mode = ui.ctx().style().visuals.dark_mode; + let response = ui.add( + TextEdit::multiline(&mut self.contract_json_input) + .desired_rows(12) + .desired_width(ui.available_width()) + .text_color(crate::ui::theme::DashColors::text_primary(dark_mode)) + .background_color(crate::ui::theme::DashColors::input_background(dark_mode)) + .code_editor(), + ); + if response.changed() { + self.parse_contract(); + } + } + + /// Renders an error message at the top of the screen with a styled bubble + fn render_error_bubble(&mut self, ui: &mut egui::Ui) { + let error_msg = match &self.broadcast_status { + BroadcastStatus::ParsingError(err) => Some(format!("Parsing error: {err}")), + BroadcastStatus::BroadcastError(msg) => Some(format!("Broadcast error: {msg}")), + _ => None, + }; + + if let Some(msg) = error_msg { + let error_color = Color32::from_rgb(255, 100, 100); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.add(egui::Label::new(RichText::new(&msg).color(error_color)).wrap()); + ui.add_space(8.0); + if ui.small_button("Dismiss").clicked() { + self.broadcast_status = BroadcastStatus::Idle; + } + }); + }); + ui.add_space(10.0); + } } fn ui_parsed_contract(&mut self, ui: &mut egui::Ui) -> AppAction { @@ -149,12 +205,40 @@ impl RegisterDataContractScreen { BroadcastStatus::Idle => { ui.label("No contract parsed yet or empty input."); } - BroadcastStatus::ParsingError(err) => { - ui.colored_label(Color32::RED, format!("Parsing error: {err}")); + BroadcastStatus::ParsingError(_) | BroadcastStatus::BroadcastError(_) => { + // Errors are now shown at the top via render_error_bubble } BroadcastStatus::ValidContract(contract) => { - // “Register” button + // Display estimated fee using SDK's registration_cost method + // This accounts for document types, indexes, tokens, and keywords + let platform_version = self.app_context.platform_version(); + let registration_fee = contract.registration_cost(platform_version).unwrap_or(0); + // Add storage and processing fees for the contract data + let contract_size = self.contract_json_input.len(); + let storage_fee = crate::model::fee_estimation::PlatformFeeEstimator::new() + .estimate_storage_based_fee(contract_size, 20); // ~20 seeks for tree operations + let estimated_fee = registration_fee.saturating_add(storage_fee); + ui.add_space(10.0); + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::new() + .fill(crate::ui::theme::DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated Fee:") + .color(crate::ui::theme::DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(crate::ui::theme::DashColors::text_primary(dark_mode)) + .strong(), + ); + }); + }); ui.add_space(10.0); + // Register button let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); @@ -197,11 +281,11 @@ impl RegisterDataContractScreen { ui.label("Broadcasted but received proof error. ⚠"); ui.label(format!("Fetching contract from Platform and inserting into DET... {elapsed} seconds elapsed.")); } - BroadcastStatus::BroadcastError(msg) => { - ui.colored_label(Color32::RED, format!("Broadcast error: {msg}")); - } BroadcastStatus::Done => { - ui.colored_label(Color32::GREEN, "Data Contract registered successfully!"); + ui.colored_label( + Color32::DARK_GREEN, + "Data Contract registered successfully!", + ); } } @@ -220,42 +304,32 @@ impl RegisterDataContractScreen { } pub fn show_success(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - - // Center the content vertically and horizontally - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - if let Some(error_message) = &self.error_message { - if error_message.contains("proof error logged, contract inserted into the database") - { - ui.heading("⚠"); - ui.heading("Transaction succeeded but received a proof error."); - ui.add_space(10.0); - ui.label("Please check if the contract was registered correctly."); - ui.label( - "If it was, this is just a Platform proofs bug and no need for concern.", - ); - ui.label("Either way, please report to Dash Core Group."); - } - } else { - ui.heading("🎉"); - ui.heading("Successfully registered data contract."); - } - - ui.add_space(20.0); - - if ui.button("Back to Contracts screen").clicked() { - action = AppAction::GoToMainScreen; - } - ui.add_space(5.0); + let action = crate::ui::helpers::show_success_screen_with_info( + ui, + "Data Contract Registered Successfully!".to_string(), + vec![ + ( + "Back to Contracts screen".to_string(), + AppAction::GoToMainScreen, + ), + ( + "Register another contract".to_string(), + AppAction::Custom("register_another".to_string()), + ), + ], + None, + ); - if ui.button("Register another contract").clicked() { - self.contract_json_input = String::new(); - self.contract_alias_input = String::new(); - self.broadcast_status = BroadcastStatus::Idle; - } - }); + // Handle the custom action to reset the form + if let AppAction::Custom(ref s) = action + && s == "register_another" + { + self.contract_json_input = String::new(); + self.contract_alias_input = String::new(); + self.broadcast_status = BroadcastStatus::Idle; + self.completed_fee_result = None; + return AppAction::None; + } action } @@ -263,45 +337,39 @@ impl RegisterDataContractScreen { impl ScreenLike for RegisterDataContractScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - if message.contains("Nonce fetched successfully") { - self.broadcast_status = BroadcastStatus::Broadcasting( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(), - ); - } else if message.contains("Transaction returned proof error") { - self.broadcast_status = BroadcastStatus::ProofError( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(), - ); - } else { - self.broadcast_status = BroadcastStatus::Done; - } - } - MessageType::Error => { - if message.contains("proof error logged, contract inserted into the database") { - self.error_message = Some(message.to_string()); - self.broadcast_status = BroadcastStatus::Done; - } else { - self.broadcast_status = BroadcastStatus::BroadcastError(message.to_string()); - } - } - MessageType::Info => { - // You could display an info label, or do nothing + if message_type == MessageType::Error { + if message.contains("proof error logged, contract inserted into the database") { + self.error_message = Some(message.to_string()); + self.broadcast_status = BroadcastStatus::Done; + } else { + self.broadcast_status = BroadcastStatus::BroadcastError(message.to_string()); } } } fn display_task_result(&mut self, result: BackendTaskSuccessResult) { - // If a separate result needs to be handled here, you can do so - // For example, if success is a special message or we want to show it in the UI - if let BackendTaskSuccessResult::Message(_msg) = result { - self.broadcast_status = BroadcastStatus::Done; + match result { + BackendTaskSuccessResult::FetchedNonce => { + self.broadcast_status = BroadcastStatus::Broadcasting( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + ); + } + BackendTaskSuccessResult::RegisteredContract(fee_result) => { + self.completed_fee_result = Some(fee_result); + self.broadcast_status = BroadcastStatus::Done; + } + BackendTaskSuccessResult::ProofErrorLogged => { + self.broadcast_status = BroadcastStatus::ProofError( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + ); + } + _ => {} } } @@ -322,148 +390,196 @@ impl ScreenLike for RegisterDataContractScreen { crate::ui::RootScreenType::RootScreenDocumentQuery, ); - // Contracts sub-left panel - action |= crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel( - ctx, - &self.app_context, - ); - action |= island_central_panel(ctx, |ui| { if self.broadcast_status == BroadcastStatus::Done { return self.show_success(ui); } - ui.heading("Register Data Contract"); - ui.add_space(10.0); - - // If no identities loaded, give message - if self.qualified_identities.is_empty() { - ui.colored_label( - egui::Color32::DARK_RED, - "No identities loaded. Please load an identity first.", - ); - return AppAction::None; - } - - // Check if any identity has suitable private keys for contract registration - let has_suitable_keys = self.qualified_identities.iter().any(|qi| { - qi.private_keys - .identity_public_keys() - .iter() - .any(|key_ref| { - let key = &key_ref.1.identity_public_key; - // Contract registration requires Authentication keys with High or Critical security level - key.purpose() == Purpose::AUTHENTICATION - && (key.security_level() == SecurityLevel::HIGH - || key.security_level() == SecurityLevel::CRITICAL) - }) - }); - - if !has_suitable_keys { - ui.colored_label( - egui::Color32::DARK_RED, - "No identities with high or critical authentication private keys loaded. Contract registration requires high or critical security level keys.", - ); - return AppAction::None; - } - - // Select the identity to register the name for - ui.heading("1. Select Identity"); - ui.add_space(5.0); - add_identity_key_chooser( - ui, - &self.app_context, - self.qualified_identities.iter(), - &mut self.selected_qualified_identity, - &mut self.selected_key, - TransactionType::RegisterContract, - ); - ui.add_space(5.0); - if let Some(identity) = &self.selected_qualified_identity { - ui.label(format!( - "Identity balance: {:.6}", - identity.identity.balance() as f64 * 1e-11 - )); - } + ScrollArea::vertical().show(ui, |ui| { + ui.horizontal(|ui| { + ui.heading("Register Data Contract"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); + ui.add_space(10.0); - if self.selected_key.is_none() { - return AppAction::None; - } + // Show error message at the top if there's an error + self.render_error_bubble(ui); - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); + // If no identities loaded, give message + if self.qualified_identities.is_empty() { + ui.colored_label( + egui::Color32::DARK_RED, + "No identities loaded. Please load an identity first.", + ); + return AppAction::None; + } - // Render wallet unlock if needed - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - if needed_unlock && !just_unlocked { + // Check if any identity has suitable private keys for contract registration + let has_suitable_keys = self.qualified_identities.iter().any(|qi| { + qi.private_keys + .identity_public_keys() + .iter() + .any(|key_ref| { + let key = &key_ref.1.identity_public_key; + // Contract registration requires Authentication keys with High or Critical security level + key.purpose() == Purpose::AUTHENTICATION + && (key.security_level() == SecurityLevel::HIGH + || key.security_level() == SecurityLevel::CRITICAL) + }) + }); + + if !has_suitable_keys { + ui.colored_label( + egui::Color32::DARK_RED, + "No identities with high or critical authentication private keys loaded. Contract registration requires high or critical security level keys.", + ); return AppAction::None; } - } - // Input for the alias - ui.heading("2. Contract alias for DET (optional)"); - ui.add_space(5.0); - ui.text_edit_singleline(&mut self.contract_alias_input); + // Select the identity to register the contract for + ui.heading("1. Select Identity"); + ui.add_space(5.0); - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); + // Identity selector + let response = ui.add( + IdentitySelector::new( + "register_contract_identity_selector", + &mut self.selected_identity_string, + &self.qualified_identities, + ) + .selected_identity(&mut self.selected_qualified_identity) + .unwrap() + .width(300.0) + .label("Identity:") + .other_option(false), + ); - // Input for the contract - ui.heading("3. Paste the contract JSON below"); - ui.add_space(5.0); - - // Add link to dashpay.io - ui.horizontal(|ui| { - ui.label("Easily create a contract JSON here:"); - ui.add(egui::Hyperlink::from_label_and_url( - RichText::new("dashpay.io") - .underline() - .color(Color32::from_rgb(0, 128, 255)), - "https://dashpay.io", - )); - }); - ui.add_space(5.0); + // Handle identity change - auto-select key and update wallet + if response.changed() { + if let Some(identity) = &self.selected_qualified_identity { + // Auto-select a suitable key for contract registration + use dash_sdk::dpp::identity::KeyType; + self.selected_key = identity + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), + KeyType::all_key_types().into(), + false, + ) + .cloned(); + + // Update wallet + self.selected_wallet = get_selected_wallet( + identity, + Some(&self.app_context), + None, + &mut self.error_message, + ); + + // Re-parse contract with new owner ID + self.parse_contract(); + } else { + self.selected_key = None; + self.selected_wallet = None; + } + } - self.ui_input_field(ui); + // Key selector (only shown in advanced mode) + if self.show_advanced_options { + ui.add_space(10.0); + if let Some(identity) = &self.selected_qualified_identity { + add_key_chooser( + ui, + &self.app_context, + identity, + &mut self.selected_key, + TransactionType::RegisterContract, + ); + } + } - // Parse the contract and show the result - self.ui_parsed_contract(ui) - }); + ui.add_space(5.0); + if let Some(identity) = &self.selected_qualified_identity { + ui.label(format!( + "Identity balance: {:.6}", + identity.identity.balance() as f64 * 1e-11 + )); + } - action - } -} + if self.selected_key.is_none() { + return AppAction::None; + } -// If you also need wallet unlocking, implement the trait -impl ScreenWithWalletUnlock for RegisterDataContractScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } + // Render wallet unlock if needed + if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + return AppAction::None; + } + } - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } + // Input for the alias + ui.heading("2. Contract alias for DET (optional)"); + ui.add_space(5.0); + ui.text_edit_singleline(&mut self.contract_alias_input); - fn show_password(&self) -> bool { - self.show_password - } + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } + // Input for the contract + ui.heading("3. Paste the contract JSON below"); + ui.add_space(5.0); + + // Add link to dashpay.io + ui.horizontal(|ui| { + ui.label("Easily create a contract JSON here:"); + ui.add(egui::Hyperlink::from_label_and_url( + RichText::new("dashpay.io") + .underline() + .color(Color32::from_rgb(0, 128, 255)), + "https://dashpay.io", + )); + }); + ui.add_space(5.0); + + self.ui_input_field(ui); + + // Parse the contract and show the result + self.ui_parsed_contract(ui) + }).inner + }); - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/contracts_documents/update_contract_screen.rs b/src/ui/contracts_documents/update_contract_screen.rs index 6e9bc0c33..58bd38372 100644 --- a/src/ui/contracts_documents/update_contract_screen.rs +++ b/src/ui/contracts_documents/update_contract_screen.rs @@ -1,15 +1,20 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; +use crate::backend_task::FeeResult; use crate::backend_task::contract::ContractTask; use crate::context::AppContext; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser}; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::get_selected_wallet; use crate::ui::{BackendTaskSuccessResult, MessageType, ScreenLike}; use dash_sdk::dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; @@ -19,7 +24,7 @@ use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicK use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{DataContract, IdentityPublicKey}; -use eframe::egui::{self, Color32, Context, TextEdit}; +use eframe::egui::{self, Color32, Context, Frame, Margin, TextEdit}; use egui::{RichText, ScrollArea, Ui}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -46,12 +51,14 @@ pub struct UpdateDataContractScreen { pub qualified_identities: Vec, pub selected_qualified_identity: Option, + selected_identity_string: String, pub selected_key: Option, + show_advanced_options: bool, pub selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, error_message: Option, + completed_fee_result: Option, } impl UpdateDataContractScreen { @@ -78,9 +85,8 @@ impl UpdateDataContractScreen { }) .collect::>(); - let mut selected_key = None; - if let Some(identity) = &selected_qualified_identity { - selected_key = identity + let selected_key = selected_qualified_identity.as_ref().and_then(|identity| { + identity .identity .get_first_public_key_matching( Purpose::AUTHENTICATION, @@ -88,8 +94,13 @@ impl UpdateDataContractScreen { KeyType::all_key_types().into(), false, ) - .cloned(); - } + .cloned() + }); + + let selected_identity_string = selected_qualified_identity + .as_ref() + .map(|qi| qi.identity.id().to_string(Encoding::Base58)) + .unwrap_or_default(); Self { app_context: app_context.clone(), @@ -100,12 +111,14 @@ impl UpdateDataContractScreen { qualified_identities, selected_qualified_identity, + selected_identity_string, selected_key, + show_advanced_options: false, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), error_message: None, + completed_fee_result: None, } } @@ -169,6 +182,34 @@ impl UpdateDataContractScreen { }); } + /// Renders an error message at the top of the screen with a styled bubble + fn render_error_bubble(&mut self, ui: &mut egui::Ui) { + let error_msg = match &self.broadcast_status { + BroadcastStatus::ParsingError(err) => Some(format!("Parsing error: {err}")), + BroadcastStatus::BroadcastError(msg) => Some(format!("Broadcast error: {msg}")), + _ => None, + }; + + if let Some(msg) = error_msg { + let error_color = Color32::from_rgb(255, 100, 100); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.add(egui::Label::new(RichText::new(&msg).color(error_color)).wrap()); + ui.add_space(8.0); + if ui.small_button("Dismiss").clicked() { + self.broadcast_status = BroadcastStatus::Idle; + } + }); + }); + ui.add_space(10.0); + } + } + fn ui_parsed_contract(&mut self, ui: &mut egui::Ui) -> AppAction { let mut app_action = AppAction::None; @@ -176,13 +217,42 @@ impl UpdateDataContractScreen { match &self.broadcast_status { BroadcastStatus::Idle => {} - BroadcastStatus::ParsingError(err) => { - ui.colored_label(Color32::RED, format!("Parsing error: {err}")); + BroadcastStatus::ParsingError(_) | BroadcastStatus::BroadcastError(_) => { + // Errors are now shown at the top via render_error_bubble } BroadcastStatus::ValidContract(contract) => { - // “Update” button + // Fee estimation display - contract updates charge registration fees for the new contract ui.add_space(10.0); + let platform_version = self.app_context.platform_version(); + let registration_fee = contract.registration_cost(platform_version).unwrap_or(0); + let base_fee = platform_version + .fee_version + .state_transition_min_fees + .contract_update; + let estimated_fee = base_fee.saturating_add(registration_fee); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::new() + .fill(crate::ui::theme::DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(crate::ui::theme::DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(crate::ui::theme::DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + // Update button + ui.add_space(10.0); let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); ui.set_style(new_style); @@ -239,10 +309,6 @@ impl UpdateDataContractScreen { "Fetching contract from Platform... {elapsed} seconds elapsed." )); } - BroadcastStatus::BroadcastError(msg) => { - ui.label("Fetched nonce successfully. ✅ "); - ui.colored_label(Color32::RED, format!("Broadcast error: {msg}")); - } BroadcastStatus::Done => { ui.colored_label(Color32::DARK_GREEN, "Data Contract updated successfully!"); } @@ -263,41 +329,31 @@ impl UpdateDataContractScreen { } pub fn show_success(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - - // Center the content vertically and horizontally - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - if let Some(error_message) = &self.error_message { - if error_message.contains("proof error logged, contract inserted into the database") - { - ui.heading("⚠"); - ui.heading("Transaction succeeded but received a proof error."); - ui.add_space(10.0); - ui.label("Please check if the contract was updated correctly."); - ui.label( - "If it was, this is just a Platform proofs bug and no need for concern.", - ); - ui.label("Either way, please report to Dash Core Group."); - } - } else { - ui.heading("🎉"); - ui.heading("Successfully updated data contract."); - } - - ui.add_space(20.0); - - if ui.button("Back to Contracts screen").clicked() { - action = AppAction::GoToMainScreen; - } - ui.add_space(5.0); + let action = crate::ui::helpers::show_success_screen_with_info( + ui, + "Data Contract Updated Successfully!".to_string(), + vec![ + ( + "Back to Contracts screen".to_string(), + AppAction::GoToMainScreen, + ), + ( + "Update another contract".to_string(), + AppAction::Custom("update_another".to_string()), + ), + ], + None, + ); - if ui.button("Update another contract").clicked() { - self.contract_json_input = String::new(); - self.broadcast_status = BroadcastStatus::Idle; - } - }); + // Handle the custom action to reset the form + if let AppAction::Custom(ref s) = action + && s == "update_another" + { + self.contract_json_input = String::new(); + self.broadcast_status = BroadcastStatus::Idle; + self.completed_fee_result = None; + return AppAction::None; + } action } @@ -305,45 +361,39 @@ impl UpdateDataContractScreen { impl ScreenLike for UpdateDataContractScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - if message.contains("Nonce fetched successfully") { - self.broadcast_status = BroadcastStatus::Broadcasting( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(), - ); - } else if message.contains("Transaction returned proof error") { - self.broadcast_status = BroadcastStatus::ProofError( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(), - ); - } else { - self.broadcast_status = BroadcastStatus::Done; - } - } - MessageType::Error => { - if message.contains("proof error logged, contract inserted into the database") { - self.error_message = Some(message.to_string()); - self.broadcast_status = BroadcastStatus::Done; - } else { - self.broadcast_status = BroadcastStatus::BroadcastError(message.to_string()); - } - } - MessageType::Info => { - // You could display an info label, or do nothing + if message_type == MessageType::Error { + if message.contains("proof error logged, contract inserted into the database") { + self.error_message = Some(message.to_string()); + self.broadcast_status = BroadcastStatus::Done; + } else { + self.broadcast_status = BroadcastStatus::BroadcastError(message.to_string()); } } } fn display_task_result(&mut self, result: BackendTaskSuccessResult) { - // If a separate result needs to be handled here, you can do so - // For example, if success is a special message or we want to show it in the UI - if let BackendTaskSuccessResult::Message(_msg) = result { - self.broadcast_status = BroadcastStatus::Done; + match result { + BackendTaskSuccessResult::FetchedNonce => { + self.broadcast_status = BroadcastStatus::Broadcasting( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + ); + } + BackendTaskSuccessResult::UpdatedContract(fee_result) => { + self.completed_fee_result = Some(fee_result); + self.broadcast_status = BroadcastStatus::Done; + } + BackendTaskSuccessResult::ProofErrorLogged => { + self.broadcast_status = BroadcastStatus::ProofError( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + ); + } + _ => {} } } @@ -364,20 +414,22 @@ impl ScreenLike for UpdateDataContractScreen { crate::ui::RootScreenType::RootScreenDocumentQuery, ); - // Contracts sub-left panel - action |= crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel( - ctx, - &self.app_context, - ); - action |= island_central_panel(ctx, |ui| { if self.broadcast_status == BroadcastStatus::Done { return self.show_success(ui); } - ui.heading("Update Data Contract"); + ui.horizontal(|ui| { + ui.heading("Update Data Contract"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); ui.add_space(10.0); + // Show error message at the top if there's an error + self.render_error_bubble(ui); + // If no identities loaded, give message if self.qualified_identities.is_empty() { ui.colored_label( @@ -408,17 +460,68 @@ impl ScreenLike for UpdateDataContractScreen { return AppAction::None; } - // Select the identity to update the name for + // Select the identity to update the contract for ui.heading("1. Select Identity"); ui.add_space(5.0); - add_identity_key_chooser( - ui, - &self.app_context, - self.qualified_identities.iter(), - &mut self.selected_qualified_identity, - &mut self.selected_key, - TransactionType::UpdateContract, + + // Identity selector + let response = ui.add( + IdentitySelector::new( + "update_contract_identity_selector", + &mut self.selected_identity_string, + &self.qualified_identities, + ) + .selected_identity(&mut self.selected_qualified_identity) + .unwrap() + .width(300.0) + .label("Identity:") + .other_option(false), ); + + // Handle identity change - auto-select key and update wallet + if response.changed() { + if let Some(identity) = &self.selected_qualified_identity { + // Auto-select a suitable key for contract updates + self.selected_key = identity + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ) + .cloned(); + + // Update wallet + self.selected_wallet = get_selected_wallet( + identity, + Some(&self.app_context), + None, + &mut self.error_message, + ); + + // Re-parse contract with new owner ID + self.parse_contract(); + } else { + self.selected_key = None; + self.selected_wallet = None; + } + } + + // Key selector (only shown in advanced mode) + if self.show_advanced_options { + ui.add_space(10.0); + if let Some(identity) = &self.selected_qualified_identity { + add_key_chooser( + ui, + &self.app_context, + identity, + &mut self.selected_key, + TransactionType::UpdateContract, + ); + } + } + ui.add_space(5.0); if let Some(identity) = &self.selected_qualified_identity { ui.label(format!( @@ -436,9 +539,20 @@ impl ScreenLike for UpdateDataContractScreen { ui.add_space(10.0); // Render the wallet unlock if needed - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - if needed_unlock && !just_unlocked { + if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } return AppAction::None; } } @@ -496,37 +610,18 @@ impl ScreenLike for UpdateDataContractScreen { self.ui_parsed_contract(ui) }); - action - } -} - -// If you also need wallet unlocking, implement the trait -impl ScreenWithWalletUnlock for UpdateDataContractScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/dashpay/add_contact_screen.rs b/src/ui/dashpay/add_contact_screen.rs new file mode 100644 index 000000000..22a8a5fc0 --- /dev/null +++ b/src/ui/dashpay/add_contact_screen.rs @@ -0,0 +1,696 @@ +use crate::app::AppAction; +use crate::backend_task::dashpay::DashPayTask; +use crate::backend_task::dashpay::errors::DashPayError; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; +use crate::ui::components::identity_selector::IdentitySelector; +use crate::ui::components::info_popup::InfoPopup; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::dashpay::DashPaySubscreen; +use crate::ui::helpers::{TransactionType, add_key_chooser}; +use crate::ui::identities::get_selected_wallet; +use crate::ui::identities::keys::add_key_screen::AddKeyScreen; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike}; +use dash_sdk::platform::IdentityPublicKey; +use egui::{Context, RichText, ScrollArea, TextEdit, Ui}; +use std::sync::{Arc, RwLock}; + +const CONTACT_REQUEST_INFO_TEXT: &str = "About Contact Requests:\n\n\ + Contact requests establish secure communication channels.\n\n\ + Both parties must accept before payments can be sent.\n\n\ + Your display name and username will be shared with the contact.\n\n\ + You can manage contacts from the Contacts screen."; + +#[derive(Debug, Clone, PartialEq)] +enum ContactRequestStatus { + NotStarted, + Sending, + Success(String), // Success message + Error(DashPayError), // Structured error with user-friendly messaging +} + +pub struct AddContactScreen { + pub app_context: Arc, + selected_identity: Option, + selected_identity_string: String, + selected_key: Option, + username_or_id: String, + account_label: String, + message: Option<(String, MessageType)>, + status: ContactRequestStatus, + show_info_popup: bool, + show_advanced_options: bool, + selected_wallet: Option>>, + wallet_unlock_popup: WalletUnlockPopup, +} + +impl AddContactScreen { + pub fn new(app_context: Arc) -> Self { + Self { + app_context, + selected_identity: None, + selected_identity_string: String::new(), + selected_key: None, + username_or_id: String::new(), + account_label: String::new(), + message: None, + status: ContactRequestStatus::NotStarted, + show_info_popup: false, + show_advanced_options: false, + selected_wallet: None, + wallet_unlock_popup: WalletUnlockPopup::new(), + } + } + + pub fn new_with_identity_id(app_context: Arc, identity_id: String) -> Self { + Self { + app_context, + selected_identity: None, + selected_identity_string: String::new(), + selected_key: None, + username_or_id: identity_id, + account_label: String::new(), + message: None, + status: ContactRequestStatus::NotStarted, + show_info_popup: false, + show_advanced_options: false, + selected_wallet: None, + wallet_unlock_popup: WalletUnlockPopup::new(), + } + } + + fn send_contact_request(&mut self) -> AppAction { + if let (Some(identity), Some(signing_key)) = + (self.selected_identity.clone(), self.selected_key.clone()) + { + // Validate input using DashPayError system + if self.username_or_id.is_empty() { + let error = DashPayError::MissingField { + field: "username or identity ID".to_string(), + }; + self.status = ContactRequestStatus::Error(error.clone()); + self.display_message(&error.user_message(), MessageType::Error); + return AppAction::None; + } + + // Validate username format if it looks like a username + if self.username_or_id.contains('.') && !self.username_or_id.ends_with(".dash") { + let error = DashPayError::InvalidUsername { + username: self.username_or_id.clone(), + }; + self.status = ContactRequestStatus::Error(error.clone()); + self.display_message(&error.user_message(), MessageType::Error); + return AppAction::None; + } + + // Validate account label length + if self.account_label.len() > 100 { + let error = DashPayError::AccountLabelTooLong { + length: self.account_label.len(), + max: 100, + }; + self.status = ContactRequestStatus::Error(error.clone()); + self.display_message(&error.user_message(), MessageType::Error); + return AppAction::None; + } + + self.status = ContactRequestStatus::Sending; + + // Create the backend task to send the contact request + let task = BackendTask::DashPayTask(Box::new(DashPayTask::SendContactRequest { + identity, + signing_key, + to_username: self.username_or_id.clone(), + account_label: if self.account_label.is_empty() { + None + } else { + Some(self.account_label.clone()) + }, + })); + + AppAction::BackendTask(task) + } else { + let error = if self.selected_identity.is_none() { + DashPayError::MissingField { + field: "identity".to_string(), + } + } else { + DashPayError::MissingField { + field: "signing key".to_string(), + } + }; + self.status = ContactRequestStatus::Error(error.clone()); + self.display_message(&error.user_message(), MessageType::Error); + AppAction::None + } + } + + fn show_success_screen(&mut self, ui: &mut Ui) -> AppAction { + let action = crate::ui::helpers::show_success_screen( + ui, + "Contact Request Sent Successfully!".to_string(), + vec![ + ( + "Send Another Request".to_string(), + AppAction::Custom("send_another".to_string()), + ), + ( + "Back to Contacts".to_string(), + AppAction::PopScreenAndRefresh, + ), + ("Back to DashPay".to_string(), AppAction::PopScreen), + ], + ); + + // Handle the custom action to reset the form + if let AppAction::Custom(ref s) = action + && s == "send_another" + { + self.status = ContactRequestStatus::NotStarted; + self.selected_key = None; + return AppAction::Refresh; + } + + action + } +} + +impl ScreenLike for AddContactScreen { + fn refresh(&mut self) { + // Don't reset success status on refresh + if !matches!(self.status, ContactRequestStatus::Success(_)) { + self.status = ContactRequestStatus::NotStarted; + } + self.message = None; + } + + fn ui(&mut self, ctx: &Context) -> AppAction { + // Add top panel with navigation breadcrumbs + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("DashPay", AppAction::None), + ("Add Contact", AppAction::None), + ], + vec![], + ); + + // Highlight DashPay in the main left panel + action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + action |= + add_dashpay_subscreen_chooser_panel(ctx, &self.app_context, DashPaySubscreen::Contacts); + + // Main content in island central panel + action |= island_central_panel(ctx, |ui| { + let mut inner_action = AppAction::None; + + // Show success screen if request was successful + if matches!(self.status, ContactRequestStatus::Success(_)) { + return self.show_success_screen(ui); + } + + // Header with Back button, info icon, and Advanced Options checkbox + ui.horizontal(|ui| { + if ui.button("Back").clicked() { + inner_action = AppAction::PopScreen; + } + ui.heading("Add Contact"); + ui.add_space(5.0); + if crate::ui::helpers::info_icon_button(ui, CONTACT_REQUEST_INFO_TEXT).clicked() { + self.show_info_popup = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); + ui.separator(); + + // Show message if any (but not if we have an error status, to avoid duplication) + if !matches!(self.status, ContactRequestStatus::Error(_)) + && let Some((message, message_type)) = &self.message + { + let color = match message_type { + MessageType::Success => egui::Color32::DARK_GREEN, + MessageType::Error => egui::Color32::DARK_RED, + MessageType::Info => egui::Color32::LIGHT_BLUE, + }; + ui.colored_label(color, message); + ui.separator(); + } + + // Identity and Key selector + let identities = self + .app_context + .load_local_qualified_identities() + .unwrap_or_default(); + + if identities.is_empty() { + inner_action |= super::render_no_identities_card(ui, &self.app_context); + return inner_action; + } + + ui.group(|ui| { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label( + RichText::new("From (Sender)") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.separator(); + + // Identity selector + let response = ui.add( + IdentitySelector::new( + "contact_sender_identity_selector", + &mut self.selected_identity_string, + &identities, + ) + .selected_identity(&mut self.selected_identity) + .unwrap() + .width(300.0) + .label("Identity:") + .other_option(false), + ); + + // Handle identity change - auto-select key and update wallet + // Also auto-select if we have an identity but no key (e.g., on initial load) + let should_auto_select = response.changed() + || (self.selected_identity.is_some() && self.selected_key.is_none()); + + if should_auto_select { + if let Some(identity) = &self.selected_identity { + // Auto-select a suitable AUTHENTICATION key for signing contact requests + // Platform requires CRITICAL or HIGH security level for contact request signing + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; + use std::collections::HashSet; + self.selected_key = identity + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::CRITICAL, SecurityLevel::HIGH]), + KeyType::all_key_types().into(), + false, + ) + .cloned(); + + // Update wallet if not already set + if self.selected_wallet.is_none() { + let mut error_message = None; + self.selected_wallet = get_selected_wallet( + identity, + Some(&self.app_context), + None, + &mut error_message, + ); + } + } else { + self.selected_key = None; + self.selected_wallet = None; + } + } + + // Key selector (only shown in advanced mode) + if self.show_advanced_options { + ui.add_space(10.0); + if let Some(identity) = &self.selected_identity { + let key_action = add_key_chooser( + ui, + &self.app_context, + identity, + &mut self.selected_key, + TransactionType::ContactRequest, + ); + if !matches!(key_action, AppAction::None) { + inner_action = key_action; + } + } + } + }); + + ui.add_space(10.0); + + // Loading indicator + if matches!(self.status, ContactRequestStatus::Sending) { + ui.horizontal(|ui| { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); + ui.label( + RichText::new("Sending contact request...") + .color(DashColors::text_primary(dark_mode)), + ); + }); + ui.separator(); + } + + // Show error if any + if let ContactRequestStatus::Error(ref err) = self.status { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let error_color = if dark_mode { + egui::Color32::from_rgb(255, 100, 100) + } else { + egui::Color32::DARK_RED + }; + + ui.group(|ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label(RichText::new(err.user_message()).color(error_color)); + + // Show retry suggestion for recoverable errors + if err.is_recoverable() { + ui.label(RichText::new("You can try again.").small().color(DashColors::text_secondary(dark_mode))); + } + + // Show action suggestion for user errors + if err.requires_user_action() { + match err { + DashPayError::UsernameResolutionFailed { .. } => { + ui.label(RichText::new("Tip: Make sure the username is spelled correctly and exists on Dash Platform.").small().color(DashColors::text_secondary(dark_mode))); + } + DashPayError::InvalidUsername { .. } => { + ui.label(RichText::new("Tip: Usernames must end with '.dash' (e.g., alice).").small().color(DashColors::text_secondary(dark_mode))); + } + DashPayError::AccountLabelTooLong { .. } => { + ui.label(RichText::new("Tip: Try a shorter, more descriptive label.").small().color(DashColors::text_secondary(dark_mode))); + } + DashPayError::MissingEncryptionKey => { + ui.add_space(5.0); + if let Some(identity) = &self.selected_identity + && ui.button("Add Encryption Key").clicked() { + inner_action = AppAction::AddScreen(Screen::AddKeyScreen( + AddKeyScreen::new_for_dashpay_encryption( + identity.clone(), + &self.app_context, + ), + )); + } + } + DashPayError::MissingDecryptionKey => { + ui.add_space(5.0); + if let Some(identity) = &self.selected_identity + && ui.button("Add Decryption Key").clicked() { + inner_action = AppAction::AddScreen(Screen::AddKeyScreen( + AddKeyScreen::new_for_dashpay_decryption( + identity.clone(), + &self.app_context, + ), + )); + } + } + _ => {} + } + } + }); + }); + }); + ui.separator(); + } + + // Contact request form + ScrollArea::vertical().show(ui, |ui| { + ui.group(|ui| { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label( + RichText::new("To (Recipient)") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.separator(); + + // Username/ID and Relationship Label in 2x2 grid + egui::Grid::new("contact_request_form") + .num_columns(2) + .spacing([10.0, 10.0]) + .show(ui, |ui| { + // Row 1: Username/ID + ui.label( + RichText::new("Username or Identity ID:") + .color(DashColors::text_primary(dark_mode)), + ); + ui.add( + TextEdit::singleline(&mut self.username_or_id) + .hint_text("e.g., alice.dash or identity ID") + .desired_width(350.0), + ); + ui.end_row(); + + // Row 2: Relationship Label + ui.label( + RichText::new("Relationship Label (optional):") + .color(DashColors::text_primary(dark_mode)), + ); + ui.add( + TextEdit::singleline(&mut self.account_label) + .hint_text("e.g., Friend, Family, Business Partner") + .desired_width(350.0), + ); + }); + + ui.add_space(10.0); + }); + + // Show summary if all required fields are filled + if self.selected_identity.is_some() && !self.username_or_id.is_empty() { + ui.group(|ui| { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label( + RichText::new("Request Summary") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.separator(); + + if let Some(identity) = &self.selected_identity { + ui.horizontal(|ui| { + ui.label( + RichText::new("From:") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(identity.to_string()) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + }); + + ui.horizontal(|ui| { + ui.label( + RichText::new("To:") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(&self.username_or_id) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + }); + + if !self.account_label.is_empty() { + ui.horizontal(|ui| { + ui.label( + RichText::new("Label:") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(&self.account_label) + .color(DashColors::text_primary(dark_mode)), + ); + }); + } + } + }); + ui.add_space(10.0); + } + + ui.group(|ui| { + let _dark_mode = ui.ctx().style().visuals.dark_mode; + + // Check wallet lock status before showing send button + let wallet_locked = if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.message = Some((e, MessageType::Error)); + } + wallet_needs_unlock(wallet) + } else { + false + }; + + if wallet_locked { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to add contact.", + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + inner_action |= AppAction::PopScreen; + } + ui.add_space(10.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + }); + } else { + // Action buttons + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + inner_action |= AppAction::PopScreen; + } + + ui.add_space(10.0); + + let send_button_enabled = !self.username_or_id.is_empty() + && self.selected_identity.is_some() + && self.selected_key.is_some(); + + let send_button = egui::Button::new( + RichText::new("Add Contact").color(egui::Color32::WHITE), + ) + .fill(if send_button_enabled { + egui::Color32::from_rgb(0, 141, 228) // Dash blue + } else { + egui::Color32::GRAY + }); + + if ui.add_enabled(send_button_enabled, send_button).clicked() { + inner_action |= self.send_contact_request(); + } + + // Show retry button for recoverable errors + if let ContactRequestStatus::Error(ref err) = self.status + && err.is_recoverable() + { + ui.add_space(10.0); + if ui.button("Retry").clicked() { + // Clear both status and message before retrying + self.status = ContactRequestStatus::NotStarted; + self.message = None; + inner_action |= self.send_contact_request(); + } + } + }); + } + }); + }); + + inner_action + }); + + // Show info popup if requested + if self.show_info_popup { + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ctx, |ui| { + let mut popup = + InfoPopup::new("About Contact Requests", CONTACT_REQUEST_INFO_TEXT); + if popup.show(ui).inner { + self.show_info_popup = false; + } + }); + } + + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully, UI will update on next frame + } + } + + action + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + self.message = Some((message.to_string(), message_type)); + if message_type == MessageType::Error { + let error = DashPayError::Internal { + message: message.to_string(), + }; + self.status = ContactRequestStatus::Error(error); + } + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + match result { + BackendTaskSuccessResult::DashPayContactRequestSent(recipient) => { + // Contact request sent successfully - show success screen + self.status = ContactRequestStatus::Success(format!( + "Contact request sent to {} successfully!", + recipient + )); + // Clear form for next use + self.username_or_id.clear(); + self.account_label.clear(); + self.selected_key = None; + } + BackendTaskSuccessResult::Message(message) => { + // Handle error messages only - success is handled by DashPayContactRequestSent + if message.contains("Error") + || message.contains("Failed") + || message.contains("does not have") + { + // Try to parse structured error, fallback to generic + let error = if message.contains("ENCRYPTION key") { + DashPayError::MissingEncryptionKey + } else if message.contains("DECRYPTION key") { + DashPayError::MissingDecryptionKey + } else if message.contains("not found") && message.contains("username") { + DashPayError::UsernameResolutionFailed { + username: self.username_or_id.clone(), + } + } else if message.contains("Identity not found") { + DashPayError::IdentityNotFound { + identity_id: dash_sdk::platform::Identifier::from_string( + &self.username_or_id, + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, + ) + .unwrap_or_else(|_| dash_sdk::platform::Identifier::random()), + } + } else if message.contains("Network") || message.contains("connection") { + DashPayError::NetworkError { + reason: message.clone(), + } + } else { + DashPayError::Internal { + message: message.clone(), + } + }; + + self.status = ContactRequestStatus::Error(error.clone()); + // Don't set message field to avoid duplicate error display + self.message = None; + } + // Ignore other messages - they're not for this screen + } + _ => { + // Ignore results not meant for this screen + } + } + } +} + +impl AddContactScreen { + pub fn change_context(&mut self, app_context: Arc) { + self.app_context = app_context; + } + + pub fn refresh_on_arrival(&mut self) { + self.refresh(); + } +} diff --git a/src/ui/dashpay/contact_details.rs b/src/ui/dashpay/contact_details.rs new file mode 100644 index 000000000..c27d79fd0 --- /dev/null +++ b/src/ui/dashpay/contact_details.rs @@ -0,0 +1,466 @@ +use crate::app::AppAction; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; +use crate::ui::components::info_popup::InfoPopup; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::dashpay::DashPaySubscreen; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, RootScreenType, ScreenLike, ScreenType}; +use dash_sdk::dpp::balances::credits::Credits; +use dash_sdk::platform::Identifier; +use egui::{RichText, ScrollArea, TextEdit, Ui}; +use std::sync::Arc; + +const PRIVATE_CONTACT_INFO_TEXT: &str = "About Private Contact Information:\n\n\ + This information is encrypted and stored on Platform.\n\n\ + It is never shared with the contact - only you can decrypt it.\n\n\ + Only you can see these nicknames and notes.\n\n\ + Use this to organize and remember your contacts."; + +#[derive(Debug, Clone)] +pub struct Payment { + pub tx_id: String, + pub amount: Credits, + pub timestamp: u64, + pub is_incoming: bool, + pub memo: Option, +} + +#[derive(Debug, Clone)] +pub struct ContactInfo { + pub identity_id: Identifier, + pub username: Option, + pub display_name: Option, + pub bio: Option, + pub avatar_url: Option, + pub nickname: Option, + pub note: Option, + pub is_hidden: bool, + pub account_reference: u32, +} + +pub struct ContactDetailsScreen { + pub app_context: Arc, + pub identity: QualifiedIdentity, + pub contact_id: Identifier, + contact_info: Option, + payment_history: Vec, + editing_info: bool, + edit_nickname: String, + edit_note: String, + edit_hidden: bool, + message: Option<(String, MessageType)>, + loading: bool, + show_info_popup: bool, +} + +impl ContactDetailsScreen { + pub fn new( + app_context: Arc, + identity: QualifiedIdentity, + contact_id: Identifier, + ) -> Self { + let mut screen = Self { + app_context, + identity, + contact_id, + contact_info: None, + payment_history: Vec::new(), + editing_info: false, + edit_nickname: String::new(), + edit_note: String::new(), + edit_hidden: false, + message: None, + loading: false, + show_info_popup: false, + }; + screen.refresh(); + screen + } + + pub fn refresh(&mut self) { + // Don't set loading here - only when actually making backend requests + self.loading = false; + + // Clear any existing data - real data should be loaded from backend when needed + self.contact_info = None; + self.payment_history.clear(); + self.message = None; + + // TODO: Implement real backend fetching of contact info and payment history + // This should be triggered by user actions or specific backend tasks + } + + fn start_editing(&mut self) { + if let Some(info) = &self.contact_info { + self.edit_nickname = info.nickname.clone().unwrap_or_default(); + self.edit_note = info.note.clone().unwrap_or_default(); + self.edit_hidden = info.is_hidden; + self.editing_info = true; + } + } + + fn save_contact_info(&mut self) { + // TODO: Save contact info via backend + if let Some(info) = &mut self.contact_info { + info.nickname = if self.edit_nickname.is_empty() { + None + } else { + Some(self.edit_nickname.clone()) + }; + info.note = if self.edit_note.is_empty() { + None + } else { + Some(self.edit_note.clone()) + }; + info.is_hidden = self.edit_hidden; + } + + self.editing_info = false; + self.display_message("Contact info updated", MessageType::Success); + } + + fn cancel_editing(&mut self) { + self.editing_info = false; + self.edit_nickname.clear(); + self.edit_note.clear(); + self.edit_hidden = false; + } + + pub fn render(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + + // Header + ui.horizontal(|ui| { + if ui.button("Back").clicked() { + action = AppAction::PopScreen; + } + ui.heading("Contact Details"); + }); + + ui.separator(); + + // Show message if any + if let Some((message, message_type)) = &self.message { + let color = match message_type { + MessageType::Success => egui::Color32::DARK_GREEN, + MessageType::Error => egui::Color32::DARK_RED, + MessageType::Info => egui::Color32::LIGHT_BLUE, + }; + ui.colored_label(color, message); + ui.separator(); + } + + // Loading indicator + if self.loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Loading contact details..."); + }); + return action; + } + + ScrollArea::vertical().show(ui, |ui| { + if let Some(info) = self.contact_info.clone() { + // Contact profile section + ui.group(|ui| { + ui.horizontal(|ui| { + // Avatar placeholder + ui.vertical_centered(|ui| { + ui.label(RichText::new("👤").size(60.0).color(DashColors::DEEP_BLUE)); + ui.small("Contact"); + }); + + ui.vertical(|ui| { + // Display nickname if set, otherwise display name + let name = info + .nickname + .as_ref() + .or(info.display_name.as_ref()) + .or(info.username.as_ref()).cloned() + .unwrap_or_else(|| "Unknown".to_string()); + ui.label(RichText::new(name).heading()); + + // Username + if let Some(username) = &info.username { + ui.label(RichText::new(format!("@{}", username)).strong()); + } + + // Bio + if let Some(bio) = &info.bio { + ui.label(RichText::new(bio).weak()); + } + + // Identity ID + ui.label( + RichText::new(format!("ID: {}", info.identity_id)) + .small() + .weak(), + ); + }); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { + // Send Payment requires SPV which is dev mode only + if self.app_context.is_developer_mode() + && ui.button("Send Payment").clicked() { + action = AppAction::AddScreen( + ScreenType::DashPaySendPayment( + self.identity.clone(), + self.contact_id, + ) + .create_screen(&self.app_context), + ); + } + }); + }); + }); + + ui.add_space(10.0); + + // Contact info section + ui.group(|ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("Private Contact Information").strong()); + ui.add_space(5.0); + if crate::ui::helpers::info_icon_button(ui, PRIVATE_CONTACT_INFO_TEXT) + .clicked() + { + self.show_info_popup = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if self.editing_info { + if ui.button("Cancel").clicked() { + self.cancel_editing(); + } + if ui.button("Save").clicked() { + self.save_contact_info(); + } + } else if ui.button("Edit").clicked() { + self.start_editing(); + } + }); + }); + + ui.separator(); + + if self.editing_info { + // Edit mode + ui.horizontal(|ui| { + ui.label("Nickname:"); + ui.add( + TextEdit::singleline(&mut self.edit_nickname) + .hint_text("Optional nickname for this contact"), + ); + }); + + ui.horizontal(|ui| { + ui.label("Note:"); + ui.add( + TextEdit::multiline(&mut self.edit_note) + .hint_text("Private notes about this contact") + .desired_rows(3), + ); + }); + + ui.horizontal(|ui| { + ui.checkbox(&mut self.edit_hidden, "Hide this contact"); + if self.edit_hidden { + ui.label( + RichText::new("(Contact will not appear in lists)") + .small() + .weak(), + ); + } + }); + } else { + // View mode + if let Some(nickname) = &info.nickname { + ui.horizontal(|ui| { + ui.label("Nickname:"); + ui.label(nickname); + }); + } + + if let Some(note) = &info.note { + ui.horizontal(|ui| { + ui.label("Note:"); + ui.label(note); + }); + } + + if info.is_hidden { + ui.label( + RichText::new("⚠️ This contact is hidden") + .color(egui::Color32::YELLOW), + ); + } + } + }); + + ui.add_space(10.0); + + // Payment history section + ui.group(|ui| { + ui.label(RichText::new("Payment History").strong()); + ui.separator(); + + if self.payment_history.is_empty() { + ui.label("No payment history with this contact"); + } else { + for payment in &self.payment_history { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.horizontal(|ui| { + // Direction indicator + if payment.is_incoming { + ui.label(RichText::new("⬇").color(egui::Color32::DARK_GREEN)); + } else { + ui.label(RichText::new("⬆").color(egui::Color32::DARK_RED)); + } + + ui.vertical(|ui| { + ui.horizontal(|ui| { + // Amount + let amount_str = + format!("{} Dash", payment.amount); + if payment.is_incoming { + ui.label( + RichText::new(format!("+{}", amount_str)) + .color(egui::Color32::DARK_GREEN), + ); + } else { + ui.label( + RichText::new(format!("-{}", amount_str)) + .color(egui::Color32::DARK_RED), + ); + } + + // Memo + if let Some(memo) = &payment.memo { + ui.label( + RichText::new(format!("\"{}\"", memo)).italics().color(DashColors::text_secondary(dark_mode)), + ); + } + }); + + ui.horizontal(|ui| { + // Transaction ID + ui.label(RichText::new(&payment.tx_id).small().color(DashColors::text_secondary(dark_mode))); + + // Timestamp + ui.label(RichText::new("• 2 days ago").small().color(DashColors::text_secondary(dark_mode))); + }); + }); + }); + ui.separator(); + } + } + }); + + ui.add_space(10.0); + + // Actions section + ui.group(|ui| { + ui.label(RichText::new("Actions").strong()); + ui.separator(); + + ui.horizontal(|ui| { + if ui.button("Remove Contact").clicked() { + // TODO: Implement contact removal + self.display_message( + "Contact removal not yet implemented", + MessageType::Info, + ); + } + + if ui.button("Block Contact").clicked() { + // TODO: Implement contact blocking + self.display_message( + "Contact blocking not yet implemented", + MessageType::Info, + ); + } + }); + }); + } else { + // No contact info loaded + ui.group(|ui| { + ui.label("No contact information available"); + ui.separator(); + ui.label(format!("Contact ID: {}", self.contact_id)); + ui.add_space(10.0); + ui.label("Contact information will be loaded automatically when available from the backend."); + }); + + ui.add_space(10.0); + + } + }); + + action + } + + pub fn display_message(&mut self, message: &str, message_type: MessageType) { + self.message = Some((message.to_string(), message_type)); + } +} + +impl ScreenLike for ContactDetailsScreen { + fn refresh(&mut self) { + self.refresh(); + } + + fn ui(&mut self, ctx: &egui::Context) -> AppAction { + let mut action = AppAction::None; + + // Add top panel with contact name if available + let contact_name = self + .contact_info + .as_ref() + .and_then(|info| { + info.nickname + .as_ref() + .or(info.display_name.as_ref().or(info.username.as_ref())) + }) + .map(|name| format!("Contact: {}", name)) + .unwrap_or_else(|| "Contact Details".to_string()); + + action |= add_top_panel( + ctx, + &self.app_context, + vec![ + ("DashPay", AppAction::None), + (&contact_name, AppAction::None), + ], + vec![], + ); + + // Highlight DashPay in the main left panel + action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + action |= + add_dashpay_subscreen_chooser_panel(ctx, &self.app_context, DashPaySubscreen::Contacts); + + action |= island_central_panel(ctx, |ui| self.render(ui)); + + // Show info popup if requested + if self.show_info_popup { + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ctx, |ui| { + let mut popup = + InfoPopup::new("Private Contact Information", PRIVATE_CONTACT_INFO_TEXT); + if popup.show(ui).inner { + self.show_info_popup = false; + } + }); + } + + action + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + self.display_message(message, message_type); + } +} diff --git a/src/ui/dashpay/contact_info_editor.rs b/src/ui/dashpay/contact_info_editor.rs new file mode 100644 index 000000000..01bb458c1 --- /dev/null +++ b/src/ui/dashpay/contact_info_editor.rs @@ -0,0 +1,391 @@ +use crate::app::{AppAction, DesiredAppAction}; +use crate::backend_task::dashpay::{ContactData, DashPayTask}; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; +use crate::ui::components::info_popup::InfoPopup; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::dashpay::DashPaySubscreen; +use crate::ui::identities::get_selected_wallet; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use dash_sdk::platform::Identifier; +use egui::{RichText, ScrollArea, TextEdit, Ui}; +use std::sync::{Arc, RwLock}; + +const PRIVATE_CONTACT_INFO_TEXT: &str = "About Private Contact Information:\n\n\ + This information is encrypted and stored on Platform.\n\n\ + It is NEVER shared with the contact - only you can decrypt it.\n\n\ + Only you can see these nicknames and notes.\n\n\ + Hidden contacts can still send you payments.\n\n\ + Use this to organize and remember your contacts."; + +pub struct ContactInfoEditorScreen { + pub app_context: Arc, + pub identity: QualifiedIdentity, + pub contact_id: Identifier, + contact_username: Option, + nickname: String, + note: String, + is_hidden: bool, + accepted_accounts: Vec, + account_input: String, + message: Option<(String, MessageType)>, + saving: bool, + show_info_popup: bool, + selected_wallet: Option>>, + wallet_unlock_popup: WalletUnlockPopup, +} + +impl ContactInfoEditorScreen { + pub fn new( + app_context: Arc, + identity: QualifiedIdentity, + contact_id: Identifier, + ) -> Self { + // Get wallet for the identity + let mut error_message = None; + let selected_wallet = + get_selected_wallet(&identity, Some(&app_context), None, &mut error_message); + + Self { + app_context, + identity, + contact_id, + contact_username: None, + nickname: String::new(), + note: String::new(), + is_hidden: false, + accepted_accounts: Vec::new(), + account_input: String::new(), + message: None, + saving: false, + show_info_popup: false, + selected_wallet, + wallet_unlock_popup: WalletUnlockPopup::new(), + } + } + + fn load_contact_info(&mut self) -> AppAction { + // Trigger fetch from platform to get existing contact info + let task = BackendTask::DashPayTask(Box::new(DashPayTask::LoadContacts { + identity: self.identity.clone(), + })); + AppAction::BackendTask(task) + } + + fn handle_contacts_result(&mut self, contacts_data: Vec) { + // Find the contact info for our specific contact + for contact_data in contacts_data { + if contact_data.identity_id == self.contact_id { + self.nickname = contact_data.nickname.unwrap_or_default(); + self.note = contact_data.note.unwrap_or_default(); + self.is_hidden = contact_data.is_hidden; + // Note: accepted_accounts would come from the ContactData but we're not fully implementing it yet + break; + } + } + } + + fn save_contact_info(&mut self) -> AppAction { + self.saving = true; + + let task = BackendTask::DashPayTask(Box::new(DashPayTask::UpdateContactInfo { + identity: self.identity.clone(), + contact_id: self.contact_id, + nickname: if self.nickname.is_empty() { + None + } else { + Some(self.nickname.clone()) + }, + note: if self.note.is_empty() { + None + } else { + Some(self.note.clone()) + }, + is_hidden: self.is_hidden, + accepted_accounts: self.accepted_accounts.clone(), + })); + + AppAction::BackendTask(task) + } + + pub fn render(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Header with Back button and title + ui.horizontal(|ui| { + if ui.button("Back").clicked() { + action = AppAction::PopScreen; + } + ui.heading("Edit Private Contact Details"); + ui.add_space(5.0); + if crate::ui::helpers::info_icon_button(ui, PRIVATE_CONTACT_INFO_TEXT).clicked() { + self.show_info_popup = true; + } + }); + + ui.separator(); + + // Show message if any + if let Some((message, message_type)) = &self.message { + let color = match message_type { + MessageType::Success => DashColors::SUCCESS, + MessageType::Error => DashColors::ERROR, + MessageType::Info => DashColors::INFO, + }; + ui.colored_label(color, message); + ui.separator(); + } + + ScrollArea::vertical().show(ui, |ui| { + ui.group(|ui| { + // Contact identity + ui.horizontal(|ui| { + ui.label(RichText::new("Contact:").strong().color(if dark_mode { DashColors::DARK_TEXT_PRIMARY } else { DashColors::TEXT_PRIMARY })); + if let Some(username) = &self.contact_username { + ui.label(RichText::new(username).color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); + } else { + ui.label(RichText::new(format!("{}", self.contact_id)) + .color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); + } + }); + + ui.separator(); + + // Nickname field + ui.label(RichText::new("Private Nickname:").strong().color(if dark_mode { DashColors::DARK_TEXT_PRIMARY } else { DashColors::TEXT_PRIMARY })); + ui.label(RichText::new("Give this contact a custom name that ONLY YOU will see").small() + .color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); + ui.add( + TextEdit::singleline(&mut self.nickname) + .hint_text("e.g., 'Mom', 'Boss', 'Alice from work'") + .desired_width(300.0) + ); + + ui.add_space(10.0); + + // Note field + ui.label(RichText::new("Private Note:").strong().color(if dark_mode { DashColors::DARK_TEXT_PRIMARY } else { DashColors::TEXT_PRIMARY })); + ui.label(RichText::new("Add notes about this contact (only visible to you)").small() + .color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); + ui.add( + TextEdit::multiline(&mut self.note) + .hint_text("e.g., 'Met at Dash conference 2024', 'Owes me for lunch'") + .desired_rows(5) + .desired_width(f32::INFINITY) + ); + + ui.add_space(10.0); + + // Hidden checkbox + ui.horizontal(|ui| { + ui.checkbox(&mut self.is_hidden, "Hide this contact from my list"); + }); + if self.is_hidden { + ui.label(RichText::new("⚠️ Hidden contacts won't appear in your contact list but can still send you payments") + .small().color(DashColors::WARNING)); + } else { + ui.label(RichText::new("Contact will appear in your contact list").small() + .color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); + } + + ui.add_space(10.0); + + // Account references section + ui.label(RichText::new("Accepted Account Indices:").strong().color(if dark_mode { DashColors::DARK_TEXT_PRIMARY } else { DashColors::TEXT_PRIMARY })); + ui.label(RichText::new("Specify which account indices this contact can pay to (comma-separated)").small() + .color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); + + ui.horizontal(|ui| { + ui.add( + TextEdit::singleline(&mut self.account_input) + .hint_text("e.g., 0, 1, 2") + .desired_width(200.0) + ); + + if ui.button("Parse").clicked() { + // Parse the account indices + self.accepted_accounts.clear(); + for part in self.account_input.split(',') { + if let Ok(index) = part.trim().parse::() + && !self.accepted_accounts.contains(&index) + { + self.accepted_accounts.push(index); + } + } + self.accepted_accounts.sort(); + + // Update the input field to show the parsed values + self.account_input = self.accepted_accounts + .iter() + .map(|i| i.to_string()) + .collect::>() + .join(", "); + } + }); + + if !self.accepted_accounts.is_empty() { + ui.label(RichText::new(format!("Accepted accounts: {:?}", self.accepted_accounts)).small() + .color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); + } else { + ui.label(RichText::new("All accounts accepted (default)").small() + .color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); + } + + ui.add_space(20.0); + + // Check wallet lock status before showing save button + let wallet_locked = if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.message = Some((e, MessageType::Error)); + } + wallet_needs_unlock(wallet) + } else { + false + }; + + if wallet_locked { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to save changes.", + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button(RichText::new("❌ Cancel").size(16.0)).clicked() { + action = AppAction::PopScreen; + } + ui.add_space(10.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + }); + } else { + // Action buttons + ui.horizontal(|ui| { + let dark_mode = ui.ctx().style().visuals.dark_mode; + + if self.saving { + ui.spinner(); + ui.label(RichText::new("Saving...").color(if dark_mode { DashColors::DARK_TEXT_SECONDARY } else { DashColors::TEXT_SECONDARY })); + } else { + if ui.button(RichText::new("💾 Save Changes").size(16.0)).clicked() { + action = self.save_contact_info(); + } + + ui.add_space(10.0); + + if ui.button(RichText::new("❌ Cancel").size(16.0)).clicked() { + action = AppAction::PopScreen; + } + } + }); + } + }); + + }); + + action + } + + pub fn display_message(&mut self, message: &str, message_type: MessageType) { + self.message = Some((message.to_string(), message_type)); + } + + pub fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + self.saving = false; + match result { + BackendTaskSuccessResult::Message(msg) => { + self.display_message(&msg, MessageType::Success); + } + BackendTaskSuccessResult::DashPayContactsWithInfo(contacts_data) => { + self.handle_contacts_result(contacts_data); + } + _ => { + self.display_message("Contact information updated", MessageType::Success); + } + } + } +} + +impl ScreenLike for ContactInfoEditorScreen { + fn ui(&mut self, ctx: &egui::Context) -> AppAction { + let mut action = AppAction::None; + + // Add top panel with back button + let right_buttons = vec![( + "Refresh", + DesiredAppAction::Custom("refresh_contact_info".to_string()), + )]; + + action |= add_top_panel( + ctx, + &self.app_context, + vec![ + ("DashPay", AppAction::None), + ("Contact Details", AppAction::PopScreen), + ("Edit", AppAction::None), + ], + right_buttons, + ); + + // Highlight DashPay in the main left panel + action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + action |= + add_dashpay_subscreen_chooser_panel(ctx, &self.app_context, DashPaySubscreen::Contacts); + + // Main content area with island styling + action |= island_central_panel(ctx, |ui| self.render(ui)); + + // Show info popup if requested + if self.show_info_popup { + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ctx, |ui| { + let mut popup = + InfoPopup::new("Private Contact Information", PRIVATE_CONTACT_INFO_TEXT); + if popup.show(ui).inner { + self.show_info_popup = false; + } + }); + } + + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully, UI will update on next frame + } + } + + // Handle custom actions from top panel + if let AppAction::Custom(command) = &action + && command.as_str() == "refresh_contact_info" + { + action = self.load_contact_info(); + } + + action + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + self.display_message(message, message_type); + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + self.display_task_result(result); + } +} diff --git a/src/ui/dashpay/contact_profile_viewer.rs b/src/ui/dashpay/contact_profile_viewer.rs new file mode 100644 index 000000000..496b97767 --- /dev/null +++ b/src/ui/dashpay/contact_profile_viewer.rs @@ -0,0 +1,758 @@ +use crate::app::AppAction; +use crate::backend_task::dashpay::DashPayTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; +use crate::ui::components::info_popup::InfoPopup; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::dashpay::DashPaySubscreen; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, RootScreenType, ScreenLike, ScreenType}; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::platform::Identifier; +use egui::{ColorImage, RichText, ScrollArea, TextureHandle, Ui}; +use std::collections::HashMap; +use std::sync::Arc; + +const PUBLIC_PROFILE_INFO_TEXT: &str = "About Public Profiles:\n\n\ + This is the contact's public DashPay profile.\n\n\ + This information is published on Dash Platform.\n\n\ + Anyone can view this profile.\n\n\ + The contact controls what information to share.\n\n\ + This is different from your private notes about them."; + +const PRIVATE_INFO_TEXT: &str = + "This information is encrypted and stored on Platform. Only you can decrypt it."; + +#[derive(Debug, Clone)] +pub struct ContactPublicProfile { + pub identity_id: Identifier, + pub display_name: Option, + pub public_message: Option, + pub avatar_url: Option, + pub avatar_hash: Option>, + pub avatar_fingerprint: Option>, +} + +pub struct ContactProfileViewerScreen { + pub app_context: Arc, + pub identity: QualifiedIdentity, + pub contact_id: Identifier, + profile: Option, + message: Option<(String, MessageType)>, + loading: bool, + initial_fetch_done: bool, + // Private contact info fields + nickname: String, + notes: String, + is_hidden: bool, + editing_private_info: bool, + avatar_textures: HashMap, + avatar_loading: bool, + show_info_popup: Option<(&'static str, &'static str)>, +} + +impl ContactProfileViewerScreen { + pub fn new( + app_context: Arc, + identity: QualifiedIdentity, + contact_id: Identifier, + ) -> Self { + // Load private contact info from database + let (nickname, notes, is_hidden) = app_context + .db + .load_contact_private_info(&identity.identity.id(), &contact_id) + .unwrap_or((String::new(), String::new(), false)); + + // Try to load cached contact profile from database + let network_str = app_context.network.to_string(); + let profile = if let Ok(contacts) = app_context + .db + .load_dashpay_contacts(&identity.identity.id(), &network_str) + { + contacts + .iter() + .find(|c| { + if let Ok(id) = Identifier::from_bytes(&c.contact_identity_id) { + id == contact_id + } else { + false + } + }) + .map(|c| ContactPublicProfile { + identity_id: contact_id, + display_name: c.display_name.clone(), + public_message: c.public_message.clone(), + avatar_url: c.avatar_url.clone(), + avatar_hash: None, // Not stored in contacts table yet + avatar_fingerprint: None, // Not stored in contacts table yet + }) + } else { + None + }; + + let initial_fetch_done = profile.is_some(); // Check before moving + + Self { + app_context, + identity, + contact_id, + profile, + message: None, + loading: false, + initial_fetch_done, // If we have cached data, don't auto-fetch + nickname, + notes, + is_hidden, + editing_private_info: false, + avatar_textures: HashMap::new(), + avatar_loading: false, + show_info_popup: None, + } + } + + fn fetch_profile(&mut self) -> AppAction { + self.loading = true; + self.profile = None; // Clear any existing profile + self.message = None; // Clear any existing message + + let task = BackendTask::DashPayTask(Box::new(DashPayTask::FetchContactProfile { + identity: self.identity.clone(), + contact_id: self.contact_id, + })); + + AppAction::BackendTask(task) + } + + fn save_private_info(&mut self) -> Result<(), String> { + self.app_context + .db + .save_contact_private_info( + &self.identity.identity.id(), + &self.contact_id, + &self.nickname, + &self.notes, + self.is_hidden, + ) + .map_err(|e| e.to_string()) + } + + fn load_avatar_texture(&mut self, ctx: &egui::Context, url: &str) { + let _texture_id = format!("contact_avatar_{}", url); + let ctx_clone = ctx.clone(); + let url_clone = url.to_string(); + + // Spawn async task to fetch and load the image + tokio::spawn(async move { + match crate::backend_task::dashpay::avatar_processing::fetch_image_bytes(&url_clone) + .await + { + Ok(image_bytes) => { + // Try to load the image + if let Ok(image) = image::load_from_memory(&image_bytes) { + // Convert to RGBA + let rgba_image = image.to_rgba8(); + let width = rgba_image.width(); + let height = rgba_image.height(); + + // Center-crop to square if not already square + let cropped_image = if width != height { + let size = width.min(height); + let x_offset = (width - size) / 2; + let y_offset = (height - size) / 2; + image::imageops::crop_imm(&rgba_image, x_offset, y_offset, size, size) + .to_image() + } else { + rgba_image + }; + + let size = [ + cropped_image.width() as usize, + cropped_image.height() as usize, + ]; + let pixels = cropped_image.into_raw(); + + // Create ColorImage + let color_image = ColorImage::from_rgba_unmultiplied(size, &pixels); + + // Request repaint to load texture in UI thread + ctx_clone.request_repaint(); + + // Store the image data temporarily for the UI thread to pick up + ctx_clone.data_mut(|data| { + data.insert_temp( + egui::Id::new(format!("contact_avatar_data_{}", url_clone)), + color_image, + ); + }); + } + } + Err(e) => { + eprintln!("Failed to fetch contact avatar image: {}", e); + } + } + }); + } + + pub fn render(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Fetch profile on first render if not already done + if !self.initial_fetch_done && !self.loading { + self.initial_fetch_done = true; + action = self.fetch_profile(); + // Return early with the fetch action + return action; + } + + // Header + ui.horizontal(|ui| { + if ui.button("Back").clicked() { + action = AppAction::PopScreen; + } + ui.heading("Public Profile"); + ui.add_space(5.0); + if crate::ui::helpers::info_icon_button(ui, PUBLIC_PROFILE_INFO_TEXT).clicked() { + self.show_info_popup = Some(("About Public Profiles", PUBLIC_PROFILE_INFO_TEXT)); + } + }); + + ui.separator(); + + // Show message if any + if let Some((message, message_type)) = &self.message { + let color = match message_type { + MessageType::Success => DashColors::success_color(dark_mode), + MessageType::Error => DashColors::error_color(dark_mode), + MessageType::Info => DashColors::DASH_BLUE, + }; + ui.colored_label(color, message); + ui.separator(); + } + + // Loading indicator + if self.loading { + ui.horizontal(|ui| { + ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); + ui.label("Loading public profile..."); + }); + return action; + } + + ScrollArea::vertical().show(ui, |ui| { + if let Some(profile) = self.profile.clone() { + // Profile header + ui.group(|ui| { + ui.horizontal(|ui| { + // Avatar placeholder or image (fixed width) + ui.allocate_ui_with_layout( + egui::vec2(100.0, 120.0), + egui::Layout::top_down(egui::Align::Center), + |ui| { + if let Some(avatar_url) = &profile.avatar_url { + if !avatar_url.is_empty() { + let texture_id = format!("contact_avatar_{}", avatar_url); + + // Check if texture is already cached + if let Some(texture) = self.avatar_textures.get(&texture_id) + { + // Display the cached avatar image + ui.add( + egui::Image::new(texture) + .fit_to_exact_size(egui::vec2(60.0, 60.0)) + .corner_radius(5.0), + ); + } else { + // Check if image data was loaded by async task + let data_id = + format!("contact_avatar_data_{}", avatar_url); + let color_image = ui.ctx().data_mut(|data| { + data.get_temp::(egui::Id::new(&data_id)) + }); + + if let Some(color_image) = color_image { + // Create texture from loaded image + let texture = ui.ctx().load_texture( + &texture_id, + color_image, + egui::TextureOptions::LINEAR, + ); + + // Display the image + ui.add( + egui::Image::new(&texture) + .fit_to_exact_size(egui::vec2(60.0, 60.0)) + .corner_radius(5.0), + ); + + // Cache the texture + self.avatar_textures.insert(texture_id, texture); + self.avatar_loading = false; + + // Clear the temporary data + ui.ctx().data_mut(|data| { + data.remove::(egui::Id::new( + &data_id, + )); + }); + } else if !self.avatar_loading { + // Start loading the avatar + self.avatar_loading = true; + self.load_avatar_texture(ui.ctx(), avatar_url); + // Show spinner while loading + ui.add( + egui::Spinner::new() + .color(DashColors::DASH_BLUE), + ); + } else { + // Show loading indicator + ui.add( + egui::Spinner::new() + .color(DashColors::DASH_BLUE), + ); + } + } + ui.label( + RichText::new("Avatar") + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + } else { + ui.label( + RichText::new("👤") + .size(60.0) + .color(DashColors::DEEP_BLUE), + ); + ui.label( + RichText::new("No avatar") + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + } + } else { + ui.label( + RichText::new("👤").size(60.0).color(DashColors::DEEP_BLUE), + ); + ui.label( + RichText::new("No avatar") + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + } + }, + ); + + ui.separator(); + + // Main content area (takes remaining space) + ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { + // Display name + if let Some(display_name) = &profile.display_name { + ui.label( + RichText::new(display_name) + .heading() + .color(DashColors::text_primary(dark_mode)), + ); + } else { + ui.label( + RichText::new("No display name set") + .heading() + .color(DashColors::text_secondary(dark_mode)) + .italics(), + ); + } + + // Identity ID + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + ui.label( + RichText::new(format!( + "Identity: {}", + profile.identity_id.to_string(Encoding::Base58) + )) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + + ui.add_space(10.0); + + // Public message + ui.label( + RichText::new("Public Message:") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + if let Some(public_message) = &profile.public_message { + ui.label( + RichText::new(public_message) + .color(DashColors::text_primary(dark_mode)), + ); + } else { + ui.label( + RichText::new("No public message") + .color(DashColors::text_secondary(dark_mode)) + .italics(), + ); + } + }); + }); + }); + + ui.add_space(10.0); + + // Additional profile details if available + if profile.avatar_hash.is_some() || profile.avatar_fingerprint.is_some() { + ui.group(|ui| { + ui.label( + RichText::new("Avatar Verification") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.separator(); + + if let Some(hash) = &profile.avatar_hash { + ui.horizontal(|ui| { + ui.label( + RichText::new("Hash:") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(hex::encode(hash)) + .small() + .monospace() + .color(DashColors::text_secondary(dark_mode)), + ); + }); + } + + if let Some(fingerprint) = &profile.avatar_fingerprint { + ui.horizontal(|ui| { + ui.label( + RichText::new("Fingerprint:") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(hex::encode(fingerprint)) + .small() + .monospace() + .color(DashColors::text_secondary(dark_mode)), + ); + }); + } + }); + } + + ui.add_space(10.0); + + // Action buttons + ui.horizontal(|ui| { + if ui.button("Refresh").clicked() { + action = self.fetch_profile(); + } + + // Pay button - requires SPV which is dev mode only + if self.app_context.is_developer_mode() { + let pay_button = + egui::Button::new(RichText::new("Pay").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(0, 141, 228)); // Dash blue + + if ui.add(pay_button).clicked() { + action = AppAction::AddScreen( + ScreenType::DashPaySendPayment( + self.identity.clone(), + self.contact_id, + ) + .create_screen(&self.app_context), + ); + } + } + }); + } else if !self.loading { + // No profile loaded and not loading + ui.group(|ui| { + ui.label( + RichText::new("No profile found") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.separator(); + ui.label("This contact has not created a public profile yet."); + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button("Retry").clicked() { + action = self.fetch_profile(); + } + + // Pay button - requires SPV which is dev mode only + if self.app_context.is_developer_mode() { + let pay_button = + egui::Button::new(RichText::new("Pay").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(0, 141, 228)); // Dash blue + + if ui.add(pay_button).clicked() { + action = AppAction::AddScreen( + ScreenType::DashPaySendPayment( + self.identity.clone(), + self.contact_id, + ) + .create_screen(&self.app_context), + ); + } + } + }); + }); + } + + // Private Contact Info Section - Always show this, regardless of whether profile exists + if !self.loading { + ui.add_space(10.0); + + ui.group(|ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.add_space(9.0); + ui.label( + RichText::new("Private Contact Information") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + }); + + ui.add_space(5.0); + + ui.vertical(|ui| { + ui.add_space(9.0); + if crate::ui::helpers::info_icon_button(ui, PRIVATE_INFO_TEXT).clicked() + { + self.show_info_popup = + Some(("Private Contact Information", PRIVATE_INFO_TEXT)); + } + }); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if self.editing_private_info { + if ui.button("Save").clicked() { + match self.save_private_info() { + Ok(_) => { + self.editing_private_info = false; + self.message = Some(( + "Private info saved".to_string(), + MessageType::Success, + )); + } + Err(e) => { + self.message = Some(( + format!("Failed to save: {}", e), + MessageType::Error, + )); + } + } + } + if ui.button("Cancel").clicked() { + self.editing_private_info = false; + // Reload from database + if let Ok((nick, notes, hidden)) = + self.app_context.db.load_contact_private_info( + &self.identity.identity.id(), + &self.contact_id, + ) + { + self.nickname = nick; + self.notes = notes; + self.is_hidden = hidden; + } + } + } else if ui.button("Edit").clicked() { + self.editing_private_info = true; + } + }); + }); + + ui.separator(); + + // Nickname field + ui.horizontal(|ui| { + ui.label( + RichText::new("Nickname:").color(DashColors::text_secondary(dark_mode)), + ); + if self.editing_private_info { + ui.text_edit_singleline(&mut self.nickname); + } else { + let display_text = if self.nickname.is_empty() { + RichText::new("Not set") + .italics() + .color(DashColors::text_secondary(dark_mode)) + } else { + RichText::new(&self.nickname) + .color(DashColors::text_primary(dark_mode)) + }; + ui.label(display_text); + } + }); + + // Notes field + ui.vertical(|ui| { + ui.label( + RichText::new("Notes:").color(DashColors::text_secondary(dark_mode)), + ); + if self.editing_private_info { + ui.text_edit_multiline(&mut self.notes); + } else { + let display_text = if self.notes.is_empty() { + RichText::new("No notes") + .italics() + .color(DashColors::text_secondary(dark_mode)) + } else { + RichText::new(&self.notes) + .color(DashColors::text_primary(dark_mode)) + }; + ui.label(display_text); + } + }); + + // Hidden toggle + ui.horizontal(|ui| { + ui.label( + RichText::new("Hidden:").color(DashColors::text_secondary(dark_mode)), + ); + if self.editing_private_info { + ui.checkbox( + &mut self.is_hidden, + "Hide this contact from the main list", + ); + } else { + ui.label( + RichText::new(if self.is_hidden { "Yes" } else { "No" }) + .color(DashColors::text_primary(dark_mode)), + ); + } + }); + }); + } + }); + + action + } + + pub fn display_message(&mut self, message: &str, message_type: MessageType) { + self.loading = false; + self.message = Some((message.to_string(), message_type)); + } + + pub fn refresh(&mut self) { + // Don't auto-fetch on refresh - just clear temporary states + self.loading = false; + self.message = None; + } + + pub fn refresh_on_arrival(&mut self) { + // Reset the initial fetch flag when arriving at the screen + // The fetch will happen on the first render + if self.profile.is_none() && !self.loading { + self.initial_fetch_done = false; + } + } +} + +impl ScreenLike for ContactProfileViewerScreen { + fn ui(&mut self, ctx: &egui::Context) -> AppAction { + let mut action = AppAction::None; + + // Add top panel + action |= add_top_panel( + ctx, + &self.app_context, + vec![ + ("DashPay", AppAction::None), + ("Contact Profile", AppAction::None), + ], + vec![], + ); + + // Highlight DashPay in the main left panel + action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + action |= + add_dashpay_subscreen_chooser_panel(ctx, &self.app_context, DashPaySubscreen::Contacts); + + action |= island_central_panel(ctx, |ui| self.render(ui)); + + // Show info popup if requested + if let Some((title, text)) = self.show_info_popup { + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ctx, |ui| { + let mut popup = InfoPopup::new(title, text); + if popup.show(ui).inner { + self.show_info_popup = None; + } + }); + } + + action + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + self.display_message(message, message_type); + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + self.loading = false; + + match result { + BackendTaskSuccessResult::DashPayContactProfile(profile_doc) => { + if let Some(doc) = profile_doc { + // Extract profile data from the document + use dash_sdk::dpp::document::DocumentV0Getters; + let properties = match &doc { + dash_sdk::platform::Document::V0(doc_v0) => doc_v0.properties(), + }; + + let display_name = properties + .get("displayName") + .and_then(|v| v.as_text()) + .map(|s| s.to_string()); + let public_message = properties + .get("publicMessage") + .and_then(|v| v.as_text()) + .map(|s| s.to_string()); + let avatar_url = properties + .get("avatarUrl") + .and_then(|v| v.as_text()) + .map(|s| s.to_string()); + let avatar_hash = properties + .get("avatarHash") + .and_then(|v| v.as_bytes().map(|b| b.to_vec())); + let avatar_fingerprint = properties + .get("avatarFingerprint") + .and_then(|v| v.as_bytes().map(|b| b.to_vec())); + + self.profile = Some(ContactPublicProfile { + identity_id: self.contact_id, + display_name: display_name.clone(), + public_message: public_message.clone(), + avatar_url: avatar_url.clone(), + avatar_hash: avatar_hash.clone(), + avatar_fingerprint: avatar_fingerprint.clone(), + }); + + // Note: We don't save to database here - that should only happen + // when actually adding them as a contact, not just viewing their profile + + self.message = None; + } else { + self.profile = None; + self.message = None; // Don't set message here, UI already shows "No profile found" + } + } + BackendTaskSuccessResult::Message(msg) => { + self.message = Some((msg, MessageType::Info)); + } + _ => { + // Ignore other results + } + } + } +} diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs new file mode 100644 index 000000000..5c9358917 --- /dev/null +++ b/src/ui/dashpay/contact_requests.rs @@ -0,0 +1,1011 @@ +use crate::app::AppAction; +use crate::backend_task::dashpay::DashPayTask; +use crate::backend_task::dashpay::errors::DashPayError; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::component_trait::Component; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; +use crate::ui::components::identity_selector::IdentitySelector; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::identities::get_selected_wallet; +use crate::ui::identities::keys::add_key_screen::AddKeyScreen; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, Screen, ScreenLike, ScreenType}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::platform::Identifier; +use egui::{Frame, Margin, RichText, ScrollArea, Ui}; +use std::collections::{BTreeMap, HashSet}; +use std::sync::{Arc, RwLock}; + +#[derive(Debug, Clone)] +pub struct ContactRequest { + pub request_id: Identifier, + pub from_identity: Identifier, + pub to_identity: Identifier, + pub from_username: Option, + pub from_display_name: Option, + pub account_reference: u32, + pub account_label: Option, + pub timestamp: u64, + pub auto_accept_proof: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum RequestTab { + Incoming, + Outgoing, +} + +pub struct ContactRequests { + pub app_context: Arc, + incoming_requests: BTreeMap, + outgoing_requests: BTreeMap, + accepted_requests: HashSet, + rejected_requests: HashSet, + selected_identity: Option, + selected_identity_string: String, + active_tab: RequestTab, + message: Option<(String, MessageType)>, + loading: bool, + has_fetched_requests: bool, + accept_confirmation_dialog: Option<(ConfirmationDialog, ContactRequest)>, + reject_confirmation_dialog: Option<(ConfirmationDialog, ContactRequest)>, + pub selected_wallet: Option>>, + pub wallet_unlock_popup: WalletUnlockPopup, + /// Structured error for displaying with action buttons + error: Option, +} + +impl ContactRequests { + pub fn new(app_context: Arc) -> Self { + let mut new_self = Self { + app_context: app_context.clone(), + incoming_requests: BTreeMap::new(), + outgoing_requests: BTreeMap::new(), + accepted_requests: HashSet::new(), + rejected_requests: HashSet::new(), + selected_identity: None, + selected_identity_string: String::new(), + active_tab: RequestTab::Incoming, + message: None, + loading: false, + has_fetched_requests: false, + accept_confirmation_dialog: None, + reject_confirmation_dialog: None, + selected_wallet: None, + wallet_unlock_popup: WalletUnlockPopup::new(), + error: None, + }; + + // Auto-select first identity on creation if available + if let Ok(identities) = app_context.load_local_qualified_identities() + && !identities.is_empty() + { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + new_self.selected_identity = Some(identities[0].clone()); + new_self.selected_identity_string = identities[0] + .identity + .id() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); + + // Get wallet for the selected identity + let mut error_message = None; + new_self.selected_wallet = + get_selected_wallet(&identities[0], Some(&app_context), None, &mut error_message); + + // Load requests from database for this identity + new_self.load_requests_from_database(); + } + + new_self + } + + /// Set the selected identity from an external source (e.g., when embedded in ContactsList) + pub fn set_selected_identity(&mut self, identity: Option) { + let identity_changed = match (&self.selected_identity, &identity) { + (Some(current), Some(new)) => current.identity.id() != new.identity.id(), + (None, Some(_)) | (Some(_), None) => true, + (None, None) => false, + }; + + if identity_changed { + self.selected_identity = identity.clone(); + if let Some(id) = &identity { + self.selected_identity_string = id + .identity + .id() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); + + // Update wallet for the newly selected identity + let mut error_message = None; + self.selected_wallet = + get_selected_wallet(id, Some(&self.app_context), None, &mut error_message); + } else { + self.selected_identity_string.clear(); + self.selected_wallet = None; + } + + // Clear the requests when identity changes + self.incoming_requests.clear(); + self.outgoing_requests.clear(); + self.message = None; + self.has_fetched_requests = false; + + // Load requests from database for the newly selected identity + self.load_requests_from_database(); + } + } + + /// Render without the header and identity selector (for use when embedded in another component) + pub fn render_embedded(&mut self, ui: &mut Ui) -> AppAction { + self.render_content(ui, false) + } + + fn load_requests_from_database(&mut self) { + // Load saved contact requests for the selected identity from database + if let Some(identity) = &self.selected_identity { + let identity_id = identity.identity.id(); + + // Clear existing requests before loading + self.incoming_requests.clear(); + self.outgoing_requests.clear(); + + let network_str = self.app_context.network.to_string(); + tracing::debug!( + "Loading contact requests from database for identity {} on network {}", + identity_id, + network_str + ); + + // Load pending incoming requests from database + match self.app_context.db.load_pending_contact_requests( + &identity_id, + &network_str, + "received", + ) { + Ok(incoming) => { + tracing::debug!("Loaded {} incoming requests from database", incoming.len()); + for request in incoming { + if let Ok(from_id) = Identifier::from_bytes(&request.from_identity_id) { + let contact_request = ContactRequest { + request_id: Identifier::new([0; 32]), // We'll need to store this in DB + from_identity: from_id, + to_identity: identity_id, + from_username: request.to_username, // This field is misnamed in DB + from_display_name: None, + account_reference: 0, + account_label: request.account_label, + timestamp: request.created_at as u64, + auto_accept_proof: None, + }; + self.incoming_requests.insert(from_id, contact_request); + } + } + } + Err(e) => { + tracing::error!("Failed to load incoming contact requests: {}", e); + } + } + + // Load pending outgoing requests from database + match self.app_context.db.load_pending_contact_requests( + &identity_id, + &network_str, + "sent", + ) { + Ok(outgoing) => { + tracing::debug!("Loaded {} outgoing requests from database", outgoing.len()); + for request in outgoing { + if let Ok(to_id) = Identifier::from_bytes(&request.to_identity_id) { + let contact_request = ContactRequest { + request_id: Identifier::new([0; 32]), // We'll need to store this in DB + from_identity: identity_id, + to_identity: to_id, + from_username: None, + from_display_name: None, + account_reference: 0, + account_label: request.account_label, + timestamp: request.created_at as u64, + auto_accept_proof: None, + }; + self.outgoing_requests.insert(to_id, contact_request); + } + } + } + Err(e) => { + tracing::error!("Failed to load outgoing contact requests: {}", e); + } + } + } + } + + pub fn trigger_fetch_requests(&mut self) -> AppAction { + // Only fetch if we have a selected identity + if let Some(identity) = &self.selected_identity { + self.loading = true; + self.message = None; + + let task = BackendTask::DashPayTask(Box::new(DashPayTask::LoadContactRequests { + identity: identity.clone(), + })); + + return AppAction::BackendTask(task); + } + + AppAction::None + } + + /// Returns the count of pending incoming requests (not yet accepted or rejected) + pub fn pending_incoming_count(&self) -> usize { + self.incoming_requests + .keys() + .filter(|id| { + !self.accepted_requests.contains(*id) && !self.rejected_requests.contains(*id) + }) + .count() + } + + pub fn fetch_all_requests(&mut self) -> AppAction { + self.trigger_fetch_requests() + } + + pub fn refresh(&mut self) -> AppAction { + // Don't clear requests - preserve loaded state + // Only clear temporary states + self.message = None; + self.loading = false; + + // Auto-select first identity if none selected + if self.selected_identity.is_none() + && let Ok(identities) = self.app_context.load_local_qualified_identities() + && !identities.is_empty() + { + self.selected_identity = Some(identities[0].clone()); + self.selected_identity_string = identities[0].display_string(); + } + + // Load requests from database if we have an identity selected + if self.selected_identity.is_some() { + self.load_requests_from_database(); + } + + AppAction::None + } + + pub fn render(&mut self, ui: &mut Ui) -> AppAction { + self.render_content(ui, true) + } + + fn render_content(&mut self, ui: &mut Ui, show_header: bool) -> AppAction { + let mut action = AppAction::None; + + // Handle accept confirmation dialog + if let Some((dialog, request)) = &mut self.accept_confirmation_dialog { + let response = dialog.show(ui); + if response.inner.dialog_response == Some(ConfirmationStatus::Confirmed) { + if let Some(identity) = &self.selected_identity { + // Don't mark as accepted yet - wait for backend confirmation + self.loading = true; + self.message = Some(( + "Accepting contact request...".to_string(), + MessageType::Info, + )); + + let task = + BackendTask::DashPayTask(Box::new(DashPayTask::AcceptContactRequest { + identity: identity.clone(), + request_id: request.request_id, + })); + + action |= AppAction::BackendTask(task); + } + self.accept_confirmation_dialog = None; + } else if response.inner.dialog_response == Some(ConfirmationStatus::Canceled) { + self.accept_confirmation_dialog = None; + } + } + + // Handle reject confirmation dialog + if let Some((dialog, request)) = &mut self.reject_confirmation_dialog { + let response = dialog.show(ui); + if response.inner.dialog_response == Some(ConfirmationStatus::Confirmed) { + if let Some(identity) = &self.selected_identity { + self.loading = true; + self.message = Some(( + "Rejecting contact request...".to_string(), + MessageType::Info, + )); + + // Don't mark as rejected yet - wait for backend confirmation + + let task = + BackendTask::DashPayTask(Box::new(DashPayTask::RejectContactRequest { + identity: identity.clone(), + request_id: request.request_id, + })); + + action |= AppAction::BackendTask(task); + } + self.reject_confirmation_dialog = None; + } else if response.inner.dialog_response == Some(ConfirmationStatus::Canceled) { + self.reject_confirmation_dialog = None; + } + } + + // Identity selector or no identities message + let identities = self + .app_context + .load_local_qualified_identities() + .unwrap_or_default(); + + // Header with identity selector on the right (only shown when not embedded) + if show_header { + ui.horizontal(|ui| { + ui.heading("Contact Requests"); + + if !identities.is_empty() { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let response = ui.add( + IdentitySelector::new( + "requests_identity_selector", + &mut self.selected_identity_string, + &identities, + ) + .selected_identity(&mut self.selected_identity) + .unwrap() + .width(300.0) + .other_option(false), // Disable "Other" option + ); + + if response.changed() { + // Clear the requests when identity changes + self.incoming_requests.clear(); + self.outgoing_requests.clear(); + self.message = None; + self.has_fetched_requests = false; + + // Update wallet for the newly selected identity + if let Some(identity) = &self.selected_identity { + let mut error_message = None; + self.selected_wallet = get_selected_wallet( + identity, + Some(&self.app_context), + None, + &mut error_message, + ); + } else { + self.selected_wallet = None; + } + + // Load requests from database for the newly selected identity + self.load_requests_from_database(); + } + }); + } + }); + + ui.separator(); + + if identities.is_empty() { + return super::render_no_identities_card(ui, &self.app_context); + } + } + + // Show structured error with action buttons if any + if let Some(err) = self.error.clone() { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let error_color = if dark_mode { + egui::Color32::from_rgb(255, 100, 100) + } else { + egui::Color32::DARK_RED + }; + + ui.group(|ui| { + ui.vertical(|ui| { + ui.label(RichText::new(err.user_message()).color(error_color)); + + // Show action button for missing encryption key + if matches!(err, DashPayError::MissingEncryptionKey) { + ui.add_space(5.0); + if let Some(identity) = &self.selected_identity + && ui.button("Add Encryption Key").clicked() + { + action = AppAction::AddScreen(Screen::AddKeyScreen( + AddKeyScreen::new_for_dashpay_encryption( + identity.clone(), + &self.app_context, + ), + )); + self.error = None; + } + } + }); + }); + ui.separator(); + } + + // Show regular message if any (non-error) + if let Some((message, message_type)) = &self.message { + let color = match message_type { + MessageType::Success => egui::Color32::DARK_GREEN, + MessageType::Error => egui::Color32::DARK_RED, + MessageType::Info => egui::Color32::LIGHT_BLUE, + }; + // Only show error messages here if there's no structured error + if message_type == &MessageType::Error && self.error.is_none() { + ui.colored_label(color, RichText::new(message).strong()); + ui.separator(); + } + } + + if self.selected_identity.is_none() { + ui.label("Please select an identity to view contact requests"); + return action; + } + + // Tabs + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.horizontal(|ui| { + let incoming_tab = egui::Button::new(RichText::new("Incoming").color( + if self.active_tab == RequestTab::Incoming { + DashColors::WHITE + } else { + DashColors::text_primary(dark_mode) + }, + )) + .fill(if self.active_tab == RequestTab::Incoming { + DashColors::DASH_BLUE + } else { + DashColors::glass_white(dark_mode) + }) + .stroke(if self.active_tab == RequestTab::Incoming { + egui::Stroke::NONE + } else { + egui::Stroke::new(1.0, DashColors::border(dark_mode)) + }) + .corner_radius(egui::CornerRadius::same(4)) + .min_size(egui::Vec2::new(120.0, 28.0)); + + if ui.add(incoming_tab).clicked() { + self.active_tab = RequestTab::Incoming; + } + + ui.add_space(8.0); + + let outgoing_tab = egui::Button::new(RichText::new("Outgoing").color( + if self.active_tab == RequestTab::Outgoing { + DashColors::WHITE + } else { + DashColors::text_primary(dark_mode) + }, + )) + .fill(if self.active_tab == RequestTab::Outgoing { + DashColors::DASH_BLUE + } else { + DashColors::glass_white(dark_mode) + }) + .stroke(if self.active_tab == RequestTab::Outgoing { + egui::Stroke::NONE + } else { + egui::Stroke::new(1.0, DashColors::border(dark_mode)) + }) + .corner_radius(egui::CornerRadius::same(4)) + .min_size(egui::Vec2::new(120.0, 28.0)); + + if ui.add(outgoing_tab).clicked() { + self.active_tab = RequestTab::Outgoing; + } + }); + + ui.add_space(8.0); + + // Display requests based on active tab + match self.active_tab { + RequestTab::Incoming => { + // Loading indicator + if self.loading { + ui.horizontal(|ui| { + ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); + + // Show specific loading message based on current message + if let Some((msg, _)) = &self.message { + ui.label(msg); + } else { + ui.label("Loading..."); + } + }); + } else { + ScrollArea::vertical().id_salt("incoming_requests_scroll").show(ui, |ui| { + if self.incoming_requests.is_empty() { + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::group(ui.style()) + .fill(ui.visuals().extreme_bg_color) + .corner_radius(5.0) + .outer_margin(Margin::same(20)) + .shadow(ui.visuals().window_shadow) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(10.0); + ui.label( + RichText::new("No Incoming Requests") + .strong() + .size(20.0) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(5.0); + ui.label( + RichText::new("You don't have any pending contact requests.") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(10.0); + }); + }); + } else { + let requests: Vec<_> = self.incoming_requests.values().cloned().collect(); + for request in requests { + ui.group(|ui| { + ui.horizontal(|ui| { + // Avatar placeholder + ui.add(egui::Label::new(RichText::new("👤").size(30.0).color(DashColors::DEEP_BLUE))); + + ui.vertical(|ui| { + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Display name or username or identity ID + let name = request + .from_display_name + .as_ref() + .or(request.from_username.as_ref()).cloned() + .unwrap_or_else(|| { + // Show truncated identity ID if no name available + let id_str = request.from_identity.to_string(Encoding::Base58); + format!("{}...{}", &id_str[..6], &id_str[id_str.len()-6..]) + }); + + ui.label(RichText::new(name).strong().color(DashColors::text_primary(dark_mode))); + + // Username or identity ID + if let Some(username) = &request.from_username { + ui.label( + RichText::new(format!("@{}", username)).small().color(DashColors::text_secondary(dark_mode)), + ); + } else { + // Show full identity ID + ui.label( + RichText::new(format!("ID: {}", request.from_identity.to_string(Encoding::Base58))) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + } + + // Account label + if let Some(label) = &request.account_label { + ui.label( + RichText::new(format!("Account: {}", label)) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + } + + // Timestamp + ui.label( + RichText::new("Received: 1 day ago").small().color(DashColors::text_secondary(dark_mode)), + ); + }); + + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + // Check if this request has been accepted or rejected + if self.accepted_requests.contains(&request.request_id) { + // Show checkmark and "Accepted" text + ui.label( + RichText::new("Accepted") + .color(egui::Color32::from_rgb(0, 150, 0)) + .strong() + ); + } else if self.rejected_requests.contains(&request.request_id) { + // Show X and "Rejected" text + ui.label( + RichText::new("Rejected") + .color(egui::Color32::from_rgb(150, 0, 0)) + .strong() + ); + } else { + // Check wallet lock status before showing buttons + let wallet_locked = if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.message = Some((e, MessageType::Error)); + } + wallet_needs_unlock(wallet) + } else { + false + }; + + if wallet_locked { + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + } else { + // Show Accept/Reject buttons + if ui.button("Reject").clicked() { + // Show confirmation dialog for reject + let name = request.from_display_name.as_ref() + .or(request.from_username.as_ref()) + .cloned() + .unwrap_or_else(|| { + let id_str = request.from_identity.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); + format!("{}...{}", &id_str[..6], &id_str[id_str.len()-6..]) + }); + + self.reject_confirmation_dialog = Some(( + ConfirmationDialog::new( + "Reject Contact Request", + format!("Are you sure you want to reject the contact request from {}?", name) + ) + .confirm_text(Some("Reject")) + .cancel_text(Some("Cancel")) + .danger_mode(true), + request.clone() + )); + } + + if ui.button("Accept").clicked() { + // Show confirmation dialog for accept + let name = request.from_display_name.as_ref() + .or(request.from_username.as_ref()) + .cloned() + .unwrap_or_else(|| { + let id_str = request.from_identity.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); + format!("{}...{}", &id_str[..6], &id_str[id_str.len()-6..]) + }); + + self.accept_confirmation_dialog = Some(( + ConfirmationDialog::new( + "Accept Contact Request", + format!("Are you sure you want to accept the contact request from {}?", name) + ) + .confirm_text(Some("Accept")) + .cancel_text(Some("Cancel")), + request.clone() + )); + } + } + } + }, + ); + }); + }); + ui.add_space(4.0); + } + } + }); + } + } + RequestTab::Outgoing => { + // Loading indicator + if self.loading { + ui.horizontal(|ui| { + ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); + + // Show specific loading message based on current message + if let Some((msg, _)) = &self.message { + ui.label(msg); + } else { + ui.label("Loading..."); + } + }); + } else { + ScrollArea::vertical().id_salt("outgoing_requests_scroll").show(ui, |ui| { + if self.outgoing_requests.is_empty() { + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::group(ui.style()) + .fill(ui.visuals().extreme_bg_color) + .corner_radius(5.0) + .outer_margin(Margin::same(20)) + .shadow(ui.visuals().window_shadow) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(10.0); + ui.label( + RichText::new("No Outgoing Requests") + .strong() + .size(20.0) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(5.0); + ui.label( + RichText::new("You haven't sent any contact requests.") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(15.0); + let add_button = egui::Button::new( + RichText::new("Add Contact").color(egui::Color32::WHITE), + ) + .fill(egui::Color32::from_rgb(0, 141, 228)); + if ui.add(add_button).clicked() { + action = AppAction::AddScreen( + ScreenType::DashPayAddContact.create_screen(&self.app_context), + ); + } + ui.add_space(10.0); + }); + }); + } else { + let requests: Vec<_> = self.outgoing_requests.values().cloned().collect(); + for request in requests { + ui.group(|ui| { + ui.horizontal(|ui| { + // Avatar placeholder + ui.add(egui::Label::new(RichText::new("👤").size(30.0).color(DashColors::DEEP_BLUE))); + + ui.vertical(|ui| { + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // For outgoing requests, show the TO identity + let id_str = request.to_identity.to_string(Encoding::Base58); + let name = format!("To: {}...{}", &id_str[..6], &id_str[id_str.len()-6..]); + + ui.label(RichText::new(name).strong().color(DashColors::text_primary(dark_mode))); + + // Show full identity ID + ui.label( + RichText::new(format!("ID: {}", id_str)) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + + // Account label + if let Some(label) = &request.account_label { + ui.label( + RichText::new(format!("Account: {}", label)) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + } + + // Status + ui.label(RichText::new("Status: Pending").small().color(DashColors::text_secondary(dark_mode))); + ui.label(RichText::new("Sent: 2 days ago").small().color(DashColors::text_secondary(dark_mode))); + }); + + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui.button("Cancel").clicked() { + // TODO: Cancel outgoing request + self.display_message( + "Request cancelled", + MessageType::Info, + ); + } + }, + ); + }); + }); + ui.add_space(4.0); + } + } + }); + } + } + } + + action + } +} + +impl ScreenLike for ContactRequests { + fn refresh_on_arrival(&mut self) { + // Load requests from database when screen is shown + if self.selected_identity.is_some() { + self.load_requests_from_database(); + } + } + + fn ui(&mut self, ctx: &egui::Context) -> AppAction { + // Create a simple central panel for rendering + let mut action = AppAction::None; + egui::CentralPanel::default().show(ctx, |ui| { + action = self.render(ui); + }); + + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully, UI will update on next frame + } + } + + action + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + // Clear loading state when displaying any message (including errors) + self.loading = false; + + // Check if this is an error about missing keys + if message_type == MessageType::Error { + if message.contains("ENCRYPTION key") { + self.error = Some(DashPayError::MissingEncryptionKey); + self.message = None; + return; + } else if message.contains("DECRYPTION key") { + self.error = Some(DashPayError::MissingDecryptionKey); + self.message = None; + return; + } + } + + self.message = Some((message.to_string(), message_type)); + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + use dash_sdk::dpp::document::DocumentV0Getters; + + self.loading = false; + + match result { + BackendTaskSuccessResult::DashPayContactRequests { incoming, outgoing } => { + tracing::debug!( + "Received DashPayContactRequests result: {} incoming, {} outgoing", + incoming.len(), + outgoing.len() + ); + + // Clear existing requests + self.incoming_requests.clear(); + self.outgoing_requests.clear(); + + // Mark as fetched + self.has_fetched_requests = true; + + // Get current identity for saving to database + let current_identity_id = self.selected_identity.as_ref().unwrap().identity.id(); + + // Process incoming requests + for (id, doc) in incoming.iter() { + let properties = doc.properties(); + let from_identity = doc.owner_id(); + + let account_reference = properties + .get("accountReference") + .and_then(|v| v.as_integer::()) + .and_then(|i| u32::try_from(i).ok()) + .unwrap_or(0); + + let timestamp = doc.created_at().or_else(|| doc.updated_at()).unwrap_or(0); + + let request = ContactRequest { + request_id: *id, + from_identity, + to_identity: current_identity_id, + from_username: None, // TODO: Resolve username from identity + from_display_name: None, // TODO: Fetch from profile + account_reference, + account_label: None, // TODO: Decrypt if present + timestamp, + auto_accept_proof: None, + }; + + self.incoming_requests.insert(*id, request.clone()); + + // Save to database as received request + let network_str = self.app_context.network.to_string(); + tracing::debug!( + "Saving incoming contact request to database: from={}, to={}, network={}", + from_identity, + current_identity_id, + network_str + ); + match self.app_context.db.save_contact_request( + &from_identity, + ¤t_identity_id, + &network_str, + None, // to_username + request.account_label.as_deref(), + "received", + ) { + Ok(id) => tracing::debug!("Saved incoming contact request with id {}", id), + Err(e) => tracing::error!("Failed to save incoming contact request: {}", e), + } + } + + // Process outgoing requests + for (id, doc) in outgoing.iter() { + let properties = doc.properties(); + let to_identity = properties + .get("toUserId") + .and_then(|v| v.to_identifier().ok()) + .unwrap_or_default(); + + let account_reference = properties + .get("accountReference") + .and_then(|v| v.as_integer::()) + .and_then(|i| u32::try_from(i).ok()) + .unwrap_or(0); + + let timestamp = doc.created_at().or_else(|| doc.updated_at()).unwrap_or(0); + + let request = ContactRequest { + request_id: *id, + from_identity: current_identity_id, + to_identity, + from_username: None, // This would be our username + from_display_name: None, // This would be our display name + account_reference, + account_label: None, // TODO: Decrypt if present + timestamp, + auto_accept_proof: None, + }; + + self.outgoing_requests.insert(*id, request.clone()); + + // Save to database as sent request + let network_str = self.app_context.network.to_string(); + tracing::debug!( + "Saving outgoing contact request to database: from={}, to={}, network={}", + current_identity_id, + to_identity, + network_str + ); + match self.app_context.db.save_contact_request( + ¤t_identity_id, + &to_identity, + &network_str, + None, // to_username + request.account_label.as_deref(), + "sent", + ) { + Ok(id) => tracing::debug!("Saved outgoing contact request with id {}", id), + Err(e) => tracing::error!("Failed to save outgoing contact request: {}", e), + } + } + + // Don't show a message, just display the results + } + BackendTaskSuccessResult::DashPayContactRequestAccepted(request_id) => { + // Mark as accepted only after successful backend operation + self.accepted_requests.insert(request_id); + self.message = Some(( + "Contact request accepted successfully".to_string(), + MessageType::Success, + )); + } + BackendTaskSuccessResult::DashPayContactRequestRejected(request_id) => { + // Mark as rejected only after successful backend operation + self.rejected_requests.insert(request_id); + self.message = Some(("Contact request rejected".to_string(), MessageType::Success)); + } + BackendTaskSuccessResult::DashPayContactAlreadyEstablished(_) => { + self.message = Some(("Contact already established".to_string(), MessageType::Info)); + } + BackendTaskSuccessResult::Message(msg) => { + // Check if this is an error message about missing keys + if msg.contains("ENCRYPTION key") { + self.error = Some(DashPayError::MissingEncryptionKey); + self.message = None; + } else if msg.contains("DECRYPTION key") { + self.error = Some(DashPayError::MissingDecryptionKey); + self.message = None; + } else { + self.message = Some((msg, MessageType::Success)); + } + } + _ => { + // Ignore other results + } + } + } +} diff --git a/src/ui/dashpay/contacts_list.rs b/src/ui/dashpay/contacts_list.rs new file mode 100644 index 000000000..23cb6f1cb --- /dev/null +++ b/src/ui/dashpay/contacts_list.rs @@ -0,0 +1,1184 @@ +use crate::app::AppAction; +use crate::backend_task::dashpay::DashPayTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; + +use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::components::identity_selector::IdentitySelector; +use crate::ui::components::wallet_unlock_popup::WalletUnlockResult; +use crate::ui::dashpay::contact_requests::ContactRequests; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, ScreenLike, ScreenType}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::Identifier; +use egui::{ColorImage, Frame, Margin, RichText, ScrollArea, TextureHandle, Ui}; +use std::collections::{BTreeMap, HashSet}; +use std::sync::Arc; + +#[derive(Debug, Clone)] +pub struct Contact { + pub identity_id: Identifier, + pub username: Option, + pub display_name: Option, + pub avatar_url: Option, + pub bio: Option, + pub nickname: Option, + pub is_hidden: bool, + pub account_reference: u32, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SearchFilter { + All, + WithUsernames, // Only contacts with usernames + WithoutUsernames, // Only contacts without usernames + WithBio, // Contacts with bio + Recent, // Recently added (TODO: needs database timestamp) + Hidden, // Only hidden contacts + Visible, // Only visible contacts +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SortOrder { + Name, // Sort by display name/username + Username, // Sort by username specifically + DateAdded, // Sort by date added (TODO: needs database timestamp) + AccountRef, // Sort by account reference number +} + +/// Tab for the combined Contacts screen +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContactsTab { + Contacts, + Requests, +} + +pub struct ContactsList { + pub app_context: Arc, + contacts: BTreeMap, + selected_identity: Option, + selected_identity_string: String, + search_query: String, + message: Option<(String, MessageType)>, + loading: bool, + has_loaded: bool, // Track if we've ever loaded contacts + show_hidden: bool, + search_filter: SearchFilter, + sort_order: SortOrder, + avatar_textures: BTreeMap, // Cache for avatar textures by URL + avatars_loading: HashSet, // Track which avatars are being loaded + /// Current active tab + active_tab: ContactsTab, + /// Embedded contact requests component + pub contact_requests: ContactRequests, +} + +impl ContactsList { + pub fn new(app_context: Arc) -> Self { + let mut new_self = Self { + app_context: app_context.clone(), + contacts: BTreeMap::new(), + selected_identity: None, + selected_identity_string: String::new(), + search_query: String::new(), + message: None, + loading: false, + has_loaded: false, + show_hidden: false, + search_filter: SearchFilter::All, + sort_order: SortOrder::Name, + avatar_textures: BTreeMap::new(), + avatars_loading: HashSet::new(), + active_tab: ContactsTab::Contacts, + contact_requests: ContactRequests::new(app_context.clone()), + }; + + // Auto-select first identity on creation if available + if let Ok(identities) = app_context.load_local_qualified_identities() + && !identities.is_empty() + { + new_self.selected_identity = Some(identities[0].clone()); + new_self.selected_identity_string = + identities[0].identity.id().to_string(Encoding::Base58); + + // Load contacts from database for this identity + new_self.load_contacts_from_database(); + } + + new_self + } + + fn load_contacts_from_database(&mut self) { + // Load saved contacts for the selected identity from database + if let Some(identity) = &self.selected_identity { + let identity_id = identity.identity.id(); + let network_str = self.app_context.network.to_string(); + + // Load saved contacts from database + if let Ok(stored_contacts) = self + .app_context + .db + .load_dashpay_contacts(&identity_id, &network_str) + { + for stored_contact in stored_contacts { + // Convert stored contact to Contact struct + if let Ok(contact_id) = + Identifier::from_bytes(&stored_contact.contact_identity_id) + { + let contact = Contact { + identity_id: contact_id, + username: stored_contact.username.clone(), + display_name: stored_contact.display_name.clone().or_else(|| { + Some(format!( + "Contact ({})", + &contact_id.to_string(Encoding::Base58)[0..8] + )) + }), + avatar_url: stored_contact.avatar_url.clone(), + bio: None, // Bio could be loaded from profile if needed + nickname: None, // Will be loaded separately from contact_private_info + is_hidden: false, // Will be loaded separately from contact_private_info + account_reference: 0, // This would need to be loaded from contactInfo document + }; + + // Only add if contact status is accepted + if stored_contact.contact_status == "accepted" { + self.contacts.insert(contact_id, contact); + } + } + } + + // Also load private contact info to populate nickname and hidden status + if let Ok(private_infos) = self + .app_context + .db + .load_all_contact_private_info(&identity_id) + { + for info in private_infos { + if let Ok(contact_id) = Identifier::from_bytes(&info.contact_identity_id) + && let Some(contact) = self.contacts.get_mut(&contact_id) + { + contact.nickname = if info.nickname.is_empty() { + None + } else { + Some(info.nickname) + }; + contact.is_hidden = info.is_hidden; + } + } + } + } + } + } + + pub fn trigger_fetch_contacts(&mut self) -> AppAction { + // Only fetch if we have a selected identity + if let Some(identity) = &self.selected_identity { + self.loading = true; + self.message = None; // Clear any existing message + + let task = BackendTask::DashPayTask(Box::new(DashPayTask::LoadContacts { + identity: identity.clone(), + })); + + return AppAction::BackendTask(task); + } + + AppAction::None + } + + pub fn fetch_contacts(&mut self) -> AppAction { + self.trigger_fetch_contacts() + } + + pub fn trigger_fetch_requests(&mut self) -> AppAction { + self.contact_requests.trigger_fetch_requests() + } + + /// Set the active tab + pub fn set_active_tab(&mut self, tab: ContactsTab) { + self.active_tab = tab; + } + + pub fn refresh(&mut self) -> AppAction { + // Don't clear contacts - preserve loaded state + // Only clear temporary states + self.message = None; + self.loading = false; + + // Auto-select first identity if none selected + if self.selected_identity.is_none() + && let Ok(identities) = self.app_context.load_local_qualified_identities() + && !identities.is_empty() + { + self.selected_identity = Some(identities[0].clone()); + self.selected_identity_string = identities[0].identity.id().to_string(Encoding::Base58); + } + + // Load contacts from database if we have an identity selected and no contacts loaded + if self.selected_identity.is_some() && self.contacts.is_empty() { + self.load_contacts_from_database(); + } + + // Also refresh contact requests + let _ = self.contact_requests.refresh(); + + AppAction::None + } + + /// Load an avatar image from a URL asynchronously + fn load_avatar_texture(&mut self, ctx: &egui::Context, url: &str) { + // Mark as loading + self.avatars_loading.insert(url.to_string()); + + let ctx_clone = ctx.clone(); + let url_clone = url.to_string(); + + // Spawn async task to fetch and load the image + tokio::spawn(async move { + match crate::backend_task::dashpay::avatar_processing::fetch_image_bytes(&url_clone) + .await + { + Ok(image_bytes) => { + // Try to load the image + if let Ok(image) = image::load_from_memory(&image_bytes) { + // Convert to RGBA + let rgba_image = image.to_rgba8(); + let width = rgba_image.width(); + let height = rgba_image.height(); + + // Center-crop to square if not already square + let cropped_image = if width != height { + let size = width.min(height); + let x_offset = (width - size) / 2; + let y_offset = (height - size) / 2; + image::imageops::crop_imm(&rgba_image, x_offset, y_offset, size, size) + .to_image() + } else { + rgba_image + }; + + let size = [ + cropped_image.width() as usize, + cropped_image.height() as usize, + ]; + let pixels = cropped_image.into_raw(); + + // Create ColorImage + let color_image = ColorImage::from_rgba_unmultiplied(size, &pixels); + + // Request repaint to load texture in UI thread + ctx_clone.request_repaint(); + + // Store the image data temporarily for the UI thread to pick up + ctx_clone.data_mut(|data| { + data.insert_temp( + egui::Id::new(format!("contact_avatar_data_{}", url_clone)), + color_image, + ); + }); + } + } + Err(e) => { + eprintln!("Failed to fetch contact avatar image: {}", e); + } + } + }); + } + + pub fn render(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Identity selector + let identities = self + .app_context + .load_local_qualified_identities() + .unwrap_or_default(); + + // Header section with identity selector on the right + ui.horizontal(|ui| { + ui.heading("Contacts"); + + if !identities.is_empty() { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let response = ui.add( + IdentitySelector::new( + "contacts_identity_selector", + &mut self.selected_identity_string, + &identities, + ) + .selected_identity(&mut self.selected_identity) + .unwrap() + .width(300.0) + .other_option(false), + ); + + if response.changed() { + // Clear contacts and avatar caches when identity changes + self.contacts.clear(); + self.avatar_textures.clear(); + self.avatars_loading.clear(); + self.message = None; + self.loading = false; + + // Load contacts from database for the newly selected identity + self.load_contacts_from_database(); + + // Sync selected identity to contact_requests + self.contact_requests + .set_selected_identity(self.selected_identity.clone()); + } + }); + } + }); + + ui.separator(); + + // Tab bar + ui.horizontal(|ui| { + let contacts_tab = egui::Button::new(RichText::new("My Contacts").color( + if self.active_tab == ContactsTab::Contacts { + DashColors::WHITE + } else { + DashColors::text_primary(dark_mode) + }, + )) + .fill(if self.active_tab == ContactsTab::Contacts { + DashColors::DASH_BLUE + } else { + DashColors::glass_white(dark_mode) + }) + .stroke(if self.active_tab == ContactsTab::Contacts { + egui::Stroke::NONE + } else { + egui::Stroke::new(1.0, DashColors::border(dark_mode)) + }) + .corner_radius(egui::CornerRadius::same(4)) + .min_size(egui::Vec2::new(120.0, 28.0)); + + if ui.add(contacts_tab).clicked() { + self.active_tab = ContactsTab::Contacts; + } + + ui.add_space(8.0); + + // Get pending request count for badge + let pending_count = self.contact_requests.pending_incoming_count(); + let requests_label = if pending_count > 0 { + format!("Requests ({})", pending_count) + } else { + "Requests".to_string() + }; + + let requests_tab = egui::Button::new(RichText::new(requests_label).color( + if self.active_tab == ContactsTab::Requests { + DashColors::WHITE + } else { + DashColors::text_primary(dark_mode) + }, + )) + .fill(if self.active_tab == ContactsTab::Requests { + DashColors::DASH_BLUE + } else { + DashColors::glass_white(dark_mode) + }) + .stroke(if self.active_tab == ContactsTab::Requests { + egui::Stroke::NONE + } else { + egui::Stroke::new(1.0, DashColors::border(dark_mode)) + }) + .corner_radius(egui::CornerRadius::same(4)) + .min_size(egui::Vec2::new(120.0, 28.0)); + + if ui.add(requests_tab).clicked() { + self.active_tab = ContactsTab::Requests; + } + }); + + ui.add_space(8.0); + + if identities.is_empty() { + return super::render_no_identities_card(ui, &self.app_context); + } else if self.active_tab == ContactsTab::Requests { + // Sync identity before rendering (in case it wasn't synced yet) + self.contact_requests + .set_selected_identity(self.selected_identity.clone()); + // Render the contact requests tab without its own header + action |= self.contact_requests.render_embedded(ui); + + // Show wallet unlock popup if open (needed because we're embedding contact_requests) + if self.contact_requests.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.contact_requests.selected_wallet + { + let result = self.contact_requests.wallet_unlock_popup.show( + ui.ctx(), + wallet, + &self.app_context, + ); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully, UI will update on next frame + } + } + + return action; + } + + // Contacts tab - show search/filter/sort controls if there are contacts + { + // Only show search/filter/sort controls if there are contacts + if !self.contacts.is_empty() { + // Search bar + ui.horizontal(|ui| { + ui.set_min_height(40.0); + ui.label("Search:"); + ui.add(egui::TextEdit::singleline(&mut self.search_query).desired_width(200.0)); + if ui.button("Clear").clicked() { + self.search_query.clear(); + } + + ui.separator(); + + // Filter and sort options in one line + ui.vertical(|ui| { + ui.add_space(11.0); + ui.label("Filter:"); + }); + ui.vertical(|ui| { + ui.add_space(4.0); + egui::ComboBox::from_id_salt("filter_combo") + .selected_text(match self.search_filter { + SearchFilter::All => "All", + SearchFilter::WithUsernames => "With usernames", + SearchFilter::WithoutUsernames => "No usernames", + SearchFilter::WithBio => "With bio", + SearchFilter::Recent => "Recent", + SearchFilter::Hidden => "Hidden", + SearchFilter::Visible => "Visible", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.search_filter, + SearchFilter::All, + "All", + ); + ui.selectable_value( + &mut self.search_filter, + SearchFilter::WithUsernames, + "With usernames", + ); + ui.selectable_value( + &mut self.search_filter, + SearchFilter::WithoutUsernames, + "No usernames", + ); + ui.selectable_value( + &mut self.search_filter, + SearchFilter::WithBio, + "With bio", + ); + ui.selectable_value( + &mut self.search_filter, + SearchFilter::Hidden, + "Hidden", + ); + ui.selectable_value( + &mut self.search_filter, + SearchFilter::Visible, + "Visible", + ); + }); + }); + + ui.separator(); + + ui.vertical(|ui| { + ui.add_space(11.0); + ui.label("Sort:"); + }); + ui.vertical(|ui| { + ui.add_space(4.0); + egui::ComboBox::from_id_salt("sort_combo") + .selected_text(match self.sort_order { + SortOrder::Name => "Name", + SortOrder::Username => "Username", + SortOrder::DateAdded => "Date", + SortOrder::AccountRef => "Account", + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.sort_order, SortOrder::Name, "Name"); + ui.selectable_value( + &mut self.sort_order, + SortOrder::Username, + "Username", + ); + ui.selectable_value( + &mut self.sort_order, + SortOrder::AccountRef, + "Account", + ); + }); + }); + + ui.separator(); + + ui.checkbox(&mut self.show_hidden, "Show hidden"); + }); + + ui.separator(); + } + } + + // Show message if any + if let Some((message, message_type)) = &self.message { + let color = match message_type { + MessageType::Success => egui::Color32::DARK_GREEN, + MessageType::Error => egui::Color32::DARK_RED, + MessageType::Info => egui::Color32::LIGHT_BLUE, + }; + ui.colored_label(color, message); + ui.separator(); + } + + // Loading indicator + if self.loading { + ui.horizontal(|ui| { + ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); + ui.label("Loading contacts..."); + }); + return action; + } + + // No identity selected or no identities available + if identities.is_empty() { + return action; + } + + if self.selected_identity.is_none() { + ui.label("Please select an identity to view contacts"); + return action; + } + + // Filter contacts based on search, filter, and hidden status + let query = self.search_query.to_lowercase(); + + let mut filtered_contacts: Vec<_> = self + .contacts + .values() + .filter(|contact| { + // Apply search filter first + match self.search_filter { + SearchFilter::WithUsernames if contact.username.is_none() => return false, + SearchFilter::WithoutUsernames if contact.username.is_some() => return false, + SearchFilter::WithBio if contact.bio.is_none() => return false, + SearchFilter::Hidden if !contact.is_hidden => return false, + SearchFilter::Visible if contact.is_hidden => return false, + SearchFilter::Recent => { + // TODO: Implement when we have timestamp data + // For now, treat as "All" + } + _ => {} // SearchFilter::All or other cases pass through + } + + // Filter by hidden status (unless we're specifically filtering for hidden) + if matches!(self.search_filter, SearchFilter::Hidden) { + // When filtering for hidden, ignore the show_hidden setting + } else if contact.is_hidden && !self.show_hidden { + return false; + } + + // Filter by search query + if query.is_empty() { + return true; + } + + // Enhanced search functionality + let search_in_text = |text: &str| text.to_lowercase().contains(&query); + + // Search in username + if let Some(username) = &contact.username + && search_in_text(username) + { + return true; + } + + // Search in display name + if let Some(display_name) = &contact.display_name + && search_in_text(display_name) + { + return true; + } + + // Search in nickname + if let Some(nickname) = &contact.nickname + && search_in_text(nickname) + { + return true; + } + + // Search in bio + if let Some(bio) = &contact.bio + && search_in_text(bio) + { + return true; + } + + // Search in identity ID (partial match) + let identity_str = contact.identity_id.to_string(Encoding::Base58); + if search_in_text(&identity_str) { + return true; + } + + false + }) + .cloned() + .collect(); + + // Sort contacts based on selected sort order + filtered_contacts.sort_by(|a, b| { + match self.sort_order { + SortOrder::Name => { + let name_a = a + .nickname + .as_ref() + .or(a.display_name.as_ref()) + .or(a.username.as_ref()) + .map(|s| s.to_lowercase()) + .unwrap_or_else(|| "zzz".to_string()); + let name_b = b + .nickname + .as_ref() + .or(b.display_name.as_ref()) + .or(b.username.as_ref()) + .map(|s| s.to_lowercase()) + .unwrap_or_else(|| "zzz".to_string()); + name_a.cmp(&name_b) + } + SortOrder::Username => { + let username_a = a + .username + .as_ref() + .map(|s| s.to_lowercase()) + .unwrap_or_else(|| "zzz".to_string()); + let username_b = b + .username + .as_ref() + .map(|s| s.to_lowercase()) + .unwrap_or_else(|| "zzz".to_string()); + username_a.cmp(&username_b) + } + SortOrder::AccountRef => a.account_reference.cmp(&b.account_reference), + SortOrder::DateAdded => { + // TODO: Implement when we have timestamp data + // For now, sort by identity ID as a proxy + a.identity_id.cmp(&b.identity_id) + } + } + }); + + // Contacts list + ScrollArea::vertical() + .id_salt("contacts_list_scroll") + .show(ui, |ui| { + if self.contacts.is_empty() { + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::group(ui.style()) + .fill(ui.visuals().extreme_bg_color) + .corner_radius(5.0) + .outer_margin(Margin::same(20)) + .shadow(ui.visuals().window_shadow) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(10.0); + ui.label( + RichText::new("No Contacts") + .strong() + .size(20.0) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(5.0); + ui.label( + RichText::new("You haven't added any contacts yet.") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(15.0); + let add_button = egui::Button::new( + RichText::new("Add Contact").color(egui::Color32::WHITE), + ) + .fill(egui::Color32::from_rgb(0, 141, 228)); + if ui.add(add_button).clicked() { + action = AppAction::AddScreen( + ScreenType::DashPayAddContact + .create_screen(&self.app_context), + ); + } + ui.add_space(10.0); + }); + }); + } else if filtered_contacts.is_empty() { + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::group(ui.style()) + .fill(ui.visuals().extreme_bg_color) + .corner_radius(5.0) + .outer_margin(Margin::same(20)) + .shadow(ui.visuals().window_shadow) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(10.0); + ui.label( + RichText::new("No Matches") + .strong() + .size(20.0) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(5.0); + ui.label( + RichText::new("No contacts match your search.") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(10.0); + }); + }); + } else { + // Collect avatar URLs that need to be loaded + let mut avatars_to_load: Vec = Vec::new(); + + for contact in filtered_contacts { + let avatar_url_clone = contact.avatar_url.clone(); + ui.group(|ui| { + ui.horizontal(|ui| { + // Avatar display + ui.vertical(|ui| { + ui.add_space(5.0); + const AVATAR_SIZE: f32 = 40.0; + + if let Some(ref url) = avatar_url_clone { + if !url.is_empty() { + let texture_id = format!("contact_avatar_{}", url); + + // Check if texture is already cached + if let Some(texture) = + self.avatar_textures.get(&texture_id) + { + // Display the cached avatar image + ui.add( + egui::Image::new(texture) + .fit_to_exact_size(egui::vec2( + AVATAR_SIZE, + AVATAR_SIZE, + )) + .corner_radius(AVATAR_SIZE / 2.0), + ); + } else { + // Check if image data was loaded by async task + let data_id = + format!("contact_avatar_data_{}", url); + let color_image = ui.ctx().data_mut(|data| { + data.get_temp::(egui::Id::new( + &data_id, + )) + }); + + if let Some(color_image) = color_image { + // Create texture from loaded image + let texture = ui.ctx().load_texture( + &texture_id, + color_image, + egui::TextureOptions::LINEAR, + ); + + // Display the image + ui.add( + egui::Image::new(&texture) + .fit_to_exact_size(egui::vec2( + AVATAR_SIZE, + AVATAR_SIZE, + )) + .corner_radius(AVATAR_SIZE / 2.0), + ); + + // Cache the texture and clear loading state + self.avatar_textures + .insert(texture_id.clone(), texture); + self.avatars_loading.remove(url); + + // Clear the temporary data + ui.ctx().data_mut(|data| { + data.remove::(egui::Id::new( + &data_id, + )); + }); + } else if !self.avatars_loading.contains(url) { + // Queue for loading + avatars_to_load.push(url.clone()); + // Show spinner while loading + ui.add( + egui::Spinner::new() + .size(AVATAR_SIZE) + .color(DashColors::DASH_BLUE), + ); + } else { + // Show loading indicator + ui.add( + egui::Spinner::new() + .size(AVATAR_SIZE) + .color(DashColors::DASH_BLUE), + ); + } + } + } else { + // Empty URL, show default emoji + ui.label( + RichText::new("👤") + .size(AVATAR_SIZE) + .color(DashColors::DEEP_BLUE), + ); + } + } else { + // No avatar URL, show default emoji + ui.label( + RichText::new("👤") + .size(AVATAR_SIZE) + .color(DashColors::DEEP_BLUE), + ); + } + }); + + ui.add_space(10.0); + + ui.vertical(|ui| { + // Display name or username + let name = contact + .nickname + .as_ref() + .or(contact.display_name.as_ref()) + .or(contact.username.as_ref()) + .cloned() + .unwrap_or_else(|| "Unknown".to_string()); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Add hidden indicator to name if contact is hidden + let display_name = if contact.is_hidden { + format!("[Hidden] {}", name) + } else { + name + }; + + ui.label( + RichText::new(display_name) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + + // Username if different from display name + if let Some(username) = &contact.username + && (contact.display_name.is_some() + || contact.nickname.is_some()) + { + ui.label( + RichText::new(format!("@{}", username)) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + } + + // Bio + if let Some(bio) = &contact.bio { + ui.label( + RichText::new(bio) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + } + + // Account reference + if contact.account_reference > 0 { + ui.label( + RichText::new(format!( + "Account #{}", + contact.account_reference + )) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + } + }); + + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + // Hide/Unhide button + let hide_button_text = + if contact.is_hidden { "Unhide" } else { "Hide" }; + if ui.button(hide_button_text).clicked() { + let new_hidden = !contact.is_hidden; + if let Some(identity) = &self.selected_identity { + let owner_id = identity.identity.id(); + if let Err(e) = + self.app_context.db.set_contact_hidden( + &owner_id, + &contact.identity_id, + new_hidden, + ) + { + self.message = Some(( + format!("Failed to update contact: {}", e), + MessageType::Error, + )); + } else { + // Update the contact in memory + if let Some(c) = + self.contacts.get_mut(&contact.identity_id) + { + c.is_hidden = new_hidden; + } + } + } + } + + // Pay button - requires SPV which is dev mode only + if self.app_context.is_developer_mode() + && ui.button("Pay").clicked() + { + action = AppAction::AddScreen( + ScreenType::DashPaySendPayment( + self.selected_identity.clone().unwrap(), + contact.identity_id, + ) + .create_screen(&self.app_context), + ); + } + + if ui.button("View Profile").clicked() { + action = AppAction::AddScreen( + ScreenType::DashPayContactProfileViewer( + self.selected_identity.clone().unwrap(), + contact.identity_id, + ) + .create_screen(&self.app_context), + ); + } + }, + ); + }); + }); + ui.add_space(4.0); + } + + // Load any avatars that were queued + for url in avatars_to_load { + self.load_avatar_texture(ui.ctx(), &url); + } + } + }); + + action + } + + pub fn display_message(&mut self, message: &str, message_type: MessageType) { + self.message = Some((message.to_string(), message_type)); + } +} + +impl ScreenLike for ContactsList { + fn refresh_on_arrival(&mut self) { + // Load contacts from database when screen is shown + if self.selected_identity.is_some() && self.contacts.is_empty() { + self.load_contacts_from_database(); + } + } + + fn ui(&mut self, ctx: &egui::Context) -> AppAction { + let mut action = AppAction::None; + egui::CentralPanel::default().show(ctx, |ui| { + action = self.render(ui); + }); + action + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + self.loading = false; + self.message = Some((message.to_string(), message_type)); + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + self.loading = false; + + match result { + BackendTaskSuccessResult::DashPayContacts(contact_ids) => { + // Clear existing contacts + self.contacts.clear(); + + // Convert contact IDs to Contact structs + for contact_id in contact_ids { + let contact = Contact { + identity_id: contact_id, + username: None, + display_name: Some(format!( + "Contact ({})", + &contact_id.to_string(Encoding::Base58)[0..8] + )), + avatar_url: None, + bio: None, + nickname: None, + is_hidden: false, + account_reference: 0, + }; + self.contacts.insert(contact_id, contact); + } + + // Mark as loaded and clear message + self.has_loaded = true; + self.message = None; + } + BackendTaskSuccessResult::DashPayContactsWithInfo(contacts_data) => { + // Clear existing contacts + self.contacts.clear(); + + // Save contacts to database if we have a selected identity + if let Some(identity) = &self.selected_identity { + let owner_id = identity.identity.id(); + let network_str = self.app_context.network.to_string(); + + // Clear all existing contacts for this identity from database first + // This prevents stale contacts from persisting + let _ = self + .app_context + .db + .clear_dashpay_contacts(&owner_id, &network_str); + + // Convert ContactData to Contact structs and save to database + for contact_data in contacts_data { + // Skip self-contacts (where contact is the same as the owner) + if contact_data.identity_id == owner_id { + continue; + } + let contact = Contact { + identity_id: contact_data.identity_id, + username: contact_data.username.clone(), + display_name: contact_data.display_name.clone().or_else(|| { + Some(format!( + "Contact ({})", + &contact_data.identity_id.to_string(Encoding::Base58)[0..8] + )) + }), + avatar_url: contact_data.avatar_url.clone(), + bio: contact_data.bio.clone(), + nickname: contact_data.nickname.clone(), + is_hidden: contact_data.is_hidden, + account_reference: contact_data.account_reference, + }; + self.contacts.insert(contact_data.identity_id, contact); + + // Save to database + let _ = self.app_context.db.save_dashpay_contact( + &owner_id, + &contact_data.identity_id, + &network_str, + contact_data.username.as_deref(), + contact_data.display_name.as_deref(), + contact_data.avatar_url.as_deref(), + None, // public_message - not yet fetched + "accepted", // Only accepted contacts are returned from load_contacts + ); + + // Save private info if present + if let Some(nickname) = &contact_data.nickname { + let _ = self.app_context.db.save_contact_private_info( + &owner_id, + &contact_data.identity_id, + nickname, + &contact_data.note.unwrap_or_default(), + contact_data.is_hidden, + ); + } + } + } else { + // No selected identity, just populate in-memory + for contact_data in contacts_data { + let contact = Contact { + identity_id: contact_data.identity_id, + username: contact_data.username, + display_name: contact_data.display_name.or_else(|| { + Some(format!( + "Contact ({})", + &contact_data.identity_id.to_string(Encoding::Base58)[0..8] + )) + }), + avatar_url: contact_data.avatar_url, + bio: contact_data.bio, + nickname: contact_data.nickname, + is_hidden: contact_data.is_hidden, + account_reference: contact_data.account_reference, + }; + self.contacts.insert(contact_data.identity_id, contact); + } + } + + // Mark as loaded and clear message + self.has_loaded = true; + self.message = None; + } + BackendTaskSuccessResult::DashPayContactProfile(Some(doc)) => { + // Extract profile information from the document + use dash_sdk::dpp::document::DocumentV0Getters; + let properties = doc.properties(); + let contact_id = doc.owner_id(); + + let display_name = properties + .get("displayName") + .and_then(|v| v.as_text()) + .map(|s| s.to_string()); + + let bio = properties + .get("bio") + .and_then(|v| v.as_text()) + .map(|s| s.to_string()); + + let avatar_url = properties + .get("avatarUrl") + .and_then(|v| v.as_text()) + .map(|s| s.to_string()); + + let public_message = properties + .get("publicMessage") + .and_then(|v| v.as_text()) + .map(|s| s.to_string()); + + // Update the contact with profile information + if let Some(contact) = self.contacts.get_mut(&contact_id) { + if let Some(name) = &display_name { + contact.display_name = Some(name.clone()); + } + if let Some(bio_text) = &bio { + contact.bio = Some(bio_text.clone()); + } + if let Some(url) = &avatar_url { + contact.avatar_url = Some(url.clone()); + } + + // Save updated profile to database if we have a selected identity + if let Some(identity) = &self.selected_identity { + let owner_id = identity.identity.id(); + let network_str = self.app_context.network.to_string(); + let _ = self.app_context.db.save_dashpay_contact( + &owner_id, + &contact_id, + &network_str, + contact.username.as_deref(), + contact.display_name.as_deref(), + contact.avatar_url.as_deref(), + public_message.as_deref(), + "accepted", + ); + } + } + } + _ => { + // Ignore other results + } + } + } +} diff --git a/src/ui/dashpay/dashpay_screen.rs b/src/ui/dashpay/dashpay_screen.rs new file mode 100644 index 000000000..8f97e6218 --- /dev/null +++ b/src/ui/dashpay/dashpay_screen.rs @@ -0,0 +1,200 @@ +use crate::app::{AppAction, BackendTasksExecutionMode, DesiredAppAction}; +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use egui::{Context, Ui}; +use std::sync::Arc; + +use super::contacts_list::ContactsList; +use super::profile_screen::ProfileScreen; +use super::send_payment::PaymentHistory; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DashPaySubscreen { + Contacts, + Profile, + Payments, + ProfileSearch, +} + +pub struct DashPayScreen { + pub app_context: Arc, + pub dashpay_subscreen: DashPaySubscreen, + pub contacts_list: ContactsList, + pub profile_screen: ProfileScreen, + pub payment_history: PaymentHistory, +} + +impl DashPayScreen { + pub fn new(app_context: &Arc, dashpay_subscreen: DashPaySubscreen) -> Self { + Self { + app_context: app_context.clone(), + dashpay_subscreen, + contacts_list: ContactsList::new(app_context.clone()), + profile_screen: ProfileScreen::new(app_context.clone()), + payment_history: PaymentHistory::new(app_context.clone()), + } + } + + fn render_subscreen(&mut self, ui: &mut Ui) -> AppAction { + match self.dashpay_subscreen { + DashPaySubscreen::Contacts => self.contacts_list.render(ui), + DashPaySubscreen::Profile => self.profile_screen.render(ui), + DashPaySubscreen::Payments => self.payment_history.render(ui), + DashPaySubscreen::ProfileSearch => { + // ProfileSearch is a separate screen, not embedded + ui.label("Use the Search Profiles tab to search for public profiles"); + AppAction::None + } + } + } +} + +impl ScreenLike for DashPayScreen { + fn refresh(&mut self) { + match self.dashpay_subscreen { + DashPaySubscreen::Contacts => { + self.contacts_list.refresh(); + } + DashPaySubscreen::Profile => self.profile_screen.refresh(), + DashPaySubscreen::Payments => self.payment_history.refresh(), + DashPaySubscreen::ProfileSearch => { + // ProfileSearch is a separate screen, not embedded here + } + } + } + + fn refresh_on_arrival(&mut self) { + self.refresh(); + } + + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = AppAction::None; + + // Add top panel with action buttons based on current subscreen + let right_buttons = match self.dashpay_subscreen { + DashPaySubscreen::Contacts => vec![ + ( + "Refresh", + DesiredAppAction::Custom("fetch_contacts_and_requests".to_string()), + ), + ( + "Add Contact", + DesiredAppAction::AddScreenType(Box::new( + crate::ui::ScreenType::DashPayAddContact, + )), + ), + ( + "Generate QR Code", + DesiredAppAction::AddScreenType(Box::new( + crate::ui::ScreenType::DashPayQRGenerator, + )), + ), + ], + DashPaySubscreen::Profile => vec![( + "Refresh", + DesiredAppAction::Custom("load_profile".to_string()), + )], + DashPaySubscreen::Payments => vec![( + "Refresh Payment History", + DesiredAppAction::Custom("fetch_payment_history".to_string()), + )], + DashPaySubscreen::ProfileSearch => vec![], + }; + + action |= add_top_panel( + ctx, + &self.app_context, + vec![("DashPay", AppAction::None)], + right_buttons, + ); + + // Highlight Dashpay in the main left panel + action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + + // DashPay subscreen chooser panel on the left side of the content area + action |= + add_dashpay_subscreen_chooser_panel(ctx, &self.app_context, self.dashpay_subscreen); + + // Main content area with island styling + action |= island_central_panel(ctx, |ui| self.render_subscreen(ui)); + + // Handle custom actions from top panel buttons + if let AppAction::Custom(command) = &action { + match command.as_str() { + "fetch_contacts_and_requests" => { + // Fetch both contacts and requests - run both tasks concurrently + let mut tasks = Vec::new(); + + // Get contacts task + if let AppAction::BackendTask(task) = + self.contacts_list.trigger_fetch_contacts() + { + tasks.push(task); + } + + // Get requests task + if let AppAction::BackendTask(task) = + self.contacts_list.trigger_fetch_requests() + { + tasks.push(task); + } + + if !tasks.is_empty() { + action = + AppAction::BackendTasks(tasks, BackendTasksExecutionMode::Concurrent); + } + } + "load_profile" => { + action = self.profile_screen.trigger_load_profile(); + } + "fetch_payment_history" => { + action = self.payment_history.trigger_fetch_payment_history(); + } + _ => {} + } + } + + action + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + match self.dashpay_subscreen { + DashPaySubscreen::Contacts => { + // Forward to both contacts list and embedded contact requests + self.contacts_list.display_message(message, message_type); + self.contacts_list + .contact_requests + .display_message(message, message_type); + } + DashPaySubscreen::Profile => self.profile_screen.display_message(message, message_type), + DashPaySubscreen::Payments => { + self.payment_history.display_message(message, message_type) + } + DashPaySubscreen::ProfileSearch => { + // ProfileSearch is a separate screen, not embedded here + } + } + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + match self.dashpay_subscreen { + DashPaySubscreen::Profile => self.profile_screen.display_task_result(result.clone()), + DashPaySubscreen::Contacts => { + // Forward to both contacts list and embedded contact requests + self.contacts_list.display_task_result(result.clone()); + self.contacts_list + .contact_requests + .display_task_result(result); + } + DashPaySubscreen::Payments => self.payment_history.display_task_result(result), + DashPaySubscreen::ProfileSearch => { + // ProfileSearch is a separate screen, not embedded here + } + } + } +} diff --git a/src/ui/dashpay/mod.rs b/src/ui/dashpay/mod.rs new file mode 100644 index 000000000..63ac34847 --- /dev/null +++ b/src/ui/dashpay/mod.rs @@ -0,0 +1,97 @@ +pub mod add_contact_screen; +pub mod contact_details; +pub mod contact_info_editor; +pub mod contact_profile_viewer; +pub mod contact_requests; +pub mod contacts_list; +pub mod dashpay_screen; +pub mod profile_screen; +pub mod profile_search; +pub mod qr_code_generator; +pub mod qr_scanner; +pub mod send_payment; + +pub use add_contact_screen::AddContactScreen; +pub use dashpay_screen::{DashPayScreen, DashPaySubscreen}; +pub use profile_search::ProfileSearchScreen; + +use crate::app::AppAction; +use crate::context::AppContext; +use crate::ui::ScreenType; +use egui::{Frame, Margin, RichText, Ui}; +use std::sync::Arc; + +/// Renders a styled "No Identities Loaded" card for DashPay screens. +/// Returns an AppAction if the user clicks the "Load Identity" button. +pub fn render_no_identities_card(ui: &mut Ui, app_context: &Arc) -> AppAction { + let dark_mode = ui.ctx().style().visuals.dark_mode; + + Frame::group(ui.style()) + .fill(ui.visuals().extreme_bg_color) + .corner_radius(5.0) + .outer_margin(Margin::same(20)) + .shadow(ui.visuals().window_shadow) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(5.0); + ui.label( + RichText::new("No Identities Loaded") + .strong() + .size(25.0) + .color(crate::ui::theme::DashColors::text_primary(dark_mode)), + ); + + ui.add_space(5.0); + ui.separator(); + ui.add_space(10.0); + + ui.label( + "To use DashPay features, you need to load or create an identity first.", + ); + + ui.add_space(10.0); + + ui.heading( + RichText::new("Here's what you can do:") + .strong() + .size(18.0) + .color(crate::ui::theme::DashColors::text_primary(dark_mode)), + ); + ui.add_space(5.0); + + ui.label("• LOAD an existing identity by clicking the button below, or"); + ui.add_space(1.0); + ui.label("• CREATE a new identity from the Identities screen after setting up a wallet."); + + ui.add_space(15.0); + + let button = egui::Button::new( + RichText::new("Load Identity") + .color(egui::Color32::WHITE) + .strong(), + ) + .fill(crate::ui::theme::DashColors::DASH_BLUE) + .min_size(egui::vec2(150.0, 36.0)); + + if ui.add(button).clicked() { + return AppAction::AddScreen( + ScreenType::AddExistingIdentity.create_screen(app_context), + ); + } + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + ui.label( + "(Make sure Dash Core is running. You can check in the network tab on the left.)", + ); + + ui.add_space(5.0); + + AppAction::None + }) + .inner + }) + .inner +} diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs new file mode 100644 index 000000000..1071b0ea2 --- /dev/null +++ b/src/ui/dashpay/profile_screen.rs @@ -0,0 +1,1523 @@ +use crate::app::AppAction; +use crate::backend_task::dashpay::DashPayTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::MessageType; +use crate::ui::components::identity_selector::IdentitySelector; +use crate::ui::components::info_popup::InfoPopup; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::identities::get_selected_wallet; +use crate::ui::theme::DashColors; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use egui::{ColorImage, Frame, Margin, RichText, ScrollArea, TextEdit, TextureHandle, Ui}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +const PROFILE_GUIDELINES_INFO_TEXT: &str = "Profile Guidelines:\n\n\ + Display names can include any UTF-8 characters (emojis, symbols, etc.).\n\n\ + Display names are limited to 25 characters.\n\n\ + Bios are limited to 250 characters.\n\n\ + Avatar URLs should point to publicly accessible images (max 500 chars).\n\n\ + Profiles are public and visible to all DashPay users."; + +const AVATAR_URL_INFO_TEXT: &str = "Avatar Image Guidelines:\n\n\ + The URL must point to a publicly accessible image.\n\n\ + Recommended: Square images (e.g., 256x256 or 512x512 pixels).\n\n\ + Supported formats: JPEG, PNG, WebP, or GIF.\n\n\ + Maximum URL length: 500 characters.\n\n\ + Example URL:\nhttps://example.com/images/avatar.jpg\n\n\ + Tip: Use image hosting services like Imgur, Cloudinary, or your own server."; + +#[derive(Debug, Clone)] +pub struct DashPayProfile { + pub display_name: String, + pub bio: String, + pub avatar_url: String, + pub avatar_bytes: Option>, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ValidationError { + DisplayNameTooLong(usize), + DisplayNameEmpty, + BioTooLong(usize), + InvalidAvatarUrl(String), + AvatarUrlTooLong(usize), +} + +impl ValidationError { + pub fn message(&self) -> String { + match self { + ValidationError::DisplayNameTooLong(len) => { + format!("Display name is {} characters, must be 25 or less", len) + } + ValidationError::DisplayNameEmpty => "Display name cannot be empty".to_string(), + ValidationError::BioTooLong(len) => { + format!("Bio is {} characters, must be 140 or less", len) + } + ValidationError::InvalidAvatarUrl(url) => { + format!( + "Invalid avatar URL: '{}'. Must start with http:// or https://", + url + ) + } + ValidationError::AvatarUrlTooLong(len) => { + format!("Avatar URL is {} characters, must be 500 or less", len) + } + } + } +} + +pub struct ProfileScreen { + pub app_context: Arc, + selected_identity: Option, + selected_identity_string: String, + profile: Option, + editing: bool, + edit_display_name: String, + edit_bio: String, + edit_avatar_url: String, + message: Option<(String, MessageType)>, + loading: bool, + saving: bool, // Track if we're saving vs loading + profile_load_attempted: bool, + validation_errors: Vec, + has_unsaved_changes: bool, + original_display_name: String, + original_bio: String, + original_avatar_url: String, + avatar_textures: HashMap, // Cache for avatar textures + avatar_loading: bool, // Track if avatar is being loaded + pending_action: Option>, // Action to execute on next frame + show_info_popup: bool, + show_avatar_info_popup: bool, + show_avatar_url_popup: bool, // Show avatar URL when clicking on avatar in view mode + selected_wallet: Option>>, + wallet_unlock_popup: WalletUnlockPopup, + show_success: bool, + was_creating_new: bool, // Track if we were creating vs updating +} + +impl ProfileScreen { + pub fn new(app_context: Arc) -> Self { + let mut new_self = Self { + app_context: app_context.clone(), + selected_identity: None, + selected_identity_string: String::new(), + profile: None, + editing: false, + edit_display_name: String::new(), + edit_bio: String::new(), + edit_avatar_url: String::new(), + message: None, + loading: false, + saving: false, + profile_load_attempted: false, + validation_errors: Vec::new(), + has_unsaved_changes: false, + original_display_name: String::new(), + original_bio: String::new(), + original_avatar_url: String::new(), + avatar_textures: HashMap::new(), + avatar_loading: false, + pending_action: None, + show_info_popup: false, + show_avatar_info_popup: false, + show_avatar_url_popup: false, + selected_wallet: None, + wallet_unlock_popup: WalletUnlockPopup::new(), + show_success: false, + was_creating_new: false, + }; + + // Auto-select identity on creation - prefer one with a profile + if let Ok(identities) = app_context.load_local_qualified_identities() + && !identities.is_empty() + { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + + // Try to find an identity with an actual profile (not just a "no profile" marker) + let network_str = app_context.network.to_string(); + tracing::info!( + "ProfileScreen::new - checking {} identities on network {}", + identities.len(), + network_str + ); + + let mut selected_idx = 0; + for (idx, identity) in identities.iter().enumerate() { + let identity_id = identity.identity.id(); + tracing::debug!("Checking identity {} for profile in DB", identity_id); + match app_context + .db + .load_dashpay_profile(&identity_id, &network_str) + { + Ok(Some(profile)) => { + tracing::debug!( + "Found profile for identity {}: display_name={:?}", + identity_id, + profile.display_name + ); + if profile.display_name.is_some() + || profile.bio.is_some() + || profile.avatar_url.is_some() + { + // Check if this is an actual profile with data (not a "no profile" marker) + selected_idx = idx; + tracing::info!("Selected identity {} with profile", identity_id); + break; + } + } + Ok(None) => { + tracing::debug!("No profile in DB for identity {}", identity_id); + } + Err(e) => { + tracing::error!( + "Error loading profile for identity {}: {}", + identity_id, + e + ); + } + } + } + + new_self.selected_identity = Some(identities[selected_idx].clone()); + new_self.selected_identity_string = identities[selected_idx] + .identity + .id() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); + + tracing::info!( + "ProfileScreen::new - selected identity {}", + new_self.selected_identity_string + ); + + // Get wallet for the selected identity + let mut error_message = None; + new_self.selected_wallet = get_selected_wallet( + &identities[selected_idx], + Some(&app_context), + None, + &mut error_message, + ); + + // Load profile from database for this identity + new_self.load_profile_from_database(); + } + + new_self + } + + fn validate_profile(&mut self) { + self.validation_errors.clear(); + + // Display name validation + if self.edit_display_name.trim().is_empty() { + self.validation_errors + .push(ValidationError::DisplayNameEmpty); + } else if self.edit_display_name.len() > 25 { + self.validation_errors + .push(ValidationError::DisplayNameTooLong( + self.edit_display_name.len(), + )); + } + + // Bio validation + if self.edit_bio.len() > 140 { + self.validation_errors + .push(ValidationError::BioTooLong(self.edit_bio.len())); + } + + // Avatar URL validation + if !self.edit_avatar_url.trim().is_empty() { + let url = self.edit_avatar_url.trim(); + if url.len() > 500 { + self.validation_errors + .push(ValidationError::AvatarUrlTooLong(url.len())); + } else if !url.starts_with("http://") && !url.starts_with("https://") { + self.validation_errors + .push(ValidationError::InvalidAvatarUrl(url.to_string())); + } + } + } + + fn check_for_changes(&mut self) { + self.has_unsaved_changes = self.edit_display_name != self.original_display_name + || self.edit_bio != self.original_bio + || self.edit_avatar_url != self.original_avatar_url; + } + + fn is_valid(&self) -> bool { + self.validation_errors.is_empty() + } + + fn load_profile_from_database(&mut self) { + // Load saved profile for the selected identity from database + if let Some(identity) = &self.selected_identity { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + let identity_id = identity.identity.id(); + let network_str = self.app_context.network.to_string(); + + tracing::debug!( + "Loading profile from database for identity {} on network {}", + identity_id, + network_str + ); + + // Load profile from database + match self + .app_context + .db + .load_dashpay_profile(&identity_id, &network_str) + { + Ok(Some(stored_profile)) => { + tracing::debug!( + "Found profile in database: display_name={:?}, bio={:?}, avatar_url={:?}", + stored_profile.display_name, + stored_profile.bio, + stored_profile.avatar_url + ); + // Check if this is a "no profile exists" marker (all fields are None) + if stored_profile.display_name.is_none() + && stored_profile.bio.is_none() + && stored_profile.avatar_url.is_none() + { + // This is a cached "no profile" state + self.profile = None; + self.profile_load_attempted = true; + } else { + // This is an actual profile with data + self.profile = Some(DashPayProfile { + display_name: stored_profile.display_name.unwrap_or_default(), + bio: stored_profile.bio.unwrap_or_default(), + avatar_url: stored_profile.avatar_url.unwrap_or_default(), + avatar_bytes: stored_profile.avatar_bytes, + }); + + // Update edit fields with loaded profile + if let Some(ref profile) = self.profile { + self.edit_display_name = profile.display_name.clone(); + self.edit_bio = profile.bio.clone(); + self.edit_avatar_url = profile.avatar_url.clone(); + + // Store original values for change detection + self.original_display_name = profile.display_name.clone(); + self.original_bio = profile.bio.clone(); + self.original_avatar_url = profile.avatar_url.clone(); + } + + // Mark as loaded from cache + self.profile_load_attempted = true; + } + } + Ok(None) => { + tracing::debug!("No profile found in database for identity {}", identity_id); + } + Err(e) => { + tracing::error!("Error loading profile from database: {}", e); + } + } + } + } + + pub fn trigger_load_profile(&mut self) -> AppAction { + if let Some(identity) = self.selected_identity.clone() { + self.loading = true; + self.profile_load_attempted = true; + AppAction::BackendTask(BackendTask::DashPayTask(Box::new( + DashPayTask::LoadProfile { identity }, + ))) + } else { + AppAction::None + } + } + + pub fn refresh(&mut self) { + // Don't set loading here - it will be set when actually triggering a backend task + // This prevents stuck loading states + self.loading = false; + + // Clear any old messages + self.message = None; + + // Auto-select first identity if none selected + if self.selected_identity.is_none() + && let Ok(identities) = self.app_context.load_local_qualified_identities() + && !identities.is_empty() + { + self.selected_identity = Some(identities[0].clone()); + self.selected_identity_string = identities[0].display_string(); + } + + // Load profile from database if we have an identity selected and no profile loaded + if self.selected_identity.is_some() + && self.profile.is_none() + && !self.profile_load_attempted + { + self.load_profile_from_database(); + } + } + + fn start_editing(&mut self) { + if let Some(profile) = &self.profile { + self.edit_display_name = profile.display_name.clone(); + self.edit_bio = profile.bio.clone(); + self.edit_avatar_url = profile.avatar_url.clone(); + + // Store originals for change detection + self.original_display_name = profile.display_name.clone(); + self.original_bio = profile.bio.clone(); + self.original_avatar_url = profile.avatar_url.clone(); + } else { + // New profile + self.edit_display_name.clear(); + self.edit_bio.clear(); + self.edit_avatar_url.clear(); + + // Store empty originals + self.original_display_name.clear(); + self.original_bio.clear(); + self.original_avatar_url.clear(); + } + + self.editing = true; + self.has_unsaved_changes = false; + self.validation_errors.clear(); + self.message = None; + } + + fn save_profile(&mut self) -> AppAction { + self.validate_profile(); + + if !self.is_valid() { + self.display_message(&self.validation_errors[0].message(), MessageType::Error); + return AppAction::None; + } + + if let Some(identity) = self.selected_identity.clone() { + // Track if this is a new profile creation + self.was_creating_new = self.profile.is_none(); + self.editing = false; + self.saving = true; + self.has_unsaved_changes = false; + + // Trim whitespace from inputs + let display_name = self.edit_display_name.trim(); + let bio = self.edit_bio.trim(); + let avatar_url = self.edit_avatar_url.trim(); + + // Trigger the actual DashPay profile update task + AppAction::BackendTask(BackendTask::DashPayTask(Box::new( + DashPayTask::UpdateProfile { + identity, + display_name: if display_name.is_empty() { + None + } else { + Some(display_name.to_string()) + }, + bio: if bio.is_empty() { + None + } else { + Some(bio.to_string()) + }, + avatar_url: if avatar_url.is_empty() { + None + } else { + Some(avatar_url.to_string()) + }, + }, + ))) + } else { + self.display_message("No identity selected", MessageType::Error); + AppAction::None + } + } + + fn cancel_editing(&mut self) { + self.editing = false; + self.edit_display_name.clear(); + self.edit_bio.clear(); + self.edit_avatar_url.clear(); + self.validation_errors.clear(); + self.has_unsaved_changes = false; + self.message = None; + } + + /// Load avatar texture from network (fetches bytes and processes them) + fn load_avatar_texture(&mut self, ctx: &egui::Context, url: &str) { + let ctx_clone = ctx.clone(); + let url_clone = url.to_string(); + + // Spawn async task to fetch and load the image + tokio::spawn(async move { + match crate::backend_task::dashpay::avatar_processing::fetch_image_bytes(&url_clone) + .await + { + Ok(image_bytes) => { + Self::process_avatar_bytes_async(ctx_clone, url_clone, image_bytes, true); + } + Err(e) => { + eprintln!("Failed to fetch avatar image: {}", e); + } + } + }); + } + + /// Load avatar texture from cached bytes synchronously + /// Returns the ColorImage if successful, or None if processing failed + fn process_avatar_bytes_sync(image_bytes: &[u8]) -> Option { + // Try to load the image + if let Ok(image) = image::load_from_memory(image_bytes) { + // Convert to RGBA + let rgba_image = image.to_rgba8(); + let width = rgba_image.width(); + let height = rgba_image.height(); + + // Center-crop to square if not already square + let cropped_image = if width != height { + let size = width.min(height); + let x_offset = (width - size) / 2; + let y_offset = (height - size) / 2; + image::imageops::crop_imm(&rgba_image, x_offset, y_offset, size, size).to_image() + } else { + rgba_image + }; + + let size = [ + cropped_image.width() as usize, + cropped_image.height() as usize, + ]; + let pixels = cropped_image.into_raw(); + + Some(ColorImage::from_rgba_unmultiplied(size, &pixels)) + } else { + None + } + } + + /// Process avatar bytes asynchronously and store result for UI thread + /// If `from_network` is true, also stores the raw bytes for database caching + fn process_avatar_bytes_async( + ctx: egui::Context, + url: String, + image_bytes: Vec, + from_network: bool, + ) { + // Try to load the image + if let Ok(image) = image::load_from_memory(&image_bytes) { + // Convert to RGBA + let rgba_image = image.to_rgba8(); + let width = rgba_image.width(); + let height = rgba_image.height(); + + // Center-crop to square if not already square + let cropped_image = if width != height { + let size = width.min(height); + let x_offset = (width - size) / 2; + let y_offset = (height - size) / 2; + image::imageops::crop_imm(&rgba_image, x_offset, y_offset, size, size).to_image() + } else { + rgba_image + }; + + let size = [ + cropped_image.width() as usize, + cropped_image.height() as usize, + ]; + let pixels = cropped_image.into_raw(); + + // Create ColorImage + let color_image = ColorImage::from_rgba_unmultiplied(size, &pixels); + + // Request repaint to load texture in UI thread + ctx.request_repaint(); + + // Store the image data temporarily for the UI thread to pick up + ctx.data_mut(|data| { + data.insert_temp(egui::Id::new(format!("avatar_data_{}", url)), color_image); + // Only store raw bytes if fetched from network (for database caching) + if from_network { + data.insert_temp(egui::Id::new(format!("avatar_bytes_{}", url)), image_bytes); + } + }); + } + } + + fn show_success_screen(&mut self, ui: &mut Ui) -> AppAction { + let success_message = if self.was_creating_new { + "DashPay Profile Created Successfully!" + } else { + "DashPay Profile Updated Successfully!" + }; + + let action = crate::ui::helpers::show_success_screen( + ui, + success_message.to_string(), + vec![( + "View Profile".to_string(), + AppAction::Custom("view_profile".to_string()), + )], + ); + + // Handle the custom action + if let AppAction::Custom(ref s) = action + && s == "view_profile" + { + self.show_success = false; + self.profile_load_attempted = true; // We already have the profile in memory + // Profile is already in self.profile from display_task_result, no need to reload + return AppAction::None; + } + + action + } + + pub fn render(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + + // Check for pending action from previous frame + if let Some(pending) = self.pending_action.take() { + action = *pending; + } + + // Show success screen if profile was just created/updated + if self.show_success { + return self.show_success_screen(ui); + } + + // Identity selector or no identities message + let identities = self + .app_context + .load_local_qualified_identities() + .unwrap_or_default(); + + // Header with identity selector on the right + ui.horizontal(|ui| { + ui.heading("My DashPay Profile"); + + if !identities.is_empty() { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let response = ui.add( + IdentitySelector::new( + "profile_identity_selector", + &mut self.selected_identity_string, + &identities, + ) + .selected_identity(&mut self.selected_identity) + .unwrap() + .width(300.0) + .other_option(false), // Disable "Other" option + ); + + if response.changed() { + // Reset state when identity changes + self.profile = None; + self.profile_load_attempted = false; + self.loading = false; + self.editing = false; + self.validation_errors.clear(); + self.has_unsaved_changes = false; + self.message = None; + self.avatar_loading = false; + // Don't clear avatar_textures - they're keyed by URL so can be reused + + // Update wallet for the newly selected identity + if let Some(identity) = &self.selected_identity { + let mut error_message = None; + self.selected_wallet = get_selected_wallet( + identity, + Some(&self.app_context), + None, + &mut error_message, + ); + } else { + self.selected_wallet = None; + } + + // Load profile from database for the newly selected identity + self.load_profile_from_database(); + } + }); + } + }); + + ui.separator(); + + if identities.is_empty() { + return super::render_no_identities_card(ui, &self.app_context); + } + + // Show message if any + if let Some((message, message_type)) = &self.message { + let color = match message_type { + MessageType::Success => egui::Color32::DARK_GREEN, + MessageType::Error => egui::Color32::DARK_RED, + MessageType::Info => egui::Color32::LIGHT_BLUE, + }; + ui.colored_label(color, message); + ui.separator(); + } + + if self.selected_identity.is_none() { + ui.label("Please select an identity to view or edit profile"); + return action; + } + + // Profile loading status - styled card when no profile loaded + if !self.profile_load_attempted && !self.loading { + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::group(ui.style()) + .fill(ui.visuals().extreme_bg_color) + .corner_radius(5.0) + .outer_margin(Margin::same(20)) + .shadow(ui.visuals().window_shadow) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(5.0); + ui.label( + RichText::new("No Profile Loaded") + .strong() + .size(25.0) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(5.0); + ui.separator(); + ui.add_space(10.0); + ui.label("The profile for this identity hasn't been loaded yet."); + ui.add_space(10.0); + ui.label("Click the 'Refresh' button above to fetch it from the network."); + ui.add_space(10.0); + }); + }); + return action; + } + + // Loading or saving indicator + if self.loading || self.saving { + ui.horizontal(|ui| { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); + let status_text = if self.saving { + "Saving profile..." + } else { + "Loading profile..." + }; + ui.label(RichText::new(status_text).color(DashColors::text_primary(dark_mode))); + }); + return action; + } else { + ScrollArea::vertical().show(ui, |ui| { + if self.editing { + // Edit mode + ui.horizontal(|ui| { + // Main editing panel (left side) + ui.vertical(|ui| { + ui.group(|ui| { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.horizontal(|ui| { + ui.label( + RichText::new("Edit Profile") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + + ui.add_space(5.0); + if crate::ui::helpers::info_icon_button( + ui, + PROFILE_GUIDELINES_INFO_TEXT, + ) + .clicked() + { + self.show_info_popup = true; + } + }); + + ui.separator(); + + // Display Name Field + ui.horizontal(|ui| { + ui.label( + RichText::new("Display Name:") + .color(DashColors::text_primary(dark_mode)), + ); + ui.label(RichText::new("*").color(egui::Color32::RED)); // Required indicator + }); + + let display_name_response = ui.add( + TextEdit::singleline(&mut self.edit_display_name) + .hint_text(egui::RichText::new("Enter your display name (required)").color(DashColors::text_secondary(dark_mode))) + .desired_width(300.0), + ); + + // Character count with color coding + let char_count = self.edit_display_name.len(); + let count_color = if char_count > 25 { + egui::Color32::RED + } else if char_count > 20 { + egui::Color32::ORANGE + } else { + DashColors::text_secondary(dark_mode) + }; + ui.label( + RichText::new(format!("{}/25", char_count)) + .small() + .color(count_color), + ); + + if display_name_response.changed() { + self.check_for_changes(); + self.validate_profile(); + } + + ui.add_space(10.0); + + // Bio Field + ui.horizontal(|ui| { + ui.label( + RichText::new("Bio/Status:") + .color(DashColors::text_primary(dark_mode)), + ); + }); + + let bio_response = ui.add( + TextEdit::multiline(&mut self.edit_bio) + .hint_text(egui::RichText::new("Tell others about yourself (optional)").color(DashColors::text_secondary(dark_mode))) + .desired_width(300.0) + .desired_rows(4), + ); + + // Bio character count with color coding + let bio_count = self.edit_bio.len(); + let bio_count_color = if bio_count > 140 { + egui::Color32::RED + } else if bio_count > 120 { + egui::Color32::ORANGE + } else { + DashColors::text_secondary(dark_mode) + }; + ui.label( + RichText::new(format!("{}/140", bio_count)) + .small() + .color(bio_count_color), + ); + + if bio_response.changed() { + self.check_for_changes(); + self.validate_profile(); + } + + ui.add_space(10.0); + + // Avatar URL Field + ui.horizontal(|ui| { + ui.label( + RichText::new("Avatar URL:") + .color(DashColors::text_primary(dark_mode)), + ); + if crate::ui::helpers::info_icon_button( + ui, + AVATAR_URL_INFO_TEXT, + ) + .clicked() + { + self.show_avatar_info_popup = true; + } + }); + + let avatar_response = ui.add( + TextEdit::singleline(&mut self.edit_avatar_url) + .hint_text(egui::RichText::new("https://example.com/avatar.jpg (optional)").color(DashColors::text_secondary(dark_mode))) + .desired_width(300.0), + ); + + // Avatar URL character count + let url_count = self.edit_avatar_url.len(); + let url_count_color = if url_count > 500 { + egui::Color32::RED + } else if url_count > 450 { + egui::Color32::ORANGE + } else { + DashColors::text_secondary(dark_mode) + }; + if !self.edit_avatar_url.is_empty() { + ui.label( + RichText::new(format!("{}/500", url_count)) + .small() + .color(url_count_color), + ); + } + + if avatar_response.changed() { + self.check_for_changes(); + self.validate_profile(); + } + + // Show validation errors + if !self.validation_errors.is_empty() { + ui.add_space(10.0); + ui.separator(); + ui.label( + RichText::new("Validation Errors:") + .color(egui::Color32::RED) + .strong(), + ); + for error in &self.validation_errors { + ui.label( + RichText::new(format!("• {}", error.message())) + .color(egui::Color32::RED) + .small(), + ); + } + } + + ui.add_space(15.0); + + // Check wallet lock status before showing save button + let wallet_locked = if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.message = Some((e, MessageType::Error)); + } + wallet_needs_unlock(wallet) + } else { + false + }; + + if wallet_locked { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to save profile.", + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + self.cancel_editing(); + } + ui.add_space(10.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + }); + } else { + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + // Profile creation/update is a document operation + let estimated_fee = if self.profile.is_some() { + fee_estimator.estimate_document_replace() + } else { + fee_estimator.estimate_document_create() + }; + + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + + ui.add_space(10.0); + + // Check if identity has enough balance + let has_enough_balance = self + .selected_identity + .as_ref() + .map(|id| id.identity.balance() > estimated_fee) + .unwrap_or(false); + + // Action buttons + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + // Show confirmation if there are unsaved changes + if self.has_unsaved_changes { + // TODO: Add confirmation dialog + self.cancel_editing(); + } else { + self.cancel_editing(); + } + } + + ui.add_space(10.0); + + let can_save = self.is_valid() && has_enough_balance; + let save_button = egui::Button::new( + RichText::new("Save Profile") + .color(egui::Color32::WHITE), + ) + .fill(if can_save { + egui::Color32::from_rgb(0, 141, 228) // Dash blue + } else { + egui::Color32::GRAY + }); + + let hover_text = if !has_enough_balance { + format!( + "Insufficient identity balance for fee (need at least {})", + format_credits_as_dash(estimated_fee) + ) + } else if !self.is_valid() { + "Please fix validation errors".to_string() + } else { + "Save profile changes".to_string() + }; + + if ui + .add_enabled(can_save, save_button) + .on_hover_text(&hover_text) + .on_disabled_hover_text(&hover_text) + .clicked() + { + action |= self.save_profile(); + } + }); + } + }); + }); + }); + } else { + // View mode + if let Some(profile) = self.profile.clone() { + ui.group(|ui| { + ui.horizontal(|ui| { + // Avatar display + ui.vertical(|ui| { + ui.add_space(5.0); + ui.horizontal(|ui| { + // Check if we have an avatar URL and try to display it + if !profile.avatar_url.is_empty() { + let texture_id = + format!("avatar_{}", profile.avatar_url); + + // Check if texture is already cached in memory + if let Some(texture) = + self.avatar_textures.get(&texture_id) + { + // Display the cached avatar image (clickable) + let image_response = ui.add( + egui::Image::new(texture) + .fit_to_exact_size(egui::vec2(80.0, 80.0)) + .corner_radius(8.0) + .sense(egui::Sense::click()), + ).on_hover_text("Click to view avatar URL"); + if image_response.clicked() { + self.show_avatar_url_popup = true; + } + } else { + // Check if image data was loaded by async task from network + let data_id = + format!("avatar_data_{}", profile.avatar_url); + let bytes_id = + format!("avatar_bytes_{}", profile.avatar_url); + let color_image = ui.ctx().data_mut(|data| { + data.get_temp::(egui::Id::new( + &data_id, + )) + }); + let fetched_bytes: Option> = ui.ctx().data_mut(|data| { + data.get_temp::>(egui::Id::new( + &bytes_id, + )) + }); + + if let Some(color_image) = color_image { + // Create texture from loaded image + let texture = ui.ctx().load_texture( + &texture_id, + color_image, + egui::TextureOptions::LINEAR, + ); + + // Display the image (clickable) + let image_response = ui.add( + egui::Image::new(&texture) + .fit_to_exact_size(egui::vec2(80.0, 80.0)) + .corner_radius(8.0) + .sense(egui::Sense::click()), + ).on_hover_text("Click to view avatar URL"); + if image_response.clicked() { + self.show_avatar_url_popup = true; + } + + // Cache the texture in memory + self.avatar_textures + .insert(texture_id, texture); + self.avatar_loading = false; + + // Save avatar bytes to database for caching + if let Some(bytes) = fetched_bytes + && let Some(ref identity) = self.selected_identity + { + let identity_id = identity.identity.id(); + let network_str = self.app_context.network.to_string(); + if let Err(e) = self.app_context.db.save_dashpay_profile_avatar_bytes( + &identity_id, + &network_str, + Some(&bytes), + ) { + tracing::error!("Failed to save avatar bytes to database: {}", e); + } else { + tracing::debug!("Saved avatar bytes to database ({} bytes)", bytes.len()); + } + // Update the profile's avatar_bytes in memory + if let Some(ref mut p) = self.profile { + p.avatar_bytes = Some(bytes); + } + } + + // Clear the temporary data + ui.ctx().data_mut(|data| { + data.remove::(egui::Id::new( + &data_id, + )); + data.remove::>(egui::Id::new( + &bytes_id, + )); + }); + } else if !self.avatar_loading { + // Check if we have cached bytes from database + if let Some(ref avatar_bytes) = profile.avatar_bytes { + // Process cached bytes synchronously to avoid spinner + if let Some(color_image) = Self::process_avatar_bytes_sync(avatar_bytes) { + let texture = ui.ctx().load_texture( + &texture_id, + color_image, + egui::TextureOptions::LINEAR, + ); + let image_response = ui.add( + egui::Image::new(&texture) + .fit_to_exact_size(egui::vec2(80.0, 80.0)) + .corner_radius(8.0) + .sense(egui::Sense::click()), + ).on_hover_text("Click to view avatar URL"); + if image_response.clicked() { + self.show_avatar_url_popup = true; + } + self.avatar_textures.insert(texture_id, texture); + } else { + // Failed to process cached bytes, fetch from network + self.avatar_loading = true; + self.load_avatar_texture( + ui.ctx(), + &profile.avatar_url, + ); + ui.add( + egui::Spinner::new() + .color(DashColors::DASH_BLUE), + ); + } + } else { + // No cached bytes, fetch from network + self.avatar_loading = true; + self.load_avatar_texture( + ui.ctx(), + &profile.avatar_url, + ); + // Show spinner while loading + ui.add( + egui::Spinner::new() + .color(DashColors::DASH_BLUE), + ); + } + } else { + // Show loading indicator + ui.add( + egui::Spinner::new() + .color(DashColors::DASH_BLUE), + ); + } + } + } else { + // No avatar URL, show default emoji + ui.label(RichText::new("👤").size(80.0).color(DashColors::DEEP_BLUE)); + } + }); + }); + + ui.vertical(|ui| { + // Display name + if !profile.display_name.is_empty() { + ui.label(RichText::new(&profile.display_name).heading()); + } else { + ui.label(RichText::new("No display name set").weak()); + } + + // Username from identity + if let Some(identity) = &self.selected_identity + && !identity.dpns_names.is_empty() + { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label( + RichText::new(format!( + "@{}", + identity.dpns_names[0].name + )) + .color(DashColors::text_secondary(dark_mode)), + ); + } + + // Identity ID + if let Some(identity) = &self.selected_identity { + ui.label( + RichText::new(format!( + "ID: {}", + identity.identity.id() + )) + .small() + .weak(), + ); + } + }); + + ui.with_layout( + egui::Layout::right_to_left(egui::Align::TOP), + |ui| { + let edit_button = egui::Button::new( + RichText::new("Edit Profile") + .color(egui::Color32::WHITE), + ) + .fill(egui::Color32::from_rgb(0, 141, 228)); // Dash blue + + if ui.add(edit_button).clicked() { + self.start_editing(); + } + }, + ); + }); + + ui.separator(); + + // Bio + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label( + RichText::new("Bio:") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + if !profile.bio.is_empty() { + ui.label( + RichText::new(&profile.bio) + .color(DashColors::text_primary(dark_mode)), + ); + } else { + ui.label( + RichText::new("No bio set") + .color(DashColors::text_secondary(dark_mode)), + ); + } + ui.add_space(5.0); + + }); + } else if self.profile_load_attempted { + // No profile exists (only show after we've tried to load) + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::group(ui.style()) + .fill(ui.visuals().extreme_bg_color) + .corner_radius(5.0) + .outer_margin(Margin::same(20)) + .shadow(ui.visuals().window_shadow) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(10.0); + ui.label( + RichText::new("No DashPay Profile") + .strong() + .size(20.0) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(5.0); + ui.label( + RichText::new( + "This identity doesn't have a DashPay profile yet.", + ) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(15.0); + let create_button = egui::Button::new( + RichText::new("Create Profile").color(egui::Color32::WHITE), + ) + .fill(egui::Color32::from_rgb(0, 141, 228)); // Dash blue + + if ui.add(create_button).clicked() { + self.start_editing(); + } + ui.add_space(10.0); + }); + }); + } + } + }); + } + + // Show info popup if requested + if self.show_info_popup { + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ui.ctx(), |ui| { + let mut popup = + InfoPopup::new("Profile Guidelines", PROFILE_GUIDELINES_INFO_TEXT); + if popup.show(ui).inner { + self.show_info_popup = false; + } + }); + } + + // Show avatar info popup if requested + if self.show_avatar_info_popup { + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ui.ctx(), |ui| { + let mut popup = InfoPopup::new("Avatar Image Guidelines", AVATAR_URL_INFO_TEXT); + if popup.show(ui).inner { + self.show_avatar_info_popup = false; + } + }); + } + + // Show avatar URL popup when clicking on avatar image + if self.show_avatar_url_popup { + if let Some(profile) = &self.profile { + let avatar_url = profile.avatar_url.clone(); + let texture_id = format!("avatar_{}", avatar_url); + egui::Window::new("Avatar") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .show(ui.ctx(), |ui| { + ui.vertical_centered(|ui| { + ui.add_space(5.0); + + // Display larger avatar image + if let Some(texture) = self.avatar_textures.get(&texture_id) { + ui.add( + egui::Image::new(texture) + .fit_to_exact_size(egui::vec2(200.0, 200.0)) + .corner_radius(10.0), + ); + } + + ui.add_space(10.0); + + // Show URL in smaller, secondary text + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label( + RichText::new(&avatar_url) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + + ui.add_space(10.0); + ui.horizontal(|ui| { + if ui.button("Copy URL").clicked() { + ui.ctx().copy_text(avatar_url.clone()); + self.display_message( + "Avatar URL copied to clipboard", + MessageType::Info, + ); + self.show_avatar_url_popup = false; + } + if ui.button("Close").clicked() { + self.show_avatar_url_popup = false; + } + }); + }); + }); + } else { + self.show_avatar_url_popup = false; + } + } + + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ui.ctx(), wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully, UI will update on next frame + } + } + + action + } + + pub fn display_message(&mut self, message: &str, message_type: MessageType) { + self.message = Some((message.to_string(), message_type)); + // Clear loading/saving states on error + if message_type == MessageType::Error { + self.loading = false; + self.saving = false; + } + } + + pub fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + // Always clear loading and saving states first + self.loading = false; + self.saving = false; + self.profile_load_attempted = true; + + match result { + BackendTaskSuccessResult::DashPayProfile(profile_data) => { + if let Some((display_name, bio, avatar_url)) = profile_data { + // Check if avatar URL changed - if so, we need to re-fetch the avatar + let old_avatar_url = self.profile.as_ref().map(|p| p.avatar_url.clone()); + let avatar_url_changed = old_avatar_url.as_ref() != Some(&avatar_url); + + // Preserve cached avatar bytes if URL hasn't changed + let avatar_bytes = if avatar_url_changed { + // URL changed, clear cached bytes and texture so new avatar is fetched + self.avatar_textures + .remove(&format!("avatar_{}", old_avatar_url.unwrap_or_default())); + self.avatar_loading = false; + + // Clear old avatar bytes from database since URL changed + if let Some(ref identity) = self.selected_identity { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + let identity_id = identity.identity.id(); + let network_str = self.app_context.network.to_string(); + let _ = self.app_context.db.save_dashpay_profile_avatar_bytes( + &identity_id, + &network_str, + None, + ); + } + None + } else { + // URL same, keep existing cached bytes + self.profile.as_ref().and_then(|p| p.avatar_bytes.clone()) + }; + + self.profile = Some(DashPayProfile { + display_name: display_name.clone(), + bio: bio.clone(), + avatar_url: avatar_url.clone(), + avatar_bytes, + }); + + // Save profile to database for caching + if let Some(ref identity) = self.selected_identity { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + let identity_id = identity.identity.id(); + let network_str = self.app_context.network.to_string(); + + if let Err(e) = self.app_context.db.save_dashpay_profile( + &identity_id, + &network_str, + Some(&display_name), + Some(&bio), + Some(&avatar_url), + None, // public_message not used in profile screen yet + ) { + eprintln!("Failed to cache profile in database: {}", e); + } + } + // Profile loaded successfully - no need to show a message + } else { + // No profile found - clear any existing profile and show create button + self.profile = None; + + // Save "no profile" state to database to avoid repeated network queries + if let Some(ref identity) = self.selected_identity { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + let identity_id = identity.identity.id(); + let network_str = self.app_context.network.to_string(); + + // Save with all fields as None to indicate "no profile exists" + // This prevents unnecessary network queries on app restart + if let Err(e) = self.app_context.db.save_dashpay_profile( + &identity_id, + &network_str, + None, // display_name + None, // bio + None, // avatar_url + None, // public_message + ) { + eprintln!("Failed to cache 'no profile' state in database: {}", e); + } + } + // Don't show a message - let the UI show "Create Profile" button + } + } + BackendTaskSuccessResult::DashPayProfileUpdated(_identity_id) => { + // Profile was successfully created/updated + // Save the profile data to database BEFORE clearing edit fields + if let Some(ref identity) = self.selected_identity { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + let identity_id = identity.identity.id(); + let network_str = self.app_context.network.to_string(); + + let display_name = self.edit_display_name.trim(); + let bio = self.edit_bio.trim(); + let avatar_url = self.edit_avatar_url.trim(); + + tracing::info!( + "Saving profile to database: identity={}, network={}, display_name={:?}, bio={:?}, avatar_url={:?}", + identity_id, + network_str, + display_name, + bio, + avatar_url + ); + + // Save to database + match self.app_context.db.save_dashpay_profile( + &identity_id, + &network_str, + if display_name.is_empty() { + None + } else { + Some(display_name) + }, + if bio.is_empty() { None } else { Some(bio) }, + if avatar_url.is_empty() { + None + } else { + Some(avatar_url) + }, + None, + ) { + Ok(_) => tracing::info!("Profile saved to database successfully"), + Err(e) => tracing::error!("Failed to save profile to database: {}", e), + } + + // Update in-memory profile (preserve existing avatar_bytes if URL didn't change) + let existing_avatar_bytes = self.profile.as_ref().and_then(|p| { + if p.avatar_url == avatar_url { + p.avatar_bytes.clone() + } else { + None // URL changed, need to re-fetch + } + }); + self.profile = Some(DashPayProfile { + display_name: display_name.to_string(), + bio: bio.to_string(), + avatar_url: avatar_url.to_string(), + avatar_bytes: existing_avatar_bytes, + }); + } + + self.cancel_editing(); // Exit edit mode (clears edit fields) + self.show_success = true; + } + _ => { + // Ignore other results - profile screen only handles DashPayProfile and DashPayProfileUpdated + } + } + } +} diff --git a/src/ui/dashpay/profile_search.rs b/src/ui/dashpay/profile_search.rs new file mode 100644 index 000000000..4b9779430 --- /dev/null +++ b/src/ui/dashpay/profile_search.rs @@ -0,0 +1,381 @@ +use crate::app::{AppAction, DesiredAppAction}; +use crate::backend_task::dashpay::DashPayTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; +use crate::ui::components::info_popup::InfoPopup; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, RootScreenType, ScreenLike, ScreenType}; + +use dash_sdk::platform::{Document, Identifier}; +use egui::{RichText, ScrollArea, TextEdit, Ui}; +use std::sync::Arc; + +const PROFILE_SEARCH_INFO_TEXT: &str = "About Profile Search:\n\n\ + Search for users by their DPNS username.\n\n\ + Usernames are unique, verified identifiers on Dash Platform.\n\n\ + Results show the username along with profile info (if available).\n\n\ + Add contacts directly from search results."; + +#[derive(Debug, Clone)] +pub struct ProfileSearchResult { + pub identity_id: Identifier, + pub display_name: Option, + pub public_message: Option, + pub avatar_url: Option, + pub username: Option, // From DPNS if available +} + +pub struct ProfileSearchScreen { + pub app_context: Arc, + search_query: String, + search_results: Vec, + message: Option<(String, MessageType)>, + loading: bool, + has_searched: bool, // Track if a search has been performed + show_info_popup: bool, +} + +impl ProfileSearchScreen { + pub fn new(app_context: Arc) -> Self { + Self { + app_context, + search_query: String::new(), + search_results: Vec::new(), + message: None, + loading: false, + has_searched: false, + show_info_popup: false, + } + } + + fn search_profiles(&mut self) -> AppAction { + if self.search_query.trim().is_empty() { + self.display_message("Please enter a search term", MessageType::Error); + return AppAction::None; + } + + self.loading = true; + self.search_results.clear(); + self.has_searched = true; // Mark that a search has been performed + + let task = BackendTask::DashPayTask(Box::new(DashPayTask::SearchProfiles { + search_query: self.search_query.trim().to_string(), + })); + + AppAction::BackendTask(task) + } + + fn view_profile(&mut self, identity_id: Identifier) -> AppAction { + // Use any available identity for viewing (just needed for context) + let identities = self + .app_context + .load_local_qualified_identities() + .unwrap_or_default(); + if identities.is_empty() { + self.display_message( + "No identities available. Please load an identity first.", + MessageType::Error, + ); + return AppAction::None; + } + + AppAction::AddScreen( + ScreenType::DashPayContactProfileViewer(identities[0].clone(), identity_id) + .create_screen(&self.app_context), + ) + } + + fn add_contact(&mut self, identity_id: Identifier) -> AppAction { + // Convert the identity ID to a base58 string and navigate to the Add Contact screen + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + let identity_id_string = identity_id.to_string(Encoding::Base58); + + // Navigate to the Add Contact screen with the pre-populated identity ID + AppAction::AddScreen( + ScreenType::DashPayAddContactWithId(identity_id_string) + .create_screen(&self.app_context), + ) + } + + pub fn render(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Header + ui.horizontal(|ui| { + ui.heading("Search Public Profiles"); + ui.add_space(5.0); + if crate::ui::helpers::info_icon_button(ui, PROFILE_SEARCH_INFO_TEXT).clicked() { + self.show_info_popup = true; + } + }); + + ui.separator(); + + // Show message if any + if let Some((message, message_type)) = &self.message { + let color = match message_type { + MessageType::Success => DashColors::success_color(dark_mode), + MessageType::Error => DashColors::error_color(dark_mode), + MessageType::Info => DashColors::DASH_BLUE, + }; + ui.colored_label(color, message); + ui.separator(); + } + + ScrollArea::vertical().show(ui, |ui| { + // Search section + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.add_space(6.0); + let response = ui.add( + TextEdit::singleline(&mut self.search_query) + .hint_text("Enter DPNS username...") + .desired_width(400.0), + ); + + // Trigger search on Enter key + if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + action = self.search_profiles(); + } + }); + + if ui.button("Search").clicked() { + action = self.search_profiles(); + } + }); + + ui.label( + RichText::new("Tip: Search by DPNS username prefix (e.g., \"john\" finds \"john.dash\", \"johnny.dash\", etc.)") + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + + ui.add_space(10.0); + + // Loading indicator + if self.loading { + ui.horizontal(|ui| { + ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); + ui.label("Searching..."); + }); + return; + } + + // Search results + if !self.search_results.is_empty() { + ui.group(|ui| { + ui.label( + RichText::new(format!("Search Results ({})", self.search_results.len())) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.separator(); + + let search_results = self.search_results.clone(); + for result in &search_results { + ui.group(|ui| { + ui.horizontal(|ui| { + // No avatar display in search results + ui.vertical(|ui| { + // Username (primary identifier per DIP-15) + if let Some(username) = &result.username { + ui.label( + RichText::new(username) + .strong() + .size(16.0) + .color(DashColors::text_primary(dark_mode)), + ); + } + + // Display name (complementary info) + if let Some(display_name) = &result.display_name { + ui.label( + RichText::new(display_name) + .color(DashColors::text_secondary(dark_mode)), + ); + } + + // Public message preview + if let Some(public_message) = &result.public_message { + let preview = if public_message.len() > 60 { + format!("{}...", &public_message[..60]) + } else { + public_message.clone() + }; + ui.label( + RichText::new(preview) + .small() + .italics() + .color(DashColors::text_secondary(dark_mode)), + ); + } + + // Identity ID + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + ui.label( + RichText::new(format!( + "ID: {}", + result.identity_id.to_string(Encoding::Base58) + )) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + }); + + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui.button("View Profile").clicked() { + action = self.view_profile(result.identity_id); + } + if ui.button("Add Contact").clicked() { + action = self.add_contact(result.identity_id); + } + }, + ); + }); + }); + ui.add_space(4.0); + } + }); + } else if self.has_searched && !self.loading { + // Only show "No users found" if we've actually performed a search + ui.group(|ui| { + ui.label("No users found"); + ui.separator(); + ui.label("Try searching with a different username prefix."); + }); + } + }); + + action + } + + pub fn display_message(&mut self, message: &str, message_type: MessageType) { + self.loading = false; + self.message = Some((message.to_string(), message_type)); + } +} + +impl ScreenLike for ProfileSearchScreen { + fn ui(&mut self, ctx: &egui::Context) -> AppAction { + let mut action = AppAction::None; + + // Add top panel - consistent with other DashPay subscreens + action |= add_top_panel( + ctx, + &self.app_context, + vec![ + ("DashPay", AppAction::None), + ("Profile Search", AppAction::None), + ], + vec![( + "Clear Results", + DesiredAppAction::Custom("clear_search".to_string()), + )], + ); + + // Highlight DashPay in the main left panel + action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + + // Add DashPay subscreen chooser panel + action |= add_dashpay_subscreen_chooser_panel( + ctx, + &self.app_context, + DashPaySubscreen::ProfileSearch, // Use ProfileSearch as the active subscreen + ); + + // Main content area with island styling + action |= island_central_panel(ctx, |ui| self.render(ui)); + + // Handle custom action from top panel button + if let AppAction::Custom(command) = &action + && command == "clear_search" + { + self.search_query.clear(); + self.search_results.clear(); + self.has_searched = false; + self.message = None; + action = AppAction::None; // Consume the action + } + + // Show info popup if requested + if self.show_info_popup { + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ctx, |ui| { + let mut popup = + InfoPopup::new("About Profile Search", PROFILE_SEARCH_INFO_TEXT); + if popup.show(ui).inner { + self.show_info_popup = false; + } + }); + } + + action + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + self.display_message(message, message_type); + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + self.loading = false; + + match result { + BackendTaskSuccessResult::DashPayProfileSearchResults(results) => { + self.search_results.clear(); + + // Convert backend results to UI results + for (identity_id, profile_doc, username) in results { + // Extract profile data from document if available + use dash_sdk::dpp::document::DocumentV0Getters; + let (display_name, public_message, avatar_url) = + if let Some(document) = &profile_doc { + let properties = match document { + Document::V0(doc_v0) => doc_v0.properties(), + }; + ( + properties + .get("displayName") + .and_then(|v| v.as_text()) + .map(|s| s.to_string()), + properties + .get("publicMessage") + .and_then(|v| v.as_text()) + .map(|s| s.to_string()), + properties + .get("avatarUrl") + .and_then(|v| v.as_text()) + .map(|s| s.to_string()), + ) + } else { + (None, None, None) + }; + + let search_result = ProfileSearchResult { + identity_id, + display_name, + public_message, + avatar_url, + username: Some(username), // DPNS username from search + }; + + self.search_results.push(search_result); + } + } + BackendTaskSuccessResult::Message(msg) => { + self.message = Some((msg, MessageType::Info)); + } + _ => { + // Ignore other results + } + } + } +} diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs new file mode 100644 index 000000000..10600240a --- /dev/null +++ b/src/ui/dashpay/qr_code_generator.rs @@ -0,0 +1,440 @@ +use crate::app::AppAction; +use crate::backend_task::dashpay::auto_accept_proof::generate_auto_accept_proof; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; +use crate::ui::components::identity_selector::IdentitySelector; +use crate::ui::components::info_popup::InfoPopup; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +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::theme::DashColors; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use eframe::epaint::TextureHandle; +use egui::{RichText, ScrollArea, TextEdit, Ui}; +use std::sync::{Arc, RwLock}; + +const QR_CODE_INFO_TEXT: &str = "About Contact QR Codes:\n\n\ + QR codes allow instant mutual contact establishment.\n\n\ + The recipient can scan to automatically send and accept contact requests.\n\n\ + QR codes expire after the specified validity period.\n\n\ + Each QR code is unique and can only be used once.\n\n\ + WARNING: Anyone with this QR code can automatically become your contact."; + +const ACCOUNT_INDEX_INFO_TEXT: &str = "Account Index:\n\n\ + The account index determines which HD wallet account is used for this contact relationship.\n\n\ + Most users should leave this at 0 (the default).\n\n\ + Advanced users may use different account indices to segregate contacts \ + (e.g., separate personal and business contacts into different wallet accounts).\n\n\ + The account index is used in the derivation path: m/9'/5'/15'/account'/..."; + +pub struct QRCodeGeneratorScreen { + pub app_context: Arc, + selected_identity: Option, + selected_identity_string: String, + account_index: String, + validity_hours: String, + generated_qr_data: Option, + message: Option<(String, MessageType)>, + show_info_popup: bool, + show_advanced_options: bool, + selected_wallet: Option>>, + wallet_unlock_popup: WalletUnlockPopup, +} + +impl QRCodeGeneratorScreen { + pub fn new(app_context: Arc) -> Self { + let mut new_self = Self { + app_context: app_context.clone(), + selected_identity: None, + selected_identity_string: String::new(), + account_index: "0".to_string(), + validity_hours: "24".to_string(), + generated_qr_data: None, + message: None, + show_info_popup: false, + show_advanced_options: false, + selected_wallet: None, + wallet_unlock_popup: WalletUnlockPopup::new(), + }; + + // Auto-select first identity on creation if available + if let Ok(identities) = app_context.load_local_qualified_identities() + && !identities.is_empty() + { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + + new_self.selected_identity = Some(identities[0].clone()); + new_self.selected_identity_string = + identities[0].identity.id().to_string(Encoding::Base58); + + // Get wallet for the selected identity + let mut error_message = None; + new_self.selected_wallet = + get_selected_wallet(&identities[0], Some(&app_context), None, &mut error_message); + } + + new_self + } + + fn generate_qr_code(&mut self) { + if let Some(identity) = &self.selected_identity { + let account_idx = match self.account_index.parse::() { + Ok(v) => v, + Err(_) => { + self.display_message("Invalid account index number", MessageType::Error); + return; + } + }; + + let validity = match self.validity_hours.parse::() { + Ok(v) if v > 0 && v <= 720 => v, // Max 30 days + _ => { + self.display_message( + "Validity hours must be between 1 and 720", + MessageType::Error, + ); + return; + } + }; + + match generate_auto_accept_proof(identity, account_idx, validity) { + Ok(proof_data) => { + let qr_string = proof_data.to_qr_string(); + self.generated_qr_data = Some(qr_string); + self.display_message("QR code generated successfully", MessageType::Success); + } + Err(e) => { + self.display_message( + &format!("Failed to generate QR code: {}", e), + MessageType::Error, + ); + } + } + } else { + self.display_message("Please select an identity first", MessageType::Error); + } + } + + pub fn render(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Header with info icon + ui.horizontal(|ui| { + if ui.button("Back").clicked() { + action = AppAction::PopScreen; + } + ui.heading("Generate Contact QR Code"); + ui.add_space(5.0); + if crate::ui::helpers::info_icon_button(ui, QR_CODE_INFO_TEXT).clicked() { + self.show_info_popup = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); + + ui.separator(); + + // Show message if any + if let Some((message, message_type)) = &self.message { + let color = match message_type { + MessageType::Success => DashColors::success_color(dark_mode), + MessageType::Error => DashColors::error_color(dark_mode), + MessageType::Info => DashColors::DASH_BLUE, + }; + ui.colored_label(color, message); + ui.separator(); + } + + // Identity selector + let identities = self + .app_context + .load_local_qualified_identities() + .unwrap_or_default(); + + if identities.is_empty() { + action |= super::render_no_identities_card(ui, &self.app_context); + return action; + } + + ScrollArea::vertical().show(ui, |ui| { + + ui.group(|ui| { + ui.label( + RichText::new("Configuration") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.separator(); + + egui::Grid::new("qr_config_grid") + .num_columns(2) + .spacing([10.0, 10.0]) + .show(ui, |ui| { + ui.label( + RichText::new("Identity:").color(DashColors::text_primary(dark_mode)), + ); + ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { + let response = ui.add( + IdentitySelector::new( + "qr_identity_selector", + &mut self.selected_identity_string, + &identities, + ) + .selected_identity(&mut self.selected_identity) + .unwrap() + .width(300.0) + .other_option(false), + ); + + if response.changed() { + // Update wallet for the newly selected identity + if let Some(identity) = &self.selected_identity { + let mut error_message = None; + self.selected_wallet = get_selected_wallet( + identity, + Some(&self.app_context), + None, + &mut error_message, + ); + } else { + self.selected_wallet = None; + } + // Clear generated QR code when identity changes + self.generated_qr_data = None; + self.message = None; + } + }); + ui.end_row(); + }); + + // Advanced options (only shown when checkbox is checked) + if self.show_advanced_options { + ui.add_space(10.0); + egui::Grid::new("qr_advanced_config_grid") + .num_columns(2) + .spacing([10.0, 10.0]) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Account Index:") + .color(DashColors::text_primary(dark_mode)), + ); + crate::ui::helpers::info_icon_button(ui, ACCOUNT_INDEX_INFO_TEXT); + }); + ui.add( + TextEdit::singleline(&mut self.account_index) + .hint_text("0") + .desired_width(100.0), + ); + ui.end_row(); + + ui.label( + RichText::new("Validity (hours):") + .color(DashColors::text_primary(dark_mode)), + ); + ui.horizontal(|ui| { + ui.add( + TextEdit::singleline(&mut self.validity_hours) + .hint_text("24") + .desired_width(100.0), + ); + ui.label( + RichText::new("How long the QR code remains valid (default: 24)") + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + }); + ui.end_row(); + }); + } + + ui.add_space(10.0); + + // Check wallet lock status before showing generate button + let wallet_locked = if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.message = Some((e, MessageType::Error)); + } + wallet_needs_unlock(wallet) + } else { + false + }; + + if wallet_locked { + ui.add_space(10.0); + ui.colored_label( + DashColors::warning_color(dark_mode), + "Wallet is locked. Please unlock to generate QR code.", + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + }); + } else { + ui.horizontal(|ui| { + if ui.button("Generate QR Code").clicked() { + self.generate_qr_code(); + } + + if self.generated_qr_data.is_some() + && ui.button("Clear").clicked() { + self.generated_qr_data = None; + self.message = None; + } + }); + } + }); + + ui.add_space(20.0); + + // Display generated QR data + let mut show_copied_message = false; + if let Some(qr_data) = &self.generated_qr_data { + ui.group(|ui| { + ui.label( + RichText::new("Generated QR Code") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.separator(); + + // Center the QR code + ui.vertical_centered(|ui| { + // Generate and display the actual QR code image + if let Ok(qr_image) = generate_qr_code_image(qr_data) { + let texture: TextureHandle = ui.ctx().load_texture( + "dashpay_qr_code", + qr_image, + egui::TextureOptions::LINEAR, + ); + // Display at a reasonable size + ui.image(&texture); + } else { + ui.label( + RichText::new("Failed to generate QR code image") + .color(DashColors::error_color(dark_mode)), + ); + } + }); + + ui.add_space(10.0); + + // Show the text data in a collapsible section + ui.collapsing("QR Code Data (text)", |ui| { + ui.code(qr_data); + }); + + ui.add_space(10.0); + + ui.horizontal(|ui| { + let copy_text = qr_data.clone(); + if ui.button("Copy Data to Clipboard").clicked() { + ui.ctx().copy_text(copy_text); + show_copied_message = true; + } + }); + + ui.add_space(10.0); + + ui.label( + RichText::new( + "Share this QR code with someone to establish a mutual contact", + ) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new( + "WARNING: Anyone with this QR code can automatically become your contact", + ) + .small() + .color(DashColors::warning_color(dark_mode)), + ); + }); + } + + if show_copied_message { + self.display_message("Copied to clipboard", MessageType::Success); + } + }); + + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ui.ctx(), wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully, UI will update on next frame + } + } + + action + } + + pub fn display_message(&mut self, message: &str, message_type: MessageType) { + self.message = Some((message.to_string(), message_type)); + } +} + +impl ScreenLike for QRCodeGeneratorScreen { + fn ui(&mut self, ctx: &egui::Context) -> AppAction { + let mut action = AppAction::None; + + // Add top panel + action |= add_top_panel( + ctx, + &self.app_context, + vec![ + ("DashPay", AppAction::None), + ("QR Generator", AppAction::None), + ], + vec![], + ); + + // Highlight DashPay in the main left panel + action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + + // Add DashPay subscreen chooser panel + action |= add_dashpay_subscreen_chooser_panel( + ctx, + &self.app_context, + DashPaySubscreen::Contacts, // Use Contacts as the active subscreen since QR Generator is launched from there + ); + + // Main content area with island styling + action |= island_central_panel(ctx, |ui| self.render(ui)); + + // Show info popup if requested + if self.show_info_popup { + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ctx, |ui| { + let mut popup = InfoPopup::new("About Contact QR Codes", QR_CODE_INFO_TEXT); + if popup.show(ui).inner { + self.show_info_popup = false; + } + }); + } + + action + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + self.display_message(message, message_type); + } +} diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs new file mode 100644 index 000000000..af4695ddc --- /dev/null +++ b/src/ui/dashpay/qr_scanner.rs @@ -0,0 +1,367 @@ +use crate::app::AppAction; +use crate::backend_task::dashpay::DashPayTask; +use crate::backend_task::dashpay::auto_accept_proof::AutoAcceptProofData; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; +use crate::ui::components::identity_selector::IdentitySelector; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; +use crate::ui::identities::get_selected_wallet; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use egui::{RichText, ScrollArea, TextEdit, Ui}; +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; + +pub struct QRScannerScreen { + pub app_context: Arc, + selected_identity: Option, + selected_identity_string: String, + qr_data_input: String, + parsed_qr_data: Option, + message: Option<(String, MessageType)>, + sending: bool, + selected_wallet: Option>>, + wallet_unlock_popup: WalletUnlockPopup, +} + +impl QRScannerScreen { + pub fn new(app_context: Arc) -> Self { + Self { + app_context, + selected_identity: None, + selected_identity_string: String::new(), + qr_data_input: String::new(), + parsed_qr_data: None, + message: None, + sending: false, + selected_wallet: None, + wallet_unlock_popup: WalletUnlockPopup::new(), + } + } + + fn parse_qr_code(&mut self) { + if self.qr_data_input.is_empty() { + self.display_message("Please enter QR code data", MessageType::Error); + return; + } + + match AutoAcceptProofData::from_qr_string(&self.qr_data_input) { + Ok(data) => { + self.parsed_qr_data = Some(data); + self.display_message("QR code parsed successfully", MessageType::Success); + } + Err(e) => { + self.parsed_qr_data = None; + self.display_message(&format!("Invalid QR code: {}", e), MessageType::Error); + } + } + } + + fn send_contact_request_with_proof(&mut self) -> AppAction { + if let Some(identity) = &self.selected_identity { + if let Some(qr_data) = &self.parsed_qr_data { + // Get signing key + let signing_key = match identity.identity.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ]), + HashSet::from([KeyType::ECDSA_SECP256K1]), + false, + ) { + Some(key) => key, + None => { + self.display_message("No suitable signing key found. This operation requires a ECDSA_SECP256K1 AUTHENTICATION key.", MessageType::Error); + return AppAction::None; + } + }; + + self.sending = true; + + // Create task to send contact request with proof + let task = + BackendTask::DashPayTask(Box::new(DashPayTask::SendContactRequestWithProof { + identity: identity.clone(), + signing_key: signing_key.clone(), + to_identity_id: qr_data.identity_id, + account_label: Some(format!( + "QR Contact (Account #{})", + qr_data.account_reference + )), + qr_auto_accept: qr_data.clone(), + })); + + return AppAction::BackendTask(task); + } else { + self.display_message("Please parse a QR code first", MessageType::Error); + } + } else { + self.display_message("Please select an identity", MessageType::Error); + } + + AppAction::None + } + + pub fn render(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Header + ui.heading("Scan Contact QR Code"); + ui.add_space(10.0); + + // Show message if any + if let Some((message, message_type)) = &self.message { + let color = match message_type { + MessageType::Success => crate::ui::theme::DashColors::success_color(dark_mode), + MessageType::Error => crate::ui::theme::DashColors::error_color(dark_mode), + MessageType::Info => crate::ui::theme::DashColors::DASH_BLUE, + }; + ui.colored_label(color, message); + ui.add_space(10.0); + } + + // Identity selector + let identities = self + .app_context + .load_local_qualified_identities() + .unwrap_or_default(); + + if identities.is_empty() { + action |= super::render_no_identities_card(ui, &self.app_context); + return action; + } + + ScrollArea::vertical().show(ui, |ui| { + + ui.group(|ui| { + ui.label(RichText::new("1. Select Your Identity").strong()); + ui.separator(); + + // Track identity before selection to detect changes + let prev_identity_id = self.selected_identity.as_ref().map(|i| i.identity.id()); + + ui.horizontal(|ui| { + ui.label("Identity:"); + ui.add( + IdentitySelector::new( + "qr_scanner_identity_selector", + &mut self.selected_identity_string, + &identities, + ) + .selected_identity(&mut self.selected_identity) + .unwrap() + .width(300.0) + .other_option(false), + ); + }); + + // Update wallet if identity changed + let new_identity_id = self.selected_identity.as_ref().map(|i| i.identity.id()); + if prev_identity_id != new_identity_id { + if let Some(identity) = &self.selected_identity { + let mut error_message = None; + self.selected_wallet = get_selected_wallet( + identity, + Some(&self.app_context), + None, + &mut error_message, + ); + } else { + self.selected_wallet = None; + } + } + }); + + ui.add_space(20.0); + + ui.group(|ui| { + ui.label(RichText::new("2. Enter QR Code Data").strong()); + ui.separator(); + + ui.label(RichText::new("Paste the QR code data below:").small()); + + ui.add( + TextEdit::multiline(&mut self.qr_data_input) + .hint_text("dash:?di=...") + .desired_rows(3) + .desired_width(f32::INFINITY) + ); + + ui.horizontal(|ui| { + if ui.button("Parse QR Code").clicked() { + self.parse_qr_code(); + } + + if ui.button("Clear").clicked() { + self.qr_data_input.clear(); + self.parsed_qr_data = None; + self.message = None; + } + }); + }); + + ui.add_space(20.0); + + // Display parsed QR data + if let Some(qr_data) = self.parsed_qr_data.clone() { + ui.group(|ui| { + ui.label(RichText::new("3. QR Code Details").strong()); + ui.separator(); + + egui::Grid::new("qr_details_grid") + .num_columns(2) + .spacing([10.0, 5.0]) + .show(ui, |ui| { + ui.label("Contact Identity:"); + ui.label(qr_data.identity_id.to_string( + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58 + )); + ui.end_row(); + + ui.label("Account Reference:"); + ui.label(format!("{}", qr_data.account_reference)); + ui.end_row(); + + ui.label("Expires:"); + let expiry_time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(qr_data.expires_at); + ui.label(format!("{:?}", expiry_time)); + ui.end_row(); + }); + + ui.add_space(10.0); + + // Check wallet lock status before showing send button + let wallet_locked = if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.message = Some((e, MessageType::Error)); + } + wallet_needs_unlock(wallet) + } else { + false + }; + + if wallet_locked { + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to add contact.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + } else { + ui.horizontal(|ui| { + if self.sending { + ui.spinner(); + ui.label("Sending contact request..."); + } else if ui.button("Add Contact").clicked() { + action = self.send_contact_request_with_proof(); + } + }); + } + + ui.add_space(10.0); + + ui.label(RichText::new("ℹ️ This will send a contact request that will be automatically accepted").small()); + ui.label(RichText::new("⚡ Both you and the contact will become mutual contacts instantly").small()); + }); + } + + ui.add_space(20.0); + + // Information box + ui.group(|ui| { + ui.label(RichText::new("ℹ️ About QR Code Scanning").strong()); + ui.separator(); + ui.label("• QR codes enable instant mutual contact establishment"); + ui.label("• The contact request is automatically accepted by both parties"); + ui.label("• No manual approval is needed when using valid QR codes"); + ui.label("• QR codes expire after the specified time period"); + ui.label("• Each QR code can only be used once"); + }); + }); + + action + } + + pub fn display_message(&mut self, message: &str, message_type: MessageType) { + self.message = Some((message.to_string(), message_type)); + } + + pub fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + self.sending = false; + match result { + BackendTaskSuccessResult::Message(msg) => { + self.display_message(&msg, MessageType::Success); + // Clear the form on success + self.qr_data_input.clear(); + self.parsed_qr_data = None; + } + _ => { + self.display_message("Contact request sent successfully", MessageType::Success); + } + } + } +} + +impl ScreenLike for QRScannerScreen { + fn ui(&mut self, ctx: &egui::Context) -> AppAction { + let mut action = AppAction::None; + + // Add top panel + action |= add_top_panel( + ctx, + &self.app_context, + vec![ + ("DashPay", AppAction::None), + ("Scan QR Code", AppAction::None), + ], + vec![], + ); + + // Highlight DashPay in the main left panel + action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + + // Add DashPay subscreen chooser panel + action |= + add_dashpay_subscreen_chooser_panel(ctx, &self.app_context, DashPaySubscreen::Contacts); + + // Main content area with island styling + action |= island_central_panel(ctx, |ui| self.render(ui)); + + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully, UI will update on next frame + } + } + + action + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + self.display_message(message, message_type); + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + self.display_task_result(result); + } +} diff --git a/src/ui/dashpay/send_payment.rs b/src/ui/dashpay/send_payment.rs new file mode 100644 index 000000000..620dea493 --- /dev/null +++ b/src/ui/dashpay/send_payment.rs @@ -0,0 +1,872 @@ +use crate::app::AppAction; +use crate::backend_task::dashpay::DashPayTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::model::amount::Amount; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; +use crate::ui::components::identity_selector::IdentitySelector; +use crate::ui::components::info_popup::InfoPopup; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::components::{Component, ComponentResponse}; +use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use dash_sdk::dpp::balances::credits::Credits; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::Identifier; +use egui::{Frame, Margin, RichText, ScrollArea, TextEdit, Ui}; +use std::sync::{Arc, RwLock}; + +const PAYMENT_GUIDELINES_INFO_TEXT: &str = "Payment Guidelines:\n\n\ + Payments to contacts use encrypted payment channels.\n\n\ + Only you and the recipient can see payment details.\n\n\ + Addresses are never reused for privacy.\n\n\ + Memos are stored locally and not sent on-chain."; + +pub struct SendPaymentScreen { + pub app_context: Arc, + pub from_identity: QualifiedIdentity, + pub to_contact_id: Identifier, + to_contact_name: Option, + amount_input: Option, + amount: Amount, + memo: String, + message: Option<(String, MessageType)>, + sending: bool, + show_info_popup: bool, + payment_success: bool, + tx_id: Option, + // Wallet unlock + selected_wallet: Option>>, + wallet_unlock_popup: WalletUnlockPopup, +} + +impl SendPaymentScreen { + pub fn new( + app_context: Arc, + from_identity: QualifiedIdentity, + to_contact_id: Identifier, + ) -> Self { + // Get wallet from identity's associated wallets + let selected_wallet = from_identity.associated_wallets.values().next().cloned(); + + Self { + app_context: app_context.clone(), + from_identity, + to_contact_id, + to_contact_name: None, + amount_input: None, + amount: Amount::new_dash(0.0), + memo: String::new(), + message: None, + sending: false, + show_info_popup: false, + payment_success: false, + tx_id: None, + selected_wallet, + wallet_unlock_popup: WalletUnlockPopup::new(), + } + } + + fn load_contact_info(&mut self) { + // TODO: Load contact info from backend/database + // Mock data for now + self.to_contact_name = Some("alice.dash".to_string()); + } + + fn send_payment(&mut self) -> AppAction { + // Validate amount + if self.amount.value() == 0 { + self.display_message("Please enter an amount", MessageType::Error); + return AppAction::None; + } + + // Check wallet is available and unlocked + let wallet_check = if let Some(wallet) = &self.selected_wallet { + match wallet.read() { + Ok(guard) => { + if guard.is_open() { + Ok(()) + } else { + Err("Wallet must be unlocked to send a payment".to_string()) + } + } + Err(e) => Err(format!("Failed to access wallet: {}", e)), + } + } else { + Err("No wallet associated with this identity".to_string()) + }; + + if let Err(e) = wallet_check { + self.display_message(&e, MessageType::Error); + return AppAction::None; + } + + // Get amount in Dash (convert from duffs) + let amount_dash = match self.amount.dash_to_duffs() { + Ok(duffs) => duffs as f64 / 100_000_000.0, + Err(e) => { + self.display_message(&format!("Invalid amount: {}", e), MessageType::Error); + return AppAction::None; + } + }; + + self.sending = true; + + // Fire the backend task + AppAction::BackendTask(BackendTask::DashPayTask(Box::new( + DashPayTask::SendPaymentToContact { + identity: self.from_identity.clone(), + contact_id: self.to_contact_id, + amount_dash, + memo: if self.memo.is_empty() { + None + } else { + Some(self.memo.clone()) + }, + }, + ))) + } + + fn show_success(&self, ui: &mut Ui) -> AppAction { + crate::ui::helpers::show_success_screen( + ui, + format!( + "Payment of {} sent successfully!{}", + self.amount, + if let Some(tx_id) = &self.tx_id { + format!("\n\nTransaction ID: {}", tx_id) + } else { + String::new() + } + ), + vec![ + ("Back to DashPay".to_string(), AppAction::GoToMainScreen), + ("Send Another Payment".to_string(), AppAction::PopScreen), + ], + ) + } + + pub fn render(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + + // Show success screen if payment was successful + if self.payment_success { + return self.show_success(ui); + } + + // Header + ui.horizontal(|ui| { + if ui.button("Back").clicked() { + action = AppAction::PopScreen; + } + ui.heading("Send Payment"); + ui.add_space(5.0); + if crate::ui::helpers::info_icon_button(ui, PAYMENT_GUIDELINES_INFO_TEXT).clicked() { + self.show_info_popup = true; + } + }); + + ui.separator(); + + // Show message if any + if let Some((message, message_type)) = &self.message { + let color = match message_type { + MessageType::Success => egui::Color32::DARK_GREEN, + MessageType::Error => egui::Color32::DARK_RED, + MessageType::Info => egui::Color32::LIGHT_BLUE, + }; + ui.colored_label(color, message); + ui.separator(); + } + + // Check wallet unlock + let (wallet_open_error, needs_unlock) = if let Some(wallet) = &self.selected_wallet { + let open_err = try_open_wallet_no_password(wallet).err(); + let needs = wallet_needs_unlock(wallet); + (open_err, needs) + } else { + (None, false) + }; + + if let Some(e) = wallet_open_error { + self.display_message(&e, MessageType::Error); + } + + if needs_unlock { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to send a payment.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + ui.add_space(10.0); + return AppAction::None; + } + + ScrollArea::vertical().show(ui, |ui| { + ui.group(|ui| { + // From identity + ui.horizontal(|ui| { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label( + RichText::new("From:") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label( + RichText::new(self.from_identity.to_string()) + .color(DashColors::text_primary(dark_mode)), + ); + }); + + // Wallet Balance (from wallet, not identity) + ui.horizontal(|ui| { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label( + RichText::new("Wallet Balance:") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + let balance_dash = if let Some(wallet) = &self.selected_wallet { + if let Ok(wallet_guard) = wallet.read() { + wallet_guard.confirmed_balance_duffs() as f64 / 100_000_000.0 + } else { + 0.0 + } + } else { + 0.0 + }; + ui.label( + RichText::new(format!("{:.8} DASH", balance_dash)) + .color(DashColors::text_primary(dark_mode)), + ); + }); + + ui.separator(); + + // To contact + ui.horizontal(|ui| { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label( + RichText::new("To:") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + if let Some(name) = &self.to_contact_name { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label(RichText::new(name).color(DashColors::text_primary(dark_mode))); + } else { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label( + RichText::new(format!("{}", self.to_contact_id)) + .color(DashColors::text_primary(dark_mode)), + ); + } + }); + + ui.separator(); + + // Amount input - use wallet balance for max + let max_balance = if let Some(wallet) = &self.selected_wallet { + if let Ok(wallet_guard) = wallet.read() { + wallet_guard.confirmed_balance_duffs() + } else { + 0 + } + } else { + 0 + }; + + let amount_input = self.amount_input.get_or_insert_with(|| { + AmountInput::new(&self.amount) + .with_hint_text("Enter amount in Dash") + .with_max_button(true) + .with_max_amount(Some(max_balance)) + .with_label("Amount:") + }); + // Update max amount in case balance changed + amount_input.set_max_amount(Some(max_balance)); + let response = amount_input.show(ui); + if response.inner.has_changed() + && let Some(new_amount) = response.inner.changed_value() + { + self.amount = new_amount.clone(); + } + + ui.add_space(10.0); + + // Memo field + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label( + RichText::new("Memo (optional):") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add( + TextEdit::multiline(&mut self.memo) + .hint_text("Add a note to this payment") + .desired_rows(3) + .desired_width(f32::INFINITY), + ); + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label( + RichText::new(format!("{}/100 characters", self.memo.len())) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + + ui.add_space(10.0); + + // Send button + ui.horizontal(|ui| { + if self.sending { + ui.spinner(); + ui.label("Sending payment..."); + } else { + let send_enabled = self.amount.value() > 0; + let send_button = egui::Button::new( + RichText::new("Send Payment").color(egui::Color32::WHITE), + ) + .fill(if send_enabled { + egui::Color32::from_rgb(0, 141, 228) // Dash blue + } else { + egui::Color32::GRAY + }); + + if ui.add_enabled(send_enabled, send_button).clicked() { + if self.memo.len() > 100 { + self.display_message( + "Memo must be 100 characters or less", + MessageType::Error, + ); + } else { + action = self.send_payment(); + } + } + + if ui.button("Cancel").clicked() { + action = AppAction::PopScreen; + } + } + }); + }); + }); + + action + } + + pub fn display_message(&mut self, message: &str, message_type: MessageType) { + self.message = Some((message.to_string(), message_type)); + } +} + +impl ScreenLike for SendPaymentScreen { + fn refresh(&mut self) { + self.load_contact_info(); + } + + fn refresh_on_arrival(&mut self) { + self.refresh(); + } + + fn ui(&mut self, ctx: &egui::Context) -> AppAction { + let mut action = AppAction::None; + + // Add top panel + action |= add_top_panel( + ctx, + &self.app_context, + vec![ + ("DashPay", AppAction::None), + ("Send Payment", AppAction::None), + ], + vec![], + ); + + // Highlight DashPay in the main left panel + action |= add_left_panel(ctx, &self.app_context, RootScreenType::RootScreenDashpay); + action |= + add_dashpay_subscreen_chooser_panel(ctx, &self.app_context, DashPaySubscreen::Payments); + + action |= island_central_panel(ctx, |ui| self.render(ui)); + + // Show info popup if requested + if self.show_info_popup { + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ctx, |ui| { + let mut popup = + InfoPopup::new("Payment Guidelines", PAYMENT_GUIDELINES_INFO_TEXT); + if popup.show(ui).inner { + self.show_info_popup = false; + } + }); + } + + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } + + action + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + self.sending = false; + self.message = Some((message.to_string(), message_type)); + } + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + self.sending = false; + if let BackendTaskSuccessResult::DashPayPaymentSent(recipient, address, amount) = result { + // Extract txid from the address (or we could modify the result to include it) + self.payment_success = true; + self.tx_id = Some(format!("Sent to {}", address)); + self.message = Some(( + format!("Payment of {} DASH sent to {}", amount, recipient), + MessageType::Success, + )); + } + } +} + +// Payment History Component (used in main DashPay screen) +pub struct PaymentHistory { + pub app_context: Arc, + selected_identity: Option, + selected_identity_string: String, + payments: Vec, + message: Option<(String, MessageType)>, + loading: bool, + has_searched: bool, +} + +#[derive(Debug, Clone)] +pub struct PaymentRecord { + pub tx_id: String, + pub contact_name: String, + pub amount: Credits, + pub is_incoming: bool, + pub timestamp: u64, + pub memo: Option, +} + +impl PaymentHistory { + pub fn new(app_context: Arc) -> Self { + let mut new_self = Self { + app_context: app_context.clone(), + selected_identity: None, + selected_identity_string: String::new(), + payments: Vec::new(), + message: None, + loading: false, + has_searched: false, + }; + + // Auto-select first identity on creation if available + if let Ok(identities) = app_context.load_local_qualified_identities() + && !identities.is_empty() + { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + new_self.selected_identity = Some(identities[0].clone()); + new_self.selected_identity_string = + identities[0].identity.id().to_string(Encoding::Base58); + + // Load payments from database for this identity + new_self.load_payments_from_database(); + } + + new_self + } + + fn load_payments_from_database(&mut self) { + // Load saved payment history for the selected identity from database + if let Some(identity) = &self.selected_identity { + let identity_id = identity.identity.id(); + + // Clear existing payments before loading + self.payments.clear(); + + // Load payment history from database (limit 100) + if let Ok(stored_payments) = self.app_context.db.load_payment_history(&identity_id, 100) + { + for payment in stored_payments { + // Determine if incoming or outgoing based on identity + let is_incoming = payment.to_identity_id == identity_id.to_buffer().to_vec(); + let contact_id = if is_incoming { + payment.from_identity_id + } else { + payment.to_identity_id + }; + + // Try to resolve contact name + let contact_name = if let Ok(contact_id) = Identifier::from_bytes(&contact_id) { + // First check if we have a saved contact with username + let network_str = self.app_context.network.to_string(); + if let Ok(contacts) = self + .app_context + .db + .load_dashpay_contacts(&identity_id, &network_str) + { + contacts + .iter() + .find(|c| c.contact_identity_id == contact_id.to_buffer().to_vec()) + .and_then(|c| c.username.clone().or(c.display_name.clone())) + .unwrap_or_else(|| { + format!( + "Unknown ({})", + &contact_id.to_string(Encoding::Base58)[0..8] + ) + }) + } else { + format!( + "Unknown ({})", + &contact_id.to_string(Encoding::Base58)[0..8] + ) + } + } else { + "Unknown".to_string() + }; + + let payment_record = PaymentRecord { + tx_id: payment.tx_id, + contact_name, + amount: Credits::from(payment.amount as u64), + is_incoming, + timestamp: payment.created_at as u64, + memo: payment.memo, + }; + + self.payments.push(payment_record); + } + } + } + } + + pub fn trigger_fetch_payment_history(&mut self) -> AppAction { + if let Some(identity) = &self.selected_identity { + self.loading = true; + self.message = Some(("Loading payment history...".to_string(), MessageType::Info)); + + let task = BackendTask::DashPayTask(Box::new(DashPayTask::LoadPaymentHistory { + identity: identity.clone(), + })); + + return AppAction::BackendTask(task); + } + + AppAction::None + } + + pub fn refresh(&mut self) { + // Don't clear if we have data, just clear temporary states + self.message = None; + self.loading = false; + + // Auto-select first identity if none selected + if self.selected_identity.is_none() + && let Ok(identities) = self.app_context.load_local_qualified_identities() + && !identities.is_empty() + { + self.selected_identity = Some(identities[0].clone()); + self.selected_identity_string = identities[0].display_string(); + } + + // Load payments from database if we have an identity selected and no payments loaded + if self.selected_identity.is_some() && self.payments.is_empty() { + self.load_payments_from_database(); + } + } + + pub fn render(&mut self, ui: &mut Ui) -> AppAction { + let action = AppAction::None; + + // Identity selector or no identities message + let identities = self + .app_context + .load_local_qualified_identities() + .unwrap_or_default(); + + // Header with identity selector on the right + ui.horizontal(|ui| { + ui.heading("Payment History"); + + if !identities.is_empty() { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let response = ui.add( + IdentitySelector::new( + "payment_history_identity_selector", + &mut self.selected_identity_string, + &identities, + ) + .selected_identity(&mut self.selected_identity) + .unwrap() + .width(300.0) + .other_option(false), // Disable "Other" option + ); + + if response.changed() { + self.refresh(); + + // Load payments from database for the newly selected identity + self.load_payments_from_database(); + } + }); + } + }); + + ui.separator(); + + if identities.is_empty() { + return super::render_no_identities_card(ui, &self.app_context); + } + + // Show message if any + if let Some((message, message_type)) = &self.message { + let color = match message_type { + MessageType::Success => egui::Color32::DARK_GREEN, + MessageType::Error => egui::Color32::DARK_RED, + MessageType::Info => egui::Color32::LIGHT_BLUE, + }; + ui.colored_label(color, message); + ui.separator(); + } + + if self.selected_identity.is_none() { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label( + RichText::new("Please select an identity to view payment history") + .color(DashColors::text_primary(dark_mode)), + ); + return action; + } + + // Loading indicator + if self.loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Loading payment history..."); + }); + return action; + } + + // Payment list + ScrollArea::vertical().show(ui, |ui| { + if self.payments.is_empty() { + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::group(ui.style()) + .fill(ui.visuals().extreme_bg_color) + .corner_radius(5.0) + .outer_margin(Margin::same(20)) + .shadow(ui.visuals().window_shadow) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(10.0); + ui.label( + RichText::new("No Payment History") + .strong() + .size(20.0) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(5.0); + ui.label( + RichText::new("No payments have been made with this identity.") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(10.0); + }); + }); + } else { + for payment in &self.payments { + ui.group(|ui| { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.horizontal(|ui| { + // Avatar placeholder + ui.vertical(|ui| { + ui.add_space(5.0); + ui.label( + RichText::new("👤").size(30.0).color(DashColors::DEEP_BLUE), + ); + }); + + ui.add_space(5.0); + + // Direction indicator + if payment.is_incoming { + ui.label( + RichText::new("⬇") + .color(egui::Color32::DARK_GREEN) + .size(20.0), + ); + } else { + ui.label( + RichText::new("⬆").color(egui::Color32::DARK_RED).size(20.0), + ); + } + + ui.vertical(|ui| { + ui.horizontal(|ui| { + // Contact name + ui.label( + RichText::new(&payment.contact_name) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + + // Amount + let amount_str = format!("{} Dash", payment.amount); + if payment.is_incoming { + ui.label( + RichText::new(format!("+{}", amount_str)) + .color(egui::Color32::DARK_GREEN), + ); + } else { + ui.label( + RichText::new(format!("-{}", amount_str)) + .color(egui::Color32::DARK_RED), + ); + } + }); + + // Memo + if let Some(memo) = &payment.memo { + ui.label( + RichText::new(format!("\"{}\"", memo)) + .italics() + .color(DashColors::text_secondary(dark_mode)), + ); + } + + ui.horizontal(|ui| { + // Transaction ID + ui.label( + RichText::new(&payment.tx_id) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + + // Timestamp + ui.label( + RichText::new("• 2 days ago") + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + }); + }); + }); + }); + ui.add_space(4.0); + } + } + }); + + action + } + + pub fn display_message(&mut self, message: &str, message_type: MessageType) { + self.message = Some((message.to_string(), message_type)); + } + + pub fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + self.loading = false; + + match result { + BackendTaskSuccessResult::DashPayPaymentHistory(payment_data) => { + self.payments.clear(); + self.has_searched = true; + + // Get current identity for saving to database + if let Some(identity) = &self.selected_identity { + let identity_id = identity.identity.id(); + + // Convert backend data to PaymentRecord structs and save to database + for (tx_id, contact_name, amount, is_incoming, memo) in payment_data { + // Parse contact identity from contact_name if it contains ID + let contact_id = if contact_name.contains("(") && contact_name.contains(")") + { + // Extract ID from format "Unknown (abcd1234)" + let start = contact_name.find('(').unwrap() + 1; + let end = contact_name.find(')').unwrap(); + let _id_str = &contact_name[start..end]; + // This is likely a partial base58 ID, we'd need the full ID + // For now, we'll use a placeholder + Identifier::new([0; 32]) + } else { + Identifier::new([0; 32]) + }; + + let payment = PaymentRecord { + tx_id: tx_id.clone(), + contact_name, + amount: Credits::from(amount), + is_incoming, + timestamp: 0, // TODO: Include timestamp in backend data + memo: if memo.is_empty() { + None + } else { + Some(memo.clone()) + }, + }; + self.payments.push(payment); + + // Save to database + let (from_id, to_id, payment_type) = if is_incoming { + (contact_id, identity_id, "received") + } else { + (identity_id, contact_id, "sent") + }; + + let _ = self.app_context.db.save_payment( + &tx_id, + &from_id, + &to_id, + amount as i64, + if memo.is_empty() { None } else { Some(&memo) }, + payment_type, + ); + } + } else { + // No selected identity, just populate in-memory + for (tx_id, contact_name, amount, is_incoming, memo) in payment_data { + let payment = PaymentRecord { + tx_id, + contact_name, + amount: Credits::from(amount), + is_incoming, + timestamp: 0, // TODO: Include timestamp in backend data + memo: if memo.is_empty() { None } else { Some(memo) }, + }; + self.payments.push(payment); + } + } + + // Don't show message - let the UI handle empty state + self.message = None; + } + _ => { + // Ignore other results + } + } + } +} diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 0a6d66804..1b52d8f9a 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -17,11 +17,12 @@ use crate::backend_task::identity::IdentityTask; use crate::context::AppContext; use crate::model::contested_name::{ContestState, ContestedName}; use crate::model::qualified_identity::{DPNSNameInfo, QualifiedIdentity}; -use crate::ui::components::contracts_subscreen_chooser_panel::add_contracts_subscreen_chooser_panel; use crate::ui::components::dpns_subscreen_chooser_panel::add_dpns_subscreen_chooser_panel; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::{StyledButton, island_central_panel}; +use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; +use crate::ui::identities::register_dpns_name_screen::RegisterDpnsNameSource; use crate::ui::theme::DashColors; use crate::ui::{BackendTaskSuccessResult, MessageType, RootScreenType, ScreenLike, ScreenType}; @@ -902,6 +903,7 @@ impl DPNSScreen { .column(Column::auto().resizable(true)) // DPNS Name .column(Column::auto().resizable(true)) // Owner ID .column(Column::auto().resizable(true)) // Acquired At + .column(Column::auto().resizable(true)) // Actions .header(30.0, |mut header| { header.col(|ui| { if ui.button("Name").clicked() { @@ -918,14 +920,27 @@ impl DPNSScreen { self.toggle_sort(SortColumn::EndingTime); } }); + header.col(|ui| { + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label( + RichText::new("Actions").color(DashColors::text_primary(dark_mode)), + ); + }); }) .body(|mut body| { for (identifier, dpns_info) in filtered_names { + let name_for_alias = dpns_info.name.clone(); + // Display name with .dash suffix + let display_name = if name_for_alias.ends_with(".dash") { + name_for_alias.clone() + } else { + format!("{}.dash", name_for_alias) + }; body.row(25.0, |mut row| { row.col(|ui| { let dark_mode = ui.ctx().style().visuals.dark_mode; ui.label( - RichText::new(dpns_info.name) + RichText::new(&display_name) .color(DashColors::text_primary(dark_mode)), ); }); @@ -948,6 +963,35 @@ impl DPNSScreen { RichText::new(dt).color(DashColors::text_primary(dark_mode)), ); }); + row.col(|ui| { + if ui.small_button("Set Alias").clicked() { + // Append .dash suffix for DPNS names + let alias_with_suffix = if name_for_alias.ends_with(".dash") { + name_for_alias.clone() + } else { + format!("{}.dash", name_for_alias) + }; + if let Err(e) = self + .app_context + .db + .set_identity_alias(&identifier, Some(&alias_with_suffix)) + { + self.display_message( + &format!("Failed to set alias: {}", e), + MessageType::Error, + ); + } else { + self.display_message( + &format!( + "Alias set to '{}' for identity {}", + alias_with_suffix, + identifier.to_string(Encoding::Base58) + ), + MessageType::Success, + ); + } + } + }); }); } }); @@ -1806,23 +1850,6 @@ impl ScreenLike for DPNSScreen { } } } - if message.contains("Successfully cast scheduled vote") { - self.scheduled_vote_cast_in_progress = false; - } - // If it's from a DPNS query or identity refresh, remove refreshing state - if message.contains("Successfully refreshed DPNS contests") - || message.contains("Successfully refreshed loaded identities dpns names") - || message.contains("Contested resource query failed") - || message.contains("Error refreshing owned DPNS names") - { - self.refreshing_status = RefreshingStatus::NotRefreshing; - } - - if message.contains("Votes scheduled") - && self.bulk_vote_handling_status == VoteHandlingStatus::SchedulingVotes - { - self.bulk_vote_handling_status = VoteHandlingStatus::Completed; - } // Save into general error_message for top-of-screen self.message = Some((message.to_string(), message_type, Utc::now())); @@ -1870,16 +1897,15 @@ impl ScreenLike for DPNSScreen { self.bulk_vote_handling_status = VoteHandlingStatus::Completed; } // If scheduling succeeded - BackendTaskSuccessResult::Message(msg) => { - if msg.contains("Votes scheduled") { - if self.bulk_vote_handling_status == VoteHandlingStatus::SchedulingVotes { - self.bulk_vote_handling_status = VoteHandlingStatus::Completed; - } - self.bulk_schedule_message = - Some((MessageType::Success, "Votes scheduled".to_string())); + BackendTaskSuccessResult::ScheduledVotes => { + if self.bulk_vote_handling_status == VoteHandlingStatus::SchedulingVotes { + self.bulk_vote_handling_status = VoteHandlingStatus::Completed; } + self.bulk_schedule_message = + Some((MessageType::Success, "Votes scheduled".to_string())); } BackendTaskSuccessResult::CastScheduledVote(vote) => { + self.scheduled_vote_cast_in_progress = false; if let Ok(mut guard) = self.scheduled_votes.lock() && let Some((_, status)) = guard.iter_mut().find(|(v, _)| { v.contested_name == vote.contested_name && v.voter_id == vote.voter_id @@ -1888,6 +1914,10 @@ impl ScreenLike for DPNSScreen { *status = ScheduledVoteCastingStatus::Completed; } } + BackendTaskSuccessResult::RefreshedDpnsContests + | BackendTaskSuccessResult::RefreshedOwnedDpnsNames => { + self.refreshing_status = RefreshingStatus::NotRefreshing; + } _ => {} } } @@ -1967,7 +1997,9 @@ impl ScreenLike for DPNSScreen { 0, ( "Register Name", - DesiredAppAction::AddScreenType(Box::new(ScreenType::RegisterDpnsName)), + DesiredAppAction::AddScreenType(Box::new(ScreenType::RegisterDpnsName( + RegisterDpnsNameSource::Dpns, + ))), ), ); } @@ -1987,39 +2019,14 @@ impl ScreenLike for DPNSScreen { } // Left panel - match self.dpns_subscreen { - DPNSSubscreen::Active => { - action |= add_left_panel( - ctx, - &self.app_context, - RootScreenType::RootScreenDPNSActiveContests, - ); - } - DPNSSubscreen::Past => { - action |= add_left_panel( - ctx, - &self.app_context, - RootScreenType::RootScreenDPNSPastContests, - ); - } - DPNSSubscreen::Owned => { - action |= add_left_panel( - ctx, - &self.app_context, - RootScreenType::RootScreenDPNSOwnedNames, - ); - } - DPNSSubscreen::ScheduledVotes => { - action |= add_left_panel( - ctx, - &self.app_context, - RootScreenType::RootScreenDPNSScheduledVotes, - ); - } - } + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenToolsPlatformInfoScreen, + ); - // Contracts area chooser (DPNS / Dashpay / Contracts) - action |= add_contracts_subscreen_chooser_panel(ctx, self.app_context.as_ref()); + // Tools area chooser + action |= add_tools_subscreen_chooser_panel(ctx, self.app_context.as_ref()); // DPNS subscreen chooser action |= add_dpns_subscreen_chooser_panel(ctx, self.app_context.as_ref()); @@ -2098,7 +2105,7 @@ impl ScreenLike for DPNSScreen { RichText::new(format!("Refreshing... Time taken so far: {}", elapsed)) .color(DashColors::text_primary(dark_mode)), ); - ui.add(egui::widgets::Spinner::default().color(Color32::from_rgb(0, 128, 255))); + ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); }); ui.add_space(2.0); // Space below } else if let Some((msg, msg_type, timestamp)) = self.message.clone() { diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index 8e3eeb99b..e12bd1dbb 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -4,7 +4,10 @@ use crate::{ app::AppAction, context::AppContext, model::{qualified_contract::QualifiedContract, qualified_identity::QualifiedIdentity}, + ui::contracts_documents::group_actions_screen::GroupActionsScreen, + ui::{RootScreenType, Screen, identities::keys::add_key_screen::AddKeyScreen}, }; +use arboard::Clipboard; use dash_sdk::{ dpp::{ data_contract::{ @@ -28,40 +31,52 @@ use super::tokens::tokens_screen::IdentityTokenInfo; /// This constant provides a constant padding to be used in such cases to ensure proper alignment. pub const BUTTON_ADJUSTMENT_PADDING_TOP: f32 = 15.0; -/// Helper function to create a styled info icon button +/// Helper function to create a styled info icon button with a circle and "i" +/// Returns a Response that can be checked for .clicked() to show an info popup pub fn info_icon_button(ui: &mut egui::Ui, hover_text: &str) -> Response { - let (rect, response) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::click()); + let size = 16.0; + let (rect, response) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click()); if ui.is_rect_visible(rect) { - // Draw circle background - ui.painter().circle( - rect.center(), - 8.0, - if response.hovered() { - Color32::from_rgb(0, 100, 200) - } else { - Color32::from_rgb(100, 100, 100) - }, - egui::Stroke::NONE, - ); + let is_hovered = response.hovered(); + let color = if is_hovered { + Color32::from_rgb(100, 180, 255) // Brighter blue on hover + } else { + Color32::from_rgb(70, 130, 180) // Steel blue + }; + + let center = rect.center(); + let radius = size / 2.0 - 1.0; + + // Draw circle outline + ui.painter() + .circle_stroke(center, radius, egui::Stroke::new(1.5, color)); - // Draw "i" text + // Draw "i" text in the center ui.painter().text( - rect.center(), + center, egui::Align2::CENTER_CENTER, "i", - egui::FontId::proportional(12.0), - Color32::WHITE, + egui::FontId::proportional(11.0), + color, ); } - response.on_hover_text(hover_text) + response + .on_hover_text(hover_text) + .on_hover_cursor(egui::CursorIcon::PointingHand) +} + +pub fn copy_text_to_clipboard(text: &str) -> Result<(), String> { + let mut clipboard = Clipboard::new().map_err(|e| e.to_string())?; + clipboard + .set_text(text.to_string()) + .map_err(|e| e.to_string()) } /// Returns the newly selected key (if changed), otherwise the existing one. // Allow dead_code: This function provides UI for key selection within identities, // useful for identity-based operations and key management interfaces -#[allow(dead_code)] pub fn render_key_selector( ui: &mut Ui, selected_identity: &QualifiedIdentity, @@ -114,6 +129,8 @@ pub enum TransactionType { TokenTransfer, /// Token action of claiming TokenClaim, + /// DashPay contact request - requires Authentication keys for signing (ENCRYPTION key for ECDH is auto-selected) + ContactRequest, } impl TransactionType { @@ -131,6 +148,7 @@ impl TransactionType { TransactionType::TokenTransfer | TransactionType::TokenClaim => { vec![Purpose::TRANSFER, Purpose::AUTHENTICATION] } + TransactionType::ContactRequest => vec![Purpose::AUTHENTICATION], } } @@ -149,6 +167,7 @@ impl TransactionType { TransactionType::TokenAction | TransactionType::TokenTransfer | TransactionType::TokenClaim => vec![SecurityLevel::CRITICAL], + TransactionType::ContactRequest => vec![SecurityLevel::CRITICAL, SecurityLevel::HIGH], } } @@ -163,19 +182,193 @@ impl TransactionType { TransactionType::TokenAction => "Token Action", TransactionType::TokenTransfer => "Token Transfer", TransactionType::TokenClaim => "Token Claim", + TransactionType::ContactRequest => "Contact Request", } } } +/// Key chooser that filters keys based on transaction type and dev mode. +/// Use this when you already have a specific identity and just need to select a key. +pub fn add_key_chooser( + ui: &mut Ui, + app_context: &Arc, + identity: &QualifiedIdentity, + selected_key: &mut Option, + transaction_type: TransactionType, +) -> AppAction { + add_key_chooser_with_doc_type( + ui, + app_context, + identity, + selected_key, + transaction_type, + None, + ) +} + +/// Key chooser that filters keys based on transaction type, document type and dev mode. +/// Use this when you already have a specific identity and just need to select a key. +pub fn add_key_chooser_with_doc_type( + ui: &mut Ui, + app_context: &Arc, + identity: &QualifiedIdentity, + selected_key: &mut Option, + transaction_type: TransactionType, + document_type: Option<&DocumentType>, +) -> AppAction { + let is_dev_mode = app_context.is_developer_mode(); + let mut action = AppAction::None; + + let allowed_purposes = transaction_type.allowed_purposes(); + let allowed_security_levels: Vec = match (transaction_type, document_type) { + (TransactionType::DocumentAction, Some(doc_type)) => { + let required_level = doc_type.security_level_requirement(); + let allowed_levels = SecurityLevel::CRITICAL as u8..=required_level as u8; + [ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ] + .into_iter() + .filter(|level| allowed_levels.contains(&(*level as u8))) + .collect() + } + _ => transaction_type.allowed_security_levels(), + }; + + // Check for keys with private keys loaded + let has_suitable_keys_with_private = + identity + .private_keys + .identity_public_keys() + .iter() + .any(|key_ref| { + let key = &key_ref.1.identity_public_key; + + allowed_purposes.contains(&key.purpose()) + && allowed_security_levels.contains(&key.security_level()) + }); + + // Check if there are eligible public keys without private keys + let has_eligible_public_keys_without_private = + identity.identity.public_keys().iter().any(|(_, pub_key)| { + let basic_ok = allowed_purposes.contains(&pub_key.purpose()) + && allowed_security_levels.contains(&pub_key.security_level()); + + let has_private = identity + .private_keys + .identity_public_keys() + .iter() + .any(|key_ref| key_ref.1.identity_public_key.id() == pub_key.id()); + + basic_ok && !has_private + }); + + if !is_dev_mode && !has_suitable_keys_with_private { + // Show message and buttons when no suitable keys + ui.group(|ui| { + ui.set_min_width(220.0); + ui.vertical(|ui| { + ui.label("No eligible key. This transaction type requires:"); + ui.label(format!("{} key", transaction_type.label())); + + if has_eligible_public_keys_without_private { + ui.label( + "This Identity has an eligible public key but the private key isn't loaded.", + ); + } + + ui.add_space(5.0); + + if ui.button("Add New Key to Identity").clicked() { + action = AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( + identity.clone(), + app_context, + ))); + } + }); + }); + } else { + // Show key combo box + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.add_space(15.0); + ui.label("Key:"); + }); + ComboBox::from_id_salt("key_chooser_combo") + .width(300.0) + .selected_text( + selected_key + .as_ref() + .map(|k| { + format!( + "Key {} | {} | {} | {}", + k.id(), + k.purpose(), + k.security_level(), + k.key_type() + ) + }) + .unwrap_or_else(|| "Select Key...".into()), + ) + .show_ui(ui, |kui| { + for key_ref in identity.private_keys.identity_public_keys() { + let key = &key_ref.1.identity_public_key; + + let is_allowed = if is_dev_mode { + true + } else { + allowed_purposes.contains(&key.purpose()) + && allowed_security_levels.contains(&key.security_level()) + }; + + if is_allowed { + let label = if is_dev_mode + && (!allowed_purposes.contains(&key.purpose()) + || !allowed_security_levels.contains(&key.security_level())) + { + format!( + "Key {} | {} | {} | {} [DEV]", + key.id(), + key.purpose(), + key.security_level(), + key.key_type() + ) + } else { + format!( + "Key {} | {} | {} | {}", + key.id(), + key.purpose(), + key.security_level(), + key.key_type() + ) + }; + + if kui + .selectable_label(selected_key.as_ref() == Some(key), label) + .clicked() + { + *selected_key = Some(key.clone()); + } + } + } + }); + }); + } + + action +} + /// Identity key chooser that filters keys based on transaction type and dev mode pub fn add_identity_key_chooser<'a, T>( ui: &mut Ui, - app_context: &AppContext, + app_context: &Arc, identities: T, selected_identity: &mut Option, selected_key: &mut Option, transaction_type: TransactionType, -) where +) -> AppAction +where T: Iterator, { add_identity_key_chooser_with_doc_type( @@ -192,16 +385,18 @@ pub fn add_identity_key_chooser<'a, T>( /// Identity key chooser that filters keys based on transaction type, document type and dev mode pub fn add_identity_key_chooser_with_doc_type<'a, T>( ui: &mut Ui, - app_context: &AppContext, + app_context: &Arc, identities: T, selected_identity: &mut Option, selected_key: &mut Option, transaction_type: TransactionType, document_type: Option<&DocumentType>, -) where +) -> AppAction +where T: Iterator, { let is_dev_mode = app_context.is_developer_mode(); + let mut action = AppAction::None; egui::Grid::new("identity_key_chooser_grid") .num_columns(2) @@ -244,6 +439,87 @@ pub fn add_identity_key_chooser_with_doc_type<'a, T>( ui.label("Key:"); ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { + // Check if selected identity has suitable keys + let mut show_combo = true; + if let Some(qi) = selected_identity { + let allowed_purposes = transaction_type.allowed_purposes(); + let allowed_security_levels: Vec = match (transaction_type, document_type) { + (TransactionType::DocumentAction, Some(doc_type)) => { + let required_level = doc_type.security_level_requirement(); + let allowed_levels = SecurityLevel::CRITICAL as u8..=required_level as u8; + [SecurityLevel::CRITICAL, SecurityLevel::HIGH, SecurityLevel::MEDIUM] + .into_iter() + .filter(|level| allowed_levels.contains(&(*level as u8))) + .collect() + } + _ => transaction_type.allowed_security_levels(), + }; + + // Check for keys with private keys loaded + let has_suitable_keys_with_private = qi + .private_keys + .identity_public_keys() + .iter() + .any(|key_ref| { + let key = &key_ref.1.identity_public_key; + + allowed_purposes.contains(&key.purpose()) + && allowed_security_levels.contains(&key.security_level()) + }); + + // Check if there are eligible public keys without private keys + let has_eligible_public_keys_without_private = qi + .identity + .public_keys() + .iter() + .any(|(_, pub_key)| { + // Check if this public key meets the criteria + let basic_ok = allowed_purposes.contains(&pub_key.purpose()) + && allowed_security_levels.contains(&pub_key.security_level()); + + // Check if we don't have the private key for this public key + let has_private = qi.private_keys + .identity_public_keys() + .iter() + .any(|key_ref| key_ref.1.identity_public_key.id() == pub_key.id()); + + basic_ok && !has_private + }); + + if !is_dev_mode && !has_suitable_keys_with_private { + show_combo = false; + // Show message and buttons in a proper group/frame + ui.group(|ui| { + ui.set_min_width(220.0); // Match the combo box width + ui.vertical(|ui| { + // Identity has eligible keys but private keys not loaded + ui.label("⚠ No eligible key. This transaction type requires:"); + ui.label(format!("• {} key", transaction_type.label())); + + if has_eligible_public_keys_without_private { + ui.label( + "This Identity already has an eligible public key but the private key isn't loaded into Dash Evo Tool yet.", + ); + ui.label("Go to the Identities screen to load an existing private key, or use the button below to add a new key:"); + } + + ui.add_space(5.0); + + // Always show option to add new key + if ui.button("Add New Key to Identity").clicked() { + action = AppAction::AddScreen(Screen::AddKeyScreen( + AddKeyScreen::new( + qi.clone(), + app_context, + ), + )); + } + }); + }); + } + } + + if show_combo { ComboBox::from_id_salt("key_combo") .width(220.0) .selected_text( @@ -296,7 +572,8 @@ pub fn add_identity_key_chooser_with_doc_type<'a, T>( let is_allowed = if is_dev_mode { true } else { - allowed_purposes.contains(&key.purpose()) + allowed_purposes + .contains(&key.purpose()) && allowed_security_levels.contains(&key.security_level()) }; @@ -333,30 +610,16 @@ pub fn add_identity_key_chooser_with_doc_type<'a, T>( } } - if !is_dev_mode - && qi - .private_keys - .identity_public_keys() - .iter() - .all(|key_ref| { - let key = &key_ref.1.identity_public_key; - !allowed_purposes.contains(&key.purpose()) - || !allowed_security_levels - .contains(&key.security_level()) - }) - { - kui.label(format!( - "No suitable keys for {}", - transaction_type.label() - )); - } } else { kui.label("Pick an identity first"); } }); + } }); ui.end_row(); }); + + action } pub fn add_contract_doc_type_chooser_with_filtering( @@ -610,20 +873,170 @@ pub fn show_success_screen( ui: &mut Ui, success_message: String, action_buttons: Vec<(String, AppAction)>, +) -> AppAction { + show_success_screen_with_info(ui, success_message, action_buttons, None) +} + +/// Shows a success screen with an optional info section above the buttons. +/// The info section takes a title and description that will be displayed in a centered box. +pub fn show_success_screen_with_info( + ui: &mut Ui, + success_message: String, + action_buttons: Vec<(String, AppAction)>, + info_section: Option<(&str, &str)>, ) -> AppAction { let mut action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.vertical_centered(|ui| { - ui.add_space(100.0); + ui.add_space(if info_section.is_some() { 60.0 } else { 100.0 }); ui.heading("🎉"); ui.heading(success_message); + // Optional info section (above buttons) + if let Some((title, description)) = info_section { + ui.add_space(24.0); + + let description_width = 500.0_f32.min(ui.available_width() - 40.0); + ui.allocate_ui_with_layout( + egui::Vec2::new(description_width, 0.0), + egui::Layout::top_down(egui::Align::Center), + |ui| { + ui.label( + egui::RichText::new(title) + .size(16.0) + .strong() + .color(crate::ui::theme::DashColors::text_primary(dark_mode)), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new(description) + .size(14.0) + .color(crate::ui::theme::DashColors::text_secondary(dark_mode)), + ); + }, + ); + } + ui.add_space(20.0); for button in action_buttons { if ui.button(button.0).clicked() { action = button.1; } } - ui.add_space(100.0); + + ui.add_space(if info_section.is_some() { 60.0 } else { 100.0 }); + }); + action +} + +/// Shows a success screen for group token actions (mint, burn, pause, resume, freeze, unfreeze, etc.) +/// Handles the three cases: +/// 1. Group action signing (group_action_id is Some) - shows "Back to Group Actions" and "Back to Tokens" +/// 2. Group action initiated (has_group && !is_unilateral) - shows "Back to Tokens" and "Go to Group Actions" +/// 3. Normal action - shows just "Back to Tokens" +pub fn show_group_token_success_screen( + ui: &mut Ui, + action_name: &str, + is_group_action_signing: bool, + is_unilateral_group_member: bool, + has_group: bool, + app_context: &Arc, +) -> AppAction { + show_group_token_success_screen_with_fee( + ui, + action_name, + is_group_action_signing, + is_unilateral_group_member, + has_group, + app_context, + None, + ) +} + +/// Shows a success screen for group token actions with optional fee info display. +/// Handles the three cases: +/// 1. Group action signing (group_action_id is Some) - shows "Back to Group Actions" and "Back to Tokens" +/// 2. Group action initiated (has_group && !is_unilateral) - shows "Back to Tokens" and "Go to Group Actions" +/// 3. Normal action - shows just "Back to Tokens" +pub fn show_group_token_success_screen_with_fee( + ui: &mut Ui, + action_name: &str, + is_group_action_signing: bool, + is_unilateral_group_member: bool, + has_group: bool, + app_context: &Arc, + fee_info: Option<(&str, &str)>, +) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + ui.vertical_centered(|ui| { + ui.add_space(if fee_info.is_some() { 60.0 } else { 100.0 }); + ui.heading("🎉"); + + // Determine the success message based on the action type + if is_group_action_signing { + ui.heading(format!("Group {} Signing Successful.", action_name)); + } else if !is_unilateral_group_member && has_group { + ui.heading(format!("Group {} Initiated.", action_name)); + } else { + ui.heading(format!("{} Successful.", action_name)); + } + + // Optional fee info section + if let Some((title, description)) = fee_info { + ui.add_space(24.0); + + let description_width = 500.0_f32.min(ui.available_width() - 40.0); + ui.allocate_ui_with_layout( + egui::Vec2::new(description_width, 0.0), + egui::Layout::top_down(egui::Align::Center), + |ui| { + ui.label( + egui::RichText::new(title) + .size(16.0) + .strong() + .color(crate::ui::theme::DashColors::text_primary(dark_mode)), + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new(description) + .size(14.0) + .color(crate::ui::theme::DashColors::text_secondary(dark_mode)), + ); + }, + ); + } + + ui.add_space(20.0); + + // Show appropriate buttons based on the action type + if is_group_action_signing { + if ui.button("Back to Group Actions").clicked() { + action = AppAction::PopScreenAndRefresh; + } + if ui.button("Back to Tokens").clicked() { + action = AppAction::SetMainScreenThenGoToMainScreen( + RootScreenType::RootScreenMyTokenBalances, + ); + } + } else { + if ui.button("Back to Tokens").clicked() { + action = AppAction::PopScreenAndRefresh; + } + + if !is_unilateral_group_member + && has_group + && ui.button("Go to Group Actions").clicked() + { + action = AppAction::PopThenAddScreenToMainScreen( + RootScreenType::RootScreenDocumentQuery, + Screen::GroupActionsScreen(GroupActionsScreen::new(app_context)), + ); + } + } + ui.add_space(if fee_info.is_some() { 60.0 } else { 100.0 }); }); action } diff --git a/src/ui/identities/add_existing_identity_screen.rs b/src/ui/identities/add_existing_identity_screen.rs index e139b3ed3..dcb88d2c4 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -1,18 +1,23 @@ use crate::app::AppAction; -use crate::backend_task::BackendTask; use crate::backend_task::identity::{IdentityInputToLoad, IdentityTask}; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::qualified_identity::IdentityType; use crate::model::wallet::Wallet; +use crate::ui::components::info_popup::InfoPopup; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; use crate::ui::{MessageType, ScreenLike}; use bip39::rand::{prelude::IteratorRandom, thread_rng}; use dash_sdk::dashcore_rpc::dashcore::Network; use dash_sdk::dpp::identity::TimestampMillis; -use eframe::egui::Context; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::Identifier; +use eframe::egui::{Context, Frame, Margin}; use egui::{Color32, ComboBox, RichText, Ui}; use serde::Deserialize; use std::fs; @@ -56,8 +61,9 @@ fn load_testnet_nodes_from_yml(file_path: &str) -> Option { #[derive(Clone, Copy, PartialEq, Eq)] enum LoadIdentityMode { - ByIdentityId, - ByWallet, + IdentityId, + Wallet, + DpnsName, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -86,8 +92,7 @@ pub struct AddExistingIdentityScreen { testnet_loaded_nodes: Option, selected_wallet: Option>>, identity_associated_with_wallet: bool, - show_password: bool, - wallet_password: String, + wallet_unlock_popup: WalletUnlockPopup, error_message: Option, pub identity_index_input: String, pub app_context: Arc, @@ -96,6 +101,9 @@ pub struct AddExistingIdentityScreen { backend_message: Option, wallet_search_mode: WalletIdentitySearchMode, success_message: Option, + dpns_name_input: String, + /// Whether to show advanced options + show_advanced_options: bool, } impl AddExistingIdentityScreen { @@ -118,29 +126,37 @@ impl AddExistingIdentityScreen { testnet_loaded_nodes, selected_wallet, identity_associated_with_wallet: true, - show_password: false, - wallet_password: "".to_string(), + wallet_unlock_popup: WalletUnlockPopup::new(), error_message: None, identity_index_input: String::new(), app_context: app_context.clone(), show_pop_up_info: None, - mode: LoadIdentityMode::ByIdentityId, + mode: LoadIdentityMode::IdentityId, backend_message: None, wallet_search_mode: WalletIdentitySearchMode::SpecificIndex, success_message: None, + dpns_name_input: String::new(), + show_advanced_options: false, } } fn render_by_identity(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; - if self.app_context.network == Network::Testnet && self.testnet_loaded_nodes.is_some() { - if ui.button("Fill Random HPMN").clicked() { - self.fill_random_hpmn(); - } - if ui.button("Fill Random Masternode").clicked() { - self.fill_random_masternode(); - } + // Advanced: Testnet quick-fill buttons + if self.show_advanced_options + && self.app_context.network == Network::Testnet + && self.testnet_loaded_nodes.is_some() + { + ui.horizontal(|ui| { + if ui.button("Fill Random HPMN").clicked() { + self.fill_random_hpmn(); + } + if ui.button("Fill Random Masternode").clicked() { + self.fill_random_masternode(); + } + }); + ui.add_space(10.0); } let wallets_snapshot: Vec<(String, Arc>)> = { @@ -161,235 +177,280 @@ impl AddExistingIdentityScreen { let has_wallets = !wallets_snapshot.is_empty(); let mut should_return_early = false; - ui.add_space(10.0); + // In simple mode, always try to derive from wallets + if !self.show_advanced_options { + self.identity_associated_with_wallet = true; + self.identity_type = IdentityType::User; + } - ui.vertical(|ui| { - ui.horizontal(|ui| { - let checkbox_response = ui.checkbox( - &mut self.identity_associated_with_wallet, - "Try to automatically derive private keys from loaded wallet", - ); - let response = crate::ui::helpers::info_icon_button( - ui, - "When enabled, Dash Evo Tool scans the selected unlocked wallet (or all unlocked wallets) right now to find matching keys.", - ); - if response.clicked() { - self.show_pop_up_info = Some( - "When enabled, Dash Evo Tool scans the selected unlocked wallet (or all unlocked wallets) right now to find matching keys." - .to_string(), + // Advanced: Wallet derivation checkbox and selection + if self.show_advanced_options { + ui.vertical(|ui| { + ui.horizontal(|ui| { + let checkbox_response = ui.checkbox( + &mut self.identity_associated_with_wallet, + "Try to automatically derive private keys from loaded wallet", ); - } + let response = crate::ui::helpers::info_icon_button( + ui, + "When enabled, Dash Evo Tool scans the selected unlocked wallet (or all unlocked wallets) right now to find matching keys.", + ); + if response.clicked() { + self.show_pop_up_info = Some( + "When enabled, Dash Evo Tool scans the selected unlocked wallet (or all unlocked wallets) right now to find matching keys." + .to_string(), + ); + } - if checkbox_response.changed() && !self.identity_associated_with_wallet { - self.selected_wallet = None; - } - }); + if checkbox_response.changed() && !self.identity_associated_with_wallet { + self.selected_wallet = None; + } + }); - if self.identity_associated_with_wallet { - if has_wallets { - let selected_label = self - .selected_wallet - .as_ref() - .and_then(|selected| { - wallets_snapshot.iter().find_map(|(alias, wallet)| { - if Arc::ptr_eq(selected, wallet) { - Some(alias.clone()) - } else { - None - } + if self.identity_associated_with_wallet { + if has_wallets { + let selected_label = self + .selected_wallet + .as_ref() + .and_then(|selected| { + wallets_snapshot.iter().find_map(|(alias, wallet)| { + if Arc::ptr_eq(selected, wallet) { + Some(alias.clone()) + } else { + None + } + }) }) - }) - .unwrap_or_else(|| "All unlocked wallets".to_string()); + .unwrap_or_else(|| "All unlocked wallets".to_string()); + + ComboBox::from_id_salt("identity_wallet_selector") + .selected_text(selected_label) + .show_ui(ui, |ui| { + if ui + .selectable_label( + self.selected_wallet.is_none(), + "All unlocked wallets", + ) + .clicked() + { + self.selected_wallet = None; + } - ComboBox::from_id_salt("identity_wallet_selector") - .selected_text(selected_label) - .show_ui(ui, |ui| { - if ui - .selectable_label( - self.selected_wallet.is_none(), - "All unlocked wallets", - ) - .clicked() - { - self.selected_wallet = None; - } + for (alias, wallet) in &wallets_snapshot { + let is_selected = self + .selected_wallet + .as_ref() + .is_some_and(|selected| Arc::ptr_eq(selected, wallet)); - for (alias, wallet) in &wallets_snapshot { - let is_selected = self - .selected_wallet - .as_ref() - .is_some_and(|selected| Arc::ptr_eq(selected, wallet)); + if ui.selectable_label(is_selected, alias).clicked() { + self.selected_wallet = Some(wallet.clone()); + } + } + }); - if ui.selectable_label(is_selected, alias).clicked() { - self.selected_wallet = Some(wallet.clone()); + ui.add_space(10.0); + if let Some(selected_wallet) = &self.selected_wallet { + let wallet_still_loaded = wallets_snapshot + .iter() + .any(|(_, wallet)| Arc::ptr_eq(wallet, selected_wallet)); + + if wallet_still_loaded { + // Try to open wallet without password if it doesn't use one + if let Err(e) = try_open_wallet_no_password(selected_wallet) { + self.error_message = Some(e); } - } - }); - ui.add_space(10.0); - if let Some(selected_wallet) = &self.selected_wallet { - let wallet_still_loaded = wallets_snapshot - .iter() - .any(|(_, wallet)| Arc::ptr_eq(wallet, selected_wallet)); - - if wallet_still_loaded { - let (needed_unlock, just_unlocked) = - self.render_wallet_unlock_if_needed(ui); - if needed_unlock && !just_unlocked { - should_return_early = true; - } else if just_unlocked { + if wallet_needs_unlock(selected_wallet) { + ui.colored_label( + Color32::from_rgb(200, 150, 50), + "Wallet is locked.", + ); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + should_return_early = true; + } + } else { + self.selected_wallet = None; ui.colored_label( - Color32::GREEN, - "Wallet unlocked. We'll pull any matching keys automatically.", + Color32::RED, + "Selected wallet is no longer loaded. We'll search unlocked wallets instead.", ); } - } else { - self.selected_wallet = None; - ui.colored_label( - Color32::RED, - "Selected wallet is no longer loaded. We'll search unlocked wallets instead.", - ); } + } else { + ui.colored_label( + Color32::GRAY, + "No wallets are currently loaded. Import one to scan for keys.", + ); } - } else { - ui.colored_label( - Color32::GRAY, - "No wallets are currently loaded. Import one to scan for keys.", - ); } - } - }); + }); + ui.add_space(10.0); + } if should_return_early { return action; } + // Main form egui::Grid::new("add_existing_identity_grid") .num_columns(2) .spacing([10.0, 10.0]) .striped(false) .show(ui, |ui| { - ui.label("Identity ID / ProTxHash (Hex or Base58):"); - ui.text_edit_singleline(&mut self.identity_id_input); - ui.label(""); - ui.end_row(); - - ui.label("Identity Type:"); - - ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { - egui::ComboBox::from_id_salt("identity_type_selector") - .selected_text(format!("{:?}", self.identity_type)) - // .width(350.0) // This sets the entire row's width - .show_ui(ui, |ui| { - ui.selectable_value(&mut self.identity_type, IdentityType::User, "User"); - ui.selectable_value( - &mut self.identity_type, - IdentityType::Masternode, - "Masternode", - ); - ui.selectable_value( - &mut self.identity_type, - IdentityType::Evonode, - "Evonode", + // Identity ID input - always shown + ui.horizontal(|ui| { + ui.label("Identity ID:"); + if self.show_advanced_options { + let response = crate::ui::helpers::info_icon_button( + ui, + "Enter the Identity ID in Hex or Base58 format. For masternodes/evonodes, use the ProTxHash.", + ); + if response.clicked() { + self.show_pop_up_info = Some( + "Enter the Identity ID in Hex or Base58 format. For masternodes/evonodes, use the ProTxHash." + .to_string(), ); - }); + } + } }); - ui.label(""); + ui.text_edit_singleline(&mut self.identity_id_input); ui.end_row(); - // Input for Alias + // Advanced: Identity Type selector + if self.show_advanced_options { + ui.label("Identity Type:"); + ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { + egui::ComboBox::from_id_salt("identity_type_selector") + .selected_text(format!("{:?}", self.identity_type)) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.identity_type, IdentityType::User, "User"); + ui.selectable_value( + &mut self.identity_type, + IdentityType::Masternode, + "Masternode", + ); + ui.selectable_value( + &mut self.identity_type, + IdentityType::Evonode, + "Evonode", + ); + }); + }); + ui.end_row(); + } + + // Alias input - always shown ui.horizontal(|ui| { ui.label("Alias (optional):"); - let response = crate::ui::helpers::info_icon_button(ui, "Alias is optional. It is only used to help identify the identity in Dash Evo Tool. It isn't saved to Dash Platform."); + let response = crate::ui::helpers::info_icon_button( + ui, + "Alias is optional. It is only used to help identify the identity in Dash Evo Tool. It isn't saved to Dash Platform.", + ); if response.clicked() { - self.show_pop_up_info = Some("Alias is optional. It is only used to help identify the identity in Dash Evo Tool. It isn't saved to Dash Platform.".to_string()); + self.show_pop_up_info = Some( + "Alias is optional. It is only used to help identify the identity in Dash Evo Tool. It isn't saved to Dash Platform." + .to_string(), + ); } }); ui.text_edit_singleline(&mut self.alias_input); - ui.label(""); ui.end_row(); - // Render the keys input based on identity type - match self.identity_type { - IdentityType::Masternode | IdentityType::Evonode => { - // Store the voting and owner private key references before borrowing `self` mutably - let voting_private_key_input = &mut self.voting_private_key_input; - let owner_private_key_input = &mut self.owner_private_key_input; - let payout_address_private_key_input = - &mut self.payout_address_private_key_input; - - ui.label("Voting Private Key:"); - ui.text_edit_singleline(voting_private_key_input); - ui.end_row(); - - ui.label("Owner Private Key:"); - ui.text_edit_singleline(owner_private_key_input); - ui.end_row(); - - ui.label("Payout Address Private Key:"); - ui.text_edit_singleline(payout_address_private_key_input); - ui.end_row(); - } - IdentityType::User => { - // A temporary vector to store indices of keys to be removed - let mut keys_to_remove = vec![]; - - for (i, key) in self.keys_input.iter_mut().enumerate() { - // First column: the label & info icon, combined horizontally - ui.horizontal(|ui| { - ui.label(format!("Private Key {} (Hex or WIF):", i + 1)); - - let response = crate::ui::helpers::info_icon_button(ui, "You don't need to add all or even any private keys here. \ - Private keys can be added later. However, without private keys, \ - you won't be able to sign any transactions."); - - if response.clicked() { - self.show_pop_up_info = Some( - "You don't need to add all or even any private keys here. \ - Private keys can be added later. However, without private keys, \ - you won't be able to sign any transactions." - .to_string(), - ); - } - }); - - // Second column: the text field - ui.text_edit_singleline(key); + // Advanced: Masternode/Evonode key inputs + if self.show_advanced_options { + match self.identity_type { + IdentityType::Masternode | IdentityType::Evonode => { + let voting_private_key_input = &mut self.voting_private_key_input; + let owner_private_key_input = &mut self.owner_private_key_input; + let payout_address_private_key_input = + &mut self.payout_address_private_key_input; + + ui.label("Voting Private Key:"); + ui.text_edit_singleline(voting_private_key_input); + ui.end_row(); - // Third column: the remove button - if ui.button("-").clicked() { - keys_to_remove.push(i); - } + ui.label("Owner Private Key:"); + ui.text_edit_singleline(owner_private_key_input); + ui.end_row(); + ui.label("Payout Address Private Key:"); + ui.text_edit_singleline(payout_address_private_key_input); ui.end_row(); } + IdentityType::User => { + // Manual key inputs for User type + let mut keys_to_remove = vec![]; + + for (i, key) in self.keys_input.iter_mut().enumerate() { + ui.horizontal(|ui| { + ui.label(format!("Private Key {} (Hex or WIF):", i + 1)); + + let response = crate::ui::helpers::info_icon_button( + ui, + "You don't need to add all or even any private keys here. Private keys can be added later. However, without private keys, you won't be able to sign any transactions.", + ); - // Remove the keys after the loop to avoid borrowing conflicts - for i in keys_to_remove.iter().rev() { - self.keys_input.remove(*i); + if response.clicked() { + self.show_pop_up_info = Some( + "You don't need to add all or even any private keys here. Private keys can be added later. However, without private keys, you won't be able to sign any transactions." + .to_string(), + ); + } + }); + + ui.text_edit_singleline(key); + + if ui.button("-").clicked() { + keys_to_remove.push(i); + } + + ui.end_row(); + } + + for i in keys_to_remove.iter().rev() { + self.keys_input.remove(*i); + } } } } }); - ui.add_space(10.0); - - // Add button to add more keys - if ui.button("+ Add key manually").clicked() { - self.keys_input.push(String::new()); + // Advanced: Add key manually button + if self.show_advanced_options && self.identity_type == IdentityType::User { + ui.add_space(10.0); + if ui.button("+ Add key manually").clicked() { + self.keys_input.push(String::new()); + } } - ui.add_space(10.0); - // Load Identity button + ui.add_space(15.0); + + // Validate identity ID + let identity_id_trimmed = self.identity_id_input.trim().to_string(); + let is_valid_id = !identity_id_trimmed.is_empty() + && Identifier::from_string_try_encodings( + &identity_id_trimmed, + &[Encoding::Base58, Encoding::Hex], + ) + .is_ok(); + + // Load Identity button - styled like Create Identity let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); ui.set_style(new_style); + let button = egui::Button::new(RichText::new("Load Identity").color(Color32::WHITE)) - .fill(Color32::from_rgb(0, 128, 255)) + .fill(if is_valid_id { + Color32::from_rgb(0, 128, 255) + } else { + Color32::from_rgb(100, 100, 100) + }) .frame(true) .corner_radius(3.0); - if ui.add(button).clicked() { - // Set the status to waiting and capture the current time + + if ui.add_enabled(is_valid_id, button).clicked() { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Time went backwards") @@ -397,6 +458,21 @@ impl AddExistingIdentityScreen { self.add_identity_status = AddIdentityStatus::WaitingForResult(now); action = self.load_identity_clicked(); } + + // Show helpful message based on input state + if identity_id_trimmed.is_empty() { + ui.add_space(5.0); + ui.label(RichText::new("Enter an Identity ID to continue.").color(Color32::GRAY)); + } else if !is_valid_id { + ui.add_space(5.0); + ui.label( + RichText::new( + "Invalid Identity ID format. Must be valid Base58 or Hex (64 characters).", + ) + .color(Color32::from_rgb(255, 150, 100)), + ); + } + action } @@ -462,9 +538,20 @@ impl AddExistingIdentityScreen { return action; } + // In simple mode, default to searching all indices up to 5 + if !self.show_advanced_options { + self.wallet_search_mode = WalletIdentitySearchMode::UpToIndex; + if self.identity_index_input.is_empty() { + self.identity_index_input = "5".to_string(); + } + } + // Wallet selection if wallets_len > 1 { + ui.label("Select which wallet to search for identities:"); + ui.add_space(5.0); self.render_wallet_selection(ui); + ui.add_space(10.0); } if self.selected_wallet.is_none() { @@ -472,67 +559,100 @@ impl AddExistingIdentityScreen { return action; }; - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); + let wallet = self.selected_wallet.as_ref().unwrap(); - if needed_unlock && !just_unlocked { - return action; + // Try to open wallet without password if it doesn't use one + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); } - let mut wallet_mode_changed = false; - ui.horizontal(|ui| { - ui.label("Search type:"); - wallet_mode_changed |= ui - .selectable_value( - &mut self.wallet_search_mode, - WalletIdentitySearchMode::SpecificIndex, - "Specific index", - ) - .changed(); - wallet_mode_changed |= ui - .selectable_value( - &mut self.wallet_search_mode, - WalletIdentitySearchMode::UpToIndex, - "All up to index", - ) - .changed(); - }); - if wallet_mode_changed { - self.add_identity_status = AddIdentityStatus::NotStarted; - self.error_message = None; - self.backend_message = None; - self.success_message = None; + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + return action; } - ui.add_space(6.0); - let identity_index_label = match self.wallet_search_mode { - WalletIdentitySearchMode::SpecificIndex => "Identity index:", - WalletIdentitySearchMode::UpToIndex => { - "Highest identity index to search (inclusive, max 29):" + // Advanced: Search type selector + if self.show_advanced_options { + let mut wallet_mode_changed = false; + ui.horizontal(|ui| { + ui.label("Search type:"); + wallet_mode_changed |= ui + .selectable_value( + &mut self.wallet_search_mode, + WalletIdentitySearchMode::SpecificIndex, + "Specific index", + ) + .changed(); + wallet_mode_changed |= ui + .selectable_value( + &mut self.wallet_search_mode, + WalletIdentitySearchMode::UpToIndex, + "All up to index", + ) + .changed(); + }); + if wallet_mode_changed { + self.add_identity_status = AddIdentityStatus::NotStarted; + self.error_message = None; + self.backend_message = None; + self.success_message = None; } - }; + ui.add_space(6.0); - ui.horizontal(|ui| { - ui.label(identity_index_label); - ui.text_edit_singleline(&mut self.identity_index_input); - }); + let identity_index_label = match self.wallet_search_mode { + WalletIdentitySearchMode::SpecificIndex => "Identity index:", + WalletIdentitySearchMode::UpToIndex => { + "Highest identity index to search (inclusive, max 29):" + } + }; - match self.wallet_search_mode { - WalletIdentitySearchMode::SpecificIndex => { - ui.label("This is the derivation index used when the identity was created."); - } - WalletIdentitySearchMode::UpToIndex => { - ui.label( - "Searches each derivation index starting at 0 up to the provided index (inclusive).", - ); + ui.horizontal(|ui| { + ui.label(identity_index_label); + ui.text_edit_singleline(&mut self.identity_index_input); + }); + + match self.wallet_search_mode { + WalletIdentitySearchMode::SpecificIndex => { + ui.label("This is the derivation index used when the identity was created."); + } + WalletIdentitySearchMode::UpToIndex => { + ui.label( + "Searches each derivation index starting at 0 up to the provided index (inclusive).", + ); + } } + } else { + // Simple mode: just show explanation and use default + ui.label("This will search your wallet for any identities created with it."); + ui.add_space(5.0); } + ui.add_space(10.0); + let button_label = match self.wallet_search_mode { WalletIdentitySearchMode::SpecificIndex => "Search For Identity", - WalletIdentitySearchMode::UpToIndex => "Load Identities", + WalletIdentitySearchMode::UpToIndex => "Search Wallet for Identities", }; - if ui.button(button_label).clicked() { + // Styled button consistent with other modes + let mut new_style = (**ui.style()).clone(); + new_style.spacing.button_padding = egui::vec2(10.0, 5.0); + ui.set_style(new_style); + + let button = egui::Button::new(RichText::new(button_label).color(Color32::WHITE)) + .fill(Color32::from_rgb(0, 128, 255)) + .frame(true) + .corner_radius(3.0); + + if ui.add(button).clicked() { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Time went backwards") @@ -555,7 +675,7 @@ impl AddExistingIdentityScreen { }, )); } else { - // Handle invalid index input (optional) + // Handle invalid index input self.add_identity_status = AddIdentityStatus::ErrorMessage("Invalid identity index".to_string()); } @@ -563,6 +683,166 @@ impl AddExistingIdentityScreen { action } + fn render_by_dpns_name(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + + ui.label("Look up an identity by its registered DPNS username."); + ui.add_space(15.0); + + let wallets_snapshot: Vec<(String, Arc>)> = { + let wallets_guard = self.app_context.wallets.read().unwrap(); + wallets_guard + .values() + .map(|wallet| { + let alias = wallet + .read() + .unwrap() + .alias + .clone() + .unwrap_or_else(|| "Unnamed Wallet".to_string()); + (alias, wallet.clone()) + }) + .collect() + }; + let has_wallets = !wallets_snapshot.is_empty(); + + // In simple mode, always try to derive from wallets + if !self.show_advanced_options { + self.identity_associated_with_wallet = true; + } + + // Advanced: Wallet derivation options + if self.show_advanced_options { + ui.horizontal(|ui| { + ui.checkbox( + &mut self.identity_associated_with_wallet, + "Try to automatically derive private keys from loaded wallet", + ); + let response = crate::ui::helpers::info_icon_button( + ui, + "When enabled, Dash Evo Tool scans the selected unlocked wallet (or all unlocked wallets) to find matching keys.", + ); + if response.clicked() { + self.show_pop_up_info = Some( + "When enabled, Dash Evo Tool scans the selected unlocked wallet (or all unlocked wallets) to find matching keys." + .to_string(), + ); + } + }); + + if self.identity_associated_with_wallet && has_wallets { + let selected_label = self + .selected_wallet + .as_ref() + .and_then(|selected| { + wallets_snapshot.iter().find_map(|(alias, wallet)| { + if Arc::ptr_eq(selected, wallet) { + Some(alias.clone()) + } else { + None + } + }) + }) + .unwrap_or_else(|| "All unlocked wallets".to_string()); + + ComboBox::from_id_salt("dpns_wallet_selector") + .selected_text(selected_label) + .show_ui(ui, |ui| { + if ui + .selectable_label( + self.selected_wallet.is_none(), + "All unlocked wallets", + ) + .clicked() + { + self.selected_wallet = None; + } + + for (alias, wallet) in &wallets_snapshot { + let is_selected = self + .selected_wallet + .as_ref() + .is_some_and(|selected| Arc::ptr_eq(selected, wallet)); + + if ui.selectable_label(is_selected, alias).clicked() { + self.selected_wallet = Some(wallet.clone()); + } + } + }); + } + ui.add_space(10.0); + } + + egui::Grid::new("dpns_search_grid") + .num_columns(2) + .spacing([10.0, 10.0]) + .show(ui, |ui| { + ui.label("Username:"); + ui.horizontal(|ui| { + ui.text_edit_singleline(&mut self.dpns_name_input); + ui.label(".dash"); + }); + ui.end_row(); + }); + + ui.add_space(5.0); + ui.label( + RichText::new("Example: Enter \"alice\" to look up \"alice.dash\"") + .color(Color32::GRAY), + ); + ui.add_space(15.0); + + // Search button - styled consistently + let mut new_style = (**ui.style()).clone(); + new_style.spacing.button_padding = egui::vec2(10.0, 5.0); + ui.set_style(new_style); + + let name_trimmed = self.dpns_name_input.trim(); + let is_valid = !name_trimmed.is_empty() && name_trimmed.len() >= 3; + + let button = egui::Button::new(RichText::new("Search by Username").color(Color32::WHITE)) + .fill(if is_valid { + Color32::from_rgb(0, 128, 255) + } else { + Color32::from_rgb(100, 100, 100) + }) + .frame(true) + .corner_radius(3.0); + + if ui.add_enabled(is_valid, button).clicked() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.add_identity_status = AddIdentityStatus::WaitingForResult(now); + self.backend_message = None; + self.success_message = None; + + // Get the selected wallet seed hash for key derivation + let selected_wallet_seed_hash = if self.identity_associated_with_wallet { + self.selected_wallet + .as_ref() + .map(|wallet| wallet.read().unwrap().seed_hash()) + } else { + None + }; + + action = AppAction::BackendTask(BackendTask::IdentityTask( + IdentityTask::SearchIdentityByDpnsName( + name_trimmed.to_string(), + selected_wallet_seed_hash, + ), + )); + } + + if !is_valid && !name_trimmed.is_empty() { + ui.add_space(5.0); + ui.label(RichText::new("Username must be at least 3 characters.").color(Color32::GRAY)); + } + + action + } + fn load_identity_clicked(&mut self) -> AppAction { let selected_wallet_seed_hash = if self.identity_associated_with_wallet { self.selected_wallet @@ -624,101 +904,92 @@ impl AddExistingIdentityScreen { } pub fn show_success(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - - // Center the content vertically and horizontally - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - let success_text = self - .success_message - .clone() - .unwrap_or_else(|| "Successfully loaded identity.".to_string()); - ui.label(RichText::new(success_text)); - - ui.add_space(20.0); - - if ui.button("Load Another").clicked() { - self.identity_id_input.clear(); - self.alias_input.clear(); - self.voting_private_key_input.clear(); - self.owner_private_key_input.clear(); - self.payout_address_private_key_input.clear(); - self.keys_input = vec![String::new(), String::new(), String::new()]; - self.identity_index_input.clear(); - self.error_message = None; - self.show_pop_up_info = None; - self.add_identity_status = AddIdentityStatus::NotStarted; - self.backend_message = None; - self.success_message = None; - } - ui.add_space(5.0); + let success_text = self + .success_message + .clone() + .unwrap_or_else(|| "Successfully loaded identity.".to_string()); + + let action = crate::ui::helpers::show_success_screen( + ui, + success_text, + vec![ + ( + "Load Another".to_string(), + AppAction::Custom("load_another".to_string()), + ), + ( + "Back to Identities Screen".to_string(), + AppAction::PopScreenAndRefresh, + ), + ], + ); - if ui.button("Back to Identities Screen").clicked() { - action = AppAction::PopScreenAndRefresh; - } - ui.add_space(5.0); - }); + // Handle the custom action to reset the form + if let AppAction::Custom(ref s) = action + && s == "load_another" + { + self.identity_id_input.clear(); + self.alias_input.clear(); + self.voting_private_key_input.clear(); + self.owner_private_key_input.clear(); + self.payout_address_private_key_input.clear(); + self.keys_input = vec![String::new(), String::new(), String::new()]; + self.identity_index_input.clear(); + self.dpns_name_input.clear(); + self.error_message = None; + self.show_pop_up_info = None; + self.add_identity_status = AddIdentityStatus::NotStarted; + self.backend_message = None; + self.success_message = None; + return AppAction::None; + } action } } -impl ScreenWithWalletUnlock for AddExistingIdentityScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } - - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() - } -} - impl ScreenLike for AddExistingIdentityScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { match message_type { + MessageType::Error => { + self.add_identity_status = AddIdentityStatus::ErrorMessage(message.to_string()); + } MessageType::Success => { - if message == "Successfully loaded identity" { - self.success_message = Some("Successfully loaded identity.".to_string()); - self.add_identity_status = AddIdentityStatus::Complete; - self.backend_message = None; - } else if (message.starts_with("Successfully loaded ") - && message.contains(" up to index ")) - || message.starts_with("Finished loading identities up to index ") + // Check if this is a final success message or a progress update + if message.starts_with("Successfully loaded") + || message.starts_with("Finished loading") { self.success_message = Some(message.to_string()); self.add_identity_status = AddIdentityStatus::Complete; self.backend_message = None; } else { + // This is a progress update self.backend_message = Some(message.to_string()); } } - MessageType::Info => {} - MessageType::Error => { - // It's not great because the error message can be coming from somewhere else if there are other processes happening - self.add_identity_status = AddIdentityStatus::ErrorMessage(message.to_string()); + _ => {} + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + match backend_task_success_result { + BackendTaskSuccessResult::LoadedIdentity(_) => { + self.success_message = Some("Successfully loaded identity.".to_string()); + self.add_identity_status = AddIdentityStatus::Complete; + self.backend_message = None; + } + BackendTaskSuccessResult::Message(msg) => { + // Check if this is a final success message or a progress update + if msg.starts_with("Successfully loaded") || msg.starts_with("Finished loading") { + self.success_message = Some(msg); + self.add_identity_status = AddIdentityStatus::Complete; + self.backend_message = None; + } else { + // This is a progress update + self.backend_message = Some(msg); + } } + _ => {} } } @@ -746,35 +1017,70 @@ impl ScreenLike for AddExistingIdentityScreen { action |= island_central_panel(ctx, |ui| { let mut inner_action = AppAction::None; + // Display error message at the top, outside of scroll area + if let Some(error_message) = self.error_message.clone() { + let error_color = Color32::from_rgb(255, 100, 100); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", error_message)) + .color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.error_message = None; + } + }); + }); + ui.add_space(10.0); + } + egui::ScrollArea::vertical() .auto_shrink([false; 2]) .show(ui, |ui| { - ui.heading("Load Existing Identity"); - ui.add_space(10.0); - + // Show success screen without the header/description/checkbox if self.add_identity_status == AddIdentityStatus::Complete { inner_action |= self.show_success(ui); return; } + // Heading with checkbox on the same line + ui.horizontal(|ui| { + ui.heading("Load Existing Identity"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Show Advanced Options"); + }); + }); + ui.add_space(5.0); + ui.label("Load an identity that already exists on Dash Platform."); + ui.add_space(15.0); + let mut mode_changed = false; ui.horizontal(|ui| { mode_changed |= ui .selectable_value( &mut self.mode, - LoadIdentityMode::ByIdentityId, - "By Identity", + LoadIdentityMode::IdentityId, + "By Identity ID", ) .changed(); + mode_changed |= ui + .selectable_value(&mut self.mode, LoadIdentityMode::Wallet, "By Wallet") + .changed(); mode_changed |= ui .selectable_value( &mut self.mode, - LoadIdentityMode::ByWallet, - "By Wallet", + LoadIdentityMode::DpnsName, + "By DPNS Name", ) .changed(); }); - ui.add_space(10.0); + ui.add_space(15.0); if mode_changed { self.add_identity_status = AddIdentityStatus::NotStarted; @@ -784,16 +1090,19 @@ impl ScreenLike for AddExistingIdentityScreen { } match self.mode { - LoadIdentityMode::ByIdentityId => { + LoadIdentityMode::IdentityId => { inner_action |= self.render_by_identity(ui); } - LoadIdentityMode::ByWallet => { + LoadIdentityMode::Wallet => { let wallets_len = { let wallets = self.app_context.wallets.read().unwrap(); wallets.len() }; inner_action |= self.render_by_wallet(ui, wallets_len); } + LoadIdentityMode::DpnsName => { + inner_action |= self.render_by_dpns_name(ui); + } } ui.add_space(10.0); @@ -827,14 +1136,34 @@ impl ScreenLike for AddExistingIdentityScreen { ) }; - ui.label(format!("Loading... Time taken so far: {}", display_time)); - - if self.backend_message.is_some() { - ui.label(self.backend_message.clone().unwrap().to_string()); + // Show progress message with time, or generic loading message + if let Some(ref progress_msg) = self.backend_message { + ui.label(format!("{} ({})", progress_msg, display_time)); + } else { + ui.label(format!("Loading... ({})", display_time)); } } AddIdentityStatus::ErrorMessage(msg) => { - ui.colored_label(egui::Color32::DARK_RED, format!("Error: {}", msg)); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = msg.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", msg)) + .color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.add_identity_status = + AddIdentityStatus::NotStarted; + } + }); + }); } AddIdentityStatus::Complete => { // handled above @@ -847,20 +1176,29 @@ impl ScreenLike for AddExistingIdentityScreen { // Show the popup window if `show_popup` is true if let Some(show_pop_up_info_text) = self.show_pop_up_info.clone() { - egui::Window::new("Load Identity Information") - .collapsible(false) // Prevent collapsing - .resizable(false) // Prevent resizing + egui::CentralPanel::default() + .frame(egui::Frame::NONE) .show(ctx, |ui| { - ui.label(show_pop_up_info_text); - - // Add a close button to dismiss the popup - ui.add_space(10.0); - if ui.button("Close").clicked() { - self.show_pop_up_info = None + let mut popup = + InfoPopup::new("Load Identity Information", &show_pop_up_info_text); + if popup.show(ui).inner { + self.show_pop_up_info = None; } }); } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } + action } } diff --git a/src/ui/identities/add_new_identity_screen/by_platform_address.rs b/src/ui/identities/add_new_identity_screen/by_platform_address.rs new file mode 100644 index 000000000..6f46ee6de --- /dev/null +++ b/src/ui/identities/add_new_identity_screen/by_platform_address.rs @@ -0,0 +1,265 @@ +use crate::app::AppAction; +use crate::model::amount::Amount; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::identities::add_new_identity_screen::{ + AddNewIdentityScreen, FundingMethod, WalletFundedScreenStep, +}; +use dash_sdk::dpp::address_funds::PlatformAddress; +use egui::{Color32, ComboBox, RichText, Ui}; + +/// Constants for credit/DASH conversion +const CREDITS_PER_DUFF: u64 = 1000; + +impl AddNewIdentityScreen { + fn show_platform_address_balance(&self, ui: &mut egui::Ui) { + if let Some(selected_wallet) = &self.selected_wallet { + let wallet = selected_wallet.read().unwrap(); + + let total_platform_balance: u64 = wallet + .platform_address_info + .values() + .map(|info| info.balance) + .sum(); + + let dash_balance = total_platform_balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + + ui.horizontal(|ui| { + ui.label(format!( + "Total Platform Address Balance: {:.8} DASH", + dash_balance + )); + }); + } else { + ui.label("No wallet selected"); + } + } + + pub fn render_ui_by_platform_address(&mut self, ui: &mut Ui, step_number: u32) -> AppAction { + let mut action = AppAction::None; + + ui.add_space(10.0); + ui.heading(format!( + "{}. Select a Platform address to fund your new identity", + step_number + )); + + ui.add_space(10.0); + self.show_platform_address_balance(ui); + ui.add_space(10.0); + + // Get Platform addresses from the wallet (using DIP-18 Bech32m format for display) + let network = self.app_context.network; + let platform_addresses: Vec<(String, PlatformAddress, u64)> = + if let Some(wallet_arc) = &self.selected_wallet { + let wallet = wallet_arc.read().unwrap(); + wallet + .platform_addresses(network) + .into_iter() + .map(|(core_addr, platform_addr)| { + let balance = wallet + .get_platform_address_info(&core_addr) + .map(|info| info.balance) + .unwrap_or(0); + // Use Bech32m format for display + ( + platform_addr.to_bech32m_string(network), + platform_addr, + balance, + ) + }) + .filter(|(_, _, balance)| *balance > 0) + .collect() + } else { + vec![] + }; + + if platform_addresses.is_empty() { + ui.colored_label( + Color32::GRAY, + "No Platform addresses with balance found. Fund a Platform address first.", + ); + return action; + } + + // Platform address selector (display in DIP-18 Bech32m format) + let selected_addr_display = self + .selected_platform_address_for_funding + .as_ref() + .map(|(addr, _)| { + let bech32_addr = addr.to_bech32m_string(network); + // Truncate for display: show first 12 chars... last 8 chars + if bech32_addr.len() > 24 { + format!( + "{}...{}", + &bech32_addr[..12], + &bech32_addr[bech32_addr.len() - 8..] + ) + } else { + bech32_addr + } + }) + .unwrap_or_else(|| "Select a Platform address".to_string()); + + ComboBox::from_label("Platform Address") + .selected_text(selected_addr_display) + .show_ui(ui, |ui| { + for (bech32_addr_str, platform_addr, balance) in &platform_addresses { + let dash_balance = *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + // Truncate Bech32m address for display in dropdown + let addr_display = if bech32_addr_str.len() > 20 { + format!( + "{}...{}", + &bech32_addr_str[..12], + &bech32_addr_str[bech32_addr_str.len() - 6..] + ) + } else { + bech32_addr_str.clone() + }; + let label = format!("{} ({:.4} DASH)", addr_display, dash_balance); + let is_selected = self + .selected_platform_address_for_funding + .as_ref() + .map(|(addr, _)| addr == platform_addr) + .unwrap_or(false); + + if ui.selectable_label(is_selected, label).clicked() { + // Get the amount from the AmountInput component + let amount_credits = self + .platform_funding_amount + .as_ref() + .map(|a| a.value()) + .unwrap_or(0); + self.selected_platform_address_for_funding = + Some((*platform_addr, amount_credits.min(*balance))); + } + } + }); + + ui.add_space(10.0); + + // Get max balance for the selected platform address + let max_balance_credits = self + .selected_platform_address_for_funding + .as_ref() + .and_then(|(platform_addr, _)| { + platform_addresses + .iter() + .find(|(_, addr, _)| addr == platform_addr) + .map(|(_, _, balance)| *balance) + }); + + // Amount input using AmountInput component + let amount_input = self.platform_funding_amount_input.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .with_label("Amount (DASH):") + .with_hint_text("Enter amount (e.g., 0.5)") + .with_max_button(true) + .with_desired_width(150.0) + }); + + // Update max amount dynamically based on selected platform address + amount_input.set_max_amount(max_balance_credits); + + let response = amount_input.show(ui); + response.inner.update(&mut self.platform_funding_amount); + + // Update selected_platform_address_for_funding with the new amount + if response.inner.changed + && let Some((platform_addr, _)) = self.selected_platform_address_for_funding + { + let amount_credits = self + .platform_funding_amount + .as_ref() + .map(|a| a.value()) + .unwrap_or(0); + let max_balance = max_balance_credits.unwrap_or(u64::MAX); + self.selected_platform_address_for_funding = + Some((platform_addr, amount_credits.min(max_balance))); + } + + // Show selected amount info + if let Some((_, amount)) = &self.selected_platform_address_for_funding { + let dash_amount = *amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label(format!("Will use: {:.8} DASH", dash_amount)); + } + + ui.add_space(20.0); + + // Extract the step from the RwLock to minimize borrow scope + let step = *self.step.read().unwrap(); + + // Display estimated fee before action button + let key_count = self.identity_keys.keys_input.len() + 1; // +1 for master key + let input_count = if self.selected_platform_address_for_funding.is_some() { + 1 + } else { + 0 + }; + let estimated_fee = PlatformFeeEstimator::new().estimate_identity_create_from_addresses( + input_count, + false, + key_count, + ); + let dark_mode = ui.ctx().style().visuals.dark_mode; + egui::Frame::new() + .fill(crate::ui::theme::DashColors::surface(dark_mode)) + .inner_margin(egui::Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated Fee:") + .color(crate::ui::theme::DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(crate::ui::theme::DashColors::text_primary(dark_mode)) + .strong(), + ); + }); + }); + ui.add_space(10.0); + + // Create Identity button + let can_create = self.selected_platform_address_for_funding.is_some() + && self + .selected_platform_address_for_funding + .as_ref() + .map(|(_, amount)| *amount > 0) + .unwrap_or(false); + + let button = egui::Button::new(RichText::new("Create Identity").color(Color32::WHITE)) + .fill(if can_create { + Color32::from_rgb(0, 128, 255) + } else { + Color32::from_rgb(100, 100, 100) + }) + .frame(true) + .corner_radius(3.0); + + if ui.add_enabled(can_create, button).clicked() { + self.error_message = None; + action = self.register_identity_clicked(FundingMethod::UsePlatformAddress); + } + + ui.add_space(20.0); + + // Only show status messages if there's no error + if self.error_message.is_none() { + ui.vertical_centered(|ui| match step { + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement <="); + } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); + } + _ => {} + }); + } + + ui.add_space(40.0); + action + } +} diff --git a/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs b/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs index 6059a3549..8e54268d9 100644 --- a/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs +++ b/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs @@ -1,8 +1,9 @@ use crate::app::AppAction; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::ui::identities::add_new_identity_screen::{ AddNewIdentityScreen, FundingMethod, WalletFundedScreenStep, }; -use egui::{Color32, Ui}; +use egui::{Color32, RichText, Ui}; impl AddNewIdentityScreen { fn render_choose_funding_asset_lock(&mut self, ui: &mut egui::Ui) { @@ -33,7 +34,7 @@ impl AddNewIdentityScreen { // Display the asset locks in a scrollable area egui::ScrollArea::vertical() - .auto_shrink([false; 2]) + .auto_shrink([false, true]) .min_scrolled_height(180.0) .show(ui, |ui| { for (index, (tx, address, amount, islock, proof)) in @@ -101,25 +102,49 @@ impl AddNewIdentityScreen { ui.add_space(10.0); self.render_choose_funding_asset_lock(ui); + // Display estimated fee before action button + let key_count = self.identity_keys.keys_input.len() + 1; // +1 for master key + let estimated_fee = PlatformFeeEstimator::new().estimate_identity_create(key_count); + ui.add_space(10.0); + let dark_mode = ui.ctx().style().visuals.dark_mode; + egui::Frame::new() + .fill(crate::ui::theme::DashColors::surface(dark_mode)) + .inner_margin(egui::Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated Fee:") + .color(crate::ui::theme::DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(crate::ui::theme::DashColors::text_primary(dark_mode)) + .strong(), + ); + }); + }); + ui.add_space(10.0); + if ui.button("Create Identity").clicked() { self.error_message = None; action |= self.register_identity_clicked(FundingMethod::UseUnusedAssetLock); } - if let Some(error_message) = self.error_message.as_ref() { - ui.colored_label(Color32::DARK_RED, error_message); - ui.add_space(20.0); - } + ui.add_space(20.0); - ui.vertical_centered(|ui| match step { - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement <="); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); - } - _ => {} - }); + // Only show status messages if there's no error + if self.error_message.is_none() { + ui.vertical_centered(|ui| match step { + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement <="); + } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); + } + _ => {} + }); + } ui.add_space(40.0); action diff --git a/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs b/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs index 81612bedc..f736eb3dd 100644 --- a/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs +++ b/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs @@ -1,4 +1,5 @@ use crate::app::AppAction; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::ui::identities::add_new_identity_screen::{ AddNewIdentityScreen, FundingMethod, WalletFundedScreenStep, }; @@ -9,7 +10,7 @@ impl AddNewIdentityScreen { if let Some(selected_wallet) = &self.selected_wallet { let wallet = selected_wallet.read().unwrap(); // Read lock on the wallet - let total_balance: u64 = wallet.max_balance(); // Sum up all the balances + let total_balance: u64 = wallet.total_balance_duffs(); // Use stored balance with UTXO fallback let dash_balance = total_balance as f64 * 1e-8; // Convert to DASH units @@ -43,9 +44,40 @@ impl AddNewIdentityScreen { // Extract the step from the RwLock to minimize borrow scope let step = *self.step.read().unwrap(); - let Ok(_) = self.funding_amount.parse::() else { + // Check if we have a valid amount before showing the button + let has_valid_amount = self + .funding_amount + .as_ref() + .map(|a| a.value() > 0) + .unwrap_or(false); + + if !has_valid_amount { return action; - }; + } + + // Display estimated fee before action button + let key_count = self.identity_keys.keys_input.len() + 1; // +1 for master key + let estimated_fee = PlatformFeeEstimator::new().estimate_identity_create(key_count); + ui.add_space(10.0); + let dark_mode = ui.ctx().style().visuals.dark_mode; + egui::Frame::new() + .fill(crate::ui::theme::DashColors::surface(dark_mode)) + .inner_margin(egui::Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated Fee:") + .color(crate::ui::theme::DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(crate::ui::theme::DashColors::text_primary(dark_mode)) + .strong(), + ); + }); + }); + ui.add_space(10.0); let button = egui::Button::new(RichText::new("Create Identity").color(Color32::WHITE)) .fill(Color32::from_rgb(0, 128, 255)) @@ -56,23 +88,25 @@ impl AddNewIdentityScreen { action = self.register_identity_clicked(FundingMethod::UseWalletBalance); } - if let Some(error_message) = self.error_message.as_ref() { - ui.colored_label(Color32::DARK_RED, error_message); - ui.add_space(20.0); - } + ui.add_space(20.0); - ui.vertical_centered(|ui| match step { - WalletFundedScreenStep::WaitingForAssetLock => { - ui.heading("=> Waiting for Core Chain to produce proof of transfer of funds. <="); - } - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement <="); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); - } - _ => {} - }); + // Only show status messages if there's no error + if self.error_message.is_none() { + ui.vertical_centered(|ui| match step { + WalletFundedScreenStep::WaitingForAssetLock => { + ui.heading( + "=> Waiting for Core Chain to produce proof of transfer of funds. <=", + ); + } + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement <="); + } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); + } + _ => {} + }); + } ui.add_space(40.0); action diff --git a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs index c3c9c3455..332e82f3a 100644 --- a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs @@ -9,7 +9,7 @@ use crate::ui::identities::add_new_identity_screen::{ use crate::ui::identities::funding_common::{self, copy_to_clipboard, generate_qr_code_image}; use dash_sdk::dashcore_rpc::RpcApi; use eframe::epaint::TextureHandle; -use egui::{Color32, Ui}; +use egui::Ui; use std::sync::Arc; impl AddNewIdentityScreen { @@ -150,10 +150,13 @@ impl AddNewIdentityScreen { .request_repaint_after(std::time::Duration::from_secs(1)); } - let Ok(amount_dash) = self.funding_amount.parse::() else { + // Get the amount in DASH from the Amount struct + let Some(amount) = &self.funding_amount else { return AppAction::None; }; + let amount_dash = amount.value() as f64 / 100_000_000_000.0; // credits to DASH + if amount_dash <= 0.0 { return AppAction::None; } @@ -167,55 +170,53 @@ impl AddNewIdentityScreen { ui.add_space(20.0); - if let Some(error_message) = self.error_message.as_ref() { - ui.colored_label(Color32::DARK_RED, error_message); - ui.add_space(20.0); - } + // Handle FundsReceived action regardless of error state + if step == WalletFundedScreenStep::FundsReceived { + let Some(selected_wallet) = &self.selected_wallet else { + return AppAction::None; + }; + if let Some((utxo, tx_out, address)) = self.funding_utxo.clone() { + let identity_input = IdentityRegistrationInfo { + alias_input: self.alias_input.clone(), + keys: self.identity_keys.clone(), + wallet: Arc::clone(selected_wallet), // Clone the Arc reference + wallet_identity_index: self.identity_id_number, + identity_funding_method: RegisterIdentityFundingMethod::FundWithUtxo( + utxo, + tx_out, + address, + self.identity_id_number, + ), + }; + + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::WaitingForAssetLock; - match step { - WalletFundedScreenStep::ChooseFundingMethod => {} - WalletFundedScreenStep::WaitingOnFunds => { - ui.heading("=> Waiting for funds. <="); + // Create the backend task to register the identity + return AppAction::BackendTask(BackendTask::IdentityTask( + IdentityTask::RegisterIdentity(identity_input), + )); } - WalletFundedScreenStep::FundsReceived => { - let Some(selected_wallet) = &self.selected_wallet else { - return AppAction::None; - }; - if let Some((utxo, tx_out, address)) = self.funding_utxo.clone() { - let identity_input = IdentityRegistrationInfo { - alias_input: self.alias_input.clone(), - keys: self.identity_keys.clone(), - wallet: Arc::clone(selected_wallet), // Clone the Arc reference - wallet_identity_index: self.identity_id_number, - identity_funding_method: - RegisterIdentityFundingMethod::FundWithUtxo( - utxo, - tx_out, - address, - self.identity_id_number, - ), - }; - - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::WaitingForAssetLock; - - // Create the backend task to register the identity - return AppAction::BackendTask(BackendTask::IdentityTask( - IdentityTask::RegisterIdentity(identity_input), - )); + } + + // Only show status messages if there's no error + if self.error_message.is_none() { + match step { + WalletFundedScreenStep::WaitingOnFunds => { + ui.heading("=> Waiting for funds. <="); } - } - WalletFundedScreenStep::ReadyToCreate => {} - WalletFundedScreenStep::WaitingForAssetLock => { - ui.heading( - "=> Waiting for Core Chain to produce proof of transfer of funds. <=", - ); - } - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement. <="); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); + WalletFundedScreenStep::WaitingForAssetLock => { + ui.heading( + "=> Waiting for Core Chain to produce proof of transfer of funds. <=", + ); + } + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement. <="); + } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); + } + _ => {} } } AppAction::None diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index 833b40dbd..8258164aa 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -1,3 +1,4 @@ +mod by_platform_address; mod by_using_unused_asset_lock; mod by_using_unused_balance; mod by_wallet_qr_code; @@ -8,27 +9,36 @@ use crate::backend_task::core::CoreItem; use crate::backend_task::identity::{ IdentityKeys, IdentityRegistrationInfo, IdentityTask, RegisterIdentityFundingMethod, }; -use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::wallet::Wallet; +use crate::ui::components::info_popup::InfoPopup; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; use crate::ui::identities::funding_common::WalletFundedScreenStep; use crate::ui::{MessageType, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::transaction::special_transaction::TransactionPayload; -use dash_sdk::dpp::balances::credits::Duffs; use dash_sdk::dpp::dashcore::secp256k1::hashes::hex::DisplayHex; -use dash_sdk::dpp::dashcore::{OutPoint, PrivateKey, Transaction, TxOut}; +use dash_sdk::dpp::dashcore::{OutPoint, Transaction, TxOut}; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::prelude::AssetLockProof; use dash_sdk::platform::Identifier; use eframe::egui::Context; use egui::ahash::HashSet; -use egui::{Button, Color32, ComboBox, ScrollArea, Ui}; +use egui::{Align, Button, Color32, ComboBox, ScrollArea, Ui}; +use egui_extras::{Column, TableBuilder}; + +use crate::model::amount::Amount; +use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::component_trait::{Component, ComponentResponse}; use std::cmp::PartialEq; use std::fmt; use std::sync::atomic::Ordering; @@ -42,6 +52,8 @@ pub enum FundingMethod { UseUnusedAssetLock, UseWalletBalance, AddressWithQRCode, + /// Use Platform Address credits + UsePlatformAddress, } impl fmt::Display for FundingMethod { @@ -49,8 +61,9 @@ impl fmt::Display for FundingMethod { let output = match self { FundingMethod::NoSelection => "Select funding method", FundingMethod::AddressWithQRCode => "Address with QR Code", - FundingMethod::UseWalletBalance => "Use Wallet Balance", - FundingMethod::UseUnusedAssetLock => "Use Unused Asset Lock (recommended)", + FundingMethod::UseWalletBalance => "Wallet Balance", + FundingMethod::UseUnusedAssetLock => "Unused Asset Lock (recommended)", + FundingMethod::UsePlatformAddress => "Platform Address", }; write!(f, "{}", output) } @@ -64,29 +77,55 @@ pub struct AddNewIdentityScreen { core_has_funding_address: Option, funding_address: Option
      , funding_method: Arc>, - funding_amount: String, - funding_amount_exact: Option, + funding_amount: Option, + funding_amount_input: Option, funding_utxo: Option<(OutPoint, TxOut, Address)>, alias_input: String, copied_to_clipboard: Option>, identity_keys: IdentityKeys, error_message: Option, - show_password: bool, - wallet_password: String, + wallet_unlock_popup: WalletUnlockPopup, show_pop_up_info: Option, in_key_selection_advanced_mode: bool, pub app_context: Arc, successful_qualified_identity_id: Option, + /// Selected Platform address for funding with the amount in credits + selected_platform_address_for_funding: Option<( + dash_sdk::dpp::address_funds::PlatformAddress, + dash_sdk::dpp::fee::Credits, + )>, + /// Amount input for Platform address funding + platform_funding_amount: Option, + platform_funding_amount_input: Option, + /// Whether to show advanced options + show_advanced_options: bool, + /// Fee result from completed identity registration + completed_fee_result: Option, } impl AddNewIdentityScreen { pub fn new(app_context: &Arc) -> Self { + Self::new_with_wallet(app_context, None) + } + + pub fn new_with_wallet( + app_context: &Arc, + wallet_seed_hash: Option<[u8; 32]>, + ) -> Self { let mut selected_wallet = None; if app_context.has_wallet.load(Ordering::Relaxed) { let wallets = &app_context.wallets.read().unwrap(); - if let Some(wallet) = wallets.values().next() { - // Automatically select the only available wallet + // If a specific wallet seed hash is provided, use that wallet + if let Some(seed_hash) = wallet_seed_hash + && let Some(wallet) = wallets.get(&seed_hash) + { + selected_wallet = Some(wallet.clone()); + } + // Otherwise, select the first available wallet + if selected_wallet.is_none() + && let Some(wallet) = wallets.values().next() + { selected_wallet = Some(wallet.clone()); } } @@ -99,8 +138,8 @@ impl AddNewIdentityScreen { core_has_funding_address: None, funding_address: None, funding_method: Arc::new(RwLock::new(FundingMethod::NoSelection)), - funding_amount: "0.5".to_string(), - funding_amount_exact: None, + funding_amount: None, + funding_amount_input: None, funding_utxo: None, alias_input: String::new(), copied_to_clipboard: None, @@ -111,12 +150,16 @@ impl AddNewIdentityScreen { keys_input: vec![], }, error_message: None, - show_password: false, - wallet_password: "".to_string(), + wallet_unlock_popup: WalletUnlockPopup::new(), show_pop_up_info: None, in_key_selection_advanced_mode: false, app_context: app_context.clone(), successful_qualified_identity_id: None, + selected_platform_address_for_funding: None, + platform_funding_amount: None, + platform_funding_amount_input: None, + show_advanced_options: false, + completed_fee_result: None, }; if let Some(wallet) = selected_wallet { @@ -160,25 +203,54 @@ impl AddNewIdentityScreen { } let app_context = &self.app_context; - let identity_id_number = self.next_identity_id(); // note: this grabs rlock on the wallet + let identity_id_number = self.identity_id_number; + + // Create DashPay contract bounds for ENCRYPTION/DECRYPTION keys + let dashpay_contract_id = app_context.dashpay_contract.id(); + let dashpay_bounds = Some(ContractBounds::SingleContract { + id: dashpay_contract_id, + }); - const DEFAULT_KEY_TYPES: [(KeyType, Purpose, SecurityLevel); 3] = [ + // Default keys per DIP-11: + // - AUTHENTICATION CRITICAL (general platform operations) + // - AUTHENTICATION HIGH (general platform operations) + // - TRANSFER CRITICAL (credit transfers) + // - ENCRYPTION MEDIUM with DashPay bounds (for contact requests per DIP-15) + // - DECRYPTION MEDIUM with DashPay bounds (for contact requests per DIP-15) + // Note: Platform enforces MEDIUM security level for ENCRYPTION/DECRYPTION keys + let default_keys: Vec<(KeyType, Purpose, SecurityLevel, Option)> = vec![ ( KeyType::ECDSA_HASH160, Purpose::AUTHENTICATION, SecurityLevel::CRITICAL, + None, ), ( KeyType::ECDSA_HASH160, Purpose::AUTHENTICATION, SecurityLevel::HIGH, + None, ), ( KeyType::ECDSA_HASH160, Purpose::TRANSFER, SecurityLevel::CRITICAL, + None, + ), + ( + KeyType::ECDSA_SECP256K1, // ECDH requires secp256k1 + Purpose::ENCRYPTION, + SecurityLevel::MEDIUM, // Platform enforces MEDIUM for ENCRYPTION + dashpay_bounds.clone(), + ), + ( + KeyType::ECDSA_SECP256K1, // ECDH requires secp256k1 + Purpose::DECRYPTION, + SecurityLevel::MEDIUM, + dashpay_bounds, ), ]; + let mut wallet = wallet_lock.write().expect("wallet lock failed"); let master_key = wallet.identity_authentication_ecdsa_private_key( app_context.network, @@ -187,22 +259,25 @@ impl AddNewIdentityScreen { Some(app_context), )?; - let other_keys = DEFAULT_KEY_TYPES + let other_keys = default_keys .into_iter() .enumerate() - .map(|(i, (key_type, purpose, security_level))| { - Ok(( - wallet.identity_authentication_ecdsa_private_key( - app_context.network, - identity_id_number, - (i + 1).try_into().expect("key index must fit u32"), // key index 0 is the master key - Some(app_context), - )?, - key_type, - purpose, - security_level, - )) - }) + .map( + |(i, (key_type, purpose, security_level, contract_bounds))| { + Ok(( + wallet.identity_authentication_ecdsa_private_key( + app_context.network, + identity_id_number, + (i + 1).try_into().expect("key index must fit u32"), // key index 0 is the master key + Some(app_context), + )?, + key_type, + purpose, + security_level, + contract_bounds, + )) + }, + ) .collect::, String>>()?; self.identity_keys = IdentityKeys { @@ -221,7 +296,10 @@ impl AddNewIdentityScreen { let mut index_changed = false; // Track if the index has changed ui.horizontal(|ui| { - ui.label("Identity Index:"); + ui.vertical(|ui| { + ui.add_space(15.0); + ui.label("Identity Index:"); + }); // Check if we have access to the selected wallet if let Some(wallet_guard) = self.selected_wallet.as_ref() { @@ -279,65 +357,6 @@ impl AddNewIdentityScreen { } } - // fn render_wallet_unlock(&mut self, ui: &mut Ui) -> bool { - // if let Some(wallet_guard) = self.selected_wallet.as_ref() { - // let mut wallet = wallet_guard.write().unwrap(); - // - // // Only render the unlock prompt if the wallet requires a password and is locked - // if wallet.uses_password && !wallet.is_open() { - // ui.add_space(10.0); - // ui.label("This wallet is locked. Please enter the password to unlock it:"); - // - // let mut unlocked = false; - // ui.horizontal(|ui| { - // let password_input = ui.add( - // egui::TextEdit::singleline(&mut self.wallet_password) - // .password(!self.show_password) - // .hint_text("Enter password"), - // ); - // - // ui.checkbox(&mut self.show_password, "Show Password"); - // - // unlocked = if password_input.lost_focus() - // && ui.input(|i| i.key_pressed(egui::Key::Enter)) - // { - // let unlocked = match wallet.wallet_seed.open(&self.wallet_password) { - // Ok(_) => { - // self.error_message = None; // Clear any previous error - // true - // } - // Err(_) => { - // if let Some(hint) = wallet.password_hint() { - // self.error_message = Some(format!( - // "Incorrect Password, password hint is {}", - // hint - // )); - // } else { - // self.error_message = Some("Incorrect Password".to_string()); - // } - // false - // } - // }; - // // Clear the password field after submission - // self.wallet_password.zeroize(); - // unlocked - // } else { - // false - // }; - // }); - // - // // Display error message if the password was incorrect - // if let Some(error_message) = &self.error_message { - // ui.add_space(5.0); - // ui.colored_label(Color32::RED, error_message); - // } - // - // return unlocked; - // } - // } - // false - // } - fn render_wallet_selection(&mut self, ui: &mut Ui) -> bool { let mut selected_wallet = None; let rendered = if self.app_context.has_wallet.load(Ordering::Relaxed) { @@ -463,7 +482,8 @@ impl AddNewIdentityScreen { { let mut step = self.step.write().unwrap(); *step = WalletFundedScreenStep::ChooseFundingMethod; - self.funding_amount = "0.5".to_string(); + self.funding_amount = None; + self.funding_amount_input = None; } let (has_unused_asset_lock, has_balance) = { @@ -476,7 +496,7 @@ impl AddNewIdentityScreen { .selectable_value( &mut *funding_method, FundingMethod::UseUnusedAssetLock, - "Use Unused Evo Funding Locks (recommended)", + "Unused Evo Funding Locks (recommended)", ) .changed() { @@ -484,22 +504,20 @@ impl AddNewIdentityScreen { .expect("failed to initialize keys"); let mut step = self.step.write().unwrap(); *step = WalletFundedScreenStep::ReadyToCreate; - self.funding_amount = "0.5".to_string(); + self.funding_amount = None; + self.funding_amount_input = None; } if has_balance && ui .selectable_value( &mut *funding_method, FundingMethod::UseWalletBalance, - "Use Wallet Balance", + "Wallet Balance", ) .changed() { - if let Some(wallet) = &self.selected_wallet { - let wallet = wallet.read().unwrap(); - let max_amount = wallet.max_balance(); - self.funding_amount = format!("{:.4}", max_amount as f64 * 1e-8); - } + self.funding_amount = None; + self.funding_amount_input = None; let mut step = self.step.write().unwrap(); // Write lock on step *step = WalletFundedScreenStep::ReadyToCreate; } @@ -513,7 +531,34 @@ impl AddNewIdentityScreen { { let mut step = self.step.write().unwrap(); *step = WalletFundedScreenStep::WaitingOnFunds; - self.funding_amount = "0.5".to_string(); + self.funding_amount = None; + self.funding_amount_input = None; + } + + // Check if wallet has Platform address balance + let has_platform_balance = { + let wallet = selected_wallet.read().unwrap(); + wallet + .platform_address_info + .values() + .any(|info| info.balance > 0) + }; + if has_platform_balance + && ui + .selectable_value( + &mut *funding_method, + FundingMethod::UsePlatformAddress, + "Platform Address", + ) + .changed() + { + self.ensure_correct_identity_keys() + .expect("failed to initialize keys"); + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::ReadyToCreate; + self.platform_funding_amount = None; + self.platform_funding_amount_input = None; + self.selected_platform_address_for_funding = None; } }); } @@ -522,7 +567,10 @@ impl AddNewIdentityScreen { fn render_key_selection(&mut self, ui: &mut egui::Ui) { // Provide the selection toggle for Default or Advanced mode ui.horizontal(|ui| { - ui.label("Key Selection Mode:"); + ui.vertical(|ui| { + ui.add_space(15.0); + ui.label("Key Selection Mode:"); + }); ComboBox::from_id_salt("key_selection_mode") .selected_text(if self.in_key_selection_advanced_mode { @@ -553,12 +601,7 @@ impl AddNewIdentityScreen { // Render additional key options only if "Advanced" mode is selected if self.in_key_selection_advanced_mode { - // Render the master key input - if let Some((master_key, _)) = self.identity_keys.master_private_key { - self.render_master_key(ui, master_key); - } - - // Render additional keys input (if any) and allow adding more keys + // Render all keys in one grid self.render_keys_input(ui); } else { ui.colored_label(Color32::DARK_GREEN, "Default allows for most operations on Platform: updating the identity, interacting with data contracts, transferring credits to other identities, and withdrawing to the Core payment chain. More keys can always be added later.".to_string()); @@ -567,61 +610,179 @@ impl AddNewIdentityScreen { fn render_keys_input(&mut self, ui: &mut egui::Ui) { let mut keys_to_remove = vec![]; + let has_master_key = self.identity_keys.master_private_key.is_some(); + let has_other_keys = !self.identity_keys.keys_input.is_empty(); - for (i, ((key, _), key_type, purpose, security_level)) in - self.identity_keys.keys_input.iter_mut().enumerate() - { - ui.add_space(5.0); - ui.horizontal(|ui| { - ui.label(format!(" • Key {}:", i + 1)); - ui.label(key.to_wif()); - - // Purpose selection - ComboBox::from_id_salt(format!("purpose_combo_{}", i)) - .selected_text(format!("{:?}", purpose)) - .show_ui(ui, |ui| { - ui.selectable_value(purpose, Purpose::AUTHENTICATION, "AUTHENTICATION"); - ui.selectable_value(purpose, Purpose::TRANSFER, "TRANSFER"); - }); + if has_master_key || has_other_keys { + let row_height = 30.0; - // Key Type selection with conditional filtering - ComboBox::from_id_salt(format!("key_type_combo_{}", i)) - .selected_text(format!("{:?}", key_type)) - .show_ui(ui, |ui| { - ui.selectable_value(key_type, KeyType::ECDSA_HASH160, "ECDSA_HASH160"); - ui.selectable_value(key_type, KeyType::ECDSA_SECP256K1, "ECDSA_SECP256K1"); - // ui.selectable_value(key_type, KeyType::BLS12_381, "BLS12_381"); - // ui.selectable_value( - // key_type, - // KeyType::EDDSA_25519_HASH160, - // "EDDSA_25519_HASH160", - // ); - }); + // Use a lighter stripe color that doesn't clash with comboboxes + let original_stripe_color = ui.visuals().faint_bg_color; + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.visuals_mut().faint_bg_color = if dark_mode { + Color32::from_rgba_unmultiplied(255, 255, 255, 10) // Very subtle light stripe in dark mode + } else { + Color32::from_rgba_unmultiplied(0, 100, 200, 10) // Light blue tint in light mode + }; - // Security Level selection with conditional filtering - ComboBox::from_id_salt(format!("security_level_combo_{}", i)) - .selected_text(format!("{:?}", security_level)) - .show_ui(ui, |ui| { - if *purpose == Purpose::TRANSFER { - // For TRANSFER purpose, security level is locked to CRITICAL - *security_level = SecurityLevel::CRITICAL; - ui.label("Locked to CRITICAL"); - } else { - // For AUTHENTICATION, allow all except MASTER - ui.selectable_value( - security_level, - SecurityLevel::CRITICAL, - "CRITICAL", - ); - ui.selectable_value(security_level, SecurityLevel::HIGH, "HIGH"); - ui.selectable_value(security_level, SecurityLevel::MEDIUM, "MEDIUM"); - } + TableBuilder::new(ui) + .striped(true) + .resizable(true) + .vscroll(false) + .cell_layout(egui::Layout::left_to_right(Align::Center)) + .column(Column::auto().at_least(80.0)) // Key + .column(Column::auto().at_least(200.0)) // WIF + .column(Column::auto().at_least(120.0)) // Purpose + .column(Column::auto().at_least(120.0)) // Type + .column(Column::auto().at_least(100.0)) // Security + .column(Column::auto().at_least(30.0)) // Delete + .header(row_height, |mut header| { + header.col(|ui| { + ui.label("Key"); + }); + header.col(|ui| { + ui.label("WIF"); + }); + header.col(|ui| { + ui.label("Purpose"); + }); + header.col(|ui| { + ui.label("Type"); }); + header.col(|ui| { + ui.label("Security"); + }); + header.col(|_ui| {}); + }) + .body(|mut body| { + // Render master key first + if let Some((master_key, _)) = self.identity_keys.master_private_key { + body.row(row_height, |mut row| { + row.col(|ui| { + ui.label("Master Key"); + }); + row.col(|ui| { + ui.label(master_key.to_wif()); + }); + row.col(|_ui| { + // No purpose for master key + }); + row.col(|ui| { + ui.vertical(|ui| { + ComboBox::from_id_salt("master_key_type") + .selected_text(format!( + "{:?}", + self.identity_keys.master_private_key_type + )) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.identity_keys.master_private_key_type, + KeyType::ECDSA_SECP256K1, + "ECDSA_SECP256K1", + ); + ui.selectable_value( + &mut self.identity_keys.master_private_key_type, + KeyType::ECDSA_HASH160, + "ECDSA_HASH160", + ); + }); + }); + }); + row.col(|_ui| { + // No security level for master key + }); + row.col(|_ui| { + // No delete for master key + }); + }); + } - if ui.button("-").clicked() { - keys_to_remove.push(i); - } - }); + // Render other keys + for (i, ((key, _), key_type, purpose, security_level, _contract_bounds)) in + self.identity_keys.keys_input.iter_mut().enumerate() + { + body.row(row_height, |mut row| { + row.col(|ui| { + ui.label(format!("Key {}", i + 1)); + }); + row.col(|ui| { + ui.label(key.to_wif()); + }); + row.col(|ui| { + ui.vertical(|ui| { + ComboBox::from_id_salt(format!("purpose_combo_{}", i)) + .selected_text(format!("{:?}", purpose)) + .show_ui(ui, |ui| { + ui.selectable_value( + purpose, + Purpose::AUTHENTICATION, + "AUTHENTICATION", + ); + ui.selectable_value( + purpose, + Purpose::TRANSFER, + "TRANSFER", + ); + }); + }); + }); + row.col(|ui| { + ui.vertical(|ui| { + ComboBox::from_id_salt(format!("key_type_combo_{}", i)) + .selected_text(format!("{:?}", key_type)) + .show_ui(ui, |ui| { + ui.selectable_value( + key_type, + KeyType::ECDSA_HASH160, + "ECDSA_HASH160", + ); + ui.selectable_value( + key_type, + KeyType::ECDSA_SECP256K1, + "ECDSA_SECP256K1", + ); + }); + }); + }); + row.col(|ui| { + ui.vertical(|ui| { + ComboBox::from_id_salt(format!("security_level_combo_{}", i)) + .selected_text(format!("{:?}", security_level)) + .show_ui(ui, |ui| { + if *purpose == Purpose::TRANSFER { + *security_level = SecurityLevel::CRITICAL; + ui.label("Locked to CRITICAL"); + } else { + ui.selectable_value( + security_level, + SecurityLevel::CRITICAL, + "CRITICAL", + ); + ui.selectable_value( + security_level, + SecurityLevel::HIGH, + "HIGH", + ); + ui.selectable_value( + security_level, + SecurityLevel::MEDIUM, + "MEDIUM", + ); + } + }); + }); + }); + row.col(|ui| { + if ui.button("-").clicked() { + keys_to_remove.push(i); + } + }); + }); + } + }); + + // Restore original stripe color + ui.visuals_mut().faint_bg_color = original_stripe_color; } // Remove keys marked for deletion @@ -673,10 +834,12 @@ impl AddNewIdentityScreen { } } FundingMethod::UseWalletBalance => { - // Parse the funding amount or fall back to the default value - let amount = self.funding_amount_exact.unwrap_or_else(|| { - (self.funding_amount.parse::().unwrap_or(0.0) * 1e8) as u64 - }); + // Get the funding amount in duffs from the Amount + let amount = self + .funding_amount + .as_ref() + .map(|a| a.value() / 1000) // Convert credits to duffs + .unwrap_or(0); if amount == 0 { return AppAction::None; @@ -703,53 +866,78 @@ impl AddNewIdentityScreen { identity_input, ))) } + FundingMethod::UsePlatformAddress => { + // Get selected Platform address and amount from the input fields + let Some((platform_addr, amount)) = self.selected_platform_address_for_funding + else { + self.error_message = Some("Please select a Platform address".to_string()); + return AppAction::None; + }; + + if amount == 0 { + self.error_message = Some("Amount must be greater than 0".to_string()); + return AppAction::None; + } + + let wallet_seed_hash = selected_wallet.read().unwrap().seed_hash(); + + let mut inputs = std::collections::BTreeMap::new(); + inputs.insert(platform_addr, amount); + + let identity_input = IdentityRegistrationInfo { + alias_input: self.alias_input.clone(), + keys: self.identity_keys.clone(), + wallet: Arc::clone(selected_wallet), + wallet_identity_index: self.identity_id_number, + identity_funding_method: + RegisterIdentityFundingMethod::FundWithPlatformAddresses { + inputs, + wallet_seed_hash, + }, + }; + + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::WaitingForPlatformAcceptance; + + AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::RegisterIdentity( + identity_input, + ))) + } _ => AppAction::None, } } fn render_funding_amount_input(&mut self, ui: &mut egui::Ui) { - let funding_method = self.funding_method.read().unwrap(); + let funding_method = *self.funding_method.read().unwrap(); + + // Calculate max amount if using wallet balance + let max_amount_credits = if funding_method == FundingMethod::UseWalletBalance { + self.selected_wallet.as_ref().map(|wallet| { + let wallet = wallet.read().unwrap(); + // Convert duffs to credits (1 duff = 1000 credits) + wallet.total_balance_duffs() * 1000 + }) + } else { + None + }; - ui.horizontal(|ui| { - ui.label("Amount (DASH):"); - - // Render the text input field for the funding amount - let amount_input = ui - .add( - egui::TextEdit::singleline(&mut self.funding_amount) - .hint_text("Enter amount (e.g., 0.1234)") - .desired_width(100.0), - ) - .lost_focus(); + let show_max_button = funding_method == FundingMethod::UseWalletBalance; - let enter_pressed = ui.input(|i| i.key_pressed(egui::Key::Enter)); + let amount_input = self.funding_amount_input.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .with_label("Amount (DASH):") + .with_hint_text("Enter amount (e.g., 0.1234)") + .with_max_button(show_max_button) + .with_desired_width(150.0) + }); - if amount_input && enter_pressed { - // Optional: Validate the input when Enter is pressed - if self.funding_amount.parse::().is_err() { - ui.label("Invalid amount. Please enter a valid number."); - } - } + // Update max amount and max button visibility dynamically + amount_input + .set_max_amount(max_amount_credits) + .set_show_max_button(show_max_button); - // Check if the funding method is `UseWalletBalance` - if *funding_method == FundingMethod::UseWalletBalance { - // Safely access the selected wallet - if let Some(wallet) = &self.selected_wallet { - let wallet = wallet.read().unwrap(); // Read lock on the wallet - if ui.button("Max").clicked() { - let max_amount = wallet.max_balance(); - self.funding_amount = format!("{:.4}", max_amount as f64 * 1e-8); - self.funding_amount_exact = Some(max_amount); - } - } - } - - if self.funding_amount.parse::().is_err() - || self.funding_amount.parse::().unwrap_or_default() <= 0.0 - { - ui.colored_label(Color32::DARK_RED, "Invalid amount"); - } - }); + let response = amount_input.show(ui); + response.inner.update(&mut self.funding_amount); ui.add_space(10.0); } @@ -774,25 +962,28 @@ impl AddNewIdentityScreen { Some(&self.app_context), )?); - // Update the additional keys input + // Update the additional keys input (preserving contract bounds) self.identity_keys.keys_input = self .identity_keys .keys_input .iter() .enumerate() - .map(|(key_index, (_, key_type, purpose, security_level))| { - Ok(( - wallet.identity_authentication_ecdsa_private_key( - self.app_context.network, - identity_index, - key_index as u32 + 1, - Some(&self.app_context), - )?, - *key_type, - *purpose, - *security_level, - )) - }) + .map( + |(key_index, (_, key_type, purpose, security_level, contract_bounds))| { + Ok(( + wallet.identity_authentication_ecdsa_private_key( + self.app_context.network, + identity_index, + key_index as u32 + 1, + Some(&self.app_context), + )?, + *key_type, + *purpose, + *security_level, + contract_bounds.clone(), + )) + }, + ) .collect::>()?; Ok(true) @@ -811,7 +1002,7 @@ impl AddNewIdentityScreen { let mut wallet = wallet_guard.write().unwrap(); let new_key_index = self.identity_keys.keys_input.len() as u32 + 1; - // Add a new key with default parameters + // Add a new key with default parameters (no contract bounds for manually added keys) self.identity_keys.keys_input.push(( wallet .identity_authentication_ecdsa_private_key( @@ -821,79 +1012,32 @@ impl AddNewIdentityScreen { Some(&self.app_context), ) .expect("expected to have decrypted wallet"), - key_type, // Default key type + key_type, purpose, security_level, + None, // No contract bounds for manually added keys )); } } - - fn render_master_key(&mut self, ui: &mut egui::Ui, key: PrivateKey) { - ui.horizontal(|ui| { - ui.label(" • Master Private Key:"); - ui.label(key.to_wif()); - - ComboBox::from_id_salt("master_key_type") - .selected_text(format!("{:?}", self.identity_keys.master_private_key_type)) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut self.identity_keys.master_private_key_type, - KeyType::ECDSA_SECP256K1, - "ECDSA_SECP256K1", - ); - ui.selectable_value( - &mut self.identity_keys.master_private_key_type, - KeyType::ECDSA_HASH160, - "ECDSA_HASH160", - ); - }); - }); - } -} - -impl ScreenWithWalletUnlock for AddNewIdentityScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } - - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() - } } impl ScreenLike for AddNewIdentityScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { if message_type == MessageType::Error { self.error_message = Some(format!("Error registering identity: {}", message)); + // Reset step so we stop showing "Waiting for Platform acknowledgement" + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::ReadyToCreate; } else { self.error_message = Some(message.to_string()); } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::RegisteredIdentity(qualified_identity) = - &backend_task_success_result + if let BackendTaskSuccessResult::RegisteredIdentity(qualified_identity, fee_result) = + backend_task_success_result { self.successful_qualified_identity_id = Some(qualified_identity.identity.id()); + self.completed_fee_result = Some(fee_result); let mut step = self.step.write().unwrap(); *step = WalletFundedScreenStep::Success; return; @@ -965,6 +1109,30 @@ impl ScreenLike for AddNewIdentityScreen { action |= island_central_panel(ctx, |ui| { let mut inner_action = AppAction::None; + + // Display error message at the top, outside of scroll area + if let Some(error_message) = self.error_message.clone() { + let message_color = Color32::from_rgb(255, 100, 100); + + ui.horizontal(|ui| { + egui::Frame::new() + .fill(message_color.gamma_multiply(0.1)) + .inner_margin(egui::Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, message_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&error_message).color(message_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.error_message = None; + } + }); + }); + }); + ui.add_space(10.0); + } + ScrollArea::vertical().show(ui, |ui| { let step = {*self.step.read().unwrap()}; if step == WalletFundedScreenStep::Success { @@ -972,7 +1140,14 @@ impl ScreenLike for AddNewIdentityScreen { return; } ui.add_space(10.0); - ui.heading("Follow these steps to create your identity!"); + + // Heading with checkbox on the same line + ui.horizontal(|ui| { + ui.heading("Follow these steps to create your identity."); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Show Advanced Options"); + }); + }); ui.add_space(15.0); let mut step_number = 1; @@ -986,79 +1161,129 @@ impl ScreenLike for AddNewIdentityScreen { return; }; - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); + // Check if wallet needs unlocking + let wallet = self.selected_wallet.as_ref().unwrap(); - if needed_unlock { - if just_unlocked { - // Select wallet will properly update all dependencies - self.update_wallet(self.selected_wallet.clone().expect("we just checked selected_wallet set above")); - } else { - return; + // Try to open wallet without password if it doesn't use one + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + + // If wallet needs password unlock + if wallet_needs_unlock(wallet) { + // Show message and button to unlock + ui.add_space(10.0); + ui.colored_label( + Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); } + return; } - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); + // Only show identity index and key selection in advanced mode + if self.show_advanced_options { + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Display the heading with an info icon that shows a tooltip on hover + ui.horizontal(|ui| { + let wallet_guard = self.selected_wallet.as_ref().unwrap(); + let wallet = wallet_guard.read().unwrap(); + if wallet.identities.is_empty() { + ui.heading(format!( + "{}. Choose an identity index for the wallet. Leaving this 0 is recommended.", + step_number + )); + } else { + ui.heading(format!( + "{}. Choose an identity index for the wallet. Leaving this {} is recommended.", + step_number, + self.next_identity_id(), + )); + } - // Display the heading with an info icon that shows a tooltip on hover - ui.horizontal(|ui| { - let wallet_guard = self.selected_wallet.as_ref().unwrap(); - let wallet = wallet_guard.read().unwrap(); - if wallet.identities.is_empty() { + + // Create info icon button with tooltip + let response = crate::ui::helpers::info_icon_button(ui, "The identity index is an internal reference within the wallet. The wallet's seed phrase can always be used to recover any identity, including this one, by using the same index."); + + // Check if the label was clicked + if response.clicked() { + self.show_pop_up_info = Some("The identity index is an internal reference within the wallet. The wallet's seed phrase can always be used to recover any identity, including this one, by using the same index.".to_string()); + } + }); + + step_number += 1; + + ui.add_space(8.0); + + self.render_identity_index_input(ui); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Display the heading with an info icon that shows a tooltip on hover + ui.horizontal(|ui| { ui.heading(format!( - "{}. Choose an identity index for the wallet. Leaving this 0 is recommended.", + "{}. Choose what keys you want to add to this new identity.", step_number )); - } else { - ui.heading(format!( - "{}. Choose an identity index for the wallet. Leaving this {} is recommended.", - step_number, - self.next_identity_id(), - )); - } + // Create info icon button with tooltip + let response = crate::ui::helpers::info_icon_button(ui, "Keys allow an identity to perform actions on the Blockchain. They are contained in your wallet and allow you to prove that the action you are making is really coming from yourself."); - // Create info icon button with tooltip - let response = crate::ui::helpers::info_icon_button(ui, "The identity index is an internal reference within the wallet. The wallet's seed phrase can always be used to recover any identity, including this one, by using the same index."); - - // Check if the label was clicked - if response.clicked() { - self.show_pop_up_info = Some("The identity index is an internal reference within the wallet. The wallet’s seed phrase can always be used to recover any identity, including this one, by using the same index.".to_string()); - } - }); + // Check if the label was clicked + if response.clicked() { + self.show_pop_up_info = Some("Keys allow an identity to perform actions on the Blockchain. They are contained in your wallet and allow you to prove that the action you are making is really coming from yourself.".to_string()); + } + }); - step_number += 1; + step_number += 1; - ui.add_space(8.0); + ui.add_space(8.0); - self.render_identity_index_input(ui); + self.render_key_selection(ui); + } ui.add_space(10.0); ui.separator(); ui.add_space(10.0); - // Display the heading with an info icon that shows a tooltip on hover + // Local alias input section ui.horizontal(|ui| { - ui.heading(format!( - "{}. Choose what keys you want to add to this new identity.", - step_number - )); - - // Create info icon button with tooltip - let response = crate::ui::helpers::info_icon_button(ui, "Keys allow an identity to perform actions on the Blockchain. They are contained in your wallet and allow you to prove that the action you are making is really coming from yourself."); - - // Check if the label was clicked - if response.clicked() { - self.show_pop_up_info = Some("Keys allow an identity to perform actions on the Blockchain. They are contained in your wallet and allow you to prove that the action you are making is really coming from yourself.".to_string()); - } + ui.heading(format!("{}. Set a local alias (optional).", step_number)); + crate::ui::helpers::info_icon_button( + ui, + "This is a local alias stored only in Dash Evo Tool to help you identify this identity.\n\n\ + This is NOT a DPNS username. DPNS names are registered on-chain after creating the identity.\n\n\ + You can change this alias anytime from the identity details screen.", + ); }); - step_number += 1; ui.add_space(8.0); - self.render_key_selection(ui); + ui.horizontal(|ui| { + ui.label("Alias:"); + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.add( + egui::TextEdit::singleline(&mut self.alias_input) + .hint_text(egui::RichText::new("e.g., My Main Identity").color(crate::ui::theme::DashColors::text_secondary(dark_mode))) + .desired_width(250.0), + ); + }); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.label( + egui::RichText::new("Note: This is a Dash Evo Tool nickname, not a DPNS username.") + .small() + .color(crate::ui::theme::DashColors::text_secondary(dark_mode)), + ); ui.add_space(10.0); ui.separator(); @@ -1092,26 +1317,39 @@ impl ScreenLike for AddNewIdentityScreen { FundingMethod::AddressWithQRCode => { inner_action |= self.render_ui_by_wallet_qr_code(ui, step_number) }, + FundingMethod::UsePlatformAddress => { + inner_action |= self.render_ui_by_platform_address(ui, step_number); + }, } }); inner_action }); - // Show the popup window if `show_popup` is true + // Show the info popup if requested if let Some(show_pop_up_info_text) = self.show_pop_up_info.clone() { - egui::Window::new("Identity Index Information") - .collapsible(false) - .resizable(false) + egui::CentralPanel::default() + .frame(egui::Frame::NONE) .show(ctx, |ui| { - ui.label(show_pop_up_info_text); - - // Add a close button to dismiss the popup - if ui.button("Close").clicked() { - self.show_pop_up_info = None + let mut popup = InfoPopup::new("Identity Information", &show_pop_up_info_text); + if popup.show(ui).inner { + self.show_pop_up_info = None; } }); } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet was unlocked, update dependencies + self.update_wallet(wallet.clone()); + } + } + action } } diff --git a/src/ui/identities/add_new_identity_screen/success_screen.rs b/src/ui/identities/add_new_identity_screen/success_screen.rs index ade991958..c239ceb40 100644 --- a/src/ui/identities/add_new_identity_screen/success_screen.rs +++ b/src/ui/identities/add_new_identity_screen/success_screen.rs @@ -1,42 +1,45 @@ use crate::app::AppAction; use crate::ui::identities::add_new_identity_screen::AddNewIdentityScreen; -use crate::ui::identities::register_dpns_name_screen::RegisterDpnsNameScreen; +use crate::ui::identities::register_dpns_name_screen::{ + RegisterDpnsNameScreen, RegisterDpnsNameSource, +}; use crate::ui::{RootScreenType, Screen}; use egui::Ui; impl AddNewIdentityScreen { pub fn show_success(&self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; + let action = crate::ui::helpers::show_success_screen_with_info( + ui, + "Identity Registered Successfully!".to_string(), + vec![ + ( + "Back to Identities".to_string(), + AppAction::PopScreenAndRefresh, + ), + ( + "Register DPNS Name".to_string(), + AppAction::Custom("register_dpns".to_string()), + ), + ], + None, + ); - // Center the content vertically and horizontally - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - ui.heading("Success!"); - - ui.add_space(20.0); - - // Display the "Back to Identities" button - if ui.button("Back to Identities").clicked() { - // Handle navigation back to the identities screen - action = AppAction::PopScreenAndRefresh; - } - - // Display the "Register DPNS Name" button - if ui.button("Register DPNS Name").clicked() { - let mut screen = RegisterDpnsNameScreen::new(&self.app_context); - if let Some(identity_id) = self.successful_qualified_identity_id { - screen.select_identity(identity_id); - screen.show_identity_selector = false; - } - // Handle the registration of a new name - action = AppAction::PopThenAddScreenToMainScreen( - RootScreenType::RootScreenDPNSOwnedNames, - Screen::RegisterDpnsNameScreen(screen), - ); + // Handle the custom action to navigate to DPNS registration + if let AppAction::Custom(ref s) = action + && s == "register_dpns" + { + // Use Identities source since we came from the Add New Identity flow + let mut screen = + RegisterDpnsNameScreen::new(&self.app_context, RegisterDpnsNameSource::Identities); + if let Some(identity_id) = self.successful_qualified_identity_id { + screen.select_identity(identity_id); + screen.show_identity_selector = false; } - }); + return AppAction::PopThenAddScreenToMainScreen( + RootScreenType::RootScreenIdentities, + Screen::RegisterDpnsNameScreen(screen), + ); + } action } diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 523f70fcb..4f8fe1fb3 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -13,8 +13,12 @@ use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; +use crate::ui::identities::register_dpns_name_screen::{ + RegisterDpnsNameScreen, RegisterDpnsNameSource, +}; use crate::ui::identities::top_up_identity_screen::TopUpIdentityScreen; use crate::ui::identities::transfer_screen::TransferScreen; +use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike, ScreenType}; use chrono::{DateTime, Utc}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -63,6 +67,9 @@ pub struct IdentitiesScreen { use_custom_order: bool, refreshing_status: IdentitiesRefreshingStatus, backend_message: Option<(String, MessageType, DateTime)>, + // Alias editing state + editing_alias_identity: Option, + editing_alias_value: String, } impl IdentitiesScreen { @@ -86,6 +93,8 @@ impl IdentitiesScreen { use_custom_order: true, refreshing_status: IdentitiesRefreshingStatus::NotRefreshing, backend_message: None, + editing_alias_identity: None, + editing_alias_value: String::new(), }; if let Ok(saved_ids) = screen.app_context.db.load_identity_order() { @@ -209,43 +218,28 @@ impl IdentitiesScreen { "".to_owned() } - fn show_alias(&self, ui: &mut Ui, qualified_identity: &QualifiedIdentity) { - let placeholder_text = match qualified_identity.identity_type { - IdentityType::Masternode => "A Masternode", - IdentityType::Evonode => "An Evonode", - IdentityType::User => "An Identity", - }; + fn show_alias(&mut self, ui: &mut Ui, qualified_identity: &QualifiedIdentity) { + let dark_mode = ui.ctx().style().visuals.dark_mode; - let mut alias = qualified_identity.alias.clone().unwrap_or_default(); + if let Some(alias) = &qualified_identity.alias { + ui.label(RichText::new(alias).color(DashColors::text_primary(dark_mode))); + } else { + let button = egui::Button::new( + RichText::new("Set Alias") + .small() + .color(DashColors::text_secondary(dark_mode)), + ) + .small() + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::new( + 1.0, + DashColors::text_secondary(dark_mode), + )) + .corner_radius(egui::CornerRadius::same(3)); - let dark_mode = ui.ctx().style().visuals.dark_mode; - let text_edit = egui::TextEdit::singleline(&mut alias) - .hint_text(placeholder_text) - .desired_width(100.0) - .text_color(crate::ui::theme::DashColors::text_primary(dark_mode)) - .background_color(crate::ui::theme::DashColors::input_background(dark_mode)); - - if ui.add(text_edit).changed() { - // If user edits alias, we do not necessarily turn on "custom order." - // This is a separate property. But we do update the stored alias. - let mut identities = self.identities.lock().unwrap(); - let identity_to_update = identities - .get_mut(&qualified_identity.identity.id()) - .unwrap(); - - if alias == placeholder_text || alias.is_empty() { - identity_to_update.alias = None; - } else { - identity_to_update.alias = Some(alias); - } - match self.app_context.set_identity_alias( - &identity_to_update.identity.id(), - identity_to_update.alias.as_deref(), - ) { - Ok(_) => {} - Err(e) => { - eprintln!("{}", e); - } + if ui.add(button).clicked() { + self.editing_alias_identity = Some(qualified_identity.identity.id()); + self.editing_alias_value.clear(); } } } @@ -260,7 +254,7 @@ impl IdentitiesScreen { let identifier_as_string = qualified_identity.identity.id().to_string(encoding); ui.add( egui::Label::new(identifier_as_string) - .sense(egui::Sense::hover()) + .selectable(true) .truncate(), ) .on_hover_text(helper); @@ -411,10 +405,7 @@ impl IdentitiesScreen { ui.add_space(10.0); // Description - ui.label( - "It looks like you are not tracking any Identities, \ - Evonodes, or Masternodes yet.", - ); + ui.label("It looks like you are not tracking any Identities yet."); ui.add_space(10.0); @@ -433,7 +424,7 @@ impl IdentitiesScreen { on \"Load Identity\" at the top right, or", ); ui.add_space(1.0); - ui.label("• REGISTER an Identity after creating or importing a wallet."); + ui.label("• CREATE an Identity after creating or importing a wallet."); ui.add_space(10.0); ui.separator(); @@ -569,9 +560,8 @@ impl IdentitiesScreen { row.col(|ui| { ui.vertical_centered(|ui| { ui.horizontal_centered(|ui| { - ui.add_enabled_ui(is_active, |ui| { - Self::show_identity_id(ui, qualified_identity); - }); + // Always allow copying identity ID, even for failed identities + Self::show_identity_id(ui, qualified_identity); }); }); }); @@ -623,17 +613,36 @@ impl IdentitiesScreen { let actions_popup_id = ui.make_persistent_id(format!("actions_popup_{}", qualified_identity.identity.id().to_string(Encoding::Base58))); egui::Popup::from_toggle_button_response(&actions_response).id(actions_popup_id) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .frame(egui::Frame::popup(ui.style()).fill(if ui.ctx().style().visuals.dark_mode { Color32::from_rgb(40, 40, 40) } else { Color32::WHITE })) .show(|ui| { ui.set_min_width(150.0); - if ui.add_sized([ui.available_width(), 0.0], egui::Button::new("💸 Withdraw")).on_hover_text("Withdraw credits from this identity to a Dash Core address").clicked() { - action = AppAction::AddScreen( - Screen::WithdrawalScreen(WithdrawalScreen::new( - qualified_identity.clone(), - &self.app_context, - )), - ); - } + // Minimum balance needed for withdrawal (0.005 DASH fee in credits) + let min_withdrawal_balance: u64 = 500_000_000; // 0.005 DASH in credits + let can_withdraw = qualified_identity.identity.balance() > min_withdrawal_balance; + + let withdraw_hover = if can_withdraw { + "Withdraw credits from this identity to a Dash Core address" + } else { + "Insufficient balance for withdrawal (need at least 0.005 DASH for fees)" + }; + let width = ui.available_width(); + ui.scope(|ui| { + if !can_withdraw { + ui.disable(); + } + if ui.add_sized([width, 0.0], egui::Button::new("💸 Withdraw")) + .on_hover_text(withdraw_hover) + .clicked() + { + action = AppAction::AddScreen( + Screen::WithdrawalScreen(WithdrawalScreen::new( + qualified_identity.clone(), + &self.app_context, + )), + ); + } + }); if ui.add_sized([ui.available_width(), 0.0], egui::Button::new("💰 Top up")).on_hover_text("Increase this identity's balance by sending it Dash from the Core chain").clicked() { action = AppAction::AddScreen( @@ -644,14 +653,46 @@ impl IdentitiesScreen { ); } - if ui.add_sized([ui.available_width(), 0.0], egui::Button::new("📤 Transfer")).on_hover_text("Transfer credits from this identity to another identity").clicked() { + // Minimum balance needed for transfer (0.0002 DASH fee in credits) + let min_transfer_balance: u64 = 20_000_000; + let can_transfer = qualified_identity.identity.balance() > min_transfer_balance; + + let transfer_hover = if can_transfer { + "Transfer credits from this identity to another identity" + } else { + "Insufficient balance for transfer (need at least 0.0002 DASH for fees)" + }; + let width = ui.available_width(); + ui.scope(|ui| { + if !can_transfer { + ui.disable(); + } + if ui.add_sized([width, 0.0], egui::Button::new("📤 Transfer")) + .on_hover_text(transfer_hover) + .clicked() + { + action = AppAction::AddScreen( + Screen::TransferScreen(TransferScreen::new( + qualified_identity.clone(), + &self.app_context, + )), + ); + } + }); + + if ui.add_sized([ui.available_width(), 0.0], egui::Button::new("📛 Register DPNS Name")).on_hover_text("Register a DPNS username for this identity").clicked() { + let mut screen = RegisterDpnsNameScreen::new(&self.app_context, RegisterDpnsNameSource::Identities); + screen.select_identity(qualified_identity.identity.id()); action = AppAction::AddScreen( - Screen::TransferScreen(TransferScreen::new( - qualified_identity.clone(), - &self.app_context, - )), + Screen::RegisterDpnsNameScreen(screen), ); } + + if ui.add_sized([ui.available_width(), 0.0], egui::Button::new("✏ Update Alias")).on_hover_text("Change the display name for this identity").clicked() { + self.editing_alias_identity = Some(qualified_identity.identity.id()); + self.editing_alias_value = qualified_identity.alias.clone().unwrap_or_default(); + ui.close_kind(egui::UiKind::Menu); + } }); }); }); @@ -680,40 +721,24 @@ impl IdentitiesScreen { let popup_id = ui.make_persistent_id(format!("keys_popup_{}", qualified_identity.identity.id().to_string(Encoding::Base58))); egui::Popup::from_toggle_button_response(&button_response).id(popup_id) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .frame(egui::Frame::popup(ui.style()).fill(if ui.ctx().style().visuals.dark_mode { Color32::from_rgb(40, 40, 40) } else { Color32::WHITE })) .show(|ui| { - ui.set_min_width(200.0); + let dark_mode = ui.ctx().style().visuals.dark_mode; // Main Identity Keys if !public_keys.is_empty() { - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.label(RichText::new("Main Identity Keys:").strong().color(crate::ui::theme::DashColors::text_primary(dark_mode))); - ui.separator(); - for (key_id, key) in public_keys.iter() { let holding_private_key = qualified_identity.private_keys .get_cloned_private_key_data_and_wallet_info(&(PrivateKeyOnMainIdentity, *key_id)); - let button_color = if holding_private_key.is_some() { - if dark_mode { - Color32::from_rgb(100, 180, 180) // Darker blue for dark mode - } else { - Color32::from_rgb(167, 232, 232) // Light blue for light mode - } + let key_label = self.format_key_name(key); + let button = if holding_private_key.is_some() { + egui::Button::new(&key_label).fill(crate::ui::theme::DashColors::selected(dark_mode)) } else { - crate::ui::theme::DashColors::glass_white(dark_mode) // Theme-aware for unloaded keys + egui::Button::new(&key_label) }; - let text_color = if holding_private_key.is_some() { - Color32::BLACK // Black text on light blue background - } else { - crate::ui::theme::DashColors::text_primary(dark_mode) // Theme-aware text - }; - - let button = egui::Button::new(RichText::new(self.format_key_name(key)).color(text_color)) - .fill(button_color) - .frame(true); - - if ui.add(button).clicked() { + if ui.add_sized([ui.available_width(), 0.0], button).clicked() { action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( qualified_identity.clone(), key.clone(), @@ -732,35 +757,19 @@ impl IdentitiesScreen { if !public_keys.is_empty() { ui.add_space(5.0); } - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.label(RichText::new("Voter Identity Keys:").strong().color(crate::ui::theme::DashColors::text_primary(dark_mode))); - ui.separator(); for (key_id, key) in voter_public_keys.iter() { let holding_private_key = qualified_identity.private_keys .get_cloned_private_key_data_and_wallet_info(&(PrivateKeyOnVoterIdentity, *key_id)); - let button_color = if holding_private_key.is_some() { - if dark_mode { - Color32::from_rgb(100, 180, 180) // Darker blue for dark mode - } else { - Color32::from_rgb(167, 232, 232) // Light blue for light mode - } - } else { - crate::ui::theme::DashColors::glass_white(dark_mode) // Theme-aware for unloaded keys - }; - - let text_color = if holding_private_key.is_some() { - Color32::BLACK // Black text on light blue background + let key_label = self.format_key_name(key); + let button = if holding_private_key.is_some() { + egui::Button::new(&key_label).fill(crate::ui::theme::DashColors::selected(dark_mode)) } else { - crate::ui::theme::DashColors::text_primary(dark_mode) // Theme-aware text + egui::Button::new(&key_label) }; - let button = egui::Button::new(RichText::new(self.format_key_name(key)).color(text_color)) - .fill(button_color) - .frame(true); - - if ui.add(button).clicked() { + if ui.add_sized([ui.available_width(), 0.0], button).clicked() { action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( qualified_identity.clone(), key.clone(), @@ -774,21 +783,14 @@ impl IdentitiesScreen { } // Add Key button - if qualified_identity.can_sign_with_master_key().is_some() { - ui.separator(); - let dark_mode = ui.ctx().style().visuals.dark_mode; - let add_button = egui::Button::new("➕ Add Key") - .fill(crate::ui::theme::DashColors::glass_white(dark_mode)) - .frame(true); - - if ui.add(add_button).on_hover_text("Add a new key to this identity").clicked() { + if qualified_identity.can_sign_with_master_key().is_some() + && ui.add_sized([ui.available_width(), 0.0], egui::Button::new("+ Add Key")).on_hover_text("Add a new key to this identity").clicked() { action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( qualified_identity.clone(), &self.app_context, ))); ui.close_kind(egui::UiKind::Menu); } - } }, ); } @@ -828,6 +830,9 @@ impl IdentitiesScreen { }); } }); + + // Add space at the bottom so the horizontal scrollbar doesn't cover content + ui.add_space(15.0); }); action @@ -836,23 +841,94 @@ impl IdentitiesScreen { fn show_identity_to_remove(&mut self, ctx: &Context) -> AppAction { if let Some(identity_to_remove) = self.identity_to_remove.clone() { let action = AppAction::None; + + // Draw dark overlay behind the popup + let screen_rect = ctx.screen_rect(); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("confirm_removal_overlay"), + )); + painter.rect_filled( + screen_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), + ); + egui::Window::new("Confirm Removal") .collapsible(false) .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .frame(egui::Frame { + inner_margin: egui::Margin::same(20), + outer_margin: egui::Margin::same(0), + corner_radius: egui::CornerRadius::same(8), + shadow: egui::epaint::Shadow { + offset: [0, 8], + blur: 16, + spread: 0, + color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), + }, + fill: ctx.style().visuals.window_fill, + stroke: egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), + ), + }) .show(ctx, |ui| { - ui.label(format!( - "Are you sure you want to no longer track this {} identity?", - identity_to_remove.identity_type - )); - ui.label(format!( - "Identity ID: {}", - identity_to_remove - .identity - .id() - .to_string(identity_to_remove.identity_type.default_encoding()) - )); + ui.set_min_width(350.0); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + + ui.label( + RichText::new(format!( + "Are you sure you want to no longer track this {} identity?", + identity_to_remove.identity_type + )) + .color(DashColors::text_primary(dark_mode)), + ); + + ui.add_space(8.0); + + ui.label( + RichText::new(format!( + "Identity ID: {}", + identity_to_remove + .identity + .id() + .to_string(identity_to_remove.identity_type.default_encoding()) + )) + .color(DashColors::text_secondary(dark_mode)), + ); + + ui.add_space(16.0); + ui.horizontal(|ui| { - if ui.button("Yes").clicked() { + // No button + let no_button = egui::Button::new( + RichText::new("No").color(DashColors::text_primary(dark_mode)), + ) + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::new( + 1.0, + DashColors::text_secondary(dark_mode), + )) + .corner_radius(egui::CornerRadius::same(4)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui.add(no_button).clicked() { + self.identity_to_remove = None; + } + + ui.add_space(8.0); + + // Yes button + let yes_button = + egui::Button::new(RichText::new("Yes").color(Color32::WHITE)) + .fill(Color32::from_rgb(200, 60, 60)) + .corner_radius(egui::CornerRadius::same(4)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui.add(yes_button).clicked() { let identity_id = identity_to_remove.identity.id(); let mut lock = self.identities.lock().unwrap(); lock.shift_remove(&identity_id); @@ -877,9 +953,6 @@ impl IdentitiesScreen { self.identity_to_remove = None; } - if ui.button("No").clicked() { - self.identity_to_remove = None; - } }); }); action @@ -888,6 +961,127 @@ impl IdentitiesScreen { } } + fn show_alias_edit_popup(&mut self, ctx: &Context) -> AppAction { + if self.editing_alias_identity.is_none() { + return AppAction::None; + } + + let identity_id = self.editing_alias_identity.unwrap(); + + // Draw dark overlay behind the popup + let screen_rect = ctx.screen_rect(); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("edit_alias_overlay"), + )); + painter.rect_filled( + screen_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), + ); + + egui::Window::new("Update Alias") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .frame(egui::Frame { + inner_margin: egui::Margin::same(20), + outer_margin: egui::Margin::same(0), + corner_radius: egui::CornerRadius::same(8), + shadow: egui::epaint::Shadow { + offset: [0, 8], + blur: 16, + spread: 0, + color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), + }, + fill: ctx.style().visuals.window_fill, + stroke: egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), + ), + }) + .show(ctx, |ui| { + ui.set_min_width(300.0); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + + ui.label( + RichText::new("Enter a new alias for this identity:") + .color(DashColors::text_primary(dark_mode)), + ); + + ui.add_space(8.0); + + let text_edit = egui::TextEdit::singleline(&mut self.editing_alias_value) + .hint_text("Enter alias...") + .desired_width(260.0); + let response = ui.add(text_edit); + + // Submit on Enter key + let submit = response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + + ui.add_space(16.0); + + ui.horizontal(|ui| { + // Cancel button + let cancel_button = egui::Button::new( + RichText::new("Cancel").color(DashColors::text_primary(dark_mode)), + ) + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::new( + 1.0, + DashColors::text_secondary(dark_mode), + )) + .corner_radius(egui::CornerRadius::same(4)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui.add(cancel_button).clicked() { + self.editing_alias_identity = None; + self.editing_alias_value.clear(); + } + + ui.add_space(8.0); + + // Save button + let save_button = + egui::Button::new(RichText::new("Save").color(Color32::WHITE)) + .fill(DashColors::DASH_BLUE) + .corner_radius(egui::CornerRadius::same(4)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui.add(save_button).clicked() || submit { + // Update the alias + let new_alias = if self.editing_alias_value.trim().is_empty() { + None + } else { + Some(self.editing_alias_value.trim().to_string()) + }; + + // Update in memory + { + let mut identities = self.identities.lock().unwrap(); + if let Some(identity_to_update) = identities.get_mut(&identity_id) { + identity_to_update.alias = new_alias.clone(); + } + } + + // Update in database + if let Err(e) = self + .app_context + .set_identity_alias(&identity_id, new_alias.as_deref()) + { + eprintln!("Failed to save alias: {}", e); + } + + self.editing_alias_identity = None; + self.editing_alias_value.clear(); + } + }); + }); + + AppAction::None + } + fn dismiss_message(&mut self) { self.backend_message = None; } @@ -925,9 +1119,7 @@ impl ScreenLike for IdentitiesScreen { } fn display_message(&mut self, message: &str, message_type: crate::ui::MessageType) { - if message.contains("Error refreshing identity") - || message.contains("Successfully refreshed identity") - { + if let crate::ui::MessageType::Error = message_type { self.refreshing_status = IdentitiesRefreshingStatus::NotRefreshing; } self.backend_message = Some((message.to_string(), message_type, Utc::now())); @@ -935,10 +1127,18 @@ impl ScreenLike for IdentitiesScreen { fn display_task_result( &mut self, - _backend_task_success_result: crate::ui::BackendTaskSuccessResult, + backend_task_success_result: crate::ui::BackendTaskSuccessResult, ) { - // Nothing - // If we don't include this, success messages from ZMQ listener will keep popping up + if let crate::ui::BackendTaskSuccessResult::RefreshedIdentity(_) = + backend_task_success_result + { + self.refreshing_status = IdentitiesRefreshingStatus::NotRefreshing; + self.backend_message = Some(( + "Successfully refreshed identity".to_string(), + crate::ui::MessageType::Success, + Utc::now(), + )); + } } fn ui(&mut self, ctx: &Context) -> AppAction { @@ -948,7 +1148,7 @@ impl ScreenLike for IdentitiesScreen { vec![ ( "Import Wallet", - DesiredAppAction::AddScreenType(Box::new(ScreenType::ImportWallet)), + DesiredAppAction::AddScreenType(Box::new(ScreenType::ImportMnemonic)), ), ( "Create Wallet", @@ -1010,6 +1210,11 @@ impl ScreenLike for IdentitiesScreen { inner_action |= self.show_identity_to_remove(ctx); } + // Handle alias editing popup + if self.editing_alias_identity.is_some() { + inner_action |= self.show_alias_edit_popup(ctx); + } + // Show either refreshing indicator or message, but not both if let IdentitiesRefreshingStatus::Refreshing(start_time) = self.refreshing_status { ui.add_space(25.0); // Space above @@ -1018,7 +1223,7 @@ impl ScreenLike for IdentitiesScreen { ui.horizontal(|ui| { ui.add_space(10.0); ui.label(format!("Refreshing... Time taken so far: {}", elapsed)); - ui.add(egui::widgets::Spinner::default().color(Color32::from_rgb(0, 128, 255))); + ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); }); ui.add_space(2.0); // Space below } else if let Some((message, message_type, timestamp)) = self.backend_message.clone() { diff --git a/src/ui/identities/keys/add_key_screen.rs b/src/ui/identities/keys/add_key_screen.rs index 756297b52..722d09aaa 100644 --- a/src/ui/identities/keys/add_key_screen.rs +++ b/src/ui/identities/keys/add_key_screen.rs @@ -1,17 +1,22 @@ use crate::app::AppAction; -use crate::backend_task::BackendTask; use crate::backend_task::identity::IdentityTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; use crate::model::wallet::Wallet; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; use crate::ui::identities::get_selected_wallet; +use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike}; use bip39::rand::{SeedableRng, rngs::StdRng}; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::hash::IdentityPublicKeyHashMethodsV0; use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; @@ -20,7 +25,7 @@ use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::Identifier; use dash_sdk::dpp::prelude::TimestampMillis; -use eframe::egui::{self, Context}; +use eframe::egui::{self, Context, Frame, Margin}; use egui::{Color32, RichText, Ui}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -43,12 +48,13 @@ pub struct AddKeyScreen { security_level: SecurityLevel, add_key_status: AddKeyStatus, selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, error_message: Option, contract_id_input: String, document_type_input: String, enable_contract_bounds: bool, + // Fee result from completed operation + completed_fee_result: Option, } impl AddKeyScreen { @@ -73,12 +79,92 @@ impl AddKeyScreen { security_level: SecurityLevel::HIGH, add_key_status: AddKeyStatus::NotStarted, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), error_message, contract_id_input: String::new(), document_type_input: String::new(), enable_contract_bounds: false, + completed_fee_result: None, + } + } + + /// Create a new AddKeyScreen pre-configured for adding a DashPay ENCRYPTION key. + /// This is required for sending contact requests. + pub fn new_for_dashpay_encryption( + identity: QualifiedIdentity, + app_context: &Arc, + ) -> Self { + let identity_clone = identity.clone(); + let selected_key = identity_clone.identity.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::MASTER]), + KeyType::all_key_types().into(), + false, + ); + let mut error_message = None; + let selected_wallet = + get_selected_wallet(&identity, None, selected_key, &mut error_message); + + let dashpay_contract_id = app_context + .dashpay_contract + .id() + .to_string(Encoding::Base58); + + Self { + identity, + app_context: app_context.clone(), + private_key_input: String::new(), + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::ENCRYPTION, + security_level: SecurityLevel::MEDIUM, + add_key_status: AddKeyStatus::NotStarted, + selected_wallet, + wallet_unlock_popup: WalletUnlockPopup::new(), + error_message, + contract_id_input: dashpay_contract_id, + document_type_input: String::new(), + enable_contract_bounds: true, + completed_fee_result: None, + } + } + + /// Create a new AddKeyScreen pre-configured for adding a DashPay DECRYPTION key. + /// This is required for receiving contact requests. + pub fn new_for_dashpay_decryption( + identity: QualifiedIdentity, + app_context: &Arc, + ) -> Self { + let identity_clone = identity.clone(); + let selected_key = identity_clone.identity.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::MASTER]), + KeyType::all_key_types().into(), + false, + ); + let mut error_message = None; + let selected_wallet = + get_selected_wallet(&identity, None, selected_key, &mut error_message); + + let dashpay_contract_id = app_context + .dashpay_contract + .id() + .to_string(Encoding::Base58); + + Self { + identity, + app_context: app_context.clone(), + private_key_input: String::new(), + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::DECRYPTION, + security_level: SecurityLevel::MEDIUM, + add_key_status: AddKeyStatus::NotStarted, + selected_wallet, + wallet_unlock_popup: WalletUnlockPopup::new(), + error_message, + contract_id_input: dashpay_contract_id, + document_type_input: String::new(), + enable_contract_bounds: true, + completed_fee_result: None, } } @@ -190,33 +276,36 @@ impl AddKeyScreen { } pub fn show_success(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - - // Center the content vertically and horizontally - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - ui.heading("Successfully added key."); - - ui.add_space(20.0); + let action = crate::ui::helpers::show_success_screen_with_info( + ui, + "Key Added Successfully!".to_string(), + vec![ + ( + "Back to Identities Screen".to_string(), + AppAction::PopScreenAndRefresh, + ), + ( + "Add another key".to_string(), + AppAction::Custom("add_another".to_string()), + ), + ], + None, + ); - if ui.button("Back to Identities Screen").clicked() { - action = AppAction::PopScreenAndRefresh; - } - ui.add_space(5.0); - - if ui.button("Add another key").clicked() { - action = AppAction::BackendTask(BackendTask::IdentityTask( - IdentityTask::RefreshIdentity(self.identity.clone()), - )); - self.private_key_input = String::new(); - self.contract_id_input = String::new(); - self.document_type_input = String::new(); - self.enable_contract_bounds = false; - self.add_key_status = AddKeyStatus::NotStarted; - } - }); + // Handle the custom action to reset the form and refresh identity + if let AppAction::Custom(ref s) = action + && s == "add_another" + { + self.private_key_input = String::new(); + self.contract_id_input = String::new(); + self.document_type_input = String::new(); + self.enable_contract_bounds = false; + self.add_key_status = AddKeyStatus::NotStarted; + self.completed_fee_result = None; + return AppAction::BackendTask(BackendTask::IdentityTask( + IdentityTask::RefreshIdentity(self.identity.clone()), + )); + } action } @@ -236,20 +325,21 @@ impl ScreenLike for AddKeyScreen { } fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - if message == "Successfully added key to identity" { - self.add_key_status = AddKeyStatus::Complete; - } - if message == "Successfully refreshed identity" { - self.refresh(); - } + if let MessageType::Error = message_type { + self.add_key_status = AddKeyStatus::ErrorMessage(message.to_string()); + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + match backend_task_success_result { + BackendTaskSuccessResult::AddedKeyToIdentity(fee_result) => { + self.completed_fee_result = Some(fee_result); + self.add_key_status = AddKeyStatus::Complete; } - MessageType::Info => {} - MessageType::Error => { - // It's not great because the error message can be coming from somewhere else if there are other processes happening - self.add_key_status = AddKeyStatus::ErrorMessage(message.to_string()); + BackendTaskSuccessResult::RefreshedIdentity(_) => { + self.refresh(); } + _ => {} } } @@ -287,10 +377,22 @@ impl ScreenLike for AddKeyScreen { return inner_action; } - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - - if needed_unlock && !just_unlocked { + if self.selected_wallet.is_some() + && let Some(wallet) = &self.selected_wallet + { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } return inner_action; } } @@ -454,6 +556,32 @@ impl ScreenLike for AddKeyScreen { }); ui.add_space(20.0); + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_identity_update(); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + + ui.add_space(10.0); + // Add Key button let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); @@ -505,7 +633,24 @@ impl ScreenLike for AddKeyScreen { ui.label(format!("Adding key... Time taken so far: {}", display_time)); } AddKeyStatus::ErrorMessage(msg) => { - ui.colored_label(egui::Color32::DARK_RED, format!("Error: {}", msg)); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = msg.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", msg)).color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.add_key_status = AddKeyStatus::NotStarted; + } + }); + }); } AddKeyStatus::Complete => { // handled above @@ -515,36 +660,18 @@ impl ScreenLike for AddKeyScreen { inner_action }); - action - } -} - -impl ScreenWithWalletUnlock for AddKeyScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index 1c12968d8..586c4c910 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -6,10 +6,13 @@ use crate::model::qualified_identity::encrypted_key_storage::{ }; use crate::model::wallet::Wallet; use crate::ui::ScreenLike; +use crate::ui::components::info_popup::InfoPopup; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; use base64::Engine; use base64::engine::general_purpose::STANDARD; use dash_sdk::dashcore_rpc::dashcore::PrivateKey as RPCPrivateKey; @@ -26,7 +29,7 @@ use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBound use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::IdentityPublicKey; use eframe::egui::{self, Context}; -use egui::{Color32, RichText, ScrollArea}; +use egui::{Color32, Frame, Margin, RichText, ScrollArea}; use std::sync::{Arc, RwLock}; pub struct KeyInfoScreen { @@ -38,8 +41,7 @@ pub struct KeyInfoScreen { private_key_input: String, error_message: Option, selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, message_input: String, signed_message: Option, sign_error_message: Option, @@ -490,34 +492,49 @@ impl ScreenLike for KeyInfoScreen { } // Display error message if validation fails - if let Some(error_message) = &self.error_message { - ui.colored_label(egui::Color32::RED, error_message); + if let Some(error_message) = self.error_message.clone() { + let error_color = Color32::from_rgb(255, 100, 100); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", error_message)) + .color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.error_message = None; + } + }); + }); } } - if self.view_wallet_unlock { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - if !needed_unlock || just_unlocked { + if self.view_wallet_unlock + && let Some(wallet) = &self.selected_wallet + { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + } else { self.wallet_open = true; } } - // Show the popup window if `show_popup` is true - if let Some(show_pop_up_info_text) = self.show_pop_up_info.clone() { - egui::Window::new("Sign Message Info") - .collapsible(false) // Prevent collapsing - .resizable(false) // Prevent resizing - .show(ctx, |ui| { - ui.label(RichText::new(show_pop_up_info_text).color(Color32::BLACK)); - ui.add_space(10.0); - - // Add a close button to dismiss the popup - if ui.button("Close").clicked() { - self.show_pop_up_info = None - } - }); - } - // Show the remove private key confirmation popup if self.show_confirm_remove_private_key { self.render_remove_private_key_confirm(ui); @@ -528,6 +545,31 @@ impl ScreenLike for KeyInfoScreen { inner_action }); + + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } + + // Show the popup window if `show_popup` is true + if let Some(show_pop_up_info_text) = self.show_pop_up_info.clone() { + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(ctx, |ui| { + let mut popup = InfoPopup::new("Sign Message Info", &show_pop_up_info_text); + if popup.show(ui).inner { + self.show_pop_up_info = None; + } + }); + } + action } } @@ -557,8 +599,7 @@ impl KeyInfoScreen { private_key_input: String::new(), error_message: None, selected_wallet, - wallet_password: "".to_string(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), message_input: "".to_string(), signed_message: None, sign_error_message: None, @@ -604,7 +645,7 @@ impl KeyInfoScreen { ); match self .app_context - .insert_local_qualified_identity(&self.identity, &None) + .update_local_qualified_identity(&self.identity) { Ok(_) => { self.error_message = None; @@ -650,8 +691,24 @@ impl KeyInfoScreen { self.sign_message(); } - if let Some(error_message) = &self.sign_error_message { - ui.colored_label(egui::Color32::RED, error_message); + if let Some(error_message) = self.sign_error_message.clone() { + let error_color = Color32::from_rgb(255, 100, 100); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", error_message)).color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.sign_error_message = None; + } + }); + }); } if let Some(signed_message) = &self.signed_message { @@ -740,7 +797,7 @@ impl KeyInfoScreen { .remove(&(self.key.purpose().into(), self.key.id())); match self .app_context - .insert_local_qualified_identity(&self.identity, &None) + .update_local_qualified_identity(&self.identity) { Ok(_) => { self.error_message = None; @@ -755,33 +812,3 @@ impl KeyInfoScreen { }); } } - -impl ScreenWithWalletUnlock for KeyInfoScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } - - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() - } -} diff --git a/src/ui/identities/mod.rs b/src/ui/identities/mod.rs index 4640b75cd..eeec7b7b3 100644 --- a/src/ui/identities/mod.rs +++ b/src/ui/identities/mod.rs @@ -20,7 +20,7 @@ use crate::{ pub mod add_existing_identity_screen; pub mod add_new_identity_screen; -mod funding_common; +pub mod funding_common; pub mod identities_screen; pub mod keys; pub mod register_dpns_name_screen; diff --git a/src/ui/identities/register_dpns_name_screen.rs b/src/ui/identities/register_dpns_name_screen.rs index 05a080539..ae3db6fa3 100644 --- a/src/ui/identities/register_dpns_name_screen.rs +++ b/src/ui/identities/register_dpns_name_screen.rs @@ -1,21 +1,26 @@ use crate::app::AppAction; -use crate::backend_task::BackendTask; use crate::backend_task::identity::{IdentityTask, RegisterDpnsNameInput}; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser_with_doc_type}; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::helpers::{TransactionType, add_key_chooser_with_doc_type}; +use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::{Purpose, TimestampMillis}; use dash_sdk::platform::{Identifier, IdentityPublicKey}; -use eframe::egui::Context; +use eframe::egui::{Context, Frame, Margin}; use egui::{Color32, RichText, Ui}; use std::sync::Arc; use std::sync::RwLock; @@ -23,6 +28,14 @@ use std::time::{SystemTime, UNIX_EPOCH}; use super::get_selected_wallet; +/// Tracks where the user navigated from to reach this screen +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RegisterDpnsNameSource { + #[default] + Dpns, + Identities, +} + #[derive(PartialEq)] pub enum RegisterDpnsNameStatus { NotStarted, @@ -35,18 +48,23 @@ pub struct RegisterDpnsNameScreen { pub show_identity_selector: bool, pub qualified_identities: Vec, pub selected_qualified_identity: Option, + selected_identity_string: String, pub selected_key: Option, name_input: String, register_dpns_name_status: RegisterDpnsNameStatus, pub app_context: Arc, selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, error_message: Option, + show_advanced_options: bool, + // Fee result from completed operation + completed_fee_result: Option, + // Source of navigation to this screen + pub source: RegisterDpnsNameSource, } impl RegisterDpnsNameScreen { - pub fn new(app_context: &Arc) -> Self { + pub fn new(app_context: &Arc, source: RegisterDpnsNameSource) -> Self { let qualified_identities: Vec<_> = app_context.load_local_user_identities().unwrap_or_default(); let selected_qualified_identity = qualified_identities.first().cloned(); @@ -58,19 +76,45 @@ impl RegisterDpnsNameScreen { None }; + // Auto-select a suitable key for DPNS registration + let selected_key = selected_qualified_identity.as_ref().and_then(|identity| { + use dash_sdk::dpp::identity::KeyType; + identity + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + dash_sdk::dpp::identity::SecurityLevel::full_range().into(), + KeyType::all_key_types().into(), + false, + ) + .cloned() + }); + + let selected_identity_string = selected_qualified_identity + .as_ref() + .map(|qi| { + qi.identity + .id() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) + }) + .unwrap_or_default(); + let show_identity_selector = qualified_identities.len() > 1; Self { show_identity_selector, qualified_identities, selected_qualified_identity, - selected_key: None, + selected_identity_string, + selected_key, name_input: String::new(), register_dpns_name_status: RegisterDpnsNameStatus::NotStarted, app_context: app_context.clone(), selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), error_message, + show_advanced_options: false, + completed_fee_result: None, + source, } } @@ -83,7 +127,23 @@ impl RegisterDpnsNameScreen { { // Set the selected_qualified_identity to the found identity self.selected_qualified_identity = Some(qi.clone()); - self.selected_key = None; // Reset key selection + self.selected_identity_string = qi + .identity + .id() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); + + // Auto-select a suitable key for DPNS registration + use dash_sdk::dpp::identity::KeyType; + self.selected_key = qi + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + dash_sdk::dpp::identity::SecurityLevel::full_range().into(), + KeyType::all_key_types().into(), + false, + ) + .cloned(); + // Update the selected wallet self.selected_wallet = get_selected_wallet(qi, Some(&self.app_context), None, &mut self.error_message); @@ -91,25 +151,80 @@ impl RegisterDpnsNameScreen { // If not found, you might want to handle this case // For now, we'll set selected_qualified_identity to None self.selected_qualified_identity = None; + self.selected_identity_string = String::new(); self.selected_key = None; self.selected_wallet = None; } } - fn render_identity_id_selection(&mut self, ui: &mut egui::Ui) { - add_identity_key_chooser_with_doc_type( - ui, - &self.app_context, - self.qualified_identities.iter(), - &mut self.selected_qualified_identity, - &mut self.selected_key, - TransactionType::DocumentAction, - self.app_context - .dpns_contract - .document_type_cloned_for_name("domain") - .ok() - .as_ref(), + fn render_identity_id_selection(&mut self, ui: &mut egui::Ui) -> AppAction { + let mut action = AppAction::None; + + // Identity selector + let response = ui.add( + IdentitySelector::new( + "dpns_register_identity_selector", + &mut self.selected_identity_string, + &self.qualified_identities, + ) + .selected_identity(&mut self.selected_qualified_identity) + .unwrap() + .width(300.0) + .label("Identity:") + .other_option(false), ); + + // Handle identity change - auto-select key and update wallet + if response.changed() { + if let Some(identity) = &self.selected_qualified_identity { + // Auto-select a suitable key for DPNS registration + use dash_sdk::dpp::identity::KeyType; + self.selected_key = identity + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + dash_sdk::dpp::identity::SecurityLevel::full_range().into(), + KeyType::all_key_types().into(), + false, + ) + .cloned(); + + // Update wallet + self.selected_wallet = get_selected_wallet( + identity, + Some(&self.app_context), + None, + &mut self.error_message, + ); + } else { + self.selected_key = None; + self.selected_wallet = None; + } + } + + // Key selector (only shown in advanced mode) + if self.show_advanced_options { + ui.add_space(10.0); + if let Some(identity) = &self.selected_qualified_identity { + let key_action = add_key_chooser_with_doc_type( + ui, + &self.app_context, + identity, + &mut self.selected_key, + TransactionType::DocumentAction, + self.app_context + .dpns_contract + .document_type_cloned_for_name("domain") + .ok() + .as_ref(), + ); + if !matches!(key_action, AppAction::None) { + action = key_action; + } + } + } + + action } fn register_dpns_name_clicked(&mut self) -> AppAction { @@ -130,27 +245,28 @@ impl RegisterDpnsNameScreen { } pub fn show_success(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - - // Center the content vertically and horizontally - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - ui.heading("Successfully registered DPNS name."); - - ui.add_space(20.0); - - if ui.button("Back to DPNS screen").clicked() { - action = AppAction::PopScreenAndRefresh; - } - ui.add_space(5.0); + let action = crate::ui::helpers::show_success_screen_with_info( + ui, + "DPNS Name Registered!".to_string(), + vec![ + ("Back".to_string(), AppAction::PopScreenAndRefresh), + ( + "Register another name".to_string(), + AppAction::Custom("register_another".to_string()), + ), + ], + None, + ); - if ui.button("Register another name").clicked() { - self.name_input = String::new(); - self.register_dpns_name_status = RegisterDpnsNameStatus::NotStarted; - } - }); + // Handle the custom action to reset the form + if let AppAction::Custom(ref s) = action + && s == "register_another" + { + self.name_input = String::new(); + self.register_dpns_name_status = RegisterDpnsNameStatus::NotStarted; + self.completed_fee_result = None; + return AppAction::None; + } action } @@ -158,37 +274,52 @@ impl RegisterDpnsNameScreen { impl ScreenLike for RegisterDpnsNameScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - if message == "Successfully registered dpns name" { - self.register_dpns_name_status = RegisterDpnsNameStatus::Complete; - } - } - MessageType::Info => {} - MessageType::Error => { - // It's not great because the error message can be coming from somewhere else if there are other processes happening - self.register_dpns_name_status = - RegisterDpnsNameStatus::ErrorMessage(message.to_string()); - } + if let MessageType::Error = message_type { + self.register_dpns_name_status = + RegisterDpnsNameStatus::ErrorMessage(message.to_string()); + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::RegisteredDpnsName(fee_result) = + backend_task_success_result + { + self.completed_fee_result = Some(fee_result); + self.register_dpns_name_status = RegisterDpnsNameStatus::Complete; } } fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("DPNS", AppAction::GoToMainScreen), + // Build breadcrumbs based on where we came from + let breadcrumbs = match self.source { + RegisterDpnsNameSource::Dpns => vec![ + ( + "DPNS", + AppAction::SetMainScreen( + crate::ui::RootScreenType::RootScreenDPNSActiveContests, + ), + ), ("Register Name", AppAction::None), ], - vec![], - ); + RegisterDpnsNameSource::Identities => vec![ + ( + "Identities", + AppAction::SetMainScreen(crate::ui::RootScreenType::RootScreenIdentities), + ), + ("Register Name", AppAction::None), + ], + }; - action |= add_left_panel( - ctx, - &self.app_context, - crate::ui::RootScreenType::RootScreenDPNSOwnedNames, - ); + let mut action = add_top_panel(ctx, &self.app_context, breadcrumbs, vec![]); + + // Use the appropriate left panel highlight based on source + let root_screen = match self.source { + RegisterDpnsNameSource::Dpns => crate::ui::RootScreenType::RootScreenDPNSActiveContests, + RegisterDpnsNameSource::Identities => crate::ui::RootScreenType::RootScreenIdentities, + }; + action |= add_left_panel(ctx, &self.app_context, root_screen); + + // Don't show the tools/dpns subscreen chooser panels for this screen action |= island_central_panel(ctx, |ui| { let mut inner_action = AppAction::None; @@ -201,7 +332,12 @@ impl ScreenLike for RegisterDpnsNameScreen { return; } - ui.heading("Register DPNS Name"); + ui.horizontal(|ui| { + ui.heading("Register DPNS Name"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); ui.add_space(10.0); // If no identities loaded, give message @@ -233,7 +369,7 @@ impl ScreenLike for RegisterDpnsNameScreen { // Select the identity to register the name for ui.heading("1. Select Identity"); ui.add_space(5.0); - self.render_identity_id_selection(ui); + inner_action |= self.render_identity_id_selection(ui); ui.add_space(5.0); if let Some(identity) = &self.selected_qualified_identity { ui.label(format!("Identity balance: {:.6}", identity.identity.balance() as f64 * 1e-11)); @@ -243,13 +379,24 @@ impl ScreenLike for RegisterDpnsNameScreen { ui.separator(); ui.add_space(10.0); - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - - if needed_unlock && !just_unlocked { - return; + if self.selected_wallet.is_some() + && let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + return; + } } - } // Input for the name ui.heading("2. Enter the Name to Register:"); @@ -289,10 +436,6 @@ impl ScreenLike for RegisterDpnsNameScreen { egui::Color32::DARK_GREEN, "This is not a contested name.", ); - ui.colored_label( - egui::Color32::DARK_GREEN, - "Cost ≈ 0.0006 Dash", - ); } } _ => { @@ -308,17 +451,76 @@ impl ScreenLike for RegisterDpnsNameScreen { ui.add_space(10.0); + // Fee estimation + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_document_create(); + let dark_mode = ui.ctx().style().visuals.dark_mode; + + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + + ui.add_space(10.0); + + // Check if identity has enough balance + let has_enough_balance = self + .selected_qualified_identity + .as_ref() + .map(|id| id.identity.balance() > estimated_fee) + .unwrap_or(false); + // Register button let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); ui.set_style(new_style); let name_is_valid = validate_dpns_name(self.name_input.trim()) == DpnsNameValidationResult::Valid; - let button_enabled = self.selected_qualified_identity.is_some() && self.selected_key.is_some() && name_is_valid; + let button_enabled = self.selected_qualified_identity.is_some() + && self.selected_key.is_some() + && name_is_valid + && has_enough_balance; + + let hover_text = if !has_enough_balance { + format!( + "Insufficient identity balance for fee (need at least {})", + format_credits_as_dash(estimated_fee) + ) + } else if !name_is_valid { + "Please enter a valid name".to_string() + } else if self.selected_key.is_none() { + "Please select a signing key".to_string() + } else { + "Register DPNS name".to_string() + }; + let button = egui::Button::new(RichText::new("Register Name").color(Color32::WHITE)) - .fill(Color32::from_rgb(0, 128, 255)) + .fill(if button_enabled { + Color32::from_rgb(0, 128, 255) + } else { + Color32::GRAY + }) .frame(true) .corner_radius(3.0); - if ui.add_enabled(button_enabled, button).clicked() { + if ui + .add_enabled(button_enabled, button) + .on_hover_text(&hover_text) + .on_disabled_hover_text(&hover_text) + .clicked() + { // Set the status to waiting and capture the current time let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -366,7 +568,22 @@ impl ScreenLike for RegisterDpnsNameScreen { )); } RegisterDpnsNameStatus::ErrorMessage(msg) => { - ui.colored_label(egui::Color32::RED, format!("Error: {}", msg)); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = msg.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("Error: {}", msg)).color(error_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.register_dpns_name_status = RegisterDpnsNameStatus::NotStarted; + } + }); + }); } RegisterDpnsNameStatus::Complete => {} } @@ -400,37 +617,19 @@ impl ScreenLike for RegisterDpnsNameScreen { inner_action }); - action - } -} - -impl ScreenWithWalletUnlock for RegisterDpnsNameScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/identities/top_up_identity_screen/by_platform_address.rs b/src/ui/identities/top_up_identity_screen/by_platform_address.rs new file mode 100644 index 000000000..1a589bd46 --- /dev/null +++ b/src/ui/identities/top_up_identity_screen/by_platform_address.rs @@ -0,0 +1,285 @@ +use crate::app::AppAction; +use crate::backend_task::BackendTask; +use crate::backend_task::identity::IdentityTask; +use crate::model::amount::Amount; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::wallet::WalletSeedHash; +use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::identities::funding_common::WalletFundedScreenStep; +use crate::ui::theme::DashColors; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::balances::credits::Credits; +use dash_sdk::dpp::dashcore::Address; +use egui::{Frame, Margin, RichText, Ui}; +use std::collections::BTreeMap; + +use super::TopUpIdentityScreen; + +impl TopUpIdentityScreen { + /// Render the UI for topping up identity from Platform addresses + pub(super) fn render_ui_by_platform_address( + &mut self, + ui: &mut Ui, + step_number: u32, + ) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + ui.heading(format!( + "{}. Select a Platform address to use for top-up.", + step_number + )); + ui.add_space(10.0); + + // Get Platform addresses from the wallet + let platform_addresses = self.get_platform_addresses_with_balance(); + + if platform_addresses.is_empty() { + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(12, 10)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.label( + RichText::new("No Platform addresses with balance found.") + .color(DashColors::text_secondary(dark_mode)) + .italics(), + ); + ui.add_space(5.0); + ui.label( + RichText::new("Fund a Platform address first to use it for top-up.") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + }); + return action; + } + + // Show list of Platform addresses (using DIP-18 Bech32m format) + let network = self.app_context.network; + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(12, 10)) + .corner_radius(5.0) + .show(ui, |ui| { + for (core_addr, platform_addr, balance) in &platform_addresses { + let is_selected = self + .selected_platform_address + .as_ref() + .map(|(_, p, _)| p == platform_addr) + .unwrap_or(false); + + // Display address in Bech32m format + let addr_display = platform_addr.to_bech32m_string(network); + let response = ui.selectable_label( + is_selected, + format!("{} - {}", addr_display, Self::format_credits(*balance)), + ); + + if response.clicked() { + self.selected_platform_address = + Some((core_addr.clone(), *platform_addr, *balance)); + } + } + }); + + ui.add_space(15.0); + + // Amount input + ui.heading(format!("{}. Enter the amount to top up.", step_number + 1)); + ui.add_space(10.0); + + // Get max balance for the selected platform address + let max_balance_credits = self + .selected_platform_address + .as_ref() + .map(|(_, _, balance)| *balance); + + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(12, 10)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + // Amount input using AmountInput component + let amount_input = self.platform_top_up_amount_input.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .with_label("Amount (DASH):") + .with_hint_text("Enter amount (e.g., 0.01)") + .with_max_button(true) + .with_desired_width(150.0) + }); + + // Update max amount dynamically based on selected platform address + amount_input.set_max_amount(max_balance_credits); + + let response = amount_input.show(ui); + response.inner.update(&mut self.platform_top_up_amount); + + if let Some((_, _, balance)) = &self.selected_platform_address { + ui.add_space(10.0); + ui.label( + RichText::new(format!("Available: {}", Self::format_credits(*balance))) + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + } + }); + }); + + ui.add_space(10.0); + + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_identity_topup(); + + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + + ui.add_space(20.0); + + // Top Up button + let has_valid_amount = self + .platform_top_up_amount + .as_ref() + .map(|a| a.value() > 0) + .unwrap_or(false); + let can_top_up = + self.selected_platform_address.is_some() && has_valid_amount && self.wallet.is_some(); + + let step = { *self.step.read().unwrap() }; + + ui.horizontal(|ui| { + let button_text = match step { + WalletFundedScreenStep::WaitingForPlatformAcceptance => "Topping Up...", + _ => "Top Up Identity", + }; + + let button = egui::Button::new( + RichText::new(button_text) + .color(egui::Color32::WHITE) + .strong(), + ) + .fill(if can_top_up { + DashColors::DASH_BLUE + } else { + DashColors::DASH_BLUE.gamma_multiply(0.5) + }) + .min_size(egui::vec2(120.0, 36.0)); + + if ui.add_enabled(can_top_up, button).clicked() { + match self.validate_and_top_up_from_platform() { + Ok(top_up_action) => { + action = top_up_action; + } + Err(e) => { + self.error_message = Some(e); + } + } + } + }); + + action + } + + /// Get Platform addresses with balance from the selected wallet + fn get_platform_addresses_with_balance(&self) -> Vec<(Address, PlatformAddress, Credits)> { + let Some(wallet_arc) = &self.wallet else { + return vec![]; + }; + let Ok(wallet) = wallet_arc.read() else { + return vec![]; + }; + + let network = self.app_context.network; + wallet + .platform_addresses(network) + .into_iter() + .map(|(core_addr, platform_addr)| { + let balance = wallet + .get_platform_address_info(&core_addr) + .map(|info| info.balance) + .unwrap_or(0); + (core_addr, platform_addr, balance) + }) + .filter(|(_, _, balance)| *balance > 0) + .collect() + } + + /// Format credits as DASH equivalent + fn format_credits(credits: Credits) -> String { + let dash_equivalent = credits as f64 / 1000.0 / 100_000_000.0; + format!("{:.8} DASH", dash_equivalent) + } + + /// Validate and create the top-up task + fn validate_and_top_up_from_platform(&mut self) -> Result { + let (_, platform_addr, available_balance) = self + .selected_platform_address + .clone() + .ok_or_else(|| "Please select a Platform address".to_string())?; + + let amount = self + .platform_top_up_amount + .as_ref() + .map(|a| a.value()) + .ok_or_else(|| "Amount is required".to_string())?; + + if amount == 0 { + return Err("Amount must be positive".to_string()); + } + + if amount > available_balance { + return Err(format!( + "Insufficient balance. Available: {}, Requested: {}", + Self::format_credits(available_balance), + Self::format_credits(amount) + )); + } + + // Get wallet seed hash + let wallet_seed_hash: WalletSeedHash = { + let wallet = self + .wallet + .as_ref() + .ok_or_else(|| "No wallet selected".to_string())?; + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + wallet_guard.seed_hash() + }; + + // Build inputs + let mut inputs: BTreeMap = BTreeMap::new(); + inputs.insert(platform_addr, amount); + + // Update step + { + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::WaitingForPlatformAcceptance; + } + + Ok(AppAction::BackendTask(BackendTask::IdentityTask( + IdentityTask::TopUpIdentityFromPlatformAddresses { + identity: self.identity.clone(), + inputs, + wallet_seed_hash, + }, + ))) + } +} diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs index 4b9ea0a4c..9d0137023 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs @@ -1,7 +1,9 @@ use crate::app::AppAction; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::ui::identities::add_new_identity_screen::FundingMethod; use crate::ui::identities::top_up_identity_screen::{TopUpIdentityScreen, WalletFundedScreenStep}; -use egui::{Color32, RichText, Ui}; +use crate::ui::theme::DashColors; +use egui::{Color32, Frame, Margin, RichText, Ui}; impl TopUpIdentityScreen { fn render_choose_funding_asset_lock(&mut self, ui: &mut egui::Ui) { @@ -92,6 +94,32 @@ impl TopUpIdentityScreen { self.render_choose_funding_asset_lock(ui); ui.add_space(10.0); + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_identity_topup(); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + + ui.add_space(10.0); + // Top up button let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); @@ -107,21 +135,19 @@ impl TopUpIdentityScreen { ui.add_space(20.0); - if let Some(error_message) = self.error_message.as_ref() { - ui.colored_label(Color32::DARK_RED, error_message); - ui.add_space(20.0); + // Only show status messages if there's no error + if self.error_message.is_none() { + ui.vertical_centered(|ui| match step { + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement <="); + } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); + } + _ => {} + }); } - ui.vertical_centered(|ui| match step { - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement <="); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); - } - _ => {} - }); - ui.add_space(40.0); action } diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs index 0a9e598e7..3cf524855 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs @@ -1,14 +1,16 @@ use crate::app::AppAction; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::ui::identities::add_new_identity_screen::FundingMethod; use crate::ui::identities::top_up_identity_screen::{TopUpIdentityScreen, WalletFundedScreenStep}; -use egui::{Color32, RichText, Ui}; +use crate::ui::theme::DashColors; +use egui::{Color32, Frame, Margin, RichText, Ui}; impl TopUpIdentityScreen { fn show_wallet_balance(&self, ui: &mut egui::Ui) { if let Some(selected_wallet) = &self.wallet { let wallet = selected_wallet.read().unwrap(); // Read lock on the wallet - let total_balance: u64 = wallet.max_balance(); // Sum up all the balances + let total_balance: u64 = wallet.total_balance_duffs(); // Use stored balance with UTXO fallback let dash_balance = total_balance as f64 * 1e-8; // Convert to DASH units @@ -45,6 +47,32 @@ impl TopUpIdentityScreen { return action; }; + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_identity_topup(); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + + ui.add_space(10.0); + // Top up button let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); @@ -60,28 +88,26 @@ impl TopUpIdentityScreen { ui.add_space(20.0); - if let Some(error_message) = self.error_message.as_ref() { - ui.colored_label(Color32::DARK_RED, error_message); - ui.add_space(20.0); + // Only show status messages if there's no error + if self.error_message.is_none() { + ui.vertical_centered(|ui| { + match step { + WalletFundedScreenStep::WaitingForAssetLock => { + ui.heading( + "=> Waiting for Core Chain to produce proof of transfer of funds. <=", + ); + } + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement <="); + } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); + } + _ => {} + }; + }); } - ui.vertical_centered(|ui| { - match step { - WalletFundedScreenStep::WaitingForAssetLock => { - ui.heading( - "=> Waiting for Core Chain to produce proof of transfer of funds. <=", - ); - } - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement <="); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); - } - _ => {} - }; - }); - ui.add_space(40.0); action } diff --git a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs index d3bf7c213..ec069f2d7 100644 --- a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs @@ -5,7 +5,7 @@ use crate::ui::identities::funding_common::{self, copy_to_clipboard, generate_qr use crate::ui::identities::top_up_identity_screen::{TopUpIdentityScreen, WalletFundedScreenStep}; use dash_sdk::dashcore_rpc::RpcApi; use eframe::epaint::TextureHandle; -use egui::{Color32, Ui}; +use egui::Ui; use std::sync::Arc; impl TopUpIdentityScreen { @@ -137,61 +137,60 @@ impl TopUpIdentityScreen { ui.add_space(20.0); - if let Some(error_message) = self.error_message.as_ref() { - ui.colored_label(Color32::DARK_RED, error_message); - ui.add_space(20.0); - } + // Handle FundsReceived action regardless of error state + if step == WalletFundedScreenStep::FundsReceived { + let Some(selected_wallet) = &self.wallet else { + return AppAction::None; + }; + if let Some((utxo, tx_out, address)) = self.funding_utxo.clone() { + let wallet_index = self.identity.wallet_index.unwrap_or(u32::MAX >> 1); + let top_up_index = self + .identity + .top_ups + .keys() + .max() + .cloned() + .map(|i| i + 1) + .unwrap_or_default(); + let identity_input = IdentityTopUpInfo { + qualified_identity: self.identity.clone(), + wallet: Arc::clone(selected_wallet), + identity_funding_method: TopUpIdentityFundingMethod::FundWithUtxo( + utxo, + tx_out, + address, + wallet_index, + top_up_index, + ), + }; + + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::WaitingForAssetLock; - match step { - WalletFundedScreenStep::ChooseFundingMethod => {} - WalletFundedScreenStep::WaitingOnFunds => { - ui.heading("=> Waiting for funds. <="); + return AppAction::BackendTask(BackendTask::IdentityTask( + IdentityTask::TopUpIdentity(identity_input), + )); } - WalletFundedScreenStep::FundsReceived => { - let Some(selected_wallet) = &self.wallet else { - return AppAction::None; - }; - if let Some((utxo, tx_out, address)) = self.funding_utxo.clone() { - let wallet_index = self.identity.wallet_index.unwrap_or(u32::MAX >> 1); - let top_up_index = self - .identity - .top_ups - .keys() - .max() - .cloned() - .map(|i| i + 1) - .unwrap_or_default(); - let identity_input = IdentityTopUpInfo { - qualified_identity: self.identity.clone(), - wallet: Arc::clone(selected_wallet), - identity_funding_method: TopUpIdentityFundingMethod::FundWithUtxo( - utxo, - tx_out, - address, - wallet_index, - top_up_index, - ), - }; - - let mut step = self.step.write().unwrap(); - *step = WalletFundedScreenStep::WaitingForAssetLock; - - return AppAction::BackendTask(BackendTask::IdentityTask( - IdentityTask::TopUpIdentity(identity_input), - )); + } + + // Only show status messages if there's no error + if self.error_message.is_none() { + match step { + WalletFundedScreenStep::WaitingOnFunds => { + ui.heading("=> Waiting for funds. <="); } - } - WalletFundedScreenStep::ReadyToCreate => {} - WalletFundedScreenStep::WaitingForAssetLock => { - ui.heading( - "=> Waiting for Core Chain to produce proof of transfer of funds. <=", - ); - } - WalletFundedScreenStep::WaitingForPlatformAcceptance => { - ui.heading("=> Waiting for Platform acknowledgement. <="); - } - WalletFundedScreenStep::Success => { - ui.heading("...Success..."); + WalletFundedScreenStep::WaitingForAssetLock => { + ui.heading( + "=> Waiting for Core Chain to produce proof of transfer of funds. <=", + ); + } + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement. <="); + } + WalletFundedScreenStep::Success => { + ui.heading("...Success..."); + } + _ => {} } } AppAction::None diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index 56b5f19ed..59242ef2c 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -1,3 +1,4 @@ +mod by_platform_address; mod by_using_unused_asset_lock; mod by_using_unused_balance; mod by_wallet_qr_code; @@ -6,20 +7,27 @@ mod success_screen; use crate::app::AppAction; use crate::backend_task::core::CoreItem; use crate::backend_task::identity::{IdentityTask, IdentityTopUpInfo, TopUpIdentityFundingMethod}; -use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; +use crate::model::amount::Amount; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::component_trait::Component; +use crate::ui::components::info_popup::InfoPopup; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; use crate::ui::identities::add_new_identity_screen::FundingMethod; use crate::ui::identities::funding_common::WalletFundedScreenStep; use crate::ui::{MessageType, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::transaction::special_transaction::TransactionPayload; -use dash_sdk::dpp::balances::credits::Duffs; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::balances::credits::{Credits, Duffs}; use dash_sdk::dpp::dashcore::{OutPoint, Transaction, TxOut}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; @@ -41,13 +49,19 @@ pub struct TopUpIdentityScreen { funding_method: Arc>, funding_amount: String, funding_amount_exact: Option, + funding_amount_input: Option, funding_utxo: Option<(OutPoint, TxOut, Address)>, copied_to_clipboard: Option>, error_message: Option, - show_password: bool, - wallet_password: String, + wallet_unlock_popup: WalletUnlockPopup, show_pop_up_info: Option, pub app_context: Arc, + // Platform address fields + selected_platform_address: Option<(Address, PlatformAddress, Credits)>, + platform_top_up_amount: Option, + platform_top_up_amount_input: Option, + /// Fee result from completed top-up + completed_fee_result: Option, } impl TopUpIdentityScreen { @@ -61,13 +75,17 @@ impl TopUpIdentityScreen { funding_method: Arc::new(RwLock::new(FundingMethod::NoSelection)), funding_amount: "".to_string(), funding_amount_exact: None, + funding_amount_input: None, funding_utxo: None, copied_to_clipboard: None, error_message: None, - show_password: false, - wallet_password: "".to_string(), + wallet_unlock_popup: WalletUnlockPopup::new(), show_pop_up_info: None, app_context: app_context.clone(), + selected_platform_address: None, + platform_top_up_amount: None, + platform_top_up_amount_input: None, + completed_fee_result: None, } } @@ -163,6 +181,7 @@ impl TopUpIdentityScreen { self.funding_address = None; self.funding_asset_lock = None; self.funding_utxo = None; + self.funding_amount_input = None; self.copied_to_clipboard = None; if let Some(method) = step_update_method { @@ -181,9 +200,9 @@ impl TopUpIdentityScreen { let mut step = self.step.write().unwrap(); *step = match funding_method { FundingMethod::AddressWithQRCode => WalletFundedScreenStep::WaitingOnFunds, - FundingMethod::UseUnusedAssetLock | FundingMethod::UseWalletBalance => { - WalletFundedScreenStep::ReadyToCreate - } + FundingMethod::UseUnusedAssetLock + | FundingMethod::UseWalletBalance + | FundingMethod::UsePlatformAddress => WalletFundedScreenStep::ReadyToCreate, FundingMethod::NoSelection => WalletFundedScreenStep::ChooseFundingMethod, }; } @@ -192,11 +211,12 @@ impl TopUpIdentityScreen { let funding_method_arc = self.funding_method.clone(); let mut funding_method = funding_method_arc.write().unwrap(); - // Check if any wallet has unused asset locks or balance - let (has_any_unused_asset_lock, has_any_balance) = { + // Check if any wallet has unused asset locks, balance, or Platform address balance + let (has_any_unused_asset_lock, has_any_balance, has_any_platform_balance) = { let wallets = self.app_context.wallets.read().unwrap(); let mut has_unused_asset_lock = false; let mut has_balance = false; + let mut has_platform_balance = false; for wallet in wallets.values() { let wallet = wallet.read().unwrap(); @@ -206,12 +226,15 @@ impl TopUpIdentityScreen { if wallet.has_balance() { has_balance = true; } - if has_unused_asset_lock && has_balance { + if wallet.total_platform_balance() > 0 { + has_platform_balance = true; + } + if has_unused_asset_lock && has_balance && has_platform_balance { break; // No need to check further } } - (has_unused_asset_lock, has_balance) + (has_unused_asset_lock, has_balance, has_platform_balance) }; ComboBox::from_id_salt("funding_method") @@ -228,7 +251,7 @@ impl TopUpIdentityScreen { .selectable_value( &mut *funding_method, FundingMethod::UseUnusedAssetLock, - "Use Unused Asset Locks", + "Unused Asset Locks", ) .changed() { @@ -242,7 +265,21 @@ impl TopUpIdentityScreen { .selectable_value( &mut *funding_method, FundingMethod::UseWalletBalance, - "Use Wallet Balance", + "Wallet Balance", + ) + .changed() + { + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::ReadyToCreate; + } + }); + + ui.add_enabled_ui(has_any_platform_balance, |ui| { + if ui + .selectable_value( + &mut *funding_method, + FundingMethod::UsePlatformAddress, + "Platform Address", ) .changed() { @@ -330,59 +367,39 @@ impl TopUpIdentityScreen { } fn top_up_funding_amount_input(&mut self, ui: &mut egui::Ui) { - ui.horizontal(|ui| { - ui.label("Amount (DASH):"); - - // Render the text input field for the funding amount - let amount_input = ui - .add(egui::TextEdit::singleline(&mut self.funding_amount).desired_width(100.0)) - .lost_focus(); - - self.funding_amount_exact = self.funding_amount.parse::().ok().map(|f| { - (f * 1e8) as u64 // Convert the amount to Duffs - }); - - let enter_pressed = ui.input(|i| i.key_pressed(egui::Key::Enter)); - - if amount_input && enter_pressed { - // Optional: Validate the input when Enter is pressed - if self.funding_amount.parse::().is_err() { - ui.label("Invalid amount. Please enter a valid number."); - } - } + // Get max amount from the selected wallet's balance (in Duffs, convert to Credits) + let max_amount_duffs = self + .wallet + .as_ref() + .map(|w| w.read().unwrap().total_balance_duffs()) + .unwrap_or(0); + // Convert Duffs to Credits (1 Duff = 1000 Credits) + let max_amount_credits = max_amount_duffs * 1000; + + // Lazy initialization of the AmountInput component + let amount_input = self.funding_amount_input.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .with_label("Amount:") + .with_max_button(true) + .with_max_amount(Some(max_amount_credits)) }); - ui.add_space(10.0); - } -} - -impl ScreenWithWalletUnlock for TopUpIdentityScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.wallet - } + // Update max amount in case wallet balance changed + amount_input.set_max_amount(Some(max_amount_credits)); - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } + let response = amount_input.show(ui); - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Update the funding_amount_exact from the parsed amount + if let Some(amount) = response.inner.parsed_amount { + // Amount.value() returns credits, convert to duffs (divide by 1000) + self.funding_amount_exact = Some(amount.value() / 1000); + // Keep the string in sync for backward compatibility + self.funding_amount = format!("{}", amount.value() as f64 / 100_000_000_000.0); + } else { + self.funding_amount_exact = None; + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + ui.add_space(10.0); } } @@ -390,19 +407,28 @@ impl ScreenLike for TopUpIdentityScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { if message_type == MessageType::Error { self.error_message = Some(format!("Error topping up identity: {}", message)); + // Reset step so UI is not stuck on waiting messages + let mut step = self.step.write().unwrap(); + if *step == WalletFundedScreenStep::WaitingForPlatformAcceptance + || *step == WalletFundedScreenStep::WaitingForAssetLock + { + *step = WalletFundedScreenStep::ReadyToCreate; + } } else { self.error_message = Some(message.to_string()); } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::ToppedUpIdentity(qualified_identity) = - &backend_task_success_result + if let BackendTaskSuccessResult::ToppedUpIdentity(qualified_identity, fee_result) = + backend_task_success_result { - self.identity = qualified_identity.clone(); + self.identity = qualified_identity; + self.completed_fee_result = Some(fee_result); self.funding_address = None; self.funding_utxo = None; self.funding_amount.clear(); self.funding_amount_exact = None; + self.funding_amount_input = None; self.copied_to_clipboard = None; self.error_message = None; @@ -477,6 +503,30 @@ impl ScreenLike for TopUpIdentityScreen { action |= island_central_panel(ctx, |ui| { let mut inner_action = AppAction::None; + let _dark_mode = ui.ctx().style().visuals.dark_mode; + + // Display error message at the top, outside of scroll area + if let Some(error_message) = self.error_message.clone() { + let message_color = egui::Color32::from_rgb(255, 100, 100); + + ui.horizontal(|ui| { + egui::Frame::new() + .fill(message_color.gamma_multiply(0.1)) + .inner_margin(egui::Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, message_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&error_message).color(message_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.error_message = None; + } + }); + }); + }); + ui.add_space(10.0); + } ScrollArea::vertical().show(ui, |ui| { let step = { *self.step.read().unwrap() }; @@ -533,20 +583,30 @@ impl ScreenLike for TopUpIdentityScreen { if funding_method == FundingMethod::UseWalletBalance || funding_method == FundingMethod::UseUnusedAssetLock || funding_method == FundingMethod::AddressWithQRCode + || funding_method == FundingMethod::UsePlatformAddress { - ui.horizontal(|ui| { - ui.heading(format!( - "{}. Choose the wallet to use to top up this identity.", - step_number - )); - ui.add_space(10.0); - - // Add info icon with hover tooltip - crate::ui::helpers::info_icon_button(ui, WALLET_SELECTION_TOOLTIP); - }); - step_number += 1; + // Check if there's more than one wallet to show selection UI + let wallet_count = self.app_context.wallets.read().unwrap().len(); + + if wallet_count > 1 { + ui.horizontal(|ui| { + ui.heading(format!( + "{}. Choose the wallet to use to top up this identity.", + step_number + )); + ui.add_space(10.0); + + // Add info icon with hover tooltip and click popup + if crate::ui::helpers::info_icon_button(ui, WALLET_SELECTION_TOOLTIP) + .clicked() + { + self.show_pop_up_info = Some(WALLET_SELECTION_TOOLTIP.to_string()); + } + }); + step_number += 1; - ui.add_space(10.0); + ui.add_space(10.0); + } self.render_wallet_selection(ui); @@ -554,15 +614,29 @@ impl ScreenLike for TopUpIdentityScreen { return; }; - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - - if needed_unlock && !just_unlocked { - return; + if let Some(wallet) = &self.wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + return; + } } - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); + if wallet_count > 1 { + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + } } match funding_method { @@ -576,23 +650,35 @@ impl ScreenLike for TopUpIdentityScreen { FundingMethod::AddressWithQRCode => { inner_action |= self.render_ui_by_wallet_qr_code(ui, step_number) } + FundingMethod::UsePlatformAddress => { + inner_action |= self.render_ui_by_platform_address(ui, step_number); + } } }); inner_action }); + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } + // Show the popup window if `show_popup` is true if let Some(show_pop_up_info_text) = self.show_pop_up_info.clone() { - egui::Window::new("Identity Index Information") - .collapsible(false) // Prevent collapsing - .resizable(false) // Prevent resizing + egui::CentralPanel::default() + .frame(egui::Frame::NONE) .show(ctx, |ui| { - ui.label(show_pop_up_info_text); - - // Add a close button to dismiss the popup - if ui.button("Close").clicked() { - self.show_pop_up_info = None + let mut popup = InfoPopup::new("Wallet Selection Info", &show_pop_up_info_text); + if popup.show(ui).inner { + self.show_pop_up_info = None; } }); } diff --git a/src/ui/identities/top_up_identity_screen/success_screen.rs b/src/ui/identities/top_up_identity_screen/success_screen.rs index 5a7bfe2e5..ff6c6e93a 100644 --- a/src/ui/identities/top_up_identity_screen/success_screen.rs +++ b/src/ui/identities/top_up_identity_screen/success_screen.rs @@ -4,24 +4,14 @@ use egui::Ui; impl TopUpIdentityScreen { pub fn show_success(&self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - - // Center the content vertically and horizontally - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - ui.heading("Successfully topped up!"); - - ui.add_space(20.0); - - // Display the "Back to Identities" button - if ui.button("Back to Identities").clicked() { - // Handle navigation back to the identities screen - action = AppAction::PopScreenAndRefresh; - } - }); - - action + crate::ui::helpers::show_success_screen_with_info( + ui, + "Identity Topped Up Successfully!".to_string(), + vec![( + "Back to Identities".to_string(), + AppAction::PopScreenAndRefresh, + )], + None, + ) } } diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index 5596766e2..65a6bdef6 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -1,8 +1,9 @@ use crate::app::AppAction; -use crate::backend_task::BackendTask; use crate::backend_task::identity::IdentityTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; @@ -14,6 +15,9 @@ use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::{MessageType, Screen, ScreenLike}; +use dash_sdk::dashcore_rpc::dashcore::Address; +use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; +use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -21,16 +25,27 @@ use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::TimestampMillis; use dash_sdk::platform::{Identifier, IdentityPublicKey}; -use eframe::egui::{self, Context, Ui}; +use eframe::egui::{self, Context, Frame, Margin, Ui}; use egui::{Color32, RichText}; +use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser}; - use super::get_selected_wallet; use super::keys::add_key_screen::AddKeyScreen; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::helpers::{TransactionType, add_key_chooser}; +use crate::ui::theme::DashColors; + +/// Transfer destination type +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TransferDestinationType { + #[default] + Identity, + PlatformAddress, +} #[derive(PartialEq)] pub enum TransferCreditsStatus { @@ -54,8 +69,13 @@ pub struct TransferScreen { confirmation_popup: bool, confirmation_dialog: Option, selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, + // Platform address transfer fields + destination_type: TransferDestinationType, + platform_address_input: String, + show_advanced_options: bool, + // Fee result from completed operation + completed_fee_result: Option, } impl TransferScreen { @@ -89,21 +109,22 @@ impl TransferScreen { confirmation_popup: false, confirmation_dialog: None, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), + destination_type: TransferDestinationType::Identity, + platform_address_input: String::new(), + show_advanced_options: false, + completed_fee_result: None, } } - fn render_key_selection(&mut self, ui: &mut Ui) { - let mut selected_identity = Some(self.identity.clone()); - add_identity_key_chooser( + fn render_key_selection(&mut self, ui: &mut Ui) -> AppAction { + add_key_chooser( ui, &self.app_context, - std::iter::once(&self.identity), - &mut selected_identity, + &self.identity, &mut self.selected_key, TransactionType::Transfer, - ); + ) } fn render_amount_input(&mut self, ui: &mut Ui) { @@ -113,7 +134,7 @@ impl TransferScreen { ui.add_space(5.0); // Calculate max amount minus fee for the "Max" button - let max_amount_minus_fee = (self.max_amount as f64 / 100_000_000_000.0 - 0.0001).max(0.0); + let max_amount_minus_fee = (self.max_amount as f64 / 100_000_000_000.0 - 0.0002).max(0.0); let max_amount_credits = (max_amount_minus_fee * 100_000_000_000.0) as u64; let amount_input = self.amount_input.get_or_insert_with(|| { @@ -151,6 +172,167 @@ impl TransferScreen { ); } + fn render_destination_type_selector(&mut self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Colors for selected/unselected states + let selected_fill = DashColors::DASH_BLUE; + let selected_text = Color32::WHITE; + let unselected_fill = if dark_mode { + Color32::from_rgb(60, 60, 60) + } else { + Color32::from_rgb(220, 220, 220) + }; + let unselected_text = DashColors::text_primary(dark_mode); + + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.add_space(5.0); + ui.label("Transfer to:"); + }); + ui.add_space(10.0); + + // Identity button + let identity_selected = self.destination_type == TransferDestinationType::Identity; + let identity_button = egui::Button::new( + RichText::new("Identity") + .color(if identity_selected { + selected_text + } else { + unselected_text + }) + .strong(), + ) + .fill(if identity_selected { + selected_fill + } else { + unselected_fill + }) + .min_size(egui::vec2(120.0, 28.0)); + + if ui.add(identity_button).clicked() { + self.destination_type = TransferDestinationType::Identity; + } + + ui.add_space(5.0); + + // Platform Address button + let platform_selected = + self.destination_type == TransferDestinationType::PlatformAddress; + let platform_button = egui::Button::new( + RichText::new("Platform Address") + .color(if platform_selected { + selected_text + } else { + unselected_text + }) + .strong(), + ) + .fill(if platform_selected { + selected_fill + } else { + unselected_fill + }) + .min_size(egui::vec2(140.0, 28.0)); + + if ui.add(platform_button).clicked() { + self.destination_type = TransferDestinationType::PlatformAddress; + } + }); + } + + fn render_platform_address_input(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Platform Address:"); + ui.add_space(5.0); + ui.add( + egui::TextEdit::singleline(&mut self.platform_address_input) + .hint_text("Enter Platform address (y...)") + .desired_width(400.0), + ); + }); + } + + /// Validate and parse the Platform address + fn validate_platform_address(&self) -> Result { + if self.platform_address_input.is_empty() { + return Err("Platform address is required".to_string()); + } + + let input = self.platform_address_input.trim(); + + // Try to parse as Bech32m Platform address first (DIP-18 format: dashevo1.../tdashevo1...) + if input.starts_with("dashevo1") || input.starts_with("tdashevo1") { + let (addr, _network) = PlatformAddress::from_bech32m_string(input) + .map_err(|e| format!("Invalid Bech32m address: {}", e))?; + return Ok(addr); + } + + // Fall back to base58 parsing for backwards compatibility + let unchecked_addr: Address = input + .parse() + .map_err(|e| format!("Invalid address format: {}", e))?; + + // Platform addresses use the same version byte (0x5a / prefix 'd') for + // testnet, devnet, and regtest per DIP-18. We use assume_checked() here + // because require_network() would fail on regtest (address parses as testnet). + let address = unchecked_addr.assume_checked(); + + PlatformAddress::try_from(address).map_err(|e| format!("Invalid Platform address: {}", e)) + } + + /// Handle the confirmation action for Platform address transfer + fn confirmation_ok_platform_address(&mut self) -> AppAction { + self.confirmation_popup = false; + self.confirmation_dialog = None; + + // Validate Platform address + let platform_address = match self.validate_platform_address() { + Ok(addr) => addr, + Err(error) => { + self.set_error_state(error); + return AppAction::None; + } + }; + + // Validate selected key + let selected_key = match self.selected_key.as_ref() { + Some(key) => key, + None => { + self.set_error_state("No selected key".to_string()); + return AppAction::None; + } + }; + + // Get the amount + let credits = self.amount.as_ref().map(|v| v.value()).unwrap_or_default() as u128; + if credits == 0 { + self.error_message = Some("Amount must be greater than 0".to_string()); + self.transfer_credits_status = + TransferCreditsStatus::ErrorMessage("Amount must be greater than 0".to_string()); + return AppAction::None; + } + + // Set waiting state + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.transfer_credits_status = TransferCreditsStatus::WaitingForResult(now); + + // Build outputs + let mut outputs: BTreeMap = BTreeMap::new(); + outputs.insert(platform_address, credits as Credits); + + AppAction::BackendTask(BackendTask::IdentityTask( + IdentityTask::TransferToAddresses { + identity: self.identity.clone(), + outputs, + key_id: Some(selected_key.id()), + }, + )) + } + /// Handle the confirmation action when user clicks OK fn confirmation_ok(&mut self) -> AppAction { self.confirmation_popup = false; @@ -256,44 +438,64 @@ impl TransferScreen { } } - pub fn show_success(&self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - - // Center the content vertically and horizontally - ui.vertical_centered(|ui| { - ui.add_space(50.0); + fn show_platform_address_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { + // Prepare values before borrowing + let Some(amount) = &self.amount else { + self.set_error_state("Incorrect or empty amount".to_string()); + return AppAction::None; + }; - ui.heading("🎉"); - ui.heading("Success!"); + let platform_address = self.platform_address_input.clone(); - ui.add_space(20.0); + let msg = format!( + "Are you sure you want to transfer {} to Platform address {}?", + amount, platform_address + ); - // Display the "Back to Identities" button - if ui.button("Back to Identities").clicked() { - // Handle navigation back to the identities screen - action = AppAction::PopScreenAndRefresh; - } + // Lazy initialization of the confirmation dialog + let confirmation_dialog = self.confirmation_dialog.get_or_insert_with(|| { + ConfirmationDialog::new("Confirm Transfer to Platform Address", msg) + .confirm_text(Some("Confirm")) + .cancel_text(Some("Cancel")) }); - action + let response = confirmation_dialog.show(ui); + + // Handle the response using the Component pattern + match response.inner.dialog_response { + Some(ConfirmationStatus::Confirmed) => self.confirmation_ok_platform_address(), + Some(ConfirmationStatus::Canceled) => self.confirmation_cancel(), + None => AppAction::None, + } + } + + pub fn show_success(&self, ui: &mut Ui) -> AppAction { + crate::ui::helpers::show_success_screen_with_info( + ui, + "Transfer Successful!".to_string(), + vec![( + "Back to Identities".to_string(), + AppAction::PopScreenAndRefresh, + )], + None, + ) } } impl ScreenLike for TransferScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - if message == "Successfully transferred credits" { - self.transfer_credits_status = TransferCreditsStatus::Complete; - } - } - MessageType::Info => {} - MessageType::Error => { - // It's not great because the error message can be coming from somewhere else if there are other processes happening - self.transfer_credits_status = - TransferCreditsStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); - } + if let MessageType::Error = message_type { + self.transfer_credits_status = TransferCreditsStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::TransferredCredits(fee_result) = + backend_task_success_result + { + self.completed_fee_result = Some(fee_result); + self.transfer_credits_status = TransferCreditsStatus::Complete; } } @@ -336,9 +538,6 @@ impl ScreenLike for TransferScreen { return inner_action; } - ui.heading("Transfer Funds"); - ui.add_space(10.0); - let has_keys = if self.app_context.is_developer_mode() { !self.identity.identity.public_keys().is_empty() } else { @@ -382,58 +581,140 @@ impl ScreenLike for TransferScreen { ))); } } else { - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - - if needed_unlock && !just_unlocked { + if self.selected_wallet.is_some() + && let Some(wallet) = &self.selected_wallet + { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } return inner_action; } } - // Select the key to sign with - ui.heading("1. Select the key to sign the transaction with"); - ui.add_space(10.0); + // Heading with checkbox on the same line ui.horizontal(|ui| { - self.render_key_selection(ui); - ui.add_space(5.0); - let identity_id_string = - self.identity.identity.id().to_string(Encoding::Base58); - let identity_display = self - .identity - .alias - .as_deref() - .unwrap_or_else(|| &identity_id_string); - ui.label(format!("Identity: {}", identity_display)); + ui.heading("Transfer Funds"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Show Advanced Options"); + }); }); - - ui.add_space(10.0); - ui.separator(); ui.add_space(10.0); // Input the amount to transfer - ui.heading("2. Input the amount to transfer"); + ui.heading("1. Input the amount to transfer"); + ui.add_space(5.0); + + // Show identity info + let identity_id_string = self.identity.identity.id().to_string(Encoding::Base58); + let identity_label = if let Some(alias) = &self.identity.alias { + format!("From: {} ({})", alias, identity_id_string) + } else { + format!("From: {}", identity_id_string) + }; + ui.label(identity_label); ui.add_space(5.0); + self.render_amount_input(ui); ui.add_space(10.0); ui.separator(); ui.add_space(10.0); - // Input the ID of the identity to transfer to - ui.heading("3. ID of the identity to transfer to"); + // Destination type selector + ui.heading("2. Select transfer destination type"); ui.add_space(5.0); - self.render_to_identity_input(ui); + self.render_destination_type_selector(ui); ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Input the destination based on type + match self.destination_type { + TransferDestinationType::Identity => { + ui.heading("3. ID of the identity to transfer to"); + ui.add_space(5.0); + self.render_to_identity_input(ui); + } + TransferDestinationType::PlatformAddress => { + ui.heading("3. Platform address to transfer to"); + ui.add_space(5.0); + self.render_platform_address_input(ui); + } + } + + // Select the key to sign with (only in advanced mode) + if self.show_advanced_options { + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + ui.heading("4. Select the key to sign the transaction with"); + ui.add_space(10.0); + inner_action |= self.render_key_selection(ui); + } + + ui.add_space(10.0); + + // Fee estimation + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = match self.destination_type { + TransferDestinationType::Identity => fee_estimator.estimate_credit_transfer(), + TransferDestinationType::PlatformAddress => { + // Platform address transfer has output cost + fee_estimator.estimate_credit_transfer_to_addresses(1) + } + }; + + // Display estimated fee + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + + ui.add_space(10.0); + + // Transfer button - check readiness based on destination type + let has_enough_balance = self.identity.identity.balance() > estimated_fee; - // Transfer button let ready = self.amount.is_some() - && !self.receiver_identity_id.is_empty() && self.selected_key.is_some() + && has_enough_balance && !matches!( self.transfer_credits_status, TransferCreditsStatus::WaitingForResult(_), - ); + ) + && match self.destination_type { + TransferDestinationType::Identity => !self.receiver_identity_id.is_empty(), + TransferDestinationType::PlatformAddress => { + !self.platform_address_input.is_empty() + } + }; let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); ui.set_style(new_style); @@ -441,16 +722,33 @@ impl ScreenLike for TransferScreen { .fill(Color32::from_rgb(0, 128, 255)) .frame(true) .corner_radius(3.0); + + let hover_text = if !has_enough_balance { + format!( + "Insufficient balance for transfer fee (need at least {})", + format_credits_as_dash(estimated_fee) + ) + } else if ready { + "Transfer credits to another identity or Platform address".to_string() + } else { + "Please ensure all fields are filled correctly".to_string() + }; + if ui .add_enabled(ready, button) - .on_disabled_hover_text("Please ensure all fields are filled correctly") + .on_hover_text(hover_text) .clicked() { self.confirmation_popup = true; } if self.confirmation_popup { - inner_action |= self.show_confirmation_popup(ui); + inner_action |= match self.destination_type { + TransferDestinationType::Identity => self.show_confirmation_popup(ui), + TransferDestinationType::PlatformAddress => { + self.show_platform_address_confirmation_popup(ui) + } + }; } // Handle transfer status messages @@ -490,7 +788,25 @@ impl ScreenLike for TransferScreen { )); } TransferCreditsStatus::ErrorMessage(msg) => { - ui.colored_label(egui::Color32::RED, format!("Error: {}", msg)); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = msg.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", msg)).color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.transfer_credits_status = + TransferCreditsStatus::NotStarted; + } + }); + }); } TransferCreditsStatus::Complete => { // Handled above @@ -500,36 +816,19 @@ impl ScreenLike for TransferScreen { inner_action }); - action - } -} - -impl ScreenWithWalletUnlock for TransferScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index 0be9a7f0d..6df01e945 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -1,8 +1,9 @@ use crate::app::AppAction; -use crate::backend_task::BackendTask; use crate::backend_task::identity::IdentityTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::model::qualified_identity::encrypted_key_storage::PrivateKeyData; use crate::model::qualified_identity::{IdentityType, PrivateKeyTarget, QualifiedIdentity}; use crate::model::wallet::Wallet; @@ -11,9 +12,12 @@ use crate::ui::components::confirmation_dialog::{ConfirmationDialog, Confirmatio use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; use crate::ui::components::{Component, ComponentResponse}; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser}; +use crate::ui::helpers::{TransactionType, add_key_chooser}; +use crate::ui::theme::DashColors; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dpp::fee::Credits; @@ -23,7 +27,7 @@ use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::TimestampMillis; use dash_sdk::platform::IdentityPublicKey; -use eframe::egui::{self, Context, Ui}; +use eframe::egui::{self, Context, Frame, Margin, Ui}; use egui::{Color32, RichText}; use std::str::FromStr; use std::sync::{Arc, RwLock}; @@ -45,6 +49,7 @@ pub struct WithdrawalScreen { pub identity: QualifiedIdentity, selected_key: Option, withdrawal_address: String, + withdrawal_address_error: Option, withdrawal_amount: Option, withdrawal_amount_input: Option, max_amount: u64, @@ -52,9 +57,11 @@ pub struct WithdrawalScreen { confirmation_dialog: Option, withdraw_from_identity_status: WithdrawFromIdentityStatus, selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, error_message: Option, + show_advanced_options: bool, + // Fee result from completed operation + completed_fee_result: Option, } impl WithdrawalScreen { @@ -74,6 +81,7 @@ impl WithdrawalScreen { identity, selected_key: selected_key.cloned(), withdrawal_address: String::new(), + withdrawal_address_error: None, withdrawal_amount: None, withdrawal_amount_input: None, max_amount, @@ -81,26 +89,25 @@ impl WithdrawalScreen { confirmation_dialog: None, withdraw_from_identity_status: WithdrawFromIdentityStatus::NotStarted, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), error_message, + show_advanced_options: false, + completed_fee_result: None, } } - fn render_key_selection(&mut self, ui: &mut Ui) { - let mut selected_identity = Some(self.identity.clone()); - add_identity_key_chooser( + fn render_key_selection(&mut self, ui: &mut Ui) -> AppAction { + add_key_chooser( ui, &self.app_context, - std::iter::once(&self.identity), - &mut selected_identity, + &self.identity, &mut self.selected_key, TransactionType::Withdraw, - ); + ) } fn render_amount_input(&mut self, ui: &mut Ui) { - let max_amount_minus_fee = (self.max_amount as f64 / 100_000_000_000.0 - 0.0001).max(0.0); + let max_amount_minus_fee = (self.max_amount as f64 / 100_000_000_000.0 - 0.005).max(0.0); let max_amount_credits = (max_amount_minus_fee * 100_000_000_000.0) as u64; // Lazy initialization with basic configuration @@ -137,7 +144,28 @@ impl WithdrawalScreen { ui.horizontal(|ui| { ui.label("Address:"); - ui.text_edit_singleline(&mut self.withdrawal_address); + let response = ui.text_edit_singleline(&mut self.withdrawal_address); + + // Validate address when it changes + if response.changed() { + if self.withdrawal_address.is_empty() { + self.withdrawal_address_error = None; + } else { + match Address::from_str(&self.withdrawal_address) { + Ok(_) => { + self.withdrawal_address_error = None; + } + Err(_) => { + self.withdrawal_address_error = Some("Invalid address".to_string()); + } + } + } + } + + // Show error next to input + if let Some(error) = &self.withdrawal_address_error { + ui.colored_label(Color32::from_rgb(255, 100, 100), error); + } }); } else { ui.label(format!( @@ -160,9 +188,8 @@ impl WithdrawalScreen { match Address::from_str(&self.withdrawal_address) { Ok(address) => Some(address.assume_checked()), Err(_) => { - self.withdraw_from_identity_status = WithdrawFromIdentityStatus::ErrorMessage( - "Invalid withdrawal address".to_string(), - ); + // Error is already shown next to the input field + self.withdrawal_address_error = Some("Invalid address".to_string()); self.confirmation_dialog = None; return AppAction::None; } @@ -242,42 +269,32 @@ impl WithdrawalScreen { } pub fn show_success(&self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - - // Center the content vertically and horizontally - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - ui.heading("Successfully withdrew from identity"); - - ui.add_space(20.0); - - // Display the "Back to Identities" button - if ui.button("Back to Identities").clicked() { - // Handle navigation back to the identities screen - action = AppAction::PopScreenAndRefresh; - } - }); - - action + crate::ui::helpers::show_success_screen_with_info( + ui, + "Withdrawal Successful!\n\nNote: It may take a few minutes for funds to appear on the Core chain.".to_string(), + vec![( + "Back to Identities".to_string(), + AppAction::PopScreenAndRefresh, + )], + None, + ) } } impl ScreenLike for WithdrawalScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - if message == "Successfully withdrew from identity" { - self.withdraw_from_identity_status = WithdrawFromIdentityStatus::Complete; - } - } - MessageType::Info => {} - MessageType::Error => { - // It's not great because the error message can be coming from somewhere else if there are other processes happening - self.withdraw_from_identity_status = - WithdrawFromIdentityStatus::ErrorMessage(message.to_string()); - } + if let MessageType::Error = message_type { + self.withdraw_from_identity_status = + WithdrawFromIdentityStatus::ErrorMessage(message.to_string()); + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::WithdrewFromIdentity(fee_result) = + backend_task_success_result + { + self.completed_fee_result = Some(fee_result); + self.withdraw_from_identity_status = WithdrawFromIdentityStatus::Complete; } } @@ -320,7 +337,13 @@ impl ScreenLike for WithdrawalScreen { return inner_action; } - ui.heading("Withdraw Funds"); + // Heading with checkbox on the same line + ui.horizontal(|ui| { + ui.heading("Withdraw Funds"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Show Advanced Options"); + }); + }); ui.add_space(10.0); let has_keys = if self.app_context.is_developer_mode() { @@ -395,22 +418,6 @@ impl ScreenLike for WithdrawalScreen { ))); } } else { - // Select the key to sign with - ui.heading("1. Select the key to sign with"); - ui.add_space(10.0); - ui.horizontal(|ui| { - self.render_key_selection(ui); - ui.add_space(5.0); - let identity_id_string = - self.identity.identity.id().to_string(Encoding::Base58); - let identity_display = self - .identity - .alias - .as_deref() - .unwrap_or_else(|| &identity_id_string); - ui.label(format!("Identity: {}", identity_display)); - }); - // Render wallet unlock component if needed if let Some(selected_key) = self.selected_key.as_ref() { // If there is an associated wallet then render the wallet unlock component for it if its locked @@ -427,35 +434,96 @@ impl ScreenLike for WithdrawalScreen { .get(&wallet_derivation_path.wallet_seed_hash) .cloned(); - let (needed_unlock, just_unlocked) = - self.render_wallet_unlock_if_needed(ui); - - if needed_unlock && !just_unlocked { - return inner_action; + if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + return inner_action; + } } } } else { return inner_action; } - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); + // Input the amount to withdraw + ui.heading("1. Amount to withdraw (Dash)"); + ui.add_space(5.0); + + // Show identity info + let identity_id_string = self.identity.identity.id().to_string(Encoding::Base58); + let identity_label = if let Some(alias) = &self.identity.alias { + format!("From: {} ({})", alias, identity_id_string) + } else { + format!("From: {}", identity_id_string) + }; + ui.label(identity_label); - // Input the amount to transfer - ui.heading("2. Input the amount to withdraw"); + // Display available balance + let balance_dash = self.max_amount as f64 / 100_000_000_000.0; + ui.horizontal(|ui| { + ui.label("Available Balance:"); + ui.label(RichText::new(format!("{:.4} Dash", balance_dash))); + }); ui.add_space(5.0); + self.render_amount_input(ui); ui.add_space(10.0); ui.separator(); ui.add_space(10.0); - // Input the ID of the identity to transfer to - ui.heading("3. Dash address to withdraw to"); + // Input the address to withdraw to + ui.heading("2. Dash address to withdraw to"); ui.add_space(5.0); self.render_address_input(ui); + // Only show key selection in advanced mode + if self.show_advanced_options { + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + ui.heading("3. Select the key to sign with"); + inner_action |= self.render_key_selection(ui); + } + + ui.add_space(10.0); + + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_credit_withdrawal(); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + ui.add_space(10.0); // Withdraw button @@ -466,11 +534,27 @@ impl ScreenLike for WithdrawalScreen { .corner_radius(3.0) .min_size(egui::vec2(60.0, 30.0)); - let ready = self.withdrawal_amount.as_ref().is_some(); + let has_valid_amount = self.withdrawal_amount.is_some(); + let has_address_error = self.withdrawal_address_error.is_some(); + let has_enough_balance = self.max_amount > estimated_fee; + let ready = has_valid_amount && !has_address_error && has_enough_balance; + + let hover_text = if !has_valid_amount { + "Please enter a valid amount to withdraw".to_string() + } else if has_address_error { + "Please enter a valid withdrawal address".to_string() + } else if !has_enough_balance { + format!( + "Insufficient balance for withdrawal fee (need at least {})", + format_credits_as_dash(estimated_fee) + ) + } else { + String::new() + }; if ui .add_enabled(ready, button) - .on_disabled_hover_text("Please enter a valid amount to withdraw") + .on_disabled_hover_text(&hover_text) .clicked() && self.confirmation_dialog.is_none() { @@ -520,7 +604,25 @@ impl ScreenLike for WithdrawalScreen { )); } WithdrawFromIdentityStatus::ErrorMessage(msg) => { - ui.colored_label(egui::Color32::RED, format!("Error: {}", msg)); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = msg.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", msg)).color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.withdraw_from_identity_status = + WithdrawFromIdentityStatus::NotStarted; + } + }); + }); } WithdrawFromIdentityStatus::Complete => { ui.colored_label( @@ -529,46 +631,23 @@ impl ScreenLike for WithdrawalScreen { ); } } - - if let WithdrawFromIdentityStatus::ErrorMessage(ref error_message) = - self.withdraw_from_identity_status - { - ui.label(format!("Error: {}", error_message)); - } } inner_action }); - action - } -} - -impl ScreenWithWalletUnlock for WithdrawalScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index db8579d9f..80098415f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -5,10 +5,20 @@ use crate::model::qualified_identity::QualifiedIdentity; use crate::model::qualified_identity::encrypted_key_storage::{ PrivateKeyData, WalletDerivationPath, }; +use crate::model::wallet::Wallet; +use crate::model::wallet::single_key::SingleKeyWallet; use crate::ui::contracts_documents::contracts_documents_screen::DocumentQueryScreen; use crate::ui::contracts_documents::document_action_screen::{ DocumentActionScreen, DocumentActionType, }; +use crate::ui::dashpay::add_contact_screen::AddContactScreen; +use crate::ui::dashpay::contact_details::ContactDetailsScreen; +use crate::ui::dashpay::contact_info_editor::ContactInfoEditorScreen; +use crate::ui::dashpay::contact_profile_viewer::ContactProfileViewerScreen; +use crate::ui::dashpay::profile_search::ProfileSearchScreen; +use crate::ui::dashpay::qr_code_generator::QRCodeGeneratorScreen; +use crate::ui::dashpay::send_payment::SendPaymentScreen; +use crate::ui::dashpay::{DashPayScreen, DashPaySubscreen}; use crate::ui::dpns::dpns_contested_names_screen::DPNSScreen; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; @@ -21,6 +31,7 @@ use crate::ui::tokens::add_token_by_id_screen::AddTokenByIdScreen; use crate::ui::tokens::tokens_screen::{IdentityTokenBasicInfo, IdentityTokenInfo}; use crate::ui::tokens::transfer_tokens_screen::TransferTokensScreen; use crate::ui::tokens::view_token_claims_screen::ViewTokenClaimsScreen; +use crate::ui::tools::address_balance_screen::AddressBalanceScreen; use crate::ui::tools::contract_visualizer_screen::ContractVisualizerScreen; use crate::ui::tools::document_visualizer_screen::DocumentVisualizerScreen; use crate::ui::tools::grovestark_screen::GroveSTARKScreen; @@ -28,24 +39,27 @@ use crate::ui::tools::masternode_list_diff_screen::MasternodeListDiffScreen; use crate::ui::tools::platform_info_screen::PlatformInfoScreen; use crate::ui::tools::proof_log_screen::ProofLogScreen; use crate::ui::tools::proof_visualizer_screen::ProofVisualizerScreen; -use crate::ui::wallets::import_wallet_screen::ImportWalletScreen; +use crate::ui::wallets::import_mnemonic_screen::ImportMnemonicScreen; +use crate::ui::wallets::send_screen::WalletSendScreen; +use crate::ui::wallets::single_key_send_screen::SingleKeyWalletSendScreen; use crate::ui::wallets::wallets_screen::WalletsBalancesScreen; use contracts_documents::add_contracts_screen::AddContractsScreen; -use contracts_documents::dashpay_coming_soon_screen::DashpayScreen; use contracts_documents::group_actions_screen::GroupActionsScreen; use contracts_documents::register_contract_screen::RegisterDataContractScreen; use contracts_documents::update_contract_screen::UpdateDataContractScreen; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::prelude::IdentityPublicKey; +use dash_sdk::platform::Identifier; use dpns::dpns_contested_names_screen::DPNSSubscreen; use egui::Context; use identities::add_existing_identity_screen::AddExistingIdentityScreen; use identities::add_new_identity_screen::AddNewIdentityScreen; use identities::identities_screen::IdentitiesScreen; -use identities::register_dpns_name_screen::RegisterDpnsNameScreen; +use identities::register_dpns_name_screen::{RegisterDpnsNameScreen, RegisterDpnsNameSource}; use std::fmt; use std::hash::Hash; use std::sync::Arc; +use std::sync::RwLock; use tokens::burn_tokens_screen::BurnTokensScreen; use tokens::claim_tokens_screen::ClaimTokensScreen; use tokens::destroy_frozen_funds_screen::DestroyFrozenFundsScreen; @@ -63,6 +77,7 @@ use wallets::add_new_wallet_screen::AddNewWalletScreen; pub mod components; pub mod contracts_documents; +pub mod dashpay; pub mod dpns; pub mod helpers; pub(crate) mod identities; @@ -71,6 +86,7 @@ pub mod theme; pub mod tokens; pub mod tools; pub(crate) mod wallets; +pub mod welcome_screen; #[derive(Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq, Hash)] #[allow(clippy::enum_variant_names)] @@ -93,7 +109,12 @@ pub enum RootScreenType { RootScreenToolsMasternodeListDiffScreen, RootScreenToolsContractVisualizerScreen, RootScreenToolsPlatformInfoScreen, + RootScreenDashPayContacts, + RootScreenDashPayProfile, + RootScreenDashPayPayments, + RootScreenDashPayProfileSearch, RootScreenToolsGroveSTARKScreen, + RootScreenToolsAddressBalanceScreen, RootScreenDashpay, } @@ -119,9 +140,15 @@ impl RootScreenType { RootScreenType::RootScreenToolsDocumentVisualizerScreen => 15, RootScreenType::RootScreenToolsContractVisualizerScreen => 16, RootScreenType::RootScreenToolsPlatformInfoScreen => 17, - RootScreenType::RootScreenToolsMasternodeListDiffScreen => 18, - RootScreenType::RootScreenDashpay => 19, - RootScreenType::RootScreenToolsGroveSTARKScreen => 20, + RootScreenType::RootScreenDashPayContacts => 18, + // 19 used to be RootScreenDashPayRequests (now consolidated into Contacts) + RootScreenType::RootScreenDashPayProfile => 20, + RootScreenType::RootScreenDashPayPayments => 21, + RootScreenType::RootScreenDashPayProfileSearch => 22, + RootScreenType::RootScreenToolsMasternodeListDiffScreen => 23, + RootScreenType::RootScreenDashpay => 24, + RootScreenType::RootScreenToolsGroveSTARKScreen => 25, + RootScreenType::RootScreenToolsAddressBalanceScreen => 26, } } @@ -146,9 +173,15 @@ impl RootScreenType { 15 => Some(RootScreenType::RootScreenToolsDocumentVisualizerScreen), 16 => Some(RootScreenType::RootScreenToolsContractVisualizerScreen), 17 => Some(RootScreenType::RootScreenToolsPlatformInfoScreen), - 18 => Some(RootScreenType::RootScreenToolsMasternodeListDiffScreen), - 19 => Some(RootScreenType::RootScreenDashpay), - 20 => Some(RootScreenType::RootScreenToolsGroveSTARKScreen), + 18 => Some(RootScreenType::RootScreenDashPayContacts), + // 19 used to be RootScreenDashPayRequests (now consolidated into Contacts) + 20 => Some(RootScreenType::RootScreenDashPayProfile), + 21 => Some(RootScreenType::RootScreenDashPayPayments), + 22 => Some(RootScreenType::RootScreenDashPayProfileSearch), + 23 => Some(RootScreenType::RootScreenToolsMasternodeListDiffScreen), + 24 => Some(RootScreenType::RootScreenDashpay), + 25 => Some(RootScreenType::RootScreenToolsGroveSTARKScreen), + 26 => Some(RootScreenType::RootScreenToolsAddressBalanceScreen), _ => None, } } @@ -183,13 +216,18 @@ impl From for ScreenType { ScreenType::ContractsVisualizer } RootScreenType::RootScreenToolsPlatformInfoScreen => ScreenType::PlatformInfo, + RootScreenType::RootScreenDashPayContacts => ScreenType::DashPayContacts, + RootScreenType::RootScreenDashPayProfile => ScreenType::DashPayProfile, + RootScreenType::RootScreenDashPayPayments => ScreenType::DashPayPayments, + RootScreenType::RootScreenDashPayProfileSearch => ScreenType::DashPayProfileSearch, RootScreenType::RootScreenToolsGroveSTARKScreen => ScreenType::GroveSTARK, + RootScreenType::RootScreenToolsAddressBalanceScreen => ScreenType::AddressBalance, RootScreenType::RootScreenDashpay => ScreenType::Dashpay, } } } -#[derive(Debug, PartialEq, Clone, Default)] +#[derive(Debug, Clone, Default)] pub enum ScreenType { #[default] Identities, @@ -198,8 +236,10 @@ pub enum ScreenType { DPNSMyUsernames, AddNewIdentity, WalletsBalances, - ImportWallet, + ImportMnemonic, AddNewWallet, + WalletSendScreen(Arc>), + SingleKeyWalletSendScreen(Arc>), AddExistingIdentity, TransitionVisualizer, WithdrawalScreen(QualifiedIdentity), @@ -213,7 +253,7 @@ pub enum ScreenType { Keys(Identity), DocumentQuery, NetworkChooser, - RegisterDpnsName, + RegisterDpnsName(RegisterDpnsNameSource), RegisterContract, UpdateContract, ProofLog, @@ -226,6 +266,7 @@ pub enum ScreenType { ContractsVisualizer, PlatformInfo, GroveSTARK, + AddressBalance, Dashpay, CreateDocument, DeleteDocument, @@ -253,6 +294,121 @@ pub enum ScreenType { UpdateTokenConfigScreen(IdentityTokenInfo), PurchaseTokenScreen(IdentityTokenInfo), SetTokenPriceScreen(IdentityTokenInfo), + + // DashPay Screens + DashPayContacts, + DashPayProfile, + DashPayPayments, + DashPayAddContact, + DashPayAddContactWithId(String), // Pre-populated identity ID + DashPayContactDetails(QualifiedIdentity, Identifier), + DashPayContactProfileViewer(QualifiedIdentity, Identifier), + DashPaySendPayment(QualifiedIdentity, Identifier), + DashPayContactInfoEditor(QualifiedIdentity, Identifier), + DashPayQRGenerator, + DashPayProfileSearch, +} + +impl PartialEq for ScreenType { + fn eq(&self, other: &Self) -> bool { + // Compare variants, ignoring Arc> contents for WalletSendScreen + match (self, other) { + (ScreenType::WalletSendScreen(_), ScreenType::WalletSendScreen(_)) => true, + ( + ScreenType::SingleKeyWalletSendScreen(_), + ScreenType::SingleKeyWalletSendScreen(_), + ) => true, + (ScreenType::Identities, ScreenType::Identities) => true, + (ScreenType::DPNSActiveContests, ScreenType::DPNSActiveContests) => true, + (ScreenType::DPNSPastContests, ScreenType::DPNSPastContests) => true, + (ScreenType::DPNSMyUsernames, ScreenType::DPNSMyUsernames) => true, + (ScreenType::AddNewIdentity, ScreenType::AddNewIdentity) => true, + (ScreenType::WalletsBalances, ScreenType::WalletsBalances) => true, + (ScreenType::ImportMnemonic, ScreenType::ImportMnemonic) => true, + (ScreenType::AddNewWallet, ScreenType::AddNewWallet) => true, + (ScreenType::AddExistingIdentity, ScreenType::AddExistingIdentity) => true, + (ScreenType::TransitionVisualizer, ScreenType::TransitionVisualizer) => true, + (ScreenType::WithdrawalScreen(a), ScreenType::WithdrawalScreen(b)) => a == b, + (ScreenType::TransferScreen(a), ScreenType::TransferScreen(b)) => a == b, + (ScreenType::AddKeyScreen(a), ScreenType::AddKeyScreen(b)) => a == b, + (ScreenType::KeyInfo(a1, a2, a3), ScreenType::KeyInfo(b1, b2, b3)) => { + a1 == b1 && a2 == b2 && a3 == b3 + } + (ScreenType::Keys(a), ScreenType::Keys(b)) => a == b, + (ScreenType::DocumentQuery, ScreenType::DocumentQuery) => true, + (ScreenType::NetworkChooser, ScreenType::NetworkChooser) => true, + (ScreenType::RegisterDpnsName(a), ScreenType::RegisterDpnsName(b)) => a == b, + (ScreenType::RegisterContract, ScreenType::RegisterContract) => true, + (ScreenType::UpdateContract, ScreenType::UpdateContract) => true, + (ScreenType::ProofLog, ScreenType::ProofLog) => true, + (ScreenType::MasternodeListDiff, ScreenType::MasternodeListDiff) => true, + (ScreenType::TopUpIdentity(a), ScreenType::TopUpIdentity(b)) => a == b, + (ScreenType::ScheduledVotes, ScreenType::ScheduledVotes) => true, + (ScreenType::AddContracts, ScreenType::AddContracts) => true, + (ScreenType::ProofVisualizer, ScreenType::ProofVisualizer) => true, + (ScreenType::DocumentsVisualizer, ScreenType::DocumentsVisualizer) => true, + (ScreenType::ContractsVisualizer, ScreenType::ContractsVisualizer) => true, + (ScreenType::PlatformInfo, ScreenType::PlatformInfo) => true, + (ScreenType::GroveSTARK, ScreenType::GroveSTARK) => true, + (ScreenType::AddressBalance, ScreenType::AddressBalance) => true, + (ScreenType::Dashpay, ScreenType::Dashpay) => true, + (ScreenType::CreateDocument, ScreenType::CreateDocument) => true, + (ScreenType::DeleteDocument, ScreenType::DeleteDocument) => true, + (ScreenType::ReplaceDocument, ScreenType::ReplaceDocument) => true, + (ScreenType::TransferDocument, ScreenType::TransferDocument) => true, + (ScreenType::PurchaseDocument, ScreenType::PurchaseDocument) => true, + (ScreenType::SetDocumentPrice, ScreenType::SetDocumentPrice) => true, + (ScreenType::GroupActions, ScreenType::GroupActions) => true, + // Token Screens + (ScreenType::TokenBalances, ScreenType::TokenBalances) => true, + (ScreenType::TokenSearch, ScreenType::TokenSearch) => true, + (ScreenType::TokenCreator, ScreenType::TokenCreator) => true, + (ScreenType::AddTokenById, ScreenType::AddTokenById) => true, + (ScreenType::TransferTokensScreen(a), ScreenType::TransferTokensScreen(b)) => a == b, + (ScreenType::MintTokensScreen(a), ScreenType::MintTokensScreen(b)) => a == b, + (ScreenType::BurnTokensScreen(a), ScreenType::BurnTokensScreen(b)) => a == b, + (ScreenType::DestroyFrozenFundsScreen(a), ScreenType::DestroyFrozenFundsScreen(b)) => { + a == b + } + (ScreenType::FreezeTokensScreen(a), ScreenType::FreezeTokensScreen(b)) => a == b, + (ScreenType::UnfreezeTokensScreen(a), ScreenType::UnfreezeTokensScreen(b)) => a == b, + (ScreenType::PauseTokensScreen(a), ScreenType::PauseTokensScreen(b)) => a == b, + (ScreenType::ResumeTokensScreen(a), ScreenType::ResumeTokensScreen(b)) => a == b, + (ScreenType::ClaimTokensScreen(a), ScreenType::ClaimTokensScreen(b)) => a == b, + (ScreenType::ViewTokenClaimsScreen(a), ScreenType::ViewTokenClaimsScreen(b)) => a == b, + (ScreenType::UpdateTokenConfigScreen(a), ScreenType::UpdateTokenConfigScreen(b)) => { + a == b + } + (ScreenType::PurchaseTokenScreen(a), ScreenType::PurchaseTokenScreen(b)) => a == b, + (ScreenType::SetTokenPriceScreen(a), ScreenType::SetTokenPriceScreen(b)) => a == b, + // DashPay Screens + (ScreenType::DashPayContacts, ScreenType::DashPayContacts) => true, + (ScreenType::DashPayProfile, ScreenType::DashPayProfile) => true, + (ScreenType::DashPayPayments, ScreenType::DashPayPayments) => true, + (ScreenType::DashPayAddContact, ScreenType::DashPayAddContact) => true, + (ScreenType::DashPayAddContactWithId(a), ScreenType::DashPayAddContactWithId(b)) => { + a == b + } + ( + ScreenType::DashPayContactDetails(a1, a2), + ScreenType::DashPayContactDetails(b1, b2), + ) => a1 == b1 && a2 == b2, + ( + ScreenType::DashPayContactProfileViewer(a1, a2), + ScreenType::DashPayContactProfileViewer(b1, b2), + ) => a1 == b1 && a2 == b2, + (ScreenType::DashPaySendPayment(a1, a2), ScreenType::DashPaySendPayment(b1, b2)) => { + a1 == b1 && a2 == b2 + } + ( + ScreenType::DashPayContactInfoEditor(a1, a2), + ScreenType::DashPayContactInfoEditor(b1, b2), + ) => a1 == b1 && a2 == b2, + (ScreenType::DashPayQRGenerator, ScreenType::DashPayQRGenerator) => true, + (ScreenType::DashPayProfileSearch, ScreenType::DashPayProfileSearch) => true, + _ => false, + } + } } impl ScreenType { @@ -288,8 +444,8 @@ impl ScreenType { app_context, )) } - ScreenType::RegisterDpnsName => { - Screen::RegisterDpnsNameScreen(RegisterDpnsNameScreen::new(app_context)) + ScreenType::RegisterDpnsName(source) => { + Screen::RegisterDpnsNameScreen(RegisterDpnsNameScreen::new(app_context, *source)) } ScreenType::RegisterContract => { Screen::RegisterDataContractScreen(RegisterDataContractScreen::new(app_context)) @@ -321,9 +477,15 @@ impl ScreenType { ScreenType::WalletsBalances => { Screen::WalletsBalancesScreen(WalletsBalancesScreen::new(app_context)) } - ScreenType::ImportWallet => { - Screen::ImportWalletScreen(ImportWalletScreen::new(app_context)) + ScreenType::ImportMnemonic => { + Screen::ImportMnemonicScreen(ImportMnemonicScreen::new(app_context)) + } + ScreenType::WalletSendScreen(wallet) => { + Screen::WalletSendScreen(WalletSendScreen::new(app_context, wallet.clone())) } + ScreenType::SingleKeyWalletSendScreen(wallet) => Screen::SingleKeyWalletSendScreen( + SingleKeyWalletSendScreen::new(app_context, wallet.clone()), + ), ScreenType::ProofLog => Screen::ProofLogScreen(ProofLogScreen::new(app_context)), ScreenType::ScheduledVotes => { Screen::DPNSScreen(DPNSScreen::new(app_context, DPNSSubscreen::ScheduledVotes)) @@ -344,7 +506,12 @@ impl ScreenType { Screen::PlatformInfoScreen(PlatformInfoScreen::new(app_context)) } ScreenType::GroveSTARK => Screen::GroveSTARKScreen(GroveSTARKScreen::new(app_context)), - ScreenType::Dashpay => Screen::DashpayScreen(DashpayScreen::new(app_context)), + ScreenType::AddressBalance => { + Screen::AddressBalanceScreen(AddressBalanceScreen::new(app_context)) + } + ScreenType::Dashpay => { + Screen::DashPayScreen(DashPayScreen::new(app_context, DashPaySubscreen::Profile)) + } ScreenType::CreateDocument => Screen::DocumentActionScreen(DocumentActionScreen::new( app_context.clone(), None, @@ -437,18 +604,68 @@ impl ScreenType { ScreenType::SetTokenPriceScreen(identity_token_info) => Screen::SetTokenPriceScreen( SetTokenPriceScreen::new(identity_token_info.clone(), app_context), ), + + // DashPay Screens + ScreenType::DashPayContacts => { + Screen::DashPayScreen(DashPayScreen::new(app_context, DashPaySubscreen::Contacts)) + } + ScreenType::DashPayProfile => { + Screen::DashPayScreen(DashPayScreen::new(app_context, DashPaySubscreen::Profile)) + } + ScreenType::DashPayPayments => { + Screen::DashPayScreen(DashPayScreen::new(app_context, DashPaySubscreen::Payments)) + } + ScreenType::DashPayAddContact => { + Screen::DashPayAddContactScreen(AddContactScreen::new(app_context.clone())) + } + ScreenType::DashPayAddContactWithId(identity_id) => Screen::DashPayAddContactScreen( + AddContactScreen::new_with_identity_id(app_context.clone(), identity_id.clone()), + ), + ScreenType::DashPayContactDetails(identity, contact_id) => { + Screen::DashPayContactDetailsScreen(ContactDetailsScreen::new( + app_context.clone(), + identity.clone(), + *contact_id, + )) + } + ScreenType::DashPayContactProfileViewer(identity, contact_id) => { + Screen::DashPayContactProfileViewerScreen(ContactProfileViewerScreen::new( + app_context.clone(), + identity.clone(), + *contact_id, + )) + } + ScreenType::DashPaySendPayment(identity, contact_id) => { + Screen::DashPaySendPaymentScreen(SendPaymentScreen::new( + app_context.clone(), + identity.clone(), + *contact_id, + )) + } + ScreenType::DashPayContactInfoEditor(identity, contact_id) => { + Screen::DashPayContactInfoEditorScreen(ContactInfoEditorScreen::new( + app_context.clone(), + identity.clone(), + *contact_id, + )) + } + ScreenType::DashPayQRGenerator => { + Screen::DashPayQRGeneratorScreen(QRCodeGeneratorScreen::new(app_context.clone())) + } + ScreenType::DashPayProfileSearch => { + Screen::DashPayProfileSearchScreen(ProfileSearchScreen::new(app_context.clone())) + } } } } -#[allow(clippy::enum_variant_names)] +#[allow(clippy::enum_variant_names, clippy::large_enum_variant)] pub enum Screen { IdentitiesScreen(IdentitiesScreen), DPNSScreen(DPNSScreen), DocumentQueryScreen(DocumentQueryScreen), - DashpayScreen(DashpayScreen), AddNewWalletScreen(AddNewWalletScreen), - ImportWalletScreen(ImportWalletScreen), + ImportMnemonicScreen(ImportMnemonicScreen), AddNewIdentityScreen(AddNewIdentityScreen), AddExistingIdentityScreen(AddExistingIdentityScreen), KeyInfoScreen(KeyInfoScreen), @@ -468,11 +685,14 @@ pub enum Screen { ContractVisualizerScreen(ContractVisualizerScreen), NetworkChooserScreen(NetworkChooserScreen), WalletsBalancesScreen(WalletsBalancesScreen), + WalletSendScreen(WalletSendScreen), + SingleKeyWalletSendScreen(SingleKeyWalletSendScreen), AddContractsScreen(AddContractsScreen), ProofVisualizerScreen(ProofVisualizerScreen), MasternodeListDiffScreen(MasternodeListDiffScreen), PlatformInfoScreen(PlatformInfoScreen), GroveSTARKScreen(GroveSTARKScreen), + AddressBalanceScreen(AddressBalanceScreen), // Token Screens TokensScreen(Box), @@ -490,6 +710,16 @@ pub enum Screen { AddTokenById(AddTokenByIdScreen), PurchaseTokenScreen(PurchaseTokenScreen), SetTokenPriceScreen(SetTokenPriceScreen), + + // DashPay Screens + DashPayScreen(DashPayScreen), + DashPayAddContactScreen(AddContactScreen), + DashPayContactDetailsScreen(ContactDetailsScreen), + DashPayContactProfileViewerScreen(ContactProfileViewerScreen), + DashPaySendPaymentScreen(SendPaymentScreen), + DashPayContactInfoEditorScreen(ContactInfoEditorScreen), + DashPayQRGeneratorScreen(QRCodeGeneratorScreen), + DashPayProfileSearchScreen(ProfileSearchScreen), } impl Screen { @@ -497,7 +727,6 @@ impl Screen { match self { Screen::IdentitiesScreen(screen) => screen.app_context = app_context, Screen::DPNSScreen(screen) => screen.app_context = app_context, - Screen::DashpayScreen(screen) => screen.app_context = app_context, Screen::AddExistingIdentityScreen(screen) => screen.app_context = app_context, Screen::KeyInfoScreen(screen) => screen.app_context = app_context, Screen::KeysScreen(screen) => screen.app_context = app_context, @@ -520,7 +749,9 @@ impl Screen { screen.app_context = app_context; screen.update_selected_wallet_for_network(); } - Screen::ImportWalletScreen(screen) => screen.app_context = app_context, + Screen::ImportMnemonicScreen(screen) => screen.app_context = app_context, + Screen::WalletSendScreen(screen) => screen.app_context = app_context, + Screen::SingleKeyWalletSendScreen(screen) => screen.app_context = app_context, Screen::ProofLogScreen(screen) => screen.app_context = app_context, Screen::AddContractsScreen(screen) => screen.app_context = app_context, Screen::ProofVisualizerScreen(screen) => screen.app_context = app_context, @@ -537,6 +768,7 @@ impl Screen { Screen::DocumentVisualizerScreen(screen) => screen.app_context = app_context, Screen::PlatformInfoScreen(screen) => screen.app_context = app_context, Screen::GroveSTARKScreen(screen) => screen.app_context = app_context, + Screen::AddressBalanceScreen(screen) => screen.app_context = app_context, // Token Screens Screen::TokensScreen(screen) => screen.app_context = app_context, @@ -554,6 +786,22 @@ impl Screen { Screen::AddTokenById(screen) => screen.app_context = app_context, Screen::PurchaseTokenScreen(screen) => screen.app_context = app_context, Screen::SetTokenPriceScreen(screen) => screen.app_context = app_context, + + // DashPay Screens + Screen::DashPayScreen(screen) => { + screen.app_context = app_context.clone(); + screen.contacts_list.app_context = app_context.clone(); + screen.contacts_list.contact_requests.app_context = app_context.clone(); + screen.profile_screen.app_context = app_context.clone(); + screen.payment_history.app_context = app_context; + } + Screen::DashPayAddContactScreen(screen) => screen.app_context = app_context, + Screen::DashPayContactDetailsScreen(screen) => screen.app_context = app_context, + Screen::DashPayContactProfileViewerScreen(screen) => screen.app_context = app_context, + Screen::DashPaySendPaymentScreen(screen) => screen.app_context = app_context, + Screen::DashPayContactInfoEditorScreen(screen) => screen.app_context = app_context, + Screen::DashPayQRGeneratorScreen(screen) => screen.app_context = app_context, + Screen::DashPayProfileSearchScreen(screen) => screen.app_context = app_context, } } } @@ -620,7 +868,6 @@ impl Screen { dpns_subscreen: DPNSSubscreen::ScheduledVotes, .. }) => ScreenType::ScheduledVotes, - Screen::DashpayScreen(_) => ScreenType::Dashpay, Screen::TransitionVisualizerScreen(_) => ScreenType::TransitionVisualizer, Screen::ContractVisualizerScreen(_) => ScreenType::ContractsVisualizer, Screen::WithdrawalScreen(screen) => { @@ -633,7 +880,7 @@ impl Screen { Screen::TopUpIdentityScreen(screen) => { ScreenType::TopUpIdentity(screen.identity.clone()) } - Screen::RegisterDpnsNameScreen(_) => ScreenType::RegisterDpnsName, + Screen::RegisterDpnsNameScreen(screen) => ScreenType::RegisterDpnsName(screen.source), Screen::RegisterDataContractScreen(_) => ScreenType::RegisterContract, Screen::UpdateDataContractScreen(_) => ScreenType::UpdateContract, Screen::DocumentActionScreen(screen) => match screen.action_type { @@ -647,7 +894,13 @@ impl Screen { Screen::GroupActionsScreen(_) => ScreenType::GroupActions, Screen::AddNewWalletScreen(_) => ScreenType::AddNewWallet, Screen::WalletsBalancesScreen(_) => ScreenType::WalletsBalances, - Screen::ImportWalletScreen(_) => ScreenType::ImportWallet, + Screen::ImportMnemonicScreen(_) => ScreenType::ImportMnemonic, + Screen::WalletSendScreen(screen) => { + ScreenType::WalletSendScreen(screen.selected_wallet.clone().unwrap()) + } + Screen::SingleKeyWalletSendScreen(screen) => { + ScreenType::SingleKeyWalletSendScreen(screen.selected_wallet.clone().unwrap()) + } Screen::ProofLogScreen(_) => ScreenType::ProofLog, Screen::AddContractsScreen(_) => ScreenType::AddContracts, Screen::ProofVisualizerScreen(_) => ScreenType::ProofVisualizer, @@ -655,6 +908,7 @@ impl Screen { Screen::DocumentVisualizerScreen(_) => ScreenType::DocumentsVisualizer, Screen::PlatformInfoScreen(_) => ScreenType::PlatformInfo, Screen::GroveSTARKScreen(_) => ScreenType::GroveSTARK, + Screen::AddressBalanceScreen(_) => ScreenType::AddressBalance, // Token Screens Screen::TokensScreen(screen) @@ -717,6 +971,29 @@ impl Screen { // Default fallback for any unmatched TokensScreen variants ScreenType::TokenBalances } + + // DashPay Screens + Screen::DashPayScreen(screen) => match screen.dashpay_subscreen { + DashPaySubscreen::Contacts => ScreenType::DashPayContacts, + DashPaySubscreen::Profile => ScreenType::DashPayProfile, + DashPaySubscreen::Payments => ScreenType::DashPayPayments, + DashPaySubscreen::ProfileSearch => ScreenType::DashPayProfileSearch, + }, + Screen::DashPayAddContactScreen(_) => ScreenType::DashPayAddContact, + Screen::DashPayContactDetailsScreen(screen) => { + ScreenType::DashPayContactDetails(screen.identity.clone(), screen.contact_id) + } + Screen::DashPayContactProfileViewerScreen(screen) => { + ScreenType::DashPayContactProfileViewer(screen.identity.clone(), screen.contact_id) + } + Screen::DashPaySendPaymentScreen(screen) => { + ScreenType::DashPaySendPayment(screen.from_identity.clone(), screen.to_contact_id) + } + Screen::DashPayContactInfoEditorScreen(screen) => { + ScreenType::DashPayContactInfoEditor(screen.identity.clone(), screen.contact_id) + } + Screen::DashPayQRGeneratorScreen(_) => ScreenType::DashPayQRGenerator, + Screen::DashPayProfileSearchScreen(_) => ScreenType::DashPayProfileSearch, } } } @@ -727,9 +1004,8 @@ impl ScreenLike for Screen { Screen::IdentitiesScreen(screen) => screen.refresh(), Screen::DPNSScreen(screen) => screen.refresh(), Screen::DocumentQueryScreen(screen) => screen.refresh(), - Screen::DashpayScreen(screen) => screen.refresh(), Screen::AddNewWalletScreen(screen) => screen.refresh(), - Screen::ImportWalletScreen(screen) => screen.refresh(), + Screen::ImportMnemonicScreen(screen) => screen.refresh(), Screen::AddNewIdentityScreen(screen) => screen.refresh(), Screen::TopUpIdentityScreen(screen) => screen.refresh(), Screen::AddExistingIdentityScreen(screen) => screen.refresh(), @@ -746,6 +1022,8 @@ impl ScreenLike for Screen { Screen::TransitionVisualizerScreen(screen) => screen.refresh(), Screen::NetworkChooserScreen(screen) => screen.refresh(), Screen::WalletsBalancesScreen(screen) => screen.refresh(), + Screen::WalletSendScreen(screen) => screen.refresh(), + Screen::SingleKeyWalletSendScreen(screen) => screen.refresh(), Screen::ProofLogScreen(screen) => screen.refresh(), Screen::AddContractsScreen(screen) => screen.refresh(), Screen::ProofVisualizerScreen(screen) => screen.refresh(), @@ -754,6 +1032,7 @@ impl ScreenLike for Screen { Screen::ContractVisualizerScreen(screen) => screen.refresh(), Screen::PlatformInfoScreen(screen) => screen.refresh(), Screen::GroveSTARKScreen(screen) => screen.refresh(), + Screen::AddressBalanceScreen(screen) => screen.refresh(), // Token Screens Screen::TokensScreen(screen) => screen.refresh(), @@ -771,6 +1050,16 @@ impl ScreenLike for Screen { Screen::AddTokenById(screen) => screen.refresh(), Screen::PurchaseTokenScreen(screen) => screen.refresh(), Screen::SetTokenPriceScreen(screen) => screen.refresh(), + + // DashPay Screens + Screen::DashPayScreen(screen) => screen.refresh(), + Screen::DashPayAddContactScreen(screen) => screen.refresh(), + Screen::DashPayContactDetailsScreen(screen) => screen.refresh(), + Screen::DashPayContactProfileViewerScreen(screen) => screen.refresh(), + Screen::DashPaySendPaymentScreen(screen) => screen.refresh(), + Screen::DashPayContactInfoEditorScreen(screen) => screen.refresh(), + Screen::DashPayQRGeneratorScreen(_) => {} + Screen::DashPayProfileSearchScreen(screen) => screen.refresh(), } } @@ -779,9 +1068,8 @@ impl ScreenLike for Screen { Screen::IdentitiesScreen(screen) => screen.refresh_on_arrival(), Screen::DPNSScreen(screen) => screen.refresh_on_arrival(), Screen::DocumentQueryScreen(screen) => screen.refresh_on_arrival(), - Screen::DashpayScreen(screen) => screen.refresh_on_arrival(), Screen::AddNewWalletScreen(screen) => screen.refresh_on_arrival(), - Screen::ImportWalletScreen(screen) => screen.refresh_on_arrival(), + Screen::ImportMnemonicScreen(screen) => screen.refresh_on_arrival(), Screen::AddNewIdentityScreen(screen) => screen.refresh_on_arrival(), Screen::TopUpIdentityScreen(screen) => screen.refresh_on_arrival(), Screen::AddExistingIdentityScreen(screen) => screen.refresh_on_arrival(), @@ -798,6 +1086,8 @@ impl ScreenLike for Screen { Screen::TransitionVisualizerScreen(screen) => screen.refresh_on_arrival(), Screen::NetworkChooserScreen(screen) => screen.refresh_on_arrival(), Screen::WalletsBalancesScreen(screen) => screen.refresh_on_arrival(), + Screen::WalletSendScreen(screen) => screen.refresh_on_arrival(), + Screen::SingleKeyWalletSendScreen(screen) => screen.refresh_on_arrival(), Screen::ProofLogScreen(screen) => screen.refresh_on_arrival(), Screen::AddContractsScreen(screen) => screen.refresh_on_arrival(), Screen::ProofVisualizerScreen(screen) => screen.refresh_on_arrival(), @@ -806,6 +1096,7 @@ impl ScreenLike for Screen { Screen::ContractVisualizerScreen(screen) => screen.refresh_on_arrival(), Screen::PlatformInfoScreen(screen) => screen.refresh_on_arrival(), Screen::GroveSTARKScreen(screen) => screen.refresh_on_arrival(), + Screen::AddressBalanceScreen(screen) => screen.refresh_on_arrival(), // Token Screens Screen::TokensScreen(screen) => screen.refresh_on_arrival(), @@ -823,6 +1114,16 @@ impl ScreenLike for Screen { Screen::AddTokenById(screen) => screen.refresh_on_arrival(), Screen::PurchaseTokenScreen(screen) => screen.refresh_on_arrival(), Screen::SetTokenPriceScreen(screen) => screen.refresh_on_arrival(), + + // DashPay Screens + Screen::DashPayScreen(screen) => screen.refresh_on_arrival(), + Screen::DashPayAddContactScreen(screen) => screen.refresh_on_arrival(), + Screen::DashPayContactDetailsScreen(screen) => screen.refresh_on_arrival(), + Screen::DashPayContactProfileViewerScreen(screen) => screen.refresh_on_arrival(), + Screen::DashPaySendPaymentScreen(screen) => screen.refresh_on_arrival(), + Screen::DashPayContactInfoEditorScreen(screen) => screen.refresh_on_arrival(), + Screen::DashPayQRGeneratorScreen(_) => {} + Screen::DashPayProfileSearchScreen(screen) => screen.refresh_on_arrival(), } } @@ -831,9 +1132,8 @@ impl ScreenLike for Screen { Screen::IdentitiesScreen(screen) => screen.ui(ctx), Screen::DPNSScreen(screen) => screen.ui(ctx), Screen::DocumentQueryScreen(screen) => screen.ui(ctx), - Screen::DashpayScreen(screen) => screen.ui(ctx), Screen::AddNewWalletScreen(screen) => screen.ui(ctx), - Screen::ImportWalletScreen(screen) => screen.ui(ctx), + Screen::ImportMnemonicScreen(screen) => screen.ui(ctx), Screen::AddNewIdentityScreen(screen) => screen.ui(ctx), Screen::TopUpIdentityScreen(screen) => screen.ui(ctx), Screen::AddExistingIdentityScreen(screen) => screen.ui(ctx), @@ -850,6 +1150,8 @@ impl ScreenLike for Screen { Screen::TransitionVisualizerScreen(screen) => screen.ui(ctx), Screen::NetworkChooserScreen(screen) => screen.ui(ctx), Screen::WalletsBalancesScreen(screen) => screen.ui(ctx), + Screen::WalletSendScreen(screen) => screen.ui(ctx), + Screen::SingleKeyWalletSendScreen(screen) => screen.ui(ctx), Screen::ProofLogScreen(screen) => screen.ui(ctx), Screen::AddContractsScreen(screen) => screen.ui(ctx), Screen::ProofVisualizerScreen(screen) => screen.ui(ctx), @@ -858,6 +1160,7 @@ impl ScreenLike for Screen { Screen::ContractVisualizerScreen(screen) => screen.ui(ctx), Screen::PlatformInfoScreen(screen) => screen.ui(ctx), Screen::GroveSTARKScreen(screen) => screen.ui(ctx), + Screen::AddressBalanceScreen(screen) => screen.ui(ctx), // Token Screens Screen::TokensScreen(screen) => screen.ui(ctx), @@ -875,6 +1178,16 @@ impl ScreenLike for Screen { Screen::AddTokenById(screen) => screen.ui(ctx), Screen::PurchaseTokenScreen(screen) => screen.ui(ctx), Screen::SetTokenPriceScreen(screen) => screen.ui(ctx), + + // DashPay Screens + Screen::DashPayScreen(screen) => screen.ui(ctx), + Screen::DashPayAddContactScreen(screen) => screen.ui(ctx), + Screen::DashPayContactDetailsScreen(screen) => screen.ui(ctx), + Screen::DashPayContactProfileViewerScreen(screen) => screen.ui(ctx), + Screen::DashPaySendPaymentScreen(screen) => screen.ui(ctx), + Screen::DashPayContactInfoEditorScreen(screen) => screen.ui(ctx), + Screen::DashPayQRGeneratorScreen(screen) => screen.ui(ctx), + Screen::DashPayProfileSearchScreen(screen) => screen.ui(ctx), } } @@ -883,9 +1196,8 @@ impl ScreenLike for Screen { Screen::IdentitiesScreen(screen) => screen.display_message(message, message_type), Screen::DPNSScreen(screen) => screen.display_message(message, message_type), Screen::DocumentQueryScreen(screen) => screen.display_message(message, message_type), - Screen::DashpayScreen(screen) => screen.display_message(message, message_type), Screen::AddNewWalletScreen(screen) => screen.display_message(message, message_type), - Screen::ImportWalletScreen(screen) => screen.display_message(message, message_type), + Screen::ImportMnemonicScreen(screen) => screen.display_message(message, message_type), Screen::AddNewIdentityScreen(screen) => screen.display_message(message, message_type), Screen::TopUpIdentityScreen(screen) => screen.display_message(message, message_type), Screen::AddExistingIdentityScreen(screen) => { @@ -910,6 +1222,10 @@ impl ScreenLike for Screen { } Screen::NetworkChooserScreen(screen) => screen.display_message(message, message_type), Screen::WalletsBalancesScreen(screen) => screen.display_message(message, message_type), + Screen::WalletSendScreen(screen) => screen.display_message(message, message_type), + Screen::SingleKeyWalletSendScreen(screen) => { + screen.display_message(message, message_type) + } Screen::ProofLogScreen(screen) => screen.display_message(message, message_type), Screen::AddContractsScreen(screen) => screen.display_message(message, message_type), Screen::ProofVisualizerScreen(screen) => screen.display_message(message, message_type), @@ -924,6 +1240,7 @@ impl ScreenLike for Screen { } Screen::PlatformInfoScreen(screen) => screen.display_message(message, message_type), Screen::GroveSTARKScreen(screen) => screen.display_message(message, message_type), + Screen::AddressBalanceScreen(screen) => screen.display_message(message, message_type), // Token Screens Screen::TokensScreen(screen) => screen.display_message(message, message_type), @@ -945,6 +1262,30 @@ impl ScreenLike for Screen { Screen::AddTokenById(screen) => screen.display_message(message, message_type), Screen::PurchaseTokenScreen(screen) => screen.display_message(message, message_type), Screen::SetTokenPriceScreen(screen) => screen.display_message(message, message_type), + + // DashPay Screens + Screen::DashPayScreen(screen) => screen.display_message(message, message_type), + Screen::DashPayAddContactScreen(screen) => { + screen.display_message(message, message_type) + } + Screen::DashPayContactDetailsScreen(screen) => { + screen.display_message(message, message_type) + } + Screen::DashPayContactProfileViewerScreen(screen) => { + screen.display_message(message, message_type) + } + Screen::DashPaySendPaymentScreen(screen) => { + screen.display_message(message, message_type) + } + Screen::DashPayContactInfoEditorScreen(screen) => { + screen.display_message(message, message_type) + } + Screen::DashPayQRGeneratorScreen(screen) => { + screen.display_message(message, message_type) + } + Screen::DashPayProfileSearchScreen(screen) => { + screen.display_message(message, message_type) + } } } @@ -957,13 +1298,10 @@ impl ScreenLike for Screen { Screen::DocumentQueryScreen(screen) => { screen.display_task_result(backend_task_success_result) } - Screen::DashpayScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } Screen::AddNewWalletScreen(screen) => { screen.display_task_result(backend_task_success_result) } - Screen::ImportWalletScreen(screen) => { + Screen::ImportMnemonicScreen(screen) => { screen.display_task_result(backend_task_success_result) } Screen::AddNewIdentityScreen(screen) => { @@ -1013,6 +1351,12 @@ impl ScreenLike for Screen { Screen::WalletsBalancesScreen(screen) => { screen.display_task_result(backend_task_success_result) } + Screen::WalletSendScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } + Screen::SingleKeyWalletSendScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } Screen::ProofLogScreen(screen) => { screen.display_task_result(backend_task_success_result) } @@ -1034,6 +1378,9 @@ impl ScreenLike for Screen { Screen::GroveSTARKScreen(screen) => { screen.display_task_result(backend_task_success_result) } + Screen::AddressBalanceScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } // Token Screens Screen::TokensScreen(screen) => screen.display_task_result(backend_task_success_result), @@ -1077,6 +1424,32 @@ impl ScreenLike for Screen { Screen::SetTokenPriceScreen(screen) => { screen.display_task_result(backend_task_success_result) } + + // DashPay Screens + Screen::DashPayScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } + Screen::DashPayAddContactScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } + Screen::DashPayContactDetailsScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } + Screen::DashPayContactProfileViewerScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } + Screen::DashPaySendPaymentScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } + Screen::DashPayContactInfoEditorScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } + Screen::DashPayQRGeneratorScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } + Screen::DashPayProfileSearchScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } } } @@ -1085,9 +1458,8 @@ impl ScreenLike for Screen { Screen::IdentitiesScreen(screen) => screen.pop_on_success(), Screen::DPNSScreen(screen) => screen.pop_on_success(), Screen::DocumentQueryScreen(screen) => screen.pop_on_success(), - Screen::DashpayScreen(screen) => screen.pop_on_success(), Screen::AddNewWalletScreen(screen) => screen.pop_on_success(), - Screen::ImportWalletScreen(screen) => screen.pop_on_success(), + Screen::ImportMnemonicScreen(screen) => screen.pop_on_success(), Screen::AddNewIdentityScreen(screen) => screen.pop_on_success(), Screen::TopUpIdentityScreen(screen) => screen.pop_on_success(), Screen::AddExistingIdentityScreen(screen) => screen.pop_on_success(), @@ -1104,6 +1476,8 @@ impl ScreenLike for Screen { Screen::TransitionVisualizerScreen(screen) => screen.pop_on_success(), Screen::NetworkChooserScreen(screen) => screen.pop_on_success(), Screen::WalletsBalancesScreen(screen) => screen.pop_on_success(), + Screen::WalletSendScreen(screen) => screen.pop_on_success(), + Screen::SingleKeyWalletSendScreen(screen) => screen.pop_on_success(), Screen::ProofLogScreen(screen) => screen.pop_on_success(), Screen::AddContractsScreen(screen) => screen.pop_on_success(), Screen::ProofVisualizerScreen(screen) => screen.pop_on_success(), @@ -1112,6 +1486,7 @@ impl ScreenLike for Screen { Screen::ContractVisualizerScreen(screen) => screen.pop_on_success(), Screen::PlatformInfoScreen(screen) => screen.pop_on_success(), Screen::GroveSTARKScreen(screen) => screen.pop_on_success(), + Screen::AddressBalanceScreen(screen) => screen.pop_on_success(), // Token Screens Screen::TokensScreen(screen) => screen.pop_on_success(), @@ -1129,6 +1504,16 @@ impl ScreenLike for Screen { Screen::AddTokenById(screen) => screen.pop_on_success(), Screen::PurchaseTokenScreen(screen) => screen.pop_on_success(), Screen::SetTokenPriceScreen(screen) => screen.pop_on_success(), + + // DashPay Screens + Screen::DashPayScreen(screen) => screen.pop_on_success(), + Screen::DashPayAddContactScreen(_) => {} + Screen::DashPayContactDetailsScreen(_) => {} + Screen::DashPayContactProfileViewerScreen(_) => {} + Screen::DashPaySendPaymentScreen(_) => {} + Screen::DashPayContactInfoEditorScreen(_) => {} + Screen::DashPayQRGeneratorScreen(_) => {} + Screen::DashPayProfileSearchScreen(_) => {} } } } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 309a72abf..3c4d5385b 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -4,19 +4,38 @@ use crate::backend_task::system_task::SystemTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::config::Config; use crate::context::AppContext; +use crate::model::wallet::DerivationPathHelpers; +use crate::spv::{CoreBackendMode, SpvStatus, SpvStatusSnapshot}; +use crate::ui::components::component_trait::Component; use crate::ui::components::left_panel::add_left_panel; -use crate::ui::components::styled::{StyledCard, StyledCheckbox, island_central_panel}; +use crate::ui::components::styled::{ + ConfirmationDialog, ConfirmationStatus, StyledCard, StyledCheckbox, island_central_panel, +}; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::theme::{DashColors, ThemeMode}; +use crate::ui::theme::{DashColors, Shape, ThemeMode}; use crate::ui::{RootScreenType, ScreenLike}; use crate::utils::path::format_path_for_display; +use dash_sdk::dash_spv::types::{DetailedSyncProgress, SyncStage}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::TimestampMillis; -use eframe::egui::{self, Context, Ui}; +use eframe::egui::{self, Color32, Context, Frame, Margin, RichText, Ui}; +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +#[derive(Debug, Clone)] +enum SpvClearMessage { + Success(String), + Error(String), +} + +#[derive(Debug, Clone)] +enum DatabaseClearMessage { + Success(String), + Error(String), +} + pub struct NetworkChooserScreen { pub mainnet_app_context: Arc, pub testnet_app_context: Option>, @@ -32,9 +51,19 @@ pub struct NetworkChooserScreen { custom_dash_qt_path: Option, custom_dash_qt_error_message: Option, overwrite_dash_conf: bool, + disable_zmq: bool, developer_mode: bool, theme_preference: ThemeMode, should_reset_collapsing_states: bool, + backend_modes: HashMap, + filter_headers_stage_start: Option, + spv_clear_dialog: Option, + spv_clear_message: Option, + db_clear_dialog: Option, + db_clear_message: Option, + use_local_spv_node: bool, + auto_start_spv: bool, + close_dash_qt_on_exit: bool, } impl NetworkChooserScreen { @@ -72,7 +101,38 @@ impl NetworkChooserScreen { .flatten() .unwrap_or_default(); let theme_preference = settings.theme_mode; + let disable_zmq = settings.disable_zmq; let custom_dash_qt_path = settings.dash_qt_path; + let use_local_spv_node = mainnet_app_context + .db + .get_use_local_spv_node() + .unwrap_or(false); + let auto_start_spv = mainnet_app_context.db.get_auto_start_spv().unwrap_or(true); + let close_dash_qt_on_exit = mainnet_app_context + .db + .get_close_dash_qt_on_exit() + .unwrap_or(true); + + let mut backend_modes = HashMap::new(); + backend_modes.insert(Network::Dash, mainnet_app_context.core_backend_mode()); + backend_modes.insert( + Network::Testnet, + testnet_app_context + .map(|ctx| ctx.core_backend_mode()) + .unwrap_or_default(), + ); + backend_modes.insert( + Network::Devnet, + devnet_app_context + .map(|ctx| ctx.core_backend_mode()) + .unwrap_or_default(), + ); + backend_modes.insert( + Network::Regtest, + local_app_context + .map(|ctx| ctx.core_backend_mode()) + .unwrap_or_default(), + ); Self { mainnet_app_context: mainnet_app_context.clone(), @@ -89,9 +149,19 @@ impl NetworkChooserScreen { custom_dash_qt_path, custom_dash_qt_error_message: None, overwrite_dash_conf, + disable_zmq, developer_mode, theme_preference, should_reset_collapsing_states: true, // Start with collapsed state + backend_modes, + filter_headers_stage_start: None, + spv_clear_dialog: None, + spv_clear_message: None, + db_clear_dialog: None, + db_clear_message: None, + use_local_spv_node, + auto_start_spv, + close_dash_qt_on_exit, } } @@ -126,530 +196,1503 @@ impl NetworkChooserScreen { ) .map_err(|e| e.to_string()) } - /// Render the network selection table + /// Render the simplified settings interface fn render_network_table(&mut self, ui: &mut Ui) -> AppAction { let mut app_action = AppAction::None; let dark_mode = ui.ctx().style().visuals.dark_mode; - egui::Grid::new("network_grid") - .striped(false) - .spacing([20.0, 10.0]) - .show(ui, |ui| { - // Header row - ui.label( - egui::RichText::new("Network") - .strong() - .underline() - .color(DashColors::text_primary(dark_mode)), - ); - ui.label( - egui::RichText::new("Status") - .strong() - .underline() - .color(DashColors::text_primary(dark_mode)), - ); - // ui.label(egui::RichText::new("Wallet Count").strong().underline()); - // ui.label(egui::RichText::new("Add New Wallet").strong().underline()); - ui.label( - egui::RichText::new("Select") - .strong() - .underline() - .color(DashColors::text_primary(dark_mode)), - ); - ui.label( - egui::RichText::new("Start") - .strong() - .underline() - .color(DashColors::text_primary(dark_mode)), - ); - ui.label( - egui::RichText::new("Dashmate Password") - .strong() - .underline() - .color(DashColors::text_primary(dark_mode)), - ); + // Connection Settings Card + StyledCard::new().padding(24.0).show(ui, |ui| { + ui.heading("Connection Settings"); + ui.add_space(20.0); + + // Create a table with rows and 2 columns + egui::Grid::new("connection_settings_grid") + .num_columns(2) + .spacing([40.0, 12.0]) + .striped(false) + .show(ui, |ui| { + // TODO: SPV is currently hidden behind Developer Mode while still in development. + // Once SPV is production-ready, remove this developer_mode check and make SPV + // the default/primary connection method, with RPC as a fallback option. + let current_backend_mode = *self + .backend_modes + .entry(self.current_network) + .or_insert(CoreBackendMode::Rpc); + + if self.developer_mode { + // Row 1: Connection Type (only shown in developer mode) + ui.label( + egui::RichText::new("Connection Type:") + .color(DashColors::text_primary(dark_mode)), + ); + + let connection_text = match current_backend_mode { + CoreBackendMode::Spv => "SPV Client", + CoreBackendMode::Rpc => "Dash Core RPC", + }; + + let mut connection_mode = current_backend_mode; + egui::ComboBox::from_id_salt("connection_mode_selector") + .selected_text(connection_text) + .width(200.0) + .show_ui(ui, |ui| { + if ui + .selectable_value( + &mut connection_mode, + CoreBackendMode::Spv, + "SPV Client", + ) + .changed() + { + self.backend_modes + .insert(self.current_network, CoreBackendMode::Spv); + let ctx = self.current_app_context(); + ctx.set_core_backend_mode(CoreBackendMode::Spv); + } + if ui + .selectable_value( + &mut connection_mode, + CoreBackendMode::Rpc, + "Dash Core RPC", + ) + .changed() + { + self.backend_modes + .insert(self.current_network, CoreBackendMode::Rpc); + let ctx = self.current_app_context(); + ctx.set_core_backend_mode(CoreBackendMode::Rpc); + ctx.stop_spv(); + } + }); + + ui.end_row(); + + // Show experimental warning when SPV mode is selected + if current_backend_mode == CoreBackendMode::Spv { + ui.label(""); // Empty label for grid alignment + egui::Frame::new() + .fill(DashColors::WARNING.gamma_multiply(0.15)) + .inner_margin(egui::Margin::symmetric(8, 4)) + .stroke(egui::Stroke::new(1.0, DashColors::WARNING)) + .corner_radius(4.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("⚠") + .color(DashColors::WARNING) + .size(14.0), + ); + ui.label( + egui::RichText::new( + "SPV mode is experimental and still in development", + ) + .color(DashColors::WARNING) + .size(12.0), + ); + }); + }); + ui.end_row(); + } + } + + // Row 2: Network + ui.label( + egui::RichText::new("Network:").color(DashColors::text_primary(dark_mode)), + ); + + // Check if currently connected via SPV (only SPV restricts network switching) + let is_spv_connected = if current_backend_mode == CoreBackendMode::Spv { + let ctx = self.current_app_context(); + let snapshot = ctx.spv_manager().status(); + snapshot.status.is_active() + } else { + false // Core mode doesn't restrict network switching + }; + + let network_text = match self.current_network { + Network::Dash => "Mainnet", + Network::Testnet => "Testnet", + Network::Devnet => "Devnet", + Network::Regtest => "Local", + _ => "Unknown", + }; + + ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { + let network_combo = egui::ComboBox::from_id_salt("network_selector") + .selected_text(network_text) + .width(200.0); + + let response = ui.add_enabled_ui(!is_spv_connected, |ui| { + network_combo.show_ui(ui, |ui| { + if ui + .selectable_value( + &mut self.current_network, + Network::Dash, + "Mainnet", + ) + .clicked() + { + app_action = AppAction::SwitchNetwork(Network::Dash); + } + if self.testnet_app_context.is_some() + && ui + .selectable_value( + &mut self.current_network, + Network::Testnet, + "Testnet", + ) + .clicked() + { + app_action = AppAction::SwitchNetwork(Network::Testnet); + } + if self.devnet_app_context.is_some() + && ui + .selectable_value( + &mut self.current_network, + Network::Devnet, + "Devnet", + ) + .clicked() + { + app_action = AppAction::SwitchNetwork(Network::Devnet); + } + if self.local_app_context.is_some() + && ui + .selectable_value( + &mut self.current_network, + Network::Regtest, + "Local", + ) + .clicked() + { + app_action = AppAction::SwitchNetwork(Network::Regtest); + } + }); + }); + + if is_spv_connected { + response.response.on_hover_text("Disconnect from SPV first"); + } + }); + + ui.end_row(); + }); + + // Password input for Local network + let current_backend_mode = *self + .backend_modes + .entry(self.current_network) + .or_insert(CoreBackendMode::Rpc); + if self.current_network == Network::Regtest + && current_backend_mode == CoreBackendMode::Rpc + { + ui.add_space(20.0); + ui.separator(); + ui.add_space(12.0); + ui.label( - egui::RichText::new("Actions") + egui::RichText::new("Local Network Password") .strong() - .underline() .color(DashColors::text_primary(dark_mode)), ); - ui.end_row(); + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.text_edit_singleline(&mut self.local_network_dashmate_password); + + if ui.button("Save").clicked() + && let Ok(mut config) = Config::load() + && let Some(local_cfg) = config.config_for_network(Network::Regtest).clone() + { + let updated_local_config = local_cfg + .update_core_rpc_password(self.local_network_dashmate_password.clone()); + config.update_config_for_network( + Network::Regtest, + updated_local_config.clone(), + ); + if let Err(e) = config.save() { + eprintln!("Failed to save config to .env: {e}"); + } - // Render Mainnet Row - app_action |= self.render_network_row(ui, Network::Dash, "Mainnet"); + // Update our local AppContext in memory + if let Some(local_app_context) = &self.local_app_context { + { + // Overwrite the config field with the new password + let mut cfg_lock = local_app_context.config.write().unwrap(); + *cfg_lock = updated_local_config; + } + + // Re-init the client & sdk from the updated config + if let Err(e) = + Arc::clone(local_app_context).reinit_core_client_and_sdk() + { + eprintln!("Failed to re-init local RPC client and sdk: {}", e); + } else { + // Trigger SwitchNetworks + app_action = AppAction::SwitchNetwork(Network::Regtest); + } + } + } + }); + } + }); - // Render Testnet Row - app_action |= self.render_network_row(ui, Network::Testnet, "Testnet"); + // Connection Status Card + ui.add_space(16.0); + + StyledCard::new().padding(24.0).show(ui, |ui| { + ui.heading("Connection Status"); + ui.add_space(10.0); + + let current_backend_mode = *self + .backend_modes + .entry(self.current_network) + .or_insert(CoreBackendMode::Rpc); + + // Check connection status + let (is_connected, snapshot) = match current_backend_mode { + CoreBackendMode::Rpc => (self.check_network_status(self.current_network), None), + CoreBackendMode::Spv => { + let ctx = self.current_app_context(); + let snap = ctx.spv_manager().status(); + let connected = snap.status.is_active() || snap.status == SpvStatus::Running; + (connected, Some(snap)) + } + }; + + // Button on the left with status + ui.horizontal(|ui| { + if is_connected { + if current_backend_mode == CoreBackendMode::Spv { + let disconnect_button = egui::Button::new( + egui::RichText::new("Disconnect").color(DashColors::WHITE), + ) + .fill(DashColors::ERROR) + .stroke(egui::Stroke::NONE) + .corner_radius(Shape::RADIUS_MD) + .min_size(egui::vec2(120.0, 36.0)); + + if ui.add(disconnect_button).clicked() { + self.current_app_context().stop_spv(); + } - // Render Devnet Row - app_action |= self.render_network_row(ui, Network::Devnet, "Devnet"); + // Show sync status next to button + ui.add_space(12.0); - // Render Local Row - app_action |= self.render_network_row(ui, Network::Regtest, "Local"); + if let Some(snap) = &snapshot { + match snap.status { + SpvStatus::Running => { + ui.colored_label(DashColors::SUCCESS, "Fully Synced - The SPV client can now be used for transacting and querying."); + } + SpvStatus::Syncing | SpvStatus::Starting => { + ui.style_mut().visuals.widgets.inactive.fg_stroke.color = + DashColors::DASH_BLUE; + ui.style_mut().visuals.widgets.hovered.fg_stroke.color = + DashColors::DASH_BLUE; + ui.style_mut().visuals.widgets.active.fg_stroke.color = + DashColors::DASH_BLUE; + ui.spinner(); + ui.label(egui::RichText::new("Syncing...")); + } + SpvStatus::Stopping => { + ui.style_mut().visuals.widgets.inactive.fg_stroke.color = + DashColors::DASH_BLUE; + ui.style_mut().visuals.widgets.hovered.fg_stroke.color = + DashColors::DASH_BLUE; + ui.style_mut().visuals.widgets.active.fg_stroke.color = + DashColors::DASH_BLUE; + ui.spinner(); + ui.label(egui::RichText::new("Disconnecting...")); + } + _ => {} + } + } + } else { + // For Core mode, just show status since it can switch networks freely + ui.colored_label(DashColors::DASH_BLUE, "✅ Connected"); + } + } else { + // Don't show Connect button for Local network in RPC mode + // (there's no Dash-Qt to start for local/regtest) + let show_connect_button = !(self.current_network == Network::Regtest + && current_backend_mode == CoreBackendMode::Rpc); + + if show_connect_button { + let connect_button = egui::Button::new( + egui::RichText::new("Connect").color(DashColors::WHITE), + ) + .fill(DashColors::DASH_BLUE) + .stroke(egui::Stroke::NONE) + .corner_radius(Shape::RADIUS_MD) + .min_size(egui::vec2(120.0, 36.0)); + + if ui.add(connect_button).clicked() { + if current_backend_mode == CoreBackendMode::Spv { + if let Err(err) = self.current_app_context().start_spv() { + app_action = + AppAction::Custom(format!("Failed to start SPV: {}", err)); + } + } else { + // Core mode connect + let settings = + self.current_app_context().get_settings().ok().flatten(); + let dash_qt_path = settings + .and_then(|s| s.dash_qt_path) + .or_else(|| self.custom_dash_qt_path.clone()); + if let Some(path) = dash_qt_path { + app_action = AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::StartDashQT( + self.current_network, + path, + self.overwrite_dash_conf, + ), + )); + } + } + } + } + } }); - ui.add_space(20.0); + // TODO: SPV sync progress is hidden when developer mode is OFF. + // Remove the developer_mode check once SPV is production-ready. + if self.developer_mode + && current_backend_mode == CoreBackendMode::Spv + && let Some(snap) = snapshot.as_ref() + && (snap.status == SpvStatus::Syncing || snap.status == SpvStatus::Starting) + { + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + self.render_spv_sync_progress(ui, snap); + } + }); - // Advanced Settings - Collapsible - let mut collapsing_state = egui::collapsing_header::CollapsingState::load_with_default_open( - ui.ctx(), - ui.make_persistent_id("advanced_settings_header"), - false, - ); + // Advanced Settings section with clean dropdown + ui.add_space(16.0); - // Force close if we need to reset - if self.should_reset_collapsing_states { - collapsing_state.set_open(false); - self.should_reset_collapsing_states = false; - } + StyledCard::new().padding(20.0).show(ui, |ui| { + // Custom collapsing header + let id = ui.make_persistent_id("advanced_settings_header"); + let mut state = egui::collapsing_header::CollapsingState::load_with_default_open( + ui.ctx(), + id, + false, + ); - collapsing_state - .show_header(ui, |ui| { - ui.label("Advanced Settings"); - }) - .body(|ui| { - // Advanced Settings Card Content - StyledCard::new().padding(20.0).show(ui, |ui| { - ui.vertical(|ui| { - // Dash-QT Path Section - ui.group(|ui| { - ui.vertical(|ui| { - ui.label( - egui::RichText::new("Custom Dash-QT Path") - .strong() - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(8.0); + // Reset to closed state when the screen is first opened + if self.should_reset_collapsing_states { + state.set_open(false); + self.should_reset_collapsing_states = false; + } - ui.horizontal(|ui| { - if ui - .add( - egui::Button::new("Select File") - .fill(DashColors::DASH_BLUE) - .stroke(egui::Stroke::NONE) - .corner_radius(egui::CornerRadius::same(6)) - .min_size(egui::vec2(120.0, 32.0)), - ) - .clicked() - && let Some(path) = rfd::FileDialog::new().pick_file() { - let file_name = - path.file_name().and_then(|f| f.to_str()); - if let Some(file_name) = file_name { - self.custom_dash_qt_path = None; - self.custom_dash_qt_error_message = None; - - // Handle macOS .app bundles - let resolved_path = if cfg!(target_os = "macos") && path.extension().and_then(|s| s.to_str()) == Some("app") { - // For .app bundles, resolve to the actual executable inside - path.join("Contents").join("MacOS").join("Dash-Qt") - } else { - path.clone() - }; - - // Check if the resolved path exists and is valid - let is_valid = if cfg!(target_os = "windows") { - file_name.to_ascii_lowercase().ends_with("dash-qt.exe") - } else if cfg!(target_os = "macos") { - // Accept both direct executable and .app bundle - file_name.eq_ignore_ascii_case("dash-qt") || - (file_name.to_ascii_lowercase().ends_with(".app") && resolved_path.exists()) - } else { - // Linux - file_name.eq_ignore_ascii_case("dash-qt") - }; - - if is_valid { - self.custom_dash_qt_path = Some(resolved_path); - self.custom_dash_qt_error_message = None; - self.save() - .expect("Expected to save db settings"); - } else { - let required_file_name = if cfg!(target_os = "windows") { - "dash-qt.exe" - } else if cfg!(target_os = "macos") { - "Dash-Qt or Dash-Qt.app" - } else { - "dash-qt" - }; - self.custom_dash_qt_error_message = Some(format!( - "Invalid file: Please select a valid '{}'.", - required_file_name - )); - } - } - } + // Custom expand/collapse icon + let icon = if state.is_open() { + "−" // Minus sign when open + } else { + "+" // Plus sign when closed + }; + + let response = ui.horizontal(|ui| { + // Make the content area clickable + let response = ui.allocate_response( + egui::vec2(ui.available_width(), 30.0), + egui::Sense::click(), + ); - if (self.custom_dash_qt_path.is_some() - || self.custom_dash_qt_error_message.is_some()) - && ui - .add( - egui::Button::new("Clear") - .fill(DashColors::ERROR.linear_multiply(0.8)) - .stroke(egui::Stroke::NONE) - .corner_radius(egui::CornerRadius::same(6)) - .min_size(egui::vec2(80.0, 32.0)), - ) - .clicked() - { - self.custom_dash_qt_path = Some(PathBuf::new()); // Reset to empty to avoid auto-detection - self.custom_dash_qt_error_message = None; - self.save().expect("Expected to save db settings"); - } - }); + // Draw the content on top of the response area + let painter = ui.painter_at(response.rect); + let mut cursor = response.rect.min; + + // Icon with background + let icon_size = egui::vec2(24.0, 24.0); + let icon_rect = egui::Rect::from_min_size(cursor, icon_size); + painter.rect_filled( + icon_rect, + egui::CornerRadius::from(4.0), + DashColors::glass_white(dark_mode), + ); - ui.add_space(8.0); + let icon_text = painter.layout_no_wrap( + icon.to_string(), + egui::FontId::proportional(16.0), + DashColors::DASH_BLUE, + ); + painter.galley( + icon_rect.center() - icon_text.size() / 2.0, + icon_text, + DashColors::DASH_BLUE, + ); - if let Some(ref file) = self.custom_dash_qt_path { - ui.horizontal(|ui| { - ui.label("Selected:"); - ui.label( - egui::RichText::new(format_path_for_display(file)).color(DashColors::SUCCESS), - ) - .on_hover_text(format!("Full path: {}", file.display())); - }); - } else if let Some(ref error) = self.custom_dash_qt_error_message { - ui.horizontal(|ui| { - ui.label("Error:"); - ui.colored_label(DashColors::ERROR, error); - }); - } else { - ui.label( - egui::RichText::new( - "dash-qt not found, click 'Select File' to choose.", - ) - .color(DashColors::TEXT_SECONDARY) - .italics(), - ); - } - }); - }); + cursor.x += icon_size.x + 8.0; - ui.add_space(16.0); + // Advanced Settings text + let text = painter.layout_no_wrap( + "Advanced Settings".to_string(), + egui::FontId::proportional(16.0), + DashColors::text_primary(dark_mode), + ); + painter.galley( + cursor + egui::vec2(0.0, (icon_size.y - text.size().y) / 2.0), + text, + DashColors::text_primary(dark_mode), + ); - // Configuration Options Section - ui.group(|ui| { - ui.vertical(|ui| { - ui.label( - egui::RichText::new("Configuration Options") - .strong() - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(8.0); + response + }); + + if response.inner.clicked() { + state.toggle(ui); + } - // Overwrite dash.conf checkbox - ui.horizontal(|ui| { - if StyledCheckbox::new( - &mut self.overwrite_dash_conf, - "Overwrite dash.conf", + if response.inner.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + }; + state.show_body_unindented(ui, |ui| { + ui.add_space(12.0); + + // Theme Selection + ui.horizontal(|ui| { + ui.label(egui::RichText::new("🎨").size(16.0)); + ui.label("Theme:"); + + ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { + ui.add_space(-6.0); + egui::ComboBox::from_id_salt("theme_selection") + .selected_text(match self.theme_preference { + ThemeMode::Light => "☀ Light", + ThemeMode::Dark => "🌙 Dark", + ThemeMode::System => "🖥 System", + }) + .width(100.0) + .show_ui(ui, |ui| { + if ui + .selectable_value( + &mut self.theme_preference, + ThemeMode::System, + "🖥 System", ) - .show(ui) .clicked() - { - self.save().expect("Expected to save db settings"); - } - ui.label( - egui::RichText::new( - "Automatically configure dash.conf with required settings", + { + app_action |= AppAction::BackendTask(BackendTask::SystemTask( + SystemTask::UpdateThemePreference(ThemeMode::System), + )); + } + if ui + .selectable_value( + &mut self.theme_preference, + ThemeMode::Light, + "☀ Light", ) - .color(DashColors::TEXT_SECONDARY), - ); - }); - - ui.add_space(8.0); - - // Developer mode checkbox - ui.horizontal(|ui| { - if StyledCheckbox::new( - &mut self.developer_mode, - "Enable developer mode", + .clicked() + { + app_action |= AppAction::BackendTask(BackendTask::SystemTask( + SystemTask::UpdateThemePreference(ThemeMode::Light), + )); + } + if ui + .selectable_value( + &mut self.theme_preference, + ThemeMode::Dark, + "🌙 Dark", ) - .show(ui) .clicked() - { - // Update the global developer mode in config - if let Ok(mut config) = Config::load() { - config.developer_mode = Some(self.developer_mode); - if let Err(e) = config.save() { - eprintln!("Failed to save config to .env: {e}"); - } + { + app_action |= AppAction::BackendTask(BackendTask::SystemTask( + SystemTask::UpdateThemePreference(ThemeMode::Dark), + )); + } + }); + }); + }); - // Update developer mode for all contexts - self.mainnet_app_context - .enable_developer_mode(self.developer_mode); + // Dash-QT Path + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); - if let Some(ref testnet_ctx) = self.testnet_app_context - { - testnet_ctx - .enable_developer_mode(self.developer_mode); - } + ui.label( + egui::RichText::new("Dash Core Executable Path") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(8.0); + + ui.horizontal(|ui| { + if ui.button("Select File").clicked() + && let Some(path) = rfd::FileDialog::new().pick_file() + { + let file_name = path.file_name().and_then(|f| f.to_str()); + if let Some(file_name) = file_name { + self.custom_dash_qt_path = None; + self.custom_dash_qt_error_message = None; + + // Handle macOS .app bundles + let resolved_path = if cfg!(target_os = "macos") + && path.extension().and_then(|s| s.to_str()) == Some("app") + { + path.join("Contents").join("MacOS").join("Dash-Qt") + } else { + path.clone() + }; + + // Check if the resolved path exists and is valid + let is_valid = if cfg!(target_os = "windows") { + file_name.to_ascii_lowercase().ends_with("dash-qt.exe") + } else if cfg!(target_os = "macos") { + file_name.eq_ignore_ascii_case("dash-qt") + || (file_name.to_ascii_lowercase().ends_with(".app") + && resolved_path.exists()) + } else { + file_name.eq_ignore_ascii_case("dash-qt") + }; + + if is_valid { + self.custom_dash_qt_path = Some(resolved_path); + self.custom_dash_qt_error_message = None; + self.save().expect("Expected to save db settings"); + } else { + let required_file_name = if cfg!(target_os = "windows") { + "dash-qt.exe" + } else if cfg!(target_os = "macos") { + "Dash-Qt or Dash-Qt.app" + } else { + "dash-qt" + }; + self.custom_dash_qt_error_message = Some(format!( + "Invalid file: Please select a valid '{}'.", + required_file_name + )); + } + } + } - if let Some(ref devnet_ctx) = self.devnet_app_context { - devnet_ctx - .enable_developer_mode(self.developer_mode); - } + if self.custom_dash_qt_path.is_some() && ui.button("Clear").clicked() { + self.custom_dash_qt_path = Some(PathBuf::new()); + self.custom_dash_qt_error_message = None; + self.save().expect("Expected to save db settings"); + } + }); - if let Some(ref local_ctx) = self.local_app_context { - local_ctx - .enable_developer_mode(self.developer_mode); - } - } - } - ui.label( - egui::RichText::new( - "Enables advanced features and less strict validation", - ) - .color(DashColors::TEXT_SECONDARY), - ); - }); + if let Some(ref file) = self.custom_dash_qt_path { + if !file.as_os_str().is_empty() { + ui.horizontal(|ui| { + ui.label("Path:"); + ui.label( + egui::RichText::new(format_path_for_display(file)) + .color(DashColors::SUCCESS) + .italics(), + ); + }); + } + } else if let Some(ref error) = self.custom_dash_qt_error_message { + let error_color = Color32::from_rgb(255, 100, 100); + let error = error.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(&error).color(error_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.custom_dash_qt_error_message = None; + } }); }); + } - // Theme Selection Section - ui.add_space(16.0); - ui.group(|ui| { - ui.vertical(|ui| { - ui.horizontal(|ui| { - ui.label( - egui::RichText::new("Theme:") - .strong() - .color(DashColors::text_primary(dark_mode)), - ); + // Configuration Options + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + ui.label( + egui::RichText::new("Configuration Options") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(8.0); + + ui.horizontal(|ui| { + if StyledCheckbox::new(&mut self.overwrite_dash_conf, "Overwrite dash.conf") + .show(ui) + .clicked() + { + self.save().expect("Expected to save db settings"); + } + ui.label( + egui::RichText::new("Auto-configure required settings") + .color(DashColors::TEXT_SECONDARY) + .italics(), + ); + }); - egui::ComboBox::from_id_salt("theme_selection") - .selected_text(match self.theme_preference { - ThemeMode::Light => "Light", - ThemeMode::Dark => "Dark", - ThemeMode::System => "System", - }) - .show_ui(ui, |ui| { - if ui.selectable_value(&mut self.theme_preference, ThemeMode::System, "System").clicked() { - app_action |= AppAction::BackendTask(BackendTask::SystemTask( - SystemTask::UpdateThemePreference(ThemeMode::System) - )); - } - if ui.selectable_value(&mut self.theme_preference, ThemeMode::Light, "Light").clicked() { - app_action |= AppAction::BackendTask(BackendTask::SystemTask( - SystemTask::UpdateThemePreference(ThemeMode::Light) - )); - } - if ui.selectable_value(&mut self.theme_preference, ThemeMode::Dark, "Dark").clicked() { - app_action |= AppAction::BackendTask(BackendTask::SystemTask( - SystemTask::UpdateThemePreference(ThemeMode::Dark) - )); - } - }); - }); - ui.label( - egui::RichText::new( - "System: follows your OS theme • Light/Dark: force specific theme", - ) - .color(DashColors::TEXT_SECONDARY), - ); - }); - }); + // Disable ZMQ toggle (requires restart) + ui.add_space(6.0); + ui.horizontal(|ui| { + if StyledCheckbox::new(&mut self.disable_zmq, "Disable ZMQ (requires restart)") + .show(ui) + .clicked() + { + // Persist immediately via context + let _ = self + .current_app_context() + .update_disable_zmq(self.disable_zmq); + } + }); - // Configuration Requirements Section (only show if not overwriting dash.conf) - if !self.overwrite_dash_conf { - ui.add_space(16.0); + ui.add_space(8.0); + + ui.horizontal(|ui| { + if StyledCheckbox::new(&mut self.developer_mode, "Developer mode") + .show(ui) + .clicked() + && let Ok(mut config) = Config::load() + { + config.developer_mode = Some(self.developer_mode); + if let Err(e) = config.save() { + eprintln!("Failed to save config: {e}"); + } - ui.group(|ui| { - ui.vertical(|ui| { - ui.label( - egui::RichText::new("Manual Configuration Required") - .strong() - .color(DashColors::WARNING), - ); - ui.add_space(8.0); - - let (network_name, zmq_ports) = match self.current_network { - Network::Dash => ("Mainnet", ("23708", "23708")), - Network::Testnet => ("Testnet", ("23709", "23709")), - Network::Devnet => ("Devnet", ("23710", "23710")), - Network::Regtest => ("Regtest", ("20302", "20302")), - _ => ("Unknown", ("0", "0")), - }; - - ui.label( - egui::RichText::new(format!( - "Add these lines to your {} dash.conf:", - network_name - )) - .color(DashColors::TEXT_PRIMARY), - ); + // Update all contexts + self.mainnet_app_context + .enable_developer_mode(self.developer_mode); + if let Some(ref ctx) = self.testnet_app_context { + ctx.enable_developer_mode(self.developer_mode); + } + if let Some(ref ctx) = self.devnet_app_context { + ctx.enable_developer_mode(self.developer_mode); + } + if let Some(ref ctx) = self.local_app_context { + ctx.enable_developer_mode(self.developer_mode); + } - ui.add_space(8.0); - - // Configuration code block - egui::Frame::new() - .fill(DashColors::INPUT_BACKGROUND) - .stroke(egui::Stroke::new(1.0, DashColors::BORDER)) - .corner_radius(egui::CornerRadius::same(6)) - .inner_margin(egui::Margin::same(12)) - .show(ui, |ui| { - ui.vertical(|ui| { - ui.label( - egui::RichText::new(format!( - "zmqpubrawtxlocksig=tcp://0.0.0.0:{}", - zmq_ports.0 - )) - .monospace() - .color(DashColors::TEXT_PRIMARY), - ); - if self.current_network != Network::Regtest { - ui.label( - egui::RichText::new(format!( - "zmqpubrawchainlock=tcp://0.0.0.0:{}", - zmq_ports.1 - )) - .monospace() - .color(DashColors::TEXT_PRIMARY), - ); + // TODO: When developer mode is disabled, stop SPV and switch to RPC. + // Remove this block once SPV is production-ready. + if !self.developer_mode { + // Stop SPV and switch to RPC for all network contexts + self.mainnet_app_context.stop_spv(); + if self.mainnet_app_context.core_backend_mode() == CoreBackendMode::Spv { + self.mainnet_app_context.set_core_backend_mode(CoreBackendMode::Rpc); + } + self.backend_modes.insert(Network::Dash, CoreBackendMode::Rpc); + + if let Some(ref ctx) = self.testnet_app_context { + ctx.stop_spv(); + if ctx.core_backend_mode() == CoreBackendMode::Spv { + ctx.set_core_backend_mode(CoreBackendMode::Rpc); + } + self.backend_modes.insert(Network::Testnet, CoreBackendMode::Rpc); + } + if let Some(ref ctx) = self.devnet_app_context { + ctx.stop_spv(); + if ctx.core_backend_mode() == CoreBackendMode::Spv { + ctx.set_core_backend_mode(CoreBackendMode::Rpc); + } + self.backend_modes.insert(Network::Devnet, CoreBackendMode::Rpc); + } + if let Some(ref ctx) = self.local_app_context { + ctx.stop_spv(); + if ctx.core_backend_mode() == CoreBackendMode::Spv { + ctx.set_core_backend_mode(CoreBackendMode::Rpc); + } + self.backend_modes.insert(Network::Regtest, CoreBackendMode::Rpc); + } + } + } + ui.label( + egui::RichText::new("Enable advanced features") + .color(DashColors::TEXT_SECONDARY) + .italics(), + ); + }); + + // Developer-only tools + if self.developer_mode { + ui.add_space(12.0); + ui.label( + egui::RichText::new("Developer Tools") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(6.0); + + ui.horizontal(|ui| { + if ui.button("Clear Platform Addresses").clicked() { + // Clear from database + let current_context = self.current_app_context(); + match current_context + .db + .clear_all_platform_addresses(¤t_context.network) + { + Ok(count) => { + tracing::info!( + "Cleared {} platform addresses from database", + count + ); + // Also clear from in-memory wallets + if let Ok(wallets) = current_context.wallets.read() { + for wallet_arc in wallets.values() { + if let Ok(mut wallet) = wallet_arc.write() { + // Clear platform address info + wallet.platform_address_info.clear(); + + // Remove platform addresses from known_addresses + wallet.known_addresses.retain(|_, path| { + !path.is_platform_payment(current_context.network) + }); + + // Remove platform addresses from watched_addresses + wallet.watched_addresses.retain(|path, _| { + !path.is_platform_payment(current_context.network) + }); + + // Remove platform addresses from address_balances + let platform_addrs: Vec<_> = wallet + .address_balances + .keys() + .filter(|addr| { + // Check if this address was a platform address + // by seeing if it's not in known_addresses anymore + !wallet.known_addresses.contains_key(*addr) + }) + .cloned() + .collect(); + for addr in platform_addrs { + wallet.address_balances.remove(&addr); } - }); - }); - }); - }); + } + } + } + } + Err(e) => { + tracing::error!("Failed to clear platform addresses: {}", e); + } + } } + ui.label( + egui::RichText::new("Removes all Platform addresses for testing sync") + .color(DashColors::TEXT_SECONDARY) + .italics(), + ); }); + } + + ui.add_space(8.0); + + ui.horizontal(|ui| { + if StyledCheckbox::new( + &mut self.close_dash_qt_on_exit, + "Close Dash-Qt when DET exits", + ) + .show(ui) + .clicked() + { + // Save to database + match self + .mainnet_app_context + .db + .update_close_dash_qt_on_exit(self.close_dash_qt_on_exit) + { + Ok(_) => { + tracing::debug!( + "close_dash_qt_on_exit setting saved: {}", + self.close_dash_qt_on_exit + ); + } + Err(e) => { + tracing::error!( + "Failed to save close_dash_qt_on_exit setting: {:?}", + e + ); + } + } + } + ui.label( + egui::RichText::new(if self.close_dash_qt_on_exit { + "Dash-Qt will close automatically" + } else { + "Dash-Qt will keep running" + }) + .color(DashColors::TEXT_SECONDARY) + .italics(), + ); }); + + // TODO: SPV settings are hidden when developer mode is OFF. + // Remove the developer_mode checks once SPV is production-ready. + if self.developer_mode { + ui.add_space(12.0); + ui.separator(); + ui.add_space(12.0); + + // SPV Peer Source + ui.label( + egui::RichText::new("SPV Peer Source") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(6.0); + ui.label( + egui::RichText::new( + "Choose how SPV finds peers for blockchain sync on mainnet/testnet.", + ) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(8.0); + + ui.horizontal(|ui| { + if StyledCheckbox::new(&mut self.use_local_spv_node, "Use local Dash Core node") + .show(ui) + .clicked() + { + // Save to database + let _ = self + .mainnet_app_context + .db + .update_use_local_spv_node(self.use_local_spv_node); + + // Update all network contexts + self.mainnet_app_context + .spv_manager() + .set_use_local_node(self.use_local_spv_node); + if let Some(ref ctx) = self.testnet_app_context { + ctx.spv_manager().set_use_local_node(self.use_local_spv_node); + } + if let Some(ref ctx) = self.devnet_app_context { + ctx.spv_manager().set_use_local_node(self.use_local_spv_node); + } + if let Some(ref ctx) = self.local_app_context { + ctx.spv_manager().set_use_local_node(self.use_local_spv_node); + } + } + ui.label( + egui::RichText::new(if self.use_local_spv_node { + "Connect to local node at 127.0.0.1" + } else { + "Use DNS seed discovery (default)" + }) + .color(DashColors::TEXT_SECONDARY) + .italics(), + ); + }); + ui.add_space(4.0); + ui.label( + egui::RichText::new( + "Note: Changes take effect on next SPV sync start. Devnet/local networks always use configured host.", + ) + .size(11.0) + .color(DashColors::text_secondary(dark_mode)) + .italics(), + ); + + // Auto-start SPV on startup + ui.add_space(12.0); + ui.separator(); + ui.add_space(12.0); + + ui.label( + egui::RichText::new("SPV Auto-Start") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(6.0); + ui.label( + egui::RichText::new( + "Automatically start SPV sync when the app opens.", + ) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(8.0); + + ui.horizontal(|ui| { + if StyledCheckbox::new(&mut self.auto_start_spv, "Auto-start SPV on startup") + .show(ui) + .clicked() + { + // Save to database + let _ = self + .mainnet_app_context + .db + .update_auto_start_spv(self.auto_start_spv); + } + ui.label( + egui::RichText::new(if self.auto_start_spv { + "Enabled" + } else { + "Disabled" + }) + .color(if self.auto_start_spv { + DashColors::DASH_BLUE + } else { + DashColors::text_secondary(dark_mode) + }), + ); + }); + } + + ui.add_space(12.0); + ui.separator(); + ui.add_space(12.0); + + ui.label( + egui::RichText::new("Database Maintenance") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(6.0); + ui.label( + egui::RichText::new("Remove all local data for the current network (wallets, contacts, identities, tokens, etc.).") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(8.0); + + let button_label = format!("Clear {} Database", self.current_network_label()); + let clear_button = egui::Button::new( + egui::RichText::new(button_label).color(DashColors::WHITE), + ) + .fill(DashColors::ERROR) + .stroke(egui::Stroke::NONE) + .corner_radius(Shape::RADIUS_MD) + .min_size(egui::vec2(0.0, 36.0)); + + if ui.add(clear_button).clicked() { + let message = format!( + "This permanently deletes all local database entries for {}. This includes wallets, tokens, contacts, and cached identity data. This cannot be undone.", + self.current_network_label() + ); + self.db_clear_dialog = Some( + ConfirmationDialog::new("Clear Database", message) + .confirm_text(Some("Delete Data")) + .cancel_text(Some("Cancel")) + .danger_mode(true), + ); + self.db_clear_message = None; + } + + if let Some(feedback) = self.db_clear_message.clone() { + ui.add_space(8.0); + let (message, color) = match &feedback { + DatabaseClearMessage::Success(msg) => (msg.as_str(), DashColors::SUCCESS), + DatabaseClearMessage::Error(msg) => (msg.as_str(), DashColors::ERROR), + }; + + egui::Frame::new() + .fill(color.gamma_multiply(0.08)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .stroke(egui::Stroke::new(1.0, color)) + .corner_radius(Shape::RADIUS_MD) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(message).color(color)); + ui.add_space(8.0); + if ui.small_button("Dismiss").clicked() { + self.db_clear_message = None; + } + }); + }); + } + + if self.db_clear_dialog.is_some() { + app_action |= self.show_database_clear_confirmation(ui); + } + + // SPV Maintenance section + // TODO: SPV maintenance is hidden when developer mode is OFF. + // Remove the developer_mode check once SPV is production-ready. + if self.developer_mode { + let current_backend_mode = self.current_app_context().core_backend_mode(); + if current_backend_mode == CoreBackendMode::Spv { + let snapshot = self.current_app_context().spv_manager().status(); + ui.add_space(12.0); + ui.separator(); + ui.add_space(12.0); + app_action |= self.render_spv_maintenance_controls(ui, &snapshot); + } + } }); + }); app_action } - /// Render a single row for the network table - fn render_network_row(&mut self, ui: &mut Ui, network: Network, name: &str) -> AppAction { - let mut app_action = AppAction::None; + fn render_spv_sync_progress(&mut self, ui: &mut Ui, snapshot: &SpvStatusSnapshot) { + if let Some(detailed) = &snapshot.detailed_progress { + match detailed.sync_stage { + SyncStage::DownloadingFilterHeaders { current, target } => { + let baseline = current.min(target); + if let Some(existing) = self.filter_headers_stage_start { + self.filter_headers_stage_start = Some(existing.min(target)); + } else { + self.filter_headers_stage_start = Some(baseline); + } + } + _ => { + self.filter_headers_stage_start = None; + } + } + } else { + self.filter_headers_stage_start = None; + } + let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.label(name); - // Check network status - let is_working = self.check_network_status(network); - let status_color = if is_working { - DashColors::success_color(dark_mode) // Theme-aware green - } else { - DashColors::error_color(dark_mode) // Theme-aware red - }; + // Raw sync status display + egui::Frame::new() + .fill(DashColors::glass_white(dark_mode)) + .corner_radius(Shape::RADIUS_SM) + .inner_margin(12.0) + .show(ui, |ui| { + ui.label( + egui::RichText::new("SPV Sync Status") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); - // Display status indicator - ui.colored_label(status_color, if is_working { "Online" } else { "Offline" }); + ui.add_space(8.0); + + // Display sync information in a grid + egui::Grid::new("spv_sync_info") + .num_columns(2) + .spacing([16.0, 4.0]) + .show(ui, |ui| { + // Show current status detail + if let Some(detail) = self.spv_status_detail(snapshot) { + ui.label( + egui::RichText::new("Status:") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.label(detail); + ui.end_row(); + } - if network == Network::Testnet && self.testnet_app_context.is_none() { - ui.label("(No configs for testnet loaded)"); - ui.end_row(); - return AppAction::None; + // Prefer detailed header progress when available + if snapshot.detailed_progress.is_some() { + // Add separator between status and progress bars + ui.separator(); + ui.separator(); + ui.end_row(); + + // Headers progress + ui.label( + egui::RichText::new("Headers:") + .color(DashColors::text_secondary(dark_mode)), + ); + let headers_progress = self.calculate_headers_progress(snapshot); + ui.add(egui::ProgressBar::new(headers_progress).show_percentage()); + ui.end_row(); + + // Validating headers progress (formerly masternode lists) + ui.label( + egui::RichText::new("Masternode Lists:") + .color(DashColors::text_secondary(dark_mode)), + ); + let validating_progress = + self.calculate_validating_headers_progress(snapshot); + ui.add(egui::ProgressBar::new(validating_progress).show_percentage()); + ui.end_row(); + + // Filter headers progress + ui.label( + egui::RichText::new("Filter Headers:") + .color(DashColors::text_secondary(dark_mode)), + ); + let filter_headers_progress = + self.calculate_filter_headers_progress(snapshot); + ui.add( + egui::ProgressBar::new(filter_headers_progress).show_percentage(), + ); + ui.end_row(); + + // Filters progress + ui.label( + egui::RichText::new("Filters:") + .color(DashColors::text_secondary(dark_mode)), + ); + let filters_progress = self.calculate_filters_progress(snapshot); + ui.add(egui::ProgressBar::new(filters_progress).show_percentage()); + ui.end_row(); + + // Blocks progress bar + ui.label( + egui::RichText::new("Blocks:") + .color(DashColors::text_secondary(dark_mode)), + ); + let blocks_progress = self.calculate_blocks_progress(snapshot); + ui.add(egui::ProgressBar::new(blocks_progress).show_percentage()); + ui.end_row(); + } else if let Some(ev) = &snapshot.sync_progress { + // Event-driven progress (updates most frequently) + ui.label( + egui::RichText::new("Synced:") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.label(format!("Headers height: {}", ev.header_height)); + ui.end_row(); + + // Add separator between stats and progress bars + ui.separator(); + ui.separator(); + ui.end_row(); + + // Progress bars for different components + let headers_progress = self.calculate_headers_progress(snapshot); + ui.label( + egui::RichText::new("Headers:") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add(egui::ProgressBar::new(headers_progress).show_percentage()); + ui.end_row(); + + let validating_progress = + self.calculate_validating_headers_progress(snapshot); + ui.label( + egui::RichText::new("Masternode Lists:") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add(egui::ProgressBar::new(validating_progress).show_percentage()); + ui.end_row(); + + let filter_headers_progress = + self.calculate_filter_headers_progress(snapshot); + ui.label( + egui::RichText::new("Filter Headers:") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add( + egui::ProgressBar::new(filter_headers_progress).show_percentage(), + ); + ui.end_row(); + + let filters_progress = self.calculate_filters_progress(snapshot); + ui.label( + egui::RichText::new("Filters:") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add(egui::ProgressBar::new(filters_progress).show_percentage()); + ui.end_row(); + + let blocks_progress = self.calculate_blocks_progress(snapshot); + ui.label( + egui::RichText::new("Blocks:") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add(egui::ProgressBar::new(blocks_progress).show_percentage()); + ui.end_row(); + } + }); + }); + } + + fn render_spv_maintenance_controls( + &mut self, + ui: &mut Ui, + snapshot: &SpvStatusSnapshot, + ) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + ui.label( + egui::RichText::new("SPV Maintenance") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(6.0); + ui.label( + egui::RichText::new("Clear cached headers and filter data for this network.") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(8.0); + + let clear_button = + egui::Button::new(egui::RichText::new("Clear SPV Data").color(DashColors::WHITE)) + .fill(DashColors::ERROR) + .stroke(egui::Stroke::NONE) + .corner_radius(Shape::RADIUS_MD) + .min_size(egui::vec2(0.0, 36.0)); + + let is_active = snapshot.status.is_active(); + let mut button_response = ui.add_enabled(!is_active, clear_button); + if is_active { + button_response = + button_response.on_disabled_hover_text("Stop the SPV client before clearing data"); } - if network == Network::Devnet && self.devnet_app_context.is_none() { - ui.label("(No configs for devnet loaded)"); - ui.end_row(); - return AppAction::None; + + if button_response.clicked() { + let network_label = self.current_network_label(); + let message = format!( + "This will delete cached SPV data for {}. The next connection will trigger a full resync.", + network_label + ); + self.spv_clear_dialog = Some( + ConfirmationDialog::new("Clear SPV Data", message) + .confirm_text(Some("Clear Data")) + .cancel_text(Some("Keep Data")) + .danger_mode(true), + ); + self.spv_clear_message = None; } - if network == Network::Regtest && self.local_app_context.is_none() { - ui.label("(No configs for local loaded)"); - ui.end_row(); - return AppAction::None; + + if let Some(feedback) = self.spv_clear_message.clone() { + ui.add_space(8.0); + + let (message, color) = match &feedback { + SpvClearMessage::Success(msg) => (msg.as_str(), DashColors::SUCCESS), + SpvClearMessage::Error(msg) => (msg.as_str(), DashColors::ERROR), + }; + + egui::Frame::new() + .fill(color.gamma_multiply(0.08)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .stroke(egui::Stroke::new(1.0, color)) + .corner_radius(Shape::RADIUS_MD) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(message).color(color)); + ui.add_space(8.0); + if ui.small_button("Dismiss").clicked() { + self.spv_clear_message = None; + } + }); + }); } - // Network selection - let mut is_selected = self.current_network == network; - if StyledCheckbox::new(&mut is_selected, "").show(ui).clicked() && is_selected { - self.current_network = network; - app_action = AppAction::SwitchNetwork(network); - // Recheck in 1 second - self.recheck_time = Some( - (SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - + Duration::from_secs(1)) - .as_millis() as u64, - ); + if self.spv_clear_dialog.is_some() { + action |= self.show_spv_clear_confirmation(ui); } - // Add a button to start the network - let start_enabled = if let Some(path) = self.custom_dash_qt_path.as_ref() { - !path.as_os_str().is_empty() && path.is_file() - } else { - false - }; + action + } - if network != Network::Regtest { - ui.add_enabled_ui(start_enabled, |ui| { - if ui - .button("Start") - .on_disabled_hover_text( - "Please select path to dash-qt binary in Advanced Settings", - ) - .clicked() - { - app_action = - AppAction::BackendTask(BackendTask::CoreTask(CoreTask::StartDashQT( - network, - self.custom_dash_qt_path - .clone() - .expect("Some() checked above"), - self.overwrite_dash_conf, - ))); + fn show_spv_clear_confirmation(&mut self, ui: &mut Ui) -> AppAction { + if let Some(dialog) = self.spv_clear_dialog.as_mut() { + let response = dialog.show(ui); + if let Some(result) = response.inner.dialog_response { + self.spv_clear_dialog = None; + match result { + ConfirmationStatus::Confirmed => { + match self.current_app_context().clear_spv_data() { + Ok(_) => { + self.spv_clear_message = Some(SpvClearMessage::Success(format!( + "Cleared SPV data for {}. Reconnect to start a new sync.", + self.current_network_label() + ))); + } + Err(err) => { + self.spv_clear_message = Some(SpvClearMessage::Error(format!( + "Failed to clear SPV data: {}", + err + ))); + } + } + } + ConfirmationStatus::Canceled => { + // No-op + } } - }); + } } + AppAction::None + } - // Add a text field for the dashmate password - if network == Network::Regtest { - ui.spacing_mut().item_spacing.x = 5.0; - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.add( - egui::TextEdit::singleline(&mut self.local_network_dashmate_password) - .desired_width(100.0) - .text_color(crate::ui::theme::DashColors::text_primary(dark_mode)) - .background_color(crate::ui::theme::DashColors::input_background(dark_mode)), - ); - if ui.button("Save Password").clicked() { - // 1) Reload the config - if let Ok(mut config) = Config::load() - && let Some(local_cfg) = config.config_for_network(Network::Regtest).clone() - { - let updated_local_config = local_cfg - .update_core_rpc_password(self.local_network_dashmate_password.clone()); - config - .update_config_for_network(Network::Regtest, updated_local_config.clone()); - if let Err(e) = config.save() { - eprintln!("Failed to save config to .env: {e}"); + fn show_database_clear_confirmation(&mut self, ui: &mut Ui) -> AppAction { + if let Some(dialog) = self.db_clear_dialog.as_mut() { + let response = dialog.show(ui); + if let Some(result) = response.inner.dialog_response { + self.db_clear_dialog = None; + match result { + ConfirmationStatus::Confirmed => { + match self.current_app_context().clear_network_database() { + Ok(_) => { + self.db_clear_message = + Some(DatabaseClearMessage::Success(format!( + "Cleared {} database. Restart or resync to rebuild state.", + self.current_network_label() + ))); + return AppAction::Refresh; + } + Err(err) => { + self.db_clear_message = Some(DatabaseClearMessage::Error(format!( + "Failed to clear database: {}", + err + ))); + } + } } + ConfirmationStatus::Canceled => { + // No-op + } + } + } + } + AppAction::None + } - // 5) Update our local AppContext in memory - if let Some(local_app_context) = &self.local_app_context { - { - // Overwrite the config field with the new password - let mut cfg_lock = local_app_context.config.write().unwrap(); - *cfg_lock = updated_local_config; - } + fn current_network_label(&self) -> &'static str { + match self.current_network { + Network::Dash => "Mainnet", + Network::Testnet => "Testnet", + Network::Devnet => "Devnet", + Network::Regtest => "Local", + _ => "this network", + } + } - // 6) Re-init the client & sdk from the updated config - if let Err(e) = Arc::clone(local_app_context).reinit_core_client_and_sdk() { - eprintln!("Failed to re-init local RPC client and sdk: {}", e); - } else { - // Trigger SwitchNetworks - app_action = AppAction::SwitchNetwork(Network::Regtest); - } + fn calculate_headers_progress(&self, snapshot: &SpvStatusSnapshot) -> f32 { + if let Some(detailed) = &snapshot.detailed_progress { + match &detailed.sync_stage { + SyncStage::DownloadingHeaders { start, end } => { + // Respect restored checkpoints: show progress relative to the download window. + if end > start { + let window = (end - start) as f32; + let current = detailed.sync_progress.header_height; + let clamped = current.clamp(*start, *end) - start; + (clamped as f32 / window).clamp(0.0, 1.0) + } else { + 0.0 } } + SyncStage::ValidatingHeaders { .. } + | SyncStage::StoringHeaders { .. } + | SyncStage::DownloadingFilterHeaders { .. } + | SyncStage::DownloadingFilters { .. } + | SyncStage::DownloadingBlocks { .. } + | SyncStage::Complete => 1.0, + SyncStage::Failed(_) => 0.0, + _ => 0.0, + } + } else if let Some(progress) = &snapshot.sync_progress { + if progress.header_height == 0 { + 0.0 + } else { + // Without detailed context fall back to comparing against masternode progress + (progress.masternode_height as f32 / progress.header_height as f32).clamp(0.0, 1.0) } } else { - ui.label(""); + 0.0 } + } - if network == Network::Devnet { - if ui.button("Clear local Platform data").clicked() { - app_action = - AppAction::BackendTask(BackendTask::SystemTask(SystemTask::WipePlatformData)); + fn calculate_filter_headers_progress(&self, snapshot: &SpvStatusSnapshot) -> f32 { + if let Some(detailed) = &snapshot.detailed_progress { + if detailed.peer_best_height == 0 { + return 0.0; + } + match &detailed.sync_stage { + SyncStage::DownloadingFilterHeaders { current, target } => { + let current = *current; + let target = *target; + if target == 0 { + return 0.0; + } + + let start = self + .filter_headers_stage_start + .unwrap_or(current) + .min(target); + let span = target.saturating_sub(start); + if span == 0 { + if current >= target { 1.0 } else { 0.0 } + } else { + let progress = current.saturating_sub(start); + (progress as f32 / span as f32).clamp(0.0, 1.0) + } + } + SyncStage::DownloadingFilters { .. } + | SyncStage::DownloadingBlocks { .. } + | SyncStage::Complete => (detailed.sync_progress.filter_header_height as f32 + / detailed.peer_best_height as f32) + .clamp(0.0, 1.0), + SyncStage::Failed(_) => 0.0, + _ => 0.0, } } else { - ui.label(""); + 0.0 } + } - ui.end_row(); - app_action + fn calculate_filters_progress(&self, snapshot: &SpvStatusSnapshot) -> f32 { + if let Some(detailed) = &snapshot.detailed_progress { + match &detailed.sync_stage { + SyncStage::DownloadingFilters { completed, total } => { + if *total == 0 { + 0.0 + } else { + (*completed as f32 / *total as f32).clamp(0.0, 1.0) + } + } + SyncStage::DownloadingBlocks { .. } | SyncStage::Complete => 1.0, + SyncStage::Failed(_) => 0.0, + _ => 0.0, + } + } else { + 0.0 + } + } + + fn calculate_validating_headers_progress(&self, snapshot: &SpvStatusSnapshot) -> f32 { + if snapshot.status == SpvStatus::Running { + return 1.0; + } + + if let Some(detailed) = &snapshot.detailed_progress { + match &detailed.sync_stage { + SyncStage::ValidatingHeaders { .. } | SyncStage::StoringHeaders { .. } => { + if detailed.peer_best_height == 0 { + 0.0 + } else { + let best_height = detailed.peer_best_height as f32; + let validated = detailed.sync_progress.masternode_height as f32; + (validated / best_height).clamp(0.0, 1.0) + } + } + SyncStage::DownloadingFilterHeaders { .. } + | SyncStage::DownloadingFilters { .. } + | SyncStage::DownloadingBlocks { .. } + | SyncStage::Complete => 1.0, + SyncStage::Failed(_) => 0.0, + _ => 0.0, + } + } else if let Some(progress) = &snapshot.sync_progress { + if progress.header_height == 0 { + 0.0 + } else { + (progress.masternode_height as f32 / progress.header_height as f32).clamp(0.0, 1.0) + } + } else { + 0.0 + } + } + + fn calculate_blocks_progress(&self, snapshot: &SpvStatusSnapshot) -> f32 { + if snapshot.status == SpvStatus::Running { + return 1.0; + } + + if let Some(detailed) = &snapshot.detailed_progress { + match &detailed.sync_stage { + SyncStage::DownloadingBlocks { .. } => { + if detailed.peer_best_height == 0 { + 0.0 + } else { + let processed_height = detailed + .sync_progress + .last_synced_filter_height + .unwrap_or(0); + (processed_height as f32 / detailed.peer_best_height as f32).clamp(0.0, 1.0) + } + } + SyncStage::Complete => 1.0, + SyncStage::Failed(_) => 0.0, + _ => 0.0, + } + } else { + 0.0 + } } /// Check if the network is working @@ -662,6 +1705,78 @@ impl NetworkChooserScreen { _ => false, } } + + fn any_rpc_backend(&self) -> bool { + self.backend_modes + .iter() + .any(|(network, mode)| *mode == CoreBackendMode::Rpc && self.has_context_for(*network)) + } + + fn has_context_for(&self, network: Network) -> bool { + match network { + Network::Dash => true, + Network::Testnet => self.testnet_app_context.is_some(), + Network::Devnet => self.devnet_app_context.is_some(), + Network::Regtest => self.local_app_context.is_some(), + _ => false, + } + } + + fn spv_status_detail(&self, snapshot: &SpvStatusSnapshot) -> Option { + if let SpvStatus::Error = snapshot.status + && let Some(err) = &snapshot.last_error + { + return Some(err.clone()); + } + + if let Some(progress) = snapshot.detailed_progress.as_ref() { + return Some(Self::format_detailed_progress(progress)); + } + + snapshot.last_error.clone() + } + + fn format_detailed_progress(progress: &DetailedSyncProgress) -> String { + let mut message = match &progress.sync_stage { + SyncStage::Connecting => "Connecting to peers".to_string(), + SyncStage::QueryingPeerHeight => "Querying peer heights".to_string(), + SyncStage::DownloadingHeaders { .. } => { + format!( + "Headers: {} / {}", + progress.sync_progress.header_height, progress.peer_best_height, + ) + } + SyncStage::ValidatingHeaders { batch_size } => { + format!( + "Masternode lists (batch {batch_size}) | Height {}", + progress.sync_progress.masternode_height + ) + } + SyncStage::StoringHeaders { batch_size } => { + format!( + "Storing headers (batch {batch_size}) | Height {}", + progress.sync_progress.header_height + ) + } + SyncStage::Complete => "Sync complete".to_string(), + SyncStage::Failed(reason) => format!("Failed: {reason}"), + SyncStage::DownloadingFilterHeaders { current, target } => { + format!("Filter headers: {current} / {target}") + } + SyncStage::DownloadingFilters { completed, total } => { + format!("Filters: {completed} / {total}") + } + SyncStage::DownloadingBlocks { pending } => { + format!("Blocks: {pending}") + } + }; + + if progress.sync_progress.peer_count > 0 { + message = format!("{message} | Peers: {}", progress.sync_progress.peer_count); + } + + message + } } impl ScreenLike for NetworkChooserScreen { @@ -676,6 +1791,21 @@ impl ScreenLike for NetworkChooserScreen { self.overwrite_dash_conf = settings.overwrite_dash_conf; self.theme_preference = settings.theme_mode; } + + self.backend_modes + .insert(Network::Dash, self.mainnet_app_context.core_backend_mode()); + if let Some(ctx) = &self.testnet_app_context { + self.backend_modes + .insert(Network::Testnet, ctx.core_backend_mode()); + } + if let Some(ctx) = &self.devnet_app_context { + self.backend_modes + .insert(Network::Devnet, ctx.core_backend_mode()); + } + if let Some(ctx) = &self.local_app_context { + self.backend_modes + .insert(Network::Regtest, ctx.core_backend_mode()); + } } fn display_message(&mut self, message: &str, _message_type: super::MessageType) { @@ -739,17 +1869,22 @@ impl ScreenLike for NetworkChooserScreen { // Recheck both network status every 3 seconds let recheck_time = Duration::from_secs(3); if action == AppAction::None { - let current_time = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards"); - if let Some(time) = self.recheck_time { - if current_time.as_millis() as u64 >= time { - action = - AppAction::BackendTask(BackendTask::CoreTask(CoreTask::GetBestChainLocks)); + if self.any_rpc_backend() { + let current_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards"); + if let Some(time) = self.recheck_time { + if current_time.as_millis() as u64 >= time { + action = AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::GetBestChainLocks, + )); + self.recheck_time = Some((current_time + recheck_time).as_millis() as u64); + } + } else { self.recheck_time = Some((current_time + recheck_time).as_millis() as u64); } } else { - self.recheck_time = Some((current_time + recheck_time).as_millis() as u64); + self.recheck_time = None; } } diff --git a/src/ui/tokens/add_token_by_id_screen.rs b/src/ui/tokens/add_token_by_id_screen.rs index 4e4c5e183..45185f97c 100644 --- a/src/ui/tokens/add_token_by_id_screen.rs +++ b/src/ui/tokens/add_token_by_id_screen.rs @@ -159,38 +159,33 @@ impl AddTokenByIdScreen { /// Renders a simple "Success!" screen after completion fn show_success_screen(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - ui.heading( - RichText::new("Token Added Successfully") - .color(Color32::from_rgb(0, 150, 0)) - .size(24.0), - ); - - ui.add_space(10.0); - if let Some(token) = &self.selected_token { - ui.label(format!( - "'{}' has been added to your tokens.", - token.token_name - )); - } + let action = crate::ui::helpers::show_success_screen( + ui, + "Token Added Successfully".to_string(), + vec![ + ( + "Add another token".to_string(), + AppAction::Custom("add_another".to_string()), + ), + ( + "Back to Tokens screen".to_string(), + AppAction::PopScreenAndRefresh, + ), + ], + ); - ui.add_space(20.0); - if ui.button("Add another token").clicked() { - self.status = AddTokenStatus::Idle; - self.contract_or_token_id_input.clear(); - self.fetched_contract = None; - self.selected_token = None; - self.try_token_id_next = false; - } + // Handle the custom action to reset the form + if let AppAction::Custom(ref s) = action + && s == "add_another" + { + self.status = AddTokenStatus::Idle; + self.contract_or_token_id_input.clear(); + self.fetched_contract = None; + self.selected_token = None; + self.try_token_id_next = false; + return AppAction::None; + } - if ui.button("Back to Tokens screen").clicked() { - action = AppAction::PopScreenAndRefresh; - } - }); action } diff --git a/src/ui/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs index 39fe3ec8d..8373679f4 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -1,11 +1,12 @@ +use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::{Component, ComponentResponse}; -use crate::ui::contracts_documents::group_actions_screen::GroupActionsScreen; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser, render_group_action_text}; +use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::theme::DashColors; use crate::ui::tokens::tokens_screen::IdentityTokenIdentifier; use dash_sdk::dpp::data_contract::GroupContractPosition; @@ -20,6 +21,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::platform::{Identifier, IdentityPublicKey}; use eframe::egui::{self, Color32, Context, Ui}; +use eframe::egui::{Frame, Margin}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -32,11 +34,13 @@ use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::wallet::Wallet; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; -use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike}; +use crate::ui::{MessageType, Screen, ScreenLike}; use super::tokens_screen::IdentityTokenInfo; @@ -52,6 +56,7 @@ pub enum BurnTokensStatus { pub struct BurnTokensScreen { pub identity_token_info: IdentityTokenInfo, selected_key: Option, + show_advanced_options: bool, group: Option<(GroupContractPosition, Group)>, is_unilateral_group_member: bool, pub group_action_id: Option, @@ -73,8 +78,9 @@ pub struct BurnTokensScreen { // For password-based wallet unlocking, if needed selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, + // Fee result from completed operation + completed_fee_result: Option, } impl BurnTokensScreen { @@ -195,6 +201,7 @@ impl BurnTokensScreen { Self { identity_token_info, selected_key: possible_key, + show_advanced_options: false, group, is_unilateral_group_member, group_action_id: None, @@ -207,8 +214,8 @@ impl BurnTokensScreen { app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), + completed_fee_result: None, } } @@ -312,65 +319,30 @@ impl BurnTokensScreen { /// Renders a simple "Success!" screen after completion fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - if self.group_action_id.is_some() { - // This burn is already initiated by the group, we are just signing it - ui.heading("Group Burn Signing Successful."); - } else if !self.is_unilateral_group_member && self.group.is_some() { - ui.heading("Group Burn Initiated."); - } else { - ui.heading("Burn Successful."); - } - - ui.add_space(20.0); - - if self.group_action_id.is_some() { - if ui.button("Back to Group Actions").clicked() { - action = AppAction::PopScreenAndRefresh; - } - if ui.button("Back to Tokens").clicked() { - action = AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenMyTokenBalances, - ); - } - } else { - if ui.button("Back to Tokens").clicked() { - action = AppAction::PopScreenAndRefresh; - } - - if !self.is_unilateral_group_member && ui.button("Go to Group Actions").clicked() { - action = AppAction::PopThenAddScreenToMainScreen( - RootScreenType::RootScreenDocumentQuery, - Screen::GroupActionsScreen(GroupActionsScreen::new( - &self.app_context.clone(), - )), - ); - } - } - }); - action + crate::ui::helpers::show_group_token_success_screen_with_fee( + ui, + "Burn", + self.group_action_id.is_some(), + self.is_unilateral_group_member, + self.group.is_some(), + &self.app_context, + None, + ) } } impl ScreenLike for BurnTokensScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - if message.contains("Successfully burned tokens") || message == "BurnTokens" { - self.status = BurnTokensStatus::Complete; - } - } - MessageType::Error => { - self.status = BurnTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); - } - MessageType::Info => { - // no-op - } + if let MessageType::Error = message_type { + self.status = BurnTokensStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::BurnedTokens(fee_result) = backend_task_success_result { + self.completed_fee_result = Some(fee_result); + self.status = BurnTokensStatus::Complete; } } @@ -492,35 +464,52 @@ impl ScreenLike for BurnTokensScreen { } } else { // Possibly handle locked wallet scenario - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - - if needed_unlock && !just_unlocked { - // Must unlock before we can proceed + if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } return AppAction::None; } } - // 1) Key selection - ui.heading("1. Select the key to sign the Burn transaction"); + // Header with Advanced Options checkbox + ui.horizontal(|ui| { + ui.heading("Burn Tokens"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); ui.add_space(10.0); - let mut selected_identity = Some(self.identity_token_info.identity.clone()); - add_identity_key_chooser( - ui, - &self.app_context, - std::iter::once(&self.identity_token_info.identity), - &mut selected_identity, - &mut self.selected_key, - TransactionType::TokenAction, - ); - - ui.add_space(10.0); - ui.separator(); + // Key selection (only in advanced mode) + if self.show_advanced_options { + ui.heading("1. Select the key to sign the Burn transaction"); + ui.add_space(10.0); + add_key_chooser( + ui, + &self.app_context, + &self.identity_token_info.identity, + &mut self.selected_key, + TransactionType::TokenAction, + ); + ui.add_space(10.0); + ui.separator(); + } ui.add_space(10.0); - // 2) Amount to burn - ui.heading("2. Amount to burn"); + // Amount to burn + let step_num = if self.show_advanced_options { 2 } else { 1 }; + ui.heading(format!("{}. Amount to burn", step_num)); ui.add_space(5.0); if self.group_action_id.is_some() { ui.label( @@ -543,7 +532,8 @@ impl ScreenLike for BurnTokensScreen { ui.add_space(10.0); // Render text input for the public note - ui.heading("3. Public note (optional)"); + let step_num = if self.show_advanced_options { 3 } else { 2 }; + ui.heading(format!("{}. Public note (optional)", step_num)); ui.add_space(5.0); if self.group_action_id.is_some() { ui.label( @@ -571,6 +561,29 @@ impl ScreenLike for BurnTokensScreen { }); } + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions + + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + let button_text = render_group_action_text( ui, &self.group, @@ -579,6 +592,28 @@ impl ScreenLike for BurnTokensScreen { &self.group_action_id, ); + // Display estimated fee before action button + let estimated_fee = PlatformFeeEstimator::new().estimate_token_transition(); + ui.add_space(10.0); + let dark_mode = ui.ctx().style().visuals.dark_mode; + egui::Frame::new() + .fill(crate::ui::theme::DashColors::surface(dark_mode)) + .inner_margin(egui::Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated Fee:") + .color(crate::ui::theme::DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(crate::ui::theme::DashColors::text_primary(dark_mode)) + .strong(), + ); + }); + }); + // Burn button if self.app_context.is_developer_mode() || !button_text.contains("Test") { ui.add_space(10.0); @@ -641,36 +676,19 @@ impl ScreenLike for BurnTokensScreen { }); action |= central_panel_action; - action - } -} -impl ScreenWithWalletUnlock for BurnTokensScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 6c6083666..40d1a8c44 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -1,9 +1,11 @@ +use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::ui::components::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser}; +use crate::ui::helpers::{TransactionType, add_key_chooser}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -19,7 +21,7 @@ use dash_sdk::dpp::data_contract::associated_token::token_perpetual_distribution use dash_sdk::dpp::data_contract::TokenConfiguration; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; -use eframe::egui::{self, Color32, Context, Ui}; +use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; @@ -28,9 +30,10 @@ use crate::context::AppContext; use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::{IdentityType, QualifiedIdentity}; use crate::model::wallet::Wallet; +use crate::ui::theme::DashColors; use crate::ui::{MessageType, Screen, ScreenLike}; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::wallet_unlock_popup::{wallet_needs_unlock, try_open_wallet_no_password, WalletUnlockPopup, WalletUnlockResult}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; @@ -49,6 +52,7 @@ pub struct ClaimTokensScreen { pub identity: QualifiedIdentity, pub identity_token_basic_info: IdentityTokenBasicInfo, selected_key: Option, + show_advanced_options: bool, pub public_note: Option, token_contract: QualifiedContract, token_configuration: TokenConfiguration, @@ -58,8 +62,9 @@ pub struct ClaimTokensScreen { pub app_context: Arc, confirmation_dialog: Option, selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, + // Fee result from completed operation + completed_fee_result: Option, } impl ClaimTokensScreen { @@ -117,6 +122,7 @@ impl ClaimTokensScreen { identity, identity_token_basic_info, selected_key: possible_key.cloned(), + show_advanced_options: false, public_note: None, token_contract, token_configuration, @@ -126,8 +132,8 @@ impl ClaimTokensScreen { app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), + completed_fee_result: None, } } @@ -227,38 +233,27 @@ impl ClaimTokensScreen { } fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - ui.heading("Claimed Successfully!"); - - ui.add_space(20.0); - - if ui.button("Back to Tokens").clicked() { - action = AppAction::PopScreenAndRefresh; - } - }); - action + crate::ui::helpers::show_success_screen_with_info( + ui, + "Claimed Successfully!".to_string(), + vec![("Back to Tokens".to_string(), AppAction::PopScreenAndRefresh)], + None, + ) } } impl ScreenLike for ClaimTokensScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - if message.contains("Claimed") || message == "ClaimTokens" { - self.status = ClaimTokensStatus::Complete; - } - } - MessageType::Error => { - self.status = ClaimTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); - } - MessageType::Info => { - // no-op - } + if let MessageType::Error = message_type { + self.status = ClaimTokensStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::ClaimedTokens(fee_result) = backend_task_success_result { + self.completed_fee_result = Some(fee_result); + self.status = ClaimTokensStatus::Complete; } } @@ -358,27 +353,46 @@ impl ScreenLike for ClaimTokensScreen { } } else { // Possibly handle locked wallet scenario - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - - if needed_unlock && !just_unlocked { + if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } return; } } - ui.heading("1. Select the key to sign the Claim transition"); + // Header with Advanced Options checkbox + ui.horizontal(|ui| { + ui.heading("Claim Tokens"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); ui.add_space(10.0); - let mut selected_identity = Some(self.identity.clone()); - add_identity_key_chooser( - ui, - &self.app_context, - std::iter::once(&self.identity), - &mut selected_identity, - &mut self.selected_key, - TransactionType::TokenClaim, - ); - ui.add_space(10.0); + // Key selection (only in advanced mode) + if self.show_advanced_options { + ui.heading("1. Select the key to sign the Claim transition"); + ui.add_space(10.0); + add_key_chooser( + ui, + &self.app_context, + &self.identity, + &mut self.selected_key, + TransactionType::TokenClaim, + ); + ui.add_space(10.0); + } self.render_token_distribution_type_selector(ui); @@ -497,6 +511,32 @@ impl ScreenLike for ClaimTokensScreen { ui.add_space(10.0); } + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions + + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + + ui.add_space(10.0); + let button = egui::Button::new(RichText::new("Claim").color(Color32::WHITE)) .fill(Color32::from_rgb(0, 128, 0)) .corner_radius(3.0); @@ -532,43 +572,42 @@ impl ScreenLike for ClaimTokensScreen { ui.label(format!("Claiming... elapsed: {}s", elapsed)); } ClaimTokensStatus::ErrorMessage(msg) => { - ui.colored_label(Color32::RED, format!("Error: {}", msg)); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = msg.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", msg)).color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.status = ClaimTokensStatus::NotStarted; + } + }); + }); } ClaimTokensStatus::Complete => {} } } }); - action - } -} - -impl ScreenWithWalletUnlock for ClaimTokensScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index 4961bc83e..50435ae88 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -1,8 +1,9 @@ use super::tokens_screen::IdentityTokenInfo; use crate::app::AppAction; -use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::component_trait::Component; @@ -12,14 +13,15 @@ use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::contracts_documents::group_actions_screen::GroupActionsScreen; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser, render_group_action_text}; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; -use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike}; +use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; @@ -31,7 +33,7 @@ use dash_sdk::dpp::group::{GroupStateTransitionInfo, GroupStateTransitionInfoSta use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::platform::{Identifier, IdentityPublicKey}; -use eframe::egui::{self, Color32, Context, Ui}; +use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -51,11 +53,12 @@ pub struct DestroyFrozenFundsScreen { /// Identity that is authorized to destroy pub identity: QualifiedIdentity, - /// Info on which token contract we’re dealing with + /// Info on which token contract we're dealing with pub identity_token_info: IdentityTokenInfo, /// The key used to sign the operation selected_key: Option, + show_advanced_options: bool, group: Option<(GroupContractPosition, Group)>, is_unilateral_group_member: bool, @@ -83,8 +86,9 @@ pub struct DestroyFrozenFundsScreen { /// If password-based wallet unlocking is needed selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, + /// Fee result from completed operation + completed_fee_result: Option, } impl DestroyFrozenFundsScreen { @@ -200,6 +204,7 @@ impl DestroyFrozenFundsScreen { frozen_identities: all_identities, identity_token_info, selected_key: possible_key, + show_advanced_options: false, group, is_unilateral_group_member, group_action_id: None, @@ -209,12 +214,12 @@ impl DestroyFrozenFundsScreen { app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), + completed_fee_result: None, } } - /// Renders the text input for specifying the “frozen identity” + /// Renders the text input for specifying the "frozen identity" fn render_frozen_identity_input(&mut self, ui: &mut Ui) { ui.add( IdentitySelector::new( @@ -309,71 +314,34 @@ impl DestroyFrozenFundsScreen { }, ))) } - /// Simple “Success” screen + /// Simple "Success" screen fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - if self.group_action_id.is_some() { - // This destroy is already initiated by the group, we are just signing it - ui.heading("Group Destroy Frozen Funds Signing Successful."); - } else if !self.is_unilateral_group_member && self.group.is_some() { - ui.heading("Group Action to Destroy Frozen Funds Initiated."); - } else { - ui.heading("Frozen Funds Destroyed Successfully."); - } - - ui.add_space(20.0); - - if self.group_action_id.is_some() { - if ui.button("Back to Group Actions").clicked() { - action = AppAction::PopScreenAndRefresh; - } - if ui.button("Back to Tokens").clicked() { - action = AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenMyTokenBalances, - ); - } - } else { - if ui.button("Back to Tokens").clicked() { - action = AppAction::PopScreenAndRefresh; - } - - if !self.is_unilateral_group_member && ui.button("Go to Group Actions").clicked() { - action = AppAction::PopThenAddScreenToMainScreen( - RootScreenType::RootScreenDocumentQuery, - Screen::GroupActionsScreen(GroupActionsScreen::new( - &self.app_context.clone(), - )), - ); - } - } - }); - action + crate::ui::helpers::show_group_token_success_screen_with_fee( + ui, + "Destroy Frozen Funds", + self.group_action_id.is_some(), + self.is_unilateral_group_member, + self.group.is_some(), + &self.app_context, + None, + ) } } impl ScreenLike for DestroyFrozenFundsScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - // If your backend returns "DestroyFrozenFunds" on success, - // or if there's a more descriptive success message: - if message.contains("Successfully destroyed frozen funds") - || message == "DestroyFrozenFunds" - { - self.status = DestroyFrozenFundsStatus::Complete; - } - } - MessageType::Error => { - self.status = DestroyFrozenFundsStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); - } - MessageType::Info => { - // no-op - } + if let MessageType::Error = message_type { + self.status = DestroyFrozenFundsStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::DestroyedFrozenFunds(fee_result) = + backend_task_success_result + { + self.completed_fee_result = Some(fee_result); + self.status = DestroyFrozenFundsStatus::Complete; } } @@ -485,33 +453,55 @@ impl ScreenLike for DestroyFrozenFundsScreen { } } else { // Possibly handle locked wallet scenario - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - if needed_unlock && !just_unlocked { + if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } return; } } - // Key selection - ui.heading("1. Select the key to sign the Destroy operation"); + // Header with Advanced Options checkbox + ui.horizontal(|ui| { + ui.heading("Destroy Frozen Funds"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); ui.add_space(10.0); - let mut selected_identity = Some(self.identity.clone()); - add_identity_key_chooser( - ui, - &self.app_context, - std::iter::once(&self.identity), - &mut selected_identity, - &mut self.selected_key, - TransactionType::TokenAction, - ); - - ui.add_space(10.0); - ui.separator(); + // Key selection (only in advanced mode) + if self.show_advanced_options { + ui.heading("1. Select the key to sign the Destroy operation"); + ui.add_space(10.0); + add_key_chooser( + ui, + &self.app_context, + &self.identity, + &mut self.selected_key, + TransactionType::TokenAction, + ); + ui.add_space(10.0); + ui.separator(); + } ui.add_space(10.0); // Frozen identity - ui.heading("2. Frozen identity to destroy funds from"); + let step_num = if self.show_advanced_options { 2 } else { 1 }; + ui.heading(format!( + "{}. Frozen identity to destroy funds from", + step_num + )); ui.add_space(5.0); if self.group_action_id.is_some() { ui.label( @@ -528,7 +518,8 @@ impl ScreenLike for DestroyFrozenFundsScreen { ui.add_space(10.0); // Render text input for the public note - ui.heading("3. Public note (optional)"); + let step_num = if self.show_advanced_options { 3 } else { 2 }; + ui.heading(format!("{}. Public note (optional)", step_num)); ui.add_space(5.0); if self.group_action_id.is_some() { ui.label( @@ -556,6 +547,29 @@ impl ScreenLike for DestroyFrozenFundsScreen { }); } + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions + + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + let button_text = render_group_action_text( ui, &self.group, @@ -622,36 +636,18 @@ impl ScreenLike for DestroyFrozenFundsScreen { } }); - action - } -} - -impl ScreenWithWalletUnlock for DestroyFrozenFundsScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index 4e2de7d98..c118995b6 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -12,10 +12,11 @@ use egui::RichText; use super::tokens_screen::IdentityTokenInfo; use crate::app::{AppAction, BackendTasksExecutionMode}; -use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; @@ -23,14 +24,16 @@ use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; use crate::ui::components::{Component, ComponentResponse}; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser}; +use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; -use crate::ui::{BackendTaskSuccessResult, MessageType, Screen, ScreenLike}; +use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::platform::IdentityPublicKey; @@ -50,6 +53,7 @@ pub struct PurchaseTokenScreen { pub identity_token_info: IdentityTokenInfo, selected_key: Option, + show_advanced_options: bool, // Specific to this transition - using AmountInput components following design pattern amount_to_purchase_input: Option, @@ -65,8 +69,9 @@ pub struct PurchaseTokenScreen { // Wallet fields selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, + // Fee result from completed operation + completed_fee_result: Option, } impl PurchaseTokenScreen { @@ -95,6 +100,7 @@ impl PurchaseTokenScreen { Self { identity_token_info, selected_key: possible_key, + show_advanced_options: false, amount_to_purchase_input: None, amount_to_purchase_value: None, fetched_pricing_schedule: None, @@ -105,8 +111,8 @@ impl PurchaseTokenScreen { app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), + completed_fee_result: None, } } @@ -312,65 +318,51 @@ impl PurchaseTokenScreen { /// Renders a simple "Success!" screen after completion fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - ui.heading("Purchase Successful!"); - - ui.add_space(20.0); - - if ui.button("Back to Tokens").clicked() { - // Pop this screen and refresh - action = AppAction::PopScreenAndRefresh; - } - }); - action + crate::ui::helpers::show_success_screen_with_info( + ui, + "Purchase Successful!".to_string(), + vec![("Back to Tokens".to_string(), AppAction::PopScreenAndRefresh)], + None, + ) } } impl ScreenLike for PurchaseTokenScreen { fn display_task_result(&mut self, result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::TokenPricing { - token_id: _, - prices, - } = result - { - self.pricing_fetch_attempted = true; - if let Some(schedule) = prices { - self.fetched_pricing_schedule = Some(schedule); - self.recalculate_price(); - self.status = PurchaseTokensStatus::NotStarted; - } else { - // No pricing schedule found - token is not for sale - self.status = PurchaseTokensStatus::ErrorMessage( - "This token is not available for direct purchase. No pricing has been set." - .to_string(), - ); - self.error_message = Some( - "This token is not available for direct purchase. No pricing has been set." - .to_string(), - ); + match result { + BackendTaskSuccessResult::TokenPricing { + token_id: _, + prices, + } => { + self.pricing_fetch_attempted = true; + if let Some(schedule) = prices { + self.fetched_pricing_schedule = Some(schedule); + self.recalculate_price(); + self.status = PurchaseTokensStatus::NotStarted; + } else { + // No pricing schedule found - token is not for sale + self.status = PurchaseTokensStatus::ErrorMessage( + "This token is not available for direct purchase. No pricing has been set." + .to_string(), + ); + self.error_message = Some( + "This token is not available for direct purchase. No pricing has been set." + .to_string(), + ); + } + } + BackendTaskSuccessResult::PurchasedTokens(fee_result) => { + self.completed_fee_result = Some(fee_result); + self.status = PurchaseTokensStatus::Complete; } + _ => {} } } fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - if message.contains("Successfully purchaseed tokens") || message == "PurchaseTokens" - { - self.status = PurchaseTokensStatus::Complete; - } - } - MessageType::Error => { - self.status = PurchaseTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); - } - MessageType::Info => { - // no-op - } + if let MessageType::Error = message_type { + self.status = PurchaseTokensStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); } } @@ -477,35 +469,52 @@ impl ScreenLike for PurchaseTokenScreen { } } else { // Possibly handle locked wallet scenario (similar to TransferTokens) - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - - if needed_unlock && !just_unlocked { - // Must unlock before we can proceed + if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } return; } } - // 1) Key selection - ui.heading("1. Select the key to sign the Purchase transaction"); + // Header with Advanced Options checkbox + ui.horizontal(|ui| { + ui.heading("Purchase Tokens"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); ui.add_space(10.0); - let mut selected_identity = Some(self.identity_token_info.identity.clone()); - add_identity_key_chooser( - ui, - &self.app_context, - std::iter::once(&self.identity_token_info.identity), - &mut selected_identity, - &mut self.selected_key, - TransactionType::TokenAction, - ); - - ui.add_space(10.0); - ui.separator(); + // Key selection (only in advanced mode) + if self.show_advanced_options { + ui.heading("1. Select the key to sign the Purchase transaction"); + ui.add_space(10.0); + add_key_chooser( + ui, + &self.app_context, + &self.identity_token_info.identity, + &mut self.selected_key, + TransactionType::TokenAction, + ); + ui.add_space(10.0); + ui.separator(); + } ui.add_space(10.0); - // 2) Amount to purchase - ui.heading("2. Amount to purchase and price"); + // Amount to purchase + let step_num = if self.show_advanced_options { 2 } else { 1 }; + ui.heading(format!("{}. Amount to purchase and price", step_num)); ui.add_space(5.0); action |= self.render_amount_input(ui); @@ -536,6 +545,28 @@ impl ScreenLike for PurchaseTokenScreen { ui.separator(); ui.add_space(10.0); + // Display estimated fee before action button + let estimated_fee = PlatformFeeEstimator::new().estimate_token_transition(); + let dark_mode = ui.ctx().style().visuals.dark_mode; + egui::Frame::new() + .fill(crate::ui::theme::DashColors::surface(dark_mode)) + .inner_margin(egui::Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated Fee:") + .color(crate::ui::theme::DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(crate::ui::theme::DashColors::text_primary(dark_mode)) + .strong(), + ); + }); + }); + ui.add_space(10.0); + // Purchase button (disabled if no valid amounts are available) let can_purchase = self.fetched_pricing_schedule.is_some() && self.calculated_price_credits.unwrap_or_default() > 0 @@ -626,37 +657,19 @@ impl ScreenLike for PurchaseTokenScreen { } }); - action - } -} - -impl ScreenWithWalletUnlock for PurchaseTokenScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index 1fbc483b5..41d9ca2f3 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -1,8 +1,9 @@ use super::tokens_screen::IdentityTokenInfo; use crate::app::AppAction; -use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::component_trait::Component; @@ -12,13 +13,15 @@ use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::contracts_documents::group_actions_screen::GroupActionsScreen; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser, render_group_action_text}; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; -use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike}; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; @@ -30,7 +33,7 @@ use dash_sdk::dpp::group::{GroupStateTransitionInfo, GroupStateTransitionInfoSta use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::platform::{Identifier, IdentityPublicKey}; -use eframe::egui::{self, Color32, Context, Ui}; +use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -50,6 +53,7 @@ pub struct FreezeTokensScreen { pub identity: QualifiedIdentity, pub identity_token_info: IdentityTokenInfo, selected_key: Option, + show_advanced_options: bool, pub public_note: Option, group: Option<(GroupContractPosition, Group)>, @@ -71,8 +75,9 @@ pub struct FreezeTokensScreen { // If password-based wallet unlocking is needed selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, + // Fee result from completed operation + completed_fee_result: Option, } impl FreezeTokensScreen { @@ -186,6 +191,7 @@ impl FreezeTokensScreen { identity: identity_token_info.identity.clone(), identity_token_info, selected_key: possible_key, + show_advanced_options: false, group, is_unilateral_group_member, group_action_id: None, @@ -196,9 +202,9 @@ impl FreezeTokensScreen { app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), known_identities, + completed_fee_result: None, } } @@ -302,64 +308,30 @@ impl FreezeTokensScreen { /// Success screen fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - if self.group_action_id.is_some() { - // This freeze is already initiated by the group, we are just signing it - ui.heading("Group Freeze of Identity Signing Successful."); - } else if !self.is_unilateral_group_member && self.group.is_some() { - ui.heading("Group Freeze of Identity Initiated."); - } else { - ui.heading("Freeze of Identity Successful."); - } - - ui.add_space(20.0); - - if self.group_action_id.is_some() { - if ui.button("Back to Group Actions").clicked() { - action = AppAction::PopScreenAndRefresh; - } - if ui.button("Back to Tokens").clicked() { - action = AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenMyTokenBalances, - ); - } - } else { - if ui.button("Back to Tokens").clicked() { - action = AppAction::PopScreenAndRefresh; - } - - if !self.is_unilateral_group_member && ui.button("Go to Group Actions").clicked() { - action = AppAction::PopThenAddScreenToMainScreen( - RootScreenType::RootScreenDocumentQuery, - Screen::GroupActionsScreen(GroupActionsScreen::new( - &self.app_context.clone(), - )), - ); - } - } - }); - action + crate::ui::helpers::show_group_token_success_screen_with_fee( + ui, + "Freeze", + self.group_action_id.is_some(), + self.is_unilateral_group_member, + self.group.is_some(), + &self.app_context, + None, + ) } } impl ScreenLike for FreezeTokensScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - // Possibly check the exact message used in your backend - if message.contains("Successfully froze identity") || message == "FreezeTokens" { - self.status = FreezeTokensStatus::Complete; - } - } - MessageType::Error => { - self.status = FreezeTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); - } - MessageType::Info => {} + if let MessageType::Error = message_type { + self.status = FreezeTokensStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::FrozeTokens(fee_result) = backend_task_success_result { + self.completed_fee_result = Some(fee_result); + self.status = FreezeTokensStatus::Complete; } } @@ -468,34 +440,52 @@ impl ScreenLike for FreezeTokensScreen { } } else { // Possibly handle locked wallet scenario - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - - if needed_unlock && !just_unlocked { + if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } return AppAction::None; } } - // 1) Key selection - ui.heading("1. Select the key to sign the Freeze transition"); + // Header with Advanced Options checkbox + ui.horizontal(|ui| { + ui.heading("Freeze Tokens"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); ui.add_space(10.0); - let mut selected_identity = Some(self.identity.clone()); - add_identity_key_chooser( - ui, - &self.app_context, - std::iter::once(&self.identity), - &mut selected_identity, - &mut self.selected_key, - TransactionType::TokenAction, - ); - - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); + // Key selection (only in advanced mode) + if self.show_advanced_options { + ui.heading("1. Select the key to sign the Freeze transition"); + ui.add_space(10.0); + add_key_chooser( + ui, + &self.app_context, + &self.identity, + &mut self.selected_key, + TransactionType::TokenAction, + ); + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + } - // 2) Identity to freeze - ui.heading("2. Enter the identity ID to freeze"); + // Identity to freeze + let step_num = if self.show_advanced_options { 2 } else { 1 }; + ui.heading(format!("{}. Enter the identity ID to freeze", step_num)); ui.add_space(5.0); if self.group_action_id.is_some() { ui.label( @@ -512,7 +502,8 @@ impl ScreenLike for FreezeTokensScreen { ui.add_space(10.0); // Render text input for the public note - ui.heading("3. Public note (optional)"); + let step_num = if self.show_advanced_options { 3 } else { 2 }; + ui.heading(format!("{}. Public note (optional)", step_num)); ui.add_space(5.0); if self.group_action_id.is_some() { ui.label( @@ -540,6 +531,30 @@ impl ScreenLike for FreezeTokensScreen { }); } + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions + + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + let button_text = render_group_action_text( ui, &self.group, @@ -548,6 +563,28 @@ impl ScreenLike for FreezeTokensScreen { &self.group_action_id, ); + // Display estimated fee before action button + let estimated_fee = PlatformFeeEstimator::new().estimate_token_transition(); + ui.add_space(10.0); + let dark_mode = ui.ctx().style().visuals.dark_mode; + egui::Frame::new() + .fill(crate::ui::theme::DashColors::surface(dark_mode)) + .inner_margin(egui::Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated Fee:") + .color(crate::ui::theme::DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(crate::ui::theme::DashColors::text_primary(dark_mode)) + .strong(), + ); + }); + }); + // Freeze button if self.app_context.is_developer_mode() || !button_text.contains("Test") { ui.add_space(10.0); @@ -582,7 +619,24 @@ impl ScreenLike for FreezeTokensScreen { ui.label(format!("Freezing... elapsed: {}s", elapsed)); } FreezeTokensStatus::ErrorMessage(msg) => { - ui.colored_label(Color32::RED, format!("Error: {}", msg)); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = msg.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", msg)).color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.status = FreezeTokensStatus::NotStarted; + } + }); + }); } FreezeTokensStatus::Complete => { // handled above @@ -594,36 +648,19 @@ impl ScreenLike for FreezeTokensScreen { }); action |= central_panel_action; - action - } -} - -impl ScreenWithWalletUnlock for FreezeTokensScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index a1ab3d629..c6c96ef24 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -1,9 +1,10 @@ use super::tokens_screen::IdentityTokenInfo; use crate::app::AppAction; -use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; @@ -14,14 +15,15 @@ use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::contracts_documents::group_actions_screen::GroupActionsScreen; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser, render_group_action_text}; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; -use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike}; +use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; @@ -35,6 +37,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::platform::{Identifier, IdentityPublicKey}; use eframe::egui::{self, Color32, Context, Ui}; +use eframe::egui::{Frame, Margin}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -53,6 +56,7 @@ pub enum MintTokensStatus { pub struct MintTokensScreen { pub identity_token_info: IdentityTokenInfo, selected_key: Option, + show_advanced_options: bool, pub public_note: Option, group: Option<(GroupContractPosition, Group)>, is_unilateral_group_member: bool, @@ -74,8 +78,9 @@ pub struct MintTokensScreen { // If needed for password-based wallet unlocking: selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, + // Fee result from completed operation + completed_fee_result: Option, } impl MintTokensScreen { @@ -188,6 +193,7 @@ impl MintTokensScreen { Self { identity_token_info, selected_key: possible_key, + show_advanced_options: false, public_note: None, group, is_unilateral_group_member, @@ -201,8 +207,8 @@ impl MintTokensScreen { app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), + completed_fee_result: None, } } @@ -334,65 +340,30 @@ impl MintTokensScreen { } /// Renders a simple "Success!" screen after completion fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - if self.group_action_id.is_some() { - // This mint is already initiated by the group, we are just signing it - ui.heading("Group Mint Signing Successful."); - } else if !self.is_unilateral_group_member && self.group.is_some() { - ui.heading("Group Mint Initiated."); - } else { - ui.heading("Mint Successful."); - } - - ui.add_space(20.0); - - if self.group_action_id.is_some() { - if ui.button("Back to Group Actions").clicked() { - action = AppAction::PopScreenAndRefresh; - } - if ui.button("Back to Tokens").clicked() { - action = AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenMyTokenBalances, - ); - } - } else { - if ui.button("Back to Tokens").clicked() { - action = AppAction::PopScreenAndRefresh; - } - - if !self.is_unilateral_group_member && ui.button("Go to Group Actions").clicked() { - action = AppAction::PopThenAddScreenToMainScreen( - RootScreenType::RootScreenDocumentQuery, - Screen::GroupActionsScreen(GroupActionsScreen::new( - &self.app_context.clone(), - )), - ); - } - } - }); - action + crate::ui::helpers::show_group_token_success_screen_with_fee( + ui, + "Mint", + self.group_action_id.is_some(), + self.is_unilateral_group_member, + self.group.is_some(), + &self.app_context, + None, + ) } } impl ScreenLike for MintTokensScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - if message.contains("Successfully minted tokens") || message == "MintTokens" { - self.status = MintTokensStatus::Complete; - } - } - MessageType::Error => { - self.status = MintTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); - } - MessageType::Info => { - // no-op - } + if let MessageType::Error = message_type { + self.status = MintTokensStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::MintedTokens(fee_result) = backend_task_success_result { + self.completed_fee_result = Some(fee_result); + self.status = MintTokensStatus::Complete; } } @@ -514,35 +485,52 @@ impl ScreenLike for MintTokensScreen { } } else { // Possibly handle locked wallet scenario (similar to TransferTokens) - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - - if needed_unlock && !just_unlocked { - // Must unlock before we can proceed + if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } return AppAction::None; } } - // 1) Key selection - ui.heading("1. Select the key to sign the Mint transaction"); + // Header with Advanced Options checkbox + ui.horizontal(|ui| { + ui.heading("Mint Tokens"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); ui.add_space(10.0); - let mut selected_identity = Some(self.identity_token_info.identity.clone()); - add_identity_key_chooser( - ui, - &self.app_context, - std::iter::once(&self.identity_token_info.identity), - &mut selected_identity, - &mut self.selected_key, - TransactionType::TokenAction, - ); - - ui.add_space(10.0); - ui.separator(); + // Key selection (only in advanced mode) + if self.show_advanced_options { + ui.heading("1. Select the key to sign the Mint transaction"); + ui.add_space(10.0); + add_key_chooser( + ui, + &self.app_context, + &self.identity_token_info.identity, + &mut self.selected_key, + TransactionType::TokenAction, + ); + ui.add_space(10.0); + ui.separator(); + } ui.add_space(10.0); - // 2) Amount to mint - ui.heading("2. Amount to mint"); + // Amount to mint + let step_num = if self.show_advanced_options { 2 } else { 1 }; + ui.heading(format!("{}. Amount to mint", step_num)); ui.add_space(5.0); if self.group_action_id.is_some() { ui.label( @@ -578,9 +566,11 @@ impl ScreenLike for MintTokensScreen { .new_tokens_destination_identity() .is_some() { - ui.heading("3. Recipient identity (optional)"); + let step_num = if self.show_advanced_options { 3 } else { 2 }; + ui.heading(format!("{}. Recipient identity (optional)", step_num)); } else { - ui.heading("3. Recipient identity (required)"); + let step_num = if self.show_advanced_options { 3 } else { 2 }; + ui.heading(format!("{}. Recipient identity (required)", step_num)); } ui.add_space(5.0); self.render_recipient_input(ui); @@ -591,7 +581,8 @@ impl ScreenLike for MintTokensScreen { ui.add_space(10.0); // Render text input for the public note - ui.heading("4. Public note (optional)"); + let step_num = if self.show_advanced_options { 4 } else { 3 }; + ui.heading(format!("{}. Public note (optional)", step_num)); ui.add_space(5.0); if self.group_action_id.is_some() { ui.label( @@ -619,6 +610,29 @@ impl ScreenLike for MintTokensScreen { }); } + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions + + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + let button_text = render_group_action_text( ui, &self.group, @@ -627,6 +641,28 @@ impl ScreenLike for MintTokensScreen { &self.group_action_id, ); + // Display estimated fee before action button + let estimated_fee = PlatformFeeEstimator::new().estimate_token_transition(); + ui.add_space(10.0); + let dark_mode = ui.ctx().style().visuals.dark_mode; + egui::Frame::new() + .fill(crate::ui::theme::DashColors::surface(dark_mode)) + .inner_margin(egui::Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated Fee:") + .color(crate::ui::theme::DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(crate::ui::theme::DashColors::text_primary(dark_mode)) + .strong(), + ); + }); + }); + // Mint button if self.app_context.is_developer_mode() || !button_text.contains("Test") { ui.add_space(10.0); @@ -684,36 +720,19 @@ impl ScreenLike for MintTokensScreen { }); action |= central_panel_action; - action - } -} - -impl ScreenWithWalletUnlock for MintTokensScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/tokens/pause_tokens_screen.rs b/src/ui/tokens/pause_tokens_screen.rs index c9333cfdf..00a8cd976 100644 --- a/src/ui/tokens/pause_tokens_screen.rs +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -1,8 +1,9 @@ use super::tokens_screen::IdentityTokenInfo; use crate::app::AppAction; -use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::Component; @@ -11,13 +12,15 @@ use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::contracts_documents::group_actions_screen::GroupActionsScreen; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser, render_group_action_text}; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; -use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike}; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; @@ -29,7 +32,7 @@ use dash_sdk::dpp::group::{GroupStateTransitionInfo, GroupStateTransitionInfoSta use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::platform::Identifier; -use eframe::egui::{self, Color32, Context, Ui}; +use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -49,6 +52,7 @@ pub struct PauseTokensScreen { pub identity: QualifiedIdentity, pub identity_token_info: IdentityTokenInfo, selected_key: Option, + show_advanced_options: bool, group: Option<(GroupContractPosition, Group)>, is_unilateral_group_member: bool, pub group_action_id: Option, @@ -65,8 +69,9 @@ pub struct PauseTokensScreen { // If password-based wallet unlocking is needed selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, + // Fee result from completed operation + completed_fee_result: Option, } impl PauseTokensScreen { @@ -176,6 +181,7 @@ impl PauseTokensScreen { identity: identity_token_info.identity.clone(), identity_token_info, selected_key: possible_key, + show_advanced_options: false, group, is_unilateral_group_member, group_action_id: None, @@ -185,8 +191,8 @@ impl PauseTokensScreen { app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), + completed_fee_result: None, } } @@ -249,65 +255,30 @@ impl PauseTokensScreen { } fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - if self.group_action_id.is_some() { - // This Pause is already initiated by the group, we are just signing it - ui.heading("Group Pause Signing Successful."); - } else if !self.is_unilateral_group_member && self.group.is_some() { - ui.heading("Group Pause Initiated."); - } else { - ui.heading("Pause Successful."); - } - - ui.add_space(20.0); - - if self.group_action_id.is_some() { - if ui.button("Back to Group Actions").clicked() { - action = AppAction::PopScreenAndRefresh; - } - if ui.button("Back to Tokens").clicked() { - action = AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenMyTokenBalances, - ); - } - } else { - if ui.button("Back to Tokens").clicked() { - action = AppAction::PopScreenAndRefresh; - } - - if !self.is_unilateral_group_member && ui.button("Go to Group Actions").clicked() { - action = AppAction::PopThenAddScreenToMainScreen( - RootScreenType::RootScreenDocumentQuery, - Screen::GroupActionsScreen(GroupActionsScreen::new( - &self.app_context.clone(), - )), - ); - } - } - }); - action + crate::ui::helpers::show_group_token_success_screen_with_fee( + ui, + "Pause", + self.group_action_id.is_some(), + self.is_unilateral_group_member, + self.group.is_some(), + &self.app_context, + None, + ) } } impl ScreenLike for PauseTokensScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - if message.contains("Paused") || message == "PauseTokens" { - self.status = PauseTokensStatus::Complete; - } - } - MessageType::Error => { - self.status = PauseTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); - } - MessageType::Info => { - // no-op - } + if let MessageType::Error = message_type { + self.status = PauseTokensStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::PausedTokens(fee_result) = backend_task_success_result { + self.completed_fee_result = Some(fee_result); + self.status = PauseTokensStatus::Complete; } } @@ -414,33 +385,52 @@ impl ScreenLike for PauseTokensScreen { } } else { // Possibly handle locked wallet scenario - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - - if needed_unlock && !just_unlocked { + if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } return AppAction::None; } } - ui.heading("1. Select the key to sign the Pause transition"); + // Header with Advanced Options checkbox + ui.horizontal(|ui| { + ui.heading("Pause Tokens"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); ui.add_space(10.0); - let mut selected_identity = Some(self.identity.clone()); - add_identity_key_chooser( - ui, - &self.app_context, - std::iter::once(&self.identity), - &mut selected_identity, - &mut self.selected_key, - TransactionType::TokenAction, - ); - - ui.add_space(10.0); - ui.separator(); + // Key selection (only in advanced mode) + if self.show_advanced_options { + ui.heading("1. Select the key to sign the Pause transition"); + ui.add_space(10.0); + add_key_chooser( + ui, + &self.app_context, + &self.identity, + &mut self.selected_key, + TransactionType::TokenAction, + ); + ui.add_space(10.0); + ui.separator(); + } ui.add_space(10.0); // Render text input for the public note - ui.heading("2. Public note (optional)"); + let step_num = if self.show_advanced_options { 2 } else { 1 }; + ui.heading(format!("{}. Public note (optional)", step_num)); ui.add_space(5.0); if self.group_action_id.is_some() { ui.label( @@ -468,6 +458,30 @@ impl ScreenLike for PauseTokensScreen { }); } + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions + + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + let button_text = render_group_action_text( ui, &self.group, @@ -510,7 +524,24 @@ impl ScreenLike for PauseTokensScreen { ui.label(format!("Pausing... elapsed: {}s", elapsed)); } PauseTokensStatus::ErrorMessage(msg) => { - ui.colored_label(Color32::RED, format!("Error: {}", msg)); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = msg.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", msg)).color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.status = PauseTokensStatus::NotStarted; + } + }); + }); } PauseTokensStatus::Complete => {} } @@ -520,36 +551,19 @@ impl ScreenLike for PauseTokensScreen { }); action |= central_panel_action; - action - } -} -impl ScreenWithWalletUnlock for PauseTokensScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/tokens/resume_tokens_screen.rs b/src/ui/tokens/resume_tokens_screen.rs index dccec0693..f447e6ec7 100644 --- a/src/ui/tokens/resume_tokens_screen.rs +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -1,8 +1,9 @@ use super::tokens_screen::IdentityTokenInfo; use crate::app::AppAction; -use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::Component; @@ -11,13 +12,15 @@ use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::contracts_documents::group_actions_screen::GroupActionsScreen; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser, render_group_action_text}; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; -use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike}; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; @@ -29,7 +32,7 @@ use dash_sdk::dpp::group::{GroupStateTransitionInfo, GroupStateTransitionInfoSta use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::platform::Identifier; -use eframe::egui::{self, Color32, Context, Ui}; +use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -48,6 +51,7 @@ pub struct ResumeTokensScreen { pub identity: QualifiedIdentity, pub identity_token_info: IdentityTokenInfo, selected_key: Option, + show_advanced_options: bool, group: Option<(GroupContractPosition, Group)>, is_unilateral_group_member: bool, pub group_action_id: Option, @@ -64,8 +68,9 @@ pub struct ResumeTokensScreen { // If password-based wallet unlocking is needed selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, + // Fee result from completed operation + completed_fee_result: Option, } impl ResumeTokensScreen { @@ -175,6 +180,7 @@ impl ResumeTokensScreen { identity: identity_token_info.identity.clone(), identity_token_info, selected_key: possible_key, + show_advanced_options: false, group, is_unilateral_group_member, group_action_id: None, @@ -184,8 +190,8 @@ impl ResumeTokensScreen { app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), + completed_fee_result: None, } } @@ -249,65 +255,30 @@ impl ResumeTokensScreen { } fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - if self.group_action_id.is_some() { - // This resume is already initiated by the group, we are just signing it - ui.heading("Group Resume Signing Successful."); - } else if !self.is_unilateral_group_member && self.group.is_some() { - ui.heading("Group Resume Initiated."); - } else { - ui.heading("Resume Successful."); - } - - ui.add_space(20.0); - - if self.group_action_id.is_some() { - if ui.button("Back to Group Actions").clicked() { - action = AppAction::PopScreenAndRefresh; - } - if ui.button("Back to Tokens").clicked() { - action = AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenMyTokenBalances, - ); - } - } else { - if ui.button("Back to Tokens").clicked() { - action = AppAction::PopScreenAndRefresh; - } - - if !self.is_unilateral_group_member && ui.button("Go to Group Actions").clicked() { - action = AppAction::PopThenAddScreenToMainScreen( - RootScreenType::RootScreenDocumentQuery, - Screen::GroupActionsScreen(GroupActionsScreen::new( - &self.app_context.clone(), - )), - ); - } - } - }); - action + crate::ui::helpers::show_group_token_success_screen_with_fee( + ui, + "Resume", + self.group_action_id.is_some(), + self.is_unilateral_group_member, + self.group.is_some(), + &self.app_context, + None, + ) } } impl ScreenLike for ResumeTokensScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - if message.contains("Resumed") || message == "ResumeTokens" { - self.status = ResumeTokensStatus::Complete; - } - } - MessageType::Error => { - self.status = ResumeTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); - } - MessageType::Info => { - // no-op - } + if let MessageType::Error = message_type { + self.status = ResumeTokensStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::ResumedTokens(fee_result) = backend_task_success_result { + self.completed_fee_result = Some(fee_result); + self.status = ResumeTokensStatus::Complete; } } @@ -415,33 +386,52 @@ impl ScreenLike for ResumeTokensScreen { } } else { // Possibly handle locked wallet scenario - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - - if needed_unlock && !just_unlocked { + if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } return; } } - ui.heading("1. Select the key to sign the Resume transition"); + // Header with Advanced Options checkbox + ui.horizontal(|ui| { + ui.heading("Resume Tokens"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); ui.add_space(10.0); - let mut selected_identity = Some(self.identity.clone()); - add_identity_key_chooser( - ui, - &self.app_context, - std::iter::once(&self.identity), - &mut selected_identity, - &mut self.selected_key, - TransactionType::TokenAction, - ); - - ui.add_space(10.0); - ui.separator(); + // Key selection (only in advanced mode) + if self.show_advanced_options { + ui.heading("1. Select the key to sign the Resume transition"); + ui.add_space(10.0); + add_key_chooser( + ui, + &self.app_context, + &self.identity, + &mut self.selected_key, + TransactionType::TokenAction, + ); + ui.add_space(10.0); + ui.separator(); + } ui.add_space(10.0); // Render text input for the public note - ui.heading("2. Public note (optional)"); + let step_num = if self.show_advanced_options { 2 } else { 1 }; + ui.heading(format!("{}. Public note (optional)", step_num)); ui.add_space(5.0); if self.group_action_id.is_some() { ui.label( @@ -469,6 +459,30 @@ impl ScreenLike for ResumeTokensScreen { }); } + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions + + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + let button_text = render_group_action_text( ui, &self.group, @@ -510,43 +524,42 @@ impl ScreenLike for ResumeTokensScreen { ui.label(format!("Resuming... elapsed: {}s", elapsed)); } ResumeTokensStatus::ErrorMessage(msg) => { - ui.colored_label(Color32::RED, format!("Error: {}", msg)); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = msg.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", msg)).color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.status = ResumeTokensStatus::NotStarted; + } + }); + }); } ResumeTokensStatus::Complete => {} } } }); - action - } -} - -impl ScreenWithWalletUnlock for ResumeTokensScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 36d699e94..b3a25a102 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -1,9 +1,10 @@ use super::tokens_screen::IdentityTokenInfo; use crate::app::AppAction; -use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::model::wallet::Wallet; use crate::ui::components::ComponentResponse; use crate::ui::components::amount_input::AmountInput; @@ -13,13 +14,15 @@ use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::contracts_documents::group_actions_screen::GroupActionsScreen; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser}; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; -use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike}; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::balances::credits::Credits; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -35,7 +38,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::tokens::token_pricing_schedule::TokenPricingSchedule; use dash_sdk::platform::{Identifier, IdentityPublicKey}; -use eframe::egui::{self, Color32, Context, Ui}; +use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use egui_extras::{Column, TableBuilder}; use std::collections::HashSet; @@ -81,6 +84,7 @@ pub enum SetTokenPriceStatus { pub struct SetTokenPriceScreen { pub identity_token_info: IdentityTokenInfo, selected_key: Option, + show_advanced_options: bool, pub public_note: Option, group: Option<(GroupContractPosition, Group)>, is_unilateral_group_member: bool, @@ -108,8 +112,9 @@ pub struct SetTokenPriceScreen { // If needed for password-based wallet unlocking: selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, + // Fee result from completed operation + completed_fee_result: Option, } /// 1 Dash = 100,000,000,000 credits @@ -144,7 +149,7 @@ impl SetTokenPriceScreen { )); } - if credits_price_per_token % decimal_divisor != 0 { + if !credits_price_per_token.is_multiple_of(decimal_divisor) { return Err(format!( "Price must be in multiples of {} to match the token decimals.", self.minimum_price_amount() @@ -261,6 +266,7 @@ impl SetTokenPriceScreen { Self { identity_token_info: identity_token_info.clone(), selected_key: possible_key.cloned(), + show_advanced_options: false, public_note: None, group, is_unilateral_group_member, @@ -276,8 +282,8 @@ impl SetTokenPriceScreen { show_confirmation_popup: false, confirmation_dialog: None, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), + completed_fee_result: None, } } @@ -797,67 +803,30 @@ impl SetTokenPriceScreen { /// Renders a simple "Success!" screen after completion fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - if self.group_action_id.is_some() { - // This is already initiated by the group, we are just signing it - ui.heading("Group Action to Set Price Signed Successfully."); - } else if !self.is_unilateral_group_member && self.group.is_some() { - ui.heading("Group Action to Set Price Initiated."); - } else { - ui.heading("Set Price of Token Successfully."); - } - - ui.add_space(20.0); - - if self.group_action_id.is_some() { - if ui.button("Back to Group Actions").clicked() { - action = AppAction::PopScreenAndRefresh; - } - if ui.button("Back to Tokens").clicked() { - action = AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenMyTokenBalances, - ); - } - } else { - if ui.button("Back to Tokens").clicked() { - action = AppAction::PopScreenAndRefresh; - } - - if !self.is_unilateral_group_member && ui.button("Go to Group Actions").clicked() { - action = AppAction::PopThenAddScreenToMainScreen( - RootScreenType::RootScreenDocumentQuery, - Screen::GroupActionsScreen(GroupActionsScreen::new( - &self.app_context.clone(), - )), - ); - } - } - }); - action + crate::ui::helpers::show_group_token_success_screen_with_fee( + ui, + "Set Price", + self.group_action_id.is_some(), + self.is_unilateral_group_member, + self.group.is_some(), + &self.app_context, + None, + ) } } impl ScreenLike for SetTokenPriceScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - if message.contains("Successfully set token pricing schedule") - || message == "SetDirectPurchasePrice" - { - self.status = SetTokenPriceStatus::Complete; - } - } - MessageType::Error => { - self.status = SetTokenPriceStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); - } - MessageType::Info => { - // no-op - } + if let MessageType::Error = message_type { + self.status = SetTokenPriceStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::SetTokenPrice(fee_result) = backend_task_success_result { + self.completed_fee_result = Some(fee_result); + self.status = SetTokenPriceStatus::Complete; } } @@ -979,35 +948,52 @@ impl ScreenLike for SetTokenPriceScreen { } } else { // Possibly handle locked wallet scenario (similar to TransferTokens) - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - - if needed_unlock && !just_unlocked { - // Must unlock before we can proceed + if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } return; } } - // 1) Key selection - ui.heading("1. Select the key to sign the SetPrice transaction"); + // Header with Advanced Options checkbox + ui.horizontal(|ui| { + ui.heading("Set Token Price"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); ui.add_space(10.0); - let mut selected_identity = Some(self.identity_token_info.identity.clone()); - add_identity_key_chooser( - ui, - &self.app_context, - std::iter::once(&self.identity_token_info.identity), - &mut selected_identity, - &mut self.selected_key, - TransactionType::TokenAction, - ); - - ui.add_space(10.0); - ui.separator(); + // Key selection (only in advanced mode) + if self.show_advanced_options { + ui.heading("1. Select the key to sign the SetPrice transaction"); + ui.add_space(10.0); + add_key_chooser( + ui, + &self.app_context, + &self.identity_token_info.identity, + &mut self.selected_key, + TransactionType::TokenAction, + ); + ui.add_space(10.0); + ui.separator(); + } ui.add_space(10.0); - // 2) Pricing schedule - ui.heading("2. Pricing Configuration"); + // Pricing schedule + let step_num = if self.show_advanced_options { 2 } else { 1 }; + ui.heading(format!("{}. Pricing Configuration", step_num)); ui.add_space(5.0); if self.group_action_id.is_some() { ui.label( @@ -1024,7 +1010,8 @@ impl ScreenLike for SetTokenPriceScreen { ui.add_space(10.0); // Render text input for the public note - ui.heading("3. Public note (optional)"); + let step_num = if self.show_advanced_options { 3 } else { 2 }; + ui.heading(format!("{}. Public note (optional)", step_num)); ui.add_space(5.0); if self.group_action_id.is_some() { ui.label( @@ -1092,6 +1079,32 @@ impl ScreenLike for SetTokenPriceScreen { "Set Price" }; + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions + + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + + ui.add_space(10.0); + // Set price button let validation_result = self.validate_pricing_configuration(); let button_active = validation_result.is_ok() && !matches!(self.status, SetTokenPriceStatus::WaitingForResult(_)); @@ -1134,7 +1147,22 @@ impl ScreenLike for SetTokenPriceScreen { ui.label(format!("Setting price... elapsed: {} seconds", elapsed)); } SetTokenPriceStatus::ErrorMessage(msg) => { - ui.colored_label(Color32::DARK_RED, format!("Error: {}", msg)); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = msg.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("Error: {}", msg)).color(error_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.status = SetTokenPriceStatus::NotStarted; + } + }); + }); } SetTokenPriceStatus::Complete => { // handled above @@ -1144,36 +1172,18 @@ impl ScreenLike for SetTokenPriceScreen { }); // end of ScrollArea }); - action - } -} - -impl ScreenWithWalletUnlock for SetTokenPriceScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/tokens/tokens_screen/contract_details.rs b/src/ui/tokens/tokens_screen/contract_details.rs index 926471086..9f96d00dc 100644 --- a/src/ui/tokens/tokens_screen/contract_details.rs +++ b/src/ui/tokens/tokens_screen/contract_details.rs @@ -1,4 +1,3 @@ -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; use crate::ui::tokens::tokens_screen::TokensScreen; use crate::{app::AppAction, ui::theme::DashColors}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; @@ -86,7 +85,7 @@ impl TokensScreen { action |= internal_action; } Err(e) => { - self.set_error_message(Some(e)); + self.token_creator_error_message = Some(e); } } } @@ -97,7 +96,7 @@ impl TokensScreen { self.json_popup_text = schema; } Err(e) => { - self.set_error_message(Some(e.to_string())); + self.token_creator_error_message = Some(e.to_string()); } } } diff --git a/src/ui/tokens/tokens_screen/keyword_search.rs b/src/ui/tokens/tokens_screen/keyword_search.rs index 5af0a2f4a..04edb2876 100644 --- a/src/ui/tokens/tokens_screen/keyword_search.rs +++ b/src/ui/tokens/tokens_screen/keyword_search.rs @@ -10,9 +10,17 @@ use chrono::Utc; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use eframe::emath::Align; use eframe::epaint::Color32; -use egui::{RichText, Ui}; +use egui::{Frame, Margin, RichText, Ui}; use egui_extras::{Column, TableBuilder}; +const KEYWORD_SEARCH_INFO_TEXT: &str = "Keyword Search allows you to find tokens by searching their associated keywords.\n\n\ + When token creators register tokens on Dash Platform, they can add searchable keywords \ + to make their tokens discoverable.\n\n\ + Tips:\n\n\ + - Try common terms like 'game', 'music', 'art', etc.\n\n\ + - Keywords are case-insensitive.\n\n\ + - Each keyword costs 0.1 Dash to register, so creators choose them carefully."; + impl TokensScreen { pub(super) fn render_keyword_search(&mut self, ui: &mut Ui) -> AppAction { ui.set_min_width(ui.available_width()); @@ -20,8 +28,13 @@ impl TokensScreen { let mut action = AppAction::None; - // 1) Input & “Go” button - ui.heading("Search Tokens by Keyword"); + // 1) Input & "Go" button + ui.horizontal(|ui| { + ui.heading("Search Tokens by Keyword"); + if crate::ui::helpers::info_icon_button(ui, KEYWORD_SEARCH_INFO_TEXT).clicked() { + self.show_pop_up_info = Some(KEYWORD_SEARCH_INFO_TEXT.to_string()); + } + }); ui.add_space(10.0); ui.horizontal(|ui| { @@ -96,7 +109,7 @@ impl TokensScreen { let elapsed = now - start_time; ui.horizontal(|ui| { ui.label(format!("Searching... {} seconds", elapsed)); - ui.add(egui::widgets::Spinner::default().color(Color32::from_rgb(0, 128, 255))); + ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); }); } ContractSearchStatus::Complete => { @@ -127,7 +140,22 @@ impl TokensScreen { } } ContractSearchStatus::ErrorMessage(e) => { - ui.colored_label(Color32::DARK_RED, format!("Error: {}", e)); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = e.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("Error: {}", msg)).color(error_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.contract_search_status = ContractSearchStatus::NotStarted; + } + }); + }); } } diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 1ae75df50..c287975a0 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -45,8 +45,8 @@ use dash_sdk::platform::proto::get_documents_request::get_documents_request_v0:: use dash_sdk::platform::{Identifier, IdentityPublicKey}; use dash_sdk::query_types::IndexMap; use eframe::egui::{self, Color32, Context, Ui}; +use crate::ui::theme::DashColors; use egui::{Checkbox, ColorImage, ComboBox, Response, RichText, TextEdit, TextureHandle}; -use egui_commonmark::{CommonMarkCache, CommonMarkViewer}; use enum_iterator::Sequence; use image::ImageReader; use crate::app::BackendTasksExecutionMode; @@ -61,11 +61,12 @@ use crate::model::qualified_identity::{IdentityType, QualifiedIdentity}; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; +use crate::ui::components::info_popup::InfoPopup; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::wallet_unlock_popup::{WalletUnlockPopup, WalletUnlockResult}; use crate::ui::components::{Component, ComponentResponse}; use crate::ui::{BackendTaskSuccessResult, MessageType, RootScreenType, ScreenLike, ScreenType}; @@ -195,20 +196,15 @@ pub enum ContractSearchStatus { ErrorMessage(String), } -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Default)] pub enum TokenCreatorStatus { + #[default] NotStarted, WaitingForResult(u64), Complete, ErrorMessage(String), } -impl Default for TokenCreatorStatus { - fn default() -> Self { - Self::NotStarted - } -} - /// Sorting columns #[derive(Clone, Copy, PartialEq, Eq)] enum SortColumn { @@ -1063,13 +1059,14 @@ pub struct TokensScreen { // ==================================== // Token Creator // ==================================== + show_advanced_token_creator: bool, selected_token_preset: Option, show_pop_up_info: Option, + identity_id_string: String, selected_identity: Option, selected_key: Option, selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, token_names_input: Vec<(String, String, TokenNameLanguage, TokenSearchable)>, contract_keywords_input: String, token_description_input: String, @@ -1415,13 +1412,14 @@ impl TokensScreen { show_token_info_popup: None, // Token Creator + show_advanced_token_creator: false, selected_token_preset: None, show_pop_up_info: None, + identity_id_string: String::new(), selected_identity: None, selected_key: None, selected_wallet: None, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), show_token_creator_confirmation_popup: false, token_creator_confirmation_dialog: None, token_creator_status: TokenCreatorStatus::NotStarted, @@ -2196,6 +2194,7 @@ impl TokensScreen { } fn reset_token_creator(&mut self) { + self.identity_id_string = String::new(); self.selected_identity = None; self.selected_key = None; self.token_creator_status = TokenCreatorStatus::NotStarted; @@ -2754,10 +2753,7 @@ impl ScreenLike for TokensScreen { ui.horizontal(|ui| { ui.add_space(10.0); ui.label(format!("Refreshing... Time so far: {}", elapsed)); - ui.add( - egui::widgets::Spinner::default() - .color(Color32::from_rgb(0, 128, 255)), - ); + ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); }); ui.add_space(2.0); // Space below } else if let Some((msg, msg_type, timestamp)) = self.backend_message.clone() { @@ -2789,19 +2785,10 @@ impl ScreenLike for TokensScreen { // If we have info text, open a pop-up window to show it if let Some(info_text) = self.show_pop_up_info.clone() { - egui::Window::new("Distribution Type Info") - .collapsible(false) - .resizable(true) - .show(ui.ctx(), |ui| { - egui::ScrollArea::vertical().show(ui, |ui| { - let mut cache = CommonMarkCache::default(); - CommonMarkViewer::new().show(ui, &mut cache, &info_text); - }); - - if ui.button("Close").clicked() { - self.show_pop_up_info = None; - } - }); + let mut popup = InfoPopup::new("Information", &info_text); + if popup.show(ui).inner { + self.show_pop_up_info = None; + } } inner_action @@ -2850,6 +2837,19 @@ impl ScreenLike for TokensScreen { { action = AppAction::BackendTask(bt); } + + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } + action } @@ -2966,41 +2966,21 @@ impl ScreenLike for TokensScreen { // Refresh display self.refreshing_status = RefreshingStatus::NotRefreshing; } + BackendTaskSuccessResult::FetchedTokenBalances => { + // Refresh my_tokens to show updated balances + self.my_tokens = my_tokens( + &self.app_context, + &self.identities, + &self.all_known_tokens, + &self.token_pricing_data, + ); + self.refreshing_status = RefreshingStatus::NotRefreshing; + } _ => {} } } } -impl ScreenWithWalletUnlock for TokensScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.token_creator_error_message = error_message; - } - - fn error_message(&self) -> Option<&String> { - self.token_creator_error_message.as_ref() - } -} - #[cfg(test)] mod tests { use std::path::Path; @@ -3040,7 +3020,8 @@ mod tests { #[test] fn test_token_creator_ui_builds_correct_contract() { - let db_file_path = "test_db"; + let db_file_path = "test_db_token_creator"; + let _ = std::fs::remove_file(db_file_path); // Clean up from previous runs let db = Arc::new(Database::new(db_file_path).unwrap()); db.initialize(Path::new(&db_file_path)).unwrap(); @@ -3345,7 +3326,8 @@ mod tests { #[test] fn test_distribution_function_random() { - let db_file_path = "test_db"; + let db_file_path = "test_db_distribution_random"; + let _ = std::fs::remove_file(db_file_path); // Clean up from previous runs let db = Arc::new(Database::new(db_file_path).unwrap()); db.initialize(Path::new(&db_file_path)).unwrap(); @@ -3464,7 +3446,8 @@ mod tests { #[test] fn test_parse_token_build_args_fails_with_empty_token_name() { - let db_file_path = "test_db"; + let db_file_path = "test_db_empty_token_name"; + let _ = std::fs::remove_file(db_file_path); // Clean up from previous runs let db = Arc::new(Database::new(db_file_path).unwrap()); db.initialize(Path::new(&db_file_path)).unwrap(); diff --git a/src/ui/tokens/tokens_screen/my_tokens.rs b/src/ui/tokens/tokens_screen/my_tokens.rs index a6d0bac44..09f294c01 100644 --- a/src/ui/tokens/tokens_screen/my_tokens.rs +++ b/src/ui/tokens/tokens_screen/my_tokens.rs @@ -2,9 +2,6 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::model::amount::Amount; -use crate::ui::Screen; -use crate::ui::components::styled::StyledButton; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; use crate::ui::theme::DashColors; use crate::ui::tokens::burn_tokens_screen::BurnTokensScreen; use crate::ui::tokens::claim_tokens_screen::ClaimTokensScreen; @@ -24,6 +21,7 @@ use crate::ui::tokens::transfer_tokens_screen::TransferTokensScreen; use crate::ui::tokens::unfreeze_tokens_screen::UnfreezeTokensScreen; use crate::ui::tokens::update_token_config::UpdateTokenConfigScreen; use crate::ui::tokens::view_token_claims_screen::ViewTokenClaimsScreen; +use crate::ui::{Screen, ScreenType}; use chrono::{Local, Utc}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; @@ -33,7 +31,7 @@ use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::tokens::token_pricing_schedule::TokenPricingSchedule; use eframe::emath::Align; use eframe::epaint::Color32; -use egui::{RichText, Ui}; +use egui::{Frame, Margin, RichText, Ui}; use egui_extras::{Column, TableBuilder}; use std::ops::Range; @@ -165,7 +163,7 @@ impl TokensScreen { // Otherwise, show the list of all tokens match self.render_token_list(ui) { Ok(list_action) => action |= list_action, - Err(e) => self.set_error_message(Some(e)), + Err(e) => self.token_creator_error_message = Some(e), } } } @@ -214,64 +212,81 @@ impl TokensScreen { } fn render_no_owned_tokens(&mut self, ui: &mut Ui) -> AppAction { let mut app_action = AppAction::None; - ui.vertical_centered(|ui| { - ui.add_space(20.0); - match self.tokens_subscreen { - TokensSubscreen::MyTokens => { - ui.label( - RichText::new("No tracked tokens.") - .heading() - .strong() - .color(Color32::GRAY), - ); - } - TokensSubscreen::SearchTokens => { + let dark_mode = ui.ctx().style().visuals.dark_mode; + + Frame::group(ui.style()) + .fill(ui.visuals().extreme_bg_color) + .corner_radius(5.0) + .outer_margin(Margin::same(20)) + .shadow(ui.visuals().window_shadow) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(10.0); + + let (title, description) = match self.tokens_subscreen { + TokensSubscreen::MyTokens => { + ("No Tracked Tokens", "You don't have any tokens yet.") + } + TokensSubscreen::SearchTokens => ( + "No Matching Tokens", + "No tokens match your search criteria.", + ), + TokensSubscreen::TokenCreator => { + ("Token Creator Error", "Cannot render token creator.") + } + }; + ui.label( - RichText::new("No matching tokens found.") - .heading() + RichText::new(title) .strong() - .color(Color32::GRAY), + .size(20.0) + .color(DashColors::text_primary(dark_mode)), ); - } - TokensSubscreen::TokenCreator => { + ui.add_space(5.0); ui.label( - RichText::new("Cannot render token creator for some reason") - .heading() - .strong() - .color(Color32::GRAY), + RichText::new(description).color(DashColors::text_secondary(dark_mode)), ); - } - } - ui.add_space(10.0); - - let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.label( - RichText::new("Please check back later or try refreshing the list.") - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(20.0); - if StyledButton::primary("Refresh").show(ui).clicked() { - if let RefreshingStatus::Refreshing(_) = self.refreshing_status { - app_action = AppAction::None; - } else { - let now = Utc::now().timestamp() as u64; - self.refreshing_status = RefreshingStatus::Refreshing(now); + ui.add_space(15.0); + match self.tokens_subscreen { TokensSubscreen::MyTokens => { - app_action = AppAction::BackendTask(BackendTask::TokenTask(Box::new( - TokenTask::QueryMyTokenBalances, - ))); - } - TokensSubscreen::SearchTokens => { - app_action = AppAction::Refresh; + let button = egui::Button::new( + RichText::new("Add Token") + .color(egui::Color32::WHITE) + .strong(), + ) + .fill(DashColors::DASH_BLUE) + .min_size(egui::vec2(150.0, 36.0)); + + if ui.add(button).clicked() { + app_action = AppAction::AddScreen( + ScreenType::AddTokenById.create_screen(&self.app_context), + ); + } } - TokensSubscreen::TokenCreator => { - app_action = AppAction::Refresh; + TokensSubscreen::SearchTokens | TokensSubscreen::TokenCreator => { + let button = egui::Button::new( + RichText::new("Refresh") + .color(egui::Color32::WHITE) + .strong(), + ) + .fill(DashColors::DASH_BLUE) + .min_size(egui::vec2(150.0, 36.0)); + + if ui.add(button).clicked() { + if let RefreshingStatus::Refreshing(_) = self.refreshing_status { + app_action = AppAction::None; + } else { + self.refreshing_status = + RefreshingStatus::Refreshing(Utc::now().timestamp() as u64); + app_action = AppAction::Refresh; + } + } } } - } - } - }); + ui.add_space(10.0); + }); + }); app_action } @@ -632,10 +647,12 @@ impl TokensScreen { ui.close_kind(egui::UiKind::Menu); } Ok(None) => { - self.set_error_message(Some("Token contract not found".to_string())); + self.token_creator_error_message = + Some("Token contract not found".to_string()); } Err(e) => { - self.set_error_message(Some(format!("Error fetching token contract: {e}"))); + self.token_creator_error_message = + Some(format!("Error fetching token contract: {e}")); } } } @@ -656,7 +673,7 @@ impl TokensScreen { ); } Err(e) => { - self.set_error_message(Some(e)); + self.token_creator_error_message = Some(e); } }; @@ -678,7 +695,7 @@ impl TokensScreen { ); } Err(e) => { - self.set_error_message(Some(e)); + self.token_creator_error_message = Some(e); } }; ui.close_kind(egui::UiKind::Menu); @@ -699,7 +716,7 @@ impl TokensScreen { ); } Err(e) => { - self.set_error_message(Some(e)); + self.token_creator_error_message = Some(e); } }; ui.close_kind(egui::UiKind::Menu); @@ -720,7 +737,7 @@ impl TokensScreen { ); } Err(e) => { - self.set_error_message(Some(e)); + self.token_creator_error_message = Some(e); } }; ui.close_kind(egui::UiKind::Menu); @@ -741,7 +758,7 @@ impl TokensScreen { ); } Err(e) => { - self.set_error_message(Some(e)); + self.token_creator_error_message = Some(e); } }; ui.close_kind(egui::UiKind::Menu); @@ -763,7 +780,7 @@ impl TokensScreen { ); } Err(e) => { - self.set_error_message(Some(e)); + self.token_creator_error_message = Some(e); } }; ui.close_kind(egui::UiKind::Menu); @@ -785,7 +802,7 @@ impl TokensScreen { ); } Err(e) => { - self.set_error_message(Some(e)); + self.token_creator_error_message = Some(e); } }; ui.close_kind(egui::UiKind::Menu); @@ -816,7 +833,7 @@ impl TokensScreen { ); } Err(e) => { - self.set_error_message(Some(e)); + self.token_creator_error_message = Some(e); } }; ui.close_kind(egui::UiKind::Menu); @@ -835,7 +852,7 @@ impl TokensScreen { if is_loading { // Show loading spinner - ui.add(egui::Spinner::new()); + ui.add(egui::Spinner::new().color(crate::ui::theme::DashColors::DASH_BLUE)); } else if has_pricing_data { // Check if identity has enough credits for at least one token let has_credits = self @@ -874,7 +891,7 @@ impl TokensScreen { ); } Err(e) => { - self.set_error_message(Some(e)); + self.token_creator_error_message = Some(e); } }; ui.close_kind(egui::UiKind::Menu); @@ -913,7 +930,7 @@ impl TokensScreen { ); } Err(e) => { - self.set_error_message(Some(e)); + self.token_creator_error_message = Some(e); } }; diff --git a/src/ui/tokens/tokens_screen/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index 1e2644a55..9c0f5a68b 100644 --- a/src/ui/tokens/tokens_screen/token_creator.rs +++ b/src/ui/tokens/tokens_screen/token_creator.rs @@ -11,16 +11,19 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; use eframe::epaint::Color32; -use egui::{ComboBox, Context, RichText, TextEdit, Ui}; +use egui::{ComboBox, Context, Frame, Margin, RichText, TextEdit, Ui}; use crate::ui::theme::DashColors; +use crate::ui::ScreenType; use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; use crate::ui::components::styled::{StyledCheckbox}; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::Component; +use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::helpers::{add_identity_key_chooser, TransactionType}; +use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use crate::ui::tokens::tokens_screen::{TokenBuildArgs, TokenCreatorStatus, TokenNameLanguage, TokensScreen, ChangeControlRulesUI}; impl TokensScreen { @@ -33,11 +36,29 @@ impl TokensScreen { return action; } - ui.heading("Token Creator"); - ui.label( - "Create custom tokens on Dash Platform with advanced features and distribution rules", - ); - ui.add_space(20.0); + // Heading with checkbox on the same line + ui.horizontal(|ui| { + ui.heading("Token Creator"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox( + &mut self.show_advanced_token_creator, + "Show Advanced Options", + ); + }); + }); + ui.add_space(5.0); + if self.show_advanced_token_creator { + ui.label( + "Create custom tokens on Dash Platform with advanced features and distribution rules.", + ); + } else { + ui.label( + "Create a simple token on Dash Platform. Enable Advanced Options for more control.", + ); + } + ui.add_space(10.0); + + let mut load_identity_clicked = false; egui::ScrollArea::horizontal() .show(ui, |ui| { @@ -55,62 +76,438 @@ impl TokensScreen { } }; if all_identities.is_empty() { - ui.colored_label( - Color32::DARK_RED, - "No identities loaded. Please load or create one to register the token contract with first.", - ); + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::group(ui.style()) + .fill(ui.visuals().extreme_bg_color) + .corner_radius(5.0) + .outer_margin(Margin::same(20)) + .shadow(ui.visuals().window_shadow) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(5.0); + ui.label( + RichText::new("No Identities Loaded") + .strong() + .size(25.0) + .color(DashColors::text_primary(dark_mode)), + ); + + ui.add_space(5.0); + ui.separator(); + ui.add_space(10.0); + + ui.label( + "To create a token, you need to load or create an identity first.", + ); + + ui.add_space(10.0); + + ui.heading( + RichText::new("Here's what you can do:") + .strong() + .size(18.0) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(5.0); + + ui.label("- LOAD an existing identity by clicking the button below, or"); + ui.add_space(1.0); + ui.label("- CREATE a new identity from the Identities screen after setting up a wallet."); + + ui.add_space(15.0); + + let button = egui::Button::new( + RichText::new("Load Identity") + .color(egui::Color32::WHITE) + .strong(), + ) + .fill(DashColors::DASH_BLUE) + .min_size(egui::vec2(150.0, 36.0)); + + if ui.add(button).clicked() { + load_identity_clicked = true; + } + + ui.add_space(10.0); + }); + }); return; } - ui.heading("1. Select an identity and key to register the token contract with:"); - ui.add_space(5.0); + // Branch: Simple mode vs Advanced mode for identity/key selection + if !self.show_advanced_token_creator { + // ===================================================== + // SIMPLE MODE - Identity selector only (no key selector) + // ===================================================== + ui.heading("1. Select an identity:"); + ui.add_space(5.0); - // Use the helper function for identity and key selection - add_identity_key_chooser( - ui, - &self.app_context, - all_identities.iter(), - &mut self.selected_identity, - &mut self.selected_key, - TransactionType::RegisterContract, - ); + // Use IdentitySelector for simple mode + let response = ui.add( + IdentitySelector::new( + "simple_identity_selector", + &mut self.identity_id_string, + &all_identities, + ) + .selected_identity(&mut self.selected_identity) + .expect("selected_identity should not fail") + .other_option(false) + .label("Identity:") + .width(300.0), + ); - ui.add_space(5.0); + // Auto-select the first eligible key when: + // 1. Identity changed, OR + // 2. Identity is selected but no key is selected yet (first load) + let should_auto_select_key = response.changed() + || (self.selected_identity.is_some() && self.selected_key.is_none()); - // If a key was selected, set the wallet reference - if let (Some(qid), Some(key)) = (&self.selected_identity, &self.selected_key) { - // If the key belongs to a wallet, set that wallet reference: - self.selected_wallet = crate::ui::identities::get_selected_wallet( - qid, - None, - Some(key), - &mut self.token_creator_error_message, + if should_auto_select_key { + if response.changed() { + self.selected_key = None; // Clear previous key only on identity change + } + if let Some(ref identity) = self.selected_identity { + // Find first eligible key for RegisterContract + // Requires Authentication purpose with High or Critical security level + let first_eligible_key = identity + .private_keys + .identity_public_keys() + .iter() + .find(|key_ref| { + let key = &key_ref.1.identity_public_key; + key.purpose() == Purpose::AUTHENTICATION + && (key.security_level() == SecurityLevel::CRITICAL + || key.security_level() == SecurityLevel::HIGH) + }) + .map(|key_ref| key_ref.1.identity_public_key.clone()); + + if first_eligible_key.is_some() { + self.selected_key = first_eligible_key; + } + } + } + + // If identity is selected but no eligible key could be found, show warning + if self.selected_identity.is_some() && self.selected_key.is_none() { + ui.add_space(5.0); + ui.colored_label( + egui::Color32::from_rgb(200, 100, 100), + "No eligible key found for this identity. Please use Advanced Options or add a suitable key.", + ); + return; + } + + if self.selected_identity.is_none() { + return; + } + + // Set wallet reference for the auto-selected key + if let (Some(qid), Some(key)) = (&self.selected_identity, &self.selected_key) { + self.selected_wallet = crate::ui::identities::get_selected_wallet( + qid, + None, + Some(key), + &mut self.token_creator_error_message, + ); + } + + ui.add_space(10.0); + ui.separator(); + + // Wallet unlock check for simple mode + if let Some(wallet) = &self.selected_wallet { + use crate::ui::components::wallet_unlock_popup::{ + wallet_needs_unlock, try_open_wallet_no_password, + }; + + if let Err(e) = try_open_wallet_no_password(wallet) { + self.token_creator_error_message = Some(e); + } + + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + return; + } + } + } else { + // ===================================================== + // ADVANCED MODE - Full identity and key selection + // ===================================================== + ui.heading("1. Select an identity and key to register the token contract with:"); + ui.add_space(5.0); + + // Use the helper function for identity and key selection + add_identity_key_chooser( + ui, + &self.app_context, + all_identities.iter(), + &mut self.selected_identity, + &mut self.selected_key, + TransactionType::RegisterContract, ); - } - if self.selected_key.is_none() { - return; - } + ui.add_space(5.0); - ui.add_space(10.0); - ui.separator(); + // If a key was selected, set the wallet reference + if let (Some(qid), Some(key)) = (&self.selected_identity, &self.selected_key) { + self.selected_wallet = crate::ui::identities::get_selected_wallet( + qid, + None, + Some(key), + &mut self.token_creator_error_message, + ); + } + + if self.selected_key.is_none() { + return; + } - // 3) If the wallet is locked, show unlock - // But only do this step if we actually have a wallet reference: - let mut need_unlock = false; - let mut just_unlocked = false; + ui.add_space(10.0); + ui.separator(); - if self.selected_wallet.is_some() { - let (n, j) = self.render_wallet_unlock_if_needed(ui); - need_unlock = n; - just_unlocked = j; - } + // Wallet unlock check for advanced mode + if let Some(wallet) = &self.selected_wallet { + use crate::ui::components::wallet_unlock_popup::{ + wallet_needs_unlock, try_open_wallet_no_password, + }; - if need_unlock && !just_unlocked { - // We must wait for unlock before continuing - return; + if let Err(e) = try_open_wallet_no_password(wallet) { + self.token_creator_error_message = Some(e); + } + + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + return; + } + } } + // Continue with mode-specific content + if !self.show_advanced_token_creator { + // ===================================================== + // SIMPLE MODE - Beginner-friendly options with info icons + // ===================================================== + ui.add_space(10.0); + ui.heading("2. Enter token details:"); + ui.add_space(5.0); + + egui::Grid::new("simple_token_info_grid") + .num_columns(2) + .spacing([8.0, 8.0]) + .show(ui, |ui| { + // Token Name + ui.horizontal(|ui| { + ui.label("Token Name*:"); + if crate::ui::helpers::info_icon_button(ui, + "The name of your token (e.g., 'MyCoin', 'GameToken').\n\n\ + This is how your token will be displayed to users.\n\n\ + Must be between 3 and 50 characters.").clicked() { + self.show_pop_up_info = Some( + "Token Name\n\n\ + The name of your token (e.g., 'MyCoin', 'GameToken').\n\n\ + This is how your token will be displayed to users.\n\n\ + Must be between 3 and 50 characters.".to_string() + ); + } + }); + ui.text_edit_singleline(&mut self.token_names_input[0].0); + ui.end_row(); + + // Token Description + ui.horizontal(|ui| { + ui.label("Description:"); + if crate::ui::helpers::info_icon_button(ui, + "An optional description explaining what your token is for.\n\n\ + This helps users understand the purpose of your token.\n\n\ + Maximum 100 characters.").clicked() { + self.show_pop_up_info = Some( + "Description\n\n\ + An optional description explaining what your token is for.\n\n\ + This helps users understand the purpose of your token.\n\n\ + Maximum 100 characters.".to_string() + ); + } + }); + ui.text_edit_singleline(&mut self.token_description_input); + ui.end_row(); + + // Initial Supply + ui.horizontal(|ui| { + ui.label("Initial Supply*:"); + if crate::ui::helpers::info_icon_button(ui, + "The number of tokens to create when the token is registered.\n\n\ + These tokens will be owned by you (the token creator).\n\n\ + You can mint more tokens later if minting is enabled.").clicked() { + self.show_pop_up_info = Some( + "Initial Supply\n\n\ + The number of tokens to create when the token is registered.\n\n\ + These tokens will be owned by you (the token creator).\n\n\ + You can mint more tokens later if minting is enabled.".to_string() + ); + } + }); + self.render_base_supply_input(ui); + ui.end_row(); + + // Max Supply + ui.horizontal(|ui| { + ui.label("Max Supply:"); + if crate::ui::helpers::info_icon_button(ui, + "The maximum number of tokens that can ever exist.\n\n\ + Leave empty or set to 0 for no maximum (unlimited supply).\n\n\ + Once set, this cannot be increased.").clicked() { + self.show_pop_up_info = Some( + "Max Supply\n\n\ + The maximum number of tokens that can ever exist.\n\n\ + Leave empty or set to 0 for no maximum (unlimited supply).\n\n\ + Once set, this cannot be increased.".to_string() + ); + } + }); + self.render_max_supply_input(ui); + ui.end_row(); + + // Preset selector + ui.vertical(|ui| { + ui.add_space(15.0); + ui.horizontal(|ui| { + ui.label("Token Preset*:"); + if crate::ui::helpers::info_icon_button(ui, + "Choose a preset that determines what actions are allowed on your token.\n\n\ + Click for more details on each preset.").clicked() { + self.show_pop_up_info = Some( + "Token Presets\n\n\ + Presets control what actions can be performed on your token after creation:\n\n\ + - Most Restrictive: No additional actions allowed. Token is fixed after creation. Best for simple, immutable tokens.\n\n\ + - Only Emergency Action: Allows pausing/unpausing the token in emergencies. Good for tokens that need a safety mechanism.\n\n\ + - Minting and Burning: Allows creating new tokens (minting) and destroying tokens (burning). Good for flexible supply tokens.\n\n\ + - Advanced Actions: Allows minting, burning, freezing accounts, and more. For tokens needing moderation capabilities.\n\n\ + - All Allowed: All actions enabled including destroying frozen funds. Maximum flexibility but requires careful management.".to_string() + ); + } + }); + }); + ComboBox::from_id_salt("simple_preset_selector") + .width(200.0) + .selected_text( + self.selected_token_preset + .map(|p| match p { + MostRestrictive => "Most Restrictive", + WithOnlyEmergencyAction => "Only Emergency Action", + WithMintingAndBurningActions => "Minting and Burning", + WithAllAdvancedActions => "Advanced Actions", + WithExtremeActions => "All Allowed", + }) + .unwrap_or("Select a preset..."), + ) + .show_ui(ui, |ui| { + for variant in [ + MostRestrictive, + WithOnlyEmergencyAction, + WithMintingAndBurningActions, + WithAllAdvancedActions, + WithExtremeActions, + ] { + let (text, description) = match variant { + MostRestrictive => ("Most Restrictive", "No actions allowed after creation"), + WithOnlyEmergencyAction => ("Only Emergency Action", "Can pause/unpause token"), + WithMintingAndBurningActions => ("Minting and Burning", "Can mint and burn tokens"), + WithAllAdvancedActions => ("Advanced Actions", "Mint, burn, freeze, and more"), + WithExtremeActions => ("All Allowed", "All actions enabled"), + }; + if ui.selectable_value( + &mut self.selected_token_preset, + Some(variant), + format!("{} - {}", text, description), + ).clicked() { + let preset = TokenConfigurationPreset { + features: variant, + action_taker: AuthorizedActionTakers::ContractOwner, + }; + self.change_to_preset(preset); + } + } + }); + ui.end_row(); + }); + + ui.add_space(20.0); + + // Create Token button + let can_create = !self.token_names_input[0].0.trim().is_empty() + && self.base_supply_amount.is_some() + && self.selected_token_preset.is_some(); + + ui.horizontal(|ui| { + let button = egui::Button::new( + RichText::new("Create Token") + .color(egui::Color32::WHITE) + .strong(), + ) + .fill(if can_create { + DashColors::DASH_BLUE + } else { + egui::Color32::GRAY + }) + .min_size(egui::vec2(150.0, 36.0)); + + if ui.add_enabled(can_create, button).clicked() { + // Auto-set plural name if empty (singular + "s") + let singular = self.token_names_input[0].0.trim().to_string(); + if self.token_names_input[0].1.trim().is_empty() { + self.token_names_input[0].1 = format!("{}s", singular); + } + + // Trigger the token creation confirmation + match self.parse_token_build_args() { + Ok(args) => { + self.cached_build_args = Some(args); + self.token_creator_error_message = None; + self.show_token_creator_confirmation_popup = true; + } + Err(err_msg) => { + self.token_creator_error_message = Some(err_msg); + } + } + } + }); + + if !can_create { + ui.add_space(5.0); + let missing = if self.token_names_input[0].0.trim().is_empty() { + "token name" + } else if self.base_supply_amount.is_none() { + "initial supply" + } else { + "token preset" + }; + ui.label( + RichText::new(format!("Please select a {}", missing)) + .color(egui::Color32::GRAY) + .italics(), + ); + } + } else { + // ===================================================== + // ADVANCED MODE - Full options + // ===================================================== + // 4) Show input fields for token name, decimals, base supply, etc. ui.add_space(10.0); ui.heading("2. Enter basic token info:"); @@ -585,11 +982,16 @@ impl TokensScreen { // 6) "Register Token Contract" button ui.add_space(10.0); - let mut new_style = (**ui.style()).clone(); - new_style.spacing.button_padding = egui::vec2(10.0, 5.0); - ui.set_style(new_style); ui.horizontal(|ui| { - if ui.button("Register Token Contract").clicked() { + let register_button = egui::Button::new( + RichText::new("Register Token Contract") + .color(egui::Color32::WHITE) + .strong(), + ) + .fill(DashColors::DASH_BLUE) + .min_size(egui::vec2(200.0, 36.0)); + + if ui.add(register_button).clicked() { match self.parse_token_build_args() { Ok(args) => { // If success, show the "confirmation popup" @@ -604,7 +1006,15 @@ impl TokensScreen { } } - if ui.button("View JSON").clicked() { + let view_json_button = egui::Button::new( + RichText::new("View JSON") + .color(egui::Color32::WHITE) + .strong(), + ) + .fill(DashColors::DASH_BLUE) + .min_size(egui::vec2(120.0, 36.0)); + + if ui.add(view_json_button).clicked() { match self.parse_token_build_args() { Ok(args) => { // We have the parsed token creation arguments @@ -682,6 +1092,8 @@ impl TokensScreen { self.should_reset_collapsing_states = false; } + } // Close advanced mode else block + // 7) If the user pressed "Register Token Contract," show a popup confirmation if self.show_token_creator_confirmation_popup { action |= self.render_token_creator_confirmation_popup(ui); @@ -701,19 +1113,40 @@ impl TokensScreen { "Registering token contract... elapsed {}s", elapsed )); - ui.add(egui::widgets::Spinner::default()); + ui.add(egui::widgets::Spinner::default().color(DashColors::DASH_BLUE)); }); } // Show an error if we have one - if let Some(err_msg) = &self.token_creator_error_message { + if let Some(err_msg) = self.token_creator_error_message.clone() { ui.add_space(10.0); - ui.colored_label(Color32::DARK_RED, err_msg.to_string()); + let error_color = Color32::from_rgb(255, 100, 100); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("Error: {}", err_msg)).color(error_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.token_creator_error_message = None; + } + }); + }); ui.add_space(10.0); } }); // Close the ScrollArea from line 40 + // Handle Load Identity button click from within the ScrollArea + if load_identity_clicked { + return AppAction::AddScreen( + ScreenType::AddExistingIdentity.create_screen(&self.app_context), + ); + } + action } diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 41effdd87..77f010d71 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -1,8 +1,9 @@ use crate::app::AppAction; -use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; @@ -13,8 +14,10 @@ use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser}; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::helpers::{TransactionType, add_key_chooser}; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::theme::DashColors; @@ -24,6 +27,7 @@ use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::prelude::TimestampMillis; use dash_sdk::platform::{Identifier, IdentityPublicKey}; use eframe::egui::{self, Context, Ui}; +use eframe::egui::{Frame, Margin}; use egui::{Color32, RichText}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -46,6 +50,7 @@ pub struct TransferTokensScreen { pub identity_token_balance: IdentityTokenBalance, known_identities: Vec, selected_key: Option, + show_advanced_options: bool, pub public_note: Option, pub receiver_identity_id: String, pub amount: Option, @@ -55,8 +60,9 @@ pub struct TransferTokensScreen { pub app_context: Arc, confirmation_dialog: Option, selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, + // Fee result from completed operation + completed_fee_result: Option, } impl TransferTokensScreen { @@ -92,6 +98,7 @@ impl TransferTokensScreen { identity_token_balance, known_identities, selected_key: selected_key.cloned(), + show_advanced_options: false, public_note: None, receiver_identity_id: String::new(), amount, @@ -101,8 +108,8 @@ impl TransferTokensScreen { app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), + completed_fee_result: None, } } @@ -233,42 +240,27 @@ impl TransferTokensScreen { ))) } pub fn show_success(&self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - - // Center the content vertically and horizontally - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - ui.heading("Success!"); - - ui.add_space(20.0); - - // Display the "Back to Identities" button - if ui.button("Back to Tokens").clicked() { - // Handle navigation back to the identities screen - action |= AppAction::PopScreenAndRefresh; - } - }); - - action + crate::ui::helpers::show_success_screen_with_info( + ui, + "Transfer Successful!".to_string(), + vec![("Back to Tokens".to_string(), AppAction::PopScreenAndRefresh)], + None, + ) } } impl ScreenLike for TransferTokensScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - if message == "TransferTokens" { - self.transfer_tokens_status = TransferTokensStatus::Complete; - } - } - MessageType::Info => {} - MessageType::Error => { - // It's not great because the error message can be coming from somewhere else if there are other processes happening - self.transfer_tokens_status = - TransferTokensStatus::ErrorMessage(message.to_string()); - } + if let MessageType::Error = message_type { + self.transfer_tokens_status = TransferTokensStatus::ErrorMessage(message.to_string()); + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::TransferredTokens(fee_result) = backend_task_success_result + { + self.completed_fee_result = Some(fee_result); + self.transfer_tokens_status = TransferTokensStatus::Complete; } } @@ -378,34 +370,52 @@ impl ScreenLike for TransferTokensScreen { ))); } } else { - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - - if needed_unlock && !just_unlocked { + if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.transfer_tokens_status = TransferTokensStatus::ErrorMessage(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } return AppAction::None; } } - // Select the key to sign with - ui.heading("1. Select the key to sign the transaction with"); + // Header with Advanced Options checkbox + ui.horizontal(|ui| { + ui.heading("Transfer Tokens"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); ui.add_space(10.0); - let mut selected_identity = Some(self.identity.clone()); - add_identity_key_chooser( - ui, - &self.app_context, - std::iter::once(&self.identity), - &mut selected_identity, - &mut self.selected_key, - TransactionType::TokenTransfer, - ); - - ui.add_space(10.0); - ui.separator(); - ui.add_space(10.0); + // Key selection (only in advanced mode) + if self.show_advanced_options { + ui.heading("1. Select the key to sign the transaction with"); + ui.add_space(10.0); + add_key_chooser( + ui, + &self.app_context, + &self.identity, + &mut self.selected_key, + TransactionType::TokenTransfer, + ); + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + } // Input the amount to transfer - ui.heading("2. Input the amount to transfer"); + let step_num = if self.show_advanced_options { "2" } else { "1" }; + ui.heading(format!("{}. Input the amount to transfer", step_num)); ui.add_space(5.0); self.render_amount_input(ui); @@ -415,7 +425,8 @@ impl ScreenLike for TransferTokensScreen { ui.add_space(10.0); // Input the ID of the identity to transfer to - ui.heading("3. ID of the identity to transfer to"); + let step_num = if self.show_advanced_options { "3" } else { "2" }; + ui.heading(format!("{}. ID of the identity to transfer to", step_num)); ui.add_space(5.0); self.render_to_identity_input(ui); @@ -424,7 +435,8 @@ impl ScreenLike for TransferTokensScreen { ui.add_space(10.0); // Render text input for the public note - ui.heading("4. Public note (optional)"); + let step_num = if self.show_advanced_options { "4" } else { "3" }; + ui.heading(format!("{}. Public note (optional)", step_num)); ui.add_space(5.0); ui.horizontal(|ui| { ui.label("Public note (optional):"); @@ -442,11 +454,38 @@ impl ScreenLike for TransferTokensScreen { }); ui.add_space(10.0); + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_document_batch(1); // Token transfers are document batch transitions + + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + + ui.add_space(10.0); + // Transfer button + let has_enough_balance = self.identity.identity.balance() > estimated_fee; let ready = self.amount.is_some() && !self.receiver_identity_id.is_empty() - && self.selected_key.is_some(); + && self.selected_key.is_some() + && has_enough_balance; let mut new_style = (**ui.style()).clone(); new_style.spacing.button_padding = egui::vec2(10.0, 5.0); ui.set_style(new_style); @@ -454,9 +493,18 @@ impl ScreenLike for TransferTokensScreen { .fill(Color32::from_rgb(0, 128, 255)) .frame(true) .corner_radius(3.0); + let hover_text = if !has_enough_balance { + format!( + "Insufficient identity balance for fee (need at least {})", + format_credits_as_dash(estimated_fee) + ) + } else { + "Please ensure all fields are filled correctly".to_string() + }; + if ui .add_enabled(ready, button) - .on_disabled_hover_text("Please ensure all fields are filled correctly") + .on_disabled_hover_text(&hover_text) .clicked() { // Use the amount value directly since it's already parsed @@ -537,42 +585,19 @@ impl ScreenLike for TransferTokensScreen { AppAction::None }); action |= central_panel_action; - action - } -} - -impl ScreenWithWalletUnlock for TransferTokensScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - if let Some(error_message) = error_message { - self.transfer_tokens_status = TransferTokensStatus::ErrorMessage(error_message); + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } } - } - fn error_message(&self) -> Option<&String> { - if let TransferTokensStatus::ErrorMessage(error_message) = &self.transfer_tokens_status { - Some(error_message) - } else { - None - } + action } } diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index 2f891b837..7e211227a 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -1,8 +1,9 @@ use super::tokens_screen::IdentityTokenInfo; use crate::app::AppAction; -use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::component_trait::Component; @@ -12,13 +13,15 @@ use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::contracts_documents::group_actions_screen::GroupActionsScreen; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser, render_group_action_text}; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; -use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike}; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; @@ -31,7 +34,7 @@ use dash_sdk::dpp::group::GroupStateTransitionInfoStatus; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::platform::{Identifier, IdentityPublicKey}; -use eframe::egui::{self, Color32, Context, Ui}; +use eframe::egui::{self, Color32, Context, Frame, Margin, Ui}; use egui::RichText; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -51,6 +54,7 @@ pub struct UnfreezeTokensScreen { pub identity: QualifiedIdentity, pub identity_token_info: IdentityTokenInfo, selected_key: Option, + show_advanced_options: bool, pub public_note: Option, group: Option<(GroupContractPosition, Group)>, @@ -75,8 +79,9 @@ pub struct UnfreezeTokensScreen { // If password-based wallet unlocking is needed selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, + // Fee result from completed operation + completed_fee_result: Option, } impl UnfreezeTokensScreen { @@ -191,6 +196,7 @@ impl UnfreezeTokensScreen { identity: identity_token_info.identity.clone(), identity_token_info, selected_key: possible_key, + show_advanced_options: false, group, is_unilateral_group_member, group_action_id: None, @@ -201,9 +207,9 @@ impl UnfreezeTokensScreen { app_context: app_context.clone(), confirmation_dialog: None, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), frozen_identities, + completed_fee_result: None, } } @@ -305,65 +311,30 @@ impl UnfreezeTokensScreen { } fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - if self.group_action_id.is_some() { - // This is already initiated by the group, we are just signing it - ui.heading("Group Unfreeze Signing Successful."); - } else if !self.is_unilateral_group_member && self.group.is_some() { - ui.heading("Group Unfreeze Initiated."); - } else { - ui.heading("Unfroze Identity Successfully."); - } - - ui.add_space(20.0); - - if self.group_action_id.is_some() { - if ui.button("Back to Group Actions").clicked() { - action |= AppAction::PopScreenAndRefresh; - } - if ui.button("Back to Tokens").clicked() { - action |= AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenMyTokenBalances, - ); - } - } else { - if ui.button("Back to Tokens").clicked() { - action |= AppAction::PopScreenAndRefresh; - } - - if !self.is_unilateral_group_member && ui.button("Go to Group Actions").clicked() { - action |= AppAction::PopThenAddScreenToMainScreen( - RootScreenType::RootScreenDocumentQuery, - Screen::GroupActionsScreen(GroupActionsScreen::new( - &self.app_context.clone(), - )), - ); - } - } - }); - action + crate::ui::helpers::show_group_token_success_screen_with_fee( + ui, + "Unfreeze", + self.group_action_id.is_some(), + self.is_unilateral_group_member, + self.group.is_some(), + &self.app_context, + None, + ) } } impl ScreenLike for UnfreezeTokensScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Success => { - // Possibly "UnfreezeTokens" or something else from your backend - if message.contains("Successfully unfroze identity") || message == "UnfreezeTokens" - { - self.status = UnfreezeTokensStatus::Complete; - } - } - MessageType::Error => { - self.status = UnfreezeTokensStatus::ErrorMessage(message.to_string()); - self.error_message = Some(message.to_string()); - } - MessageType::Info => {} + if let MessageType::Error = message_type { + self.status = UnfreezeTokensStatus::ErrorMessage(message.to_string()); + self.error_message = Some(message.to_string()); + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::UnfrozeTokens(fee_result) = backend_task_success_result { + self.completed_fee_result = Some(fee_result); + self.status = UnfreezeTokensStatus::Complete; } } @@ -472,34 +443,52 @@ impl ScreenLike for UnfreezeTokensScreen { } } else { // Possibly handle locked wallet scenario - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - - if needed_unlock && !just_unlocked { + if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } return; } } - // 1) Key selection - ui.heading("1. Select the key to sign the Unfreeze transition"); + // Header with Advanced Options checkbox + ui.horizontal(|ui| { + ui.heading("Unfreeze Tokens"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); ui.add_space(10.0); - let mut selected_identity = Some(self.identity.clone()); - add_identity_key_chooser( - ui, - &self.app_context, - std::iter::once(&self.identity), - &mut selected_identity, - &mut self.selected_key, - TransactionType::TokenAction, - ); - - ui.add_space(10.0); - ui.separator(); + // Key selection (only in advanced mode) + if self.show_advanced_options { + ui.heading("1. Select the key to sign the Unfreeze transition"); + ui.add_space(10.0); + add_key_chooser( + ui, + &self.app_context, + &self.identity, + &mut self.selected_key, + TransactionType::TokenAction, + ); + ui.add_space(10.0); + ui.separator(); + } ui.add_space(10.0); - // 2) Identity to unfreeze - ui.heading("2. Enter the identity ID to unfreeze"); + // Identity to unfreeze + let step_num = if self.show_advanced_options { 2 } else { 1 }; + ui.heading(format!("{}. Enter the identity ID to unfreeze", step_num)); ui.add_space(5.0); if self.group_action_id.is_some() { ui.label( @@ -516,7 +505,8 @@ impl ScreenLike for UnfreezeTokensScreen { ui.add_space(10.0); // Render text input for the public note - ui.heading("3. Public note (optional)"); + let step_num = if self.show_advanced_options { 3 } else { 2 }; + ui.heading(format!("{}. Public note (optional)", step_num)); ui.add_space(5.0); if self.group_action_id.is_some() { ui.label( @@ -544,6 +534,30 @@ impl ScreenLike for UnfreezeTokensScreen { }); } + // Fee estimation display + let fee_estimator = PlatformFeeEstimator::new(); + let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions + + let dark_mode = ui.ctx().style().visuals.dark_mode; + Frame::new() + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + }); + let button_text = render_group_action_text( ui, &self.group, @@ -594,7 +608,24 @@ impl ScreenLike for UnfreezeTokensScreen { ui.label(format!("Unfreezing... elapsed: {}s", elapsed)); } UnfreezeTokensStatus::ErrorMessage(msg) => { - ui.colored_label(Color32::RED, format!("Error: {}", msg)); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = msg.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", msg)).color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.status = UnfreezeTokensStatus::NotStarted; + } + }); + }); } UnfreezeTokensStatus::Complete => { // handled above @@ -603,36 +634,18 @@ impl ScreenLike for UnfreezeTokensScreen { } }); - action - } -} - -impl ScreenWithWalletUnlock for UnfreezeTokensScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 49460b84d..fdf783a3a 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -1,21 +1,23 @@ use super::tokens_screen::IdentityTokenInfo; use crate::app::AppAction; -use crate::backend_task::BackendTask; use crate::backend_task::tokens::TokenTask; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; +use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tokens_subscreen_chooser_panel::add_tokens_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; -use crate::ui::contracts_documents::group_actions_screen::GroupActionsScreen; -use crate::ui::helpers::{TransactionType, add_identity_key_chooser, render_group_action_text}; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::helpers::{TransactionType, add_key_chooser, render_group_action_text}; use crate::ui::identities::get_selected_wallet; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; -use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike}; +use crate::ui::{MessageType, Screen, ScreenLike}; use chrono::{DateTime, Utc}; use dash_sdk::dpp::data_contract::GroupContractPosition; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -33,7 +35,7 @@ use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{DataContract, Identifier, IdentityPublicKey}; use eframe::egui::{self, Color32, Context, Ui}; -use egui::RichText; +use egui::{Frame, Margin, RichText}; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -52,6 +54,7 @@ pub struct UpdateTokenConfigScreen { pub update_text: String, pub text_input_error: String, signing_key: Option, + show_advanced_options: bool, identity: QualifiedIdentity, pub public_note: Option, group: Option<(GroupContractPosition, Group)>, @@ -63,9 +66,10 @@ pub struct UpdateTokenConfigScreen { pub authorized_group_input: Option, selected_wallet: Option>>, - wallet_password: String, - show_password: bool, + wallet_unlock_popup: WalletUnlockPopup, error_message: Option, // unused + // Fee result from completed operation + completed_fee_result: Option, } impl UpdateTokenConfigScreen { @@ -106,20 +110,21 @@ impl UpdateTokenConfigScreen { update_text: "".to_string(), text_input_error: "".to_string(), signing_key: possible_key, + show_advanced_options: false, public_note: None, authorized_identity_input: None, authorized_group_input: None, selected_wallet, - wallet_password: String::new(), - show_password: false, + wallet_unlock_popup: WalletUnlockPopup::new(), error_message, identity: identity_token_info.identity, group, is_unilateral_group_member, group_action_id: None, + completed_fee_result: None, } } @@ -705,6 +710,28 @@ impl UpdateTokenConfigScreen { }); } + // Display estimated fee before action button + let estimated_fee = PlatformFeeEstimator::new().estimate_token_transition(); + ui.add_space(10.0); + let dark_mode = ui.ctx().style().visuals.dark_mode; + egui::Frame::new() + .fill(crate::ui::theme::DashColors::surface(dark_mode)) + .inner_margin(egui::Margin::symmetric(10, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated Fee:") + .color(crate::ui::theme::DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(format_credits_as_dash(estimated_fee)) + .color(crate::ui::theme::DashColors::text_primary(dark_mode)) + .strong(), + ); + }); + }); + let button_text = render_group_action_text( ui, &self.group, @@ -879,69 +906,50 @@ impl UpdateTokenConfigScreen { } fn show_success_screen(&self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - ui.vertical_centered(|ui| { - ui.add_space(50.0); - - ui.heading("🎉"); - if self.group_action_id.is_some() { - // This ConfigUpdate is already initiated by the group, we are just signing it - ui.heading("Group ConfigUpdate Signing Successful."); - } else if !self.is_unilateral_group_member { - ui.heading("Group ConfigUpdate Initiated."); - } else { - ui.heading("ConfigUpdate Successful."); - } - - ui.add_space(20.0); - - if self.group_action_id.is_some() { - if ui.button("Back to Group Actions").clicked() { - action |= AppAction::PopScreenAndRefresh; - } - if ui.button("Back to Tokens").clicked() { - action |= AppAction::SetMainScreenThenGoToMainScreen( - RootScreenType::RootScreenMyTokenBalances, - ); - } - } else { - if ui.button("Back to Tokens").clicked() { - action |= AppAction::PopScreenAndRefresh; - } - - if !self.is_unilateral_group_member && ui.button("Go to Group Actions").clicked() { - action |= AppAction::PopThenAddScreenToMainScreen( - RootScreenType::RootScreenDocumentQuery, - Screen::GroupActionsScreen(GroupActionsScreen::new( - &self.app_context.clone(), - )), - ); - } - } - }); - action + crate::ui::helpers::show_group_token_success_screen( + ui, + "Config Update", + self.group_action_id.is_some(), + self.is_unilateral_group_member, + self.group.is_some(), + &self.app_context, + ) } } impl ScreenLike for UpdateTokenConfigScreen { fn display_message(&mut self, message: &str, message_type: MessageType) { match message_type { - MessageType::Success => { - self.backend_message = - Some((message.to_string(), MessageType::Success, Utc::now())); - if message.contains("Successfully updated token config item") { - self.update_status = UpdateTokenConfigStatus::NotUpdating; - } - } MessageType::Error => { self.backend_message = Some((message.to_string(), MessageType::Error, Utc::now())); - if message.contains("Failed to update token config") { - self.update_status = UpdateTokenConfigStatus::NotUpdating; - } + self.update_status = UpdateTokenConfigStatus::NotUpdating; } MessageType::Info => { self.backend_message = Some((message.to_string(), MessageType::Info, Utc::now())); } + _ => {} + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + if let BackendTaskSuccessResult::UpdatedTokenConfig(change_item, fee_result) = + backend_task_success_result + { + self.completed_fee_result = Some(fee_result.clone()); + let fee_info = format!( + " (Fee: Estimated {} • Actual {})", + format_credits_as_dash(fee_result.estimated_fee), + format_credits_as_dash(fee_result.actual_fee) + ); + self.backend_message = Some(( + format!( + "Successfully updated token config item: {}{}", + change_item, fee_info + ), + MessageType::Success, + Utc::now(), + )); + self.update_status = UpdateTokenConfigStatus::NotUpdating; } } @@ -1043,46 +1051,76 @@ impl ScreenLike for UpdateTokenConfigScreen { } } else { // Possibly handle locked wallet scenario (similar to TransferTokens) - if self.selected_wallet.is_some() { - let (needed_unlock, just_unlocked) = self.render_wallet_unlock_if_needed(ui); - - if needed_unlock && !just_unlocked { - // Must unlock before we can proceed + if let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } return; } } - // 1) Key selection - ui.heading("1. Select the key to sign the transaction with"); + // Header with Advanced Options checkbox + ui.horizontal(|ui| { + ui.heading("Update Token Config"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); ui.add_space(10.0); - let mut selected_identity = Some(self.identity.clone()); - add_identity_key_chooser( - ui, - &self.app_context, - std::iter::once(&self.identity), - &mut selected_identity, - &mut self.signing_key, - TransactionType::TokenAction, - ); - - ui.add_space(10.0); - ui.separator(); + // Key selection (only in advanced mode) + if self.show_advanced_options { + ui.heading("1. Select the key to sign the transaction with"); + ui.add_space(10.0); + add_key_chooser( + ui, + &self.app_context, + &self.identity, + &mut self.signing_key, + TransactionType::TokenAction, + ); + ui.add_space(10.0); + ui.separator(); + } ui.add_space(10.0); action |= self.render_token_config_updater(ui); - if let Some((msg, msg_type, _)) = &self.backend_message { + if let Some((msg, msg_type, _)) = self.backend_message.clone() { ui.add_space(10.0); match msg_type { MessageType::Success => { - ui.colored_label(Color32::DARK_GREEN, msg); + ui.colored_label(Color32::DARK_GREEN, &msg); } MessageType::Error => { - ui.colored_label(Color32::DARK_RED, msg); + let error_color = Color32::from_rgb(255, 100, 100); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("Error: {}", msg)).color(error_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.backend_message = None; + } + }); + }); } MessageType::Info => { - ui.label(msg); + ui.label(&msg); } }; } @@ -1098,37 +1136,19 @@ impl ScreenLike for UpdateTokenConfigScreen { }); // end of ScrollArea }); - action - } -} - -impl ScreenWithWalletUnlock for UpdateTokenConfigScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password - } - - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } - - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; - } + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() + action } } diff --git a/src/ui/tools/address_balance_screen.rs b/src/ui/tools/address_balance_screen.rs new file mode 100644 index 000000000..d9f1f9f54 --- /dev/null +++ b/src/ui/tools/address_balance_screen.rs @@ -0,0 +1,198 @@ +use crate::app::AppAction; +use crate::backend_task::platform_info::{PlatformInfoTaskRequestType, PlatformInfoTaskResult}; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::{MessageType, ScreenLike}; +use eframe::egui::{self, Color32, Context, Frame, Margin, RichText, ScrollArea, TextEdit, Ui}; +use std::sync::Arc; + +pub struct AddressBalanceScreen { + pub(crate) app_context: Arc, + address_input: String, + is_loading: bool, + result: Option, + error_message: Option, +} + +#[derive(Clone, Debug)] +pub struct AddressBalanceResult { + pub address: String, + pub balance: u64, + pub nonce: u32, +} + +impl AddressBalanceScreen { + pub fn new(app_context: &Arc) -> Self { + Self { + app_context: app_context.clone(), + address_input: String::new(), + is_loading: false, + result: None, + error_message: None, + } + } + + fn trigger_fetch(&mut self) -> AppAction { + let address = self.address_input.trim().to_string(); + if address.is_empty() { + self.error_message = Some("Please enter an address".to_string()); + return AppAction::None; + } + + self.is_loading = true; + self.error_message = None; + self.result = None; + + let task = + BackendTask::PlatformInfo(PlatformInfoTaskRequestType::FetchAddressBalance(address)); + AppAction::BackendTask(task) + } + + fn render_input(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + + ui.heading("Platform Address Balance Lookup"); + ui.add_space(10.0); + + ui.label("Enter a Platform address (dashevo1... or tdashevo1...):"); + ui.add_space(5.0); + + let text_edit = TextEdit::singleline(&mut self.address_input) + .hint_text("dashevo1... or tdashevo1...") + .desired_width(500.0); + + let response = ui.add(text_edit); + + // Submit on Enter key + if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + action = self.trigger_fetch(); + } + + ui.add_space(10.0); + + let button = ui.add_enabled( + !self.is_loading && !self.address_input.trim().is_empty(), + egui::Button::new(if self.is_loading { + "Loading..." + } else { + "Fetch Balance" + }), + ); + + if button.clicked() { + action = self.trigger_fetch(); + } + + action + } + + fn render_result(&mut self, ui: &mut Ui) { + if let Some(ref error) = self.error_message { + ui.add_space(20.0); + let error_color = Color32::from_rgb(255, 100, 100); + let error = error.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("Error: {}", error)).color(error_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.error_message = None; + } + }); + }); + } + + if let Some(ref result) = self.result { + ui.add_space(20.0); + ui.separator(); + ui.add_space(10.0); + + ui.heading("Result"); + ui.add_space(10.0); + + egui::Grid::new("address_balance_grid") + .num_columns(2) + .spacing([20.0, 8.0]) + .show(ui, |ui| { + ui.label("Address:"); + ui.monospace(&result.address); + ui.end_row(); + + ui.label("Balance:"); + let credits = result.balance; + let dash = credits as f64 / 100_000_000_000.0; // credits to Dash + ui.monospace(format!("{} credits ({:.8} Dash)", credits, dash)); + ui.end_row(); + + ui.label("Nonce:"); + ui.monospace(format!("{}", result.nonce)); + ui.end_row(); + }); + } + } +} + +impl ScreenLike for AddressBalanceScreen { + fn display_message(&mut self, message: &str, message_type: MessageType) { + if message_type == MessageType::Error { + self.error_message = Some(message.to_string()); + } + } + + fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + self.is_loading = false; + + if let BackendTaskSuccessResult::PlatformInfo(PlatformInfoTaskResult::AddressBalance { + address, + balance, + nonce, + }) = backend_task_success_result + { + self.result = Some(AddressBalanceResult { + address, + balance, + nonce, + }); + } + } + + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![("Tools", AppAction::None)], + vec![], + ); + + action |= add_left_panel( + ctx, + &self.app_context, + crate::ui::RootScreenType::RootScreenToolsAddressBalanceScreen, + ); + action |= add_tools_subscreen_chooser_panel(ctx, &self.app_context); + + island_central_panel(ctx, |ui| { + ScrollArea::vertical().show(ui, |ui| { + action |= self.render_input(ui); + self.render_result(ui); + }); + }); + + action + } + + fn refresh(&mut self) {} + + fn refresh_on_arrival(&mut self) {} + + fn pop_on_success(&mut self) {} +} diff --git a/src/ui/tools/contract_visualizer_screen.rs b/src/ui/tools/contract_visualizer_screen.rs index efe8e8123..e810214e1 100644 --- a/src/ui/tools/contract_visualizer_screen.rs +++ b/src/ui/tools/contract_visualizer_screen.rs @@ -8,7 +8,7 @@ use crate::ui::components::top_panel::add_top_panel; use base64::{Engine, engine::general_purpose::STANDARD}; use dash_sdk::dpp::serialization::PlatformDeserializableWithPotentialValidationFromVersionedStructure; use dash_sdk::platform::DataContract; -use eframe::egui::{Color32, Context, ScrollArea, TextEdit, Ui}; +use eframe::egui::{Color32, Context, Frame, Margin, RichText, ScrollArea, TextEdit, Ui}; use std::sync::Arc; // ======================= 1. Data & helpers ======================= @@ -144,7 +144,22 @@ impl ContractVisualizerScreen { ui.monospace(self.parsed_json.as_ref().unwrap()); } ContractParseStatus::Error(msg) => { - ui.colored_label(Color32::RED, format!("Error: {msg}")); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = msg.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("Error: {msg}")).color(error_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.parse_status = ContractParseStatus::NotStarted; + } + }); + }); } ContractParseStatus::NotStarted => { ui.colored_label(Color32::GRAY, "Awaiting input …"); diff --git a/src/ui/tools/document_visualizer_screen.rs b/src/ui/tools/document_visualizer_screen.rs index ffceccc8c..00fd2aebc 100644 --- a/src/ui/tools/document_visualizer_screen.rs +++ b/src/ui/tools/document_visualizer_screen.rs @@ -11,7 +11,7 @@ use crate::ui::helpers::add_contract_doc_type_chooser_with_filtering; use base64::{Engine, engine::general_purpose::STANDARD}; use dash_sdk::dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; use dash_sdk::dpp::{data_contract::document_type::DocumentType, document::Document}; -use eframe::egui::{self, Color32, Context, TextEdit, Ui}; +use eframe::egui::{self, Color32, Context, Frame, Margin, RichText, TextEdit, Ui}; use std::sync::Arc; // ======================= 1. Data & helpers ======================= @@ -166,7 +166,22 @@ impl DocumentVisualizerScreen { ui.colored_label(Color32::GRAY, "Select a contract and document type."); } DocumentParseStatus::Error(msg) => { - ui.colored_label(Color32::RED, format!("Error: {msg}")); + let error_color = Color32::from_rgb(255, 100, 100); + let msg = msg.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("Error: {msg}")).color(error_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.parse_status = DocumentParseStatus::NotStarted; + } + }); + }); } DocumentParseStatus::NotStarted => { ui.colored_label(Color32::GRAY, "Awaiting input …"); diff --git a/src/ui/tools/grovestark_screen.rs b/src/ui/tools/grovestark_screen.rs index e6f95532d..5ad4c70fa 100644 --- a/src/ui/tools/grovestark_screen.rs +++ b/src/ui/tools/grovestark_screen.rs @@ -806,7 +806,22 @@ impl GroveSTARKScreen { // Error Display if let Some(error) = &self.gen_error_message { - ui.colored_label(egui::Color32::RED, format!("Error: {}", error)); + let error_color = egui::Color32::from_rgb(255, 100, 100); + let error = error.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("Error: {}", error)).color(error_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.gen_error_message = None; + } + }); + }); } // Success Display @@ -871,7 +886,22 @@ impl GroveSTARKScreen { // Error Display (above the button) if let Some(error) = &self.verify_error_message { - ui.colored_label(egui::Color32::RED, format!("Error: {}", error)); + let error_color = egui::Color32::from_rgb(255, 100, 100); + let error = error.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("Error: {}", error)).color(error_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.verify_error_message = None; + } + }); + }); } // Verify Button diff --git a/src/ui/tools/mod.rs b/src/ui/tools/mod.rs index 02e084194..721ea7746 100644 --- a/src/ui/tools/mod.rs +++ b/src/ui/tools/mod.rs @@ -1,3 +1,4 @@ +pub mod address_balance_screen; pub mod contract_visualizer_screen; pub mod document_visualizer_screen; pub mod grovestark_screen; diff --git a/src/ui/tools/platform_info_screen.rs b/src/ui/tools/platform_info_screen.rs index 0ae93bcd6..bf0b30e7f 100644 --- a/src/ui/tools/platform_info_screen.rs +++ b/src/ui/tools/platform_info_screen.rs @@ -9,7 +9,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::version::PlatformVersion; -use eframe::egui::{self, Context, ScrollArea, Ui}; +use eframe::egui::{self, Context, Frame, Margin, RichText, ScrollArea, Ui}; use egui::Color32; use std::sync::Arc; @@ -108,20 +108,17 @@ impl PlatformInfoScreen { action } - fn render_results(&self, ui: &mut Ui) { + fn render_results(&mut self, ui: &mut Ui) { // Check if any task is loading if !self.active_tasks.is_empty() { ui.vertical_centered(|ui| { ui.add_space(50.0); - // Show spinner with theme-aware color - let dark_mode = ui.ctx().style().visuals.dark_mode; - let spinner_color = if dark_mode { - Color32::from_gray(200) - } else { - Color32::from_gray(60) - }; - ui.add(egui::widgets::Spinner::default().color(spinner_color)); + // Show spinner with Dash blue color + ui.add( + egui::widgets::Spinner::default() + .color(crate::ui::theme::DashColors::DASH_BLUE), + ); ui.add_space(10.0); ui.heading("Loading..."); @@ -132,9 +129,22 @@ impl PlatformInfoScreen { // Check for errors and display them in the results area if let Some(error) = &self.error_message { - ui.heading("Error"); - ui.separator(); - ui.colored_label(Color32::RED, error); + let error_color = Color32::from_rgb(255, 100, 100); + let error = error.clone(); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("Error: {}", error)).color(error_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.error_message = None; + } + }); + }); return; } @@ -311,6 +321,9 @@ impl ScreenLike for PlatformInfoScreen { self.active_tasks.clear(); // Clear any remaining active tasks self.error_message = None; } + PlatformInfoTaskResult::AddressBalance { .. } => { + // This result is handled by AddressBalanceScreen, not here + } } } } diff --git a/src/ui/wallets/account_summary.rs b/src/ui/wallets/account_summary.rs new file mode 100644 index 000000000..27dc134f1 --- /dev/null +++ b/src/ui/wallets/account_summary.rs @@ -0,0 +1,242 @@ +use std::collections::BTreeMap; + +use dash_sdk::dpp::balances::credits::Credits; + +use crate::model::wallet::{DerivationPathHelpers, DerivationPathReference, Wallet}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AccountCategory { + Bip44, + Bip32, + CoinJoin, + IdentityRegistration, + IdentitySystem, + IdentityTopup, + IdentityInvitation, + ProviderVoting, + ProviderOwner, + ProviderOperator, + ProviderPlatform, + /// DIP-17: Platform Payment Addresses (dashevo/tdashevo Bech32m prefix per DIP-18) + PlatformPayment, + Other(DerivationPathReference), +} + +impl AccountCategory { + pub fn from_reference(reference: DerivationPathReference) -> Self { + match reference { + DerivationPathReference::BIP44 => AccountCategory::Bip44, + DerivationPathReference::BIP32 => AccountCategory::Bip32, + DerivationPathReference::BlockchainIdentities => AccountCategory::IdentitySystem, + DerivationPathReference::BlockchainIdentityCreditRegistrationFunding => { + AccountCategory::IdentityRegistration + } + DerivationPathReference::BlockchainIdentityCreditInvitationFunding => { + AccountCategory::IdentityInvitation + } + DerivationPathReference::BlockchainIdentityCreditTopupFunding => { + AccountCategory::IdentityTopup + } + DerivationPathReference::ProviderVotingKeys => AccountCategory::ProviderVoting, + DerivationPathReference::ProviderOwnerKeys => AccountCategory::ProviderOwner, + DerivationPathReference::ProviderOperatorKeys => AccountCategory::ProviderOperator, + DerivationPathReference::ProviderPlatformNodeKeys => AccountCategory::ProviderPlatform, + DerivationPathReference::ProviderFunds | DerivationPathReference::CoinJoin => { + AccountCategory::CoinJoin + } + DerivationPathReference::PlatformPayment => AccountCategory::PlatformPayment, + _ => AccountCategory::Other(reference), + } + } + + pub fn label(&self, index: Option) -> String { + match self { + AccountCategory::Bip44 => match index.unwrap_or(0) { + 0 => "Main Account".to_string(), + idx => format!("BIP44 Account #{}", idx), + }, + AccountCategory::Bip32 => format!("BIP32 Account {:?}", index.unwrap_or(0)), + AccountCategory::CoinJoin => "CoinJoin".to_string(), + AccountCategory::IdentityRegistration => "Identity Registration".to_string(), + AccountCategory::IdentitySystem => "Identity System".to_string(), + AccountCategory::IdentityTopup => "Identity Top-up".to_string(), + AccountCategory::IdentityInvitation => "Identity Invitation".to_string(), + AccountCategory::ProviderVoting => "Provider Voting".to_string(), + AccountCategory::ProviderOwner => "Provider Owner".to_string(), + AccountCategory::ProviderOperator => "Provider Operator".to_string(), + AccountCategory::ProviderPlatform => "Provider Platform".to_string(), + AccountCategory::PlatformPayment => "Platform Account".to_string(), + AccountCategory::Other(reference) => format!("{:?}", reference), + } + } + + fn sort_key(&self) -> u8 { + match self { + AccountCategory::Bip44 => 0, + AccountCategory::PlatformPayment => 1, + AccountCategory::Bip32 => 2, + AccountCategory::CoinJoin => 3, + AccountCategory::IdentityRegistration => 4, + AccountCategory::IdentitySystem => 5, + AccountCategory::IdentityTopup => 6, + AccountCategory::IdentityInvitation => 7, + AccountCategory::ProviderOwner => 8, + AccountCategory::ProviderVoting => 9, + AccountCategory::ProviderOperator => 10, + AccountCategory::ProviderPlatform => 11, + AccountCategory::Other(_) => 12, + } + } + + pub fn description(&self) -> Option<&'static str> { + match self { + AccountCategory::Bip44 => { + Some("Standard BIP44 account (m/44'/5'/… ) used for normal wallet funds.") + } + AccountCategory::Bip32 => { + Some("Legacy BIP32 branch reserved for custom derivations or advanced tools.") + } + AccountCategory::CoinJoin => { + Some("CoinJoin mixing account. Funds here are earmarked for privacy transactions.") + } + AccountCategory::IdentityRegistration => Some( + "Credit funding addresses used to register new identities (DIP‑9). Each identity consumes one hardened address here.", + ), + AccountCategory::IdentitySystem => Some( + "Identity authentication/system addresses. They back the identity keys stored on Platform and usually hold zero balance.", + ), + AccountCategory::IdentityTopup => Some( + "Credit funding addresses used when topping up an existing identity's balance.", + ), + AccountCategory::IdentityInvitation => Some( + "Invitation credit funding addresses. Use these when sponsoring a new identity.", + ), + AccountCategory::ProviderVoting => Some( + "Voting key branch for masternodes (Dash Platform / Core DIP‑3 voting key outputs).", + ), + AccountCategory::ProviderOwner => { + Some("Masternode owner key branch (collateral ownership outputs).") + } + AccountCategory::ProviderOperator => { + Some("Operator key branch for masternode BLS operator keys.") + } + AccountCategory::ProviderPlatform => { + Some("Platform service key branch used by masternode platform nodes.") + } + AccountCategory::PlatformPayment => Some( + "DIP-17 Platform payment addresses (dashevo/tdashevo prefix). Hold Dash Credits on Platform, independent of identities.", + ), + AccountCategory::Other(_) => None, + } + } + + /// Returns true if this account category is for key derivation/proofs only + /// and does not hold funds (balance is always N/A). + pub fn is_key_only(&self) -> bool { + matches!( + self, + AccountCategory::IdentityRegistration + | AccountCategory::IdentityTopup + | AccountCategory::IdentityInvitation + | AccountCategory::IdentitySystem + | AccountCategory::ProviderVoting + | AccountCategory::ProviderOwner + | AccountCategory::ProviderOperator + | AccountCategory::ProviderPlatform + ) + } +} + +#[derive(Clone, Debug)] +pub struct AccountSummary { + pub category: AccountCategory, + pub label: String, + pub index: Option, + pub confirmed_balance: u64, + /// Platform credits balance for Platform Payment addresses + pub platform_credits: Credits, +} + +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd)] +struct AccountKey { + category: AccountCategory, + index: Option, +} + +struct AccountSummaryBuilder { + key: AccountKey, + confirmed_balance: u64, + platform_credits: Credits, +} + +impl AccountSummaryBuilder { + fn new(category: AccountCategory, index: Option) -> Self { + Self { + key: AccountKey { category, index }, + confirmed_balance: 0, + platform_credits: 0, + } + } + + fn add_address(&mut self, balance: u64, platform_credits: Credits) { + self.confirmed_balance += balance; + self.platform_credits += platform_credits; + } + + fn build(self) -> AccountSummary { + let label = self.key.category.label(self.key.index); + + AccountSummary { + category: self.key.category, + label, + index: self.key.index, + confirmed_balance: self.confirmed_balance, + platform_credits: self.platform_credits, + } + } +} + +pub fn collect_account_summaries(wallet: &Wallet) -> Vec { + let mut builders: BTreeMap = BTreeMap::new(); + + for (path, info) in &wallet.watched_addresses { + let category = AccountCategory::from_reference(info.path_reference); + let index = match category { + AccountCategory::Bip44 | AccountCategory::Bip32 => path.bip44_account_index(), + _ => None, + }; + + let balance = wallet + .address_balances + .get(&info.address) + .cloned() + .unwrap_or_default(); + + // Get Platform credits balance for Platform Payment addresses + // Use canonical lookup to handle potential Address key mismatches + let platform_credits = wallet + .get_platform_address_info(&info.address) + .map(|info| info.balance) + .unwrap_or_default(); + + builders + .entry(AccountKey { + category: category.clone(), + index, + }) + .or_insert_with(|| AccountSummaryBuilder::new(category, index)) + .add_address(balance, platform_credits); + } + + let mut summaries: Vec<_> = builders + .into_values() + .map(|builder| builder.build()) + .collect(); + + summaries.sort_by(|a, b| { + (a.category.sort_key(), a.index.unwrap_or(0)) + .cmp(&(b.category.sort_key(), b.index.unwrap_or(0))) + }); + + summaries +} diff --git a/src/ui/wallets/add_new_wallet_screen.rs b/src/ui/wallets/add_new_wallet_screen.rs index 0b5aa99f7..661a8473a 100644 --- a/src/ui/wallets/add_new_wallet_screen.rs +++ b/src/ui/wallets/add_new_wallet_screen.rs @@ -1,22 +1,27 @@ use crate::app::AppAction; use crate::context::AppContext; -use crate::ui::ScreenLike; +use crate::model::wallet::encryption::{DASH_SECRET_MESSAGE, encrypt_message}; +use crate::model::wallet::{ + AddressInfo as WalletAddressInfo, ClosedKeyItem, DerivationPathReference, DerivationPathType, + OpenWalletSeed, Wallet, WalletSeed, +}; +use crate::ui::components::entropy_grid::U256EntropyGrid; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::theme::DashColors; -use eframe::egui::Context; - -use crate::model::wallet::encryption::{DASH_SECRET_MESSAGE, encrypt_message}; -use crate::model::wallet::{ClosedKeyItem, OpenWalletSeed, Wallet, WalletSeed}; -use crate::ui::components::entropy_grid::U256EntropyGrid; +use crate::ui::identities::add_new_identity_screen::AddNewIdentityScreen; +use crate::ui::identities::funding_common::generate_qr_code_image; +use crate::ui::{RootScreenType, Screen, ScreenLike}; use bip39::{Language, Mnemonic}; use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; +use dash_sdk::dpp::dashcore::Address; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; use dash_sdk::dpp::key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey}; +use eframe::egui::{Context, TextureHandle, TextureOptions}; use eframe::emath::Align; -use egui::{Color32, ComboBox, Direction, Frame, Grid, Layout, Margin, RichText, Stroke, Ui, Vec2}; +use egui::load::SizedTexture; +use egui::{Color32, ComboBox, Frame, Grid, Layout, Margin, RichText, Stroke, Ui, Vec2}; use std::sync::atomic::Ordering; use std::sync::{Arc, RwLock}; use zxcvbn::zxcvbn; @@ -45,11 +50,40 @@ pub const DASH_BIP44_ACCOUNT_0_PATH_TESTNET: [ChildNumber; 3] = [ ChildNumber::Hardened { index: 0 }, ]; +/// Word count options for BIP39 mnemonic seed phrases +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WordCount { + Words12 = 12, + Words15 = 15, + Words18 = 18, + Words21 = 21, + Words24 = 24, +} + +impl WordCount { + /// Returns the number of entropy bytes required for this word count + pub fn entropy_bytes(&self) -> usize { + match self { + WordCount::Words12 => 16, // 128 bits + WordCount::Words15 => 20, // 160 bits + WordCount::Words18 => 24, // 192 bits + WordCount::Words21 => 28, // 224 bits + WordCount::Words24 => 32, // 256 bits + } + } + + /// Returns the word count as a number + pub fn count(&self) -> usize { + *self as usize + } +} + pub struct AddNewWalletScreen { seed_phrase: Option, password: String, entropy_grid: U256EntropyGrid, selected_language: Language, + selected_word_count: WordCount, alias_input: String, wrote_it_down: bool, password_strength: f64, @@ -57,6 +91,14 @@ pub struct AddNewWalletScreen { error: Option, pub app_context: Arc, use_password_for_app: bool, + wallet_created: bool, + // Success screen state + created_wallet_seed_hash: Option<[u8; 32]>, + receive_address: Option
      , + receive_address_string: Option, + receive_qr_texture: Option, + show_receive_popup: bool, + funds_received: bool, } impl AddNewWalletScreen { @@ -66,6 +108,7 @@ impl AddNewWalletScreen { password: String::new(), entropy_grid: U256EntropyGrid::new(), selected_language: Language::English, + selected_word_count: WordCount::Words24, // Default to 24 words for maximum security alias_input: String::new(), wrote_it_down: false, password_strength: 0.0, @@ -73,16 +116,25 @@ impl AddNewWalletScreen { error: None, app_context: app_context.clone(), use_password_for_app: true, + wallet_created: false, + created_wallet_seed_hash: None, + receive_address: None, + receive_address_string: None, + receive_qr_texture: None, + show_receive_popup: false, + funds_received: false, } } - /// Generate a new seed phrase based on the selected language + /// Generate a new seed phrase based on the selected language and word count fn generate_seed_phrase(&mut self) { - let mnemonic = Mnemonic::from_entropy_in( - self.selected_language, - &self.entropy_grid.random_number_with_user_input(), - ) - .expect("Failed to generate mnemonic"); + let full_entropy = self.entropy_grid.random_number_with_user_input(); + let entropy_bytes = self.selected_word_count.entropy_bytes(); + + // Use only the required number of bytes for the selected word count + let mnemonic = + Mnemonic::from_entropy_in(self.selected_language, &full_entropy[..entropy_bytes]) + .expect("Failed to generate mnemonic"); self.seed_phrase = Some(mnemonic); } @@ -125,6 +177,69 @@ impl AddNewWalletScreen { // Compute the seed hash let seed_hash = ClosedKeyItem::compute_seed_hash(&seed); + // Generate the first receive address BEFORE creating wallet (no locks needed) + let address_path_extension = DerivationPath::from( + [ + ChildNumber::Normal { index: 0 }, // receive (not change) + ChildNumber::Normal { index: 0 }, // first address + ] + .as_slice(), + ); + let first_address = master_bip44_ecdsa_extended_public_key + .derive_pub(&secp, &address_path_extension) + .ok() + .map(|pk| Address::p2pkh(&pk.to_pub(), self.app_context.network)); + + // Build known_addresses and watched_addresses with the first address + let mut known_addresses = std::collections::BTreeMap::new(); + let mut watched_addresses = std::collections::BTreeMap::new(); + + if let Some(ref address) = first_address { + let full_derivation_path = DerivationPath::from(match self.app_context.network { + Network::Dash => [ + DASH_BIP44_ACCOUNT_0_PATH_MAINNET[0], + DASH_BIP44_ACCOUNT_0_PATH_MAINNET[1], + DASH_BIP44_ACCOUNT_0_PATH_MAINNET[2], + ChildNumber::Normal { index: 0 }, + ChildNumber::Normal { index: 0 }, + ] + .as_slice(), + _ => [ + DASH_BIP44_ACCOUNT_0_PATH_TESTNET[0], + DASH_BIP44_ACCOUNT_0_PATH_TESTNET[1], + DASH_BIP44_ACCOUNT_0_PATH_TESTNET[2], + ChildNumber::Normal { index: 0 }, + ChildNumber::Normal { index: 0 }, + ] + .as_slice(), + }); + known_addresses.insert(address.clone(), full_derivation_path.clone()); + watched_addresses.insert( + full_derivation_path, + WalletAddressInfo { + address: address.clone(), + path_type: DerivationPathType::CLEAR_FUNDS, + path_reference: DerivationPathReference::BIP44, + }, + ); + + self.receive_address_string = Some(address.to_string()); + self.receive_address = Some(address.clone()); + } + + // Generate default wallet name if none provided + let wallet_alias = if self.alias_input.trim().is_empty() { + let existing_wallet_count = self + .app_context + .wallets + .read() + .map(|w| w.len()) + .unwrap_or(0); + format!("Wallet {}", existing_wallet_count + 1) + } else { + self.alias_input.clone() + }; + let wallet = Wallet { wallet_seed: WalletSeed::Open(OpenWalletSeed { seed, @@ -133,19 +248,25 @@ impl AddNewWalletScreen { encrypted_seed, salt, nonce, - password_hint: None, // Set a password hint if needed + password_hint: None, }, }), uses_password, master_bip44_ecdsa_extended_public_key, address_balances: Default::default(), - known_addresses: Default::default(), - watched_addresses: Default::default(), + address_total_received: Default::default(), + known_addresses, + watched_addresses, unused_asset_locks: Default::default(), - alias: Some(self.alias_input.clone()), + alias: Some(wallet_alias), identities: Default::default(), utxos: Default::default(), + transactions: Vec::new(), is_main: true, + confirmed_balance: 0, + unconfirmed_balance: 0, + total_balance: 0, + platform_address_info: Default::default(), }; self.app_context @@ -153,68 +274,363 @@ impl AddNewWalletScreen { .store_wallet(&wallet, &self.app_context.network) .map_err(|e| e.to_string())?; + let new_wallet_seed_hash = wallet.seed_hash(); + let wallet_arc = Arc::new(RwLock::new(wallet)); + // Acquire a write lock and add the new wallet if let Ok(mut wallets) = self.app_context.wallets.write() { - wallets.insert(wallet.seed_hash(), Arc::new(RwLock::new(wallet))); + wallets.insert(new_wallet_seed_hash, wallet_arc.clone()); self.app_context.has_wallet.store(true, Ordering::Relaxed); } else { eprintln!("Failed to acquire write lock on wallets"); } - Ok(AppAction::GoToMainScreen) // Navigate back to the main screen after saving + // Set pending wallet selection so the wallet screen auto-selects this wallet + if let Ok(mut pending) = self.app_context.pending_wallet_selection.lock() { + *pending = Some(new_wallet_seed_hash); + } + + // Save the first address to database + if let Some(ref address) = first_address { + let full_derivation_path = DerivationPath::from(match self.app_context.network { + Network::Dash => [ + DASH_BIP44_ACCOUNT_0_PATH_MAINNET[0], + DASH_BIP44_ACCOUNT_0_PATH_MAINNET[1], + DASH_BIP44_ACCOUNT_0_PATH_MAINNET[2], + ChildNumber::Normal { index: 0 }, + ChildNumber::Normal { index: 0 }, + ] + .as_slice(), + _ => [ + DASH_BIP44_ACCOUNT_0_PATH_TESTNET[0], + DASH_BIP44_ACCOUNT_0_PATH_TESTNET[1], + DASH_BIP44_ACCOUNT_0_PATH_TESTNET[2], + ChildNumber::Normal { index: 0 }, + ChildNumber::Normal { index: 0 }, + ] + .as_slice(), + }); + let _ = self.app_context.db.add_address_if_not_exists( + &new_wallet_seed_hash, + address, + &self.app_context.network, + &full_derivation_path, + DerivationPathReference::BIP44, + DerivationPathType::CLEAR_FUNDS, + None, + ); + } + + // Load SPV wallet in background + if self.app_context.core_backend_mode() == crate::spv::CoreBackendMode::Spv { + self.app_context.handle_wallet_unlocked(&wallet_arc); + } + + self.created_wallet_seed_hash = Some(new_wallet_seed_hash); + self.wallet_created = true; + Ok(AppAction::None) // Show success screen instead of navigating away } else { Ok(AppAction::None) // No action if no seed phrase exists } } - fn render_seed_phrase_input(&mut self, ui: &mut Ui) { - ui.add_space(15.0); // Add spacing from the top + fn show_success(&mut self, ui: &mut Ui, ctx: &Context) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Check for incoming funds by looking at wallet balance + // Use total_balance_duffs() which falls back to max_balance() (from UTXOs) if SPV balance not set + if !self.funds_received { + if let Some(seed_hash) = &self.created_wallet_seed_hash + && let Ok(wallets) = self.app_context.wallets.read() + && let Some(wallet) = wallets.get(seed_hash) + && let Ok(wallet_guard) = wallet.read() + && wallet_guard.total_balance_duffs() > 0 + { + self.funds_received = true; + // Auto-close the popup when funds are received + self.show_receive_popup = false; + } + + // Request periodic repaint while waiting for funds + ui.ctx() + .request_repaint_after(std::time::Duration::from_secs(1)); + } + ui.vertical_centered(|ui| { - // Center the language selector and generate button - ui.horizontal(|ui| { - ui.label("Language:"); - - ComboBox::from_label("") - .selected_text(format!("{:?}", self.selected_language)) - .width(150.0) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut self.selected_language, - Language::English, - "English", - ); - ui.selectable_value( - &mut self.selected_language, - Language::Spanish, - "Spanish", + ui.add_space(50.0); + ui.heading("🎉"); + if self.funds_received { + ui.heading("Funds Received!"); + } else { + ui.heading("Wallet Created Successfully!"); + } + + ui.add_space(30.0); + + // Recommended Next Steps section + let description_width = 500.0_f32.min(ui.available_width() - 40.0); + ui.allocate_ui_with_layout( + Vec2::new(description_width, 0.0), + Layout::top_down(Align::Center), + |ui| { + ui.label( + RichText::new("Recommended Next Steps:") + .size(16.0) + .strong() + .color(crate::ui::theme::DashColors::text_primary(dark_mode)), + ); + ui.add_space(12.0); + + // Step 1: Fund wallet + ui.horizontal(|ui| { + let step_color = if self.funds_received { + crate::ui::theme::DashColors::success_color(dark_mode) + } else { + crate::ui::theme::DashColors::text_secondary(dark_mode) + }; + ui.label( + RichText::new("1.") + .size(14.0) + .strong() + .color(step_color), ); - ui.selectable_value( - &mut self.selected_language, - Language::French, - "French", + let step_text = if self.funds_received { + "Fund your wallet with Dash (Done)" + } else { + "Fund your wallet with Dash" + }; + ui.label( + RichText::new(step_text) + .size(14.0) + .color(step_color), ); - ui.selectable_value( - &mut self.selected_language, - Language::Italian, - "Italian", + }); + ui.add_space(4.0); + + // Step 2: Create identity + ui.horizontal(|ui| { + ui.label( + RichText::new("2.") + .size(14.0) + .strong() + .color(crate::ui::theme::DashColors::text_secondary(dark_mode)), ); - ui.selectable_value( - &mut self.selected_language, - Language::Portuguese, - "Portuguese", + ui.label( + RichText::new("Create a Platform Identity to register a username and interact with apps") + .size(14.0) + .color(crate::ui::theme::DashColors::text_secondary(dark_mode)), ); }); + }, + ); + + ui.add_space(20.0); + + // Buttons + if !self.funds_received { + if ui.button("Fund Wallet").clicked() { + self.show_receive_popup = true; + } + ui.add_space(8.0); + } + + if ui.button("Create Platform Identity").clicked() { + action = AppAction::PopThenAddScreenToMainScreen( + RootScreenType::RootScreenIdentities, + Screen::AddNewIdentityScreen(AddNewIdentityScreen::new_with_wallet( + &self.app_context, + self.created_wallet_seed_hash, + )), + ); + } + + ui.add_space(8.0); + + if ui.button("Go To Wallet Screen").clicked() { + action = AppAction::GoToMainScreen; + } + + ui.add_space(40.0); + }); + + // Render receive popup + action |= self.render_receive_popup(ctx); + + action + } + + fn render_receive_popup(&mut self, ctx: &Context) -> AppAction { + if !self.show_receive_popup { + return AppAction::None; + } + + // Draw dark overlay behind the dialog + let screen_rect = ctx.screen_rect(); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("receive_funds_overlay"), + )); + painter.rect_filled( + screen_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), + ); + + // Generate QR code if needed + let mut qr_error: Option = None; + if let Some(address) = &self.receive_address_string + && self.receive_qr_texture.is_none() + { + match generate_qr_code_image(address) { + Ok(image) => { + let texture = ctx.load_texture( + format!("wallet_receive_{}", address), + image, + TextureOptions::LINEAR, + ); + self.receive_qr_texture = Some(texture); + } + Err(e) => { + qr_error = Some(format!("QR error: {:?}", e)); + } + } + } + + let mut open = self.show_receive_popup; + egui::Window::new("Fund Wallet") + .collapsible(false) + .resizable(false) + .open(&mut open) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .show(ctx, |ui| { + ui.vertical_centered(|ui| { + if let Some(texture) = &self.receive_qr_texture { + ui.image(SizedTexture::new(texture.id(), egui::vec2(220.0, 220.0))); + } else if let Some(err) = &qr_error { + ui.label(err); + } else if self.receive_address_string.is_none() { + ui.label("No receive address available"); + } else { + ui.label("Generating QR code..."); + } + + ui.add_space(8.0); + + if let Some(address) = &self.receive_address_string { + ui.label(address); + ui.add_space(4.0); + if ui.button("Copy Address").clicked() + && let Err(err) = crate::ui::helpers::copy_text_to_clipboard(address) + { + tracing::warn!("Failed to copy address: {}", err); + } + } - ui.add_space(20.0); + ui.add_space(8.0); + + ui.label("Waiting for funds..."); + }); + }); + + self.show_receive_popup = open; + AppAction::None + } + + fn render_seed_phrase_input(&mut self, ui: &mut Ui) { + ui.add_space(15.0); // Add spacing from the top + ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { + ui.add_space(-6.0); + // Language and word count selectors with generate button + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.add_space(7.0); + ui.label("Language:"); + }); + + ui.vertical(|ui| { + ComboBox::from_id_salt("language_selector") + .selected_text(format!("{:?}", self.selected_language)) + .width(120.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.selected_language, + Language::English, + "English", + ); + ui.selectable_value( + &mut self.selected_language, + Language::Spanish, + "Spanish", + ); + ui.selectable_value( + &mut self.selected_language, + Language::French, + "French", + ); + ui.selectable_value( + &mut self.selected_language, + Language::Italian, + "Italian", + ); + ui.selectable_value( + &mut self.selected_language, + Language::Portuguese, + "Portuguese", + ); + }); + }); + + ui.add_space(10.0); + + ui.vertical(|ui| { + ui.add_space(7.0); + ui.label("Word Count:"); + }); + + ui.vertical(|ui| { + ComboBox::from_id_salt("word_count_selector") + .selected_text(format!("{} words", self.selected_word_count.count())) + .width(100.0) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.selected_word_count, + WordCount::Words12, + "12 words", + ); + ui.selectable_value( + &mut self.selected_word_count, + WordCount::Words15, + "15 words", + ); + ui.selectable_value( + &mut self.selected_word_count, + WordCount::Words18, + "18 words", + ); + ui.selectable_value( + &mut self.selected_word_count, + WordCount::Words21, + "21 words", + ); + ui.selectable_value( + &mut self.selected_word_count, + WordCount::Words24, + "24 words", + ); + }); + }); + + ui.add_space(10.0); let generate_button = egui::Button::new( RichText::new("Generate") .strong() - .size(18.0) + .size(12.0) .color(Color32::WHITE), ) - .min_size(Vec2::new(120.0, 35.0)) - .fill(Color32::from_rgb(0, 128, 255)) // Blue background like other buttons + .min_size(Vec2::new(100.0, 20.0)) + .fill(Color32::from_rgb(0, 128, 255)) // Blue background .corner_radius(5.0); if ui.add(generate_button).clicked() { @@ -222,29 +638,34 @@ impl AddNewWalletScreen { } }); - ui.add_space(10.0); + // Only show the seed phrase box after generation + if let Some(mnemonic) = &self.seed_phrase { + ui.add_space(10.0); + + // Calculate grid dimensions based on word count + let word_count = mnemonic.word_count(); + let columns = if word_count <= 12 { 3 } else { 4 }; + let rows = word_count.div_ceil(columns); // Ceiling division + + // Create a container with a fixed width (limited to 600px max to prevent overflow) + let available_width = ui.available_width(); + let frame_width = (available_width * 0.65).min(600.0); + let frame_height = (rows as f32 * 40.0).max(120.0); // Dynamic height based on rows + + ui.allocate_ui_with_layout( + Vec2::new(frame_width, frame_height + 20.0), // Set width and height of the container + egui::Layout::top_down(egui::Align::Center), + |ui| { + Frame::new() + .fill(Color32::WHITE) + .stroke(Stroke::new(1.0, Color32::BLACK)) + .corner_radius(5.0) + .inner_margin(Margin::same(10)) + .show(ui, |ui| { + // Calculate the size of each grid cell with padding + let column_width = (frame_width - 20.0) / columns as f32; // Account for inner margin + let row_height = frame_height / rows as f32; - // Create a container with a fixed width (limited to 600px max to prevent overflow) - let available_width = ui.available_width(); - let frame_width = (available_width * 0.65).min(600.0); - ui.allocate_ui_with_layout( - Vec2::new(frame_width, 260.0), // Set width and height of the container - egui::Layout::top_down(egui::Align::Center), - |ui| { - Frame::new() - .fill(Color32::WHITE) - .stroke(Stroke::new(1.0, Color32::BLACK)) - .corner_radius(5.0) - .inner_margin(Margin::same(10)) - .show(ui, |ui| { - let columns = 4; // Reduced from 6 to 4 for better fit - let rows = 24 / columns; - - // Calculate the size of each grid cell with padding - let column_width = (frame_width - 20.0) / columns as f32; // Account for inner margin - let row_height = 240.0 / rows as f32; // Reduced height for padding - - if let Some(mnemonic) = &self.seed_phrase { Grid::new("seed_phrase_grid") .num_columns(columns) .spacing((0.0, 0.0)) @@ -253,7 +674,7 @@ impl AddNewWalletScreen { .show(ui, |ui| { for (i, word) in mnemonic.words().enumerate() { let number_text = RichText::new(format!("{} ", i + 1)) - .size(row_height * 0.2) + .size(row_height * 0.3) .color(Color32::GRAY); let word_text = RichText::new(word) @@ -273,19 +694,10 @@ impl AddNewWalletScreen { } } }); - } else { - let word_text = RichText::new("Seed Phrase").size(40.0).monospace(); - - ui.with_layout( - Layout::centered_and_justified(Direction::LeftToRight), - |ui| { - ui.label(word_text); - }, - ); - } - }); - }, - ); + }); + }, + ); + } }); } } @@ -310,26 +722,39 @@ impl ScreenLike for AddNewWalletScreen { action |= island_central_panel(ctx, |ui| { let mut inner_action = AppAction::None; + let ctx = ui.ctx().clone(); + + // Show success screen if wallet was created + if self.wallet_created { + inner_action = self.show_success(ui, &ctx); + return inner_action; + } // Add the scroll area to make the content scrollable both vertically and horizontally egui::ScrollArea::both() .auto_shrink([false; 2]) // Prevent shrinking when content is less than the available area .show(ui, |ui| { ui.add_space(10.0); - ui.heading("Follow these steps to create your wallet!"); + ui.heading("Follow these steps to create your wallet."); + ui.add_space(10.0); + ui.separator(); ui.add_space(5.0); self.entropy_grid.ui(ui); + ui.add_space(10.0); + ui.separator(); ui.add_space(5.0); - ui.heading("2. Select your desired seed phrase language and press \"Generate\"."); + ui.heading("2. Select your desired seed phrase language and word count and press \"Generate\"."); self.render_seed_phrase_input(ui); if self.seed_phrase.is_none() { return; } + ui.add_space(10.0); + ui.separator(); ui.add_space(10.0); ui.heading( @@ -347,9 +772,11 @@ impl ScreenLike for AddNewWalletScreen { return; } - ui.add_space(20.0); + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); - ui.heading("4. Select a wallet name to remember it. (This will not go to the blockchain)"); + ui.heading("4. Enter a wallet name to remember it by. (This will not go on the blockchain)"); ui.add_space(8.0); @@ -358,7 +785,9 @@ impl ScreenLike for AddNewWalletScreen { ui.text_edit_singleline(&mut self.alias_input); }); - ui.add_space(20.0); + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); ui.heading("5. Add a password that must be used to unlock the wallet. (Optional but recommended)"); @@ -396,7 +825,7 @@ impl ScreenLike for AddNewWalletScreen { let strength_percentage = (self.password_strength / 100.0).min(1.0); let fill_color = match self.password_strength as i32 { 0..=25 => Color32::from_rgb(255, 182, 193), // Light pink - 26..=50 => Color32::from_rgb(255, 224, 130), // Light yellow + 26..=50 => Color32::from_rgb(255, 224, 130), // Light yellow 51..=75 => Color32::from_rgb(144, 238, 144), // Light green _ => Color32::from_rgb(90, 200, 90), // Medium green }; @@ -421,41 +850,34 @@ impl ScreenLike for AddNewWalletScreen { self.estimated_time_to_crack )); - // if self.app_context.password_info.is_none() { - // ui.add_space(10.0); - // ui.checkbox(&mut self.use_password_for_app, "Use password for Dash Evo Tool loose keys (recommended)"); - // } - - ui.add_space(20.0); + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); ui.heading("6. Save the wallet."); - ui.add_space(5.0); - - // Centered "Save Wallet" button at the bottom - ui.with_layout(Layout::centered_and_justified(Direction::TopDown), |ui| { - let save_button = egui::Button::new( - RichText::new("Save Wallet").strong().size(30.0).color(DashColors::text_primary(ui.ctx().style().visuals.dark_mode)), - ) - .min_size(Vec2::new(300.0, 60.0)) - .corner_radius(10.0) - .stroke(Stroke::new(1.5, Color32::WHITE)) - .sense(if self.wrote_it_down && self.seed_phrase.is_some() { - egui::Sense::click() - } else { - egui::Sense::hover() - }); + ui.add_space(10.0); - if ui.add(save_button).clicked() { - match self.save_wallet() { - Ok(save_wallet_action) => { - inner_action = save_wallet_action; - } - Err(e) => { - self.error = Some(e) - } + // Save Wallet button styled like Load Identity button + let mut new_style = (**ui.style()).clone(); + new_style.spacing.button_padding = egui::vec2(10.0, 5.0); + ui.set_style(new_style); + let save_button = egui::Button::new( + RichText::new("Save Wallet").color(Color32::WHITE), + ) + .fill(Color32::from_rgb(0, 128, 255)) + .frame(true) + .corner_radius(3.0); + + if ui.add(save_button).clicked() { + match self.save_wallet() { + Ok(save_wallet_action) => { + inner_action = save_wallet_action; + } + Err(e) => { + self.error = Some(e) } } - }); + } }); inner_action diff --git a/src/ui/wallets/import_mnemonic_screen.rs b/src/ui/wallets/import_mnemonic_screen.rs new file mode 100644 index 000000000..cc1b8e133 --- /dev/null +++ b/src/ui/wallets/import_mnemonic_screen.rs @@ -0,0 +1,761 @@ +use crate::app::AppAction; +use crate::context::AppContext; +use crate::model::wallet::single_key::SingleKeyWallet; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::identities::add_existing_identity_screen::AddExistingIdentityScreen; +use crate::ui::identities::add_new_identity_screen::AddNewIdentityScreen; +use crate::ui::{RootScreenType, Screen, ScreenLike}; +use eframe::egui::Context; + +use crate::model::wallet::encryption::{DASH_SECRET_MESSAGE, encrypt_message}; +use crate::model::wallet::{ClosedKeyItem, OpenWalletSeed, Wallet, WalletSeed}; +use crate::ui::wallets::add_new_wallet_screen::{ + DASH_BIP44_ACCOUNT_0_PATH_MAINNET, DASH_BIP44_ACCOUNT_0_PATH_TESTNET, +}; +use bip39::Mnemonic; +use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::dpp::key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey}; +use egui::{Color32, ComboBox, Grid, RichText, Ui, Vec2}; +use std::sync::atomic::Ordering; +use std::sync::{Arc, RwLock}; +use zxcvbn::zxcvbn; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImportType { + Mnemonic, + PrivateKey, +} + +pub struct ImportMnemonicScreen { + // Common fields + import_type: ImportType, + password: String, + alias_input: String, + password_strength: f64, + estimated_time_to_crack: String, + error: Option, + pub app_context: Arc, + use_password_for_app: bool, + wallet_imported: bool, + show_advanced_options: bool, + + // Mnemonic-specific fields + seed_phrase_words: Vec, + selected_seed_phrase_length: usize, + seed_phrase: Option, + + // Private key-specific fields + private_key_input: String, + parsed_single_key_wallet: Option, + + // Identity discovery options + identity_scan_count: u32, +} + +impl ImportMnemonicScreen { + pub fn new(app_context: &Arc) -> Self { + Self { + // Common fields + import_type: ImportType::Mnemonic, + password: String::new(), + alias_input: String::new(), + password_strength: 0.0, + estimated_time_to_crack: String::new(), + error: None, + app_context: app_context.clone(), + use_password_for_app: true, + wallet_imported: false, + show_advanced_options: false, + + // Mnemonic-specific fields + seed_phrase_words: vec!["".to_string(); 24], + selected_seed_phrase_length: 12, + seed_phrase: None, + + // Private key-specific fields + private_key_input: String::new(), + parsed_single_key_wallet: None, + + // Identity discovery options + identity_scan_count: 5, + } + } + + fn try_parse_private_key(&mut self) { + let input = self.private_key_input.trim(); + if input.is_empty() { + self.parsed_single_key_wallet = None; + self.error = None; + return; + } + + // Try to parse as WIF first, then as hex + let result = SingleKeyWallet::from_wif(input, None, None) + .or_else(|_| SingleKeyWallet::from_hex(input, self.app_context.network, None, None)); + + match result { + Ok(wallet) => { + self.parsed_single_key_wallet = Some(wallet); + self.error = None; + } + Err(e) => { + self.parsed_single_key_wallet = None; + self.error = Some(format!("Invalid private key: {}", e)); + } + } + } + + fn save_private_key_wallet(&mut self) -> Result { + let input = self.private_key_input.trim(); + if input.is_empty() { + return Err("Please enter a private key".to_string()); + } + + // Parse the key with password and alias + let password = if self.password.is_empty() { + None + } else { + Some(self.password.as_str()) + }; + + // Generate default wallet name if none provided + let alias = if self.alias_input.trim().is_empty() { + let existing_wallet_count = self + .app_context + .single_key_wallets + .read() + .map(|w| w.len()) + .unwrap_or(0); + Some(format!("Key {}", existing_wallet_count + 1)) + } else { + Some(self.alias_input.clone()) + }; + + // Try WIF first, then hex + let wallet = SingleKeyWallet::from_wif(input, password, alias.clone()).or_else(|_| { + SingleKeyWallet::from_hex(input, self.app_context.network, password, alias) + })?; + + let key_hash = wallet.key_hash(); + + // Store in database + self.app_context + .db + .store_single_key_wallet(&wallet, self.app_context.network) + .map_err(|e| { + if e.to_string().contains("UNIQUE constraint failed") { + "This key has already been imported.".to_string() + } else { + e.to_string() + } + })?; + + // Add to app context + let wallet_arc = Arc::new(RwLock::new(wallet)); + if let Ok(mut single_key_wallets) = self.app_context.single_key_wallets.write() { + single_key_wallets.insert(key_hash, wallet_arc); + self.app_context.has_wallet.store(true, Ordering::Relaxed); + } + + self.wallet_imported = true; + Ok(AppAction::None) + } + fn save_wallet(&mut self) -> Result { + if let Some(mnemonic) = &self.seed_phrase { + let seed = mnemonic.to_seed(""); + + let (encrypted_seed, salt, nonce, uses_password) = if self.password.is_empty() { + (seed.to_vec(), vec![], vec![], false) + } else { + // Encrypt the seed to obtain encrypted_seed, salt, and nonce + let (encrypted_seed, salt, nonce) = + ClosedKeyItem::encrypt_seed(&seed, self.password.as_str())?; + if self.use_password_for_app { + let (encrypted_message, salt, nonce) = + encrypt_message(DASH_SECRET_MESSAGE, self.password.as_str())?; + self.app_context + .update_main_password(&salt, &nonce, &encrypted_message) + .map_err(|e| e.to_string())?; + } + (encrypted_seed, salt, nonce, true) + }; + + // Generate master ECDSA extended private key + let master_ecdsa_extended_private_key = + ExtendedPrivKey::new_master(self.app_context.network, &seed) + .expect("Failed to create master ECDSA extended private key"); + let bip44_root_derivation_path: DerivationPath = match self.app_context.network { + Network::Dash => DerivationPath::from(DASH_BIP44_ACCOUNT_0_PATH_MAINNET.as_slice()), + _ => DerivationPath::from(DASH_BIP44_ACCOUNT_0_PATH_TESTNET.as_slice()), + }; + let secp = Secp256k1::new(); + let master_bip44_ecdsa_extended_public_key = master_ecdsa_extended_private_key + .derive_priv(&secp, &bip44_root_derivation_path) + .map_err(|e| e.to_string())?; + + let master_bip44_ecdsa_extended_public_key = + ExtendedPubKey::from_priv(&secp, &master_bip44_ecdsa_extended_public_key); + + // Compute the seed hash + let seed_hash = ClosedKeyItem::compute_seed_hash(&seed); + + // Generate default wallet name if none provided + let wallet_alias = if self.alias_input.trim().is_empty() { + let existing_wallet_count = self + .app_context + .wallets + .read() + .map(|w| w.len()) + .unwrap_or(0); + format!("Wallet {}", existing_wallet_count + 1) + } else { + self.alias_input.clone() + }; + + let wallet = Wallet { + wallet_seed: WalletSeed::Open(OpenWalletSeed { + seed, + wallet_info: ClosedKeyItem { + seed_hash, + encrypted_seed, + salt, + nonce, + password_hint: None, // Set a password hint if needed + }, + }), + uses_password, + master_bip44_ecdsa_extended_public_key, + address_balances: Default::default(), + address_total_received: Default::default(), + known_addresses: Default::default(), + watched_addresses: Default::default(), + unused_asset_locks: Default::default(), + alias: Some(wallet_alias), + identities: Default::default(), + utxos: Default::default(), + transactions: Vec::new(), + is_main: true, + confirmed_balance: 0, + unconfirmed_balance: 0, + total_balance: 0, + platform_address_info: Default::default(), + }; + + self.app_context + .db + .store_wallet(&wallet, &self.app_context.network) + .map_err(|e| { + if e.to_string().contains("UNIQUE constraint failed: wallet.seed_hash") { + "This wallet has already been imported for another network. Each wallet can only be imported once per network. If you want to use this wallet on a different network, please switch networks first.".to_string() + } else { + e.to_string() + } + })?; + + let wallet_arc = Arc::new(RwLock::new(wallet)); + let new_wallet_seed_hash = wallet_arc.read().unwrap().seed_hash(); + + // Acquire a write lock and add the new wallet + if let Ok(mut wallets) = self.app_context.wallets.write() { + wallets.insert(new_wallet_seed_hash, wallet_arc.clone()); + self.app_context.has_wallet.store(true, Ordering::Relaxed); + } else { + eprintln!("Failed to acquire write lock on wallets"); + } + + // Set pending wallet selection so the wallet screen auto-selects this wallet + if let Ok(mut pending) = self.app_context.pending_wallet_selection.lock() { + *pending = Some(new_wallet_seed_hash); + } + + self.app_context.bootstrap_wallet_addresses(&wallet_arc); + if self.app_context.core_backend_mode() == crate::spv::CoreBackendMode::Spv { + self.app_context.handle_wallet_unlocked(&wallet_arc); + } + + // Auto-discover identities derived from this wallet + if self.identity_scan_count > 0 { + self.app_context + .queue_wallet_identity_discovery(&wallet_arc, self.identity_scan_count - 1); + } + + self.wallet_imported = true; + Ok(AppAction::None) // Show success screen instead of navigating away + } else { + Ok(AppAction::None) // No action if no seed phrase exists + } + } + + fn show_success(&mut self, ui: &mut Ui) -> AppAction { + let title = match self.import_type { + ImportType::Mnemonic => "Wallet Imported Successfully!", + ImportType::PrivateKey => "Key Imported Successfully!", + }; + + let mut buttons = vec![("Go to Wallet Screen".to_string(), AppAction::GoToMainScreen)]; + + // Only show identity options for HD wallets (mnemonic import) + if self.import_type == ImportType::Mnemonic { + buttons.push(( + "Create Identity".to_string(), + AppAction::PopThenAddScreenToMainScreen( + RootScreenType::RootScreenIdentities, + Screen::AddNewIdentityScreen(AddNewIdentityScreen::new(&self.app_context)), + ), + )); + buttons.push(( + "Load Existing Identity".to_string(), + AppAction::PopThenAddScreenToMainScreen( + RootScreenType::RootScreenIdentities, + Screen::AddExistingIdentityScreen(AddExistingIdentityScreen::new( + &self.app_context, + )), + ), + )); + } + + buttons.push(( + "Import Another Wallet".to_string(), + AppAction::Custom("import_another_wallet".to_string()), + )); + + let action = crate::ui::helpers::show_success_screen(ui, title.to_string(), buttons); + + // Handle the custom action to reset the form + if let AppAction::Custom(ref s) = action + && s == "import_another_wallet" + { + // Reset mnemonic fields + self.seed_phrase_words = vec!["".to_string(); 24]; + self.selected_seed_phrase_length = 12; + self.seed_phrase = None; + + // Reset private key fields + self.private_key_input = String::new(); + self.parsed_single_key_wallet = None; + + // Reset common fields + self.password = String::new(); + self.alias_input = String::new(); + self.password_strength = 0.0; + self.estimated_time_to_crack = String::new(); + self.error = None; + self.wallet_imported = false; + self.identity_scan_count = 5; + return AppAction::None; + } + + action + } + + fn render_seed_phrase_input(&mut self, ui: &mut Ui) { + ui.add_space(15.0); // Add spacing from the top + ui.vertical_centered(|ui| { + // Select the seed phrase length + ui.horizontal(|ui| { + ui.label("Seed Phrase Length:"); + + ComboBox::from_label("") + .selected_text(format!("{}", self.selected_seed_phrase_length)) + .width(100.0) + .show_ui(ui, |ui| { + for &length in &[12, 15, 18, 21, 24] { + ui.selectable_value( + &mut self.selected_seed_phrase_length, + length, + format!("{}", length), + ); + } + }); + }); + + ui.add_space(10.0); + + // Ensure the seed_phrase_words vector matches the selected length + self.seed_phrase_words + .resize(self.selected_seed_phrase_length, "".to_string()); + + // Seed phrase input grid with shorter inputs + let columns = 4; // 4 columns + let _rows = self.selected_seed_phrase_length.div_ceil(columns); + let input_width = 120.0; // Fixed width for each input + + Grid::new("seed_phrase_input_grid") + .num_columns(columns) + .spacing((15.0, 10.0)) + .show(ui, |ui| { + for i in 0..self.selected_seed_phrase_length { + ui.horizontal(|ui| { + ui.label(format!("{:2}:", i + 1)); + + let mut word = self.seed_phrase_words[i].clone(); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + let response = ui.add_sized( + Vec2::new(input_width, 20.0), + egui::TextEdit::singleline(&mut word) + .text_color(crate::ui::theme::DashColors::text_primary( + dark_mode, + )) + .background_color( + crate::ui::theme::DashColors::input_background(dark_mode), + ), + ); + + if response.changed() { + // Update the seed_phrase_words[i] + self.seed_phrase_words[i] = word.clone(); + + // Check if the input contains multiple words + let words: Vec<&str> = word.split_whitespace().collect(); + + if words.len() > 1 { + // User pasted multiple words into this field + // Let's distribute them into the seed_phrase_words vector + let total_words = self.selected_seed_phrase_length; + let mut idx = i; + for word in words { + if idx < total_words { + self.seed_phrase_words[idx] = word.to_string(); + idx += 1; + } else { + break; + } + } + // Since we've updated the seed_phrase_words, the UI will reflect changes on the next frame + } + } + }); + + if (i + 1) % columns == 0 { + ui.end_row(); + } + } + }); + }); + } + + fn render_private_key_input(&mut self, ui: &mut Ui, step: u32) { + ui.heading(format!( + "{}. Enter your private key (WIF or 64-character hex format)", + step + )); + ui.add_space(8.0); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + let response = ui.add_sized( + Vec2::new(ui.available_width() - 20.0, 40.0), + egui::TextEdit::singleline(&mut self.private_key_input) + .hint_text("Enter private key (WIF: 51-52 chars, or hex: 64 chars)") + .text_color(crate::ui::theme::DashColors::text_primary(dark_mode)) + .background_color(crate::ui::theme::DashColors::input_background(dark_mode)) + .password(true), + ); + + if response.changed() { + self.try_parse_private_key(); + } + + // Show parsed address preview + if let Some(ref wallet) = self.parsed_single_key_wallet { + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.label("Derived Address:"); + ui.label( + RichText::new(wallet.address.to_string()) + .monospace() + .color(Color32::from_rgb(100, 200, 100)), + ); + }); + } + + // Show error if any + if let Some(ref err) = self.error { + ui.add_space(5.0); + ui.colored_label(Color32::from_rgb(255, 100, 100), err); + } + } + + fn render_import_type_selection(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label("Import Type:"); + ui.selectable_value( + &mut self.import_type, + ImportType::Mnemonic, + "Seed Phrase (HD Wallet)", + ); + ui.selectable_value( + &mut self.import_type, + ImportType::PrivateKey, + "Private Key (Single Address)", + ); + }); + } +} + +impl ScreenLike for ImportMnemonicScreen { + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ("Wallets", AppAction::GoToMainScreen), + ("Import Wallet", AppAction::None), + ], + vec![], + ); + + action |= add_left_panel( + ctx, + &self.app_context, + crate::ui::RootScreenType::RootScreenWalletsBalances, + ); + + action |= island_central_panel(ctx, |ui| { + let mut inner_action = AppAction::None; + + // Show success screen if wallet was imported + if self.wallet_imported { + inner_action = self.show_success(ui); + return inner_action; + } + + // Add the scroll area to make the content scrollable both vertically and horizontally + egui::ScrollArea::both() + .auto_shrink([false; 2]) // Prevent shrinking when content is less than the available area + .show(ui, |ui| { + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.heading("Follow these steps to import your wallet."); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Show Advanced Options"); + }); + }); + ui.add_space(10.0); + + // Track step number based on whether advanced options are shown + let mut step = 1; + + // Import type selection (only show when advanced options is checked) + if self.show_advanced_options { + ui.heading(format!("{}. Select what you want to import.", step)); + ui.add_space(10.0); + self.render_import_type_selection(ui); + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + step += 1; + + // Identity scan count option (only for mnemonic/HD wallets) + if self.import_type == ImportType::Mnemonic { + ui.heading(format!("{}. Configure identity auto-discovery.", step)); + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.label("Identity indices to scan:"); + ui.add(egui::DragValue::new(&mut self.identity_scan_count) + .range(0..=20) + .speed(0.1)); + ui.label("(0 to disable)"); + }); + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + step += 1; + } + } else { + // Reset to mnemonic when advanced options is hidden + self.import_type = ImportType::Mnemonic; + } + + // Different UI based on import type + match self.import_type { + ImportType::Mnemonic => { + ui.heading(format!("{}. Select the seed phrase length and enter all words.", step)); + self.render_seed_phrase_input(ui); + + // Check seed phrase validity whenever all words are filled + if self.seed_phrase_words.iter().all(|string| !string.is_empty()) { + match Mnemonic::parse_normalized(self.seed_phrase_words.join(" ").as_str()) { + Ok(mnemonic) => { + self.seed_phrase = Some(mnemonic); + // Clear any existing seed phrase error + if let Some(ref mut error) = self.error + && error.contains("Invalid seed phrase") { + self.error = None; + } + } + Err(_) => { + self.seed_phrase = None; + self.error = Some("Invalid seed phrase. Please check that all words are spelled correctly and are valid BIP39 words.".to_string()); + } + } + } else { + // Clear seed phrase and error if not all words are filled + self.seed_phrase = None; + if let Some(ref mut error) = self.error + && error.contains("Invalid seed phrase") { + self.error = None; + } + } + + // Display error message if seed phrase is invalid + if let Some(ref error_msg) = self.error + && error_msg.contains("Invalid seed phrase") { + ui.add_space(10.0); + ui.colored_label(Color32::from_rgb(255, 100, 100), error_msg); + } + + if self.seed_phrase.is_none() { + return; + } + } + ImportType::PrivateKey => { + self.render_private_key_input(ui, step); + + if self.parsed_single_key_wallet.is_none() { + return; + } + } + } + step += 1; + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + ui.heading(format!("{}. Enter a name to remember it by. (This will not go on the blockchain)", step)); + + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.label("Name:"); + ui.text_edit_singleline(&mut self.alias_input); + }); + + step += 1; + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + ui.heading(format!("{}. Add a password to encrypt. (Optional but recommended)", step)); + + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.label("Optional Password:"); + if ui.text_edit_singleline(&mut self.password).changed() { + if !self.password.is_empty() { + let estimate = zxcvbn(&self.password, &[]); + + // Convert Score to u8 + let score_u8 = u8::from(estimate.score()); + + // Use the score to determine password strength percentage + self.password_strength = score_u8 as f64 * 25.0; // Since score ranges from 0 to 4 + + // Get the estimated crack time in seconds + let estimated_seconds = estimate.crack_times().offline_slow_hashing_1e4_per_second(); + + // Format the estimated time to a human-readable string + self.estimated_time_to_crack = estimated_seconds.to_string(); + } else { + self.password_strength = 0.0; + self.estimated_time_to_crack = String::new(); + } + } + }); + + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.label("Password Strength:"); + + // Since score ranges from 0 to 4, adjust percentage accordingly + let strength_percentage = (self.password_strength / 100.0).min(1.0); + let fill_color = match self.password_strength as i32 { + 0..=25 => Color32::from_rgb(255, 182, 193), // Light pink + 26..=50 => Color32::from_rgb(255, 224, 130), // Light yellow + 51..=75 => Color32::from_rgb(144, 238, 144), // Light green + _ => Color32::from_rgb(90, 200, 90), // Medium green + }; + ui.add( + egui::ProgressBar::new(strength_percentage as f32) + .desired_width(200.0) + .show_percentage() + .text(match self.password_strength as i32 { + 0 => "None".to_string(), + 1..=25 => "Very Weak".to_string(), + 26..=50 => "Weak".to_string(), + 51..=75 => "Strong".to_string(), + _ => "Very Strong".to_string(), + }) + .fill(fill_color), + ); + }); + + ui.add_space(10.0); + ui.label(format!( + "Estimated time to crack: {}", + self.estimated_time_to_crack + )); + + // if self.app_context.password_info.is_none() { + // ui.add_space(10.0); + // ui.checkbox(&mut self.use_password_for_app, "Use password for Dash Evo Tool loose keys (recommended)"); + // } + + step += 1; + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + let button_text = match self.import_type { + ImportType::Mnemonic => format!("{}. Save the wallet.", step), + ImportType::PrivateKey => format!("{}. Import the key.", step), + }; + ui.heading(button_text); + ui.add_space(10.0); + + // Save button + let mut new_style = (**ui.style()).clone(); + new_style.spacing.button_padding = egui::vec2(10.0, 5.0); + ui.set_style(new_style); + + let button_label = match self.import_type { + ImportType::Mnemonic => "Save Wallet", + ImportType::PrivateKey => "Import Key", + }; + let save_button = egui::Button::new( + RichText::new(button_label).color(Color32::WHITE), + ) + .fill(Color32::from_rgb(0, 128, 255)) + .frame(true) + .corner_radius(3.0); + + if ui.add(save_button).clicked() { + let result = match self.import_type { + ImportType::Mnemonic => self.save_wallet(), + ImportType::PrivateKey => self.save_private_key_wallet(), + }; + match result { + Ok(save_action) => { + inner_action = save_action; + } + Err(e) => { + self.error = Some(e) + } + } + } + }); + + inner_action + }); + + action + } +} diff --git a/src/ui/wallets/import_wallet_screen.rs b/src/ui/wallets/import_wallet_screen.rs deleted file mode 100644 index 6a28634d2..000000000 --- a/src/ui/wallets/import_wallet_screen.rs +++ /dev/null @@ -1,461 +0,0 @@ -use crate::app::AppAction; -use crate::context::AppContext; -use crate::ui::ScreenLike; -use crate::ui::components::left_panel::add_left_panel; -use crate::ui::components::styled::island_central_panel; -use crate::ui::components::top_panel::add_top_panel; -use eframe::egui::Context; - -use crate::model::wallet::encryption::{DASH_SECRET_MESSAGE, encrypt_message}; -use crate::model::wallet::{ClosedKeyItem, OpenWalletSeed, Wallet, WalletSeed}; -use crate::ui::wallets::add_new_wallet_screen::{ - DASH_BIP44_ACCOUNT_0_PATH_MAINNET, DASH_BIP44_ACCOUNT_0_PATH_TESTNET, -}; -use bip39::{Language, Mnemonic}; -use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; -use dash_sdk::dpp::dashcore::Network; -use dash_sdk::dpp::key_wallet::bip32::DerivationPath; -use dash_sdk::dpp::key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey}; -use egui::{Color32, ComboBox, Direction, Frame, Grid, Layout, Margin, RichText, Stroke, Ui, Vec2}; -use std::sync::atomic::Ordering; -use std::sync::{Arc, RwLock}; -use zxcvbn::zxcvbn; - -pub struct ImportWalletScreen { - seed_phrase_words: Vec, - selected_seed_phrase_length: usize, - seed_phrase: Option, - password: String, - alias_input: String, - password_strength: f64, - estimated_time_to_crack: String, - error: Option, - pub app_context: Arc, - use_password_for_app: bool, -} - -impl ImportWalletScreen { - pub fn new(app_context: &Arc) -> Self { - Self { - seed_phrase_words: vec!["".to_string(); 24], - selected_seed_phrase_length: 12, - seed_phrase: None, - password: String::new(), - alias_input: String::new(), - password_strength: 0.0, - estimated_time_to_crack: "".to_string(), - error: None, - app_context: app_context.clone(), - use_password_for_app: true, - } - } - fn save_wallet(&mut self) -> Result { - if let Some(mnemonic) = &self.seed_phrase { - let seed = mnemonic.to_seed(""); - - let (encrypted_seed, salt, nonce, uses_password) = if self.password.is_empty() { - (seed.to_vec(), vec![], vec![], false) - } else { - // Encrypt the seed to obtain encrypted_seed, salt, and nonce - let (encrypted_seed, salt, nonce) = - ClosedKeyItem::encrypt_seed(&seed, self.password.as_str())?; - if self.use_password_for_app { - let (encrypted_message, salt, nonce) = - encrypt_message(DASH_SECRET_MESSAGE, self.password.as_str())?; - self.app_context - .update_main_password(&salt, &nonce, &encrypted_message) - .map_err(|e| e.to_string())?; - } - (encrypted_seed, salt, nonce, true) - }; - - // Generate master ECDSA extended private key - let master_ecdsa_extended_private_key = - ExtendedPrivKey::new_master(self.app_context.network, &seed) - .expect("Failed to create master ECDSA extended private key"); - let bip44_root_derivation_path: DerivationPath = match self.app_context.network { - Network::Dash => DerivationPath::from(DASH_BIP44_ACCOUNT_0_PATH_MAINNET.as_slice()), - _ => DerivationPath::from(DASH_BIP44_ACCOUNT_0_PATH_TESTNET.as_slice()), - }; - let secp = Secp256k1::new(); - let master_bip44_ecdsa_extended_public_key = master_ecdsa_extended_private_key - .derive_priv(&secp, &bip44_root_derivation_path) - .map_err(|e| e.to_string())?; - - let master_bip44_ecdsa_extended_public_key = - ExtendedPubKey::from_priv(&secp, &master_bip44_ecdsa_extended_public_key); - - // Compute the seed hash - let seed_hash = ClosedKeyItem::compute_seed_hash(&seed); - - let wallet = Wallet { - wallet_seed: WalletSeed::Open(OpenWalletSeed { - seed, - wallet_info: ClosedKeyItem { - seed_hash, - encrypted_seed, - salt, - nonce, - password_hint: None, // Set a password hint if needed - }, - }), - uses_password, - master_bip44_ecdsa_extended_public_key, - address_balances: Default::default(), - known_addresses: Default::default(), - watched_addresses: Default::default(), - unused_asset_locks: Default::default(), - alias: Some(self.alias_input.clone()), - identities: Default::default(), - utxos: Default::default(), - is_main: true, - }; - - self.app_context - .db - .store_wallet(&wallet, &self.app_context.network) - .map_err(|e| { - if e.to_string().contains("UNIQUE constraint failed: wallet.seed_hash") { - "This wallet has already been imported for another network. Each wallet can only be imported once.".to_string() - } else { - e.to_string() - } - })?; - - // Acquire a write lock and add the new wallet - if let Ok(mut wallets) = self.app_context.wallets.write() { - wallets.insert(wallet.seed_hash(), Arc::new(RwLock::new(wallet))); - self.app_context.has_wallet.store(true, Ordering::Relaxed); - } else { - eprintln!("Failed to acquire write lock on wallets"); - } - - Ok(AppAction::GoToMainScreen) // Navigate back to the main screen after saving - } else { - Ok(AppAction::None) // No action if no seed phrase exists - } - } - - fn render_seed_phrase_input(&mut self, ui: &mut Ui) { - ui.add_space(15.0); // Add spacing from the top - ui.vertical_centered(|ui| { - // Select the seed phrase length - ui.horizontal(|ui| { - ui.label("Seed Phrase Length:"); - - ComboBox::from_label("") - .selected_text(format!("{}", self.selected_seed_phrase_length)) - .width(100.0) - .show_ui(ui, |ui| { - for &length in &[12, 15, 18, 21, 24] { - ui.selectable_value( - &mut self.selected_seed_phrase_length, - length, - format!("{}", length), - ); - } - }); - }); - - ui.add_space(10.0); - - // Ensure the seed_phrase_words vector matches the selected length - self.seed_phrase_words - .resize(self.selected_seed_phrase_length, "".to_string()); - - // Seed phrase input grid with shorter inputs - let columns = 4; // 4 columns - let _rows = self.selected_seed_phrase_length.div_ceil(columns); - let input_width = 120.0; // Fixed width for each input - - Grid::new("seed_phrase_input_grid") - .num_columns(columns) - .spacing((15.0, 10.0)) - .show(ui, |ui| { - for i in 0..self.selected_seed_phrase_length { - ui.horizontal(|ui| { - ui.label(format!("{:2}:", i + 1)); - - let mut word = self.seed_phrase_words[i].clone(); - - let dark_mode = ui.ctx().style().visuals.dark_mode; - let response = ui.add_sized( - Vec2::new(input_width, 20.0), - egui::TextEdit::singleline(&mut word) - .text_color(crate::ui::theme::DashColors::text_primary( - dark_mode, - )) - .background_color( - crate::ui::theme::DashColors::input_background(dark_mode), - ), - ); - - if response.changed() { - // Update the seed_phrase_words[i] - self.seed_phrase_words[i] = word.clone(); - - // Check if the input contains multiple words - let words: Vec<&str> = word.split_whitespace().collect(); - - if words.len() > 1 { - // User pasted multiple words into this field - // Let's distribute them into the seed_phrase_words vector - let total_words = self.selected_seed_phrase_length; - let mut idx = i; - for word in words { - if idx < total_words { - self.seed_phrase_words[idx] = word.to_string(); - idx += 1; - } else { - break; - } - } - // Since we've updated the seed_phrase_words, the UI will reflect changes on the next frame - } - } - }); - - if (i + 1) % columns == 0 { - ui.end_row(); - } - } - }); - }); - } -} - -impl ScreenLike for ImportWalletScreen { - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action = add_top_panel( - ctx, - &self.app_context, - vec![ - ("Wallets", AppAction::GoToMainScreen), - ("Import Wallet", AppAction::None), - ], - vec![], - ); - - action |= add_left_panel( - ctx, - &self.app_context, - crate::ui::RootScreenType::RootScreenWalletsBalances, - ); - - action |= island_central_panel(ctx, |ui| { - let mut inner_action = AppAction::None; - - if let Some(error_msg) = self - .error - .clone() - .filter(|msg| !msg.contains("Invalid seed phrase")) - { - let message_color = Color32::from_rgb(255, 100, 100); - let mut dismiss_requested = false; - ui.horizontal(|ui| { - Frame::new() - .fill(message_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(Stroke::new(1.0, message_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(&error_msg).color(message_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - dismiss_requested = true; - } - }); - }); - }); - if dismiss_requested { - self.error = None; - } - ui.add_space(10.0); - } - - // Add the scroll area to make the content scrollable both vertically and horizontally - egui::ScrollArea::both() - .auto_shrink([false; 2]) // Prevent shrinking when content is less than the available area - .show(ui, |ui| { - ui.add_space(10.0); - ui.heading("Follow these steps to import your wallet."); - - ui.add_space(5.0); - - ui.heading("1. Select the seed phrase length and enter all words."); - self.render_seed_phrase_input(ui); - - let normalized_words: Vec = self - .seed_phrase_words - .iter() - .map(|word| word.trim().to_lowercase()) - .collect(); - let all_words_filled = normalized_words.iter().all(|word| !word.is_empty()); - let all_words_valid = all_words_filled - && normalized_words.iter().all(|word| { - Language::English - .word_list() - .binary_search(&word.as_str()) - .is_ok() - }); - - // Check seed phrase validity whenever all words are valid BIP39 words - if all_words_valid { - match Mnemonic::parse_normalized(normalized_words.join(" ").as_str()) { - Ok(mnemonic) => { - self.seed_phrase = Some(mnemonic); - // Clear any existing seed phrase error - if let Some(ref mut error) = self.error - && error.contains("Invalid seed phrase") { - self.error = None; - } - } - Err(_) => { - self.seed_phrase = None; - self.error = Some("Invalid seed phrase. Please check that all words are spelled correctly and are valid BIP39 words.".to_string()); - } - } - } else { - // Clear seed phrase and error if not all words are filled - self.seed_phrase = None; - if let Some(ref mut error) = self.error - && error.contains("Invalid seed phrase") { - self.error = None; - } - } - - ui.add_space(10.0); - - if !all_words_valid { - ui.colored_label( - Color32::from_gray(180), - "Waiting for a valid seed phrase...", - ); - } else if let Some(ref error_msg) = self.error - && error_msg.contains("Invalid seed phrase") - { - ui.colored_label(Color32::from_rgb(255, 100, 100), error_msg); - } - - if self.seed_phrase.is_none() { - return; - } - - ui.add_space(20.0); - - ui.heading("2. Select a wallet name to remember it. (This will not go to the blockchain)"); - - ui.add_space(8.0); - - ui.horizontal(|ui| { - ui.label("Wallet Name:"); - ui.text_edit_singleline(&mut self.alias_input); - }); - - ui.add_space(20.0); - - ui.heading("3. Add a password that must be used to unlock the wallet. (Optional but recommended)"); - - ui.add_space(8.0); - - ui.horizontal(|ui| { - ui.label("Optional Password:"); - if ui.text_edit_singleline(&mut self.password).changed() { - if !self.password.is_empty() { - let estimate = zxcvbn(&self.password, &[]); - - // Convert Score to u8 - let score_u8 = u8::from(estimate.score()); - - // Use the score to determine password strength percentage - self.password_strength = score_u8 as f64 * 25.0; // Since score ranges from 0 to 4 - - // Get the estimated crack time in seconds - let estimated_seconds = estimate.crack_times().offline_slow_hashing_1e4_per_second(); - - // Format the estimated time to a human-readable string - self.estimated_time_to_crack = estimated_seconds.to_string(); - } else { - self.password_strength = 0.0; - self.estimated_time_to_crack = String::new(); - } - } - }); - - ui.add_space(10.0); - ui.horizontal(|ui| { - ui.label("Password Strength:"); - - // Since score ranges from 0 to 4, adjust percentage accordingly - let strength_percentage = (self.password_strength / 100.0).min(1.0); - let fill_color = match self.password_strength as i32 { - 0..=25 => Color32::from_rgb(255, 182, 193), // Light pink - 26..=50 => Color32::from_rgb(255, 224, 130), // Light yellow - 51..=75 => Color32::from_rgb(144, 238, 144), // Light green - _ => Color32::from_rgb(90, 200, 90), // Medium green - }; - ui.add( - egui::ProgressBar::new(strength_percentage as f32) - .desired_width(200.0) - .show_percentage() - .text(match self.password_strength as i32 { - 0 => "None".to_string(), - 1..=25 => "Very Weak".to_string(), - 26..=50 => "Weak".to_string(), - 51..=75 => "Strong".to_string(), - _ => "Very Strong".to_string(), - }) - .fill(fill_color), - ); - }); - - ui.add_space(10.0); - ui.label(format!( - "Estimated time to crack: {}", - self.estimated_time_to_crack - )); - - // if self.app_context.password_info.is_none() { - // ui.add_space(10.0); - // ui.checkbox(&mut self.use_password_for_app, "Use password for Dash Evo Tool loose keys (recommended)"); - // } - - ui.add_space(20.0); - - ui.heading("4. Save the wallet."); - ui.add_space(5.0); - - // Centered "Save Wallet" button at the bottom - ui.with_layout(Layout::centered_and_justified(Direction::TopDown), |ui| { - let save_button = egui::Button::new( - RichText::new("Save Wallet").strong().size(30.0), - ) - .min_size(Vec2::new(300.0, 60.0)) - .corner_radius(10.0) - .stroke(Stroke::new(1.5, Color32::WHITE)) - .sense(if self.seed_phrase.is_some() { - egui::Sense::click() - } else { - egui::Sense::hover() - }); - - if ui.add(save_button).clicked() { - match self.save_wallet() { - Ok(save_wallet_action) => { - inner_action = save_wallet_action; - } - Err(e) => { - self.error = Some(e) - } - } - } - }); - }); - - inner_action - }); - - action - } -} diff --git a/src/ui/wallets/mod.rs b/src/ui/wallets/mod.rs index 8ced7a4dd..988edcf69 100644 --- a/src/ui/wallets/mod.rs +++ b/src/ui/wallets/mod.rs @@ -1,3 +1,6 @@ +pub mod account_summary; pub mod add_new_wallet_screen; -pub mod import_wallet_screen; +pub mod import_mnemonic_screen; +pub mod send_screen; +pub mod single_key_send_screen; pub mod wallets_screen; diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs new file mode 100644 index 000000000..7cdc2fa6e --- /dev/null +++ b/src/ui/wallets/send_screen.rs @@ -0,0 +1,2157 @@ +use crate::app::AppAction; +use crate::backend_task::BackendTask; +use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; +use crate::backend_task::wallet::WalletTask; +use crate::context::AppContext; +use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; +use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::wallet::{Wallet, WalletSeedHash}; +use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock_popup::{ + WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, +}; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use dash_sdk::dashcore_rpc::dashcore::Address; +use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; +use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::balances::credits::Credits; +use dash_sdk::dpp::identity::core_script::CoreScript; +use eframe::egui::{self, Context, RichText, Ui}; +use egui::{Color32, Frame, Margin}; +use std::collections::BTreeMap; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Detected address type +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AddressType { + Core, + Platform, + Unknown, +} + +/// Source selection for sending +#[derive(Debug, Clone, PartialEq)] +pub enum SourceSelection { + /// Use Core wallet UTXOs + CoreWallet, + /// Use a specific Platform address (stores both platform address and original core address for lookup) + PlatformAddress(PlatformAddress, Address), +} + +/// Status of the send operation +#[derive(Debug, Clone, PartialEq)] +pub enum SendStatus { + NotStarted, + /// Waiting for result, stores the start time in seconds since epoch + WaitingForResult(u64), + /// Successfully completed with a success message + Complete(String), + /// Error occurred + Error(String), +} + +/// Fee strategy for platform transfers +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum PlatformFeeStrategy { + /// Deduct fee from first input + #[default] + DeductFromFirstInput, + /// Deduct fee from last input + DeductFromLastInput, + /// Reduce first output by fee amount + ReduceFirstOutput, + /// Reduce last output by fee amount + ReduceLastOutput, +} + +impl std::fmt::Display for PlatformFeeStrategy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DeductFromFirstInput => write!(f, "Deduct from first input"), + Self::DeductFromLastInput => write!(f, "Deduct from last input"), + Self::ReduceFirstOutput => write!(f, "Reduce first output"), + Self::ReduceLastOutput => write!(f, "Reduce last output"), + } + } +} + +/// Source type for advanced mode - Core or Platform +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AdvancedSourceType { + Core, + Platform, +} + +impl std::fmt::Display for AdvancedSourceType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Core => write!(f, "Core Wallet"), + Self::Platform => write!(f, "Platform Addresses"), + } + } +} + +/// A Core address input for advanced mode +#[derive(Debug, Clone)] +pub struct CoreAddressInput { + /// The core address + pub address: Address, + /// Amount to send from this address (as string for input field) + pub amount: String, +} + +/// A Platform address input for advanced mode +#[derive(Debug, Clone)] +pub struct PlatformAddressInput { + /// The platform address + pub platform_address: PlatformAddress, + /// The corresponding core address (for lookup/display) + #[allow(dead_code)] + pub core_address: Address, + /// Amount to send from this address (as string for input field) + pub amount: String, +} + +/// An output for advanced mode (destination + amount) +#[derive(Debug, Clone)] +pub struct AdvancedOutput { + /// Destination address string + pub address: String, + /// Amount to send to this address (as string for input field) + pub amount: String, +} + +pub struct WalletSendScreen { + pub app_context: Arc, + pub selected_wallet: Option>>, + #[allow(dead_code)] + selected_wallet_seed_hash: Option, + + // Unified send fields (simple mode) + selected_source: Option, + destination_address: String, + amount: Option, + amount_input: Option, + + // Advanced mode state + show_advanced_options: bool, + advanced_source_type: AdvancedSourceType, + /// For Core source type: list of core address inputs + core_inputs: Vec, + /// For Platform source type: list of platform address inputs + platform_inputs: Vec, + advanced_outputs: Vec, + fee_strategy: PlatformFeeStrategy, + + // Common options + subtract_fee: bool, + + // State + send_status: SendStatus, + + // Wallet unlock + wallet_unlock_popup: WalletUnlockPopup, + error_message: Option, +} + +impl WalletSendScreen { + pub fn new(app_context: &Arc, wallet: Arc>) -> Self { + let seed_hash = wallet.read().ok().map(|w| w.seed_hash()); + Self { + app_context: app_context.clone(), + selected_wallet: Some(wallet), + selected_wallet_seed_hash: seed_hash, + selected_source: Some(SourceSelection::CoreWallet), + destination_address: String::new(), + amount: None, + amount_input: None, + show_advanced_options: false, + advanced_source_type: AdvancedSourceType::Core, + core_inputs: Vec::new(), + platform_inputs: Vec::new(), + advanced_outputs: vec![AdvancedOutput { + address: String::new(), + amount: String::new(), + }], + fee_strategy: PlatformFeeStrategy::default(), + subtract_fee: false, + send_status: SendStatus::NotStarted, + wallet_unlock_popup: WalletUnlockPopup::new(), + error_message: None, + } + } + + fn reset_form(&mut self) { + self.destination_address.clear(); + self.amount = None; + self.amount_input = None; + self.selected_source = Some(SourceSelection::CoreWallet); + self.advanced_source_type = AdvancedSourceType::Core; + self.core_inputs.clear(); + self.platform_inputs.clear(); + self.advanced_outputs = vec![AdvancedOutput { + address: String::new(), + amount: String::new(), + }]; + self.fee_strategy = PlatformFeeStrategy::default(); + self.send_status = SendStatus::NotStarted; + } + + fn format_dash(amount_duffs: u64) -> String { + Amount::dash_from_duffs(amount_duffs).to_string() + } + + fn format_credits(credits: Credits) -> String { + let dash = credits as f64 / 1000.0 / 100_000_000.0; + format!("{:.8} DASH", dash) + } + + fn parse_amount_to_duffs(input: &str) -> Result { + let amount = Amount::parse(input, DASH_DECIMAL_PLACES)?.with_unit_name("DASH"); + amount.dash_to_duffs() + } + + fn parse_amount_to_credits(input: &str) -> Result { + let amount = Amount::parse(input, DASH_DECIMAL_PLACES)?.with_unit_name("DASH"); + let duffs = amount.dash_to_duffs()?; + Ok(duffs as Credits * 1000) + } + + /// Detect address type from the address string + fn detect_address_type(&self, address: &str) -> AddressType { + let trimmed = address.trim(); + if trimmed.is_empty() { + return AddressType::Unknown; + } + + // Check for Platform address (Bech32m format) + if trimmed.starts_with("dashevo1") || trimmed.starts_with("tdashevo1") { + return AddressType::Platform; + } + + // Try to parse as Core address + if trimmed.parse::>().is_ok() { + return AddressType::Core; + } + + AddressType::Unknown + } + + /// Get available Platform addresses with balances + /// Deduplicates addresses based on their canonical Bech32m string representation, + /// preferring the entry with the highest nonce (most recent update) + fn get_platform_addresses(&self) -> Vec<(Address, PlatformAddress, Credits)> { + use std::collections::HashMap; + + let Some(wallet_arc) = &self.selected_wallet else { + return vec![]; + }; + let Ok(wallet) = wallet_arc.read() else { + return vec![]; + }; + + let network = self.app_context.network; + // Use HashMap to deduplicate by canonical address string + // Store (core_addr, platform_addr, balance, nonce) and prefer higher nonce + let mut address_map: HashMap = + HashMap::new(); + + for (addr, info) in wallet.platform_address_info.iter() { + if let Ok(platform_addr) = PlatformAddress::try_from(addr.clone()) { + let canonical_str = platform_addr.to_bech32m_string(network); + + // Check if we already have this address + let should_update = match address_map.get(&canonical_str) { + Some((_, _, _, existing_nonce)) => { + // Prefer the entry with higher nonce (more recent) + info.nonce >= *existing_nonce + } + None => true, + }; + + if should_update { + address_map.insert( + canonical_str, + (addr.clone(), platform_addr, info.balance, info.nonce), + ); + } + } + } + + // Filter to only addresses with positive balance, sort by canonical string, and return + let mut result: Vec<_> = address_map + .into_iter() + .filter(|(_, (_, _, balance, _))| *balance > 0) + .map(|(canonical_str, (addr, platform_addr, balance, _))| { + (canonical_str, addr, platform_addr, balance) + }) + .collect(); + + // Sort by canonical address string for consistent ordering + result.sort_by(|a, b| a.0.cmp(&b.0)); + + result + .into_iter() + .map(|(_, addr, platform_addr, balance)| (addr, platform_addr, balance)) + .collect() + } + + /// Get Core wallet balance + fn get_core_balance(&self) -> u64 { + self.selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|w| w.confirmed_balance_duffs()) + .unwrap_or(0) + } + + /// Get Core addresses with their UTXO balances + fn get_core_addresses(&self) -> Vec<(Address, u64)> { + let Some(wallet_arc) = &self.selected_wallet else { + return vec![]; + }; + let Ok(wallet) = wallet_arc.read() else { + return vec![]; + }; + + let mut addresses = wallet.utxos_by_address(); + // Sort by balance descending for better UX + addresses.sort_by(|a, b| b.1.cmp(&a.1)); + addresses + } + + /// Get description of transaction type based on source and destination + fn get_transaction_type_description(&self) -> &'static str { + let dest_type = self.detect_address_type(&self.destination_address); + match (&self.selected_source, dest_type) { + (Some(SourceSelection::CoreWallet), AddressType::Core) => "Core Transaction", + (Some(SourceSelection::CoreWallet), AddressType::Platform) => "Fund Platform Address", + (Some(SourceSelection::PlatformAddress(_, _)), AddressType::Platform) => { + "Platform Transfer" + } + (Some(SourceSelection::PlatformAddress(_, _)), AddressType::Core) => "Withdraw to Core", + _ => "Send", + } + } + + /// Validate and execute the send based on detected types + fn validate_and_send(&mut self) -> Result { + let wallet = self.selected_wallet.as_ref().ok_or("No wallet selected")?; + + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + + if !wallet_guard.is_open() { + return Err("Wallet must be unlocked first".to_string()); + } + + let seed_hash = wallet_guard.seed_hash(); + let network = self.app_context.network; + + // Validate source + let source = self + .selected_source + .as_ref() + .ok_or("Please select a source")?; + + // Validate destination + let dest_type = self.detect_address_type(&self.destination_address); + if dest_type == AddressType::Unknown { + return Err( + "Invalid destination address. Use a Dash address (X.../y...) or Platform address (dashevo1.../tdashevo1...)" + .to_string(), + ); + } + + // Validate amount + let amount = self + .amount + .as_ref() + .ok_or_else(|| "Please enter an amount".to_string())?; + if amount.value() == 0 { + return Err("Amount must be greater than 0".to_string()); + } + + drop(wallet_guard); + + // Route to appropriate handler based on source and destination types + match (source.clone(), dest_type) { + (SourceSelection::CoreWallet, AddressType::Core) => self.send_core_to_core(), + (SourceSelection::CoreWallet, AddressType::Platform) => { + self.send_core_to_platform(seed_hash) + } + (SourceSelection::PlatformAddress(platform_addr, core_addr), AddressType::Platform) => { + self.send_platform_to_platform(seed_hash, platform_addr, core_addr) + } + (SourceSelection::PlatformAddress(platform_addr, core_addr), AddressType::Core) => { + self.send_platform_to_core(seed_hash, platform_addr, core_addr, network) + } + _ => Err("Invalid source/destination combination".to_string()), + } + } + + fn send_core_to_core(&mut self) -> Result { + let amount_duffs = self + .amount + .as_ref() + .ok_or_else(|| "Amount is required".to_string())? + .dash_to_duffs()?; + if amount_duffs == 0 { + return Err("Amount must be greater than 0".to_string()); + } + + // Check balance + let balance = self.get_core_balance(); + if amount_duffs > balance { + return Err(format!( + "Insufficient balance. Need {} but have {}", + Self::format_dash(amount_duffs), + Self::format_dash(balance) + )); + } + + let wallet = self + .selected_wallet + .as_ref() + .ok_or("No wallet selected")? + .clone(); + + let recipient = PaymentRecipient { + address: self.destination_address.trim().to_string(), + amount_duffs, + }; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.send_status = SendStatus::WaitingForResult(now); + + Ok(AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::SendWalletPayment { + wallet, + request: WalletPaymentRequest { + recipients: vec![recipient], + subtract_fee_from_amount: self.subtract_fee, + memo: None, + override_fee: None, + }, + }, + ))) + } + + fn send_core_to_platform(&mut self, seed_hash: WalletSeedHash) -> Result { + let amount_duffs = self + .amount + .as_ref() + .ok_or_else(|| "Amount is required".to_string())? + .dash_to_duffs()?; + if amount_duffs == 0 { + return Err("Amount must be greater than 0".to_string()); + } + + // Check balance (include fee for asset lock) + let required = amount_duffs.saturating_add(3000); + let balance = self.get_core_balance(); + if required > balance { + return Err(format!( + "Insufficient balance. Need {} (including fee) but have {}", + Self::format_dash(required), + Self::format_dash(balance) + )); + } + + // Parse platform address + let address_str = self.destination_address.trim(); + let destination = PlatformAddress::from_bech32m_string(address_str) + .map(|(addr, _)| addr) + .map_err(|e| format!("Invalid platform address: {}", e))?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.send_status = SendStatus::WaitingForResult(now); + + Ok(AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::FundPlatformAddressFromWalletUtxos { + seed_hash, + amount: amount_duffs, + destination, + // In simple mode, default to deducting fees from output (current behavior) + fee_deduct_from_output: true, + }, + ))) + } + + fn send_platform_to_platform( + &mut self, + seed_hash: WalletSeedHash, + source_addr: PlatformAddress, + source_core_addr: Address, + ) -> Result { + // Amount in credits (Amount stores in credits for DASH with 11 decimal places) + let amount_credits = self + .amount + .as_ref() + .ok_or_else(|| "Amount is required".to_string())? + .value(); + if amount_credits == 0 { + return Err("Amount must be greater than 0".to_string()); + } + + // Check balance using the original core address + let wallet = self.selected_wallet.as_ref().ok_or("No wallet")?; + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + + let balance = wallet_guard + .get_platform_address_info(&source_core_addr) + .map(|info| info.balance) + .unwrap_or(0); + + if amount_credits > balance { + return Err(format!( + "Insufficient balance. Need {} but have {}", + Self::format_credits(amount_credits), + Self::format_credits(balance) + )); + } + drop(wallet_guard); + + // Parse destination platform address + let address_str = self.destination_address.trim(); + let destination = PlatformAddress::from_bech32m_string(address_str) + .map(|(addr, _)| addr) + .map_err(|e| format!("Invalid platform address: {}", e))?; + + let mut inputs = BTreeMap::new(); + inputs.insert(source_addr, amount_credits); + + let mut outputs = BTreeMap::new(); + outputs.insert(destination, amount_credits); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.send_status = SendStatus::WaitingForResult(now); + + Ok(AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::TransferPlatformCredits { + seed_hash, + inputs, + outputs, + }, + ))) + } + + fn send_platform_to_core( + &mut self, + seed_hash: WalletSeedHash, + source_addr: PlatformAddress, + source_core_addr: Address, + network: dash_sdk::dpp::dashcore::Network, + ) -> Result { + // Amount in credits + let amount_credits = self + .amount + .as_ref() + .ok_or_else(|| "Amount is required".to_string())? + .value(); + if amount_credits == 0 { + return Err("Amount must be greater than 0".to_string()); + } + + // Check balance using the original core address + let wallet = self.selected_wallet.as_ref().ok_or("No wallet")?; + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + + let balance = wallet_guard + .get_platform_address_info(&source_core_addr) + .map(|info| info.balance) + .unwrap_or(0); + + if amount_credits > balance { + return Err(format!( + "Insufficient balance. Need {} but have {}", + Self::format_credits(amount_credits), + Self::format_credits(balance) + )); + } + drop(wallet_guard); + + // Parse destination Core address + let address_str = self.destination_address.trim(); + let dest_address: Address = address_str + .parse() + .map_err(|e| format!("Invalid Core address: {}", e))?; + let dest_address = dest_address + .require_network(network) + .map_err(|e| format!("Address network mismatch: {}", e))?; + + let output_script = CoreScript::new(dest_address.script_pubkey()); + + let mut inputs = BTreeMap::new(); + inputs.insert(source_addr, amount_credits); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.send_status = SendStatus::WaitingForResult(now); + + Ok(AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::WithdrawFromPlatformAddress { + seed_hash, + inputs, + output_script, + core_fee_per_byte: 1, + }, + ))) + } + + fn render_unified_send(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + + // Wallet info + self.render_wallet_info(ui); + + // Wallet unlock if needed + let wallet_is_open = self + .selected_wallet + .as_ref() + .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); + + if !wallet_is_open && let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + ui.add_space(10.0); + return AppAction::None; + } + } + + ui.add_space(10.0); + + // Source selection + self.render_source_selection(ui); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Destination address + self.render_destination_input(ui); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Amount + self.render_amount_input(ui); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Send button + action |= self.render_send_button(ui); + + action + } + + fn render_wallet_info(&self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + + if let Some(wallet_arc) = &self.selected_wallet + && let Ok(wallet) = wallet_arc.read() + { + let alias = wallet + .alias + .clone() + .unwrap_or_else(|| "Unnamed Wallet".to_string()); + + egui::Grid::new("wallet_info_grid") + .num_columns(2) + .spacing([10.0, 4.0]) + .show(ui, |ui| { + ui.label( + RichText::new("Wallet:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(&alias) + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(14.0), + ); + ui.end_row(); + }); + + ui.add_space(10.0); + ui.separator(); + } + } + + fn render_source_selection(&mut self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + + ui.label( + RichText::new("Send from") + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(14.0), + ); + + ui.add_space(8.0); + + // Core wallet option + let core_balance = self.get_core_balance(); + let is_core_selected = matches!(self.selected_source, Some(SourceSelection::CoreWallet)); + + Frame::group(ui.style()) + .fill(if is_core_selected { + DashColors::DASH_BLUE.gamma_multiply(0.1) + } else { + DashColors::surface(dark_mode) + }) + .stroke(if is_core_selected { + egui::Stroke::new(2.0, DashColors::DASH_BLUE) + } else { + egui::Stroke::new(1.0, DashColors::border_light(dark_mode)) + }) + .inner_margin(Margin::symmetric(12, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + let mut selected = is_core_selected; + if ui.radio_value(&mut selected, true, "").changed() && selected { + self.selected_source = Some(SourceSelection::CoreWallet); + } + ui.label( + RichText::new("Core Wallet") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new(Self::format_dash(core_balance)) + .color(DashColors::SUCCESS) + .strong(), + ); + }); + }); + }); + + // Platform addresses option (simplified - shows combined balance) + let platform_addresses = self.get_platform_addresses(); + if !platform_addresses.is_empty() { + ui.add_space(5.0); + + // Calculate total platform balance + let total_platform_balance: u64 = platform_addresses.iter().map(|(_, _, b)| *b).sum(); + + // Check if any platform address is selected + let is_platform_selected = matches!( + &self.selected_source, + Some(SourceSelection::PlatformAddress(_, _)) + ); + + Frame::group(ui.style()) + .fill(if is_platform_selected { + DashColors::DASH_BLUE.gamma_multiply(0.1) + } else { + DashColors::surface(dark_mode) + }) + .stroke(if is_platform_selected { + egui::Stroke::new(2.0, DashColors::DASH_BLUE) + } else { + egui::Stroke::new(1.0, DashColors::border_light(dark_mode)) + }) + .inner_margin(Margin::symmetric(12, 8)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + let mut selected = is_platform_selected; + if ui.radio_value(&mut selected, true, "").changed() && selected { + // Select the first platform address with balance + if let Some((core_addr, platform_addr, _)) = platform_addresses.first() + { + self.selected_source = Some(SourceSelection::PlatformAddress( + *platform_addr, + core_addr.clone(), + )); + } + } + ui.label( + RichText::new("Platform Addresses") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new(Self::format_credits(total_platform_balance)) + .color(DashColors::SUCCESS) + .strong(), + ); + }); + }); + }); + } + } + + fn render_destination_input(&mut self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let dest_type = self.detect_address_type(&self.destination_address); + + ui.horizontal(|ui| { + ui.label( + RichText::new("Send to") + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(14.0), + ); + + // Show detected type + if dest_type != AddressType::Unknown { + ui.add_space(10.0); + let (type_text, type_color) = match dest_type { + AddressType::Core => ("Core Address", DashColors::DASH_BLUE), + AddressType::Platform => ("Platform Address", Color32::from_rgb(130, 80, 220)), + AddressType::Unknown => ("", Color32::GRAY), + }; + ui.label( + RichText::new(format!("({})", type_text)) + .color(type_color) + .size(12.0), + ); + } + }); + + ui.add_space(8.0); + + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(12, 10)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut self.destination_address) + .hint_text("Enter address (X.../y.../dashevo1.../tdashevo1...)") + .desired_width(f32::INFINITY), + ); + }); + + // Show error for invalid address + if !self.destination_address.trim().is_empty() && dest_type == AddressType::Unknown { + ui.add_space(5.0); + ui.label( + RichText::new("Invalid address format") + .color(DashColors::ERROR) + .size(12.0), + ); + } + } + + fn render_amount_input(&mut self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + + ui.label( + RichText::new("Amount") + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(14.0), + ); + + ui.add_space(8.0); + + // Get max amount based on source selection + let max_amount_credits = match &self.selected_source { + Some(SourceSelection::CoreWallet) => self.selected_wallet.as_ref().and_then(|w| { + w.read() + .ok() + .map(|wallet| wallet.total_balance_duffs() * 1000) // duffs to credits + }), + Some(SourceSelection::PlatformAddress(_, core_addr)) => { + self.selected_wallet.as_ref().and_then(|w| { + w.read().ok().and_then(|wallet| { + wallet + .get_platform_address_info(core_addr) + .map(|info| info.balance) + }) + }) + } + None => None, + }; + + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(12, 10)) + .corner_radius(5.0) + .show(ui, |ui| { + let amount_input = self.amount_input.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .with_hint_text("Enter amount") + .with_max_button(true) + .with_desired_width(150.0) + }); + + // Update max amount dynamically + amount_input.set_max_amount(max_amount_credits); + + let response = amount_input.show(ui); + response.inner.update(&mut self.amount); + + // When Max is clicked for Core wallet, automatically enable subtract_fee + // so the transaction fee is deducted from the amount instead of failing + if response.inner.max_clicked + && matches!(self.selected_source, Some(SourceSelection::CoreWallet)) + { + self.subtract_fee = true; + } + }); + + // Show transaction type hint + let tx_type = self.get_transaction_type_description(); + if tx_type != "Send" && !self.destination_address.trim().is_empty() { + ui.add_space(5.0); + ui.label( + RichText::new(format!("Transaction type: {}", tx_type)) + .color(DashColors::text_secondary(dark_mode)) + .italics() + .size(12.0), + ); + } + + // Show subtract fee checkbox for Core wallet to Core address transactions + let dest_type = self.detect_address_type(&self.destination_address); + if matches!(self.selected_source, Some(SourceSelection::CoreWallet)) + && dest_type == AddressType::Core + { + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.checkbox(&mut self.subtract_fee, "Subtract fee from amount"); + if self.subtract_fee { + ui.label( + RichText::new("(recipient receives amount minus fee)") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0) + .italics(), + ); + } + }); + } + } + + fn render_send_button(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + + let wallet_open = self + .selected_wallet + .as_ref() + .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); + + let dest_type = self.detect_address_type(&self.destination_address); + let has_destination = dest_type != AddressType::Unknown; + let has_amount = self.amount.as_ref().map(|a| a.value() > 0).unwrap_or(false); + let has_source = self.selected_source.is_some(); + + let is_sending = matches!(self.send_status, SendStatus::WaitingForResult(_)); + let can_send = wallet_open && !is_sending && has_destination && has_amount && has_source; + + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + action = AppAction::PopScreen; + } + + ui.add_space(10.0); + + let button_text = if is_sending { + "Sending..." + } else { + self.get_transaction_type_description() + }; + + let send_button = + egui::Button::new(RichText::new(button_text).color(Color32::WHITE).strong()) + .fill(if can_send { + DashColors::DASH_BLUE + } else { + DashColors::DASH_BLUE.gamma_multiply(0.5) + }) + .min_size(egui::vec2(160.0, 36.0)); + + if ui.add_enabled(can_send, send_button).clicked() { + match self.validate_and_send() { + Ok(send_action) => { + action = send_action; + } + Err(e) => { + self.display_message(&e, MessageType::Error); + } + } + } + }); + + action + } + + /// Render the advanced send UI with multiple inputs/outputs + fn render_advanced_send(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Wallet info + self.render_wallet_info(ui); + + // Wallet unlock if needed + let wallet_is_open = self + .selected_wallet + .as_ref() + .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); + + if !wallet_is_open && let Some(wallet) = &self.selected_wallet { + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + ui.add_space(10.0); + return AppAction::None; + } + } + + ui.add_space(10.0); + + // ========== SOURCE TYPE SELECTION ========== + ui.label( + RichText::new("Source Type") + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(16.0), + ); + ui.add_space(5.0); + ui.label( + RichText::new("Select whether to send from Core wallet or Platform addresses") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + ui.add_space(8.0); + + // Source type radio buttons + let platform_addresses = self.get_platform_addresses(); + let has_platform_addresses = !platform_addresses.is_empty(); + + ui.horizontal(|ui| { + if ui + .radio_value( + &mut self.advanced_source_type, + AdvancedSourceType::Core, + "Core Wallet", + ) + .changed() + { + // Clear inputs when switching to Core + self.core_inputs.clear(); + self.platform_inputs.clear(); + } + + ui.add_enabled_ui(has_platform_addresses, |ui| { + if ui + .radio_value( + &mut self.advanced_source_type, + AdvancedSourceType::Platform, + "Platform Addresses", + ) + .changed() + { + // Clear inputs when switching to Platform + self.core_inputs.clear(); + self.platform_inputs.clear(); + } + }); + + if !has_platform_addresses { + ui.label( + RichText::new("(no Platform addresses with balance)") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0) + .italics(), + ); + } + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // ========== INPUTS SECTION ========== + match self.advanced_source_type { + AdvancedSourceType::Core => { + self.render_core_inputs(ui); + } + AdvancedSourceType::Platform => { + self.render_platform_inputs(ui); + } + } + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // ========== OUTPUTS SECTION ========== + ui.label( + RichText::new("Outputs (Send To)") + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(16.0), + ); + ui.add_space(5.0); + + // Show hint based on source type + let hint = match self.advanced_source_type { + AdvancedSourceType::Core => "Add Core or Platform destination addresses", + AdvancedSourceType::Platform => "Add Platform or Core destination addresses", + }; + ui.label( + RichText::new(hint) + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + ui.add_space(8.0); + + self.render_advanced_outputs(ui); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // ========== FEE STRATEGY SECTION ========== + // Only show for platform source or platform outputs + let has_platform_output = self.advanced_outputs.iter().any(|o| { + let addr_type = Self::detect_address_type_static(&o.address); + addr_type == AddressType::Platform + }); + + if self.advanced_source_type == AdvancedSourceType::Platform || has_platform_output { + ui.label( + RichText::new("Fee Strategy") + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(14.0), + ); + ui.add_space(8.0); + + egui::ComboBox::from_id_salt("fee_strategy") + .selected_text(format!("{}", self.fee_strategy)) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.fee_strategy, + PlatformFeeStrategy::DeductFromFirstInput, + "Deduct from first input", + ); + ui.selectable_value( + &mut self.fee_strategy, + PlatformFeeStrategy::DeductFromLastInput, + "Deduct from last input", + ); + ui.selectable_value( + &mut self.fee_strategy, + PlatformFeeStrategy::ReduceFirstOutput, + "Reduce first output", + ); + ui.selectable_value( + &mut self.fee_strategy, + PlatformFeeStrategy::ReduceLastOutput, + "Reduce last output", + ); + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + } + + // ========== SEND BUTTON ========== + action |= self.render_advanced_send_button(ui); + + action + } + + /// Render Core address inputs for advanced mode + fn render_core_inputs(&mut self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let mut inputs_to_remove = Vec::new(); + + ui.label( + RichText::new("Core Address Inputs") + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(14.0), + ); + ui.add_space(5.0); + ui.label( + RichText::new("Select core addresses and amounts to send from each") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + ui.add_space(8.0); + + // Get available core addresses + let core_addresses = self.get_core_addresses(); + + // Collect already-used addresses + let used_addresses: std::collections::HashSet<_> = + self.core_inputs.iter().map(|i| i.address.clone()).collect(); + + let num_inputs = self.core_inputs.len(); + for idx in 0..num_inputs { + let input = &self.core_inputs[idx]; + let addr_str = input.address.to_string(); + + // Find balance for this address + let balance = core_addresses + .iter() + .find(|(a, _)| *a == input.address) + .map(|(_, b)| *b) + .unwrap_or(0); + + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(12, 10)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(&addr_str) + .color(DashColors::text_primary(dark_mode)) + .monospace(), + ); + ui.label( + RichText::new(format!("({})", Self::format_dash(balance))) + .color(DashColors::SUCCESS) + .size(12.0), + ); + + // Remove button + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui.button("x").clicked() { + inputs_to_remove.push(idx); + } + }, + ); + }); + + ui.horizontal(|ui| { + ui.label("Amount:"); + ui.add( + egui::TextEdit::singleline(&mut self.core_inputs[idx].amount) + .hint_text(RichText::new("0.0").color(Color32::GRAY)) + .desired_width(100.0), + ); + ui.label( + RichText::new("DASH") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + }); + }); + }); + ui.add_space(5.0); + } + + // Remove marked inputs + for idx in inputs_to_remove.into_iter().rev() { + self.core_inputs.remove(idx); + } + + // Add input dropdown - only show addresses not already added + let available_addresses: Vec<_> = core_addresses + .iter() + .filter(|(a, _)| !used_addresses.contains(a)) + .collect(); + + if !available_addresses.is_empty() { + egui::ComboBox::from_id_salt("add_core_input") + .selected_text("+ Add Core Address") + .show_ui(ui, |ui| { + for (address, balance) in available_addresses { + let addr_str = address.to_string(); + let display = format!( + "{}... ({})", + &addr_str[..12.min(addr_str.len())], + Self::format_dash(*balance) + ); + if ui.selectable_label(false, display).clicked() { + self.core_inputs.push(CoreAddressInput { + address: address.clone(), + amount: String::new(), + }); + } + } + }); + } else if self.core_inputs.is_empty() { + ui.label( + RichText::new("No core addresses with balance available") + .color(DashColors::text_secondary(dark_mode)) + .italics(), + ); + } + } + + /// Render Platform address inputs for advanced mode + fn render_platform_inputs(&mut self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let mut inputs_to_remove = Vec::new(); + + ui.label( + RichText::new("Platform Address Inputs") + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(14.0), + ); + ui.add_space(5.0); + ui.label( + RichText::new("Select platform addresses and amounts to send from each") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + ui.add_space(8.0); + + // Get available platform addresses + let platform_addresses = self.get_platform_addresses(); + let network = self.app_context.network; + + // Collect already-used addresses + let used_addresses: std::collections::HashSet<_> = self + .platform_inputs + .iter() + .map(|i| i.platform_address) + .collect(); + + let num_inputs = self.platform_inputs.len(); + for idx in 0..num_inputs { + let input = &self.platform_inputs[idx]; + let addr_str = input.platform_address.to_bech32m_string(network); + + // Find balance for this address + let balance = platform_addresses + .iter() + .find(|(_, pa, _)| *pa == input.platform_address) + .map(|(_, _, b)| *b) + .unwrap_or(0); + + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(12, 10)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(&addr_str) + .color(DashColors::text_primary(dark_mode)) + .monospace(), + ); + ui.label( + RichText::new(format!("({})", Self::format_credits(balance))) + .color(DashColors::SUCCESS) + .size(12.0), + ); + + // Remove button + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui.button("x").clicked() { + inputs_to_remove.push(idx); + } + }, + ); + }); + + ui.horizontal(|ui| { + ui.label("Amount:"); + ui.add( + egui::TextEdit::singleline(&mut self.platform_inputs[idx].amount) + .hint_text(RichText::new("0.0").color(Color32::GRAY)) + .desired_width(100.0), + ); + ui.label( + RichText::new("DASH") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + }); + }); + }); + ui.add_space(5.0); + } + + // Remove marked inputs + for idx in inputs_to_remove.into_iter().rev() { + self.platform_inputs.remove(idx); + } + + // Add input dropdown - only show addresses not already added + let available_addresses: Vec<_> = platform_addresses + .iter() + .filter(|(_, pa, _)| !used_addresses.contains(pa)) + .collect(); + + if !available_addresses.is_empty() { + egui::ComboBox::from_id_salt("add_platform_input") + .selected_text("+ Add Platform Address") + .show_ui(ui, |ui| { + for (core_addr, platform_addr, balance) in available_addresses { + let addr_str = platform_addr.to_bech32m_string(network); + let display = format!( + "{}... ({})", + &addr_str[..20.min(addr_str.len())], + Self::format_credits(*balance) + ); + if ui.selectable_label(false, display).clicked() { + self.platform_inputs.push(PlatformAddressInput { + platform_address: *platform_addr, + core_address: core_addr.clone(), + amount: String::new(), + }); + } + } + }); + } else if self.platform_inputs.is_empty() { + ui.label( + RichText::new("No platform addresses with balance available") + .color(DashColors::text_secondary(dark_mode)) + .italics(), + ); + } + } + + /// Render the outputs section for advanced mode + fn render_advanced_outputs(&mut self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let mut outputs_to_remove = Vec::new(); + let num_outputs = self.advanced_outputs.len(); + + // Pre-compute address types to avoid borrow issues + let addr_types: Vec = self + .advanced_outputs + .iter() + .map(|o| Self::detect_address_type_static(&o.address)) + .collect(); + + for (idx, &addr_type) in addr_types.iter().enumerate() { + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(12, 10)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label("To:"); + ui.add( + egui::TextEdit::singleline(&mut self.advanced_outputs[idx].address) + .hint_text("Enter address (X.../y.../dashevo1.../tdashevo1...)") + .desired_width(350.0), + ); + + // Show detected type + if addr_type != AddressType::Unknown { + let (type_text, type_color) = match addr_type { + AddressType::Core => ("Core", DashColors::DASH_BLUE), + AddressType::Platform => { + ("Platform", Color32::from_rgb(130, 80, 220)) + } + AddressType::Unknown => ("", Color32::GRAY), + }; + ui.label( + RichText::new(format!("({})", type_text)) + .color(type_color) + .size(12.0), + ); + } + + ui.label("Amount:"); + ui.add( + egui::TextEdit::singleline(&mut self.advanced_outputs[idx].amount) + .hint_text(RichText::new("0.0").color(Color32::GRAY)) + .desired_width(100.0), + ); + ui.label( + RichText::new("DASH") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + + // Remove button (only if more than one output) + if num_outputs > 1 { + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui.button("x").clicked() { + outputs_to_remove.push(idx); + } + }, + ); + } + }); + }); + }); + ui.add_space(5.0); + } + + // Remove marked outputs + for idx in outputs_to_remove.into_iter().rev() { + self.advanced_outputs.remove(idx); + } + + // Add output button + if ui.button("+ Add Output").clicked() { + self.advanced_outputs.push(AdvancedOutput { + address: String::new(), + amount: String::new(), + }); + } + } + + /// Static version of detect_address_type that doesn't need self + fn detect_address_type_static(address: &str) -> AddressType { + let trimmed = address.trim(); + if trimmed.is_empty() { + return AddressType::Unknown; + } + + // Check for Platform address (Bech32m format) + if trimmed.starts_with("dashevo1") || trimmed.starts_with("tdashevo1") { + return AddressType::Platform; + } + + // Try to parse as Core address + if trimmed.parse::>().is_ok() { + return AddressType::Core; + } + + AddressType::Unknown + } + + /// Render the send button for advanced mode + fn render_advanced_send_button(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + + let wallet_open = self + .selected_wallet + .as_ref() + .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); + + let is_sending = matches!(self.send_status, SendStatus::WaitingForResult(_)); + + // Check if we have valid inputs based on source type + let has_valid_inputs = match self.advanced_source_type { + AdvancedSourceType::Core => { + !self.core_inputs.is_empty() + && self.core_inputs.iter().any(|i| !i.amount.trim().is_empty()) + } + AdvancedSourceType::Platform => { + !self.platform_inputs.is_empty() + && self + .platform_inputs + .iter() + .any(|i| !i.amount.trim().is_empty()) + } + }; + + let has_outputs = self + .advanced_outputs + .iter() + .any(|o| !o.address.trim().is_empty() && !o.amount.trim().is_empty()); + + let can_send = wallet_open && !is_sending && has_valid_inputs && has_outputs; + + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + action = AppAction::PopScreen; + } + + ui.add_space(10.0); + + let button_text = if is_sending { "Sending..." } else { "Send" }; + + let send_button = + egui::Button::new(RichText::new(button_text).color(Color32::WHITE).strong()) + .fill(if can_send { + DashColors::DASH_BLUE + } else { + DashColors::DASH_BLUE.gamma_multiply(0.5) + }) + .min_size(egui::vec2(160.0, 36.0)); + + if ui.add_enabled(can_send, send_button).clicked() { + match self.validate_and_send_advanced() { + Ok(send_action) => { + action = send_action; + } + Err(e) => { + self.display_message(&e, MessageType::Error); + } + } + } + }); + + action + } + + /// Validate and execute advanced send + fn validate_and_send_advanced(&mut self) -> Result { + let wallet = self.selected_wallet.as_ref().ok_or("No wallet selected")?; + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + + if !wallet_guard.is_open() { + return Err("Wallet must be unlocked first".to_string()); + } + + let seed_hash = wallet_guard.seed_hash(); + let network = self.app_context.network; + + // Validate outputs + if self.advanced_outputs.is_empty() { + return Err("Please add at least one output".to_string()); + } + + // Determine output types + let output_types: Vec = self + .advanced_outputs + .iter() + .map(|o| Self::detect_address_type_static(&o.address)) + .collect(); + + let has_core_output = output_types.contains(&AddressType::Core); + let has_platform_output = output_types.contains(&AddressType::Platform); + + // Validate that we don't mix output types + if has_core_output && has_platform_output { + return Err( + "Cannot mix Core and Platform address outputs in the same transaction".to_string(), + ); + } + + drop(wallet_guard); + + // Route to appropriate handler based on source type and output type + match self.advanced_source_type { + AdvancedSourceType::Core => { + if self.core_inputs.is_empty() { + return Err("Please add at least one Core address input".to_string()); + } + + if has_core_output { + self.send_advanced_core_to_core() + } else if has_platform_output { + self.send_advanced_core_to_platform(seed_hash) + } else { + Err("Invalid output address".to_string()) + } + } + AdvancedSourceType::Platform => { + if self.platform_inputs.is_empty() { + return Err("Please add at least one Platform address input".to_string()); + } + + if has_platform_output { + self.send_advanced_platform_to_platform(seed_hash) + } else if has_core_output { + self.send_advanced_platform_to_core(seed_hash, network) + } else { + Err("Invalid output address".to_string()) + } + } + } + } + + /// Advanced Core to Core send (multiple outputs) + fn send_advanced_core_to_core(&mut self) -> Result { + let wallet = self + .selected_wallet + .as_ref() + .ok_or("No wallet selected")? + .clone(); + + // Parse inputs to get total available + let mut total_input = 0u64; + for input in &self.core_inputs { + let amount_duffs = Self::parse_amount_to_duffs(&input.amount)?; + total_input = total_input.saturating_add(amount_duffs); + } + + if total_input == 0 { + return Err("Please specify amounts for the input addresses".to_string()); + } + + // Parse outputs + let mut recipients = Vec::new(); + let mut total_output = 0u64; + + for output in &self.advanced_outputs { + let amount_duffs = Self::parse_amount_to_duffs(&output.amount)?; + if amount_duffs == 0 { + continue; + } + total_output = total_output.saturating_add(amount_duffs); + recipients.push(PaymentRecipient { + address: output.address.trim().to_string(), + amount_duffs, + }); + } + + if recipients.is_empty() { + return Err("No valid outputs specified".to_string()); + } + + // Check that inputs cover outputs (with some margin for fees) + if total_output > total_input { + return Err(format!( + "Insufficient input amount. Outputs total {} but inputs only {}", + Self::format_dash(total_output), + Self::format_dash(total_input) + )); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.send_status = SendStatus::WaitingForResult(now); + + Ok(AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::SendWalletPayment { + wallet, + request: WalletPaymentRequest { + recipients, + subtract_fee_from_amount: self.subtract_fee, + memo: None, + override_fee: None, + }, + }, + ))) + } + + /// Advanced Core to Platform send + fn send_advanced_core_to_platform( + &mut self, + seed_hash: WalletSeedHash, + ) -> Result { + // For now, only support single output for Core to Platform + // The SDK's FundPlatformAddressFromWalletUtxos only supports a single destination + if self.advanced_outputs.len() != 1 { + return Err( + "Core to Platform currently only supports a single destination".to_string(), + ); + } + + // Validate core inputs have enough + let mut total_input = 0u64; + for input in &self.core_inputs { + let amount_duffs = Self::parse_amount_to_duffs(&input.amount)?; + total_input = total_input.saturating_add(amount_duffs); + } + + let output = &self.advanced_outputs[0]; + let amount_duffs = Self::parse_amount_to_duffs(&output.amount)?; + if amount_duffs == 0 { + return Err("Amount must be greater than 0".to_string()); + } + + if amount_duffs > total_input { + return Err(format!( + "Insufficient input amount. Output is {} but inputs only {}", + Self::format_dash(amount_duffs), + Self::format_dash(total_input) + )); + } + + // Parse platform address + let address_str = output.address.trim(); + let destination = PlatformAddress::from_bech32m_string(address_str) + .map(|(addr, _)| addr) + .map_err(|e| format!("Invalid platform address: {}", e))?; + + // Determine fee strategy based on user selection + // DeductFromInput variants mean fees are paid from wallet (recipient gets exact amount) + // ReduceOutput variants mean fees are deducted from output (recipient gets less) + let fee_deduct_from_output = matches!( + self.fee_strategy, + PlatformFeeStrategy::ReduceFirstOutput | PlatformFeeStrategy::ReduceLastOutput + ); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.send_status = SendStatus::WaitingForResult(now); + + Ok(AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::FundPlatformAddressFromWalletUtxos { + seed_hash, + amount: amount_duffs, + destination, + fee_deduct_from_output, + }, + ))) + } + + /// Advanced Platform to Platform send + fn send_advanced_platform_to_platform( + &mut self, + seed_hash: WalletSeedHash, + ) -> Result { + // Build inputs map from platform_inputs + let mut inputs: BTreeMap = BTreeMap::new(); + for input in &self.platform_inputs { + let credits = Self::parse_amount_to_credits(&input.amount)?; + if credits > 0 { + *inputs.entry(input.platform_address).or_insert(0) += credits; + } + } + + if inputs.is_empty() { + return Err("No valid Platform inputs specified".to_string()); + } + + // Build outputs map + let mut outputs: BTreeMap = BTreeMap::new(); + for output in &self.advanced_outputs { + let destination = PlatformAddress::from_bech32m_string(output.address.trim()) + .map(|(addr, _)| addr) + .map_err(|e| format!("Invalid platform address: {}", e))?; + let credits = Self::parse_amount_to_credits(&output.amount)?; + if credits > 0 { + *outputs.entry(destination).or_insert(0) += credits; + } + } + + if outputs.is_empty() { + return Err("No valid Platform outputs specified".to_string()); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.send_status = SendStatus::WaitingForResult(now); + + Ok(AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::TransferPlatformCredits { + seed_hash, + inputs, + outputs, + }, + ))) + } + + /// Advanced Platform to Core send (withdrawal) + fn send_advanced_platform_to_core( + &mut self, + seed_hash: WalletSeedHash, + network: dash_sdk::dpp::dashcore::Network, + ) -> Result { + // For withdrawal, we only support a single Core output + if self.advanced_outputs.len() != 1 { + return Err("Withdrawal currently only supports a single Core destination".to_string()); + } + + // Build inputs map from platform_inputs + let mut inputs: BTreeMap = BTreeMap::new(); + for input in &self.platform_inputs { + let credits = Self::parse_amount_to_credits(&input.amount)?; + if credits > 0 { + *inputs.entry(input.platform_address).or_insert(0) += credits; + } + } + + if inputs.is_empty() { + return Err("No valid Platform inputs specified".to_string()); + } + + // Parse Core destination + let output = &self.advanced_outputs[0]; + let address_str = output.address.trim(); + let dest_address: Address = address_str + .parse() + .map_err(|e| format!("Invalid Core address: {}", e))?; + let dest_address = dest_address + .require_network(network) + .map_err(|e| format!("Address network mismatch: {}", e))?; + + let output_script = CoreScript::new(dest_address.script_pubkey()); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + self.send_status = SendStatus::WaitingForResult(now); + + Ok(AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::WithdrawFromPlatformAddress { + seed_hash, + inputs, + output_script, + core_fee_per_byte: 1, + }, + ))) + } +} + +impl ScreenLike for WalletSendScreen { + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![("Wallets", AppAction::PopScreen), ("Send", AppAction::None)], + vec![], + ); + + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenWalletsBalances, + ); + + action |= island_central_panel(ctx, |ui| { + let mut inner_action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Handle different states - clone to avoid borrow issues + let current_status = self.send_status.clone(); + match current_status { + SendStatus::Complete(message) => { + // Show custom success screen + ui.vertical_centered(|ui| { + ui.add_space(100.0); + ui.heading("🎉"); + ui.heading(&message); + ui.add_space(20.0); + + if ui.button("Send Another").clicked() { + self.reset_form(); + } + ui.add_space(8.0); + if ui.button("Back to Wallet").clicked() { + inner_action = AppAction::PopScreenAndRefresh; + } + + ui.add_space(100.0); + }); + + return inner_action; + } + SendStatus::WaitingForResult(start_time) => { + // Show sending spinner + ui.vertical_centered(|ui| { + ui.add_space(100.0); + ui.add(egui::Spinner::new().size(40.0)); + ui.add_space(20.0); + ui.heading("Sending..."); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + let elapsed_seconds = now.saturating_sub(start_time); + + let display_time = if elapsed_seconds < 60 { + format!( + "{} second{}", + elapsed_seconds, + if elapsed_seconds == 1 { "" } else { "s" } + ) + } else { + let minutes = elapsed_seconds / 60; + let seconds = elapsed_seconds % 60; + format!( + "{} minute{} {} second{}", + minutes, + if minutes == 1 { "" } else { "s" }, + seconds, + if seconds == 1 { "" } else { "s" } + ) + }; + + ui.add_space(10.0); + ui.label( + RichText::new(format!("Time elapsed: {}", display_time)) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(100.0); + }); + return inner_action; + } + SendStatus::Error(error_msg) => { + // Show error at the top + let mut dismiss = false; + ui.horizontal(|ui| { + Frame::new() + .fill(Color32::from_rgb(255, 100, 100).gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, Color32::from_rgb(255, 100, 100))) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(&error_msg) + .color(Color32::from_rgb(255, 100, 100)), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + dismiss = true; + } + }); + }); + }); + if dismiss { + self.send_status = SendStatus::NotStarted; + } + ui.add_space(10.0); + } + SendStatus::NotStarted => { + // Normal flow - continue to render the form + } + } + + egui::ScrollArea::vertical() + .auto_shrink([true; 2]) + .show(ui, |ui| { + // Heading with advanced options checkbox + ui.horizontal(|ui| { + ui.heading( + RichText::new("Send Dash") + .color(DashColors::text_primary(dark_mode)) + .size(24.0), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); + + ui.add_space(15.0); + + if self.show_advanced_options { + inner_action |= self.render_advanced_send(ui); + } else { + inner_action |= self.render_unified_send(ui); + } + }); + + inner_action + }); + + // Show wallet unlock popup if open + if self.wallet_unlock_popup.is_open() + && let Some(wallet) = &self.selected_wallet + { + let result = self + .wallet_unlock_popup + .show(ctx, wallet, &self.app_context); + if result == WalletUnlockResult::Unlocked { + // Wallet unlocked successfully + } + } + + action + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + match message_type { + MessageType::Error => { + self.send_status = SendStatus::Error(message.to_string()); + } + MessageType::Success => { + self.send_status = SendStatus::Complete(message.to_string()); + } + MessageType::Info => { + // Info messages don't change status + } + } + } + + fn display_task_result( + &mut self, + backend_task_success_result: crate::backend_task::BackendTaskSuccessResult, + ) { + match backend_task_success_result { + crate::backend_task::BackendTaskSuccessResult::WalletPayment { + txid: _, + recipients, + total_amount, + } => { + let msg = if recipients.len() == 1 { + let (address, amount) = &recipients[0]; + format!("Sent {} to {}", Self::format_dash(*amount), address,) + } else { + format!( + "Sent {} to {} recipients", + Self::format_dash(total_amount), + recipients.len(), + ) + }; + self.send_status = SendStatus::Complete(msg); + } + crate::backend_task::BackendTaskSuccessResult::TransferredCredits(fee_result) => { + let fee_info = format!( + "\n\nFee: Estimated {} • Actual {}", + format_credits_as_dash(fee_result.estimated_fee), + format_credits_as_dash(fee_result.actual_fee) + ); + self.send_status = + SendStatus::Complete(format!("Credits transferred successfully!{}", fee_info)); + } + crate::backend_task::BackendTaskSuccessResult::PlatformAddressFunded { .. } => { + self.send_status = + SendStatus::Complete("Platform address funded successfully!".to_string()); + } + crate::backend_task::BackendTaskSuccessResult::PlatformAddressWithdrawal { .. } => { + self.send_status = + SendStatus::Complete("Withdrawal initiated successfully!\n\nNote: It may take a few minutes for funds to appear on the Core chain.".to_string()); + } + crate::backend_task::BackendTaskSuccessResult::PlatformCreditsTransferred { + .. + } => { + self.send_status = + SendStatus::Complete("Platform credits transferred successfully!".to_string()); + } + _ => { + // Ignore other results + } + } + } + + fn refresh_on_arrival(&mut self) {} + + fn refresh(&mut self) {} +} diff --git a/src/ui/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs new file mode 100644 index 000000000..d00931560 --- /dev/null +++ b/src/ui/wallets/single_key_send_screen.rs @@ -0,0 +1,1042 @@ +//! Single Key Wallet Send Screen + +use crate::app::AppAction; +use crate::backend_task::BackendTask; +use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; +use crate::context::AppContext; +use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; +use crate::model::wallet::single_key::SingleKeyWallet; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use chrono::{DateTime, Utc}; +use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeLevel; +use eframe::egui::{self, Context, RichText, Ui}; +use egui::{Color32, Frame, Margin}; +use std::sync::{Arc, RwLock}; + +/// A single recipient entry with address and amount +#[derive(Debug, Clone)] +pub struct SendRecipient { + pub id: usize, + pub address: String, + pub amount: String, + pub error: Option, +} + +impl SendRecipient { + pub fn new(id: usize) -> Self { + Self { + id, + address: String::new(), + amount: String::new(), + error: None, + } + } +} + +/// State for the fee confirmation dialog shown when min relay fee is higher than estimated +#[derive(Debug, Clone, Default)] +struct FeeConfirmationDialog { + is_open: bool, + estimated_fee: u64, + required_fee: u64, + pending_request: Option, +} + +pub struct SingleKeyWalletSendScreen { + pub app_context: Arc, + pub selected_wallet: Option>>, + + // Recipients (support multiple) + recipients: Vec, + next_recipient_id: usize, + + // Common options + subtract_fee: bool, + memo: String, + + // State + sending: bool, + message: Option<(String, MessageType, DateTime)>, + + // Wallet unlock + wallet_password: String, + show_password: bool, + error_message: Option, + + // Fee confirmation dialog + fee_dialog: FeeConfirmationDialog, + + // Advanced options toggle + show_advanced_options: bool, +} + +impl SingleKeyWalletSendScreen { + pub fn new(app_context: &Arc, wallet: Arc>) -> Self { + Self { + app_context: app_context.clone(), + selected_wallet: Some(wallet), + recipients: vec![SendRecipient::new(0)], + next_recipient_id: 1, + subtract_fee: false, + memo: String::new(), + sending: false, + message: None, + wallet_password: String::new(), + show_password: false, + error_message: None, + fee_dialog: FeeConfirmationDialog::default(), + show_advanced_options: false, + } + } + + fn add_recipient(&mut self) { + let id = self.next_recipient_id; + self.next_recipient_id += 1; + self.recipients.push(SendRecipient::new(id)); + } + + fn remove_recipient(&mut self, id: usize) { + if self.recipients.len() > 1 { + self.recipients.retain(|r| r.id != id); + } + } + + fn format_dash(amount_duffs: u64) -> String { + Amount::dash_from_duffs(amount_duffs).to_string() + } + + fn parse_amount_to_duffs(input: &str) -> Result { + let amount = Amount::parse(input, DASH_DECIMAL_PLACES)?.with_unit_name("DASH"); + amount.dash_to_duffs() + } + + /// Estimate transaction size for P2PKH transactions + fn estimate_p2pkh_tx_size(inputs: usize, outputs: usize) -> usize { + fn varint_size(value: usize) -> usize { + match value { + 0..=0xfc => 1, + 0xfd..=0xffff => 3, + 0x1_0000..=0xffff_ffff => 5, + _ => 9, + } + } + let mut size = 8; // version/type/lock_time + size += varint_size(inputs); + size += varint_size(outputs); + size += inputs * 148; // P2PKH input size + size += outputs * 34; // P2PKH output size + size + } + + /// Calculate estimated fee based on UTXO selection for the send amount + fn estimate_fee(&self) -> Option<(u64, usize, usize)> { + let wallet = self.selected_wallet.as_ref()?; + let wallet_guard = wallet.read().ok()?; + + if wallet_guard.utxos.is_empty() { + return None; + } + + // Calculate total amount to send + let total_output: u64 = self + .recipients + .iter() + .filter_map(|r| Self::parse_amount_to_duffs(&r.amount).ok()) + .sum(); + + if total_output == 0 { + // No valid amounts entered yet, show estimate for minimum tx + let output_count = self.recipients.len().max(1) + 1; + let estimated_size = Self::estimate_p2pkh_tx_size(1, output_count); + let fee = FeeLevel::Normal.fee_rate().calculate_fee(estimated_size); + return Some((fee, 1, estimated_size)); + } + + // Sort UTXOs by value descending to estimate how many we'd need + let mut utxo_values: Vec = wallet_guard.utxos.values().map(|tx| tx.value).collect(); + utxo_values.sort_by(|a, b| b.cmp(a)); + + let output_count = self.recipients.len() + 1; // +1 for change + + // Select UTXOs until we have enough (simulating the backend logic) + let mut selected_count = 0; + let mut selected_total: u64 = 0; + + for value in utxo_values { + selected_count += 1; + selected_total += value; + + // Recalculate fee with current input count + let current_size = Self::estimate_p2pkh_tx_size(selected_count, output_count); + let current_fee = FeeLevel::Normal.fee_rate().calculate_fee(current_size); + + if selected_total >= total_output + current_fee { + return Some((current_fee, selected_count, current_size)); + } + } + + // Not enough funds - show what we'd need with all UTXOs + let estimated_size = Self::estimate_p2pkh_tx_size(selected_count, output_count); + let fee = FeeLevel::Normal.fee_rate().calculate_fee(estimated_size); + Some((fee, selected_count, estimated_size)) + } + + /// Parse the required fee from a "min relay fee not met" error message + fn parse_min_relay_fee_error(error: &str) -> Option { + // Error format: "min relay fee not met, X < Y" + if error.contains("min relay fee not met") || error.contains("min relay fee") { + // Try to find the pattern "X < Y" and extract Y + if let Some(pos) = error.find('<') { + let after_lt = &error[pos + 1..]; + // Extract the number after '<' + let num_str: String = after_lt + .trim() + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + if let Ok(required_fee) = num_str.parse::() { + return Some(required_fee); + } + } + } + None + } + + fn validate_and_send(&mut self) -> Result { + let wallet = self + .selected_wallet + .as_ref() + .ok_or_else(|| "No wallet selected".to_string())?; + + // Check wallet is open + { + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + if !wallet_guard.is_open() { + return Err("Wallet must be unlocked first".to_string()); + } + } + + // Validate recipients + if self.recipients.is_empty() { + return Err("At least one recipient is required".to_string()); + } + + // Validate all recipients and build PaymentRecipient list + let mut payment_recipients: Vec = + Vec::with_capacity(self.recipients.len()); + let mut total_amount: u64 = 0; + + for (index, recipient) in self.recipients.iter().enumerate() { + if recipient.address.trim().is_empty() { + return Err(format!("Recipient {} has an empty address", index + 1)); + } + let amount = Self::parse_amount_to_duffs(&recipient.amount) + .map_err(|e| format!("Recipient {}: {}", index + 1, e))?; + if amount == 0 { + return Err(format!("Recipient {} has zero amount", index + 1)); + } + total_amount = total_amount.saturating_add(amount); + + payment_recipients.push(PaymentRecipient { + address: recipient.address.trim().to_string(), + amount_duffs: amount, + }); + } + + // Check balance + { + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + if total_amount > wallet_guard.total_balance { + return Err(format!( + "Insufficient balance. Need {} but only have {}", + Self::format_dash(total_amount), + Self::format_dash(wallet_guard.total_balance) + )); + } + } + + let memo = self.memo.trim(); + let request = WalletPaymentRequest { + recipients: payment_recipients, + subtract_fee_from_amount: self.subtract_fee, + memo: if memo.is_empty() { + None + } else { + Some(memo.to_string()) + }, + override_fee: None, + }; + + // Store the request for potential retry if min relay fee is too low + self.fee_dialog.pending_request = Some(request.clone()); + // Store estimated fee for display in dialog + if let Some((estimated_fee, _, _)) = self.estimate_fee() { + self.fee_dialog.estimated_fee = estimated_fee; + } + + self.sending = true; + Ok(AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::SendSingleKeyWalletPayment { + wallet: wallet.clone(), + request, + }, + ))) + } + + fn render_recipients(&mut self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + + ui.add_space(15.0); + + ui.horizontal(|ui| { + ui.label( + RichText::new("Recipients") + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(16.0), + ); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .button(RichText::new("+ Add Recipient").color(DashColors::DASH_BLUE)) + .clicked() + { + self.add_recipient(); + } + }); + }); + + ui.add_space(10.0); + + // Collect IDs to remove after the loop + let mut to_remove: Option = None; + let recipient_count = self.recipients.len(); + let show_remove = recipient_count > 1; + + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(12, 10)) + .corner_radius(5.0) + .show(ui, |ui| { + for i in 0..recipient_count { + let recipient_id = self.recipients[i].id; + + // Address field + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Address {}:", i + 1)) + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.add_space(5.0); + ui.add( + egui::TextEdit::singleline(&mut self.recipients[i].address) + .hint_text( + RichText::new("Enter Dash address (e.g., y...)") + .color(Color32::GRAY), + ) + .desired_width(600.0), + ); + + ui.add_space(5.0); + + // Amount field + ui.label( + RichText::new(format!("Amount {} (DASH):", i + 1)) + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.add_space(5.0); + ui.add( + egui::TextEdit::singleline(&mut self.recipients[i].amount) + .hint_text(RichText::new("0.01").color(Color32::GRAY)) + .desired_width(150.0), + ); + + ui.add_space(5.0); + + if show_remove { + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui + .small_button( + RichText::new("Remove").color(DashColors::ERROR), + ) + .clicked() + { + to_remove = Some(recipient_id); + } + }, + ); + } + }); + + if let Some(error) = &self.recipients[i].error { + ui.add_space(5.0); + ui.label(RichText::new(error).color(DashColors::ERROR).size(12.0)); + } + } + }); + + // Remove recipient if requested + if let Some(id) = to_remove { + self.remove_recipient(id); + } + } + + fn render_options(&mut self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + + ui.add_space(15.0); + + ui.label( + RichText::new("Options") + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(16.0), + ); + + ui.add_space(10.0); + + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(12, 10)) + .corner_radius(5.0) + .show(ui, |ui| { + // Memo field + ui.horizontal(|ui| { + ui.label( + RichText::new("Memo (optional):") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.add_space(5.0); + ui.add( + egui::TextEdit::singleline(&mut self.memo) + .hint_text("Add a note...") + .desired_width(300.0), + ); + }); + + ui.add_space(10.0); + + // Subtract fee checkbox + ui.checkbox( + &mut self.subtract_fee, + RichText::new("Subtract fee from amount") + .color(DashColors::text_primary(dark_mode)), + ); + + // Fee estimation display + if let Some((estimated_fee, utxo_count, tx_size)) = self.estimate_fee() { + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format!( + "{} ({:.8} DASH)", + estimated_fee, + estimated_fee as f64 * 1e-8 + )) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + + ui.horizontal(|ui| { + ui.label( + RichText::new("Transaction details:") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + ui.label( + RichText::new(format!("{} inputs, ~{} bytes", utxo_count, tx_size)) + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + }); + + if utxo_count > 100 { + ui.add_space(5.0); + ui.label( + RichText::new( + "Note: Large number of inputs may require higher network fee", + ) + .color(DashColors::WARNING) + .size(12.0), + ); + } + } + }); + } + + /// Render the simple (beginner) send UI - single recipient, minimal options + fn render_simple_send(&mut self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + + ui.add_space(15.0); + + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(12, 10)) + .corner_radius(5.0) + .show(ui, |ui| { + // Address field + ui.horizontal(|ui| { + ui.label( + RichText::new("To:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.add_space(5.0); + ui.add( + egui::TextEdit::singleline(&mut self.recipients[0].address) + .hint_text(RichText::new("Enter Dash address").color(Color32::GRAY)) + .desired_width(500.0), + ); + }); + + ui.add_space(10.0); + + // Amount field + ui.horizontal(|ui| { + ui.label( + RichText::new("Amount:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.add_space(5.0); + ui.add( + egui::TextEdit::singleline(&mut self.recipients[0].amount) + .hint_text(RichText::new("0.00").color(Color32::GRAY)) + .desired_width(150.0), + ); + ui.label( + RichText::new("DASH") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + }); + + // Simple fee display + if let Some((estimated_fee, _, _)) = self.estimate_fee() { + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.label( + RichText::new("Fee:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(format!("~{:.8} DASH", estimated_fee as f64 * 1e-8)) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + } + }); + } + + fn render_fee_confirmation_dialog(&mut self, ctx: &Context) -> AppAction { + let mut action = AppAction::None; + + if !self.fee_dialog.is_open { + return action; + } + + let dark_mode = ctx.style().visuals.dark_mode; + + egui::Window::new("Fee Confirmation Required") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.add_space(10.0); + + ui.label( + RichText::new("The network requires a higher fee than estimated.") + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + + ui.add_space(15.0); + + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(12, 10)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Estimated fee:") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(format!( + "{} duffs ({:.8} DASH)", + self.fee_dialog.estimated_fee, + self.fee_dialog.estimated_fee as f64 * 1e-8 + )) + .color(DashColors::text_primary(dark_mode)), + ); + }); + + ui.horizontal(|ui| { + ui.label( + RichText::new("Required fee:") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(format!( + "{} duffs ({:.8} DASH)", + self.fee_dialog.required_fee, + self.fee_dialog.required_fee as f64 * 1e-8 + )) + .color(DashColors::WARNING) + .strong(), + ); + }); + + let fee_diff = self + .fee_dialog + .required_fee + .saturating_sub(self.fee_dialog.estimated_fee); + ui.horizontal(|ui| { + ui.label( + RichText::new("Additional cost:") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.label( + RichText::new(format!( + "+{} duffs ({:.8} DASH)", + fee_diff, + fee_diff as f64 * 1e-8 + )) + .color(DashColors::text_primary(dark_mode)), + ); + }); + }); + + ui.add_space(15.0); + + ui.label( + RichText::new("Would you like to proceed with the higher fee?") + .color(DashColors::text_primary(dark_mode)), + ); + + ui.add_space(15.0); + + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { + self.fee_dialog.is_open = false; + self.fee_dialog.pending_request = None; + self.sending = false; + } + + ui.add_space(20.0); + + let confirm_button = egui::Button::new( + RichText::new("Confirm & Send") + .color(Color32::WHITE) + .strong(), + ) + .fill(DashColors::DASH_BLUE); + + if ui.add(confirm_button).clicked() { + if let Some(mut request) = self.fee_dialog.pending_request.take() { + // Update the request to use the higher fee + request.override_fee = Some(self.fee_dialog.required_fee); + + if let Some(wallet) = &self.selected_wallet { + action = AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::SendSingleKeyWalletPayment { + wallet: wallet.clone(), + request, + }, + )); + } + } + self.fee_dialog.is_open = false; + } + }); + + ui.add_space(10.0); + }); + + action + } + + fn render_wallet_info(&self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + + if let Some(wallet_arc) = &self.selected_wallet + && let Ok(wallet) = wallet_arc.read() + { + let alias = wallet + .alias + .clone() + .unwrap_or_else(|| "Unnamed Wallet".to_string()); + let balance = wallet.total_balance; + + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(12, 10)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("Sending from:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(&alias) + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(14.0), + ); + }); + + ui.horizontal(|ui| { + ui.label( + RichText::new("Address:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(wallet.address.to_string()) + .color(DashColors::text_primary(dark_mode)) + .size(14.0), + ); + }); + + ui.horizontal(|ui| { + ui.label( + RichText::new("Available balance:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.label( + RichText::new(Self::format_dash(balance)) + .color(DashColors::SUCCESS) + .strong() + .size(14.0), + ); + }); + }); + } + } + + fn render_wallet_unlock(&mut self, ui: &mut Ui) -> AppAction { + let dark_mode = ui.ctx().style().visuals.dark_mode; + + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(12, 10)) + .corner_radius(5.0) + .show(ui, |ui| { + ui.label( + RichText::new("Unlock Wallet") + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(14.0), + ); + + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.label( + RichText::new("Password:") + .color(DashColors::text_secondary(dark_mode)) + .size(14.0), + ); + ui.add_space(5.0); + + let password_field = if self.show_password { + egui::TextEdit::singleline(&mut self.wallet_password) + } else { + egui::TextEdit::singleline(&mut self.wallet_password).password(true) + }; + ui.add(password_field.desired_width(200.0)); + + ui.checkbox(&mut self.show_password, "Show"); + + ui.add_space(10.0); + + if ui.button("Unlock").clicked() + && let Some(wallet) = &self.selected_wallet + { + match wallet.write() { + Ok(mut wallet_guard) => { + match wallet_guard.open(&self.wallet_password) { + Ok(_) => { + self.error_message = None; + self.wallet_password.clear(); + } + Err(e) => { + self.error_message = + Some(format!("Failed to unlock: {}", e)); + } + } + } + Err(_) => { + self.error_message = + Some("Wallet lock error, please try again".to_string()); + } + } + } + }); + + if let Some(error) = &self.error_message { + ui.add_space(5.0); + ui.label(RichText::new(error).color(DashColors::ERROR).size(12.0)); + } + }); + + AppAction::None + } + + fn render_send_button(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + + ui.add_space(20.0); + + ui.horizontal(|ui| { + // Back button + if ui.button("Cancel").clicked() { + action = AppAction::PopScreen; + } + + ui.add_space(20.0); + + // Send button + let wallet_is_open = self + .selected_wallet + .as_ref() + .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); + + let send_button = egui::Button::new( + RichText::new(if self.sending { "Sending..." } else { "Send" }) + .color(Color32::WHITE) + .strong(), + ) + .fill(if wallet_is_open && !self.sending { + DashColors::DASH_BLUE + } else { + DashColors::DASH_BLUE.gamma_multiply(0.5) + }) + .min_size(egui::vec2(120.0, 36.0)); + + let button_enabled = wallet_is_open && !self.sending; + if ui.add_enabled(button_enabled, send_button).clicked() { + match self.validate_and_send() { + Ok(send_action) => { + action = send_action; + } + Err(e) => { + self.display_message(&e, MessageType::Error); + } + } + } + }); + + action + } + + fn dismiss_message(&mut self) { + self.message = None; + } +} + +impl ScreenLike for SingleKeyWalletSendScreen { + fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![("Wallets", AppAction::PopScreen), ("Send", AppAction::None)], + vec![], + ); + + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenWalletsBalances, + ); + + action |= island_central_panel(ctx, |ui| { + let mut inner_action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Display messages at the top + let mut should_dismiss = false; + if let Some((message, message_type, _)) = &self.message { + let message = message.clone(); + let message_color = match message_type { + MessageType::Error => Color32::from_rgb(255, 100, 100), + MessageType::Info => DashColors::text_primary(dark_mode), + MessageType::Success => Color32::DARK_GREEN, + }; + + ui.horizontal(|ui| { + Frame::new() + .fill(message_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, message_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(&message).color(message_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + should_dismiss = true; + } + }); + }); + }); + ui.add_space(10.0); + } + if should_dismiss { + self.dismiss_message(); + } + + egui::ScrollArea::vertical() + .auto_shrink([true; 2]) + .show(ui, |ui| { + // Heading with Advanced Options checkbox + ui.horizontal(|ui| { + ui.heading( + RichText::new("Send Dash") + .color(DashColors::text_primary(dark_mode)) + .size(24.0), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); + + ui.add_space(15.0); + + // Wallet info + self.render_wallet_info(ui); + + ui.add_space(10.0); + + // Wallet unlock if needed + let wallet_is_open = self + .selected_wallet + .as_ref() + .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); + + if !wallet_is_open { + inner_action |= self.render_wallet_unlock(ui); + ui.add_space(10.0); + } + + if self.show_advanced_options { + // Advanced mode: multiple recipients, memo, subtract fee, detailed info + self.render_recipients(ui); + self.render_options(ui); + } else { + // Simple mode: single recipient, minimal UI + self.render_simple_send(ui); + } + + // Send button + inner_action |= self.render_send_button(ui); + }); + + inner_action + }); + + // Render fee confirmation dialog (modal, on top of everything) + action |= self.render_fee_confirmation_dialog(ctx); + + action + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + // Check for success messages to reset sending state + if message.contains("Sent") || message.contains("TxID") { + self.sending = false; + self.fee_dialog.pending_request = None; + } + + // Check for min relay fee error and show confirmation dialog + if message_type == MessageType::Error + && let Some(required_fee) = Self::parse_min_relay_fee_error(message) + { + // Show the fee confirmation dialog instead of the error message + self.fee_dialog.required_fee = required_fee; + self.fee_dialog.is_open = true; + // Keep sending state true until user confirms or cancels + return; + } + + self.message = Some((message.to_string(), message_type, Utc::now())); + } + + fn display_task_result( + &mut self, + backend_task_success_result: crate::backend_task::BackendTaskSuccessResult, + ) { + self.sending = false; + + match backend_task_success_result { + crate::backend_task::BackendTaskSuccessResult::WalletPayment { + txid, + recipients, + total_amount, + } => { + let msg = if recipients.len() == 1 { + let (address, amount) = &recipients[0]; + format!( + "Sent {} to {}\nTxID: {}", + Self::format_dash(*amount), + address, + txid + ) + } else { + let recipient_list: String = recipients + .iter() + .map(|(addr, amt)| format!(" {} to {}", Self::format_dash(*amt), addr)) + .collect::>() + .join("\n"); + format!( + "Sent {} total to {} recipients:\n{}\nTxID: {}", + Self::format_dash(total_amount), + recipients.len(), + recipient_list, + txid + ) + }; + self.display_message(&msg, MessageType::Success); + + // Clear the form after successful send + self.recipients = vec![SendRecipient::new(0)]; + self.next_recipient_id = 1; + self.memo.clear(); + self.subtract_fee = false; + } + _ => { + // Ignore other results + } + } + } + + fn refresh_on_arrival(&mut self) {} + + fn refresh(&mut self) {} +} diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 75fd9ade8..114926dce 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1,26 +1,40 @@ use crate::app::{AppAction, DesiredAppAction}; use crate::backend_task::BackendTask; -use crate::backend_task::core::CoreTask; +use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; +use crate::backend_task::wallet::WalletTask; use crate::context::AppContext; -use crate::model::wallet::{Wallet, WalletSeedHash}; -use crate::ui::components::component_trait::Component; +use crate::model::amount::Amount; +use crate::model::wallet::{ + DerivationPathHelpers, DerivationPathReference, Wallet, WalletSeedHash, WalletTransaction, +}; +use crate::spv::CoreBackendMode; +use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; -use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::components::wallet_unlock_popup::{WalletUnlockPopup, WalletUnlockResult}; +use crate::ui::helpers::copy_text_to_clipboard; +use crate::ui::identities::funding_common::generate_qr_code_image; use crate::ui::theme::DashColors; +use crate::ui::wallets::account_summary::{ + AccountCategory, AccountSummary, collect_account_summaries, +}; use crate::ui::{MessageType, RootScreenType, ScreenLike, ScreenType}; use chrono::{DateTime, Utc}; -use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; +use dash_sdk::dashcore_rpc::dashcore::Address; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; use eframe::egui::{self, ComboBox, Context, Ui}; -use egui::{Color32, Frame, Margin, RichText}; +use eframe::epaint::TextureHandle; +use egui::load::SizedTexture; +use egui::{Color32, Frame, Margin, RichText, TextureOptions}; use egui_extras::{Column, TableBuilder}; -use std::collections::HashSet; -use std::sync::atomic::Ordering; use std::sync::{Arc, RwLock}; +use crate::model::wallet::single_key::SingleKeyWallet; + #[derive(Clone, Copy, PartialEq, Eq)] enum SortColumn { Address, @@ -38,142 +52,310 @@ enum SortOrder { Descending, } +/// Refresh mode for dev mode dropdown - controls what gets refreshed +#[derive(Clone, Copy, PartialEq, Eq, Default)] +enum RefreshMode { + /// Current behavior: Core wallet + Platform (auto decides full vs terminal) + #[default] + All, + /// Only refresh Core wallet balances + CoreOnly, + /// Only Platform sync - force full sync + PlatformFull, + /// Only Platform sync - terminal only + PlatformTerminal, + /// Core wallet + Platform full sync + CoreAndPlatformFull, + /// Core wallet + Platform terminal sync + CoreAndPlatformTerminal, +} + +impl RefreshMode { + fn label(&self) -> &'static str { + match self { + RefreshMode::All => "All (Auto)", + RefreshMode::CoreOnly => "Core Only", + RefreshMode::PlatformFull => "Platform (Full)", + RefreshMode::PlatformTerminal => "Platform (Terminal)", + RefreshMode::CoreAndPlatformFull => "Core + Platform (Full)", + RefreshMode::CoreAndPlatformTerminal => "Core + Platform (Terminal)", + } + } + + fn all_modes() -> &'static [RefreshMode] { + &[ + RefreshMode::All, + RefreshMode::CoreOnly, + RefreshMode::PlatformFull, + RefreshMode::PlatformTerminal, + RefreshMode::CoreAndPlatformFull, + RefreshMode::CoreAndPlatformTerminal, + ] + } +} + pub struct WalletsBalancesScreen { selected_wallet: Option>>, + selected_single_key_wallet: Option>>, pub(crate) app_context: Arc, message: Option<(String, MessageType, DateTime)>, sort_column: SortColumn, sort_order: SortOrder, - selected_filters: HashSet, refreshing: bool, show_rename_dialog: bool, rename_input: String, - wallet_password: String, - show_password: bool, - error_message: Option, + wallet_unlock_popup: WalletUnlockPopup, + show_sk_unlock_dialog: bool, + sk_wallet_password: String, + sk_show_password: bool, + sk_error_message: Option, remove_wallet_dialog: Option, pending_wallet_removal: Option, pending_wallet_removal_alias: Option, -} - -pub trait DerivationPathHelpers { - fn is_bip44(&self, network: Network) -> bool; - fn is_bip44_external(&self, network: Network) -> bool; - fn is_bip44_change(&self, network: Network) -> bool; - fn is_asset_lock_funding(&self, network: Network) -> bool; -} -impl DerivationPathHelpers for DerivationPath { - fn is_bip44(&self, network: Network) -> bool { - // BIP44 external paths have the form m/44'/coin_type'/account'/0/... - let coin_type = match network { - Network::Dash => 5, - _ => 1, - }; - let components = self.as_ref(); - components.len() == 5 - && components[0] == ChildNumber::Hardened { index: 44 } - && components[1] == ChildNumber::Hardened { index: coin_type } - } - - fn is_bip44_external(&self, network: Network) -> bool { - // BIP44 external paths have the form m/44'/coin_type'/account'/0/... - let coin_type = match network { - Network::Dash => 5, - _ => 1, - }; - let components = self.as_ref(); - components.len() == 5 - && components[0] == ChildNumber::Hardened { index: 44 } - && components[1] == ChildNumber::Hardened { index: coin_type } - && components[3] == ChildNumber::Normal { index: 0 } - } - - fn is_bip44_change(&self, network: Network) -> bool { - // BIP44 change paths have the form m/44'/coin_type'/account'/1/... - let coin_type = match network { - Network::Dash => 5, - _ => 1, - }; - let components = self.as_ref(); - components.len() >= 5 - && components[0] == ChildNumber::Hardened { index: 44 } - && components[1] == ChildNumber::Hardened { index: coin_type } - && components[3] == ChildNumber::Normal { index: 1 } - } - - fn is_asset_lock_funding(&self, network: Network) -> bool { - // BIP44 change paths have the form m/44'/coin_type'/account'/1/... - let coin_type = match network { - Network::Dash => 5, - _ => 1, - }; - // Asset lock funding paths have the form m/9'/coin_type'/5'/1'/x - let components = self.as_ref(); - components.len() == 5 - && components[0] == ChildNumber::Hardened { index: 9 } - && components[1] == ChildNumber::Hardened { index: coin_type } - && components[2] == ChildNumber::Hardened { index: 5 } - && components[3] == ChildNumber::Hardened { index: 1 } - } + send_dialog: SendDialogState, + receive_dialog: ReceiveDialogState, + fund_platform_dialog: FundPlatformAddressDialogState, + private_key_dialog: PrivateKeyDialogState, + selected_account: Option<(AccountCategory, Option)>, + /// Pending refresh of platform address balances (triggered after transfers) + pending_platform_balance_refresh: Option, + /// Whether we should refresh the wallet after it's unlocked + pending_refresh_after_unlock: bool, + /// The refresh mode to use after unlock (if pending_refresh_after_unlock is true) + pending_refresh_mode: RefreshMode, + /// Whether we should search for asset locks after wallet is unlocked + pending_asset_lock_search_after_unlock: bool, + /// Current page for single key wallet UTXO pagination (0-indexed) + utxo_page: usize, + /// Selected refresh mode (only shown in dev mode) + refresh_mode: RefreshMode, } // Define a struct to hold the address data struct AddressData { address: Address, balance: u64, + /// Platform credits balance for Platform Payment addresses + platform_credits: u64, utxo_count: usize, total_received: u64, address_type: String, index: u32, derivation_path: DerivationPath, + account_category: AccountCategory, + account_index: Option, +} + +#[derive(Default)] +struct SendDialogState { + is_open: bool, + address: String, + amount: Option, + amount_input: Option, + subtract_fee: bool, + memo: String, + error: Option, +} + +/// Type of address to receive to +#[derive(Default, Clone, Copy, PartialEq, Eq)] +enum ReceiveAddressType { + /// Core (L1) address for receiving Dash + #[default] + Core, + /// Platform address for receiving credits + Platform, +} + +/// Unified state for the receive dialog (Core and Platform) +#[derive(Default)] +struct ReceiveDialogState { + is_open: bool, + /// Selected address type (Core or Platform) + address_type: ReceiveAddressType, + /// Core addresses with balances: (address, balance_duffs) + core_addresses: Vec<(String, u64)>, + /// Currently selected Core address index + selected_core_index: usize, + /// Platform addresses with balances: (display_address, balance_credits) + platform_addresses: Vec<(String, u64)>, + /// Currently selected Platform address index + selected_platform_index: usize, + qr_texture: Option, + qr_address: Option, + status: Option, +} + +/// State for the Fund Platform Address from Asset Lock dialog +#[derive(Default)] +struct FundPlatformAddressDialogState { + is_open: bool, + /// Selected asset lock index + selected_asset_lock_index: Option, + /// Selected Platform address to fund + selected_platform_address: Option, + /// List of Platform addresses available + platform_addresses: Vec<(String, u64)>, + status: Option, + /// Whether the current status is an error message + status_is_error: bool, + is_processing: bool, + /// Whether we should continue funding after the wallet is unlocked + pending_fund_after_unlock: bool, +} + +/// State for the Private Key dialog +#[derive(Default)] +struct PrivateKeyDialogState { + is_open: bool, + /// The address being displayed + address: String, + /// The private key in WIF format + private_key_wif: String, + /// Whether to show the private key (hidden by default) + show_key: bool, + /// Pending derivation path (when wallet needs unlock first) + pending_derivation_path: Option, + /// Pending address string (when wallet needs unlock first) + pending_address: Option, } impl WalletsBalancesScreen { pub fn new(app_context: &Arc) -> Self { - let selected_wallet = app_context.wallets.read().unwrap().values().next().cloned(); - let mut selected_filters = HashSet::new(); - selected_filters.insert("Funds".to_string()); // "Funds" selected by default + // Try to restore previously selected wallet from AppContext + let (selected_wallet, selected_single_key_wallet) = { + let selected_hd_hash = app_context + .selected_wallet_hash + .lock() + .ok() + .and_then(|g| *g); + let selected_sk_hash = app_context + .selected_single_key_hash + .lock() + .ok() + .and_then(|g| *g); + + // If we have a persisted single key selection, try to find it + if let Some(sk_hash) = selected_sk_hash + && let Ok(sk_wallets) = app_context.single_key_wallets.read() + && let Some(wallet) = sk_wallets.get(&sk_hash) + { + return Self::create_with_selection(app_context, None, Some(wallet.clone())); + } + + // If we have a persisted HD wallet selection, try to find it + if let Some(hd_hash) = selected_hd_hash + && let Ok(wallets) = app_context.wallets.read() + && let Some(wallet) = wallets.get(&hd_hash) + { + return Self::create_with_selection(app_context, Some(wallet.clone()), None); + } + + // Default: try HD wallet first, then single key wallet + let hd_wallet = app_context.wallets.read().unwrap().values().next().cloned(); + let sk_wallet = if hd_wallet.is_none() { + app_context + .single_key_wallets + .read() + .unwrap() + .values() + .next() + .cloned() + } else { + None + }; + (hd_wallet, sk_wallet) + }; + + Self::create_with_selection(app_context, selected_wallet, selected_single_key_wallet) + } + + fn create_with_selection( + app_context: &Arc, + selected_wallet: Option>>, + selected_single_key_wallet: Option>>, + ) -> Self { Self { selected_wallet, + selected_single_key_wallet, app_context: app_context.clone(), message: None, sort_column: SortColumn::Index, sort_order: SortOrder::Ascending, - selected_filters, refreshing: false, show_rename_dialog: false, rename_input: String::new(), - wallet_password: String::new(), - show_password: false, - error_message: None, + wallet_unlock_popup: WalletUnlockPopup::new(), + show_sk_unlock_dialog: false, + sk_wallet_password: String::new(), + sk_show_password: false, + sk_error_message: None, remove_wallet_dialog: None, pending_wallet_removal: None, pending_wallet_removal_alias: None, + send_dialog: SendDialogState::default(), + receive_dialog: ReceiveDialogState::default(), + fund_platform_dialog: FundPlatformAddressDialogState::default(), + private_key_dialog: PrivateKeyDialogState::default(), + selected_account: None, + pending_platform_balance_refresh: None, + pending_refresh_after_unlock: false, + pending_refresh_mode: RefreshMode::default(), + pending_asset_lock_search_after_unlock: false, + utxo_page: 0, + refresh_mode: RefreshMode::default(), } } pub(crate) fn update_selected_wallet_for_network(&mut self) { - let selected_seed = self - .selected_wallet - .as_ref() - .and_then(|wallet| wallet.read().ok().map(|wallet| wallet.seed_hash())); + // Check if HD wallet selection is still valid + if let Some(wallet_arc) = &self.selected_wallet { + let seed_hash = wallet_arc.read().ok().map(|w| w.seed_hash()); + if let Some(hash) = seed_hash + && let Ok(wallets) = self.app_context.wallets.read() + && wallets.contains_key(&hash) + { + self.selected_account = None; + return; + } + // HD wallet no longer valid + self.selected_wallet = None; + } - let wallets = match self.app_context.wallets.read() { - Ok(guard) => guard, - Err(_) => { - self.selected_wallet = None; + // Check if single key wallet selection is still valid + if let Some(wallet_arc) = &self.selected_single_key_wallet { + let key_hash = wallet_arc.read().ok().map(|w| w.key_hash()); + if let Some(hash) = key_hash + && let Ok(wallets) = self.app_context.single_key_wallets.read() + && wallets.contains_key(&hash) + { + self.selected_account = None; return; } - }; + // Single key wallet no longer valid + self.selected_single_key_wallet = None; + } - if let Some(seed_hash) = selected_seed - && let Some(wallet) = wallets.get(&seed_hash) + // No valid selection, pick a new one (HD wallet first, then single key) + if let Ok(wallets) = self.app_context.wallets.read() + && let Some(wallet) = wallets.values().next().cloned() { - self.selected_wallet = Some(wallet.clone()); + self.selected_wallet = Some(wallet); + self.selected_single_key_wallet = None; + self.selected_account = None; + return; + } + + if let Ok(wallets) = self.app_context.single_key_wallets.read() + && let Some(wallet) = wallets.values().next().cloned() + { + self.selected_single_key_wallet = Some(wallet); + self.selected_wallet = None; + self.selected_account = None; return; } - self.selected_wallet = wallets.values().next().cloned(); + self.selected_account = None; } fn add_receiving_address(&mut self) { @@ -230,165 +412,328 @@ impl WalletsBalancesScreen { }); } - fn render_filter_selector(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; - let filter_options = [ - ("Funds", "Show receiving and change addresses"), - ( - "Identity Creation", - "Show addresses used for identity creation", - ), - ("System", "Show system-related addresses"), - ( - "Unused Asset Locks", - "Show available asset locks for identity creation", - ), - ]; + fn render_wallet_selection(&mut self, ui: &mut Ui) -> AppAction { + let action = AppAction::None; - // Single row layout - ui.horizontal(|ui| { - for (filter_option, description) in filter_options.iter() { - let is_selected = self.selected_filters.contains(*filter_option); - - // Create a button with distinct styling - let button = if is_selected { - egui::Button::new( - RichText::new(*filter_option) - .color(Color32::WHITE) - .size(12.0), + // Build items for the selector - both HD and single key wallets + #[derive(Clone)] + enum WalletItem { + Hd(Arc>), + SingleKey(Arc>), + } + + let mut items: Vec<(String, WalletItem)> = Vec::new(); + + // Add HD wallets + if let Ok(wallets_guard) = self.app_context.wallets.read() { + for wallet in wallets_guard.values() { + let guard = wallet.read().unwrap(); + let balance_dash = guard.total_balance_duffs() as f64 * 1e-8; + let label = format!( + "HD: {} ({:.4} DASH)", + guard.alias.clone().unwrap_or_else(|| "Unnamed".to_string()), + balance_dash + ); + items.push((label, WalletItem::Hd(wallet.clone()))); + } + } + + // Add single key wallets + if let Ok(wallets_guard) = self.app_context.single_key_wallets.read() { + for wallet in wallets_guard.values() { + let guard = wallet.read().unwrap(); + let balance_dash = guard.total_balance_duffs() as f64 * 1e-8; + let label = format!( + "SK: {} ({:.4} DASH)", + guard.alias.clone().unwrap_or_else(|| "Unnamed".to_string()), + balance_dash + ); + items.push((label, WalletItem::SingleKey(wallet.clone()))); + } + } + + if items.is_empty() { + self.render_no_wallets_view(ui); + return action; + } + + // Determine the currently selected label + let selected_label = if let Some(wallet) = &self.selected_wallet { + wallet + .read() + .ok() + .map(|guard| { + format!( + "HD: {}", + guard.alias.clone().unwrap_or_else(|| "Unnamed".to_string()) ) - .fill(egui::Color32::from_rgb(0, 128, 255)) - .stroke(egui::Stroke::NONE) - .corner_radius(3.0) - .min_size(egui::vec2(0.0, 22.0)) - } else { - egui::Button::new( - RichText::new(*filter_option) - .color(DashColors::text_primary(dark_mode)) - .size(12.0), + }) + .unwrap_or_else(|| "Select a wallet".to_string()) + } else if let Some(wallet) = &self.selected_single_key_wallet { + wallet + .read() + .ok() + .map(|guard| { + format!( + "SK: {}", + guard.alias.clone().unwrap_or_else(|| "Unnamed".to_string()) ) - .fill(DashColors::glass_white(dark_mode)) - .stroke(egui::Stroke::new(1.0, DashColors::border(dark_mode))) - .corner_radius(3.0) - .min_size(egui::vec2(0.0, 22.0)) - }; + }) + .unwrap_or_else(|| "Select a wallet".to_string()) + } else { + "Select a wallet".to_string() + }; - if ui - .add(button) - .on_hover_text(format!("{} (Shift+click for multiple)", description)) - .clicked() - { - let shift_held = ui.input(|i| i.modifiers.shift_only()); + // Get current balance + let current_balance = if let Some(wallet) = &self.selected_wallet { + wallet + .read() + .ok() + .map(|g| g.total_balance_duffs()) + .unwrap_or(0) + } else if let Some(wallet) = &self.selected_single_key_wallet { + wallet + .read() + .ok() + .map(|g| g.total_balance_duffs()) + .unwrap_or(0) + } else { + 0 + }; - if shift_held { - // If Shift is held, toggle the filter - if is_selected { - self.selected_filters.remove(*filter_option); - } else { - self.selected_filters.insert((*filter_option).to_string()); - } - } else { - // Without Shift, replace the selection - self.selected_filters.clear(); - self.selected_filters.insert((*filter_option).to_string()); + ui.with_layout( + egui::Layout::left_to_right(egui::Align::TOP).with_main_justify(true), + |ui| { + ui.horizontal(|ui| { + ComboBox::from_id_salt("wallet_selector") + .selected_text(&selected_label) + .show_ui(ui, |ui| { + for (label, wallet_item) in &items { + let is_selected = match wallet_item { + WalletItem::Hd(w) => self + .selected_wallet + .as_ref() + .is_some_and(|selected| Arc::ptr_eq(selected, w)), + WalletItem::SingleKey(w) => self + .selected_single_key_wallet + .as_ref() + .is_some_and(|selected| Arc::ptr_eq(selected, w)), + }; + if ui.selectable_label(is_selected, label).clicked() { + match wallet_item { + WalletItem::Hd(w) => { + self.selected_wallet = Some(w.clone()); + self.selected_single_key_wallet = None; + // Persist selection to AppContext and database + if let Ok(hash) = w.read().map(|g| g.seed_hash()) + && let Ok(mut guard) = + self.app_context.selected_wallet_hash.lock() + { + *guard = Some(hash); + // Save to database for persistence across restarts + let _ = self + .app_context + .db + .update_selected_wallet_hash(Some(&hash)); + } + if let Ok(mut guard) = + self.app_context.selected_single_key_hash.lock() + { + *guard = None; + let _ = self + .app_context + .db + .update_selected_single_key_hash(None); + } + } + WalletItem::SingleKey(w) => { + self.selected_single_key_wallet = Some(w.clone()); + self.selected_wallet = None; + self.utxo_page = 0; // Reset pagination + // Persist selection to AppContext and database + if let Ok(hash) = w.read().map(|g| g.key_hash) + && let Ok(mut guard) = + self.app_context.selected_single_key_hash.lock() + { + *guard = Some(hash); + // Save to database for persistence across restarts + let _ = self + .app_context + .db + .update_selected_single_key_hash(Some(&hash)); + } + if let Ok(mut guard) = + self.app_context.selected_wallet_hash.lock() + { + *guard = None; + let _ = self + .app_context + .db + .update_selected_wallet_hash(None); + } + } + } + self.selected_account = None; + } + } + }); + + ui.colored_label( + DashColors::text_primary(ui.ctx().style().visuals.dark_mode), + format!(" Balance: {}", Self::format_dash(current_balance)), + ); + + ui.separator(); + + // Dev mode: Refresh mode selector + if self.app_context.is_developer_mode() { + ui.label( + egui::RichText::new("Refresh Mode:").color(DashColors::text_primary( + ui.ctx().style().visuals.dark_mode, + )), + ); + + ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { + ComboBox::from_id_salt("refresh_mode_selector") + .selected_text(self.refresh_mode.label()) + .show_ui(ui, |ui| { + for mode in RefreshMode::all_modes() { + ui.selectable_value( + &mut self.refresh_mode, + *mode, + mode.label(), + ); + } + }); + }); } - } - } - }); - } + }); - fn render_wallet_selection(&mut self, ui: &mut Ui) { - let dark_mode = ui.ctx().style().visuals.dark_mode; - if self.app_context.has_wallet.load(Ordering::Relaxed) { - let wallets = &self.app_context.wallets.read().unwrap(); - let wallet_aliases: Vec = wallets - .values() - .map(|wallet| { - wallet - .read() - .unwrap() - .alias - .clone() - .unwrap_or_else(|| "Unnamed Wallet".to_string()) - }) - .collect(); + ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { + // Clone wallet arcs before using to avoid borrow conflicts + let hd_wallet_opt = self.selected_wallet.clone(); + let single_key_wallet_opt = self.selected_single_key_wallet.clone(); - let selected_wallet_alias = self - .selected_wallet - .as_ref() - .and_then(|wallet| wallet.read().ok()?.alias.clone()) - .unwrap_or_else(|| "Select a wallet".to_string()); + // Buttons for HD wallet + if let Some(wallet_arc) = hd_wallet_opt { + self.render_remove_wallet_button(ui); + ui.add_space(8.0); - // Compact horizontal layout - ui.horizontal(|ui| { - // Display the ComboBox for wallet selection - ComboBox::from_label("") - .selected_text(selected_wallet_alias.clone()) - .width(200.0) - .show_ui(ui, |ui| { - for (idx, wallet) in wallets.values().enumerate() { - let wallet_alias = wallet_aliases[idx].clone(); - - let is_selected = self - .selected_wallet - .as_ref() - .is_some_and(|selected| Arc::ptr_eq(selected, wallet)); + // Extract wallet state before calling mutable methods + let (uses_password, is_open, alias) = { + if let Ok(wallet) = wallet_arc.read() { + (wallet.uses_password, wallet.is_open(), wallet.alias.clone()) + } else { + (false, false, None) + } + }; + + let mut should_lock_wallet = false; + if uses_password { + if is_open { + if ui.button("Lock").clicked() { + should_lock_wallet = true; + } + } else if ui.button("Unlock").clicked() { + self.wallet_unlock_popup.open(); + } + } + if should_lock_wallet { + self.lock_selected_wallet(); + } + ui.add_space(8.0); + if ui.button("Rename").clicked() { + self.show_rename_dialog = true; + self.rename_input = alias.unwrap_or_default(); + } + } - if ui - .selectable_label(is_selected, wallet_alias.clone()) - .clicked() + // Buttons for single key wallet + if let Some(wallet_arc) = single_key_wallet_opt { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let (key_hash, alias) = wallet_arc + .read() + .ok() + .map(|w| (w.key_hash, w.alias.clone())) + .unwrap_or(([0u8; 32], None)); + + // Remove button (styled red like HD wallet) + let remove_button = egui::Button::new( + RichText::new("Remove").color(Color32::WHITE).size(14.0), + ) + .min_size(egui::vec2(0.0, 28.0)) + .fill(DashColors::error_color(!dark_mode)) + .stroke(egui::Stroke::NONE) + .corner_radius(4.0); + + if ui.add(remove_button).clicked() { + if let Err(e) = self + .app_context + .db + .remove_single_key_wallet(&key_hash, self.app_context.network) { - // Update the selected wallet - self.selected_wallet = Some(wallet.clone()); + self.display_message( + &format!("Failed to remove: {}", e), + MessageType::Error, + ); + } else { + if let Ok(mut wallets) = self.app_context.single_key_wallets.write() + { + wallets.remove(&key_hash); + } + self.selected_single_key_wallet = None; + // Clear persisted selection in AppContext and database + if let Ok(mut guard) = + self.app_context.selected_single_key_hash.lock() + { + *guard = None; + } + let _ = self.app_context.db.update_selected_single_key_hash(None); + self.display_message("Wallet removed", MessageType::Success); } } - }); - if let Some(selected_wallet) = &self.selected_wallet { - let wallet = selected_wallet.read().unwrap(); + ui.add_space(8.0); - if ui.button("Rename").clicked() { - self.show_rename_dialog = true; - self.rename_input = wallet.alias.clone().unwrap_or_default(); - } - } + // Lock/Unlock buttons for SK wallet + let (uses_password, is_open) = wallet_arc + .read() + .ok() + .map(|w| (w.uses_password, w.is_open())) + .unwrap_or((false, false)); - // Balance and rename button on same row - if let Some(selected_wallet) = &self.selected_wallet { - ui.separator(); + let mut should_lock_sk_wallet = false; + if uses_password { + if is_open { + if ui.button("Lock").clicked() { + should_lock_sk_wallet = true; + } + } else if ui.button("Unlock").clicked() { + self.show_sk_unlock_dialog = true; + } + } + if should_lock_sk_wallet && let Ok(mut wallet) = wallet_arc.write() { + wallet.private_key_data.close(); + } - let wallet = selected_wallet.read().unwrap(); - let total_balance = wallet.max_balance(); - let dash_balance = total_balance as f64 * 1e-8; // Convert to DASH - ui.label( - RichText::new(format!("Balance: {:.8} DASH", dash_balance)) - .strong() - .color(DashColors::success_color(dark_mode)), - ); - } - }); - } else { - ui.label("No wallets available."); - } + ui.add_space(8.0); + + // Rename button + if ui.button("Rename").clicked() { + self.show_rename_dialog = true; + self.rename_input = alias.unwrap_or_default(); + } + } + }); + }, + ); + + action } fn render_address_table(&mut self, ui: &mut Ui) -> AppAction { let action = AppAction::None; - let mut included_address_types = HashSet::new(); - - for filter in &self.selected_filters { - match filter.as_str() { - "Funds" => { - included_address_types.insert("Funds".to_string()); - included_address_types.insert("Change".to_string()); - } - other => { - included_address_types.insert(other.to_string()); - } - } - } - // Move the data preparation into its own scope let mut address_data = { let wallet = self.selected_wallet.as_ref().unwrap().read().unwrap(); @@ -397,14 +742,16 @@ impl WalletsBalancesScreen { wallet .known_addresses .iter() - .filter_map(|(address, derivation_path)| { + .map(|(address, derivation_path)| { let utxo_info = wallet.utxos.get(address); let utxo_count = utxo_info.map(|outpoints| outpoints.len()).unwrap_or(0); - // Calculate total received by summing UTXO values - let total_received = utxo_info - .map(|outpoints| outpoints.values().map(|txout| txout.value).sum::()) + // Get total received from the wallet (fetched from Core RPC) + let total_received = wallet + .address_total_received + .get(address) + .cloned() .unwrap_or(0u64); let index = derivation_path @@ -424,26 +771,42 @@ impl WalletsBalancesScreen { "Change".to_string() } else if derivation_path.is_asset_lock_funding(self.app_context.network) { "Identity Creation".to_string() + } else if derivation_path.is_platform_payment(self.app_context.network) { + "Platform".to_string() } else { "System".to_string() }; - if included_address_types.contains(address_type.as_str()) { - Some(AddressData { - address: address.clone(), - balance: wallet - .address_balances - .get(address) - .cloned() - .unwrap_or_default(), - utxo_count, - total_received, - address_type, - index, - derivation_path: derivation_path.clone(), - }) - } else { - None + let path_reference = wallet + .watched_addresses + .get(derivation_path) + .map(|info| info.path_reference) + .unwrap_or(DerivationPathReference::Unknown); + let (account_category, account_index) = + Self::categorize_path(derivation_path, path_reference); + + // Get Platform credits balance for Platform Payment addresses + // Use canonical lookup to handle potential Address key mismatches + let platform_credits = wallet + .get_platform_address_info(address) + .map(|info| info.balance) + .unwrap_or_default(); + + AddressData { + address: address.clone(), + balance: wallet + .address_balances + .get(address) + .cloned() + .unwrap_or_default(), + platform_credits, + utxo_count, + total_received, + address_type, + index, + derivation_path: derivation_path.clone(), + account_category, + account_index, } }) .collect::>() @@ -453,145 +816,262 @@ impl WalletsBalancesScreen { // Sort the data self.sort_address_data(&mut address_data); + if let Some((category, index)) = self.selected_account.clone() { + address_data + .retain(|data| data.account_category == category && data.account_index == index); + } + // Space allocation for UI elements is handled by the layout system // Render the table - egui::ScrollArea::both() - .id_salt("address_table") - .show(ui, |ui| { - TableBuilder::new(ui) - .striped(false) - .resizable(true) - .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) - .column(Column::auto()) // Address - .column(Column::initial(100.0)) // Balance - .column(Column::initial(60.0)) // UTXOs - .column(Column::initial(150.0)) // Total Received - .column(Column::initial(100.0)) // Type - .column(Column::initial(60.0)) // Index - .column(Column::remainder()) // Derivation Path - .header(30.0, |mut header| { - header.col(|ui| { - let label = if self.sort_column == SortColumn::Address { - match self.sort_order { - SortOrder::Ascending => "Address ^", - SortOrder::Descending => "Address v", + TableBuilder::new(ui) + .id_salt("addresses_table") + .striped(false) + .resizable(true) + .vscroll(false) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto()) // Address + .column(Column::initial(140.0)) // Balance + .column(Column::initial(70.0)) // UTXOs + .column(Column::initial(150.0)) // Total Received + .column(Column::initial(100.0)) // Type + .column(Column::initial(70.0)) // Index + .column(Column::initial(120.0)) // Derivation Path + .column(Column::initial(120.0)) // Actions + .header(30.0, |mut header| { + header.col(|ui| { + let label = if self.sort_column == SortColumn::Address { + match self.sort_order { + SortOrder::Ascending => "Address ^", + SortOrder::Descending => "Address v", + } + } else { + "Address" + }; + if ui.button(label).clicked() { + self.toggle_sort(SortColumn::Address); + } + }); + header.col(|ui| { + let label = if self.sort_column == SortColumn::Balance { + match self.sort_order { + SortOrder::Ascending => "Balance (DASH) ^", + SortOrder::Descending => "Balance (DASH) v", + } + } else { + "Balance (DASH)" + }; + if ui.button(label).clicked() { + self.toggle_sort(SortColumn::Balance); + } + }); + header.col(|ui| { + let label = if self.sort_column == SortColumn::UTXOs { + match self.sort_order { + SortOrder::Ascending => "UTXOs ^", + SortOrder::Descending => "UTXOs v", + } + } else { + "UTXOs" + }; + if ui.button(label).clicked() { + self.toggle_sort(SortColumn::UTXOs); + } + }); + header.col(|ui| { + let label = if self.sort_column == SortColumn::TotalReceived { + match self.sort_order { + SortOrder::Ascending => "Total Received (DASH) ^", + SortOrder::Descending => "Total Received (DASH) v", + } + } else { + "Total Received (DASH)" + }; + if ui.button(label).clicked() { + self.toggle_sort(SortColumn::TotalReceived); + } + }); + header.col(|ui| { + let label = if self.sort_column == SortColumn::Type { + match self.sort_order { + SortOrder::Ascending => "Type ^", + SortOrder::Descending => "Type v", + } + } else { + "Type" + }; + if ui.button(label).clicked() { + self.toggle_sort(SortColumn::Type); + } + }); + header.col(|ui| { + let label = if self.sort_column == SortColumn::Index { + match self.sort_order { + SortOrder::Ascending => "Index ^", + SortOrder::Descending => "Index v", + } + } else { + "Index" + }; + if ui.button(label).clicked() { + self.toggle_sort(SortColumn::Index); + } + }); + header.col(|ui| { + let label = if self.sort_column == SortColumn::DerivationPath { + match self.sort_order { + SortOrder::Ascending => "Full Path ^", + SortOrder::Descending => "Full Path v", + } + } else { + "Full Path" + }; + if ui.button(label).clicked() { + self.toggle_sort(SortColumn::DerivationPath); + } + }); + header.col(|ui| { + ui.label("Private Key"); + }); + }) + .body(|mut body| { + let network = self.app_context.network; + for data in &address_data { + body.row(25.0, |mut row| { + row.col(|ui| { + // For Platform Payment addresses, display in DIP-18 Bech32m format + if data.account_category == AccountCategory::PlatformPayment { + use dash_sdk::dpp::address_funds::PlatformAddress; + if let Ok(platform_addr) = + PlatformAddress::try_from(data.address.clone()) + { + ui.label(platform_addr.to_bech32m_string(network)); + } else { + ui.label(data.address.to_string()); } } else { - "Address" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::Address); + ui.label(data.address.to_string()); } }); - header.col(|ui| { - let label = if self.sort_column == SortColumn::Balance { - match self.sort_order { - SortOrder::Ascending => "Total Received (DASH) ^", - SortOrder::Descending => "Total Received (DASH) v", - } + row.col(|ui| { + // These address types are used for key derivation/proofs, not holding funds + let is_key_only_address = matches!( + data.account_category, + AccountCategory::IdentityRegistration + | AccountCategory::IdentityTopup + | AccountCategory::IdentityInvitation + | AccountCategory::IdentitySystem + | AccountCategory::ProviderVoting + | AccountCategory::ProviderOwner + | AccountCategory::ProviderOperator + | AccountCategory::ProviderPlatform + ); + + if is_key_only_address { + ui.label("N/A"); + } else if data.account_category == AccountCategory::PlatformPayment { + // Platform credits: convert from credits to DASH + // Credits are in duffs * 1000, so divide by 1000 then by 1e8 + let dash_balance = + data.platform_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label(format!("{:.8}", dash_balance)); } else { - "Total Received (DASH)" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::Balance); + let dash_balance = data.balance as f64 * 1e-8; + ui.label(format!("{:.8}", dash_balance)); } }); - header.col(|ui| { - let label = if self.sort_column == SortColumn::UTXOs { - match self.sort_order { - SortOrder::Ascending => "UTXOs ^", - SortOrder::Descending => "UTXOs v", - } + row.col(|ui| { + // Key-only addresses don't hold UTXOs + let is_key_only_address = matches!( + data.account_category, + AccountCategory::IdentityRegistration + | AccountCategory::IdentityTopup + | AccountCategory::IdentityInvitation + | AccountCategory::IdentitySystem + | AccountCategory::ProviderVoting + | AccountCategory::ProviderOwner + | AccountCategory::ProviderOperator + | AccountCategory::ProviderPlatform + ); + + if is_key_only_address { + ui.label("N/A"); } else { - "UTXOs" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::UTXOs); + ui.label(format!("{}", data.utxo_count)); } }); - header.col(|ui| { - let label = if self.sort_column == SortColumn::TotalReceived { - match self.sort_order { - SortOrder::Ascending => "Balance (DASH) ^", - SortOrder::Descending => "Balance (DASH) v", - } + row.col(|ui| { + // These address types are used for key derivation/proofs, not receiving funds + let is_key_only_address = matches!( + data.account_category, + AccountCategory::IdentityRegistration + | AccountCategory::IdentityTopup + | AccountCategory::IdentityInvitation + | AccountCategory::IdentitySystem + | AccountCategory::ProviderVoting + | AccountCategory::ProviderOwner + | AccountCategory::ProviderOperator + | AccountCategory::ProviderPlatform + ); + + if is_key_only_address { + ui.label("N/A"); + } else if data.account_category == AccountCategory::PlatformPayment { + // For Platform addresses, show platform credits balance + // (since we don't track historical Platform received) + let dash_received = + data.platform_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label(format!("{:.8}", dash_received)); } else { - "Balance (DASH)" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::TotalReceived); + let dash_received = data.total_received as f64 * 1e-8; + ui.label(format!("{:.8}", dash_received)); } }); - header.col(|ui| { - let label = if self.sort_column == SortColumn::Type { - match self.sort_order { - SortOrder::Ascending => "Type ^", - SortOrder::Descending => "Type v", - } - } else { - "Type" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::Type); - } + row.col(|ui| { + ui.label(&data.address_type); }); - header.col(|ui| { - let label = if self.sort_column == SortColumn::Index { - match self.sort_order { - SortOrder::Ascending => "Index ^", - SortOrder::Descending => "Index v", - } - } else { - "Index" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::Index); - } + row.col(|ui| { + ui.label(format!("{}", data.index)); }); - header.col(|ui| { - let label = if self.sort_column == SortColumn::DerivationPath { - match self.sort_order { - SortOrder::Ascending => "Full Path ^", - SortOrder::Descending => "Full Path v", + row.col(|ui| { + ui.label(format!("{}", data.derivation_path)); + }); + row.col(|ui| { + if ui.button("View Key").clicked() { + // Check if wallet is locked first + let wallet_locked = self + .selected_wallet + .as_ref() + .map(|w| { + w.read() + .map(|g| g.uses_password && !g.is_open()) + .unwrap_or(false) + }) + .unwrap_or(false); + + if wallet_locked { + // Store pending info and show unlock popup + self.private_key_dialog.pending_derivation_path = + Some(data.derivation_path.clone()); + self.private_key_dialog.pending_address = + Some(data.address.to_string()); + self.wallet_unlock_popup.open(); + } else { + match self.derive_private_key_wif(&data.derivation_path) { + Ok(key) => { + self.private_key_dialog.is_open = true; + self.private_key_dialog.address = + data.address.to_string(); + self.private_key_dialog.private_key_wif = key; + self.private_key_dialog.show_key = false; + } + Err(err) => self.display_message(&err, MessageType::Error), + } } - } else { - "Full Path" - }; - if ui.button(label).clicked() { - self.toggle_sort(SortColumn::DerivationPath); } }); - }) - .body(|mut body| { - for data in &address_data { - body.row(25.0, |mut row| { - row.col(|ui| { - ui.label(data.address.to_string()); - }); - row.col(|ui| { - let dash_balance = data.balance as f64 * 1e-8; - ui.label(format!("{:.8}", dash_balance)); - }); - row.col(|ui| { - ui.label(format!("{}", data.utxo_count)); - }); - row.col(|ui| { - let dash_received = data.total_received as f64 * 1e-8; - ui.label(format!("{:.8}", dash_received)); - }); - row.col(|ui| { - ui.label(&data.address_type); - }); - row.col(|ui| { - ui.label(format!("{}", data.index)); - }); - row.col(|ui| { - ui.label(format!("{}", data.derivation_path)); - }); - }); - } }); + } }); action } @@ -602,41 +1082,39 @@ impl WalletsBalancesScreen { .as_ref() .is_some_and(|wallet_guard| wallet_guard.read().unwrap().is_open()); - if self.selected_filters.contains("Funds") { + // Only show "Add Receiving Address" button for Main Account (BIP44 account 0) + let is_main_account = self + .selected_account + .as_ref() + .is_some_and(|(category, index)| { + *category == AccountCategory::Bip44 && index.unwrap_or(0) == 0 + }); + + if wallet_is_open && is_main_account { ui.add_space(10.0); + ui.horizontal(|ui| { + if ui + .button(RichText::new("➕ Add Receiving Address").size(14.0)) + .clicked() + { + self.add_receiving_address(); + } + }); + } + } - if wallet_is_open { - ui.horizontal(|ui| { - if ui - .button(RichText::new("➕ Add Receiving Address").size(14.0)) - .clicked() - { - self.add_receiving_address(); - } - }); - } else { - // Show wallet unlock UI for locked wallets when Funds filter is active - self.render_wallet_unlock_if_needed(ui); - } - } - - if self.selected_wallet.is_some() { - ui.add_space(16.0); - let dark_mode = ui.ctx().style().visuals.dark_mode; + fn render_remove_wallet_button(&mut self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; - let remove_button = egui::Button::new( - RichText::new("🗑 Remove Wallet") - .color(Color32::WHITE) - .size(14.0), - ) - .min_size(egui::vec2(0.0, 28.0)) - .fill(DashColors::error_color(!dark_mode)) - .stroke(egui::Stroke::NONE) - .corner_radius(4.0); + if let Some(selected_wallet) = &self.selected_wallet { + let remove_button = + egui::Button::new(RichText::new("Remove").color(Color32::WHITE).size(14.0)) + .min_size(egui::vec2(0.0, 28.0)) + .fill(DashColors::error_color(!dark_mode)) + .stroke(egui::Stroke::NONE) + .corner_radius(4.0); - if ui.add(remove_button).clicked() - && let Some(selected_wallet) = &self.selected_wallet - { + if ui.add(remove_button).clicked() { let wallet = selected_wallet.read().unwrap(); let alias = wallet .alias @@ -660,29 +1138,29 @@ impl WalletsBalancesScreen { .danger_mode(true), ); } + } - if let Some(dialog) = self.remove_wallet_dialog.as_mut() { - let response = dialog.show(ui); - if let Some(status) = response.inner.dialog_response { - match status { - ConfirmationStatus::Confirmed => { - self.remove_wallet_dialog = None; - if let Some(seed_hash) = self.pending_wallet_removal.take() { - let alias = self - .pending_wallet_removal_alias - .take() - .unwrap_or_else(|| "Unnamed Wallet".to_string()); - self.handle_wallet_removal(seed_hash, alias); - } else { - self.pending_wallet_removal_alias = None; - } - } - ConfirmationStatus::Canceled => { - self.remove_wallet_dialog = None; - self.pending_wallet_removal = None; + if let Some(dialog) = self.remove_wallet_dialog.as_mut() { + let response = dialog.show(ui); + if let Some(status) = response.inner.dialog_response { + match status { + ConfirmationStatus::Confirmed => { + self.remove_wallet_dialog = None; + if let Some(seed_hash) = self.pending_wallet_removal.take() { + let alias = self + .pending_wallet_removal_alias + .take() + .unwrap_or_else(|| "Unnamed Wallet".to_string()); + self.handle_wallet_removal(seed_hash, alias); + } else { self.pending_wallet_removal_alias = None; } } + ConfirmationStatus::Canceled => { + self.remove_wallet_dialog = None; + self.pending_wallet_removal = None; + self.pending_wallet_removal_alias = None; + } } } } @@ -698,18 +1176,24 @@ impl WalletsBalancesScreen { .ok() .and_then(|wallets| wallets.values().next().cloned()); - self.selected_wallet = next_wallet; + self.selected_wallet = next_wallet.clone(); - if self.selected_wallet.is_none() { - self.selected_filters.clear(); - self.selected_filters.insert("Funds".to_string()); + // Update persisted selection in AppContext and database + let new_hash = next_wallet + .as_ref() + .and_then(|w| w.read().ok().map(|g| g.seed_hash())); + if let Ok(mut guard) = self.app_context.selected_wallet_hash.lock() { + *guard = new_hash; } + // Persist to database + let _ = self + .app_context + .db + .update_selected_wallet_hash(new_hash.as_ref()); self.show_rename_dialog = false; self.rename_input.clear(); - self.wallet_password.clear(); - self.show_password = false; - self.error_message = None; + self.wallet_unlock_popup.close(); self.refreshing = false; self.display_message( @@ -728,6 +1212,9 @@ impl WalletsBalancesScreen { fn render_wallet_asset_locks(&mut self, ui: &mut Ui) -> AppAction { let mut app_action = AppAction::None; + let mut open_fund_dialog_for_idx: Option<(usize, Vec<(String, u64)>)> = None; + let mut recover_asset_locks_clicked = false; + if let Some(arc_wallet) = &self.selected_wallet { let wallet = arc_wallet.read().unwrap(); @@ -739,7 +1226,14 @@ impl WalletsBalancesScreen { .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) .show(ui, |ui| { let dark_mode = ui.ctx().style().visuals.dark_mode; - ui.heading(RichText::new("Asset Locks").color(DashColors::text_primary(dark_mode))); + ui.horizontal(|ui| { + ui.heading(RichText::new("Unused Asset Locks").color(DashColors::text_primary(dark_mode))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Search for Unused Asset Locks").on_hover_text("Scan Core wallet for untracked asset locks").clicked() { + recover_asset_locks_clicked = true; + } + }); + }); ui.add_space(10.0); if wallet.unused_asset_locks.is_empty() { @@ -747,18 +1241,32 @@ impl WalletsBalancesScreen { ui.add_space(20.0); ui.label(RichText::new("No asset locks found").color(Color32::GRAY).size(14.0)); ui.add_space(10.0); - ui.label(RichText::new("Asset locks are special transactions that can be used to create identities").color(Color32::GRAY).size(12.0)); - ui.add_space(15.0); - if ui.button("Search for asset locks").clicked() { - app_action = AppAction::BackendTask(BackendTask::CoreTask( - CoreTask::RefreshWalletInfo(arc_wallet.clone()), - )) - }; + ui.label(RichText::new("Asset locks are special transactions that can be used to create identities or fund Platform addresses").color(Color32::GRAY).size(12.0)); ui.add_space(20.0); }); } else { + // Collect Platform addresses for the fund dialog (using DIP-18 Bech32m format) + // Get from known_addresses where path is platform payment + let network = self.app_context.network; + let platform_addresses: Vec<(String, u64)> = wallet + .known_addresses + .iter() + .filter(|(_, path)| path.is_platform_payment(network)) + .filter_map(|(addr, _)| { + use dash_sdk::dpp::address_funds::PlatformAddress; + let balance = wallet + .get_platform_address_info(addr) + .map(|info| info.balance) + .unwrap_or(0); + PlatformAddress::try_from(addr.clone()) + .ok() + .map(|pa| (pa.to_bech32m_string(network), balance)) + }) + .collect(); + egui::ScrollArea::both() .id_salt("asset_locks_table") + .min_scrolled_height(200.0) .show(ui, |ui| { TableBuilder::new(ui) .striped(false) @@ -769,6 +1277,7 @@ impl WalletsBalancesScreen { .column(Column::initial(100.0)) // Amount (Duffs) .column(Column::initial(100.0)) // InstantLock status .column(Column::initial(100.0)) // Usable status + .column(Column::initial(150.0)) // Actions .header(30.0, |mut header| { header.col(|ui| { ui.label("Transaction ID"); @@ -785,9 +1294,12 @@ impl WalletsBalancesScreen { header.col(|ui| { ui.label("Usable"); }); + header.col(|ui| { + ui.label("Actions"); + }); }) .body(|mut body| { - for (tx, address, amount, islock, proof) in &wallet.unused_asset_locks { + for (idx, (tx, address, amount, islock, proof)) in wallet.unused_asset_locks.iter().enumerate() { body.row(25.0, |mut row| { row.col(|ui| { ui.label(tx.txid().to_string()); @@ -806,6 +1318,15 @@ impl WalletsBalancesScreen { let status = if proof.is_some() { "Yes" } else { "No" }; ui.label(status); }); + row.col(|ui| { + if proof.is_some() { + if ui.small_button("Fund Platform Addr").on_hover_text("Fund a Platform address with this asset lock").clicked() { + open_fund_dialog_for_idx = Some((idx, platform_addresses.clone())); + } + } else { + ui.label(RichText::new("Not ready").color(Color32::GRAY).size(11.0)); + } + }); }); } }); @@ -815,6 +1336,22 @@ impl WalletsBalancesScreen { } else { ui.label("No wallet selected."); } + + // Handle dialog opening outside the borrow + if let Some((idx, platform_addresses)) = open_fund_dialog_for_idx { + self.fund_platform_dialog.selected_asset_lock_index = Some(idx); + self.fund_platform_dialog.is_open = true; + self.fund_platform_dialog.platform_addresses = platform_addresses; + self.fund_platform_dialog.selected_platform_address = None; + self.fund_platform_dialog.status = None; + self.fund_platform_dialog.is_processing = false; + } + + // Handle recover asset locks button click - use custom action to check lock status + if recover_asset_locks_clicked { + app_action = AppAction::Custom("SearchAssetLocks".to_string()); + } + app_action } @@ -889,211 +1426,2035 @@ impl WalletsBalancesScreen { fn check_message_expiration(&mut self) { // Messages no longer auto-expire, they must be dismissed manually } -} -impl ScreenLike for WalletsBalancesScreen { - fn ui(&mut self, ctx: &Context) -> AppAction { - self.check_message_expiration(); - let right_buttons = if let Some(wallet) = self.selected_wallet.as_ref() { - match self.refreshing { - true => vec![ - ("Refreshing...", DesiredAppAction::None), - ( - "Import Wallet", - DesiredAppAction::AddScreenType(Box::new(ScreenType::ImportWallet)), - ), - ( - "Create Wallet", - DesiredAppAction::AddScreenType(Box::new(ScreenType::AddNewWallet)), - ), - ], - false => vec![ - ( - "Refresh", - DesiredAppAction::BackendTask(Box::new(BackendTask::CoreTask( - CoreTask::RefreshWalletInfo(wallet.clone()), - ))), - ), - ( - "Import Wallet", - DesiredAppAction::AddScreenType(Box::new(ScreenType::ImportWallet)), - ), - ( - "Create Wallet", - DesiredAppAction::AddScreenType(Box::new(ScreenType::AddNewWallet)), - ), - ], - } + fn format_dash(amount_duffs: u64) -> String { + Amount::dash_from_duffs(amount_duffs).to_string() + } + + fn transaction_direction_label(tx: &WalletTransaction) -> &'static str { + if tx.is_incoming() { + "Received" + } else if tx.is_outgoing() { + "Sent" } else { - vec![ - ( - "Import Wallet", - DesiredAppAction::AddScreenType(Box::new(ScreenType::ImportWallet)), - ), - ( - "Create Wallet", - DesiredAppAction::AddScreenType(Box::new(ScreenType::AddNewWallet)), - ), - ] - }; - let mut action = add_top_panel( - ctx, - &self.app_context, - vec![("Wallets", AppAction::None)], - right_buttons, - ); + "Internal" + } + } - action |= add_left_panel( - ctx, - &self.app_context, - RootScreenType::RootScreenWalletsBalances, - ); + fn transaction_amount_display(tx: &WalletTransaction, dark_mode: bool) -> (String, Color32) { + let amount = Self::format_dash(tx.amount_abs()); + if tx.is_incoming() { + (format!("+{}", amount), DashColors::SUCCESS) + } else if tx.is_outgoing() { + (format!("-{}", amount), DashColors::ERROR) + } else { + (amount, DashColors::text_primary(dark_mode)) + } + } - action |= island_central_panel(ctx, |ui| { - let mut inner_action = AppAction::None; - let dark_mode = ui.ctx().style().visuals.dark_mode; + fn format_transaction_status(tx: &WalletTransaction) -> String { + if tx.is_confirmed() { + tx.height + .map(|h| format!("Confirmed @{}", h)) + .unwrap_or_else(|| "Confirmed".to_string()) + } else { + "Pending".to_string() + } + } - // Display messages at the top, outside of scroll area - let message = self.message.clone(); - if let Some((message, message_type, _timestamp)) = message { - let message_color = match message_type { - MessageType::Error => egui::Color32::from_rgb(255, 100, 100), - MessageType::Info => DashColors::text_primary(dark_mode), - MessageType::Success => egui::Color32::DARK_GREEN, - }; + fn format_transaction_timestamp(ts: u64) -> String { + DateTime::::from_timestamp(ts as i64, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| "Unknown".to_string()) + } - // Display message in a prominent frame - ui.horizontal(|ui| { - Frame::new() - .fill(message_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, message_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(message).color(message_color)); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.dismiss_message(); - } - }); - }); - }); - ui.add_space(10.0); + fn platform_balance_duffs(wallet: &Wallet) -> u64 { + // Only sum Platform address balances + // Identity balances are shown separately on the Identities screen + wallet + .platform_address_info + .values() + .map(|info| info.balance / CREDITS_PER_DUFF) + .sum() + } + + fn render_wallet_overview(&self, ui: &mut Ui, wallet: &Wallet) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let total = wallet.total_balance_duffs(); + let platform = Self::platform_balance_duffs(wallet); + + ui.horizontal(|ui| { + ui.label(RichText::new(format!( + "Core balance: {}", + Self::format_dash(total) + ))); + }); + ui.label( + RichText::new(format!("Platform balance: {}", Self::format_dash(platform))) + .color(DashColors::text_primary(dark_mode)), + ); + } + + fn render_action_buttons(&mut self, ui: &mut Ui, ctx: &Context) -> AppAction { + let mut action = AppAction::None; + ui.add_space(10.0); + let dark_mode = ui.ctx().style().visuals.dark_mode; + ui.horizontal(|ui| { + if ui + .button( + RichText::new("Send") + .color(DashColors::text_primary(dark_mode)) + .strong(), + ) + .clicked() + { + if let Some(wallet) = &self.selected_wallet { + action = AppAction::AddScreen( + crate::ui::ScreenType::WalletSendScreen(wallet.clone()) + .create_screen(&self.app_context), + ); + } else if let Some(sk_wallet) = &self.selected_single_key_wallet { + action = AppAction::AddScreen( + crate::ui::ScreenType::SingleKeyWalletSendScreen(sk_wallet.clone()) + .create_screen(&self.app_context), + ); + } else { + self.display_message("Select a wallet first", MessageType::Error); + } } - egui::ScrollArea::vertical() - .auto_shrink([true; 2]) - .show(ui, |ui| { - if self.app_context.wallets.read().unwrap().is_empty() { - self.render_no_wallets_view(ui); - return; + if ui + .button(RichText::new("Receive").color(DashColors::text_primary(dark_mode))) + .clicked() + { + action |= self.open_receive_dialog(ctx); + } + }); + action + } + + fn render_accounts_section(&mut self, ui: &mut Ui, summaries: &[AccountSummary]) { + ui.add_space(14.0); + ui.heading("Accounts"); + ui.add_space(6.0); + + if summaries.is_empty() { + ui.label("No account activity yet."); + return; + } + + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Find the currently selected summary + let selected_summary = self.selected_account.as_ref().and_then(|(cat, idx)| { + summaries + .iter() + .find(|s| &s.category == cat && s.index == *idx) + }); + + // Build the selected text for the dropdown + let selected_text = selected_summary + .map(|s| { + if s.category.is_key_only() { + s.label.clone() + } else if s.category == AccountCategory::PlatformPayment { + let credits_as_dash = s.platform_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; + format!("{} - {:.4} DASH", s.label, credits_as_dash) + } else { + format!("{} - {}", s.label, Self::format_dash(s.confirmed_balance)) + } + }) + .unwrap_or_else(|| "Select an account".to_string()); + + // Account dropdown selector + ComboBox::from_id_salt("account_selector") + .selected_text(&selected_text) + .width(ui.available_width() - 16.0) + .show_ui(ui, |ui| { + for summary in summaries { + let is_selected = self + .selected_account + .as_ref() + .map(|(cat, idx)| cat == &summary.category && *idx == summary.index) + .unwrap_or(false); + + let label = if summary.category.is_key_only() { + summary.label.clone() + } else if summary.category == AccountCategory::PlatformPayment { + let credits_as_dash = + summary.platform_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; + format!("{} - {:.4} DASH", summary.label, credits_as_dash) + } else { + format!( + "{} - {}", + summary.label, + Self::format_dash(summary.confirmed_balance) + ) + }; + + if ui.selectable_label(is_selected, &label).clicked() { + self.selected_account = Some((summary.category.clone(), summary.index)); } + } + }); - // Wallet Information Panel (fit content) - ui.vertical(|ui| { - ui.heading( - RichText::new("Wallets").color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(5.0); - ui.horizontal(|ui| { - Frame::new() - .fill(DashColors::surface(dark_mode)) - .corner_radius(5.0) - .inner_margin(Margin::symmetric(15, 10)) - .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) - .show(ui, |ui| { - self.render_wallet_selection(ui); - }); + // Show description of the selected account below the dropdown + if let Some(summary) = selected_summary + && let Some(description) = summary.category.description() + { + ui.add_space(4.0); + ui.label( + RichText::new(description) + .color(DashColors::text_secondary(dark_mode)) + .italics() + .size(12.0), + ); + } + } + + fn render_transactions_section(&self, ui: &mut Ui) { + ui.add_space(10.0); + ui.heading("Transactions"); + let Some(wallet_arc) = self.selected_wallet.as_ref() else { + ui.label("Select a wallet to view its transaction history."); + return; + }; + + let wallet_guard = wallet_arc.read().unwrap(); + if wallet_guard.transactions.is_empty() { + ui.label("No transactions yet from SPV. Keep your wallet online to sync history."); + return; + } + + let dark_mode = ui.ctx().style().visuals.dark_mode; + let mut order: Vec = (0..wallet_guard.transactions.len()).collect(); + order.sort_by(|&a, &b| { + wallet_guard.transactions[b] + .timestamp + .cmp(&wallet_guard.transactions[a].timestamp) + .then_with(|| { + wallet_guard.transactions[b] + .txid + .cmp(&wallet_guard.transactions[a].txid) + }) + }); + + let row_height = 26.0; + TableBuilder::new(ui) + .id_salt("transactions_table") + .striped(true) + .column(Column::initial(150.0)) // Date + .column(Column::initial(80.0)) // Type + .column(Column::initial(120.0)) // Amount + .column(Column::initial(150.0)) // Status + .column(Column::remainder()) // TxID + .header(row_height, |mut header| { + header.col(|ui| { + ui.label( + RichText::new("Date") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + }); + header.col(|ui| { + ui.label( + RichText::new("Type") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + }); + header.col(|ui| { + ui.label( + RichText::new("Amount") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + }); + header.col(|ui| { + ui.label( + RichText::new("Status") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + }); + header.col(|ui| { + ui.label( + RichText::new("TxID") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + }); + }) + .body(|mut body| { + for idx in order { + let tx = &wallet_guard.transactions[idx]; + body.row(row_height, |mut row| { + row.col(|ui| { + ui.label(Self::format_transaction_timestamp(tx.timestamp)); + }); + row.col(|ui| { + ui.label(Self::transaction_direction_label(tx)); + }); + row.col(|ui| { + let (amount_text, amount_color) = + Self::transaction_amount_display(tx, dark_mode); + ui.label(RichText::new(amount_text).color(amount_color).strong()); + }); + row.col(|ui| { + ui.label(Self::format_transaction_status(tx)); + }); + row.col(|ui| { + let full_txid = tx.txid.to_string(); + ui.horizontal(|ui| { + let response = ui.label(RichText::new(&full_txid).monospace()); + response.on_hover_text(&full_txid); + if ui + .small_button("Copy") + .on_hover_text("Copy transaction ID") + .clicked() + { + let _ = copy_text_to_clipboard(&full_txid); + } + }); }); }); + } + }); + } - ui.add_space(10.0); + fn render_wallet_detail_panel(&mut self, ui: &mut Ui, ctx: &Context) -> AppAction { + let Some(wallet_arc) = self.selected_wallet.clone() else { + self.render_no_wallets_view(ui); + return AppAction::None; + }; - if self.selected_wallet.is_some() { - ui.separator(); - ui.add_space(10.0); + let (alias, _seed_hash, _wallet_is_main) = { + let wallet = wallet_arc.read().unwrap(); + ( + wallet + .alias + .clone() + .unwrap_or_else(|| "Unnamed Wallet".to_string()), + wallet.seed_hash(), + wallet.is_main, + ) + }; + let mut action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; - // Always show the filter selector - ui.vertical(|ui| { + let detail_width = ui.available_width(); + ui.horizontal(|row| { + row.vertical(|col| { + col.set_width(detail_width); + Frame::group(col.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(18, 16)) + .show(col, |ui| { + ui.horizontal(|ui| { ui.heading( - RichText::new("Addresses") - .color(DashColors::text_primary(dark_mode)), + RichText::new(alias.clone()) + .color(DashColors::text_primary(dark_mode)) + .size(25.0), ); - ui.add_space(10.0); - - // Filter section - self.render_filter_selector(ui); - ui.add_space(5.0); - ui.label( - RichText::new("Tip: Hold Shift to select multiple filters") - .color(Color32::GRAY) - .size(10.0) - .italics(), + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if self.refreshing { + ui.add(egui::Spinner::new().color(DashColors::DASH_BLUE)) + } else { + ui.add(egui::Label::new("")) + } + }, ); }); - ui.add_space(10.0); - if !(self.selected_filters.contains("Unused Asset Locks") - && self.selected_filters.len() == 1) - { - inner_action |= self.render_address_table(ui); - } + let summaries = { + let wallet = wallet_arc.read().unwrap(); + self.render_wallet_overview(ui, &wallet); + collect_account_summaries(&wallet) + }; + + self.ensure_account_selection(&summaries); + action |= self.render_action_buttons(ui, ctx); + ui.add_space(10.0); + ui.separator(); + self.render_accounts_section(ui, &summaries); + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + let addresses_heading = self + .selected_account + .as_ref() + .map(|(category, index)| { + format!("Addresses ({})", category.label(*index)) + }) + .unwrap_or_else(|| "Addresses".to_string()); + ui.heading( + RichText::new(addresses_heading) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(8.0); + action |= self.render_address_table(ui); - if self.selected_filters.contains("Unused Asset Locks") { - ui.add_space(15.0); - // Render the asset locks section - inner_action |= self.render_wallet_asset_locks(ui); + // Transactions section - requires SPV which is dev mode only + if self.app_context.is_developer_mode() { + ui.add_space(10.0); + ui.separator(); + self.render_transactions_section(ui); } - ui.add_space(10.0); + ui.add_space(14.0); self.render_bottom_options(ui); - } - }); - inner_action + ui.add_space(16.0); + action |= self.render_wallet_asset_locks(ui); + }); + }); }); - // Rename dialog - if self.show_rename_dialog { - egui::Window::new("Rename Wallet") - .collapsible(false) - .resizable(false) - .show(ctx, |ui| { - ui.vertical(|ui| { - ui.label("Enter new wallet name:"); - ui.add_space(5.0); + action + } - let text_edit = egui::TextEdit::singleline(&mut self.rename_input) - .hint_text("Enter wallet name") - .desired_width(250.0); - ui.add(text_edit); + fn render_send_dialog(&mut self, ctx: &Context) -> AppAction { + if !self.send_dialog.is_open { + return AppAction::None; + } - ui.add_space(10.0); + let mut action = AppAction::None; + let mut open = self.send_dialog.is_open; + egui::Window::new("Send Dash") + .collapsible(false) + .resizable(false) + .open(&mut open) + .show(ctx, |ui| { + ui.label("Recipient Address"); + ui.add(egui::TextEdit::singleline(&mut self.send_dialog.address).hint_text("y...")); + + ui.add_space(8.0); + + // Amount input using AmountInput component + let amount_input = self.send_dialog.amount_input.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .with_label("Amount (DASH):") + .with_hint_text("Enter amount (e.g., 0.01)") + .with_desired_width(150.0) + }); - ui.horizontal(|ui| { - if ui.button("Save").clicked() { - if let Some(selected_wallet) = &self.selected_wallet { - let mut wallet = selected_wallet.write().unwrap(); + let response = amount_input.show(ui); + response.inner.update(&mut self.send_dialog.amount); - // Limit the alias length to 64 characters - if self.rename_input.len() > 64 { - self.rename_input.truncate(64); - } + ui.checkbox( + &mut self.send_dialog.subtract_fee, + "Subtract fee from amount", + ); - wallet.alias = Some(self.rename_input.clone()); + ui.label("Memo (optional)"); + ui.add(egui::TextEdit::singleline(&mut self.send_dialog.memo)); - // Update the alias in the database - let seed_hash = wallet.seed_hash(); - self.app_context - .db - .set_wallet_alias( - &seed_hash, + if let Some(error) = self.send_dialog.error.clone() { + let error_color = Color32::from_rgb(255, 100, 100); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", error)).color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.send_dialog.error = None; + } + }); + }); + } + + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Send").clicked() { + match self.prepare_send_action() { + Ok(app_action) => { + action = app_action; + self.send_dialog = SendDialogState::default(); + } + Err(err) => self.send_dialog.error = Some(err), + } + } + }); + }); + + self.send_dialog.is_open = open; + action + } + + fn render_receive_dialog(&mut self, ctx: &Context) -> AppAction { + if !self.receive_dialog.is_open { + return AppAction::None; + } + + let dark_mode = ctx.style().visuals.dark_mode; + + // Determine current address based on selected type + let current_address = match self.receive_dialog.address_type { + ReceiveAddressType::Core => self + .receive_dialog + .core_addresses + .get(self.receive_dialog.selected_core_index) + .map(|(addr, _)| addr.clone()), + ReceiveAddressType::Platform => self + .receive_dialog + .platform_addresses + .get(self.receive_dialog.selected_platform_index) + .map(|(addr, _)| addr.clone()), + }; + + // Generate QR texture if needed + if let Some(address) = current_address.clone() { + let needs_texture = self.receive_dialog.qr_texture.is_none() + || self.receive_dialog.qr_address.as_deref() != Some(&address); + if needs_texture { + match generate_qr_code_image(&address) { + Ok(image) => { + let texture = ctx.load_texture( + format!("receive_{}", address), + image, + TextureOptions::LINEAR, + ); + self.receive_dialog.qr_texture = Some(texture); + self.receive_dialog.qr_address = Some(address); + } + Err(err) => { + self.receive_dialog.status = Some(err.to_string()); + } + } + } + } + + let mut open = self.receive_dialog.is_open; + + // Draw dark overlay behind the dialog (only when open) + if open { + let screen_rect = ctx.screen_rect(); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("receive_dialog_overlay"), + )); + painter.rect_filled( + screen_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), + ); + } + + egui::Window::new("Receive") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .open(&mut open) + .frame(egui::Frame { + inner_margin: egui::Margin::same(20), + outer_margin: egui::Margin::same(0), + corner_radius: egui::CornerRadius::same(8), + shadow: egui::epaint::Shadow { + offset: [0, 8], + blur: 16, + spread: 0, + color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), + }, + fill: ctx.style().visuals.window_fill, + stroke: egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), + ), + }) + .show(ctx, |ui| { + ui.set_min_width(350.0); + ui.vertical_centered(|ui| { + ui.add_space(5.0); + + // Address type selector at the top + ui.horizontal(|ui| { + ui.selectable_value( + &mut self.receive_dialog.address_type, + ReceiveAddressType::Core, + RichText::new("Core").color(DashColors::text_primary(dark_mode)), + ); + ui.selectable_value( + &mut self.receive_dialog.address_type, + ReceiveAddressType::Platform, + RichText::new("Platform").color(DashColors::text_primary(dark_mode)), + ); + }); + + // Clear QR when switching types + let type_label = match self.receive_dialog.address_type { + ReceiveAddressType::Core => "Core Address", + ReceiveAddressType::Platform => "Platform Address", + }; + + ui.add_space(5.0); + ui.label( + RichText::new(type_label) + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + ui.add_space(10.0); + + // Show QR code + if let Some(texture) = &self.receive_dialog.qr_texture { + ui.image(SizedTexture::new(texture.id(), egui::vec2(220.0, 220.0))); + } else if current_address.is_some() { + ui.label("Generating QR code..."); + } + + ui.add_space(10.0); + + match self.receive_dialog.address_type { + ReceiveAddressType::Core => { + // Core address selector (if multiple addresses) + if self.receive_dialog.core_addresses.len() > 1 { + ui.horizontal(|ui| { + ui.label("Address:"); + ComboBox::from_id_salt("core_addr_selector") + .selected_text( + self.receive_dialog + .core_addresses + .get(self.receive_dialog.selected_core_index) + .map(|(addr, balance)| { + let balance_dash = *balance as f64 / 1e8; + format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + balance_dash + ) + }) + .unwrap_or_default(), + ) + .show_ui(ui, |ui| { + for (idx, (addr, balance)) in + self.receive_dialog.core_addresses.iter().enumerate() + { + let balance_dash = *balance as f64 / 1e8; + let label = format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + balance_dash + ); + if ui + .selectable_label( + idx == self.receive_dialog.selected_core_index, + label, + ) + .clicked() + { + self.receive_dialog.selected_core_index = idx; + // Clear QR so it regenerates + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + } + } + }); + }); + ui.add_space(5.0); + } + + // Show selected Core address + if let Some((address, balance)) = self + .receive_dialog + .core_addresses + .get(self.receive_dialog.selected_core_index) + .cloned() + { + ui.label( + RichText::new(&address) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ); + + let balance_dash = balance as f64 / 1e8; + ui.label( + RichText::new(format!("Balance: {:.8} DASH", balance_dash)) + .color(DashColors::text_secondary(dark_mode)), + ); + + ui.add_space(8.0); + + let mut copy_status: Option = None; + let mut generate_new = false; + + ui.horizontal(|ui| { + if ui.button("Copy Address").clicked() { + if let Err(err) = copy_text_to_clipboard(&address) { + copy_status = Some(format!("Error: {}", err)); + } else { + copy_status = Some("Address copied!".to_string()); + } + } + + if ui.button("New Address").clicked() { + generate_new = true; + } + }); + + if let Some(status) = copy_status { + self.receive_dialog.status = Some(status); + } + + if generate_new + && let Some(wallet) = &self.selected_wallet { + match self.generate_new_core_receive_address(wallet) { + Ok((new_addr, new_balance)) => { + self.receive_dialog.core_addresses.push((new_addr, new_balance)); + self.receive_dialog.selected_core_index = + self.receive_dialog.core_addresses.len() - 1; + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + self.receive_dialog.status = Some("New address generated!".to_string()); + } + Err(err) => { + self.receive_dialog.status = Some(err); + } + } + } + } + + ui.add_space(10.0); + ui.label( + RichText::new("Send Dash to this address to add funds to your wallet.") + .color(DashColors::text_secondary(dark_mode)) + .size(11.0) + .italics(), + ); + } + ReceiveAddressType::Platform => { + // Platform address selector (if multiple addresses) + if self.receive_dialog.platform_addresses.len() > 1 { + ui.horizontal(|ui| { + ui.label("Address:"); + ComboBox::from_id_salt("platform_addr_selector") + .selected_text( + self.receive_dialog + .platform_addresses + .get(self.receive_dialog.selected_platform_index) + .map(|(addr, balance)| { + let credits_as_dash = + *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + credits_as_dash + ) + }) + .unwrap_or_default(), + ) + .show_ui(ui, |ui| { + for (idx, (addr, balance)) in + self.receive_dialog.platform_addresses.iter().enumerate() + { + let credits_as_dash = + *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + let label = format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + credits_as_dash + ); + if ui + .selectable_label( + idx == self.receive_dialog.selected_platform_index, + label, + ) + .clicked() + { + self.receive_dialog.selected_platform_index = idx; + // Clear QR so it regenerates + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + } + } + }); + }); + ui.add_space(5.0); + } + + // Show selected Platform address + let selected_addr_data = self + .receive_dialog + .platform_addresses + .get(self.receive_dialog.selected_platform_index) + .cloned(); + + if let Some((address, balance)) = selected_addr_data { + ui.label( + RichText::new(&address) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ); + + let credits_as_dash = balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label( + RichText::new(format!("Balance: {:.8} DASH", credits_as_dash)) + .color(DashColors::text_secondary(dark_mode)), + ); + + ui.add_space(8.0); + + let mut copy_status: Option = None; + let mut new_addr_result: Option> = None; + + ui.horizontal(|ui| { + if ui.button("Copy Address").clicked() { + if let Err(err) = copy_text_to_clipboard(&address) { + copy_status = Some(format!("Error: {}", err)); + } else { + copy_status = Some("Address copied!".to_string()); + } + } + + // Button to add new Platform address + if let Some(wallet) = &self.selected_wallet + && ui.button("New Address").clicked() + { + new_addr_result = Some(self.generate_platform_address(wallet)); + } + }); + + // Handle copy status after the closure + if let Some(status) = copy_status { + self.receive_dialog.status = Some(status); + } + + // Handle new address generation after the closure + if let Some(result) = new_addr_result { + match result { + Ok(new_addr) => { + self.receive_dialog.platform_addresses.push((new_addr, 0)); + self.receive_dialog.selected_platform_index = + self.receive_dialog.platform_addresses.len() - 1; + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + self.receive_dialog.status = + Some("New address generated!".to_string()); + } + Err(err) => { + self.receive_dialog.status = Some(err); + } + } + } + } + + ui.add_space(10.0); + ui.label( + RichText::new( + "Send credits from an identity or another Platform address to fund this address.", + ) + .color(DashColors::text_secondary(dark_mode)) + .size(11.0) + .italics(), + ); + } + } + + if let Some(status) = &self.receive_dialog.status { + ui.add_space(8.0); + ui.label( + RichText::new(status).color(DashColors::text_secondary(dark_mode)), + ); + } + }); + }); + + self.receive_dialog.is_open = open; + if !self.receive_dialog.is_open { + self.receive_dialog = ReceiveDialogState::default(); + } + AppAction::None + } + + /// Generate a new Platform address for the wallet. + /// Returns the address in DIP-18 Bech32m format (e.g., tdashevo1... for testnet) + fn generate_platform_address(&self, wallet: &Arc>) -> Result { + use dash_sdk::dpp::address_funds::PlatformAddress; + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + // Pass true to skip known addresses and generate a new one + let address = wallet_guard + .platform_receive_address(self.app_context.network, true, Some(&self.app_context)) + .map_err(|e| e.to_string())?; + // Convert to PlatformAddress and encode as Bech32m per DIP-18 + let platform_addr = + PlatformAddress::try_from(address).map_err(|e| format!("Invalid address: {}", e))?; + Ok(platform_addr.to_bech32m_string(self.app_context.network)) + } + + /// Generate a new Core receive address for the wallet + /// Returns (address_string, balance_duffs) + fn generate_new_core_receive_address( + &self, + wallet: &Arc>, + ) -> Result<(String, u64), String> { + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + let address = wallet_guard + .receive_address(self.app_context.network, true, Some(&self.app_context)) + .map_err(|e| e.to_string())?; + let balance = wallet_guard + .address_balances + .get(&address) + .copied() + .unwrap_or(0); + Ok((address.to_string(), balance)) + } + + /// Render the Fund Platform Address from Asset Lock dialog + fn render_fund_platform_dialog(&mut self, ctx: &Context) -> AppAction { + if !self.fund_platform_dialog.is_open { + return AppAction::None; + } + + let mut action = AppAction::None; + let mut open = self.fund_platform_dialog.is_open; + let dark_mode = ctx.style().visuals.dark_mode; + + // Draw dark overlay behind the popup + let screen_rect = ctx.screen_rect(); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("fund_platform_dialog_overlay"), + )); + painter.rect_filled( + screen_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), + ); + + egui::Window::new("Fund Platform Address from Asset Lock") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .open(&mut open) + .frame(egui::Frame { + inner_margin: egui::Margin::same(20), + outer_margin: egui::Margin::same(0), + corner_radius: egui::CornerRadius::same(8), + shadow: egui::epaint::Shadow { + offset: [0, 8], + blur: 16, + spread: 0, + color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), + }, + fill: ctx.style().visuals.window_fill, + stroke: egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), + ), + }) + .show(ctx, |ui| { + ui.set_min_width(400.0); + + ui.vertical(|ui| { + ui.label( + RichText::new("Select a Platform address to fund:") + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(10.0); + + // Platform address selector + if self.fund_platform_dialog.platform_addresses.is_empty() { + ui.label( + RichText::new("No Platform addresses found. Generate one first.") + .color(DashColors::text_secondary(dark_mode)) + .italics(), + ); + } else { + ComboBox::from_id_salt("fund_platform_addr_selector") + .selected_text( + self.fund_platform_dialog + .selected_platform_address + .as_deref() + .map(|addr| { + let balance = self + .fund_platform_dialog + .platform_addresses + .iter() + .find(|(a, _)| a == addr) + .map(|(_, b)| *b) + .unwrap_or(0); + let credits_as_dash = + balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + credits_as_dash + ) + }) + .unwrap_or_else(|| "Select an address".to_string()), + ) + .show_ui(ui, |ui| { + for (addr, balance) in &self.fund_platform_dialog.platform_addresses + { + let credits_as_dash = + *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + let label = format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + credits_as_dash + ); + let is_selected = self + .fund_platform_dialog + .selected_platform_address + .as_deref() + == Some(addr.as_str()); + if ui.selectable_label(is_selected, label).clicked() { + self.fund_platform_dialog.selected_platform_address = + Some(addr.clone()); + } + } + }); + } + + ui.add_space(15.0); + + // Status message + if let Some(status) = &self.fund_platform_dialog.status { + let status_color = if self.fund_platform_dialog.status_is_error { + egui::Color32::from_rgb(220, 50, 50) + } else { + DashColors::text_secondary(dark_mode) + }; + ui.label(RichText::new(status).color(status_color)); + ui.add_space(10.0); + } + + // Buttons + ui.horizontal(|ui| { + let can_fund = self.fund_platform_dialog.selected_platform_address.is_some() + && self.fund_platform_dialog.selected_asset_lock_index.is_some() + && !self.fund_platform_dialog.is_processing; + + // Cancel button + let cancel_button = egui::Button::new( + RichText::new("Cancel").color(DashColors::text_primary(dark_mode)), + ) + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::new(1.0, DashColors::text_secondary(dark_mode))) + .corner_radius(egui::CornerRadius::same(4)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui.add(cancel_button).clicked() { + self.fund_platform_dialog.is_open = false; + } + + ui.add_space(8.0); + + // Fund button + let fund_button = egui::Button::new( + RichText::new(if self.fund_platform_dialog.is_processing { + "Funding..." + } else { + "Fund Address" + }) + .color(egui::Color32::WHITE), + ) + .fill(if can_fund { + DashColors::DASH_BLUE + } else { + DashColors::text_secondary(dark_mode) + }) + .corner_radius(egui::CornerRadius::same(4)) + .min_size(egui::Vec2::new(100.0, 32.0)); + + if ui.add_enabled(can_fund, fund_button).clicked() { + // Check if wallet is locked + let is_locked = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|w| !w.is_open()) + .unwrap_or(false); + + if is_locked { + // Wallet is locked - open unlock popup and set pending flag + self.fund_platform_dialog.pending_fund_after_unlock = true; + self.wallet_unlock_popup.open(); + } else { + action = self.prepare_fund_platform_action(); + } + } + }); + + ui.add_space(10.0); + ui.label( + RichText::new( + "The entire asset lock amount will be used to fund the Platform address.", + ) + .color(DashColors::text_secondary(dark_mode)) + .size(11.0) + .italics(), + ); + }); + }); + + // Only update from `open` if we didn't manually close via cancel button + if self.fund_platform_dialog.is_open { + self.fund_platform_dialog.is_open = open; + } + if !self.fund_platform_dialog.is_open { + self.fund_platform_dialog = FundPlatformAddressDialogState::default(); + } + action + } + + /// Render the Private Key dialog + fn render_private_key_dialog(&mut self, ctx: &Context) { + if !self.private_key_dialog.is_open { + return; + } + + let dark_mode = ctx.style().visuals.dark_mode; + let mut open = self.private_key_dialog.is_open; + + // Draw dark overlay behind the dialog + if open { + let screen_rect = ctx.screen_rect(); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("private_key_dialog_overlay"), + )); + painter.rect_filled( + screen_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), + ); + } + + egui::Window::new("Private Key") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .open(&mut open) + .frame(egui::Frame { + inner_margin: egui::Margin::same(20), + outer_margin: egui::Margin::same(0), + corner_radius: egui::CornerRadius::same(8), + shadow: egui::epaint::Shadow { + offset: [0, 8], + blur: 16, + spread: 0, + color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), + }, + fill: ctx.style().visuals.window_fill, + stroke: egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), + ), + }) + .show(ctx, |ui| { + ui.set_min_width(400.0); + ui.vertical_centered(|ui| { + ui.add_space(5.0); + + // Address label + ui.label( + RichText::new("Address") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + ui.add_space(5.0); + + // Address value + ui.label( + RichText::new(&self.private_key_dialog.address) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ); + + ui.add_space(5.0); + + // Copy address button + if ui.button("Copy Address").clicked() { + let _ = copy_text_to_clipboard(&self.private_key_dialog.address); + } + + ui.add_space(15.0); + ui.separator(); + ui.add_space(15.0); + + // Private key label + ui.label( + RichText::new("Private Key (WIF)") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + ui.add_space(5.0); + + // Private key value (hidden by default) + if self.private_key_dialog.show_key { + ui.label( + RichText::new(&self.private_key_dialog.private_key_wif) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ); + } else { + ui.label( + RichText::new("••••••••••••••••••••••••••••••••••••••••••••••••••••") + .monospace() + .color(DashColors::text_secondary(dark_mode)), + ); + } + + ui.add_space(10.0); + + // Show/Hide and Copy buttons + ui.horizontal(|ui| { + if ui + .button(if self.private_key_dialog.show_key { + "Hide Key" + } else { + "Show Key" + }) + .clicked() + { + self.private_key_dialog.show_key = !self.private_key_dialog.show_key; + } + + if ui.button("Copy Key").clicked() { + let _ = + copy_text_to_clipboard(&self.private_key_dialog.private_key_wif); + } + }); + + ui.add_space(15.0); + + // Warning message + ui.label( + RichText::new("Keep your private key secure. Never share it with anyone.") + .color(DashColors::error_color(dark_mode)) + .size(11.0) + .italics(), + ); + }); + }); + + self.private_key_dialog.is_open = open; + if !self.private_key_dialog.is_open { + self.private_key_dialog = PrivateKeyDialogState::default(); + } + } + + /// Prepare the backend task for funding a Platform address from asset lock + fn prepare_fund_platform_action(&mut self) -> AppAction { + use dash_sdk::dpp::address_funds::PlatformAddress; + use std::collections::BTreeMap; + + let Some(wallet_arc) = &self.selected_wallet else { + self.fund_platform_dialog.status = Some("No wallet selected".to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + }; + + let Some(selected_addr) = &self.fund_platform_dialog.selected_platform_address else { + self.fund_platform_dialog.status = Some("Select a Platform address".to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + }; + + let Some(asset_lock_idx) = self.fund_platform_dialog.selected_asset_lock_index else { + self.fund_platform_dialog.status = Some("No asset lock selected".to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + }; + + // Get the asset lock proof and address from the wallet + let (seed_hash, asset_lock_proof, asset_lock_address, platform_addr) = { + let wallet = match wallet_arc.read() { + Ok(guard) => guard, + Err(e) => { + self.fund_platform_dialog.status = Some(e.to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + } + }; + + let asset_lock = wallet.unused_asset_locks.get(asset_lock_idx); + let Some((_, addr, _, _, Some(proof))) = asset_lock else { + self.fund_platform_dialog.status = + Some("Asset lock not found or not ready".to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + }; + + // Parse the Platform address (Bech32m format: dashevo1.../tdashevo1...) + use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; + let platform_addr = if selected_addr.starts_with("dashevo1") + || selected_addr.starts_with("tdashevo1") + { + match PlatformAddress::from_bech32m_string(selected_addr) { + Ok((addr, _network)) => addr, + Err(e) => { + self.fund_platform_dialog.status = + Some(format!("Invalid Bech32m address: {}", e)); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + } + } + } else { + // Fall back to base58 parsing for backwards compatibility + match selected_addr + .parse::>() + .map_err(|e| e.to_string()) + .and_then(|a| { + PlatformAddress::try_from(a.assume_checked()) + .map_err(|e| format!("Invalid Platform address: {}", e)) + }) { + Ok(addr) => addr, + Err(e) => { + self.fund_platform_dialog.status = Some(e); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + } + } + }; + + ( + wallet.seed_hash(), + Box::new(proof.clone()), + addr.clone(), + platform_addr, + ) + }; + + // Build outputs - fund the entire asset lock to the selected Platform address + let mut outputs: BTreeMap> = BTreeMap::new(); + outputs.insert(platform_addr, None); // None = take the full amount + + self.fund_platform_dialog.is_processing = true; + self.fund_platform_dialog.status = Some("Processing...".to_string()); + self.fund_platform_dialog.status_is_error = false; + + AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::FundPlatformAddressFromAssetLock { + seed_hash, + asset_lock_proof, + asset_lock_address, + outputs, + }, + )) + } + + fn prepare_send_action(&mut self) -> Result { + let wallet = self + .selected_wallet + .as_ref() + .ok_or_else(|| "Select a wallet first".to_string())?; + + let amount_duffs = self + .send_dialog + .amount + .as_ref() + .ok_or_else(|| "Enter an amount".to_string())? + .dash_to_duffs()?; + + if amount_duffs == 0 { + return Err("Amount must be greater than 0".to_string()); + } + + { + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + if amount_duffs > wallet_guard.confirmed_balance_duffs() { + return Err("Insufficient confirmed balance".to_string()); + } + } + + if self.send_dialog.address.trim().is_empty() { + return Err("Enter a recipient address".to_string()); + } + + let memo = self.send_dialog.memo.trim(); + let request = WalletPaymentRequest { + recipients: vec![PaymentRecipient { + address: self.send_dialog.address.trim().to_string(), + amount_duffs, + }], + subtract_fee_from_amount: self.send_dialog.subtract_fee, + memo: if memo.is_empty() { + None + } else { + Some(memo.to_string()) + }, + override_fee: None, + }; + + Ok(AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::SendWalletPayment { + wallet: wallet.clone(), + request, + }, + ))) + } + + fn open_receive_dialog(&mut self, _ctx: &Context) -> AppAction { + let Some(wallet) = self.selected_wallet.clone() else { + self.receive_dialog.status = Some("Select a wallet first".to_string()); + self.receive_dialog.core_addresses.clear(); + self.receive_dialog.platform_addresses.clear(); + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + self.receive_dialog.is_open = true; + return AppAction::None; + }; + + self.receive_dialog.is_open = true; + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + + // Load Core addresses (works with locked wallet - uses existing addresses) + self.load_core_addresses_for_receive(&wallet); + + // Load Platform addresses (works with locked wallet - uses existing addresses) + self.load_platform_addresses_for_receive(&wallet); + + AppAction::None + } + + /// Load Core addresses into the receive dialog + fn load_core_addresses_for_receive(&mut self, wallet: &Arc>) { + let wallet_guard = match wallet.read() { + Ok(guard) => guard, + Err(err) => { + self.receive_dialog.status = Some(err.to_string()); + return; + } + }; + + // Collect all BIP44 external (receive) addresses with their balances + let network = self.app_context.network; + let core_addresses: Vec<(String, u64)> = wallet_guard + .watched_addresses + .iter() + .filter(|(path, _)| path.is_bip44_external(network)) + .map(|(_, info)| { + let balance = wallet_guard + .address_balances + .get(&info.address) + .copied() + .unwrap_or(0); + (info.address.to_string(), balance) + }) + .collect(); + + drop(wallet_guard); + + if core_addresses.is_empty() { + // Generate a new Core address if none exists + match self.generate_new_core_receive_address(wallet) { + Ok((address, balance)) => { + self.receive_dialog.core_addresses = vec![(address, balance)]; + self.receive_dialog.selected_core_index = 0; + } + Err(err) => { + self.receive_dialog.status = Some(err); + self.receive_dialog.core_addresses.clear(); + } + } + } else { + self.receive_dialog.core_addresses = core_addresses; + self.receive_dialog.selected_core_index = 0; + } + } + + /// Load Platform addresses into the receive dialog + fn load_platform_addresses_for_receive(&mut self, wallet: &Arc>) { + let wallet_guard = match wallet.read() { + Ok(guard) => guard, + Err(err) => { + self.receive_dialog.status = Some(err.to_string()); + return; + } + }; + + // Collect Platform addresses with their balances (using DIP-18 Bech32m format) + let network = self.app_context.network; + let platform_addresses: Vec<(String, u64)> = wallet_guard + .platform_address_info + .iter() + .filter_map(|(addr, info)| { + use dash_sdk::dpp::address_funds::PlatformAddress; + PlatformAddress::try_from(addr.clone()) + .ok() + .map(|pa| (pa.to_bech32m_string(network), info.balance)) + }) + .collect(); + + drop(wallet_guard); + + if platform_addresses.is_empty() { + // Generate a new Platform address if none exists + match self.generate_platform_address(wallet) { + Ok(address) => { + self.receive_dialog.platform_addresses = vec![(address, 0)]; + self.receive_dialog.selected_platform_index = 0; + } + Err(err) => { + self.receive_dialog.status = Some(err); + self.receive_dialog.platform_addresses.clear(); + } + } + } else { + self.receive_dialog.platform_addresses = platform_addresses; + self.receive_dialog.selected_platform_index = 0; + } + } + + fn categorize_path( + path: &DerivationPath, + reference: DerivationPathReference, + ) -> (AccountCategory, Option) { + let category = AccountCategory::from_reference(reference); + let index = match category { + AccountCategory::Bip44 | AccountCategory::Bip32 => path.bip44_account_index(), + _ => None, + }; + (category, index) + } + + fn ensure_account_selection(&mut self, summaries: &[AccountSummary]) { + if summaries.is_empty() { + self.selected_account = None; + return; + } + + if let Some((cat, idx)) = &self.selected_account + && summaries + .iter() + .any(|summary| &summary.category == cat && summary.index == *idx) + { + return; + } + + if let Some(first) = summaries.first() { + self.selected_account = Some((first.category.clone(), first.index)); + } + } + + fn derive_private_key_wif(&self, path: &DerivationPath) -> Result { + let wallet_arc = self + .selected_wallet + .clone() + .ok_or_else(|| "Select a wallet first".to_string())?; + let wallet = wallet_arc.read().map_err(|e| e.to_string())?; + if wallet.uses_password && !wallet.is_open() { + return Err("Unlock this wallet to view private keys.".to_string()); + } + let private_key = wallet.private_key_at_derivation_path(path, self.app_context.network)?; + Ok(private_key.to_wif()) + } + + fn lock_selected_wallet(&mut self) { + let Some(wallet_arc) = self.selected_wallet.clone() else { + return; + }; + + let locked = { + let mut wallet = match wallet_arc.write() { + Ok(guard) => guard, + Err(err) => { + self.display_message( + &format!("Failed to lock wallet: {}", err), + MessageType::Error, + ); + return; + } + }; + + if !wallet.is_open() { + return; + } + + wallet.wallet_seed.close(); + true + }; + + if locked { + self.app_context.handle_wallet_locked(&wallet_arc); + self.display_message("Wallet locked", MessageType::Info); + } + } + + /// Render the detail view for a selected single key wallet + fn render_single_key_wallet_view(&mut self, ui: &mut Ui, dark_mode: bool) -> AppAction { + let mut action = AppAction::None; + + let wallet_arc = match &self.selected_single_key_wallet { + Some(w) => w.clone(), + None => return action, + }; + + let wallet = wallet_arc.read().unwrap(); + let address = wallet.address.to_string(); + let alias = wallet + .alias + .clone() + .unwrap_or_else(|| "Unnamed Key".to_string()); + let balance_duffs = wallet.total_balance_duffs(); + let balance_dash = balance_duffs as f64 * 1e-8; + let utxo_count = wallet.utxos.len(); + let utxos: Vec<_> = wallet.utxos.iter().map(|(o, t)| (*o, t.clone())).collect(); + drop(wallet); + + let text_color = DashColors::text_primary(dark_mode); + + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(16, 16)) + .show(ui, |ui| { + ui.vertical(|ui| { + ui.heading(RichText::new(&alias).strong().color(text_color)); + ui.add_space(10.0); + + // Balance info + ui.label(RichText::new(format!("Balance: {:.8} DASH", balance_dash))); + ui.add_space(10.0); + + // Action buttons for SK wallet + ui.horizontal(|ui| { + if ui + .button(RichText::new("Send").color(text_color).strong()) + .clicked() + { + action = AppAction::AddScreen( + crate::ui::ScreenType::SingleKeyWalletSendScreen( + wallet_arc.clone(), + ) + .create_screen(&self.app_context), + ); + } + + if ui + .button(RichText::new("Receive").color(text_color)) + .clicked() + { + self.receive_dialog.core_addresses = + vec![(address.clone(), balance_duffs)]; + self.receive_dialog.selected_core_index = 0; + self.receive_dialog.is_open = true; + } + }); + ui.add_space(15.0); + + // UTXOs section + ui.separator(); + ui.add_space(10.0); + ui.heading(RichText::new(format!("UTXOs ({})", utxo_count)).color(text_color)); + ui.add_space(10.0); + + if utxos.is_empty() { + ui.label("No UTXOs available. Click 'Refresh' to load UTXOs from Core."); + } else { + const UTXOS_PER_PAGE: usize = 50; + let total_pages = utxo_count.div_ceil(UTXOS_PER_PAGE); + + // Ensure current page is valid + if self.utxo_page >= total_pages { + self.utxo_page = total_pages.saturating_sub(1); + } + + let start_idx = self.utxo_page * UTXOS_PER_PAGE; + let utxos_page: Vec<_> = + utxos.iter().skip(start_idx).take(UTXOS_PER_PAGE).collect(); + + // Pagination controls + if total_pages > 1 { + ui.horizontal(|ui| { + if ui + .add_enabled(self.utxo_page > 0, egui::Button::new("<< First")) + .clicked() + { + self.utxo_page = 0; + } + if ui + .add_enabled(self.utxo_page > 0, egui::Button::new("< Prev")) + .clicked() + { + self.utxo_page = self.utxo_page.saturating_sub(1); + } + + ui.label(format!( + "Page {} of {} ({}-{} of {})", + self.utxo_page + 1, + total_pages, + start_idx + 1, + (start_idx + utxos_page.len()).min(utxo_count), + utxo_count + )); + + if ui + .add_enabled( + self.utxo_page < total_pages - 1, + egui::Button::new("Next >"), + ) + .clicked() + { + self.utxo_page += 1; + } + if ui + .add_enabled( + self.utxo_page < total_pages - 1, + egui::Button::new("Last >>"), + ) + .clicked() + { + self.utxo_page = total_pages - 1; + } + }); + ui.add_space(10.0); + } + + egui::ScrollArea::vertical() + .max_height(300.0) + .show(ui, |ui| { + for (outpoint, tx_out) in utxos_page { + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode).gamma_multiply(0.9)) + .inner_margin(Margin::symmetric(10, 8)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label("TxID:"); + ui.label( + RichText::new(format!( + "{}:{}", + outpoint.txid, outpoint.vout + )) + .monospace() + .size(11.0) + .color(text_color), + ); + }); + ui.horizontal(|ui| { + ui.label("Amount:"); + ui.label( + RichText::new(format!( + "{:.8} DASH", + tx_out.value as f64 * 1e-8 + )) + .strong() + .color(text_color), + ); + }); + }); + }); + }); + ui.add_space(5.0); + } + }); + } + }); + }); + + action + } + + /// Creates the appropriate refresh action based on the current refresh mode + fn create_refresh_action(&self, wallet_arc: &Arc>) -> AppAction { + use crate::backend_task::wallet::PlatformSyncMode; + + let seed_hash = wallet_arc + .read() + .ok() + .map(|w| w.seed_hash()) + .unwrap_or_default(); + + match self.refresh_mode { + RefreshMode::All => { + // Default behavior: Core + Platform (Auto) + AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( + wallet_arc.clone(), + Some(PlatformSyncMode::Auto), + ))) + } + RefreshMode::CoreOnly => { + // Core only, no Platform sync + AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( + wallet_arc.clone(), + None, + ))) + } + RefreshMode::PlatformFull => { + // Platform only with forced full sync + AppAction::BackendTask(BackendTask::WalletTask( + crate::backend_task::wallet::WalletTask::FetchPlatformAddressBalances { + seed_hash, + sync_mode: PlatformSyncMode::ForceFull, + }, + )) + } + RefreshMode::PlatformTerminal => { + // Platform only with terminal sync + AppAction::BackendTask(BackendTask::WalletTask( + crate::backend_task::wallet::WalletTask::FetchPlatformAddressBalances { + seed_hash, + sync_mode: PlatformSyncMode::TerminalOnly, + }, + )) + } + RefreshMode::CoreAndPlatformFull => { + // Core + Platform with forced full sync + AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( + wallet_arc.clone(), + Some(PlatformSyncMode::ForceFull), + ))) + } + RefreshMode::CoreAndPlatformTerminal => { + // Core + Platform with terminal sync + AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( + wallet_arc.clone(), + Some(PlatformSyncMode::TerminalOnly), + ))) + } + } + } + + /// Creates the appropriate refresh action using the pending refresh mode + fn create_pending_refresh_action(&self, wallet_arc: &Arc>) -> AppAction { + use crate::backend_task::wallet::PlatformSyncMode; + + let seed_hash = wallet_arc + .read() + .ok() + .map(|w| w.seed_hash()) + .unwrap_or_default(); + + match self.pending_refresh_mode { + RefreshMode::All => AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::RefreshWalletInfo(wallet_arc.clone(), Some(PlatformSyncMode::Auto)), + )), + RefreshMode::CoreOnly => AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::RefreshWalletInfo(wallet_arc.clone(), None), + )), + RefreshMode::PlatformFull => AppAction::BackendTask(BackendTask::WalletTask( + crate::backend_task::wallet::WalletTask::FetchPlatformAddressBalances { + seed_hash, + sync_mode: PlatformSyncMode::ForceFull, + }, + )), + RefreshMode::PlatformTerminal => AppAction::BackendTask(BackendTask::WalletTask( + crate::backend_task::wallet::WalletTask::FetchPlatformAddressBalances { + seed_hash, + sync_mode: PlatformSyncMode::TerminalOnly, + }, + )), + RefreshMode::CoreAndPlatformFull => AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::RefreshWalletInfo(wallet_arc.clone(), Some(PlatformSyncMode::ForceFull)), + )), + RefreshMode::CoreAndPlatformTerminal => { + AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( + wallet_arc.clone(), + Some(PlatformSyncMode::TerminalOnly), + ))) + } + } + } +} + +impl ScreenLike for WalletsBalancesScreen { + fn ui(&mut self, ctx: &Context) -> AppAction { + self.check_message_expiration(); + + // Check for pending platform balance refresh (triggered after transfers) + let pending_refresh_action = + if let Some(seed_hash) = self.pending_platform_balance_refresh.take() { + AppAction::BackendTask(BackendTask::WalletTask( + crate::backend_task::wallet::WalletTask::FetchPlatformAddressBalances { + seed_hash, + sync_mode: crate::backend_task::wallet::PlatformSyncMode::Auto, + }, + )) + } else { + AppAction::None + }; + + let mut right_buttons = vec![ + ( + "Import Wallet", + DesiredAppAction::AddScreenType(Box::new(ScreenType::ImportMnemonic)), + ), + ( + "Create Wallet", + DesiredAppAction::AddScreenType(Box::new(ScreenType::AddNewWallet)), + ), + ]; + + // Add Refresh button for HD wallet + if !self.refreshing + && self.app_context.core_backend_mode() == CoreBackendMode::Rpc + && self.selected_wallet.is_some() + { + right_buttons.push(( + "Refresh", + DesiredAppAction::Custom("RefreshHDWallet".to_string()), + )); + } + + // Add Refresh button for single key wallet + if !self.refreshing + && self.app_context.core_backend_mode() == CoreBackendMode::Rpc + && self.selected_single_key_wallet.is_some() + { + right_buttons.push(( + "Refresh", + DesiredAppAction::Custom("RefreshSKWallet".to_string()), + )); + } + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![("Wallets", AppAction::None)], + right_buttons, + ); + + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenWalletsBalances, + ); + + action |= island_central_panel(ctx, |ui| { + let mut inner_action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Display messages at the top, outside of scroll area + let message = self.message.clone(); + if let Some((message, message_type, _timestamp)) = message { + let message_color = match message_type { + MessageType::Error => egui::Color32::from_rgb(255, 100, 100), + MessageType::Info => DashColors::text_primary(dark_mode), + MessageType::Success => egui::Color32::DARK_GREEN, + }; + + // Display message in a prominent frame with text wrapping + Frame::new() + .fill(message_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, message_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.add( + egui::Label::new( + egui::RichText::new(&message).color(message_color), + ) + .wrap(), + ); + ui.add_space(5.0); + if ui.small_button("Dismiss").clicked() { + self.dismiss_message(); + } + }); + }); + ui.add_space(10.0); + } + + egui::ScrollArea::vertical() + .auto_shrink([true; 2]) + .show(ui, |ui| { + let has_hd_wallets = !self.app_context.wallets.read().unwrap().is_empty(); + let has_single_key_wallets = !self + .app_context + .single_key_wallets + .read() + .unwrap() + .is_empty(); + + if !has_hd_wallets && !has_single_key_wallets { + self.render_no_wallets_view(ui); + return; + } + + // Unified wallet selector (includes both HD and single key wallets) + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode)) + .inner_margin(Margin::symmetric(16, 12)) + .show(ui, |ui| { + inner_action |= self.render_wallet_selection(ui); + }); + + ui.add_space(10.0); + + // Render the appropriate detail view based on selection + if self.selected_wallet.is_some() { + inner_action |= self.render_wallet_detail_panel(ui, ctx); + } else if self.selected_single_key_wallet.is_some() { + inner_action |= self.render_single_key_wallet_view(ui, dark_mode); + } + }); + + inner_action + }); + + action |= self.render_send_dialog(ctx); + action |= self.render_receive_dialog(ctx); + action |= self.render_fund_platform_dialog(ctx); + self.render_private_key_dialog(ctx); + + // Rename dialog + if self.show_rename_dialog { + egui::Window::new("Rename Wallet") + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.label("Enter new wallet name:"); + ui.add_space(5.0); + + let text_edit = egui::TextEdit::singleline(&mut self.rename_input) + .hint_text("Enter wallet name") + .desired_width(250.0); + ui.add(text_edit); + + ui.add_space(10.0); + + ui.horizontal(|ui| { + if ui.button("Save").clicked() { + // Limit the alias length to 64 characters + if self.rename_input.len() > 64 { + self.rename_input.truncate(64); + } + + // Handle HD wallet rename + if let Some(selected_wallet) = &self.selected_wallet { + let mut wallet = selected_wallet.write().unwrap(); + wallet.alias = Some(self.rename_input.clone()); + + // Update the alias in the database + let seed_hash = wallet.seed_hash(); + self.app_context + .db + .set_wallet_alias( + &seed_hash, Some(self.rename_input.clone()), ) .ok(); } + // Handle single key wallet rename + else if let Some(selected_sk_wallet) = + &self.selected_single_key_wallet + { + let mut wallet = selected_sk_wallet.write().unwrap(); + wallet.alias = Some(self.rename_input.clone()); + + // Update the alias in the database + let key_hash = wallet.key_hash; + self.app_context + .db + .update_single_key_wallet_alias( + &key_hash, + Some(&self.rename_input), + ) + .ok(); + } + self.show_rename_dialog = false; self.rename_input.clear(); } @@ -1107,63 +3468,449 @@ impl ScreenLike for WalletsBalancesScreen { }); } - if let AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo(_))) = + // HD Wallet unlock popup + if let Some(wallet_arc) = &self.selected_wallet.clone() { + let result = self + .wallet_unlock_popup + .show(ctx, wallet_arc, &self.app_context); + match result { + WalletUnlockResult::Unlocked => { + // Check if we were trying to view a private key + if let Some(path) = self.private_key_dialog.pending_derivation_path.take() + && let Some(address) = self.private_key_dialog.pending_address.take() + { + match self.derive_private_key_wif(&path) { + Ok(key) => { + self.private_key_dialog.is_open = true; + self.private_key_dialog.address = address; + self.private_key_dialog.private_key_wif = key; + self.private_key_dialog.show_key = false; + } + Err(err) => { + self.display_message(&err, MessageType::Error); + } + } + } + + // Check if we were trying to fund a Platform address + if self.fund_platform_dialog.pending_fund_after_unlock { + self.fund_platform_dialog.pending_fund_after_unlock = false; + action |= self.prepare_fund_platform_action(); + } + + // Check if we were trying to refresh the wallet + // Note: handle_wallet_unlocked also queues a refresh in the background, + // but we dispatch our own so the UI gets the result and can stop the spinner + if self.pending_refresh_after_unlock { + self.pending_refresh_after_unlock = false; + if let Some(wallet_arc) = &self.selected_wallet { + self.refreshing = true; + action |= self.create_pending_refresh_action(wallet_arc); + } + } + + // Check if we were trying to search for asset locks + if self.pending_asset_lock_search_after_unlock { + self.pending_asset_lock_search_after_unlock = false; + if let Some(wallet_arc) = self.selected_wallet.clone() { + self.display_message( + "Searching for unused asset locks...", + MessageType::Info, + ); + action |= AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::RecoverAssetLocks(wallet_arc), + )); + } + } + } + WalletUnlockResult::Cancelled => { + // Clear any pending private key view request on cancel + self.private_key_dialog.pending_derivation_path = None; + self.private_key_dialog.pending_address = None; + + // Clear pending fund request on cancel + self.fund_platform_dialog.pending_fund_after_unlock = false; + + // Clear pending refresh request on cancel + self.pending_refresh_after_unlock = false; + + // Clear pending asset lock search on cancel + self.pending_asset_lock_search_after_unlock = false; + } + WalletUnlockResult::Pending => {} + } + } + + // SK wallet unlock dialog + if self.show_sk_unlock_dialog { + let mut close_dialog = false; + egui::Window::new("Unlock Wallet") + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + ui.vertical(|ui| { + if let Some(wallet_arc) = &self.selected_single_key_wallet + && let Ok(wallet) = wallet_arc.read() { + if let Some(alias) = &wallet.alias { + ui.label(format!( + "Wallet \"{}\" is locked. Please enter the password to unlock it:", + alias + )); + } else { + ui.label("This wallet is locked. Please enter the password to unlock it:"); + } + } + + ui.add_space(10.0); + + let dark_mode = ui.ctx().style().visuals.dark_mode; + let mut attempt_unlock = false; + + ui.horizontal(|ui| { + let password_input = ui.add( + egui::TextEdit::singleline(&mut self.sk_wallet_password) + .password(!self.sk_show_password) + .hint_text("Enter password") + .desired_width(250.0) + .text_color(DashColors::text_primary(dark_mode)) + .background_color(DashColors::input_background(dark_mode)), + ); + + if password_input.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)) + { + attempt_unlock = true; + } + }); + + ui.add_space(5.0); + + ui.checkbox(&mut self.sk_show_password, "Show Password"); + + ui.add_space(10.0); + + ui.horizontal(|ui| { + if ui.button("Unlock").clicked() { + attempt_unlock = true; + } + + if ui.button("Cancel").clicked() { + close_dialog = true; + } + }); + + if attempt_unlock { + if let Some(wallet_arc) = &self.selected_single_key_wallet { + let mut wallet = wallet_arc.write().unwrap(); + let unlock_result = wallet.open(&self.sk_wallet_password); + + match unlock_result { + Ok(_) => { + self.sk_error_message = None; + close_dialog = true; + } + Err(_) => { + self.sk_error_message = + Some("Incorrect Password".to_string()); + } + } + } + self.sk_wallet_password.clear(); + } + + // Display error message if the password was incorrect + if let Some(error_message) = self.sk_error_message.clone() { + ui.add_space(5.0); + let error_color = Color32::from_rgb(255, 100, 100); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("Error: {}", error_message)).color(error_color)); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.sk_error_message = None; + } + }); + }); + } + }); + }); + + if close_dialog { + self.show_sk_unlock_dialog = false; + self.sk_wallet_password.clear(); + self.sk_error_message = None; + + // Check if we were trying to refresh the SK wallet + if self.pending_refresh_after_unlock { + self.pending_refresh_after_unlock = false; + if let Some(wallet_arc) = &self.selected_single_key_wallet { + self.refreshing = true; + action |= AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::RefreshSingleKeyWalletInfo(wallet_arc.clone()), + )); + } + } + } + } + + if let AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo(_, _))) = action { self.refreshing = true; } + // Handle custom refresh actions - check wallet lock status + if let AppAction::Custom(ref cmd) = action { + if cmd == "RefreshHDWallet" { + if let Some(wallet_arc) = &self.selected_wallet { + let is_locked = wallet_arc.read().map(|w| !w.is_open()).unwrap_or(true); + if is_locked { + // Wallet is locked - open unlock popup and store the refresh mode + self.pending_refresh_after_unlock = true; + self.pending_refresh_mode = self.refresh_mode; + self.wallet_unlock_popup.open(); + action = AppAction::None; + } else { + // Wallet is unlocked - proceed with refresh using selected mode + self.refreshing = true; + action = self.create_refresh_action(wallet_arc); + } + } + } else if cmd == "RefreshSKWallet" + && let Some(wallet_arc) = &self.selected_single_key_wallet + { + let is_locked = wallet_arc.read().map(|w| !w.is_open()).unwrap_or(true); + if is_locked { + // SK wallet is locked - open unlock dialog + self.pending_refresh_after_unlock = true; + self.show_sk_unlock_dialog = true; + action = AppAction::None; + } else { + // SK wallet is unlocked - proceed with refresh + self.refreshing = true; + action = AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::RefreshSingleKeyWalletInfo(wallet_arc.clone()), + )); + } + } else if cmd == "SearchAssetLocks" + && let Some(wallet_arc) = self.selected_wallet.clone() + { + let is_locked = wallet_arc.read().map(|w| !w.is_open()).unwrap_or(true); + if is_locked { + // Wallet is locked - open unlock popup + self.pending_asset_lock_search_after_unlock = true; + self.wallet_unlock_popup.open(); + action = AppAction::None; + } else { + // Wallet is unlocked - proceed with search + self.display_message("Searching for unused asset locks...", MessageType::Info); + action = AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::RecoverAssetLocks(wallet_arc), + )); + } + } + } + + // Combine with pending refresh action + action |= pending_refresh_action; action } fn display_message(&mut self, message: &str, message_type: MessageType) { - if message.contains("Successfully refreshed wallet") - || message.contains("Error refreshing wallet") - { + if let MessageType::Error = message_type { self.refreshing = false; + + // If the fund platform dialog is processing, show error in the dialog instead + if self.fund_platform_dialog.is_processing { + self.fund_platform_dialog.is_processing = false; + self.fund_platform_dialog.status = Some(message.to_string()); + self.fund_platform_dialog.status_is_error = true; + return; + } } self.message = Some((message.to_string(), message_type, Utc::now())) } fn display_task_result( &mut self, - _backend_task_success_result: crate::ui::BackendTaskSuccessResult, + backend_task_success_result: crate::ui::BackendTaskSuccessResult, ) { - // Nothing - // If we don't include this, messages from the ZMQ listener will keep popping up - } - - fn refresh_on_arrival(&mut self) {} - - fn refresh(&mut self) {} -} - -impl ScreenWithWalletUnlock for WalletsBalancesScreen { - fn selected_wallet_ref(&self) -> &Option>> { - &self.selected_wallet - } - - fn wallet_password_ref(&self) -> &String { - &self.wallet_password - } - - fn wallet_password_mut(&mut self) -> &mut String { - &mut self.wallet_password - } - - fn show_password(&self) -> bool { - self.show_password + match backend_task_success_result { + crate::ui::BackendTaskSuccessResult::RefreshedWallet { warning } => { + self.refreshing = false; + if let Some(warn_msg) = warning { + self.message = Some(( + format!("Wallet refreshed with warning: {}", warn_msg), + MessageType::Info, + Utc::now(), + )); + } else { + self.message = Some(( + "Successfully refreshed wallet".to_string(), + MessageType::Success, + Utc::now(), + )); + } + } + crate::ui::BackendTaskSuccessResult::RecoveredAssetLocks { + recovered_count, + total_amount, + } => { + let msg = if recovered_count == 0 { + "No additional unused asset locks found".to_string() + } else { + format!( + "Found {} unused asset lock(s) worth {} Dash", + recovered_count, + Self::format_dash(total_amount) + ) + }; + self.display_message(&msg, MessageType::Success); + } + crate::ui::BackendTaskSuccessResult::WalletPayment { + txid, + recipients, + total_amount, + } => { + let msg = if recipients.len() == 1 { + let (address, amount) = &recipients[0]; + format!( + "Sent {} to {}\nTxID: {}", + Self::format_dash(*amount), + address, + txid + ) + } else { + format!( + "Sent {} total to {} recipients\nTxID: {}", + Self::format_dash(total_amount), + recipients.len(), + txid + ) + }; + self.display_message(&msg, MessageType::Success); + } + crate::ui::BackendTaskSuccessResult::GeneratedReceiveAddress { seed_hash, address } => { + if let Some(selected) = &self.selected_wallet + && let Ok(wallet) = selected.read() + && wallet.seed_hash() == seed_hash + { + // Parse address and get balance + let balance = address + .parse::>() + .ok() + .and_then(|addr| { + wallet.address_balances.get(&addr.assume_checked()).copied() + }) + .unwrap_or(0); + self.receive_dialog + .core_addresses + .push((address.clone(), balance)); + self.receive_dialog.selected_core_index = + self.receive_dialog.core_addresses.len() - 1; + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + self.receive_dialog.status = None; + } + } + crate::ui::BackendTaskSuccessResult::PlatformAddressWithdrawal { .. } => { + self.display_message("Platform withdrawal successful. Note: It may take a few minutes for funds to appear on the Core chain.", MessageType::Success); + } + crate::ui::BackendTaskSuccessResult::PlatformAddressFunded { .. } => { + self.fund_platform_dialog.is_processing = false; + self.fund_platform_dialog.status = Some("Funding successful!".to_string()); + self.fund_platform_dialog.status_is_error = false; + self.display_message("Platform address funded successfully", MessageType::Success); + } + crate::ui::BackendTaskSuccessResult::PlatformCreditsTransferred { seed_hash } => { + self.display_message( + "Platform credits transferred successfully", + MessageType::Success, + ); + // Schedule a refresh of platform address balances to update the UI + self.pending_platform_balance_refresh = Some(seed_hash); + } + crate::ui::BackendTaskSuccessResult::PlatformAddressBalances { + seed_hash, + balances, + } => { + self.refreshing = false; + // Update wallet's platform_address_info if this is for the selected wallet + if let Some(selected) = &self.selected_wallet + && let Ok(mut wallet) = selected.write() + && wallet.seed_hash() == seed_hash + { + // Update balances in the wallet + for (addr_str, (balance, nonce)) in balances { + // Find the address that matches the string + if let Some((addr, _)) = wallet + .platform_address_info + .iter() + .find(|(a, _)| a.to_string() == addr_str) + { + let addr = addr.clone(); + wallet.set_platform_address_info(addr, balance, nonce); + } + } + } + self.message = Some(( + "Successfully synced Platform balances".to_string(), + MessageType::Success, + Utc::now(), + )); + } + crate::ui::BackendTaskSuccessResult::Message(msg) => { + self.refreshing = false; + self.display_message(&msg, MessageType::Success); + } + _ => {} + } } - fn show_password_mut(&mut self) -> &mut bool { - &mut self.show_password - } + fn refresh_on_arrival(&mut self) { + // Check if there's a pending wallet selection (e.g., from wallet creation/import) + if let Ok(mut pending) = self.app_context.pending_wallet_selection.lock() + && let Some(seed_hash) = pending.take() + && let Ok(wallets) = self.app_context.wallets.read() + && let Some(wallet) = wallets.get(&seed_hash) + { + self.selected_wallet = Some(wallet.clone()); + self.selected_single_key_wallet = None; // Clear SK selection + self.selected_account = None; + // Persist selection to AppContext and database + if let Ok(mut guard) = self.app_context.selected_wallet_hash.lock() { + *guard = Some(seed_hash); + } + if let Ok(mut guard) = self.app_context.selected_single_key_hash.lock() { + *guard = None; + } + let _ = self + .app_context + .db + .update_selected_wallet_hash(Some(&seed_hash)); + let _ = self.app_context.db.update_selected_single_key_hash(None); + return; + } - fn set_error_message(&mut self, error_message: Option) { - self.error_message = error_message; + // If no wallet of either type is selected but wallets exist, select the first HD wallet + if self.selected_wallet.is_none() && self.selected_single_key_wallet.is_none() { + if let Ok(wallets) = self.app_context.wallets.read() + && let Some(wallet) = wallets.values().next().cloned() + { + self.selected_wallet = Some(wallet); + return; + } + // If no HD wallets, try single key wallets + if let Ok(wallets) = self.app_context.single_key_wallets.read() { + self.selected_single_key_wallet = wallets.values().next().cloned(); + } + } } - fn error_message(&self) -> Option<&String> { - self.error_message.as_ref() - } + fn refresh(&mut self) {} } diff --git a/src/ui/welcome_screen.rs b/src/ui/welcome_screen.rs new file mode 100644 index 000000000..1a5a6e5e2 --- /dev/null +++ b/src/ui/welcome_screen.rs @@ -0,0 +1,204 @@ +use crate::app::AppAction; +use crate::context::AppContext; +use crate::ui::components::left_panel::load_svg_icon; +use crate::ui::components::styled::island_central_panel; +use crate::ui::theme::{DashColors, Shadow, Shape, Spacing}; +use crate::ui::{RootScreenType, ScreenType}; +use egui::{Context, RichText, ScrollArea, Vec2}; +use std::sync::Arc; + +/// The action the user wants to take after onboarding +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OnboardingAction { + LoadWallet, + CreateWallet, + ImportIdentity, + JustBrowse, +} + +pub struct WelcomeScreen { + pub app_context: Arc, +} + +impl WelcomeScreen { + pub fn new(app_context: Arc) -> Self { + Self { app_context } + } + + pub fn ui(&mut self, ctx: &Context) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ctx.style().visuals.dark_mode; + + // Central panel with welcome content (using island style like other screens) + island_central_panel(ctx, |ui| { + ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.vertical_centered(|ui| { + ui.add_space(80.0); + + // Logo + if let Some(logo) = load_svg_icon(ctx, "dashlogo.svg", 200, 80) { + ui.add( + egui::Image::new(&logo).fit_to_exact_size(Vec2::new(150.0, 60.0)), + ); + } + + ui.add_space(24.0); + + // Title + ui.label( + RichText::new("Welcome to Dash Evo Tool") + .size(28.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + + ui.add_space(8.0); + + // Subtitle + ui.label( + RichText::new("Your gateway to decentralized data") + .size(16.0) + .color(DashColors::text_secondary(dark_mode)), + ); + + ui.add_space(50.0); + + // Instructional text + ui.label( + RichText::new("Select an option to get started:") + .size(14.0) + .color(DashColors::text_secondary(dark_mode)), + ); + + ui.add_space(16.0); + + // Getting Started section - cards directly trigger navigation + action |= self.render_getting_started_section(ui, dark_mode); + + ui.add_space(40.0); + }); + }); + }); + + action + } + + fn render_getting_started_section(&mut self, ui: &mut egui::Ui, dark_mode: bool) -> AppAction { + let card_spacing = 16.0; + // Card dimensions: 170 inner + 16*2 padding + ~2 border = ~204 per card + let card_visual_width = 170.0 + (Spacing::MD * 2.0) + 2.0; + let total_width = (card_visual_width * 3.0) + (card_spacing * 2.0); + + let mut action = AppAction::None; + + // Use a fixed-width horizontal layout so it can be centered properly + ui.allocate_ui(Vec2::new(total_width, 100.0), |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = card_spacing; + + action |= self.render_action_card( + ui, + dark_mode, + OnboardingAction::CreateWallet, + "Create Wallet", + "Start fresh with a new HD wallet", + ); + + action |= self.render_action_card( + ui, + dark_mode, + OnboardingAction::LoadWallet, + "Import Wallet", + "Load a wallet you already have", + ); + + action |= self.render_action_card( + ui, + dark_mode, + OnboardingAction::JustBrowse, + "Just Explore", + "Explore without setting up", + ); + }); + }); + + action + } + + fn render_action_card( + &self, + ui: &mut egui::Ui, + dark_mode: bool, + onboarding_action: OnboardingAction, + title: &str, + description: &str, + ) -> AppAction { + let card_width = 170.0; + let card_height = 60.0; + + let bg_color = DashColors::background(dark_mode); + let border_color = DashColors::border_light(dark_mode); + + let response = egui::Frame::new() + .fill(bg_color) + .stroke(egui::Stroke::new(1.0, border_color)) + .corner_radius(Shape::RADIUS_LG) + .shadow(Shadow::small()) + .inner_margin(Spacing::MD) + .show(ui, |ui| { + ui.set_min_size(Vec2::new(card_width, card_height)); + ui.set_max_size(Vec2::new(card_width, card_height)); + + ui.vertical_centered(|ui| { + ui.add_space(5.0); + + ui.label( + RichText::new(title) + .size(14.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + + ui.add_space(6.0); + + ui.label( + RichText::new(description) + .size(11.0) + .color(DashColors::text_secondary(dark_mode)), + ); + }); + }); + + if response.response.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + + if response.response.interact(egui::Sense::click()).clicked() { + // Save settings to database + let _ = self.app_context.db.update_onboarding_completed(true); + + // Return OnboardingComplete with navigation based on selection + let (main_screen, add_screen) = match onboarding_action { + OnboardingAction::CreateWallet => ( + RootScreenType::RootScreenWalletsBalances, + Some(Box::new(ScreenType::AddNewWallet)), + ), + OnboardingAction::LoadWallet => ( + RootScreenType::RootScreenWalletsBalances, + Some(Box::new(ScreenType::ImportMnemonic)), + ), + OnboardingAction::ImportIdentity => (RootScreenType::RootScreenIdentities, None), + OnboardingAction::JustBrowse => (RootScreenType::RootScreenDashPayProfile, None), + }; + + return AppAction::OnboardingComplete { + main_screen, + add_screen, + }; + } + + AppAction::None + } +} diff --git a/tests/kittest/startup.rs b/tests/kittest/startup.rs index c5d937014..3ea5c7151 100644 --- a/tests/kittest/startup.rs +++ b/tests/kittest/startup.rs @@ -3,8 +3,12 @@ use egui_kittest::Harness; /// Test that demonstrates basic app startup and shutdown with kittest #[test] fn test_app_startup() { + // Create a tokio runtime for async operations during app initialization + // The app uses tokio::spawn internally for background tasks + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + // Create a test harness for the egui app - // let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) }); @@ -12,6 +16,8 @@ fn test_app_startup() { // Set the window size harness.set_size(egui::vec2(800.0, 600.0)); - // Run one frame to ensure the app initializes - harness.run(); + // Run a few frames to ensure the app initializes + // Using run_steps instead of run() because the app may show spinners + // which cause continuous repainting + harness.run_steps(10); } From ba251f54972448727116921fd004c6a7869f9c29 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:40:13 +0700 Subject: [PATCH 044/106] feat: comprehensive test coverage implementation (#481) * feat: comprehensive test suite * fmt --- .github/workflows/clippy.yml | 5 +- src/backend_task/dashpay/avatar_processing.rs | 258 ++++++++++ src/backend_task/dashpay/encryption.rs | 258 +++++++++- src/backend_task/dashpay/hd_derivation.rs | 206 ++++++++ src/backend_task/dashpay/payments.rs | 146 +++++- src/database/contacts.rs | 203 ++++++++ src/database/initialization.rs | 7 +- src/database/mod.rs | 2 + src/database/settings.rs | 216 ++++++++ src/database/test_helpers.rs | 94 ++++ src/database/utxo.rs | 204 ++++++++ src/database/wallet.rs | 480 ++++++++++++++++++ src/logging.rs | 16 +- tests/e2e/helpers.rs | 36 ++ tests/e2e/main.rs | 8 + tests/e2e/mod.rs | 9 + tests/e2e/navigation.rs | 101 ++++ tests/e2e/wallet_flows.rs | 80 +++ tests/kittest/identities_screen.rs | 73 +++ tests/kittest/main.rs | 3 + tests/kittest/network_chooser.rs | 60 +++ tests/kittest/wallets_screen.rs | 49 ++ 22 files changed, 2502 insertions(+), 12 deletions(-) create mode 100644 src/database/test_helpers.rs create mode 100644 tests/e2e/helpers.rs create mode 100644 tests/e2e/main.rs create mode 100644 tests/e2e/mod.rs create mode 100644 tests/e2e/navigation.rs create mode 100644 tests/e2e/wallet_flows.rs create mode 100644 tests/kittest/identities_screen.rs create mode 100644 tests/kittest/network_chooser.rs create mode 100644 tests/kittest/wallets_screen.rs diff --git a/.github/workflows/clippy.yml b/.github/workflows/clippy.yml index 18d7075d4..55a42e859 100644 --- a/.github/workflows/clippy.yml +++ b/.github/workflows/clippy.yml @@ -57,7 +57,7 @@ jobs: uses: actions-rs/toolchain@v1 with: toolchain: 1.92 - components: clippy + components: clippy, rustfmt override: true - name: Install system dependencies @@ -74,6 +74,9 @@ jobs: env: PROTOC: /usr/local/bin/protoc + - name: Check formatting + run: cargo fmt --all -- --check + - name: Run Clippy uses: actions-rs/clippy-check@v1 with: diff --git a/src/backend_task/dashpay/avatar_processing.rs b/src/backend_task/dashpay/avatar_processing.rs index 779f395e8..a3637666b 100644 --- a/src/backend_task/dashpay/avatar_processing.rs +++ b/src/backend_task/dashpay/avatar_processing.rs @@ -290,6 +290,28 @@ mod tests { assert_eq!(hash.len(), 32); } + #[test] + fn test_avatar_hash_deterministic() { + // Same data should produce same hash + let test_data = b"deterministic test data"; + let hash1 = calculate_avatar_hash(test_data); + let hash2 = calculate_avatar_hash(test_data); + assert_eq!(hash1, hash2, "Hash should be deterministic"); + } + + #[test] + fn test_avatar_hash_different_data() { + // Different data should produce different hashes + let data1 = b"first image data"; + let data2 = b"second image data"; + let hash1 = calculate_avatar_hash(data1); + let hash2 = calculate_avatar_hash(data2); + assert_ne!( + hash1, hash2, + "Different data should produce different hashes" + ); + } + #[test] fn test_hamming_distance() { let hash1 = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; @@ -300,6 +322,28 @@ mod tests { assert_eq!(hamming_distance(&hash1, &hash3), 0); } + #[test] + fn test_hamming_distance_single_bit() { + let hash1 = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + let hash2 = [0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + assert_eq!( + hamming_distance(&hash1, &hash2), + 1, + "Single bit difference should be 1" + ); + } + + #[test] + fn test_hamming_distance_symmetric() { + let hash1 = [0xAB, 0xCD, 0xEF, 0x12, 0x34, 0x56, 0x78, 0x9A]; + let hash2 = [0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0]; + assert_eq!( + hamming_distance(&hash1, &hash2), + hamming_distance(&hash2, &hash1), + "Hamming distance should be symmetric" + ); + } + #[test] fn test_image_similarity() { let hash1 = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; @@ -309,6 +353,25 @@ mod tests { assert!(!are_images_similar(&hash1, &hash2, 0)); } + #[test] + fn test_image_similarity_threshold() { + let hash1 = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + let hash2 = [0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; // 8 bits different + + assert!( + are_images_similar(&hash1, &hash2, 10), + "Should be similar with threshold 10" + ); + assert!( + are_images_similar(&hash1, &hash2, 8), + "Should be similar with threshold 8" + ); + assert!( + !are_images_similar(&hash1, &hash2, 7), + "Should not be similar with threshold 7" + ); + } + #[test] fn test_dhash_with_real_image() { // Create a simple test image (3x3 grayscale) @@ -330,6 +393,93 @@ mod tests { assert_eq!(hash.len(), 8); } + #[test] + fn test_dhash_calculator_default() { + let calculator = DHashCalculator::default(); + assert_eq!(calculator.width, 9); + assert_eq!(calculator.height, 8); + } + + #[test] + fn test_dhash_calculator_new() { + let calculator = DHashCalculator::new(); + assert_eq!(calculator.width, 9); + assert_eq!(calculator.height, 8); + } + + #[test] + fn test_dhash_with_uniform_image() { + // Create a uniform image (all same color) + let pixels = vec![128u8; 64]; // 8x8 uniform gray + let img = image::GrayImage::from_raw(8, 8, pixels).unwrap(); + let dynamic_img = DynamicImage::ImageLuma8(img); + + let calculator = DHashCalculator::new(); + let hash = calculator.calculate_from_image(&dynamic_img); + + // A uniform image should have all 0s or very few 1s in the hash + // because no pixel is "brighter" than its neighbor + let bit_count: u32 = hash.iter().map(|b| b.count_ones()).sum(); + // Allow some variance due to resizing artifacts + assert!( + bit_count < 10, + "Uniform image should have low bit count, got {}", + bit_count + ); + } + + #[test] + fn test_dhash_from_grayscale_bytes() { + let calculator = DHashCalculator::new(); + + // Test with a simple 9x8 grayscale image where left > right + let mut pixels = vec![0u8; 72]; // 9x8 + for y in 0..8 { + for x in 0..9 { + // Create pattern where each pixel is dimmer than the one to its left + pixels[y * 9 + x] = (255 - x * 28) as u8; + } + } + + let hash = calculator.calculate(&pixels, 9, 8); + assert_eq!(hash.len(), 8); + + // With left > right pattern, most bits should be 1 + let bit_count: u32 = hash.iter().map(|b| b.count_ones()).sum(); + assert!( + bit_count > 50, + "Left > right pattern should have high bit count" + ); + } + + #[test] + fn test_calculate_dhash_fingerprint_with_valid_png() { + // Create a simple valid PNG image in memory + use image::{ImageBuffer, Rgb}; + + let img: ImageBuffer, Vec> = ImageBuffer::from_fn(100, 100, |x, y| { + Rgb([(x % 256) as u8, (y % 256) as u8, 128]) + }); + + let mut png_bytes = Vec::new(); + let mut cursor = std::io::Cursor::new(&mut png_bytes); + img.write_to(&mut cursor, image::ImageFormat::Png).unwrap(); + + let result = calculate_dhash_fingerprint(&png_bytes); + assert!( + result.is_ok(), + "Should successfully calculate fingerprint for valid PNG" + ); + assert_eq!(result.unwrap().len(), 8); + } + + #[test] + fn test_calculate_dhash_fingerprint_with_invalid_data() { + let invalid_data = b"not an image"; + let result = calculate_dhash_fingerprint(invalid_data); + assert!(result.is_err(), "Should fail for invalid image data"); + } + #[tokio::test] async fn test_url_validation() { // Test non-HTTPS URL @@ -343,4 +493,112 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().contains("exceeds maximum length")); } + + #[tokio::test] + async fn test_fetch_image_bytes_http_rejected() { + // HTTP URLs should be rejected immediately + let result = fetch_image_bytes("http://example.com/avatar.png").await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("HTTPS")); + } + + #[tokio::test] + async fn test_fetch_image_bytes_url_length_check() { + // Test URL exactly at limit + let url_2048 = format!("https://example.com/{}", "x".repeat(2048 - 24)); // minus https://example.com/ length + // This might be at limit, just verify it doesn't panic + let _ = fetch_image_bytes(&url_2048).await; + + // Test URL over limit + let url_over_limit = format!("https://example.com/{}", "x".repeat(2100)); + let result = fetch_image_bytes(&url_over_limit).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("maximum length")); + } + + #[tokio::test] + async fn test_invalid_avatar_url_handling() { + // Test with a URL that will fail to resolve (invalid domain) + let result = + fetch_image_bytes("https://invalid.domain.that.does.not.exist.test/avatar.png").await; + assert!(result.is_err(), "Invalid domain should return error"); + } + + #[test] + fn test_similar_images_have_similar_hashes() { + // Create two slightly different images + use image::{ImageBuffer, Rgb}; + + let img1: ImageBuffer, Vec> = ImageBuffer::from_fn(100, 100, |x, y| { + Rgb([(x % 256) as u8, (y % 256) as u8, 128]) + }); + + let img2: ImageBuffer, Vec> = ImageBuffer::from_fn(100, 100, |x, y| { + // Slightly different - add 1 to red channel + Rgb([((x + 1) % 256) as u8, (y % 256) as u8, 128]) + }); + + let mut png1 = Vec::new(); + let mut png2 = Vec::new(); + img1.write_to( + &mut std::io::Cursor::new(&mut png1), + image::ImageFormat::Png, + ) + .unwrap(); + img2.write_to( + &mut std::io::Cursor::new(&mut png2), + image::ImageFormat::Png, + ) + .unwrap(); + + let hash1 = calculate_dhash_fingerprint(&png1).unwrap(); + let hash2 = calculate_dhash_fingerprint(&png2).unwrap(); + + // Similar images should have similar hashes (low hamming distance) + let distance = hamming_distance(&hash1, &hash2); + assert!( + distance < 20, + "Similar images should have hamming distance < 20, got {}", + distance + ); + } + + #[test] + fn test_different_images_have_different_hashes() { + // Create two completely different images with distinct patterns + use image::{ImageBuffer, Luma}; + + // Image 1: Horizontal gradient (left bright, right dark) + let img1: ImageBuffer, Vec> = + ImageBuffer::from_fn(100, 100, |x, _y| Luma([(255 - x * 2).min(255) as u8])); + + // Image 2: Horizontal gradient (left dark, right bright) - opposite direction + let img2: ImageBuffer, Vec> = + ImageBuffer::from_fn(100, 100, |x, _y| Luma([(x * 2).min(255) as u8])); + + let mut png1 = Vec::new(); + let mut png2 = Vec::new(); + img1.write_to( + &mut std::io::Cursor::new(&mut png1), + image::ImageFormat::Png, + ) + .unwrap(); + img2.write_to( + &mut std::io::Cursor::new(&mut png2), + image::ImageFormat::Png, + ) + .unwrap(); + + let hash1 = calculate_dhash_fingerprint(&png1).unwrap(); + let hash2 = calculate_dhash_fingerprint(&png2).unwrap(); + + // Opposite gradient images should have nearly opposite hashes + // (high hamming distance, ideally close to 64) + let distance = hamming_distance(&hash1, &hash2); + assert!( + distance > 30, + "Opposite gradient images should have hamming distance > 30, got {}", + distance + ); + } } diff --git a/src/backend_task/dashpay/encryption.rs b/src/backend_task/dashpay/encryption.rs index b660a9adf..9e57a9cb6 100644 --- a/src/backend_task/dashpay/encryption.rs +++ b/src/backend_task/dashpay/encryption.rs @@ -109,17 +109,23 @@ pub fn encrypt_extended_public_key( /// Encrypt account label according to DashPay DIP-15 /// Format: IV (16 bytes) + Encrypted Data (32-64 bytes) = 48-80 bytes total /// Uses CBC-AES-256 as specified in the DIP +/// +/// Note: Maximum label length is 62 bytes due to the internal format: +/// - 1 byte length prefix + label bytes + PKCS7 padding +/// - For 63 bytes: 1 + 63 = 64, PKCS7 adds 16 = 80 byte ciphertext = 96 total (exceeds limit) +/// - For 62 bytes: 1 + 62 = 63, PKCS7 adds 1 = 64 byte ciphertext = 80 total (at limit) pub fn encrypt_account_label(label: &str, shared_key: &[u8; 32]) -> Result, String> { use cbc::cipher::{BlockEncryptMut, KeyIvInit, block_padding::Pkcs7}; let label_bytes = label.as_bytes(); // Label length check + // Max 62 bytes due to 1-byte length prefix + PKCS7 padding constraints if label_bytes.is_empty() { return Err("Account label cannot be empty".to_string()); } - if label_bytes.len() > 63 { - return Err("Account label too long (max 63 characters)".to_string()); + if label_bytes.len() > 62 { + return Err("Account label too long (max 62 bytes)".to_string()); } // To ensure minimum ciphertext size of 32 bytes, pad the label to at least 16 bytes @@ -270,3 +276,251 @@ pub fn decrypt_account_label( String::from_utf8(label_bytes.to_vec()) .map_err(|e| format!("Invalid UTF-8 in decrypted label: {}", e)) } + +#[cfg(test)] +mod tests { + use super::*; + use bip39::rand::{self, RngCore}; + use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; + + fn generate_test_shared_key() -> [u8; 32] { + let mut shared_key = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut shared_key); + shared_key + } + + fn generate_test_key_pair() -> (SecretKey, PublicKey) { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, + 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, + 0x1D, 0x1E, 0x1F, 0x20, + ]) + .unwrap(); + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + (secret_key, public_key) + } + + #[test] + fn test_encrypt_decrypt_extended_public_key_roundtrip() { + // Generate test data + let parent_fingerprint = [0x12, 0x34, 0x56, 0x78]; + let mut chain_code = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut chain_code); + + let (_, public_key) = generate_test_key_pair(); + let public_key_bytes = public_key.serialize(); + + let shared_key = generate_test_shared_key(); + + // Encrypt + let encrypted = encrypt_extended_public_key( + parent_fingerprint, + chain_code, + public_key_bytes, + &shared_key, + ) + .expect("Encryption should succeed"); + + // Verify encrypted data length is 96 bytes (16 IV + 80 encrypted) + assert_eq!(encrypted.len(), 96, "Encrypted data should be 96 bytes"); + + // Decrypt + let (decrypted_fingerprint, decrypted_chain_code, decrypted_public_key) = + decrypt_extended_public_key(&encrypted, &shared_key) + .expect("Decryption should succeed"); + + // Verify decrypted data matches original + assert_eq!( + decrypted_fingerprint, + parent_fingerprint.to_vec(), + "Parent fingerprint should match" + ); + assert_eq!(decrypted_chain_code, chain_code, "Chain code should match"); + assert_eq!( + decrypted_public_key, public_key_bytes, + "Public key should match" + ); + } + + #[test] + fn test_encrypt_decrypt_account_label_roundtrip() { + let shared_key = generate_test_shared_key(); + + // Test various label lengths + let test_labels = vec![ + "Personal", + "Business Account", + "Savings - Long Term Investment Fund 2024", + "Short", + ]; + + for label in test_labels { + let encrypted = + encrypt_account_label(label, &shared_key).expect("Encryption should succeed"); + + // Verify encrypted length is in expected range (48-80 bytes) + assert!( + encrypted.len() >= 48 && encrypted.len() <= 80, + "Encrypted label length {} should be 48-80", + encrypted.len() + ); + + let decrypted = + decrypt_account_label(&encrypted, &shared_key).expect("Decryption should succeed"); + + assert_eq!(decrypted, label, "Decrypted label should match original"); + } + } + + #[test] + fn test_account_label_with_unicode() { + let shared_key = generate_test_shared_key(); + + // Test with unicode characters + let label = "你好世界"; // "Hello World" in Chinese + + let encrypted = + encrypt_account_label(label, &shared_key).expect("Encryption should succeed"); + + let decrypted = + decrypt_account_label(&encrypted, &shared_key).expect("Decryption should succeed"); + + assert_eq!(decrypted, label, "Unicode label should roundtrip correctly"); + } + + #[test] + fn test_account_label_length_validation() { + let shared_key = generate_test_shared_key(); + + // Test empty label - should fail + let result = encrypt_account_label("", &shared_key); + assert!(result.is_err(), "Empty label should be rejected"); + assert!( + result.unwrap_err().contains("empty"), + "Error should mention empty" + ); + + // Test label that's too long (> 62 bytes) - should fail + let long_label = "x".repeat(63); + let result = encrypt_account_label(&long_label, &shared_key); + assert!(result.is_err(), "Label > 62 bytes should be rejected"); + assert!( + result.unwrap_err().contains("too long"), + "Error should mention too long" + ); + + // Test label at exactly the limit (62 bytes) - should succeed + let max_label = "x".repeat(62); + let result = encrypt_account_label(&max_label, &shared_key); + assert!(result.is_ok(), "Label of 62 bytes should be accepted"); + + // Test label just under the limit - should succeed + let valid_label = "x".repeat(45); + let result = encrypt_account_label(&valid_label, &shared_key); + assert!(result.is_ok(), "Label of 45 bytes should be accepted"); + } + + #[test] + fn test_decrypt_with_wrong_key_fails() { + let shared_key = generate_test_shared_key(); + let wrong_key = generate_test_shared_key(); + + // Generate test data + let parent_fingerprint = [0x12, 0x34, 0x56, 0x78]; + let mut chain_code = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut chain_code); + + let (_, public_key) = generate_test_key_pair(); + let public_key_bytes = public_key.serialize(); + + // Encrypt with correct key + let encrypted = encrypt_extended_public_key( + parent_fingerprint, + chain_code, + public_key_bytes, + &shared_key, + ) + .expect("Encryption should succeed"); + + // Try to decrypt with wrong key - should fail + let result = decrypt_extended_public_key(&encrypted, &wrong_key); + assert!(result.is_err(), "Decryption with wrong key should fail"); + } + + #[test] + fn test_decrypt_account_label_with_wrong_key_fails() { + let shared_key = generate_test_shared_key(); + let wrong_key = generate_test_shared_key(); + + let encrypted = + encrypt_account_label("Test Label", &shared_key).expect("Encryption should succeed"); + + let result = decrypt_account_label(&encrypted, &wrong_key); + assert!(result.is_err(), "Decryption with wrong key should fail"); + } + + #[test] + fn test_invalid_encrypted_data_length() { + let shared_key = generate_test_shared_key(); + + // Test extended public key with wrong length + let too_short = vec![0u8; 50]; + let result = decrypt_extended_public_key(&too_short, &shared_key); + assert!(result.is_err(), "Too short data should be rejected"); + + let too_long = vec![0u8; 100]; + let result = decrypt_extended_public_key(&too_long, &shared_key); + assert!(result.is_err(), "Too long data should be rejected"); + + // Test account label with wrong length + let too_short_label = vec![0u8; 30]; + let result = decrypt_account_label(&too_short_label, &shared_key); + assert!(result.is_err(), "Too short label data should be rejected"); + + let too_long_label = vec![0u8; 100]; + let result = decrypt_account_label(&too_long_label, &shared_key); + assert!(result.is_err(), "Too long label data should be rejected"); + } + + #[test] + fn test_encryption_produces_different_ciphertext() { + let shared_key = generate_test_shared_key(); + + // Encrypt the same data twice + let parent_fingerprint = [0x12, 0x34, 0x56, 0x78]; + let chain_code = [0xAB; 32]; + let (_, public_key) = generate_test_key_pair(); + let public_key_bytes = public_key.serialize(); + + let encrypted1 = encrypt_extended_public_key( + parent_fingerprint, + chain_code, + public_key_bytes, + &shared_key, + ) + .expect("Encryption should succeed"); + + let encrypted2 = encrypt_extended_public_key( + parent_fingerprint, + chain_code, + public_key_bytes, + &shared_key, + ) + .expect("Encryption should succeed"); + + // Due to random IV, the ciphertexts should be different + assert_ne!( + encrypted1, encrypted2, + "Random IVs should produce different ciphertexts" + ); + + // But both should decrypt to the same value + let (fp1, cc1, pk1) = decrypt_extended_public_key(&encrypted1, &shared_key).unwrap(); + let (fp2, cc2, pk2) = decrypt_extended_public_key(&encrypted2, &shared_key).unwrap(); + + assert_eq!(fp1, fp2); + assert_eq!(cc1, cc2); + assert_eq!(pk1, pk2); + } +} diff --git a/src/backend_task/dashpay/hd_derivation.rs b/src/backend_task/dashpay/hd_derivation.rs index 9b2e55c3b..cc60f8df9 100644 --- a/src/backend_task/dashpay/hd_derivation.rs +++ b/src/backend_task/dashpay/hd_derivation.rs @@ -185,4 +185,210 @@ mod tests { // Verify version bits are in the right place assert_eq!(account_ref >> 28, 0); } + + #[test] + fn test_derive_payment_address_deterministic() { + // Test that deriving the same address index gives the same address + let network = Network::Testnet; + let master_seed = [0x42u8; 64]; + + // Create two test identity IDs + let sender_bytes = [0x11u8; 32]; + let recipient_bytes = [0x22u8; 32]; + let sender_id = Identifier::from_bytes(&sender_bytes).unwrap(); + let recipient_id = Identifier::from_bytes(&recipient_bytes).unwrap(); + + // Derive xpub twice + let xpub1 = + derive_dashpay_incoming_xpub(&master_seed, network, 0, &sender_id, &recipient_id) + .expect("Should derive xpub"); + let xpub2 = + derive_dashpay_incoming_xpub(&master_seed, network, 0, &sender_id, &recipient_id) + .expect("Should derive xpub"); + + // Derive addresses from both xpubs + let addr1 = derive_payment_address(&xpub1, 0).expect("Should derive address"); + let addr2 = derive_payment_address(&xpub2, 0).expect("Should derive address"); + + // Same seed + identities + index should give same address + assert_eq!( + addr1, addr2, + "Deterministic derivation should produce same address" + ); + } + + #[test] + fn test_derive_payment_address_different_indices() { + // Test that different indices produce different addresses + let network = Network::Testnet; + let master_seed = [0x42u8; 64]; + + let sender_bytes = [0x11u8; 32]; + let recipient_bytes = [0x22u8; 32]; + let sender_id = Identifier::from_bytes(&sender_bytes).unwrap(); + let recipient_id = Identifier::from_bytes(&recipient_bytes).unwrap(); + + let xpub = + derive_dashpay_incoming_xpub(&master_seed, network, 0, &sender_id, &recipient_id) + .expect("Should derive xpub"); + + let addr0 = derive_payment_address(&xpub, 0).expect("Should derive address at index 0"); + let addr1 = derive_payment_address(&xpub, 1).expect("Should derive address at index 1"); + let addr2 = derive_payment_address(&xpub, 2).expect("Should derive address at index 2"); + + // Different indices should produce different addresses + assert_ne!( + addr0, addr1, + "Different indices should produce different addresses" + ); + assert_ne!( + addr1, addr2, + "Different indices should produce different addresses" + ); + assert_ne!( + addr0, addr2, + "Different indices should produce different addresses" + ); + } + + #[test] + fn test_auto_accept_key_derivation() { + // Test auto-accept key derivation + let network = Network::Testnet; + let master_seed = [0x42u8; 64]; + let timestamp = 1700000000u32; + + let key = derive_auto_accept_key(&master_seed, network, timestamp) + .expect("Should derive auto-accept key"); + + // Verify the key was derived correctly + assert_eq!(key.network, network); + assert_eq!(key.depth, 4); // m/9'/5'/16'/timestamp' = depth 4 + } + + #[test] + fn test_auto_accept_key_different_timestamps() { + // Different timestamps should produce different keys + let network = Network::Testnet; + let master_seed = [0x42u8; 64]; + + let key1 = + derive_auto_accept_key(&master_seed, network, 1700000000).expect("Should derive key 1"); + let key2 = + derive_auto_accept_key(&master_seed, network, 1700000001).expect("Should derive key 2"); + + // Different timestamps should produce different keys + assert_ne!( + key1.private_key.secret_bytes(), + key2.private_key.secret_bytes(), + "Different timestamps should produce different keys" + ); + } + + #[test] + fn test_generate_contact_xpub_data() { + // Test generating contact xpub data + let network = Network::Testnet; + let master_seed = [0x42u8; 64]; + + let sender_bytes = [0x11u8; 32]; + let recipient_bytes = [0x22u8; 32]; + let sender_id = Identifier::from_bytes(&sender_bytes).unwrap(); + let recipient_id = Identifier::from_bytes(&recipient_bytes).unwrap(); + + let (fingerprint, chain_code, pubkey) = + generate_contact_xpub_data(&master_seed, network, 0, &sender_id, &recipient_id) + .expect("Should generate xpub data"); + + // Verify the data has the expected sizes + assert_eq!(fingerprint.len(), 4, "Fingerprint should be 4 bytes"); + assert_eq!(chain_code.len(), 32, "Chain code should be 32 bytes"); + assert_eq!( + pubkey.len(), + 33, + "Public key should be 33 bytes (compressed)" + ); + + // Verify public key is valid compressed format (starts with 0x02 or 0x03) + assert!( + pubkey[0] == 0x02 || pubkey[0] == 0x03, + "Public key should start with 0x02 or 0x03" + ); + } + + #[test] + fn test_account_reference_version_bits() { + // Test that version bits are correctly placed + let secret_key = [1u8; 32]; + let network = Network::Testnet; + let master_seed = [2u8; 64]; + + let master_xprv = ExtendedPrivKey::new_master(network, &master_seed).unwrap(); + let secp = dash_sdk::dpp::dashcore::secp256k1::Secp256k1::new(); + let xpub = ExtendedPubKey::from_priv(&secp, &master_xprv); + + // Test version 0 + let ref_v0 = calculate_account_reference(&secret_key, &xpub, 0, 0); + assert_eq!(ref_v0 >> 28, 0, "Version 0 should have 0 in top 4 bits"); + + // Test version 1 + let ref_v1 = calculate_account_reference(&secret_key, &xpub, 0, 1); + assert_eq!(ref_v1 >> 28, 1, "Version 1 should have 1 in top 4 bits"); + + // Test version 15 (max) + let ref_v15 = calculate_account_reference(&secret_key, &xpub, 0, 15); + assert_eq!(ref_v15 >> 28, 15, "Version 15 should have 15 in top 4 bits"); + } + + #[test] + fn test_dashpay_xpub_different_accounts() { + // Different accounts should produce different xpubs + let network = Network::Testnet; + let master_seed = [0x42u8; 64]; + + let sender_bytes = [0x11u8; 32]; + let recipient_bytes = [0x22u8; 32]; + let sender_id = Identifier::from_bytes(&sender_bytes).unwrap(); + let recipient_id = Identifier::from_bytes(&recipient_bytes).unwrap(); + + let xpub_account0 = + derive_dashpay_incoming_xpub(&master_seed, network, 0, &sender_id, &recipient_id) + .expect("Should derive xpub for account 0"); + let xpub_account1 = + derive_dashpay_incoming_xpub(&master_seed, network, 1, &sender_id, &recipient_id) + .expect("Should derive xpub for account 1"); + + assert_ne!( + xpub_account0.public_key.serialize(), + xpub_account1.public_key.serialize(), + "Different accounts should produce different xpubs" + ); + } + + #[test] + fn test_dashpay_xpub_different_networks() { + // Different networks should produce different xpubs + let master_seed = [0x42u8; 64]; + + let sender_bytes = [0x11u8; 32]; + let recipient_bytes = [0x22u8; 32]; + let sender_id = Identifier::from_bytes(&sender_bytes).unwrap(); + let recipient_id = Identifier::from_bytes(&recipient_bytes).unwrap(); + + let xpub_testnet = derive_dashpay_incoming_xpub( + &master_seed, + Network::Testnet, + 0, + &sender_id, + &recipient_id, + ) + .expect("Should derive xpub for testnet"); + let xpub_mainnet = + derive_dashpay_incoming_xpub(&master_seed, Network::Dash, 0, &sender_id, &recipient_id) + .expect("Should derive xpub for mainnet"); + + // Keys should be the same but network should differ + assert_eq!(xpub_testnet.network, Network::Testnet); + assert_eq!(xpub_mainnet.network, Network::Dash); + } } diff --git a/src/backend_task/dashpay/payments.rs b/src/backend_task/dashpay/payments.rs index 20a1b2647..4863b9a51 100644 --- a/src/backend_task/dashpay/payments.rs +++ b/src/backend_task/dashpay/payments.rs @@ -394,6 +394,12 @@ pub async fn check_address_usage( mod tests { use super::*; + fn create_test_address() -> Address { + let pubkey_bytes = [0x02; 33]; + let pubkey = dash_sdk::dpp::dashcore::PublicKey::from_slice(&pubkey_bytes).unwrap(); + Address::p2pkh(&pubkey, dash_sdk::dpp::dashcore::Network::Testnet) + } + #[test] fn test_payment_record_creation() { let from_id = Identifier::random(); @@ -404,10 +410,7 @@ mod tests { from_identity: from_id, to_identity: to_id, from_address: None, - to_address: Address::p2pkh( - &dash_sdk::dpp::dashcore::PublicKey::from_slice(&[0x02; 33]).unwrap(), - dash_sdk::dpp::dashcore::Network::Testnet, - ), + to_address: create_test_address(), amount: 100_000_000, // 1 Dash tx_id: None, memo: Some("Test payment".to_string()), @@ -419,4 +422,139 @@ mod tests { assert_eq!(payment.amount, 100_000_000); assert_eq!(payment.status, PaymentStatus::Pending); } + + #[test] + fn test_payment_status_pending() { + let status = PaymentStatus::Pending; + assert_eq!(status, PaymentStatus::Pending); + } + + #[test] + fn test_payment_status_broadcast() { + let status = PaymentStatus::Broadcast; + assert_eq!(status, PaymentStatus::Broadcast); + } + + #[test] + fn test_payment_status_confirmed() { + let status = PaymentStatus::Confirmed(6); + if let PaymentStatus::Confirmed(confirmations) = status { + assert_eq!(confirmations, 6); + } else { + panic!("Expected Confirmed status"); + } + } + + #[test] + fn test_payment_status_failed() { + let status = PaymentStatus::Failed("Insufficient funds".to_string()); + if let PaymentStatus::Failed(msg) = status { + assert_eq!(msg, "Insufficient funds"); + } else { + panic!("Expected Failed status"); + } + } + + #[test] + fn test_payment_status_equality() { + assert_eq!(PaymentStatus::Pending, PaymentStatus::Pending); + assert_eq!(PaymentStatus::Broadcast, PaymentStatus::Broadcast); + assert_eq!(PaymentStatus::Confirmed(6), PaymentStatus::Confirmed(6)); + assert_ne!(PaymentStatus::Confirmed(6), PaymentStatus::Confirmed(7)); + assert_eq!( + PaymentStatus::Failed("error".to_string()), + PaymentStatus::Failed("error".to_string()) + ); + } + + #[test] + fn test_payment_record_with_tx_id() { + let payment = PaymentRecord { + id: "test_payment".to_string(), + from_identity: Identifier::random(), + to_identity: Identifier::random(), + from_address: Some(create_test_address()), + to_address: create_test_address(), + amount: 50_000_000, // 0.5 Dash + tx_id: Some("abc123def456".to_string()), + memo: None, + timestamp: 1700000000, + status: PaymentStatus::Broadcast, + address_index: 5, + }; + + assert_eq!(payment.tx_id, Some("abc123def456".to_string())); + assert_eq!(payment.status, PaymentStatus::Broadcast); + assert_eq!(payment.address_index, 5); + assert!(payment.from_address.is_some()); + assert!(payment.memo.is_none()); + } + + #[test] + fn test_payment_record_amount_in_duffs() { + // Test that we can properly handle various Dash amounts in duffs + let test_amounts: Vec<(f64, u64)> = vec![ + (0.1, 10_000_000), // 0.1 DASH + (1.0, 100_000_000), // 1 DASH + (10.5, 1_050_000_000), // 10.5 DASH + (100.12345678, 10_012_345_678), // Full precision + ]; + + for (dash, expected_duffs) in test_amounts { + let duffs = (dash * 100_000_000.0).round() as u64; + assert_eq!(duffs, expected_duffs, "Conversion failed for {} DASH", dash); + + // Test reverse conversion + let back_to_dash = duffs as f64 / 100_000_000.0; + // Use approximate equality due to floating point + assert!( + (back_to_dash - dash).abs() < 0.00000001, + "Reverse conversion failed for {} duffs", + duffs + ); + } + } + + #[test] + fn test_payment_record_clone() { + let payment = PaymentRecord { + id: "original".to_string(), + from_identity: Identifier::random(), + to_identity: Identifier::random(), + from_address: None, + to_address: create_test_address(), + amount: 100_000_000, + tx_id: Some("tx123".to_string()), + memo: Some("Original memo".to_string()), + timestamp: 1700000000, + status: PaymentStatus::Pending, + address_index: 0, + }; + + let cloned = payment.clone(); + + assert_eq!(payment.id, cloned.id); + assert_eq!(payment.amount, cloned.amount); + assert_eq!(payment.status, cloned.status); + assert_eq!(payment.memo, cloned.memo); + assert_eq!(payment.tx_id, cloned.tx_id); + } + + #[test] + fn test_payment_status_debug_format() { + // Test Debug trait implementation + let pending = format!("{:?}", PaymentStatus::Pending); + assert!(pending.contains("Pending")); + + let broadcast = format!("{:?}", PaymentStatus::Broadcast); + assert!(broadcast.contains("Broadcast")); + + let confirmed = format!("{:?}", PaymentStatus::Confirmed(10)); + assert!(confirmed.contains("Confirmed")); + assert!(confirmed.contains("10")); + + let failed = format!("{:?}", PaymentStatus::Failed("Test error".to_string())); + assert!(failed.contains("Failed")); + assert!(failed.contains("Test error")); + } } diff --git a/src/database/contacts.rs b/src/database/contacts.rs index 6966577ee..8787b9081 100644 --- a/src/database/contacts.rs +++ b/src/database/contacts.rs @@ -151,3 +151,206 @@ impl crate::database::Database { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::test_helpers::create_test_database; + + fn create_test_identifier() -> Identifier { + Identifier::random() + } + + #[test] + fn test_save_and_retrieve_contact_private_info() { + let db = create_test_database().expect("Failed to create test database"); + let owner_id = create_test_identifier(); + let contact_id = create_test_identifier(); + + // Save contact info + db.save_contact_private_info(&owner_id, &contact_id, "Alice", "My best friend", false) + .expect("Failed to save contact info"); + + // Retrieve it + let (nickname, notes, is_hidden) = db + .load_contact_private_info(&owner_id, &contact_id) + .expect("Failed to load contact info"); + + assert_eq!(nickname, "Alice"); + assert_eq!(notes, "My best friend"); + assert!(!is_hidden); + } + + #[test] + fn test_contact_private_info_not_found_returns_defaults() { + let db = create_test_database().expect("Failed to create test database"); + let owner_id = create_test_identifier(); + let contact_id = create_test_identifier(); + + // Try to load non-existent contact info + let (nickname, notes, is_hidden) = db + .load_contact_private_info(&owner_id, &contact_id) + .expect("Failed to load contact info"); + + assert_eq!(nickname, ""); + assert_eq!(notes, ""); + assert!(!is_hidden); + } + + #[test] + fn test_update_contact_private_info() { + let db = create_test_database().expect("Failed to create test database"); + let owner_id = create_test_identifier(); + let contact_id = create_test_identifier(); + + // Save initial info + db.save_contact_private_info(&owner_id, &contact_id, "Alice", "Note 1", false) + .expect("Failed to save contact info"); + + // Update it + db.save_contact_private_info(&owner_id, &contact_id, "Bob", "Note 2", true) + .expect("Failed to update contact info"); + + // Retrieve updated info + let (nickname, notes, is_hidden) = db + .load_contact_private_info(&owner_id, &contact_id) + .expect("Failed to load contact info"); + + assert_eq!(nickname, "Bob"); + assert_eq!(notes, "Note 2"); + assert!(is_hidden); + } + + #[test] + fn test_delete_contact_private_info() { + let db = create_test_database().expect("Failed to create test database"); + let owner_id = create_test_identifier(); + let contact_id = create_test_identifier(); + + // Save contact info + db.save_contact_private_info(&owner_id, &contact_id, "Alice", "Notes", false) + .expect("Failed to save contact info"); + + // Delete it + db.delete_contact_private_info(&owner_id, &contact_id) + .expect("Failed to delete contact info"); + + // Should return defaults now + let (nickname, notes, is_hidden) = db + .load_contact_private_info(&owner_id, &contact_id) + .expect("Failed to load contact info"); + + assert_eq!(nickname, ""); + assert_eq!(notes, ""); + assert!(!is_hidden); + } + + #[test] + fn test_load_all_contact_private_info() { + let db = create_test_database().expect("Failed to create test database"); + let owner_id = create_test_identifier(); + + // Add multiple contacts + for i in 0..5 { + let contact_id = create_test_identifier(); + db.save_contact_private_info( + &owner_id, + &contact_id, + &format!("Contact {}", i), + &format!("Notes for contact {}", i), + i % 2 == 0, // Every other contact is hidden + ) + .expect("Failed to save contact info"); + } + + // Load all contacts for this owner + let contacts = db + .load_all_contact_private_info(&owner_id) + .expect("Failed to load all contacts"); + + assert_eq!(contacts.len(), 5); + + // Verify hidden status pattern + let hidden_count = contacts.iter().filter(|c| c.is_hidden).count(); + assert_eq!(hidden_count, 3); // 0, 2, 4 are hidden + } + + #[test] + fn test_set_contact_hidden_new_contact() { + let db = create_test_database().expect("Failed to create test database"); + let owner_id = create_test_identifier(); + let contact_id = create_test_identifier(); + + // Set hidden on a new contact (should create entry) + db.set_contact_hidden(&owner_id, &contact_id, true) + .expect("Failed to set contact hidden"); + + let (nickname, notes, is_hidden) = db + .load_contact_private_info(&owner_id, &contact_id) + .expect("Failed to load contact info"); + + assert_eq!(nickname, ""); + assert_eq!(notes, ""); + assert!(is_hidden); + } + + #[test] + fn test_set_contact_hidden_preserves_existing_data() { + let db = create_test_database().expect("Failed to create test database"); + let owner_id = create_test_identifier(); + let contact_id = create_test_identifier(); + + // Save contact info with nickname and notes + db.save_contact_private_info(&owner_id, &contact_id, "Alice", "Important notes", false) + .expect("Failed to save contact info"); + + // Change hidden status + db.set_contact_hidden(&owner_id, &contact_id, true) + .expect("Failed to set contact hidden"); + + // Verify nickname and notes are preserved + let (nickname, notes, is_hidden) = db + .load_contact_private_info(&owner_id, &contact_id) + .expect("Failed to load contact info"); + + assert_eq!(nickname, "Alice"); + assert_eq!(notes, "Important notes"); + assert!(is_hidden); + } + + #[test] + fn test_contacts_isolation_between_owners() { + let db = create_test_database().expect("Failed to create test database"); + let owner1 = create_test_identifier(); + let owner2 = create_test_identifier(); + let contact_id = create_test_identifier(); + + // Both owners have the same contact but with different info + db.save_contact_private_info( + &owner1, + &contact_id, + "Alice (Owner1)", + "Notes from 1", + false, + ) + .expect("Failed to save contact info"); + db.save_contact_private_info(&owner2, &contact_id, "Alice (Owner2)", "Notes from 2", true) + .expect("Failed to save contact info"); + + // Verify isolation + let (nickname1, notes1, hidden1) = db + .load_contact_private_info(&owner1, &contact_id) + .expect("Failed to load contact info"); + let (nickname2, notes2, hidden2) = db + .load_contact_private_info(&owner2, &contact_id) + .expect("Failed to load contact info"); + + assert_eq!(nickname1, "Alice (Owner1)"); + assert_eq!(notes1, "Notes from 1"); + assert!(!hidden1); + + assert_eq!(nickname2, "Alice (Owner2)"); + assert_eq!(notes2, "Notes from 2"); + assert!(hidden2); + } +} diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 2cefcecce..8581b6a90 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -283,7 +283,9 @@ impl Database { user_mode TEXT DEFAULT 'Advanced', use_local_spv_node INTEGER DEFAULT 0, auto_start_spv INTEGER DEFAULT 1, - close_dash_qt_on_exit INTEGER DEFAULT 1 + close_dash_qt_on_exit INTEGER DEFAULT 1, + selected_wallet_hash BLOB, + selected_single_key_hash BLOB )", [], )?; @@ -305,7 +307,8 @@ impl Database { unconfirmed_balance INTEGER DEFAULT 0, total_balance INTEGER DEFAULT 0, last_platform_full_sync INTEGER DEFAULT 0, - last_platform_sync_checkpoint INTEGER DEFAULT 0 + last_platform_sync_checkpoint INTEGER DEFAULT 0, + last_terminal_block INTEGER DEFAULT 0 )", [], )?; diff --git a/src/database/mod.rs b/src/database/mod.rs index 745b2ed64..c719d0bb7 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -9,6 +9,8 @@ mod proof_log; mod scheduled_votes; mod settings; mod single_key_wallet; +#[cfg(test)] +pub mod test_helpers; mod tokens; mod top_ups; mod utxo; diff --git a/src/database/settings.rs b/src/database/settings.rs index c78cfce48..d2cdd7055 100644 --- a/src/database/settings.rs +++ b/src/database/settings.rs @@ -627,3 +627,219 @@ impl Database { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::test_helpers::create_test_database; + + #[test] + fn test_get_settings_empty_database() { + // A freshly initialized database should have default settings + let db = create_test_database().expect("Failed to create test database"); + + let settings = db.get_settings().expect("Failed to get settings"); + assert!( + settings.is_some(), + "Database should have default settings after initialization" + ); + + let (network, root_screen, password_info, _, _, _, theme, core_mode, _, _, _, _) = + settings.unwrap(); + // Default network is "dash" (mainnet) + assert_eq!(network, Network::Dash); + // Default start screen is RootScreenDashPayProfile (20) + assert_eq!(root_screen, RootScreenType::RootScreenDashPayProfile); + // No password set initially + assert!(password_info.is_none()); + // Default theme is System + assert_eq!(theme, ThemeMode::System); + // Default core mode is SPV (1) + assert_eq!(core_mode, 1); + } + + #[test] + fn test_insert_or_update_settings() { + let db = create_test_database().expect("Failed to create test database"); + + // Update to testnet and a different start screen + db.insert_or_update_settings(Network::Testnet, RootScreenType::RootScreenIdentities) + .expect("Failed to update settings"); + + let settings = db.get_settings().expect("Failed to get settings").unwrap(); + assert_eq!(settings.0, Network::Testnet); + assert_eq!(settings.1, RootScreenType::RootScreenIdentities); + } + + #[test] + fn test_update_theme_preference() { + let db = create_test_database().expect("Failed to create test database"); + + // Test Dark theme + db.update_theme_preference(ThemeMode::Dark) + .expect("Failed to update theme"); + + let settings = db.get_settings().expect("Failed to get settings").unwrap(); + assert_eq!(settings.6, ThemeMode::Dark); + + // Test Light theme + db.update_theme_preference(ThemeMode::Light) + .expect("Failed to update theme"); + + let settings = db.get_settings().expect("Failed to get settings").unwrap(); + assert_eq!(settings.6, ThemeMode::Light); + + // Test System theme + db.update_theme_preference(ThemeMode::System) + .expect("Failed to update theme"); + + let settings = db.get_settings().expect("Failed to get settings").unwrap(); + assert_eq!(settings.6, ThemeMode::System); + } + + #[test] + fn test_core_backend_mode_persistence() { + let db = create_test_database().expect("Failed to create test database"); + + // Default should be SPV (1) + let settings = db.get_settings().expect("Failed to get settings").unwrap(); + assert_eq!(settings.7, 1); + + // Update to RPC mode (0) + db.update_core_backend_mode(0) + .expect("Failed to update core backend mode"); + + let settings = db.get_settings().expect("Failed to get settings").unwrap(); + assert_eq!(settings.7, 0); + + // Update back to SPV mode (1) + db.update_core_backend_mode(1) + .expect("Failed to update core backend mode"); + + let settings = db.get_settings().expect("Failed to get settings").unwrap(); + assert_eq!(settings.7, 1); + } + + #[test] + fn test_selected_wallet_hash_operations() { + let db = create_test_database().expect("Failed to create test database"); + + // Initially no wallet selected + let (wallet_hash, single_key_hash) = db + .get_selected_wallet_hashes() + .expect("Failed to get wallet hashes"); + assert!(wallet_hash.is_none()); + assert!(single_key_hash.is_none()); + + // Set a wallet hash + let test_hash: [u8; 32] = [0x42; 32]; + db.update_selected_wallet_hash(Some(&test_hash)) + .expect("Failed to update wallet hash"); + + let (wallet_hash, _) = db + .get_selected_wallet_hashes() + .expect("Failed to get wallet hashes"); + assert_eq!(wallet_hash, Some(test_hash)); + + // Set a single key hash + let single_key_test_hash: [u8; 32] = [0x24; 32]; + db.update_selected_single_key_hash(Some(&single_key_test_hash)) + .expect("Failed to update single key hash"); + + let (_, single_key_hash) = db + .get_selected_wallet_hashes() + .expect("Failed to get wallet hashes"); + assert_eq!(single_key_hash, Some(single_key_test_hash)); + + // Clear wallet hash + db.update_selected_wallet_hash(None) + .expect("Failed to clear wallet hash"); + + let (wallet_hash, _) = db + .get_selected_wallet_hashes() + .expect("Failed to get wallet hashes"); + assert!(wallet_hash.is_none()); + } + + #[test] + fn test_onboarding_and_user_mode_settings() { + let db = create_test_database().expect("Failed to create test database"); + + // Default onboarding is not completed + let settings = db.get_settings().expect("Failed to get settings").unwrap(); + assert!(!settings.8); // onboarding_completed + assert!(!settings.9); // show_evonode_tools + + // Complete onboarding + db.update_onboarding_completed(true) + .expect("Failed to update onboarding"); + + let settings = db.get_settings().expect("Failed to get settings").unwrap(); + assert!(settings.8); + + // Enable evonode tools + db.update_show_evonode_tools(true) + .expect("Failed to update evonode tools"); + + let settings = db.get_settings().expect("Failed to get settings").unwrap(); + assert!(settings.9); + + // Update user mode to Beginner + db.update_user_mode("Beginner") + .expect("Failed to update user mode"); + + let settings = db.get_settings().expect("Failed to get settings").unwrap(); + assert_eq!(settings.10, UserMode::Beginner); + } + + #[test] + fn test_spv_settings() { + let db = create_test_database().expect("Failed to create test database"); + + // Test auto_start_spv (default true) + let auto_start = db + .get_auto_start_spv() + .expect("Failed to get auto_start_spv"); + assert!(auto_start); + + db.update_auto_start_spv(false) + .expect("Failed to update auto_start_spv"); + let auto_start = db + .get_auto_start_spv() + .expect("Failed to get auto_start_spv"); + assert!(!auto_start); + + // Test use_local_spv_node (default false) + let use_local = db + .get_use_local_spv_node() + .expect("Failed to get use_local_spv_node"); + assert!(!use_local); + + db.update_use_local_spv_node(true) + .expect("Failed to update use_local_spv_node"); + let use_local = db + .get_use_local_spv_node() + .expect("Failed to get use_local_spv_node"); + assert!(use_local); + } + + #[test] + fn test_close_dash_qt_on_exit() { + let db = create_test_database().expect("Failed to create test database"); + + // Default should be true + let close_on_exit = db + .get_close_dash_qt_on_exit() + .expect("Failed to get close_dash_qt_on_exit"); + assert!(close_on_exit); + + // Update to false + db.update_close_dash_qt_on_exit(false) + .expect("Failed to update close_dash_qt_on_exit"); + + let close_on_exit = db + .get_close_dash_qt_on_exit() + .expect("Failed to get close_dash_qt_on_exit"); + assert!(!close_on_exit); + } +} diff --git a/src/database/test_helpers.rs b/src/database/test_helpers.rs new file mode 100644 index 000000000..ca75e2733 --- /dev/null +++ b/src/database/test_helpers.rs @@ -0,0 +1,94 @@ +//! Test helper utilities for database testing. +//! +//! This module provides utilities for creating test databases that can be used +//! in unit and integration tests throughout the codebase. + +use crate::database::Database; +use std::path::PathBuf; +use tempfile::TempDir; + +/// Creates an in-memory SQLite database for testing. +/// +/// This is the fastest option for tests that don't need to persist data +/// or test file-based functionality. +/// +/// # Example +/// ``` +/// use dash_evo_tool::database::test_helpers::create_test_database; +/// +/// let db = create_test_database().unwrap(); +/// // Use db for testing... +/// ``` +pub fn create_test_database() -> rusqlite::Result { + let db = Database::new(":memory:")?; + // Initialize tables using the standard initialization path + // Note: We use a dummy path since :memory: doesn't use the file system + let dummy_path = PathBuf::from(":memory:"); + db.initialize(&dummy_path)?; + Ok(db) +} + +/// Creates a file-based temporary database for testing. +/// +/// Use this when you need to test file-based operations like: +/// - Database migrations +/// - Backup functionality +/// - Persistence across connections +/// +/// The returned `TempDir` must be kept alive for the duration of the test, +/// as dropping it will delete the temporary directory. +/// +/// # Example +/// ``` +/// use dash_evo_tool::database::test_helpers::create_temp_database; +/// +/// let (db, _temp_dir) = create_temp_database().unwrap(); +/// // Use db for testing... +/// // _temp_dir is dropped at the end, cleaning up the test files +/// ``` +pub fn create_temp_database() -> rusqlite::Result<(Database, TempDir)> { + let temp_dir = tempfile::tempdir().map_err(|e| { + rusqlite::Error::ToSqlConversionFailure(format!("Failed to create temp dir: {}", e).into()) + })?; + let db_path = temp_dir.path().join("test_data.db"); + let db = Database::new(&db_path)?; + db.initialize(&db_path)?; + Ok((db, temp_dir)) +} + +/// Creates a test database with a specific file path. +/// +/// Useful when you need to control the exact location of the database file. +pub fn create_database_at_path(path: &std::path::Path) -> rusqlite::Result { + let db = Database::new(path)?; + db.initialize(path)?; + Ok(db) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_test_database() { + let db = create_test_database(); + assert!(db.is_ok(), "Should create in-memory database successfully"); + } + + #[test] + fn test_create_temp_database() { + let result = create_temp_database(); + assert!( + result.is_ok(), + "Should create temporary database successfully" + ); + + let (db, temp_dir) = result.unwrap(); + let db_path = temp_dir.path().join("test_data.db"); + assert!(db_path.exists(), "Database file should exist"); + + // Verify database is functional + let settings = db.get_settings(); + assert!(settings.is_ok(), "Should be able to query settings"); + } +} diff --git a/src/database/utxo.rs b/src/database/utxo.rs index 526635b0b..7ebdf3700 100644 --- a/src/database/utxo.rs +++ b/src/database/utxo.rs @@ -87,3 +87,207 @@ impl Database { Ok(utxos) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::test_helpers::create_test_database; + + fn create_test_address(network: Network) -> Address { + // Create a test P2PKH address + let pubkey_bytes = [0x02; 33]; // Dummy compressed public key + let pubkey = dash_sdk::dpp::dashcore::PublicKey::from_slice(&pubkey_bytes).unwrap(); + Address::p2pkh(&pubkey, network) + } + + fn create_test_txid() -> Txid { + // Create a test txid from 32 bytes + let txid_bytes: [u8; 32] = [ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ]; + Txid::from_slice(&txid_bytes).unwrap() + } + + #[test] + fn test_insert_utxo() { + let db = create_test_database().expect("Failed to create test database"); + let network = Network::Testnet; + let address = create_test_address(network); + let txid = create_test_txid(); + let script_pubkey = address.script_pubkey(); + + // Insert a UTXO + db.insert_utxo( + txid.as_byte_array(), + 0, + &address, + 100_000_000, // 1 DASH + script_pubkey.as_bytes(), + network, + ) + .expect("Failed to insert UTXO"); + + // Verify it was inserted by retrieving it + let utxos = db + .get_utxos_by_address(&address.to_string(), &network.to_string()) + .expect("Failed to get UTXOs"); + + assert_eq!(utxos.len(), 1); + assert_eq!(utxos[0].0.txid, txid); + assert_eq!(utxos[0].0.vout, 0); + assert_eq!(utxos[0].1.value, 100_000_000); + } + + #[test] + fn test_insert_utxo_duplicate_ignored() { + let db = create_test_database().expect("Failed to create test database"); + let network = Network::Testnet; + let address = create_test_address(network); + let txid = create_test_txid(); + let script_pubkey = address.script_pubkey(); + + // Insert the same UTXO twice (should be ignored due to INSERT OR IGNORE) + db.insert_utxo( + txid.as_byte_array(), + 0, + &address, + 100_000_000, + script_pubkey.as_bytes(), + network, + ) + .expect("Failed to insert UTXO"); + + db.insert_utxo( + txid.as_byte_array(), + 0, + &address, + 200_000_000, // Different value + script_pubkey.as_bytes(), + network, + ) + .expect("Failed to insert UTXO"); + + // Should still only have 1 UTXO with original value + let utxos = db + .get_utxos_by_address(&address.to_string(), &network.to_string()) + .expect("Failed to get UTXOs"); + + assert_eq!(utxos.len(), 1); + assert_eq!(utxos[0].1.value, 100_000_000); // Original value preserved + } + + #[test] + fn test_drop_utxo() { + let db = create_test_database().expect("Failed to create test database"); + let network = Network::Testnet; + let address = create_test_address(network); + let txid = create_test_txid(); + let script_pubkey = address.script_pubkey(); + + // Insert a UTXO + db.insert_utxo( + txid.as_byte_array(), + 0, + &address, + 100_000_000, + script_pubkey.as_bytes(), + network, + ) + .expect("Failed to insert UTXO"); + + // Verify it exists + let utxos = db + .get_utxos_by_address(&address.to_string(), &network.to_string()) + .expect("Failed to get UTXOs"); + assert_eq!(utxos.len(), 1); + + // Drop the UTXO + let outpoint = OutPoint { txid, vout: 0 }; + db.drop_utxo(&outpoint, &network.to_string()) + .expect("Failed to drop UTXO"); + + // Verify it's gone + let utxos = db + .get_utxos_by_address(&address.to_string(), &network.to_string()) + .expect("Failed to get UTXOs"); + assert_eq!(utxos.len(), 0); + } + + #[test] + fn test_utxo_network_filtering() { + let db = create_test_database().expect("Failed to create test database"); + let testnet_address = create_test_address(Network::Testnet); + let mainnet_address = create_test_address(Network::Dash); + let txid = create_test_txid(); + + // Insert UTXO for testnet + db.insert_utxo( + txid.as_byte_array(), + 0, + &testnet_address, + 100_000_000, + testnet_address.script_pubkey().as_bytes(), + Network::Testnet, + ) + .expect("Failed to insert testnet UTXO"); + + // Insert UTXO for mainnet with different vout + db.insert_utxo( + txid.as_byte_array(), + 1, + &mainnet_address, + 200_000_000, + mainnet_address.script_pubkey().as_bytes(), + Network::Dash, + ) + .expect("Failed to insert mainnet UTXO"); + + // Query testnet UTXOs + let testnet_utxos = db + .get_utxos_by_address(&testnet_address.to_string(), "testnet") + .expect("Failed to get testnet UTXOs"); + assert_eq!(testnet_utxos.len(), 1); + assert_eq!(testnet_utxos[0].1.value, 100_000_000); + + // Query mainnet UTXOs + let mainnet_utxos = db + .get_utxos_by_address(&mainnet_address.to_string(), "dash") + .expect("Failed to get mainnet UTXOs"); + assert_eq!(mainnet_utxos.len(), 1); + assert_eq!(mainnet_utxos[0].1.value, 200_000_000); + } + + #[test] + fn test_multiple_utxos_same_address() { + let db = create_test_database().expect("Failed to create test database"); + let network = Network::Testnet; + let address = create_test_address(network); + let txid = create_test_txid(); + let script_pubkey = address.script_pubkey(); + + // Insert multiple UTXOs for the same address (different vouts) + for vout in 0..5 { + db.insert_utxo( + txid.as_byte_array(), + vout, + &address, + (vout as u64 + 1) * 100_000_000, + script_pubkey.as_bytes(), + network, + ) + .expect("Failed to insert UTXO"); + } + + // Should have 5 UTXOs + let utxos = db + .get_utxos_by_address(&address.to_string(), &network.to_string()) + .expect("Failed to get UTXOs"); + assert_eq!(utxos.len(), 5); + + // Calculate total value + let total: u64 = utxos.iter().map(|(_, tx_out)| tx_out.value).sum(); + assert_eq!(total, 1_500_000_000); // 1+2+3+4+5 = 15 DASH + } +} diff --git a/src/database/wallet.rs b/src/database/wallet.rs index 220ff6c6f..5a4f4f4fd 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -1105,3 +1105,483 @@ impl From for rusqlite::Error { rusqlite::Error::UserFunctionError(Box::new(err)) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::test_helpers::create_test_database; + use dash_sdk::dpp::key_wallet::bip32::DerivationPath; + use std::str::FromStr; + + fn create_test_address(network: Network) -> Address { + let pubkey_bytes = [0x02; 33]; + let pubkey = dash_sdk::dpp::dashcore::PublicKey::from_slice(&pubkey_bytes).unwrap(); + Address::p2pkh(&pubkey, network) + } + + fn create_test_seed_hash() -> [u8; 32] { + let mut hash = [0u8; 32]; + for (i, byte) in hash.iter_mut().enumerate() { + *byte = i as u8; + } + hash + } + + #[test] + fn test_wallet_balance_update() { + let db = create_test_database().expect("Failed to create test database"); + let seed_hash = create_test_seed_hash(); + + // We need to insert a wallet first (simplified - using raw SQL for test setup) + { + let conn = db.conn.lock().unwrap(); + conn.execute( + "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) + VALUES (?, ?, ?, ?, ?, 0, 'testnet')", + rusqlite::params![ + seed_hash.as_slice(), + vec![0u8; 64], // Dummy encrypted seed + vec![0u8; 16], // Dummy salt + vec![0u8; 12], // Dummy nonce + vec![0u8; 78], // Dummy extended public key + ], + ) + .expect("Failed to insert test wallet"); + } + + // Update balances + db.update_wallet_balances(&seed_hash, 1_000_000, 500_000, 1_500_000) + .expect("Failed to update wallet balances"); + + // Verify via raw query (since get_wallets is complex) + let conn = db.conn.lock().unwrap(); + let (confirmed, unconfirmed, total): (i64, i64, i64) = conn + .query_row( + "SELECT confirmed_balance, unconfirmed_balance, total_balance FROM wallet WHERE seed_hash = ?", + rusqlite::params![seed_hash.as_slice()], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .expect("Failed to query balances"); + + assert_eq!(confirmed, 1_000_000); + assert_eq!(unconfirmed, 500_000); + assert_eq!(total, 1_500_000); + } + + #[test] + fn test_platform_address_info() { + let db = create_test_database().expect("Failed to create test database"); + let network = Network::Testnet; + let seed_hash = create_test_seed_hash(); + let address = create_test_address(network); + + // Insert test wallet first + { + let conn = db.conn.lock().unwrap(); + conn.execute( + "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) + VALUES (?, ?, ?, ?, ?, 0, 'testnet')", + rusqlite::params![ + seed_hash.as_slice(), + vec![0u8; 64], + vec![0u8; 16], + vec![0u8; 12], + vec![0u8; 78], + ], + ) + .expect("Failed to insert test wallet"); + } + + // Initially no platform address info + let info = db + .get_platform_address_info(&seed_hash, &address, &network) + .expect("Failed to get platform address info"); + assert!(info.is_none()); + + // Set platform address info + db.set_platform_address_info(&seed_hash, &address, 10_000_000, 5, &network) + .expect("Failed to set platform address info"); + + // Retrieve it + let info = db + .get_platform_address_info(&seed_hash, &address, &network) + .expect("Failed to get platform address info") + .expect("Expected platform address info"); + + assert_eq!(info.0, 10_000_000); // balance + assert_eq!(info.1, 5); // nonce + + // Update it + db.set_platform_address_info(&seed_hash, &address, 20_000_000, 10, &network) + .expect("Failed to update platform address info"); + + let info = db + .get_platform_address_info(&seed_hash, &address, &network) + .expect("Failed to get platform address info") + .expect("Expected platform address info"); + + assert_eq!(info.0, 20_000_000); + assert_eq!(info.1, 10); + } + + #[test] + fn test_get_all_platform_address_info() { + let db = create_test_database().expect("Failed to create test database"); + let network = Network::Testnet; + let seed_hash = create_test_seed_hash(); + + // Insert test wallet first + { + let conn = db.conn.lock().unwrap(); + conn.execute( + "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) + VALUES (?, ?, ?, ?, ?, 0, 'testnet')", + rusqlite::params![ + seed_hash.as_slice(), + vec![0u8; 64], + vec![0u8; 16], + vec![0u8; 12], + vec![0u8; 78], + ], + ) + .expect("Failed to insert test wallet"); + } + + // Add multiple platform addresses using the same valid pubkey base but with different addresses + // by modifying the address string directly in the database + let base_address = create_test_address(network); + for i in 0..3u8 { + // Insert directly with modified address string to avoid secp256k1 key generation issues + let addr_str = format!("{}_{}", base_address, i); + let conn = db.conn.lock().unwrap(); + let updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + conn.execute( + "INSERT OR REPLACE INTO platform_address_balances + (seed_hash, address, balance, nonce, network, updated_at) + VALUES (?, ?, ?, ?, ?, ?)", + rusqlite::params![ + seed_hash.as_slice(), + addr_str, + (i as i64 + 1) * 1_000_000, + i as i64, + network.to_string(), + updated_at + ], + ) + .expect("Failed to insert platform address info"); + } + + // Get all addresses (note: the addresses won't parse correctly, but the function should still return 0 valid entries) + // This tests that the function handles the case gracefully + let all_info = db + .get_all_platform_address_info(&seed_hash, &network) + .expect("Failed to get all platform address info"); + + // The modified addresses won't parse, so we expect 0 results + // This is actually testing the error handling path + assert_eq!(all_info.len(), 0); + } + + #[test] + fn test_get_all_platform_address_info_valid() { + let db = create_test_database().expect("Failed to create test database"); + let network = Network::Testnet; + let seed_hash = create_test_seed_hash(); + + // Insert test wallet first + { + let conn = db.conn.lock().unwrap(); + conn.execute( + "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) + VALUES (?, ?, ?, ?, ?, 0, 'testnet')", + rusqlite::params![ + seed_hash.as_slice(), + vec![0u8; 64], + vec![0u8; 16], + vec![0u8; 12], + vec![0u8; 78], + ], + ) + .expect("Failed to insert test wallet"); + } + + // Add a single valid platform address using the helper function + let address = create_test_address(network); + db.set_platform_address_info(&seed_hash, &address, 5_000_000, 3, &network) + .expect("Failed to set platform address info"); + + // Get all addresses + let all_info = db + .get_all_platform_address_info(&seed_hash, &network) + .expect("Failed to get all platform address info"); + + assert_eq!(all_info.len(), 1); + assert_eq!(all_info[0].1, 5_000_000); // balance + assert_eq!(all_info[0].2, 3); // nonce + } + + #[test] + fn test_delete_platform_address_info() { + let db = create_test_database().expect("Failed to create test database"); + let network = Network::Testnet; + let seed_hash = create_test_seed_hash(); + let address = create_test_address(network); + + // Insert test wallet first + { + let conn = db.conn.lock().unwrap(); + conn.execute( + "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) + VALUES (?, ?, ?, ?, ?, 0, 'testnet')", + rusqlite::params![ + seed_hash.as_slice(), + vec![0u8; 64], + vec![0u8; 16], + vec![0u8; 12], + vec![0u8; 78], + ], + ) + .expect("Failed to insert test wallet"); + } + + // Set platform address info + db.set_platform_address_info(&seed_hash, &address, 10_000_000, 5, &network) + .expect("Failed to set platform address info"); + + // Verify it exists + let info = db + .get_platform_address_info(&seed_hash, &address, &network) + .expect("Failed to get platform address info"); + assert!(info.is_some()); + + // Delete all platform address info for the wallet + db.delete_platform_address_info(&seed_hash, &network) + .expect("Failed to delete platform address info"); + + // Should be gone + let info = db + .get_platform_address_info(&seed_hash, &address, &network) + .expect("Failed to get platform address info"); + assert!(info.is_none()); + } + + #[test] + fn test_platform_sync_info() { + let db = create_test_database().expect("Failed to create test database"); + let seed_hash = create_test_seed_hash(); + + // Insert test wallet first + { + let conn = db.conn.lock().unwrap(); + conn.execute( + "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) + VALUES (?, ?, ?, ?, ?, 0, 'testnet')", + rusqlite::params![ + seed_hash.as_slice(), + vec![0u8; 64], + vec![0u8; 16], + vec![0u8; 12], + vec![0u8; 78], + ], + ) + .expect("Failed to insert test wallet"); + } + + // Initial sync info should be zeros + let (last_sync, checkpoint, last_terminal) = db + .get_platform_sync_info(&seed_hash) + .expect("Failed to get platform sync info"); + assert_eq!(last_sync, 0); + assert_eq!(checkpoint, 0); + assert_eq!(last_terminal, 0); + + // Set sync info + let timestamp = 1700000000u64; + let height = 100000u64; + db.set_platform_sync_info(&seed_hash, timestamp, height) + .expect("Failed to set platform sync info"); + + let (last_sync, checkpoint, last_terminal) = db + .get_platform_sync_info(&seed_hash) + .expect("Failed to get platform sync info"); + assert_eq!(last_sync, timestamp); + assert_eq!(checkpoint, height); + assert_eq!(last_terminal, 0); // Reset to 0 by set_platform_sync_info + + // Set last terminal block + db.set_last_terminal_block(&seed_hash, 100500) + .expect("Failed to set last terminal block"); + + let (_, _, last_terminal) = db + .get_platform_sync_info(&seed_hash) + .expect("Failed to get platform sync info"); + assert_eq!(last_terminal, 100500); + } + + #[test] + fn test_set_wallet_alias() { + let db = create_test_database().expect("Failed to create test database"); + let seed_hash = create_test_seed_hash(); + + // Insert test wallet first + { + let conn = db.conn.lock().unwrap(); + conn.execute( + "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) + VALUES (?, ?, ?, ?, ?, 0, 'testnet')", + rusqlite::params![ + seed_hash.as_slice(), + vec![0u8; 64], + vec![0u8; 16], + vec![0u8; 12], + vec![0u8; 78], + ], + ) + .expect("Failed to insert test wallet"); + } + + // Set alias + db.set_wallet_alias(&seed_hash, Some("My Wallet".to_string())) + .expect("Failed to set wallet alias"); + + // Verify + let conn = db.conn.lock().unwrap(); + let alias: Option = conn + .query_row( + "SELECT alias FROM wallet WHERE seed_hash = ?", + rusqlite::params![seed_hash.as_slice()], + |row| row.get(0), + ) + .expect("Failed to query alias"); + assert_eq!(alias, Some("My Wallet".to_string())); + + drop(conn); + + // Clear alias + db.set_wallet_alias(&seed_hash, None) + .expect("Failed to clear wallet alias"); + + let conn = db.conn.lock().unwrap(); + let alias: Option = conn + .query_row( + "SELECT alias FROM wallet WHERE seed_hash = ?", + rusqlite::params![seed_hash.as_slice()], + |row| row.get(0), + ) + .expect("Failed to query alias"); + assert!(alias.is_none()); + } + + #[test] + fn test_address_balance_operations() { + let db = create_test_database().expect("Failed to create test database"); + let network = Network::Testnet; + let seed_hash = create_test_seed_hash(); + let address = create_test_address(network); + let derivation_path = DerivationPath::from_str("m/44'/1'/0'/0/0").unwrap(); + + // Insert test wallet first + { + let conn = db.conn.lock().unwrap(); + conn.execute( + "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) + VALUES (?, ?, ?, ?, ?, 0, 'testnet')", + rusqlite::params![ + seed_hash.as_slice(), + vec![0u8; 64], + vec![0u8; 16], + vec![0u8; 12], + vec![0u8; 78], + ], + ) + .expect("Failed to insert test wallet"); + } + + // Add address + db.add_address_if_not_exists( + &seed_hash, + &address, + &network, + &derivation_path, + DerivationPathReference::BIP44, + DerivationPathType::CLEAR_FUNDS, + Some(1_000_000), + ) + .expect("Failed to add address"); + + // Update address balance + db.update_address_balance(&seed_hash, &address, 2_000_000) + .expect("Failed to update address balance"); + + // Add to address balance + db.add_to_address_balance(&seed_hash, &address, 500_000) + .expect("Failed to add to address balance"); + + // Verify final balance + let conn = db.conn.lock().unwrap(); + let balance: i64 = conn + .query_row( + "SELECT balance FROM wallet_addresses WHERE seed_hash = ? AND address = ?", + rusqlite::params![seed_hash.as_slice(), address.to_string()], + |row| row.get(0), + ) + .expect("Failed to query balance"); + assert_eq!(balance, 2_500_000); + } + + #[test] + fn test_update_address_total_received() { + let db = create_test_database().expect("Failed to create test database"); + let network = Network::Testnet; + let seed_hash = create_test_seed_hash(); + let address = create_test_address(network); + let derivation_path = DerivationPath::from_str("m/44'/1'/0'/0/0").unwrap(); + + // Insert test wallet first + { + let conn = db.conn.lock().unwrap(); + conn.execute( + "INSERT INTO wallet (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, uses_password, network) + VALUES (?, ?, ?, ?, ?, 0, 'testnet')", + rusqlite::params![ + seed_hash.as_slice(), + vec![0u8; 64], + vec![0u8; 16], + vec![0u8; 12], + vec![0u8; 78], + ], + ) + .expect("Failed to insert test wallet"); + } + + // Add address + db.add_address_if_not_exists( + &seed_hash, + &address, + &network, + &derivation_path, + DerivationPathReference::BIP44, + DerivationPathType::CLEAR_FUNDS, + None, + ) + .expect("Failed to add address"); + + // Update total received + db.update_address_total_received(&seed_hash, &address, 10_000_000) + .expect("Failed to update total received"); + + // Verify + let conn = db.conn.lock().unwrap(); + let total_received: i64 = conn + .query_row( + "SELECT total_received FROM wallet_addresses WHERE seed_hash = ? AND address = ?", + rusqlite::params![seed_hash.as_slice(), address.to_string()], + |row| row.get(0), + ) + .expect("Failed to query total_received"); + assert_eq!(total_received, 10_000_000); + } +} diff --git a/src/logging.rs b/src/logging.rs index 68e44e1a7..1e22463c5 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,9 +1,18 @@ use crate::{VERSION, app_dir::app_user_data_file_path}; use std::panic; +use std::sync::Once; use tracing::{error, info}; use tracing_subscriber::EnvFilter; +static INIT_LOGGER: Once = Once::new(); + pub fn initialize_logger() { + INIT_LOGGER.call_once(|| { + initialize_logger_internal(); + }); +} + +fn initialize_logger_internal() { // Initialize log file, with improved error handling let log_file_path = app_user_data_file_path("det.log").expect("should create log file path"); let log_file = match std::fs::File::create(&log_file_path) { @@ -23,9 +32,10 @@ pub fn initialize_logger() { .with_ansi(false) .finish(); - // Set global subscriber with proper error handling - if let Err(e) = tracing::subscriber::set_global_default(subscriber) { - panic!("Unable to set global default subscriber: {:?}", e); + // Set global subscriber - ignore error if already set (can happen in tests) + if let Err(_e) = tracing::subscriber::set_global_default(subscriber) { + // Logger already initialized, this is fine + return; } // Log panic events diff --git a/tests/e2e/helpers.rs b/tests/e2e/helpers.rs new file mode 100644 index 000000000..1deba5264 --- /dev/null +++ b/tests/e2e/helpers.rs @@ -0,0 +1,36 @@ +//! E2E Test Helpers +//! +//! This module provides shared utilities for E2E testing, including: +//! - Test harness setup +//! - Common test fixtures + +/// Create a minimal test harness for E2E tests +#[allow(dead_code)] +pub struct TestHarness { + pub runtime: tokio::runtime::Runtime, +} + +impl TestHarness { + pub fn new() -> Self { + let runtime = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + Self { runtime } + } +} + +impl Default for TestHarness { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_harness_creation() { + let harness = TestHarness::new(); + // Just verify we can create the harness without panicking + drop(harness); + } +} diff --git a/tests/e2e/main.rs b/tests/e2e/main.rs new file mode 100644 index 000000000..d75b5912e --- /dev/null +++ b/tests/e2e/main.rs @@ -0,0 +1,8 @@ +//! E2E Test Suite Entry Point +//! +//! This file serves as the entry point for the E2E test suite. +//! Run with: cargo test --test e2e + +mod helpers; +mod navigation; +mod wallet_flows; diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs new file mode 100644 index 000000000..acacf49c1 --- /dev/null +++ b/tests/e2e/mod.rs @@ -0,0 +1,9 @@ +//! E2E Test Suite for Dash Evo Tool +//! +//! This module contains end-to-end tests that verify complete user journeys. +//! The tests use egui_kittest to simulate the UI and verify that screens +//! render and behave correctly. + +mod helpers; +mod navigation; +mod wallet_flows; diff --git a/tests/e2e/navigation.rs b/tests/e2e/navigation.rs new file mode 100644 index 000000000..322193f8a --- /dev/null +++ b/tests/e2e/navigation.rs @@ -0,0 +1,101 @@ +//! E2E Tests for Navigation +//! +//! These tests verify that navigation between screens works correctly +//! and that state is preserved appropriately. + +use egui_kittest::Harness; + +/// Test that app navigation completes without errors +#[test] +fn test_basic_navigation() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + harness.set_size(egui::vec2(1024.0, 768.0)); + + // Run initial frames + harness.run_steps(20); +} + +/// Test navigation with different window sizes +#[test] +fn test_navigation_responsive_layout() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let sizes = [ + egui::vec2(640.0, 480.0), + egui::vec2(1024.0, 768.0), + egui::vec2(1440.0, 900.0), + ]; + + for size in sizes { + let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + harness.set_size(size); + harness.run_steps(15); + } +} + +/// Test that rapid navigation doesn't cause issues +#[test] +fn test_rapid_frame_navigation() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(300).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + harness.set_size(egui::vec2(1024.0, 768.0)); + + // Run many single-step frames + for _ in 0..50 { + harness.run_steps(1); + } +} + +/// Test that the app maintains stability over extended use +#[test] +fn test_extended_navigation_stability() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(500).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + harness.set_size(egui::vec2(1280.0, 720.0)); + + // Run 100 frames in batches + for batch in 0..10 { + harness.run_steps(10); + // Verify each batch completes + let _ = batch; + } +} + +/// Test app behavior with minimum window size +#[test] +fn test_minimum_size_navigation() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + // Very small window + harness.set_size(egui::vec2(320.0, 240.0)); + harness.run_steps(10); + + // Resize to normal + harness.set_size(egui::vec2(1024.0, 768.0)); + harness.run_steps(10); +} diff --git a/tests/e2e/wallet_flows.rs b/tests/e2e/wallet_flows.rs new file mode 100644 index 000000000..124af420d --- /dev/null +++ b/tests/e2e/wallet_flows.rs @@ -0,0 +1,80 @@ +//! E2E Tests for Wallet Flows +//! +//! These tests verify complete user journeys related to wallets, +//! including balance display and state management. + +use egui_kittest::Harness; + +/// Test that the app starts with proper wallet state initialization +#[test] +fn test_wallet_state_initialization() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + harness.set_size(egui::vec2(1024.0, 768.0)); + harness.run_steps(20); +} + +/// Test that wallet balance display renders correctly +#[test] +fn test_wallet_balance_rendering() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + harness.set_size(egui::vec2(1024.0, 768.0)); + + // Run enough frames to fully initialize + harness.run_steps(30); +} + +/// Test wallet operations don't cause UI freezes +#[test] +fn test_wallet_ui_responsiveness() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(200).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + harness.set_size(egui::vec2(1024.0, 768.0)); + + // Run many frames to test UI responsiveness + for batch in 0..10 { + harness.run_steps(15); + // Each batch should complete without hanging + let _ = batch; + } +} + +/// Test that the app handles rapid resizing during wallet views +#[test] +fn test_wallet_resize_stability() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(150).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + // Test various resize scenarios + let sizes = [ + egui::vec2(800.0, 600.0), + egui::vec2(1200.0, 900.0), + egui::vec2(640.0, 480.0), + egui::vec2(1920.0, 1080.0), + ]; + + for size in sizes { + harness.set_size(size); + harness.run_steps(10); + } +} diff --git a/tests/kittest/identities_screen.rs b/tests/kittest/identities_screen.rs new file mode 100644 index 000000000..01927d089 --- /dev/null +++ b/tests/kittest/identities_screen.rs @@ -0,0 +1,73 @@ +use egui_kittest::Harness; + +/// Test that the identities screen can be rendered +#[test] +fn test_identities_screen_renders() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + harness.set_size(egui::vec2(1024.0, 768.0)); + harness.run_steps(10); +} + +/// Test that the app renders correctly at minimum size +#[test] +fn test_minimum_window_size() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + // Test with a small window size + harness.set_size(egui::vec2(400.0, 300.0)); + harness.run_steps(10); +} + +/// Test that the app handles resize gracefully +#[test] +fn test_window_resize() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + // Start small + harness.set_size(egui::vec2(640.0, 480.0)); + harness.run_steps(5); + + // Resize larger + harness.set_size(egui::vec2(1280.0, 720.0)); + harness.run_steps(5); + + // Resize smaller again + harness.set_size(egui::vec2(800.0, 600.0)); + harness.run_steps(5); +} + +/// Test multiple frame batches +#[test] +fn test_frame_batch_processing() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(150).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + harness.set_size(egui::vec2(1024.0, 768.0)); + + // Process frames in batches + for batch in 0..10 { + harness.run_steps(10); + // Just ensure we can run multiple batches without error + let _ = batch; + } +} diff --git a/tests/kittest/main.rs b/tests/kittest/main.rs index 27a425da9..e5cc0a94f 100644 --- a/tests/kittest/main.rs +++ b/tests/kittest/main.rs @@ -1 +1,4 @@ +mod identities_screen; +mod network_chooser; mod startup; +mod wallets_screen; diff --git a/tests/kittest/network_chooser.rs b/tests/kittest/network_chooser.rs new file mode 100644 index 000000000..1d249b45c --- /dev/null +++ b/tests/kittest/network_chooser.rs @@ -0,0 +1,60 @@ +use egui_kittest::Harness; + +/// Test that the network chooser screen renders without panicking +#[test] +fn test_network_chooser_renders() { + // Create a tokio runtime for async operations during app initialization + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + // Create a test harness for the egui app + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + // Set the window size + harness.set_size(egui::vec2(1024.0, 768.0)); + + // Run a few frames to ensure the app initializes + harness.run_steps(10); +} + +/// Test that the app can handle screen navigation +#[test] +fn test_app_handles_frame_stepping() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + harness.set_size(egui::vec2(800.0, 600.0)); + + // Run multiple batches of frames + for _ in 0..5 { + harness.run_steps(5); + } +} + +/// Test that the app renders at different window sizes +#[test] +fn test_app_renders_at_various_sizes() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let sizes = [ + egui::vec2(640.0, 480.0), // Small + egui::vec2(1024.0, 768.0), // Medium + egui::vec2(1920.0, 1080.0), // Large + ]; + + for size in sizes { + let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + harness.set_size(size); + harness.run_steps(5); + } +} diff --git a/tests/kittest/wallets_screen.rs b/tests/kittest/wallets_screen.rs new file mode 100644 index 000000000..c305fdcc4 --- /dev/null +++ b/tests/kittest/wallets_screen.rs @@ -0,0 +1,49 @@ +use egui_kittest::Harness; + +/// Test that the wallets screen can be rendered +#[test] +fn test_wallets_screen_renders() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + harness.set_size(egui::vec2(1024.0, 768.0)); + harness.run_steps(10); +} + +/// Test that the app can run many frames without issues +#[test] +fn test_app_stability_over_many_frames() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(200).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + harness.set_size(egui::vec2(1024.0, 768.0)); + + // Run 50 frames to test stability + harness.run_steps(50); +} + +/// Test rapid frame stepping +#[test] +fn test_rapid_frame_stepping() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + harness.set_size(egui::vec2(800.0, 600.0)); + + // Run single steps rapidly + for _ in 0..20 { + harness.run_steps(1); + } +} From 5c228ddf1d60404324e8445e0d4c6daba37e45d2 Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Tue, 20 Jan 2026 08:26:47 +0100 Subject: [PATCH 045/106] fix: cannot create identity from address due to wrong identity id (#470) * feat: dashpay * ok * ok * remove doc * ok * ok * database * ok * fix: use proper BackendTaskSuccessResults rather than Message * fmt and clippy * remove dashpay dip file * logo and cleanup * fix top panel spacing * ui stuff * fmt * dip 14 * image fetching * some fixes * cleaning up * todos * feat: SPV phase 1 (#440) * feat: spv in det * ui * progress bars * working progress updates * fmt * fast sync * add back progress monitoring * back to git dep * newer dashcore version * deduplication * spv context provider * fixes * small fix * clippy fixes * fixes --------- Co-authored-by: Quantum Explorer * feat: spv progress bars and sync from genesis when wallets are loaded (#454) * feat: spv progress bars * feat: start from 0 if syncing with wallets * clippy * fmt * update * fixes * fix: add sdk features * feat: add accounts and transactions to wallet screen * clippy * fmt * fix: dashpay database table initialization was hanging on new db (#463) * fix: display stuff * add accounts and much progress * feat: transaction history in wallet * clippy * ok * feat: hd wallet * send tx via wallet and wallet lock/unlock * update to PeerNetworkManager * ok * ok * ok * fmt * clippy * clippy * feat: clear SPV data button * fix: switch from dash core rpc to spv mode * feat: switch to DashSpvClientInterface * clean spv button fix * feat: send from wallet screen * feat: platform addresses * feat: platform address transitions * feat: platform address transitions * feat: move dashpay screen to top level * platform addresses sdk * remove async context provider fn * lock * update for new sdk with addresses and handle sdk result data * chore: update grovestark dependency * chore: update dash-sdk rev * fmt * remove elliptic curves patch * clippy * feat: use BackendTaskSuccessResult variants instead of Message(String) * fmt * clippy * update DIP18 implementation, fixes and some features around wallets and PAs * fix * wallet unlock on wallet screen * info popup component * small fixes * wallet unlock component * fix startup panic * welcome screen and dashpay stuff * cargo fix * cargo fmt * chore: update deps to use most recent dash-sdk * simplify welcome screen and move spv storage clear to advanced settings * default connect to peers via SPV DNS seeds * fix: exit app without waiting * fix error message and reverse quorum hash on lookup * fmt * settings to turn off spv auto sync * clippy * work on getting started flow * AddressProvider stuff * address sync stuff * many fixes all in one * fix * many fixes * fixes * amount input stuff * alignment in identity create screen for adding keys * more fixes * many improvements * simple token creator, word count selection for wallet seed phrases, more * fix: platform address info storage * feat: generate platform addresses and address sync post-checkpoint * many things * fix: cannot create identity from address * chore: update to recent changes in platform * docs: added few lines about .env file * fix: banned addresses and failure during address sync * fix: updates for new platform and fee estimation * fix: only take PA credits after checkpoint * feat: fee estimation and display * feat: fee estimation follow-up * chore: fix build * chore: correct platform revision * refactor: don't query for nonce when creating identity * more on fee displays * fix: proof logs in data contract create * fix: white on white text for fee estimation display * fix: always start as closed for advanced settings dropdown in settings screen * fix: hard to see data contracts in the register contract screen * fix: document create screen scroll * fix: fee estimations accuracy * fmt * clippy auto fixes * clippy manual fixes and pin dash-sdk dep * fix failing tests * fmt * fix: update rust toolchain to 1.92 * refactor: hide SPV behind dev mode * feat: warning about SPV being experimental * fix: platform nonces not updated in sync * chore: cargo fmt * deps: update platform repo * chore: clippy and rabbit * chore: rabbit review * chore: enforce address networ with require_network() * cleanup based on claude review * fmt * cleanup based on another claude review * fix: kittest failing * fix: remove fee result display in success screens and clean up dashpay avatar display * dashpay fixes and platform address fixes * deps: update platform repo * chore: fixes after merge * refactor: move some AppContext functions to backend_task module * clippy * clippy * fix: refresh mode alignment in wallet screen and fmt * chore: fmt * fmt --------- Co-authored-by: pauldelucia Co-authored-by: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Co-authored-by: Quantum Explorer Co-authored-by: Ivan Shumkov --- Cargo.lock | 88 +++++++++---------- Cargo.toml | 2 +- .../identity/register_identity.rs | 80 +++++++++++++---- src/backend_task/mod.rs | 6 +- .../wallet/fetch_platform_address_balances.rs | 34 +++---- src/database/wallet.rs | 49 ++++++++--- src/model/wallet/mod.rs | 87 +++++++++++------- src/sdk_wrapper.rs | 1 + src/ui/wallets/wallets_screen/mod.rs | 12 +-- 9 files changed, 221 insertions(+), 138 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d5c06de6c..5893b47cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1734,8 +1734,8 @@ dependencies = [ [[package]] name = "dapi-grpc" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "dash-platform-macros", "futures-core", @@ -1802,8 +1802,8 @@ dependencies = [ [[package]] name = "dash-context-provider" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "dpp", "drive", @@ -1891,8 +1891,8 @@ dependencies = [ [[package]] name = "dash-platform-macros" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "heck", "quote", @@ -1901,8 +1901,8 @@ dependencies = [ [[package]] name = "dash-sdk" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "arc-swap", "async-trait", @@ -2051,8 +2051,8 @@ dependencies = [ [[package]] name = "dashpay-contract" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "platform-value", "platform-version", @@ -2062,8 +2062,8 @@ dependencies = [ [[package]] name = "data-contracts" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2324,8 +2324,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "platform-value", "platform-version", @@ -2335,8 +2335,8 @@ dependencies = [ [[package]] name = "dpp" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "anyhow", "async-trait", @@ -2384,8 +2384,8 @@ dependencies = [ [[package]] name = "drive" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "bincode 2.0.0-rc.3", "byteorder", @@ -2409,8 +2409,8 @@ dependencies = [ [[package]] name = "drive-proof-verifier" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "bincode 2.0.0-rc.3", "dapi-grpc", @@ -2985,8 +2985,8 @@ dependencies = [ [[package]] name = "feature-flags-contract" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "platform-value", "platform-version", @@ -4552,8 +4552,8 @@ dependencies = [ [[package]] name = "keyword-search-contract" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "platform-value", "platform-version", @@ -4778,8 +4778,8 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "platform-value", "platform-version", @@ -5844,8 +5844,8 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "platform-serialization" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "bincode 2.0.0-rc.3", "platform-version", @@ -5853,8 +5853,8 @@ dependencies = [ [[package]] name = "platform-serialization-derive" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "proc-macro2", "quote", @@ -5864,8 +5864,8 @@ dependencies = [ [[package]] name = "platform-value" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "base64 0.22.1", "bincode 2.0.0-rc.3", @@ -5884,8 +5884,8 @@ dependencies = [ [[package]] name = "platform-version" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "bincode 2.0.0-rc.3", "grovedb-version 4.0.0", @@ -5896,8 +5896,8 @@ dependencies = [ [[package]] name = "platform-versioning" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "proc-macro2", "quote", @@ -6507,8 +6507,8 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rs-dapi-client" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "backon", "chrono", @@ -7636,8 +7636,8 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "platform-value", "platform-version", @@ -8376,8 +8376,8 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "platform-value", "platform-version", @@ -9631,8 +9631,8 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "withdrawals-contract" -version = "3.0.0-dev.11" -source = "git+https://github.com/dashpay/platform.git?rev=eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167#eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167" +version = "3.0.0-rc.1" +source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index acae0381e..76f09ec19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.30.1", features = ["signal"] } eframe = { version = "0.32.0", features = ["persistence"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform.git", rev = "eace6d1c4563c3d9d58e6a12f4d5ed8bdd53d167", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index 3674d1990..ae015bb2d 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -4,18 +4,22 @@ use crate::context::AppContext; use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::proof_log_item::{ProofLogItem, RequestType}; use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; +use dash_sdk::dash_spv::Network; use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dpp::ProtocolError; +use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::block::extended_epoch_info::ExtendedEpochInfo; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::{OutPoint, PrivateKey}; +use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; use dash_sdk::dpp::native_bls::NativeBlsModule; -use dash_sdk::dpp::prelude::AssetLockProof; +use dash_sdk::dpp::prelude::{AddressNonce, AssetLockProof}; use dash_sdk::dpp::state_transition::identity_create_transition::IdentityCreateTransition; use dash_sdk::dpp::state_transition::identity_create_transition::methods::IdentityCreateTransitionMethodsV0; use dash_sdk::platform::transition::put_identity::PutIdentity; use dash_sdk::platform::{Fetch, Identity}; +use dash_sdk::query_types::AddressInfo; use dash_sdk::{Error, Sdk}; use std::collections::BTreeMap; use std::time::Duration; @@ -223,14 +227,25 @@ impl AppContext { inputs, wallet_seed_hash, } => { - // This is a separate flow - we call a dedicated function for Platform address funding + // inputs with nonces, incremented by 1 from current nonce + let inputs_with_nonces = inputs + .into_iter() + .map(|(addr, credits)| { + self.get_platform_address_best_info(&addr, self.network) + .map(|info| (addr, (info.nonce.saturating_add(1), credits))) + }) + .collect::>>() + .ok_or(String::from( + "Each input platform address must be present in at least one wallet", + ))?; + return self .register_identity_from_platform_addresses( alias_input, keys, wallet, wallet_identity_index, - inputs, + inputs_with_nonces, wallet_seed_hash, ) .await; @@ -674,7 +689,10 @@ impl AppContext { } } - /// Register a new identity funded by Platform addresses + /// Register a new identity funded by Platform addresses. + /// + /// `inputs` is a map of Platform addresses to (nonce, credits) tuples. Nonces must be incremented by 1 + /// from the current nonce of the address. async fn register_identity_from_platform_addresses( &self, alias_input: String, @@ -683,7 +701,7 @@ impl AppContext { wallet_identity_index: u32, inputs: BTreeMap< dash_sdk::dpp::address_funds::PlatformAddress, - dash_sdk::dpp::fee::Credits, + (AddressNonce, dash_sdk::dpp::fee::Credits), >, wallet_seed_hash: super::WalletSeedHash, ) -> Result { @@ -708,17 +726,12 @@ impl AppContext { // Clone the wallet for use as the address signer (needed across async boundary) let wallet_clone = { wallet.read().map_err(|e| e.to_string())?.clone() }; - // For Platform address funding, we need to compute the identity ID from the inputs - // The SDK will handle this internally when creating the identity - // We create a temporary identity with a placeholder ID, which will be computed correctly - // during the state transition creation - - // Create a temporary identity ID - will be replaced by the actual one from Platform - let temp_identity_id = dash_sdk::platform::Identifier::random(); - - let identity = - Identity::new_with_id_and_keys(temp_identity_id, public_keys.clone(), sdk.version()) - .map_err(|e| format!("Failed to create identity: {}", e))?; + let identity = Identity::new_with_input_addresses_and_keys( + &inputs, + public_keys.clone(), + sdk.version(), + ) + .map_err(|e| format!("Failed to create identity: {}", e))?; let wallet_seed_hash_actual = { wallet.read().unwrap().seed_hash() }; let mut qualified_identity = QualifiedIdentity { @@ -825,4 +838,39 @@ impl AppContext { } } } + + /// Get the best (most recent nonce) AddressInfo from all wallets for the given [PlatformAddress] in current [Self::network]. + /// + /// Returns `None`` if no info is found. + fn get_platform_address_best_info( + &self, + platform_address: &PlatformAddress, + network: Network, + ) -> Option { + let generic_address = platform_address.to_address_with_network(network); + let wallets = self + .wallets + .read() + .inspect_err(|e| tracing::error!(err=%e, "wallet lock poisoned")) + .ok()?; + + let mut recent_info: Option = None; + for wallet in wallets.values() { + let wallet_guard = wallet.read().ok()?; + + if let Some(new_info) = wallet_guard.get_platform_address_info(&generic_address) + && recent_info + .as_ref() + .is_none_or(|recent| new_info.nonce > recent.nonce) + { + recent_info = Some(AddressInfo { + address: *platform_address, + balance: new_info.balance, + nonce: new_info.nonce, + }); + } + } + + recent_info + } } diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 07428534e..7afd08192 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -9,6 +9,8 @@ use crate::backend_task::platform_info::{PlatformInfoTaskRequestType, PlatformIn use crate::backend_task::system_task::SystemTask; use crate::backend_task::wallet::WalletTask; use crate::context::AppContext; +use dash_sdk::dpp::dashcore::Address; +use dash_sdk::dpp::dashcore::address::NetworkChecked; use dash_sdk::dpp::dashcore::bls_sig_utils::BLSSignature; use dash_sdk::dpp::dashcore::network::message_qrinfo::QRInfo; use dash_sdk::dpp::dashcore::BlockHash; @@ -173,8 +175,8 @@ pub enum BackendTaskSuccessResult { /// Platform address balances fetched from Platform PlatformAddressBalances { seed_hash: WalletSeedHash, - /// Map of address string to (balance, nonce) - balances: BTreeMap, + /// Map of address to (balance, nonce) + balances: BTreeMap, (u64, u32)>, }, /// Platform credits transferred between addresses PlatformCreditsTransferred { diff --git a/src/backend_task/wallet/fetch_platform_address_balances.rs b/src/backend_task/wallet/fetch_platform_address_balances.rs index b95c48426..65ead0b35 100644 --- a/src/backend_task/wallet/fetch_platform_address_balances.rs +++ b/src/backend_task/wallet/fetch_platform_address_balances.rs @@ -149,15 +149,16 @@ impl AppContext { ); // Log the found balances from provider - for (addr, balance) in provider.found_balances() { + for (addr, funds) in provider.found_balances() { use dash_sdk::dpp::address_funds::PlatformAddress; let platform_addr_str = PlatformAddress::try_from(addr.clone()) .map(|p| p.to_bech32m_string(self.network)) .unwrap_or_else(|_| addr.to_string()); tracing::info!( - "Sync found address: {} with balance: {}", + "Sync found address: {} with balance: {}, nonce: {}", platform_addr_str, - balance + funds.balance, + funds.nonce ); } @@ -248,7 +249,7 @@ impl AppContext { provider.apply_results_to_wallet(&mut wallet); // Persist addresses and balances to database - for (index, (address, balance)) in provider.found_balances_with_indices() { + for (index, (address, funds)) in provider.found_balances_with_indices() { // Persist the address to wallet_addresses table if not already there let derivation_path = DerivationPath::platform_payment_path( self.network, @@ -269,34 +270,23 @@ impl AppContext { } // Persist balance to platform_address_balances table - let nonce = wallet - .platform_address_info - .get(address) - .map(|info| info.nonce) - .unwrap_or(0); + // Use the nonce from AddressFunds which comes directly from SDK sync if let Err(e) = self.db.set_platform_address_info( &seed_hash, address, - *balance, - nonce, + funds.balance, + funds.nonce, &self.network, ) { tracing::warn!("Failed to persist Platform address info: {}", e); } } - // Return balances for result (nonce preserved from existing info or 0) + // Return balances for result (use nonce from AddressFunds) provider .found_balances() .iter() - .map(|(addr, bal)| { - let nonce = wallet - .platform_address_info - .get(addr) - .map(|info| info.nonce) - .unwrap_or(0); - (addr.to_string(), (*bal, nonce)) - }) + .map(|(addr, funds)| (addr.clone(), (funds.balance, funds.nonce))) .collect() }; @@ -408,7 +398,7 @@ impl AppContext { let current_balance = provider .found_balances() .get(&core_addr) - .copied() + .map(|funds| funds.balance) .unwrap_or(0); let new_balance = match credit_op { @@ -495,7 +485,7 @@ impl AppContext { let current_balance = provider .found_balances() .get(&core_addr) - .copied() + .map(|funds| funds.balance) .unwrap_or(0); let new_balance = match credit_op { diff --git a/src/database/wallet.rs b/src/database/wallet.rs index 5a4f4f4fd..eae4c5b3a 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -583,9 +583,20 @@ impl Database { total_received, ) = row?; if let Some(wallet) = wallets_map.get_mut(&seed_array) { + // Canonicalize Platform addresses to avoid duplicate representations + let canonical_address = Wallet::canonical_address(&address, *network); + // Update the address balance if available. if let Some(balance) = balance { - wallet.address_balances.insert(address.clone(), balance); + wallet + .address_balances + .insert(canonical_address.clone(), balance); + } + // Update total received if available. + if let Some(total_received) = total_received { + wallet + .address_total_received + .insert(canonical_address.clone(), total_received); } // Update total received if available. if let Some(total_received) = total_received { @@ -597,16 +608,16 @@ impl Database { // Add the address to the `known_addresses` map. wallet .known_addresses - .insert(address.clone(), derivation_path.clone()); + .insert(canonical_address.clone(), derivation_path.clone()); tracing::trace!( - address = ?address, + address = ?canonical_address, network = address.network().to_string(), expected_network = network.to_string(), "loaded address from database"); // Add the address to the `watched_addresses` map with AddressInfo. let address_info = AddressInfo { - address: address.clone(), + address: canonical_address.clone(), path_reference, path_type, }; @@ -843,9 +854,18 @@ impl Database { && let Some(wallet) = wallets_map.get_mut(&seed_hash) && let Ok(address) = Address::::from_str(&address_str) { - let address = address.assume_checked(); + let address_checked = address.require_network(*network).map_err(|e| { + tracing::error!(address = %address_str, error = ?e, "Failed to validate Platform address for network"); + rusqlite::Error::FromSqlConversionFailure( + 1, + rusqlite::types::Type::Text, + Box::new(std::fmt::Error), + ) + })?; + let canonical_address = Wallet::canonical_address(&address_checked, *network); + wallet.platform_address_info.insert( - address, + canonical_address, crate::model::wallet::PlatformAddressInfo { balance, nonce, @@ -870,7 +890,8 @@ impl Database { network: &Network, ) -> rusqlite::Result<()> { let network_str = network.to_string(); - let address_str = address.to_string(); + let canonical_address = Wallet::canonical_address(address, *network); + let address_str = canonical_address.to_string(); let updated_at = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -901,7 +922,8 @@ impl Database { ) -> rusqlite::Result> { let conn = self.conn.lock().unwrap(); let network_str = network.to_string(); - let address_str = address.to_string(); + let canonical_address = Wallet::canonical_address(address, *network); + let address_str = canonical_address.to_string(); let mut stmt = conn.prepare( "SELECT balance, nonce FROM platform_address_balances @@ -946,8 +968,15 @@ impl Database { for row in rows { let (address_str, balance, nonce) = row?; if let Ok(address) = Address::::from_str(&address_str) { - let address = address.assume_checked(); - results.push((address, balance, nonce)); + let address_checked = address.require_network(*network).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 1, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?; + let canonical_address = Wallet::canonical_address(&address_checked, *network); + results.push((canonical_address, balance, nonce)); } } diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 2efd99089..9be4def6e 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -12,7 +12,7 @@ use dash_sdk::dpp::key_wallet::bip32::{ }; use dash_sdk::dpp::key_wallet::psbt::serialize::Serialize; use dash_sdk::dpp::prelude::AddressNonce; -use dash_sdk::platform::address_sync::{AddressIndex, AddressKey, AddressProvider}; +use dash_sdk::platform::address_sync::{AddressFunds, AddressIndex, AddressKey, AddressProvider}; use dash_sdk::dpp::dashcore::secp256k1::{Message, Secp256k1}; use dash_sdk::dpp::dashcore::sighash::SighashCache; @@ -464,6 +464,16 @@ impl Drop for WalletSeed { } impl Wallet { + /// Convert a Platform address to a canonical Core address representation for map keys. + /// + /// This ensures we always use the same `dashcore::Address` instance for a given Platform + /// address, avoiding duplicate map entries caused by different internal representations. + pub(crate) fn canonical_address(address: &Address, network: Network) -> Address { + PlatformAddress::try_from(address.clone()) + .map(|pa| pa.to_address_with_network(network)) + .unwrap_or_else(|_| address.clone()) + } + pub fn is_open(&self) -> bool { matches!(self.wallet_seed, WalletSeed::Open(_)) } @@ -1260,13 +1270,15 @@ impl Wallet { path_reference: DerivationPathReference, app_context: &AppContext, ) -> Result<(), String> { + let canonical_address = Wallet::canonical_address(&address, app_context.network); + // Store the address in known_addresses and watched_addresses // Note: We don't import to Core wallet since Platform addresses are not valid there app_context .db .add_address_if_not_exists( &self.seed_hash(), - &address, + &canonical_address, &app_context.network, derivation_path, path_reference, @@ -1276,11 +1288,11 @@ impl Wallet { .map_err(|e| e.to_string())?; self.known_addresses - .insert(address.clone(), derivation_path.clone()); + .insert(canonical_address.clone(), derivation_path.clone()); self.watched_addresses.insert( derivation_path.clone(), AddressInfo { - address: address.clone(), + address: canonical_address.clone(), path_type, path_reference, }, @@ -2092,7 +2104,7 @@ pub struct WalletAddressProvider { /// Highest index found with a non-zero balance highest_found: Option, /// Results: address -> balance for addresses found with balance - found_balances: BTreeMap, + found_balances: BTreeMap, } impl WalletAddressProvider { @@ -2144,7 +2156,7 @@ impl WalletAddressProvider { /// Get the found balances after sync is complete. /// /// Returns a map of Core Address -> balance (in credits). - pub fn found_balances(&self) -> &BTreeMap { + pub fn found_balances(&self) -> &BTreeMap { &self.found_balances } @@ -2154,7 +2166,7 @@ impl WalletAddressProvider { /// The index can be used to reconstruct the derivation path. pub fn found_balances_with_indices( &self, - ) -> impl Iterator { + ) -> impl Iterator { // Build a reverse lookup from address to index let address_to_index: BTreeMap<&Address, AddressIndex> = self .pending @@ -2175,7 +2187,16 @@ impl WalletAddressProvider { /// /// This allows applying balance changes discovered after the initial sync. pub fn update_balance(&mut self, address: &Address, balance: u64) { - self.found_balances.insert(address.clone(), balance); + let canonical_address = Wallet::canonical_address(address, self.network); + + let nonce = self + .found_balances + .get(&canonical_address) + .map(|funds| funds.nonce) + .unwrap_or(0); + + self.found_balances + .insert(canonical_address, AddressFunds { nonce, balance }); } /// Apply the sync results to a wallet, updating Platform address info. @@ -2183,29 +2204,28 @@ impl WalletAddressProvider { /// This updates the wallet's `platform_address_info` with the balances found during sync. /// Also ensures addresses are registered in `known_addresses` and `watched_addresses` /// so they appear in the UI. - /// Note: This does not update nonces - those should be fetched separately if needed. + /// Nonces are taken directly from the SDK sync results. pub fn apply_results_to_wallet(&self, wallet: &mut Wallet) { // Build a reverse lookup from address to index - let address_to_index: BTreeMap<&Address, AddressIndex> = self + let address_to_index: BTreeMap = self .pending .iter() - .map(|(idx, (_, addr))| (addr, *idx)) + .map(|(idx, (_, addr))| (Wallet::canonical_address(addr, self.network), *idx)) .collect(); - for (address, balance) in &self.found_balances { - // Get existing nonce or default to 0 - let nonce = wallet - .platform_address_info - .get(address) - .map(|info| info.nonce) - .unwrap_or(0); + for (address, funds) in &self.found_balances { + let canonical_address = Wallet::canonical_address(address, self.network); // Use sync-specific method that also updates last_synced_balance - wallet.set_platform_address_info_from_sync(address.clone(), *balance, nonce); + wallet.set_platform_address_info_from_sync( + canonical_address.clone(), + funds.balance, + funds.nonce, + ); // Also register in known_addresses and watched_addresses if not already present - if !wallet.known_addresses.contains_key(address) - && let Some(&index) = address_to_index.get(address) + if !wallet.known_addresses.contains_key(&canonical_address) + && let Some(&index) = address_to_index.get(&canonical_address) { let derivation_path = DerivationPath::platform_payment_path( self.network, @@ -2216,12 +2236,12 @@ impl WalletAddressProvider { wallet .known_addresses - .insert(address.clone(), derivation_path.clone()); + .insert(canonical_address.clone(), derivation_path.clone()); wallet.watched_addresses.insert( derivation_path, AddressInfo { - address: address.clone(), + address: canonical_address.clone(), path_type: DerivationPathType::CLEAR_FUNDS, path_reference: DerivationPathReference::PlatformPayment, }, @@ -2296,7 +2316,7 @@ impl AddressProvider for WalletAddressProvider { .collect() } - fn on_address_found(&mut self, index: AddressIndex, _key: &[u8], balance: u64) { + fn on_address_found(&mut self, index: AddressIndex, _key: &[u8], funds: AddressFunds) { self.resolved.insert(index); // Log what the SDK is returning @@ -2306,29 +2326,30 @@ impl AddressProvider for WalletAddressProvider { .map(|p| p.to_bech32m_string(self.network)) .unwrap_or_else(|_| "conversion failed".to_string()); tracing::info!( - "on_address_found: index={}, core_address={}, platform_address={}, balance={}", + "on_address_found: index={}, core_address={}, platform_address={}, balance={}, nonce={}", index, core_address, platform_addr_str, - balance + funds.balance, + funds.nonce ); } else { tracing::warn!( "on_address_found: index={} not in pending! balance={}", index, - balance + funds.balance ); } - if balance > 0 { + if let Some((_, core_address)) = self.pending.get(&index) { + let canonical_address = Wallet::canonical_address(core_address, self.network); + self.found_balances.insert(canonical_address, funds); + } + + if funds.balance > 0 { // Update highest found self.highest_found = Some(self.highest_found.map(|h| h.max(index)).unwrap_or(index)); - // Store the balance result - if let Some((_, core_address)) = self.pending.get(&index) { - self.found_balances.insert(core_address.clone(), balance); - } - // Extend the address range based on gap limit if let Err(e) = self.extend_for_gap_limit(index) { tracing::warn!("Failed to extend addresses for gap limit: {}", e); diff --git a/src/sdk_wrapper.rs b/src/sdk_wrapper.rs index 58c62c426..36be904c9 100644 --- a/src/sdk_wrapper.rs +++ b/src/sdk_wrapper.rs @@ -18,6 +18,7 @@ pub fn initialize_sdk( timeout: Some(Duration::from_secs(10)), retries: Some(6), ban_failed_address: Some(true), + max_decoding_message_size: None, }; let platform_version = default_platform_version(&network); diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 114926dce..719b6c40f 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -3846,16 +3846,8 @@ impl ScreenLike for WalletsBalancesScreen { && wallet.seed_hash() == seed_hash { // Update balances in the wallet - for (addr_str, (balance, nonce)) in balances { - // Find the address that matches the string - if let Some((addr, _)) = wallet - .platform_address_info - .iter() - .find(|(a, _)| a.to_string() == addr_str) - { - let addr = addr.clone(); - wallet.set_platform_address_info(addr, balance, nonce); - } + for (addr, (balance, nonce)) in balances { + wallet.set_platform_address_info(addr, balance, nonce); } } self.message = Some(( From b641a146b02b639b229002d906406666e8b99020 Mon Sep 17 00:00:00 2001 From: PastaPastaPasta <6443210+PastaPastaPasta@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:17:30 -0600 Subject: [PATCH 046/106] docs: add CLAUDE.md for Claude Code guidance (#484) Adds documentation to help Claude Code (claude.ai/code) work effectively with this codebase, including build commands, testing instructions, architecture overview, and UI component patterns. Co-authored-by: Claude Opus 4.5 --- CLAUDE.md | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..a9288d619 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build Commands + +```bash +cargo build # Debug build +cargo build --release # Release build +cargo run # Run application +cargo fmt --all # Format code +cargo clippy --all-features --all-targets -- -D warnings # Lint (warnings as errors) +``` + +## Testing + +```bash +cargo test --all-features --workspace # All tests +cargo test --doc --all-features --workspace # Doc tests only +cargo test --all-features # Single test +cargo test --test kittest --all-features # UI integration tests (egui_kittest) +cargo test --test e2e --all-features # End-to-end tests +``` + +Test locations: +- Unit tests: inline in source files (`#[test]`) +- UI integration: `tests/kittest/` +- E2E: `tests/e2e/` + +## 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. + +### Core Module Structure + +- **app.rs** - Main application state, task result handling, screen management +- **ui/** - Screens (network_chooser, dpns, identities, wallets, contracts_documents, tokens, dashpay, tools) and reusable components +- **backend_task/** - Async business logic (contract, document, platform_info, identity, wallet operations) +- **model/** - Data types (amounts, fees, settings, wallet/identity models) +- **database/** - SQLite persistence (rusqlite) for wallets, identities, settings, proof logs +- **context.rs** - Application context (network config, SDK client, database connection) +- **spv/** - Simplified Payment Verification for light wallet support +- **components/core_zmq_listener** - Real-time Dash Core event listening + +### Key Dependencies + +- `dash-sdk` - Dash blockchain SDK (platform protocol, core interactions) +- `egui/eframe` - Immediate mode GUI framework +- `tokio` - Async runtime (12 worker threads) +- `rusqlite` - SQLite with bundled library + +### Configuration + +Environment config via `.env` in app directory: +- macOS: `~/Library/Application Support/Dash-Evo-Tool/.env` +- Linux: `~/.config/dash-evo-tool/.env` +- Windows: `C:\Users\\AppData\Roaming\Dash-Evo-Tool\config\.env` + +See `.env.example` for network configuration options. + +## UI Component Pattern + +Components follow a lazy initialization pattern (see `doc/COMPONENT_DESIGN_PATTERN.md`): + +```rust +struct MyScreen { + amount: Option, // Domain data + amount_widget: Option, // UI component (lazy) +} + +// In show(): +let widget = self.amount_widget.get_or_insert_with(|| AmountInput::new(type)); +let response = widget.show(ui); +response.inner.update(&mut self.amount); +``` + +**Requirements:** +- Private fields only +- Builder methods for configuration (`with_label()`, etc.) +- Response struct with `ComponentResponse` trait +- Self-contained validation and error handling +- Support both light and dark mode via `ComponentStyles` + +**Anti-patterns:** public mutable fields, eager initialization, not clearing invalid data + +## Platform Targets + +Linux (x86_64/aarch64), Windows (x86_64), macOS (x86_64/aarch64 with code signing) + +Requires protoc v25.2+ for protocol buffer compilation. From 28b4e078872dc93ec2b091835c6061adc0ad7e9a Mon Sep 17 00:00:00 2001 From: PastaPastaPasta <6443210+PastaPastaPasta@users.noreply.github.com> Date: Wed, 21 Jan 2026 00:15:26 -0600 Subject: [PATCH 047/106] build: update dash-sdk to use rust-dashcore v0.42-dev (#483) * build: update dash-sdk to use rust-dashcore v0.41.0 Updates dash-sdk dependency to rev 7b2669c0b250504a4cded9e2b9e78551583e6744 which includes rust-dashcore v0.41.0. Breaking changes addressed: - WalletBalance: confirmed field renamed to spendable(), fields now methods - WalletManager::new() now requires Network argument - import_wallet_from_extended_priv_key() no longer takes network argument - current_height() no longer takes network argument - RequestSettings: removed max_decoding_message_size field - sync_to_tip() removed - initial sync now handled by monitor_network() Co-Authored-By: Claude Opus 4.5 * style: apply cargo fmt Co-Authored-By: Claude Opus 4.5 * clippy --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: pauldelucia --- Cargo.lock | 1136 +++++++++++++++++----------------- Cargo.toml | 2 +- src/backend_task/core/mod.rs | 2 +- src/context.rs | 14 +- src/sdk_wrapper.rs | 1 - src/spv/manager.rs | 88 +-- 6 files changed, 591 insertions(+), 652 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5893b47cf..a13239e4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,7 +125,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ "crypto-common", - "generic-array 0.14.9", + "generic-array 0.14.7", ] [[package]] @@ -250,22 +250,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -305,9 +305,12 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +dependencies = [ + "rustversion", +] [[package]] name = "argon2" @@ -438,9 +441,9 @@ dependencies = [ [[package]] name = "ashpd" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" dependencies = [ "async-fs", "async-net", @@ -546,16 +549,16 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.2", + "rustix 1.1.3", "slab", "windows-sys 0.61.2", ] [[package]] name = "async-lock" -version = "3.4.1" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ "event-listener 5.4.1", "event-listener-strategy", @@ -588,7 +591,7 @@ dependencies = [ "cfg-if", "event-listener 5.4.1", "futures-lite", - "rustix 1.1.2", + "rustix 1.1.3", ] [[package]] @@ -599,7 +602,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -614,7 +617,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.2", + "rustix 1.1.3", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -660,7 +663,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -760,8 +763,8 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" dependencies = [ - "bitcoin-internals 0.3.0", - "bitcoin_hashes 0.14.0", + "bitcoin-internals", + "bitcoin_hashes", ] [[package]] @@ -787,9 +790,9 @@ dependencies = [ [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bech32" @@ -850,7 +853,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.110", + "syn 2.0.114", "which 4.4.2", ] @@ -869,7 +872,7 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -884,11 +887,11 @@ dependencies = [ [[package]] name = "bip39" -version = "2.2.0" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d193de1f7487df1914d3a568b772458861d33f9c54249612cc2893d6915054" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes 0.13.0", + "bitcoin_hashes", "rand 0.8.5", "rand_core 0.6.4", "serde", @@ -926,12 +929,6 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" -[[package]] -name = "bitcoin-internals" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" - [[package]] name = "bitcoin-internals" version = "0.3.0" @@ -940,28 +937,18 @@ checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" [[package]] name = "bitcoin-io" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" [[package]] name = "bitcoin_hashes" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" -dependencies = [ - "bitcoin-internals 0.2.0", - "hex-conservative 0.1.2", -] - -[[package]] -name = "bitcoin_hashes" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ "bitcoin-io", - "hex-conservative 0.2.1", + "hex-conservative", ] [[package]] @@ -1002,15 +989,16 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.2" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", + "cpufeatures", ] [[package]] @@ -1025,7 +1013,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array 0.14.9", + "generic-array 0.14.7", ] [[package]] @@ -1034,7 +1022,7 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" dependencies = [ - "generic-array 0.14.9", + "generic-array 0.14.7", ] [[package]] @@ -1087,7 +1075,7 @@ dependencies = [ "sha2", "sha3", "subtle", - "thiserror 2.0.17", + "thiserror 2.0.18", "uint-zigzag", "vsss-rs", "zeroize", @@ -1134,9 +1122,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytemuck" @@ -1155,7 +1143,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -1172,9 +1160,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" dependencies = [ "serde", ] @@ -1211,7 +1199,7 @@ checksum = "cb9f6e1368bd4621d2c86baa7e37de77a938adf5221e5dd3d6133340101b309e" dependencies = [ "bitflags 2.10.0", "polling", - "rustix 1.1.2", + "rustix 1.1.3", "slab", "tracing", ] @@ -1235,7 +1223,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop 0.14.3", - "rustix 1.1.2", + "rustix 1.1.3", "wayland-backend", "wayland-client", ] @@ -1251,9 +1239,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.45" +version = "1.2.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" dependencies = [ "find-msvc-tools", "jobserver", @@ -1309,9 +1297,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -1402,9 +1390,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.51" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", "clap_derive", @@ -1412,9 +1400,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.51" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstream", "anstyle", @@ -1431,14 +1419,14 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "clipboard-win" @@ -1474,11 +1462,11 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "colored" -version = "3.0.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1508,9 +1496,18 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "core-foundation" @@ -1672,7 +1669,7 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array 0.14.9", + "generic-array 0.14.7", "rand_core 0.6.4", "serdect", "subtle", @@ -1681,11 +1678,11 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array 0.14.9", + "generic-array 0.14.7", "rand_core 0.6.4", "typenum", ] @@ -1729,17 +1726,17 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] name = "dapi-grpc" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "dash-platform-macros", "futures-core", - "getrandom 0.2.16", + "getrandom 0.2.17", "platform-version", "prost", "serde", @@ -1786,7 +1783,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -1797,13 +1794,13 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] name = "dash-context-provider" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "dpp", "drive", @@ -1829,7 +1826,7 @@ dependencies = [ "crossbeam-channel", "dark-light", "dash-sdk", - "derive_more 2.0.1", + "derive_more 2.1.1", "directories", "dotenvy", "ed25519-dalek", @@ -1864,7 +1861,7 @@ dependencies = [ "serde_yaml", "sha2", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -1880,8 +1877,8 @@ dependencies = [ [[package]] name = "dash-network" -version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" +version = "0.41.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" dependencies = [ "bincode 2.0.0-rc.3", "bincode_derive", @@ -1892,17 +1889,17 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "heck", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] name = "dash-sdk" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "arc-swap", "async-trait", @@ -1927,7 +1924,7 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -1936,8 +1933,8 @@ dependencies = [ [[package]] name = "dash-spv" -version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" +version = "0.41.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" dependencies = [ "anyhow", "async-trait", @@ -1949,7 +1946,7 @@ dependencies = [ "dashcore_hashes", "hex", "hickory-resolver", - "indexmap 2.12.0", + "indexmap 2.13.0", "key-wallet", "key-wallet-manager", "log", @@ -1967,8 +1964,8 @@ dependencies = [ [[package]] name = "dashcore" -version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" +version = "0.41.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" dependencies = [ "anyhow", "base64-compat", @@ -1988,18 +1985,18 @@ dependencies = [ "rustversion", "secp256k1", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "dashcore-private" -version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" +version = "0.41.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" [[package]] name = "dashcore-rpc" -version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" +version = "0.41.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" dependencies = [ "dashcore-rpc-json", "hex", @@ -2011,8 +2008,8 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" -version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" +version = "0.41.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" dependencies = [ "bincode 2.0.0-rc.3", "dashcore", @@ -2026,8 +2023,8 @@ dependencies = [ [[package]] name = "dashcore_hashes" -version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" +version = "0.41.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" dependencies = [ "bincode 2.0.0-rc.3", "dashcore-private", @@ -2052,18 +2049,18 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "data-contracts" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2073,7 +2070,7 @@ dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "token-history-contract", "wallet-utils-contract", "withdrawals-contract", @@ -2081,9 +2078,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "data-url" @@ -2130,7 +2127,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -2151,7 +2148,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -2161,7 +2158,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -2175,11 +2172,11 @@ dependencies = [ [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "derive_more-impl 2.0.1", + "derive_more-impl 2.1.1", ] [[package]] @@ -2190,19 +2187,21 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", "unicode-xid", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", - "syn 2.0.110", + "rustc_version", + "syn 2.0.114", ] [[package]] @@ -2283,7 +2282,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -2325,18 +2324,18 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "dpp" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "anyhow", "async-trait", @@ -2355,9 +2354,9 @@ dependencies = [ "data-contracts", "derive_more 1.0.0", "env_logger", - "getrandom 0.2.16", + "getrandom 0.2.17", "hex", - "indexmap 2.12.0", + "indexmap 2.13.0", "integer-encoding", "itertools 0.13.0", "key-wallet", @@ -2378,14 +2377,14 @@ dependencies = [ "serde_repr", "sha2", "strum 0.26.3", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", ] [[package]] name = "drive" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "bincode 2.0.0-rc.3", "byteorder", @@ -2397,20 +2396,20 @@ dependencies = [ "grovedb-path 4.0.0", "grovedb-version 4.0.0", "hex", - "indexmap 2.12.0", + "indexmap 2.13.0", "integer-encoding", "nohash-hasher", "platform-version", "serde", "sqlparser", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", ] [[package]] name = "drive-proof-verifier" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "bincode 2.0.0-rc.3", "dapi-grpc", @@ -2419,12 +2418,12 @@ dependencies = [ "dpp", "drive", "hex", - "indexmap 2.12.0", + "indexmap 2.13.0", "platform-serialization", "platform-serialization-derive", "serde", "tenderdash-abci", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", ] @@ -2670,7 +2669,7 @@ dependencies = [ "crypto-bigint", "digest", "ff", - "generic-array 0.14.9", + "generic-array 0.14.7", "group", "hkdf", "pkcs8", @@ -2716,9 +2715,9 @@ dependencies = [ [[package]] name = "endi" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-as-inner" @@ -2729,7 +2728,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -2749,7 +2748,7 @@ checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -2769,7 +2768,7 @@ checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -2790,7 +2789,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -2801,7 +2800,7 @@ checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -2891,9 +2890,9 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] name = "euclid" -version = "0.22.11" +version = "0.22.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" dependencies = [ "num-traits", ] @@ -2971,7 +2970,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -2986,12 +2985,12 @@ dependencies = [ [[package]] name = "feature-flags-contract" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3013,9 +3012,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" [[package]] name = "fixedbitset" @@ -3025,13 +3024,13 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", - "libz-rs-sys", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -3108,7 +3107,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -3219,7 +3218,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -3254,9 +3253,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -3280,15 +3279,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.2", + "rustix 1.1.3", "windows-link 0.2.1", ] [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -3517,14 +3516,14 @@ dependencies = [ "grovedb-visualize 3.1.0", "hex", "hex-literal 0.4.1", - "indexmap 2.12.0", + "indexmap 2.13.0", "integer-encoding", "intmap", "itertools 0.14.0", "reqwest", "sha2", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3542,11 +3541,11 @@ dependencies = [ "grovedb-version 4.0.0", "hex", "hex-literal 1.1.0", - "indexmap 2.12.0", + "indexmap 2.13.0", "integer-encoding", "reqwest", "sha2", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3557,7 +3556,7 @@ checksum = "e74fafe53bf5ae27128799856e557ef5cb2d7109f1f7bc7f4440bbd0f97c7072" dependencies = [ "integer-encoding", "intmap", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3567,7 +3566,7 @@ source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655ee dependencies = [ "integer-encoding", "intmap", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3581,7 +3580,7 @@ dependencies = [ "grovedb-version 4.0.0", "hex", "integer-encoding", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3593,7 +3592,7 @@ dependencies = [ "hex", "integer-encoding", "intmap", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3614,11 +3613,11 @@ dependencies = [ "grovedb-version 3.1.0", "grovedb-visualize 3.1.0", "hex", - "indexmap 2.12.0", + "indexmap 2.13.0", "integer-encoding", "num_cpus", "rand 0.8.5", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3637,9 +3636,9 @@ dependencies = [ "grovedb-version 4.0.0", "grovedb-visualize 4.0.0", "hex", - "indexmap 2.12.0", + "indexmap 2.13.0", "integer-encoding", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3676,7 +3675,7 @@ dependencies = [ "rocksdb", "strum 0.27.2", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3685,7 +3684,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdc855662f05f41b10dd022226cb78e345a33f35c390e25338d21dedd45966ae" dependencies = [ - "thiserror 2.0.17", + "thiserror 2.0.18", "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -3694,7 +3693,7 @@ name = "grovedb-version" version = "4.0.0" source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" dependencies = [ - "thiserror 2.0.17", + "thiserror 2.0.18", "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -3756,9 +3755,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -3766,7 +3765,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.12.0", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -3819,9 +3818,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "foldhash 0.2.0", ] @@ -3868,15 +3867,9 @@ dependencies = [ [[package]] name = "hex-conservative" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" - -[[package]] -name = "hex-conservative" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5313b072ce3c597065a808dbf612c4c8e8590bdbf8b579508bf7a762c5eae6cd" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" dependencies = [ "arrayvec", ] @@ -3923,7 +3916,7 @@ dependencies = [ "once_cell", "rand 0.9.2", "ring", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tokio", "tracing", @@ -3946,7 +3939,7 @@ dependencies = [ "rand 0.9.2", "resolv-conf", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -3980,12 +3973,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -4042,9 +4034,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", @@ -4110,9 +4102,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ "base64 0.22.1", "bytes", @@ -4206,9 +4198,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -4220,9 +4212,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -4268,9 +4260,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.8" +version = "0.25.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", "byteorder-lite", @@ -4278,8 +4270,8 @@ dependencies = [ "num-traits", "png 0.18.0", "tiff", - "zune-core", - "zune-jpeg", + "zune-core 0.5.1", + "zune-jpeg 0.5.11", ] [[package]] @@ -4311,12 +4303,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -4328,7 +4320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ "block-padding", - "generic-array 0.14.9", + "generic-array 0.14.7", ] [[package]] @@ -4366,9 +4358,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", @@ -4409,15 +4401,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" dependencies = [ "jiff-static", "log", @@ -4428,13 +4420,13 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -4471,9 +4463,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -4511,8 +4503,8 @@ dependencies = [ [[package]] name = "key-wallet" -version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" +version = "0.41.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" dependencies = [ "async-trait", "base58ck", @@ -4524,7 +4516,7 @@ dependencies = [ "dashcore", "dashcore-private", "dashcore_hashes", - "getrandom 0.2.16", + "getrandom 0.2.17", "hex", "hkdf", "rand 0.8.5", @@ -4538,8 +4530,8 @@ dependencies = [ [[package]] name = "key-wallet-manager" -version = "0.40.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=e7792c431c55c0d28efb0344b3a1948f576be5ce#e7792c431c55c0d28efb0344b3a1948f576be5ce" +version = "0.41.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" dependencies = [ "async-trait", "bincode 2.0.0-rc.3", @@ -4547,18 +4539,19 @@ dependencies = [ "dashcore_hashes", "key-wallet", "secp256k1", + "tokio", "zeroize", ] [[package]] name = "keyword-search-contract" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -4629,9 +4622,9 @@ checksum = "744a4c881f502e98c2241d2e5f50040ac73b30194d64452bb6260393b53f0dc9" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libloading" @@ -4651,13 +4644,13 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.10.0", "libc", - "redox_syscall 0.5.18", + "redox_syscall 0.7.0", ] [[package]] @@ -4686,20 +4679,11 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libz-rs-sys" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" -dependencies = [ - "zlib-rs", -] - [[package]] name = "libz-sys" -version = "1.1.22" +version = "1.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" dependencies = [ "cc", "pkg-config", @@ -4741,9 +4725,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" dependencies = [ "value-bag", ] @@ -4779,12 +4763,12 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -4883,9 +4867,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", @@ -4894,9 +4878,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.11" +version = "0.12.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" +checksum = "a3dec6bd31b08944e08b58fd99373893a6c17054d6f3ea5006cc894f4f4eee2a" dependencies = [ "crossbeam-channel", "crossbeam-epoch", @@ -4904,7 +4888,6 @@ dependencies = [ "equivalent", "parking_lot", "portable-atomic", - "rustc_version", "smallvec", "tagptr", "uuid", @@ -4912,9 +4895,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.9" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" dependencies = [ "num-traits", "pxfm", @@ -4960,22 +4943,22 @@ dependencies = [ "half", "hashbrown 0.15.5", "hexf-parse", - "indexmap 2.12.0", + "indexmap 2.13.0", "log", "num-traits", "once_cell", "rustc-hash 1.1.0", "spirv", "strum 0.26.3", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-ident", ] [[package]] name = "native-dialog" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "454a816a8fed70bb5ba4ae90901073173dd5142f5df5ee503acde1ebcfaa4c4b" +checksum = "89853bb05334e192e6646290ea94ca31bcb80443f25ad40ebf478b6dafb08d6c" dependencies = [ "ascii", "block2 0.6.2", @@ -4988,7 +4971,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation 0.3.2", "raw-window-handle", - "thiserror 2.0.17", + "thiserror 2.0.18", "versions", "wfd", "which 7.0.3", @@ -5004,7 +4987,7 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "schannel", "security-framework 2.11.1", @@ -5061,7 +5044,6 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "memoffset", ] [[package]] @@ -5149,7 +5131,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -5244,7 +5226,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -5573,7 +5555,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -5582,6 +5564,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" + [[package]] name = "openssl-sys" version = "0.9.111" @@ -5602,10 +5590,11 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orbclient" -version = "0.3.49" +version = "0.3.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "247ad146e19b9437f8604c21f8652423595cf710ad108af40e77d3ae6e96b827" +checksum = "52ad2c6bae700b7aa5d1cc30c59bdd3a1c180b09dbaea51e2ae2b8e1cf211fdd" dependencies = [ + "libc", "libredox", ] @@ -5715,12 +5704,13 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "petgraph" -version = "0.7.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", - "indexmap 2.12.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", ] [[package]] @@ -5763,7 +5753,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", "unicase", ] @@ -5800,7 +5790,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -5845,7 +5835,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "platform-serialization" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "bincode 2.0.0-rc.3", "platform-version", @@ -5854,54 +5844,54 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", "virtue 0.0.17", ] [[package]] name = "platform-value" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "base64 0.22.1", "bincode 2.0.0-rc.3", "bs58", "ciborium", "hex", - "indexmap 2.12.0", + "indexmap 2.13.0", "platform-serialization", "platform-version", "rand 0.8.5", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "treediff", ] [[package]] name = "platform-version" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "bincode 2.0.0-rc.3", "grovedb-version 4.0.0", "once_cell", - "thiserror 2.0.17", + "thiserror 2.0.18", "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", ] [[package]] name = "platform-versioning" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -5940,7 +5930,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.2", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -5964,9 +5954,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" [[package]] name = "portable-atomic-util" @@ -6014,7 +6004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -6033,14 +6023,14 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.23.7", + "toml_edit 0.23.10+spec-1.0.0", ] [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" dependencies = [ "unicode-ident", ] @@ -6053,9 +6043,9 @@ checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" [[package]] name = "prost" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", "prost-derive", @@ -6063,15 +6053,14 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", "itertools 0.14.0", "log", "multimap", - "once_cell", "petgraph", "prettyplease", "prost", @@ -6079,28 +6068,28 @@ dependencies = [ "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn 2.0.110", + "syn 2.0.114", "tempfile", ] [[package]] name = "prost-derive" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] name = "prost-types" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ "prost", ] @@ -6118,18 +6107,18 @@ dependencies = [ [[package]] name = "pulldown-cmark-to-cmark" -version = "21.1.0" +version = "22.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8246feae3db61428fd0bb94285c690b460e4517d83152377543ca802357785f1" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" dependencies = [ "pulldown-cmark", ] [[package]] name = "pxfm" -version = "0.1.25" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" dependencies = [ "num-traits", ] @@ -6151,28 +6140,19 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.36.2" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", "serde", ] -[[package]] -name = "quick-xml" -version = "0.37.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" -dependencies = [ - "memchr", -] - [[package]] name = "quote" -version = "1.0.42" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -6207,7 +6187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -6227,7 +6207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -6236,14 +6216,14 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -6316,15 +6296,24 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "redox_users" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -6364,9 +6353,9 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", @@ -6408,9 +6397,9 @@ dependencies = [ [[package]] name = "resolv-conf" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "resvg" @@ -6426,7 +6415,7 @@ dependencies = [ "svgtypes", "tiny-skia", "usvg", - "zune-jpeg", + "zune-jpeg 0.4.21", ] [[package]] @@ -6435,7 +6424,7 @@ version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" dependencies = [ - "ashpd 0.11.0", + "ashpd 0.11.1", "block2 0.6.2", "dispatch2", "js-sys", @@ -6470,7 +6459,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -6508,13 +6497,13 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rs-dapi-client" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "backon", "chrono", "dapi-grpc", "futures", - "getrandom 0.2.16", + "getrandom 0.2.17", "gloo-timers", "hex", "http", @@ -6524,7 +6513,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tonic-web-wasm-client", "tracing", @@ -6558,9 +6547,9 @@ dependencies = [ [[package]] name = "rust-embed" -version = "8.9.0" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "947d7f3fad52b283d261c4c99a084937e2fe492248cb9a68a8435a861b8798ca" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -6569,22 +6558,22 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.9.0" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fa2c8c9e8711e10f9c4fd2d64317ef13feaab820a4c51541f1a8c8e2e851ab2" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.110", + "syn 2.0.114", "walkdir", ] [[package]] name = "rust-embed-utils" -version = "8.9.0" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b161f275cb337fe0a44d924a5f4df0ed69c2c39519858f931ce61c779d3475" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ "sha2", "walkdir", @@ -6626,9 +6615,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags 2.10.0", "errno", @@ -6639,9 +6628,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "log", "once_cell", @@ -6654,11 +6643,11 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.0", "rustls-pki-types", "schannel", "security-framework 3.5.1", @@ -6675,18 +6664,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "ring", "rustls-pki-types", @@ -6719,9 +6708,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "same-file" @@ -6774,7 +6763,7 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", "der", - "generic-array 0.14.9", + "generic-array 0.14.7", "pkcs8", "subtle", "zeroize", @@ -6786,7 +6775,7 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ - "bitcoin_hashes 0.14.0", + "bitcoin_hashes", "rand 0.8.5", "secp256k1-sys", "serde", @@ -6889,21 +6878,21 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.13.0", "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -6914,7 +6903,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -6963,7 +6952,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -6972,7 +6961,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.13.0", "itoa", "ryu", "serde", @@ -7027,10 +7016,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -7045,9 +7035,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simplecss" @@ -7072,9 +7062,9 @@ checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "slotmap" -version = "1.0.7" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" dependencies = [ "version_check", ] @@ -7123,8 +7113,8 @@ dependencies = [ "libc", "log", "memmap2", - "rustix 1.1.2", - "thiserror 2.0.17", + "rustix 1.1.3", + "thiserror 2.0.18", "wayland-backend", "wayland-client", "wayland-csd-frame", @@ -7229,7 +7219,7 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "227c4f8561598188d0df96dbe749824576174bba278b5b6bb2eacff1066067d0" dependencies = [ - "hashbrown 0.16.0", + "hashbrown 0.16.1", "rustversion", "spin", ] @@ -7277,7 +7267,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -7289,7 +7279,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -7330,9 +7320,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.110" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -7356,7 +7346,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -7413,14 +7403,14 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.1.2", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -7434,7 +7424,7 @@ dependencies = [ "lhash", "semver", "tenderdash-proto", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "url", ] @@ -7446,14 +7436,14 @@ source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.0-dev.2#3f6 dependencies = [ "bytes", "chrono", - "derive_more 2.0.1", + "derive_more 2.1.1", "num-derive", "num-traits", "prost", "serde", "subtle-encoding", "tenderdash-proto-compiler", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] @@ -7491,11 +7481,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -7506,18 +7496,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -7549,35 +7539,35 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg", + "zune-jpeg 0.4.21", ] [[package]] name = "time" -version = "0.3.44" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" dependencies = [ "num-conv", "time-core", @@ -7637,19 +7627,19 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", @@ -7670,7 +7660,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -7695,9 +7685,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -7706,9 +7696,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -7750,9 +7740,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] @@ -7763,7 +7753,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.13.0", "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -7774,32 +7764,32 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.13.0", "serde", "serde_spanned", "toml_datetime 0.6.11", - "winnow 0.7.13", + "winnow 0.7.14", ] [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ - "indexmap 2.12.0", - "toml_datetime 0.7.3", + "indexmap 2.13.0", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", - "winnow 0.7.13", + "winnow 0.7.14", ] [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ - "winnow 0.7.13", + "winnow 0.7.14", ] [[package]] @@ -7842,7 +7832,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -7867,7 +7857,7 @@ dependencies = [ "prost-build", "prost-types", "quote", - "syn 2.0.110", + "syn 2.0.114", "tempfile", "tonic-build", ] @@ -7888,7 +7878,7 @@ dependencies = [ "httparse", "js-sys", "pin-project", - "thiserror 2.0.17", + "thiserror 2.0.18", "tonic", "tower-service", "wasm-bindgen", @@ -7899,13 +7889,13 @@ dependencies = [ [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.12.0", + "indexmap 2.13.0", "pin-project-lite", "slab", "sync_wrapper", @@ -7918,9 +7908,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", @@ -7948,9 +7938,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -7965,27 +7955,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" dependencies = [ "crossbeam-channel", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tracing-subscriber", ] [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -8004,9 +7994,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -8084,9 +8074,9 @@ dependencies = [ [[package]] name = "unicase" -version = "2.8.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-bidi" @@ -8198,9 +8188,9 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b4531c118335662134346048ddb0e54cc86bd7e81866757873055f0e38f5d2" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" dependencies = [ "base64 0.22.1", "http", @@ -8210,14 +8200,15 @@ dependencies = [ [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -8273,13 +8264,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom 0.3.4", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -8291,9 +8282,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-bag" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" [[package]] name = "vcpkg" @@ -8377,12 +8368,12 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "platform-value", "platform-version", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -8402,18 +8393,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -8424,11 +8415,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -8437,9 +8429,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8447,22 +8439,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -8482,13 +8474,13 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.2", + "rustix 1.1.3", "scoped-tls", "smallvec", "wayland-sys", @@ -8496,12 +8488,12 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.11" +version = "0.31.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" dependencies = [ "bitflags 2.10.0", - "rustix 1.1.2", + "rustix 1.1.3", "wayland-backend", "wayland-scanner", ] @@ -8519,20 +8511,20 @@ dependencies = [ [[package]] name = "wayland-cursor" -version = "0.31.11" +version = "0.31.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "447ccc440a881271b19e9989f75726d60faa09b95b0200a9b7eb5cc47c3eeb29" +checksum = "5864c4b5b6064b06b1e8b74ead4a98a6c45a285fe7a0e784d24735f011fdb078" dependencies = [ - "rustix 1.1.2", + "rustix 1.1.3", "wayland-client", "xcursor", ] [[package]] name = "wayland-protocols" -version = "0.32.9" +version = "0.32.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" dependencies = [ "bitflags 2.10.0", "wayland-backend", @@ -8555,9 +8547,9 @@ dependencies = [ [[package]] name = "wayland-protocols-misc" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dfe33d551eb8bffd03ff067a8b44bb963919157841a99957151299a6307d19c" +checksum = "791c58fdeec5406aa37169dd815327d1e47f334219b523444bc26d70ceb4c34e" dependencies = [ "bitflags 2.10.0", "wayland-backend", @@ -8568,9 +8560,9 @@ dependencies = [ [[package]] name = "wayland-protocols-plasma" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07a14257c077ab3279987c4f8bb987851bf57081b93710381daea94f2c2c032" +checksum = "aa98634619300a535a9a97f338aed9a5ff1e01a461943e8346ff4ae26007306b" dependencies = [ "bitflags 2.10.0", "wayland-backend", @@ -8581,9 +8573,9 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" +checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" dependencies = [ "bitflags 2.10.0", "wayland-backend", @@ -8594,20 +8586,20 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.7" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" dependencies = [ "proc-macro2", - "quick-xml 0.37.5", + "quick-xml", "quote", ] [[package]] name = "wayland-sys" -version = "0.31.7" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" dependencies = [ "dlib", "log", @@ -8617,9 +8609,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -8653,18 +8645,18 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" dependencies = [ "rustls-pki-types", ] [[package]] name = "weezl" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009936b22a61d342859b5f0ea64681cbb35a358ab548e2a44a8cf0dac2d980b8" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "wfd" @@ -8717,7 +8709,7 @@ dependencies = [ "cfg_aliases", "document-features", "hashbrown 0.15.5", - "indexmap 2.12.0", + "indexmap 2.13.0", "log", "naga", "once_cell", @@ -8727,7 +8719,7 @@ dependencies = [ "raw-window-handle", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", "wgpu-core-deps-windows-linux-android", @@ -8801,7 +8793,7 @@ dependencies = [ "raw-window-handle", "renderdoc-sys", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "wasm-bindgen", "web-sys", "wgpu-types", @@ -8819,7 +8811,7 @@ dependencies = [ "bytemuck", "js-sys", "log", - "thiserror 2.0.17", + "thiserror 2.0.18", "web-sys", ] @@ -8843,7 +8835,7 @@ checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" dependencies = [ "either", "env_home", - "rustix 1.1.2", + "rustix 1.1.3", "winsafe", ] @@ -8854,7 +8846,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" dependencies = [ "env_home", - "rustix 1.1.2", + "rustix 1.1.3", "winsafe", ] @@ -8985,7 +8977,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -8996,7 +8988,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -9007,7 +8999,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -9018,7 +9010,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -9045,13 +9037,13 @@ dependencies = [ [[package]] name = "windows-registry" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -9478,9 +9470,9 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] @@ -9572,7 +9564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d31a19dae58475d019850e25b0170e94b16d382fbf6afee9c0e80fdc935e73e" dependencies = [ "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -9625,14 +9617,14 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "withdrawals-contract" version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17#a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17" +source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" dependencies = [ "num_enum 0.5.11", "platform-value", @@ -9640,7 +9632,7 @@ dependencies = [ "serde", "serde_json", "serde_repr", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -9680,7 +9672,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.2", + "rustix 1.1.3", "x11rb-protocol", ] @@ -9746,15 +9738,15 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", "synstructure", ] [[package]] name = "zbus" -version = "5.12.0" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" dependencies = [ "async-broadcast", "async-executor", @@ -9770,15 +9762,16 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "libc", "ordered-stream", + "rustix 1.1.3", "serde", "serde_repr", "tracing", "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.13", + "winnow 0.7.14", "zbus_macros", "zbus_names", "zvariant", @@ -9786,9 +9779,9 @@ dependencies = [ [[package]] name = "zbus-lockstep" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29e96e38ded30eeab90b6ba88cb888d70aef4e7489b6cd212c5e5b5ec38045b6" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" dependencies = [ "zbus_xml", "zvariant", @@ -9796,13 +9789,13 @@ dependencies = [ [[package]] name = "zbus-lockstep-macros" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6821851fa840b708b4cbbaf6241868cabc85a2dc22f426361b0292bfc0b836" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", "zbus-lockstep", "zbus_xml", "zvariant", @@ -9810,14 +9803,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.12.0" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", "zbus_names", "zvariant", "zvariant_utils", @@ -9825,47 +9818,45 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "static_assertions", - "winnow 0.7.13", + "winnow 0.7.14", "zvariant", ] [[package]] name = "zbus_xml" -version = "5.0.2" +version = "5.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589e9a02bfafb9754bb2340a9e3b38f389772684c63d9637e76b1870377bec29" +checksum = "441a0064125265655bccc3a6af6bef56814d9277ac83fce48b1cd7e160b80eac" dependencies = [ - "quick-xml 0.36.2", + "quick-xml", "serde", - "static_assertions", "zbus_names", "zvariant", ] [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -9885,7 +9876,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", "synstructure", ] @@ -9901,13 +9892,13 @@ dependencies = [ [[package]] name = "zeroize_derive" -version = "1.4.2" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -9977,7 +9968,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", ] [[package]] @@ -9989,16 +9980,22 @@ dependencies = [ "arbitrary", "crc32fast", "flate2", - "indexmap 2.12.0", + "indexmap 2.13.0", "memchr", "zopfli", ] [[package]] name = "zlib-rs" -version = "0.5.2" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + +[[package]] +name = "zmij" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" [[package]] name = "zmq" @@ -10050,54 +10047,69 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + [[package]] name = "zune-jpeg" version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ - "zune-core", + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2959ca473aae96a14ecedf501d20b3608d2825ba280d5adb57d651721885b0c2" +dependencies = [ + "zune-core 0.5.1", ] [[package]] name = "zvariant" -version = "5.8.0" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" dependencies = [ "endi", "enumflags2", "serde", "url", - "winnow 0.7.13", + "winnow 0.7.14", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.8.0" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.114", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.110", - "winnow 0.7.13", + "syn 2.0.114", + "winnow 0.7.14", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 76f09ec19..45fc9dc69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.30.1", features = ["signal"] } eframe = { version = "0.32.0", features = ["persistence"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "a3d135c0e4baa5e6690cea99d1c79e2e5ad50e17", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "7b2669c0b250504a4cded9e2b9e78551583e6744", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index aed28e3b2..15ce38e77 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -465,7 +465,7 @@ impl AppContext { const FALLBACK_STEP: u64 = 100; let network = self.wallet_network_key(); - let current_height = wm.current_height(network); + let current_height = wm.current_height(); let total_amount: u64 = recipients.iter().map(|(_, amt)| *amt).sum(); let mut scale_factor = 1.0f64; let mut attempted_fallback = false; diff --git a/src/context.rs b/src/context.rs index ed0179693..475999600 100644 --- a/src/context.rs +++ b/src/context.rs @@ -747,7 +747,7 @@ impl AppContext { let balance = wm .get_wallet_balance(wallet_id) .map_err(|e| format!("get_wallet_balance failed: {e}"))?; - tracing::debug!(wallet = %hex::encode(seed_hash), confirmed = balance.confirmed, unconfirmed = balance.unconfirmed, total = balance.total, "SPV balance snapshot"); + tracing::debug!(wallet = %hex::encode(seed_hash), spendable = balance.spendable(), unconfirmed = balance.unconfirmed(), total = balance.total(), "SPV balance snapshot"); let Some(wallet_info) = wm.get_wallet_info(wallet_id) else { continue; @@ -760,13 +760,17 @@ impl AppContext { self.sync_spv_account_addresses(wallet_info, &wallet_arc); if let Ok(mut wallet) = wallet_arc.write() { - wallet.update_spv_balances(balance.confirmed, balance.unconfirmed, balance.total); + wallet.update_spv_balances( + balance.spendable(), + balance.unconfirmed(), + balance.total(), + ); // Persist balances to database if let Err(e) = self.db.update_wallet_balances( seed_hash, - balance.confirmed, - balance.unconfirmed, - balance.total, + balance.spendable(), + balance.unconfirmed(), + balance.total(), ) { tracing::warn!(wallet = %hex::encode(seed_hash), error = %e, "Failed to persist wallet balances"); } diff --git a/src/sdk_wrapper.rs b/src/sdk_wrapper.rs index 36be904c9..58c62c426 100644 --- a/src/sdk_wrapper.rs +++ b/src/sdk_wrapper.rs @@ -18,7 +18,6 @@ pub fn initialize_sdk( timeout: Some(Duration::from_secs(10)), retries: Some(6), ban_failed_address: Some(true), - max_decoding_message_size: None, }; let platform_version = default_platform_version(&network); diff --git a/src/spv/manager.rs b/src/spv/manager.rs index f5fa76935..3e4620597 100644 --- a/src/spv/manager.rs +++ b/src/spv/manager.rs @@ -11,7 +11,6 @@ use dash_sdk::dash_spv::types::{ }; use dash_sdk::dash_spv::{ClientConfig, DashSpvClient, Hash, LLMQType, QuorumHash}; use dash_sdk::dpp::dashcore::{Address, Network, Transaction}; -use dash_sdk::dpp::key_wallet; use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::{ @@ -26,7 +25,7 @@ use std::net::ToSocketAddrs; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; -use std::time::{Duration, SystemTime}; +use std::time::SystemTime; use tokio::sync::RwLock as AsyncRwLock; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; @@ -263,7 +262,9 @@ impl SpvManager { data_dir, config, subtasks, - wallet: Arc::new(AsyncRwLock::new(WalletManager::::new())), + wallet: Arc::new(AsyncRwLock::new(WalletManager::::new( + network, + ))), storage: Arc::new(Mutex::new(None)), client_interface: Arc::new(RwLock::new(None)), status: Arc::new(RwLock::new(SpvStatus::Idle)), @@ -620,8 +621,6 @@ impl SpvManager { seed_hash: WalletSeedHash, mut seed_bytes: [u8; 64], ) -> Result { - let wallet_network = Self::wallet_network(self.network); - let existing_wallet_id = { let map = self.det_wallets.read().map_err(|e| e.to_string())?; map.get(&seed_hash).copied() @@ -653,11 +652,7 @@ impl SpvManager { let account_options = Self::default_account_creation_options(); - let wallet_id = match wm.import_wallet_from_extended_priv_key( - &xprv_str, - wallet_network, - account_options, - ) { + let wallet_id = match wm.import_wallet_from_extended_priv_key(&xprv_str, account_options) { Ok(id) => id, Err(WalletError::WalletExists(id)) => id, Err(err) => { @@ -723,22 +718,6 @@ impl SpvManager { }) } - fn wallet_network(network: Network) -> key_wallet::Network { - match network { - Network::Dash => key_wallet::Network::Dash, - Network::Testnet => key_wallet::Network::Testnet, - Network::Devnet => key_wallet::Network::Devnet, - Network::Regtest => key_wallet::Network::Regtest, - other => { - tracing::warn!( - ?other, - "Unknown dashcore::Network; defaulting to Dash for wallet mapping" - ); - key_wallet::Network::Dash - } - } - } - fn default_account_creation_options() -> WalletAccountCreationOptions { WalletAccountCreationOptions::Default } @@ -828,62 +807,7 @@ impl SpvManager { stop_token: CancellationToken, global_cancel: CancellationToken, ) -> Result<(), String> { - // Wait for at least one peer to connect - let mut waited_ms: u64 = 0; - loop { - // Check for cancellation - if stop_token.is_cancelled() || global_cancel.is_cancelled() { - let _ = client.stop().await; - let _ = self.write_status(SpvStatus::Stopped); - return Ok(()); - } - - let peers = client.get_peer_count().await; - if peers > 0 { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - waited_ms = waited_ms.saturating_add(200); - if waited_ms.is_multiple_of(5000) { - tracing::info!("SPV waiting for peers... {}s elapsed", waited_ms / 1000); - } - } - - // Sync to tip with timeout to prevent indefinite hangs - const SYNC_TIMEOUT_SECS: u64 = 300; // 5 minutes - match tokio::time::timeout(Duration::from_secs(SYNC_TIMEOUT_SECS), client.sync_to_tip()) - .await - { - Ok(Ok(progress)) => { - tracing::info!("Initial sync progress snapshot: {:?}", progress); - let _ = self.write_sync_progress(Some(progress.clone())); - let _ = self.write_progress_updated_at(Some(SystemTime::now())); - // Stay in Syncing mode until detailed progress reports completion. - let _ = self.write_status(SpvStatus::Syncing); - } - Ok(Err(err)) => { - tracing::error!("Initial sync failed: {}", err); - let _ = client.stop().await; - let _ = self.write_last_error(Some(format!("Initial sync failed: {err}"))); - let _ = self.write_status(SpvStatus::Error); - return Err(format!("Initial sync failed: {err}")); - } - Err(_) => { - tracing::error!("Initial sync timed out after {} seconds", SYNC_TIMEOUT_SECS); - let _ = client.stop().await; - let _ = self.write_last_error(Some(format!( - "Initial sync timed out after {} seconds", - SYNC_TIMEOUT_SECS - ))); - let _ = self.write_status(SpvStatus::Error); - return Err(format!( - "Initial sync timed out after {} seconds", - SYNC_TIMEOUT_SECS - )); - } - } - - // Monitor network continuously - this is designed to run once and keep running + // Monitor network continuously - this handles initial sync and ongoing monitoring // Requests are handled through the DashSpvClientInterface command channel enum Outcome { MonitorCompleted(Result<(), dash_sdk::dash_spv::SpvError>), From 071946e9f31b2ceddab386ba502d5481c9beb18b Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Thu, 22 Jan 2026 17:21:57 +0700 Subject: [PATCH 048/106] feat: hint text next to withdrawal destination input in dev mode with advanced options and owner key --- src/ui/identities/withdraw_screen.rs | 29 +++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index 6df01e945..2ad49338c 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -135,11 +135,13 @@ impl WithdrawalScreen { } fn render_address_input(&mut self, ui: &mut Ui) { - let can_have_withdrawal_address = if let Some(key) = self.selected_key.as_ref() { - key.purpose() != Purpose::OWNER - } else { - true - }; + let is_owner_key = self + .selected_key + .as_ref() + .map(|key| key.purpose() == Purpose::OWNER) + .unwrap_or(false); + let can_have_withdrawal_address = !is_owner_key; + if can_have_withdrawal_address || self.app_context.is_developer_mode() { ui.horizontal(|ui| { ui.label("Address:"); @@ -167,6 +169,23 @@ impl WithdrawalScreen { ui.colored_label(Color32::from_rgb(255, 100, 100), error); } }); + + // In dev mode with OWNER key, show hint about auto-selected payout address + if self.app_context.is_developer_mode() && is_owner_key { + if let Some(payout_address) = self + .identity + .masternode_payout_address(self.app_context.network) + { + ui.label( + RichText::new(format!( + "Leave empty to use masternode payout address: {}", + payout_address + )) + .italics() + .color(Color32::GRAY), + ); + } + } } else { ui.label(format!( "Masternode payout address: {}", From d64921915397d42b2a425cc43a4412c5ee612578 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Thu, 22 Jan 2026 23:07:46 -0800 Subject: [PATCH 049/106] feat: implement asset lock creation and detail screens (#418) * feat: implement asset lock creation and detail screens * clippy fix * some fixes * fix: ui cleanup and backend fixes * fix: clippy * feat: tests * fix: remove identity index selector for top ups * fmt * refactor: use `AmountInput` component * fixes * fix: clippy --------- Co-authored-by: pauldelucia --- src/backend_task/core/create_asset_lock.rs | 131 +++ src/backend_task/core/mod.rs | 18 + src/ui/identities/funding_common.rs | 2 +- src/ui/mod.rs | 42 + src/ui/wallets/asset_lock_detail_screen.rs | 448 +++++++++ src/ui/wallets/create_asset_lock_screen.rs | 1059 ++++++++++++++++++++ src/ui/wallets/mod.rs | 2 + src/ui/wallets/wallets_screen/mod.rs | 30 +- tests/kittest/create_asset_lock_screen.rs | 57 ++ tests/kittest/main.rs | 1 + 10 files changed, 1779 insertions(+), 11 deletions(-) create mode 100644 src/backend_task/core/create_asset_lock.rs create mode 100644 src/ui/wallets/asset_lock_detail_screen.rs create mode 100644 src/ui/wallets/create_asset_lock_screen.rs create mode 100644 tests/kittest/create_asset_lock_screen.rs diff --git a/src/backend_task/core/create_asset_lock.rs b/src/backend_task/core/create_asset_lock.rs new file mode 100644 index 000000000..94faf5379 --- /dev/null +++ b/src/backend_task/core/create_asset_lock.rs @@ -0,0 +1,131 @@ +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::wallet::Wallet; +use dash_sdk::dashcore_rpc::RpcApi; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use dash_sdk::dpp::fee::Credits; +use std::sync::{Arc, RwLock}; + +impl AppContext { + pub fn create_registration_asset_lock( + &self, + wallet: Arc>, + amount: Credits, + allow_take_fee_from_amount: bool, + identity_index: u32, + ) -> Result { + // Convert credits to duffs (1 duff = 1000 credits) + let amount_duffs = amount / CREDITS_PER_DUFF; + + // Create the asset lock transaction + let (asset_lock_transaction, _private_key, _change_address, used_utxos) = { + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + + wallet_guard.registration_asset_lock_transaction( + self.network, + amount_duffs, + allow_take_fee_from_amount, + identity_index, + Some(self), + )? + }; + + let tx_id = asset_lock_transaction.txid(); + + // Insert the transaction into waiting for finality + { + let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); + proofs.insert(tx_id, None); + } + + // Broadcast the transaction + self.core_client + .read() + .expect("Core client lock was poisoned") + .send_raw_transaction(&asset_lock_transaction) + .map_err(|e| format!("Failed to broadcast asset lock transaction: {}", e))?; + + // Update wallet UTXOs + { + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + wallet_guard.utxos.retain(|_, utxo_map| { + utxo_map.retain(|outpoint, _| !used_utxos.contains_key(outpoint)); + !utxo_map.is_empty() // Keep addresses that still have UTXOs + }); + + // Drop used UTXOs from database + for utxo in used_utxos.keys() { + self.db + .drop_utxo(utxo, &self.network.to_string()) + .map_err(|e| e.to_string())?; + } + } + + Ok(BackendTaskSuccessResult::Message(format!( + "Asset lock transaction broadcast successfully. TX ID: {}", + tx_id + ))) + } + + pub fn create_top_up_asset_lock( + &self, + wallet: Arc>, + amount: Credits, + allow_take_fee_from_amount: bool, + identity_index: u32, + top_up_index: u32, + ) -> Result { + // Convert credits to duffs (1 duff = 1000 credits) + let amount_duffs = amount / CREDITS_PER_DUFF; + + // Create the asset lock transaction + let (asset_lock_transaction, _private_key, _change_address, used_utxos) = { + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + + wallet_guard.top_up_asset_lock_transaction( + self.network, + amount_duffs, + allow_take_fee_from_amount, + identity_index, + top_up_index, + Some(self), + )? + }; + + let tx_id = asset_lock_transaction.txid(); + + // Insert the transaction into waiting for finality + { + let mut proofs = self.transactions_waiting_for_finality.lock().unwrap(); + proofs.insert(tx_id, None); + } + + // Broadcast the transaction + self.core_client + .read() + .expect("Core client lock was poisoned") + .send_raw_transaction(&asset_lock_transaction) + .map_err(|e| format!("Failed to broadcast asset lock transaction: {}", e))?; + + // Update wallet UTXOs + { + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + wallet_guard.utxos.retain(|_, utxo_map| { + utxo_map.retain(|outpoint, _| !used_utxos.contains_key(outpoint)); + !utxo_map.is_empty() // Keep addresses that still have UTXOs + }); + + // Drop used UTXOs from database + for utxo in used_utxos.keys() { + self.db + .drop_utxo(utxo, &self.network.to_string()) + .map_err(|e| e.to_string())?; + } + } + + Ok(BackendTaskSuccessResult::Message(format!( + "Asset lock transaction broadcast successfully. TX ID: {}", + tx_id + ))) + } +} diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 15ce38e77..bdad43d1f 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -1,3 +1,4 @@ +mod create_asset_lock; mod recover_asset_locks; mod refresh_single_key_wallet_info; mod refresh_wallet_info; @@ -18,6 +19,7 @@ use dash_sdk::dpp::dashcore::sighash::SighashCache; use dash_sdk::dpp::dashcore::{ Address, Block, ChainLock, InstantLock, Network, OutPoint, PrivateKey, Transaction, TxOut, }; +use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::key_wallet::Network as WalletNetwork; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use dash_sdk::dpp::key_wallet::wallet::managed_wallet_info::fee::FeeLevel; @@ -57,6 +59,8 @@ pub enum CoreTask { RefreshWalletInfo(Arc>, Option), RefreshSingleKeyWalletInfo(Arc>), StartDashQT(Network, PathBuf, bool), + CreateRegistrationAssetLock(Arc>, Credits, u32), // wallet, amount in credits, identity index + CreateTopUpAssetLock(Arc>, Credits, u32, u32), // wallet, amount in credits, identity index, top up index SendWalletPayment { wallet: Arc>, request: WalletPaymentRequest, @@ -85,6 +89,14 @@ impl PartialEq for CoreTask { CoreTask::StartDashQT(_, _, _), CoreTask::StartDashQT(_, _, _) ) + | ( + CoreTask::CreateRegistrationAssetLock(_, _, _), + CoreTask::CreateRegistrationAssetLock(_, _, _) + ) + | ( + CoreTask::CreateTopUpAssetLock(_, _, _, _), + CoreTask::CreateTopUpAssetLock(_, _, _, _) + ) | ( CoreTask::SendWalletPayment { .. }, CoreTask::SendWalletPayment { .. }, @@ -241,6 +253,12 @@ impl AppContext { .start_dash_qt(network, custom_dash_qt, overwrite_dash_conf) .map_err(|e| e.to_string()) .map(|_| BackendTaskSuccessResult::None), + CoreTask::CreateRegistrationAssetLock(wallet, amount, identity_index) => self + .create_registration_asset_lock(wallet, amount, true, identity_index) + .map_err(|e| format!("Error creating asset lock: {}", e)), + CoreTask::CreateTopUpAssetLock(wallet, amount, identity_index, top_up_index) => self + .create_top_up_asset_lock(wallet, amount, true, identity_index, top_up_index) + .map_err(|e| format!("Error creating top up asset lock: {}", e)), CoreTask::SendWalletPayment { wallet, request } => { self.send_wallet_payment(wallet, request).await } diff --git a/src/ui/identities/funding_common.rs b/src/ui/identities/funding_common.rs index d1909e044..98c35555e 100644 --- a/src/ui/identities/funding_common.rs +++ b/src/ui/identities/funding_common.rs @@ -9,7 +9,7 @@ use crate::model::wallet::Wallet; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dpp::dashcore::{OutPoint, TxOut}; -#[derive(Eq, PartialEq, Ord, PartialOrd, Copy, Clone)] +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Copy, Clone)] pub enum WalletFundedScreenStep { ChooseFundingMethod, WaitingOnFunds, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 80098415f..152be79fd 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -39,6 +39,8 @@ use crate::ui::tools::masternode_list_diff_screen::MasternodeListDiffScreen; use crate::ui::tools::platform_info_screen::PlatformInfoScreen; use crate::ui::tools::proof_log_screen::ProofLogScreen; use crate::ui::tools::proof_visualizer_screen::ProofVisualizerScreen; +use crate::ui::wallets::asset_lock_detail_screen::AssetLockDetailScreen; +use crate::ui::wallets::create_asset_lock_screen::CreateAssetLockScreen; use crate::ui::wallets::import_mnemonic_screen::ImportMnemonicScreen; use crate::ui::wallets::send_screen::WalletSendScreen; use crate::ui::wallets::single_key_send_screen::SingleKeyWalletSendScreen; @@ -295,6 +297,10 @@ pub enum ScreenType { PurchaseTokenScreen(IdentityTokenInfo), SetTokenPriceScreen(IdentityTokenInfo), + // Wallet screens + AssetLockDetail([u8; 32], usize), + CreateAssetLock(Arc>), + // DashPay Screens DashPayContacts, DashPayProfile, @@ -318,6 +324,10 @@ impl PartialEq for ScreenType { ScreenType::SingleKeyWalletSendScreen(_), ScreenType::SingleKeyWalletSendScreen(_), ) => true, + (ScreenType::CreateAssetLock(_), ScreenType::CreateAssetLock(_)) => true, + (ScreenType::AssetLockDetail(a1, a2), ScreenType::AssetLockDetail(b1, b2)) => { + a1 == b1 && a2 == b2 + } (ScreenType::Identities, ScreenType::Identities) => true, (ScreenType::DPNSActiveContests, ScreenType::DPNSActiveContests) => true, (ScreenType::DPNSPastContests, ScreenType::DPNSPastContests) => true, @@ -604,6 +614,12 @@ impl ScreenType { ScreenType::SetTokenPriceScreen(identity_token_info) => Screen::SetTokenPriceScreen( SetTokenPriceScreen::new(identity_token_info.clone(), app_context), ), + ScreenType::AssetLockDetail(wallet_seed_hash, index) => Screen::AssetLockDetailScreen( + AssetLockDetailScreen::new(*wallet_seed_hash, *index, app_context), + ), + ScreenType::CreateAssetLock(wallet) => Screen::CreateAssetLockScreen( + CreateAssetLockScreen::new(wallet.clone(), app_context), + ), // DashPay Screens ScreenType::DashPayContacts => { @@ -710,6 +726,8 @@ pub enum Screen { AddTokenById(AddTokenByIdScreen), PurchaseTokenScreen(PurchaseTokenScreen), SetTokenPriceScreen(SetTokenPriceScreen), + AssetLockDetailScreen(AssetLockDetailScreen), + CreateAssetLockScreen(CreateAssetLockScreen), // DashPay Screens DashPayScreen(DashPayScreen), @@ -786,6 +804,8 @@ impl Screen { Screen::AddTokenById(screen) => screen.app_context = app_context, Screen::PurchaseTokenScreen(screen) => screen.app_context = app_context, Screen::SetTokenPriceScreen(screen) => screen.app_context = app_context, + Screen::AssetLockDetailScreen(screen) => screen.app_context = app_context, + Screen::CreateAssetLockScreen(screen) => screen.app_context = app_context, // DashPay Screens Screen::DashPayScreen(screen) => { @@ -967,6 +987,12 @@ impl Screen { Screen::SetTokenPriceScreen(screen) => { ScreenType::SetTokenPriceScreen(screen.identity_token_info.clone()) } + Screen::AssetLockDetailScreen(screen) => { + ScreenType::AssetLockDetail(screen.wallet_seed_hash, screen.asset_lock_index) + } + Screen::CreateAssetLockScreen(screen) => { + ScreenType::CreateAssetLock(screen.wallet.clone()) + } Screen::TokensScreen(_) => { // Default fallback for any unmatched TokensScreen variants ScreenType::TokenBalances @@ -1050,6 +1076,8 @@ impl ScreenLike for Screen { Screen::AddTokenById(screen) => screen.refresh(), Screen::PurchaseTokenScreen(screen) => screen.refresh(), Screen::SetTokenPriceScreen(screen) => screen.refresh(), + Screen::AssetLockDetailScreen(screen) => screen.refresh(), + Screen::CreateAssetLockScreen(screen) => screen.refresh(), // DashPay Screens Screen::DashPayScreen(screen) => screen.refresh(), @@ -1114,6 +1142,8 @@ impl ScreenLike for Screen { Screen::AddTokenById(screen) => screen.refresh_on_arrival(), Screen::PurchaseTokenScreen(screen) => screen.refresh_on_arrival(), Screen::SetTokenPriceScreen(screen) => screen.refresh_on_arrival(), + Screen::AssetLockDetailScreen(screen) => screen.refresh_on_arrival(), + Screen::CreateAssetLockScreen(screen) => screen.refresh_on_arrival(), // DashPay Screens Screen::DashPayScreen(screen) => screen.refresh_on_arrival(), @@ -1178,6 +1208,8 @@ impl ScreenLike for Screen { Screen::AddTokenById(screen) => screen.ui(ctx), Screen::PurchaseTokenScreen(screen) => screen.ui(ctx), Screen::SetTokenPriceScreen(screen) => screen.ui(ctx), + Screen::AssetLockDetailScreen(screen) => screen.ui(ctx), + Screen::CreateAssetLockScreen(screen) => screen.ui(ctx), // DashPay Screens Screen::DashPayScreen(screen) => screen.ui(ctx), @@ -1262,6 +1294,8 @@ impl ScreenLike for Screen { Screen::AddTokenById(screen) => screen.display_message(message, message_type), Screen::PurchaseTokenScreen(screen) => screen.display_message(message, message_type), Screen::SetTokenPriceScreen(screen) => screen.display_message(message, message_type), + Screen::AssetLockDetailScreen(screen) => screen.display_message(message, message_type), + Screen::CreateAssetLockScreen(screen) => screen.display_message(message, message_type), // DashPay Screens Screen::DashPayScreen(screen) => screen.display_message(message, message_type), @@ -1424,6 +1458,12 @@ impl ScreenLike for Screen { Screen::SetTokenPriceScreen(screen) => { screen.display_task_result(backend_task_success_result) } + Screen::AssetLockDetailScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } + Screen::CreateAssetLockScreen(screen) => { + screen.display_task_result(backend_task_success_result) + } // DashPay Screens Screen::DashPayScreen(screen) => { @@ -1504,6 +1544,8 @@ impl ScreenLike for Screen { Screen::AddTokenById(screen) => screen.pop_on_success(), Screen::PurchaseTokenScreen(screen) => screen.pop_on_success(), Screen::SetTokenPriceScreen(screen) => screen.pop_on_success(), + Screen::AssetLockDetailScreen(screen) => screen.pop_on_success(), + Screen::CreateAssetLockScreen(screen) => screen.pop_on_success(), // DashPay Screens Screen::DashPayScreen(screen) => screen.pop_on_success(), diff --git a/src/ui/wallets/asset_lock_detail_screen.rs b/src/ui/wallets/asset_lock_detail_screen.rs new file mode 100644 index 000000000..7e65e4395 --- /dev/null +++ b/src/ui/wallets/asset_lock_detail_screen.rs @@ -0,0 +1,448 @@ +use crate::app::AppAction; +use crate::context::AppContext; +use crate::model::wallet::Wallet; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use chrono::{DateTime, Utc}; +use dash_sdk::dashcore_rpc::dashcore::{Address, InstantLock, Transaction}; +use dash_sdk::dpp::fee::Credits; +use dash_sdk::dpp::prelude::AssetLockProof; +use eframe::egui::{self, Context, Ui}; +use egui::{Color32, Frame, Margin, RichText}; +use std::sync::{Arc, RwLock}; + +pub struct AssetLockDetailScreen { + pub wallet_seed_hash: [u8; 32], + pub asset_lock_index: usize, + pub app_context: Arc, + message: Option<(String, MessageType, DateTime)>, + wallet: Option>>, + wallet_password: String, + show_password: bool, + error_message: Option, + show_private_key_popup: bool, + private_key_wif: Option, +} + +impl AssetLockDetailScreen { + pub fn new( + wallet_seed_hash: [u8; 32], + asset_lock_index: usize, + app_context: &Arc, + ) -> Self { + // Find the wallet by seed hash + let wallet = app_context + .wallets + .read() + .unwrap() + .values() + .find(|w| w.read().unwrap().seed_hash() == wallet_seed_hash) + .cloned(); + + Self { + wallet_seed_hash, + asset_lock_index, + app_context: app_context.clone(), + message: None, + wallet, + wallet_password: String::new(), + show_password: false, + error_message: None, + show_private_key_popup: false, + private_key_wif: None, + } + } + + #[allow(clippy::type_complexity)] + fn get_asset_lock_data( + &self, + ) -> Option<( + Transaction, + Address, + Credits, + Option, + Option, + )> { + self.wallet.as_ref().and_then(|wallet| { + let wallet = wallet.read().unwrap(); + wallet + .unused_asset_locks + .get(self.asset_lock_index) + .cloned() + }) + } + + fn render_asset_lock_info(&mut self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + + if let Some((tx, address, amount, _islock, proof)) = self.get_asset_lock_data() { + Frame::new() + .fill(DashColors::surface(dark_mode)) + .corner_radius(5.0) + .inner_margin(Margin::same(15)) + .stroke(egui::Stroke::new(1.0, DashColors::border_light(dark_mode))) + .show(ui, |ui| { + ui.heading(RichText::new("Asset Lock Details").color(DashColors::text_primary(dark_mode))); + ui.add_space(10.0); + + // Transaction Information + ui.label(RichText::new("Transaction Information").strong().color(DashColors::text_primary(dark_mode))); + ui.separator(); + ui.add_space(5.0); + + ui.horizontal(|ui| { + ui.label("Transaction ID:"); + ui.label(RichText::new(tx.txid().to_string()).font(egui::FontId::monospace(12.0))); + }); + ui.add_space(5.0); + + ui.horizontal(|ui| { + ui.label("Address:"); + ui.label(RichText::new(address.to_string()).font(egui::FontId::monospace(12.0))); + }); + ui.add_space(5.0); + + ui.horizontal(|ui| { + ui.label("Amount:"); + let dash_amount = amount.to_string().parse::().unwrap_or(0) as f64 * 1e-8; + ui.label(RichText::new(format!("{:.8} DASH ({} duffs)", dash_amount, amount)) + .strong() + .color(DashColors::text_primary(dark_mode))); + }); + ui.add_space(5.0); + + ui.horizontal(|ui| { + ui.label("Asset Lock Proof Type:"); + let (proof_type, color) = match &proof { + Some(AssetLockProof::Instant(_)) => ("Instant Send Locked", DashColors::success_color(dark_mode)), + Some(AssetLockProof::Chain(_)) => ("Chain Locked", DashColors::success_color(dark_mode)), + None => ("Waiting for Lock", DashColors::warning_color(dark_mode)), + }; + ui.label(RichText::new(proof_type).color(color)); + }); + ui.add_space(5.0); + + // Asset Lock Proof Details + if let Some(proof) = &proof { + ui.add_space(15.0); + ui.label(RichText::new("Asset Lock Proof Details").strong().color(DashColors::text_primary(dark_mode))); + ui.separator(); + ui.add_space(5.0); + + // Show specific proof details based on type + match proof { + AssetLockProof::Instant(instant_proof) => { + ui.horizontal(|ui| { + ui.label("Type:"); + ui.label(RichText::new("Instant Send").font(egui::FontId::monospace(12.0))); + }); + ui.add_space(5.0); + + // The instant lock is in the instant_proof + ui.horizontal(|ui| { + ui.label("InstantLock TxID:"); + ui.label(RichText::new(instant_proof.instant_lock.txid.to_string()).font(egui::FontId::monospace(12.0))); + }); + ui.add_space(5.0); + + ui.horizontal(|ui| { + ui.label("Output Index:"); + ui.label(RichText::new(instant_proof.output_index.to_string()).font(egui::FontId::monospace(12.0))); + }); + ui.add_space(5.0); + } + AssetLockProof::Chain(chain_proof) => { + ui.horizontal(|ui| { + ui.label("Type:"); + ui.label(RichText::new("Chain Lock").font(egui::FontId::monospace(12.0))); + }); + ui.add_space(5.0); + + ui.horizontal(|ui| { + ui.label("Core Chain Locked Height:"); + ui.label(RichText::new(chain_proof.core_chain_locked_height.to_string()).font(egui::FontId::monospace(12.0))); + }); + ui.add_space(5.0); + + ui.horizontal(|ui| { + ui.label("OutPoint:"); + ui.label(RichText::new(format!("{}:{}", chain_proof.out_point.txid, chain_proof.out_point.vout)).font(egui::FontId::monospace(12.0))); + }); + ui.add_space(5.0); + } + } + + // Asset Lock Proof Hex + ui.add_space(10.0); + + // Serialize the proof to get hex + let proof_hex = match serde_json::to_vec(proof) { + Ok(bytes) => hex::encode(bytes), + Err(e) => format!("Error serializing proof: {}", e), + }; + + ui.horizontal(|ui| { + ui.label("Asset Lock Proof (hex):"); + if ui.small_button("Copy").clicked() { + ui.ctx().copy_text(proof_hex.clone()); + self.display_message("Asset lock proof copied to clipboard", MessageType::Success); + } + }); + ui.add_space(5.0); + + // Display hex in a scrollable area with monospace font + egui::ScrollArea::horizontal() + .id_salt("proof_hex") + .show(ui, |ui| { + ui.label(RichText::new(&proof_hex).font(egui::FontId::monospace(10.0)).color(DashColors::text_secondary(dark_mode))); + }); + + ui.add_space(10.0); + ui.collapsing("View Raw Proof Details", |ui| { + ui.label(RichText::new(format!("{:#?}", proof)).font(egui::FontId::monospace(10.0))); + }); + } + + // Private Key Section (requires wallet unlock) + ui.add_space(20.0); + ui.label(RichText::new("Private Key Information").strong().color(DashColors::text_primary(dark_mode))); + ui.separator(); + ui.add_space(5.0); + + let (needs_unlock, unlocked) = self.render_wallet_unlock_if_needed(ui); + + if (!needs_unlock || unlocked) + && let Some(wallet_arc) = self.wallet.clone() { + let wallet = wallet_arc.read().unwrap(); + + // Find the private key for this address + if let Some(derivation_path) = wallet.known_addresses.get(&address).cloned() { + drop(wallet); // Release the read lock before getting write lock + + ui.horizontal(|ui| { + ui.label("Private Key (WIF):"); + ui.label(RichText::new("••••••••••••••••••••").font(egui::FontId::monospace(12.0)).color(DashColors::text_secondary(dark_mode))); + if ui.small_button("View").clicked() { + // Retrieve the private key when View is clicked + let wallet = wallet_arc.write().unwrap(); + match wallet.private_key_at_derivation_path(&derivation_path, self.app_context.network) { + Ok(private_key) => { + self.private_key_wif = Some(private_key.to_wif()); + self.show_private_key_popup = true; + } + Err(e) => { + self.display_message(&format!("Error retrieving private key: {}", e), MessageType::Error); + } + } + } + }); + + ui.add_space(5.0); + ui.label(RichText::new("Warning: Keep this private key secure! Anyone with access to it can spend these funds.") + .color(DashColors::warning_color(dark_mode)) + .italics()); + } else { + ui.label(RichText::new("Private key not found for this address") + .color(DashColors::error_color(dark_mode))); + } + } + }); + } else { + ui.vertical_centered(|ui| { + ui.add_space(50.0); + ui.label( + RichText::new("Asset lock not found") + .size(16.0) + .color(Color32::GRAY), + ); + }); + } + } + + fn check_message_expiration(&mut self) { + if let Some((_, _, timestamp)) = &self.message { + let now = Utc::now(); + let elapsed = now.signed_duration_since(*timestamp); + + if elapsed.num_seconds() >= 10 { + self.message = None; + } + } + } +} + +impl ScreenWithWalletUnlock for AssetLockDetailScreen { + fn selected_wallet_ref(&self) -> &Option>> { + &self.wallet + } + + fn wallet_password_ref(&self) -> &String { + &self.wallet_password + } + + fn wallet_password_mut(&mut self) -> &mut String { + &mut self.wallet_password + } + + fn show_password(&self) -> bool { + self.show_password + } + + fn show_password_mut(&mut self) -> &mut bool { + &mut self.show_password + } + + fn set_error_message(&mut self, error_message: Option) { + self.error_message = error_message; + } + + fn error_message(&self) -> Option<&String> { + self.error_message.as_ref() + } + + fn app_context(&self) -> Arc { + self.app_context.clone() + } +} + +impl ScreenLike for AssetLockDetailScreen { + fn ui(&mut self, ctx: &Context) -> AppAction { + self.check_message_expiration(); + + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ( + "Wallets", + AppAction::SetMainScreenThenGoToMainScreen( + RootScreenType::RootScreenWalletsBalances, + ), + ), + ("Asset Lock Details", AppAction::None), + ], + vec![], + ); + + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenWalletsBalances, + ); + + action |= island_central_panel(ctx, |ui| { + let mut inner_action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Header with Back button (outside ScrollArea to avoid scrollbar overlap) + ui.horizontal(|ui| { + ui.heading( + RichText::new("Asset Lock Information") + .color(DashColors::text_primary(dark_mode)) + .size(24.0), + ); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Back").clicked() { + inner_action = AppAction::PopScreenAndRefresh; + } + }); + }); + ui.add_space(10.0); + + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + self.render_asset_lock_info(ui); + }); + + // Display messages + if let Some((message, message_type, timestamp)) = &self.message { + let message_color = match message_type { + MessageType::Error => egui::Color32::DARK_RED, + MessageType::Info => DashColors::text_primary(dark_mode), + MessageType::Success => egui::Color32::DARK_GREEN, + }; + + ui.add_space(25.0); + ui.horizontal(|ui| { + ui.add_space(10.0); + + let now = Utc::now(); + let elapsed = now.signed_duration_since(*timestamp); + let remaining = (10 - elapsed.num_seconds()).max(0); + + let full_msg = format!("{} ({}s)", message, remaining); + ui.label(egui::RichText::new(full_msg).color(message_color)); + }); + ui.add_space(2.0); + } + + inner_action + }); + + // Private key popup + if self.show_private_key_popup { + // Draw dark overlay behind the popup + let screen_rect = ctx.screen_rect(); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("private_key_popup_overlay"), + )); + painter.rect_filled( + screen_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), + ); + + egui::Window::new("Private Key") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_min_width(400.0); + + ui.add_space(10.0); + ui.label(RichText::new("⚠ Warning").color(Color32::from_rgb(255, 152, 0)).strong()); + ui.label("Keep this private key secure! Anyone with access to it can spend these funds."); + ui.add_space(15.0); + + ui.label("Private Key (WIF):"); + if let Some(wif) = self.private_key_wif.clone() { + ui.add(egui::TextEdit::multiline(&mut wif.as_str()) + .font(egui::FontId::monospace(12.0)) + .desired_width(f32::INFINITY) + .desired_rows(1)); + + ui.add_space(10.0); + + ui.horizontal(|ui| { + if ui.button("Copy").clicked() { + ui.ctx().copy_text(wif.clone()); + self.display_message("Private key copied to clipboard", MessageType::Success); + } + if ui.button("Close").clicked() { + self.show_private_key_popup = false; + self.private_key_wif = None; + } + }); + } + ui.add_space(10.0); + }); + } + + action + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + self.message = Some((message.to_string(), message_type, Utc::now())); + } + + fn refresh_on_arrival(&mut self) {} + + fn refresh(&mut self) {} +} diff --git a/src/ui/wallets/create_asset_lock_screen.rs b/src/ui/wallets/create_asset_lock_screen.rs new file mode 100644 index 000000000..c581f7e84 --- /dev/null +++ b/src/ui/wallets/create_asset_lock_screen.rs @@ -0,0 +1,1059 @@ +use crate::app::AppAction; +use crate::backend_task::core::{CoreItem, CoreTask}; +use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::context::AppContext; +use crate::model::amount::Amount; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::Wallet; +use crate::ui::components::Component; +use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::identity_selector::IdentitySelector; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::wallet_unlock::ScreenWithWalletUnlock; +use crate::ui::identities::funding_common::{self, WalletFundedScreenStep, generate_qr_code_image}; +use crate::ui::theme::DashColors; +use crate::ui::{MessageType, RootScreenType, ScreenLike}; +use chrono::{DateTime, Utc}; +use dash_sdk::dashcore_rpc::RpcApi; +use dash_sdk::dashcore_rpc::dashcore::{Address, OutPoint, TxOut}; +use eframe::egui::{self, Context, Ui}; +use egui::{Button, RichText, Vec2}; +use std::collections::HashSet; +use std::sync::{Arc, RwLock}; + +const MAX_IDENTITY_INDEX: u32 = 30; + +#[derive(Debug, Clone, Copy, PartialEq)] +enum AssetLockPurpose { + Registration, + TopUp, +} + +pub struct CreateAssetLockScreen { + pub wallet: Arc>, + selected_wallet: Option>>, + pub app_context: Arc, + message: Option<(String, MessageType, DateTime)>, + wallet_password: String, + show_password: bool, + error_message: Option, + + // Asset lock creation fields + step: Arc>, + amount_input: Option, + identity_index: u32, + funding_address: Option
      , + funding_utxo: Option<(OutPoint, TxOut, Address)>, + core_has_funding_address: Option, + is_creating: bool, + asset_lock_tx_id: Option, + + // New fields for asset lock purpose flow + asset_lock_purpose: Option, + selected_identity: Option, + selected_identity_string: String, + top_up_index: u32, + show_advanced_options: bool, +} + +impl CreateAssetLockScreen { + pub fn new(wallet: Arc>, app_context: &Arc) -> Self { + let selected_wallet = Some(wallet.clone()); + + // Calculate next unused identity index + let identity_index = { + let wallet_guard = wallet.read().unwrap(); + wallet_guard + .identities + .keys() + .copied() + .max() + .map(|max| max + 1) + .unwrap_or(0) + }; + + Self { + wallet, + selected_wallet, + app_context: app_context.clone(), + message: None, + wallet_password: String::new(), + show_password: false, + error_message: None, + step: Arc::new(RwLock::new(WalletFundedScreenStep::WaitingOnFunds)), + amount_input: Some( + AmountInput::new(Amount::new_dash(0.5)) + .with_label("Amount (DASH):") + .with_min_amount(Some(1000)), // Minimum 0.00000001 DASH (1000 credits) + ), + identity_index, + funding_address: None, + funding_utxo: None, + core_has_funding_address: None, + is_creating: false, + asset_lock_tx_id: None, + asset_lock_purpose: None, + selected_identity: None, + selected_identity_string: String::new(), + top_up_index: 0, + show_advanced_options: false, + } + } + + fn generate_funding_address(&mut self) -> Result<(), String> { + let mut wallet = self.wallet.write().unwrap(); + + // Generate a new asset lock funding address + let receive_address = + wallet.receive_address(self.app_context.network, false, Some(&self.app_context))?; + + // Import address to core if needed + if let Some(has_address) = self.core_has_funding_address { + if !has_address { + self.app_context + .core_client + .read() + .expect("Core client lock was poisoned") + .import_address( + &receive_address, + Some("Managed by Dash Evo Tool - Asset Lock"), + Some(false), + ) + .map_err(|e| e.to_string())?; + } + self.funding_address = Some(receive_address); + } else { + let info = self + .app_context + .core_client + .read() + .expect("Core client lock was poisoned") + .get_address_info(&receive_address) + .map_err(|e| e.to_string())?; + + if !(info.is_watchonly || info.is_mine) { + self.app_context + .core_client + .read() + .expect("Core client lock was poisoned") + .import_address( + &receive_address, + Some("Managed by Dash Evo Tool - Asset Lock"), + Some(false), + ) + .map_err(|e| e.to_string())?; + } + self.funding_address = Some(receive_address); + self.core_has_funding_address = Some(true); + } + + Ok(()) + } + + fn render_qr_code(&mut self, ui: &mut egui::Ui) -> Result<(), String> { + if self.funding_address.is_none() { + self.generate_funding_address()? + } + + let address = self.funding_address.as_ref().unwrap(); + let amount = self + .amount_input + .as_ref() + .and_then(|ai| ai.current_value()) + .map(|a| a.to_f64()) + .unwrap_or(0.5); + let dash_uri = format!("dash:{}?amount={:.4}", address, amount); + + // Generate the QR code image + if let Ok(qr_image) = generate_qr_code_image(&dash_uri) { + let texture = ui + .ctx() + .load_texture("qr_code", qr_image, egui::TextureOptions::LINEAR); + ui.image((texture.id(), Vec2::new(200.0, 200.0))); + } else { + ui.label("Failed to generate QR code."); + } + + ui.add_space(10.0); + ui.label(&dash_uri); + ui.add_space(5.0); + + if ui.button("Copy Address").clicked() { + ui.ctx().copy_text(dash_uri.clone()); + self.display_message("Address copied to clipboard", MessageType::Success); + } + + Ok(()) + } + + fn check_message_expiration(&mut self) { + if let Some((_, _, timestamp)) = &self.message { + let now = Utc::now(); + let elapsed = now.signed_duration_since(*timestamp); + + if elapsed.num_seconds() >= 10 { + self.message = None; + } + } + } + + fn show_success(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + + ui.vertical_centered(|ui| { + ui.add_space(100.0); + + ui.heading("🎉"); + ui.heading("Asset Lock Created Successfully!"); + + if let Some(tx_id) = &self.asset_lock_tx_id { + ui.add_space(20.0); + ui.horizontal(|ui| { + ui.label("Transaction ID:"); + ui.label(RichText::new(tx_id).font(egui::FontId::monospace(12.0))); + if ui.small_button("Copy").clicked() { + ui.ctx().copy_text(tx_id.clone()); + } + }); + } + + ui.add_space(20.0); + + if ui.button("Back to Wallets").clicked() { + action = AppAction::PopScreenAndRefresh; + } + if ui.button("Create Another").clicked() { + // Reset state for creating another asset lock + self.asset_lock_purpose = None; + self.selected_identity = None; + self.selected_identity_string.clear(); + // Recalculate next unused identity index + self.identity_index = { + let wallet_guard = self.wallet.read().unwrap(); + wallet_guard + .identities + .keys() + .copied() + .max() + .map(|max| max + 1) + .unwrap_or(0) + }; + self.top_up_index = 0; + // Reset amount input to default 0.5 DASH + self.amount_input = Some( + AmountInput::new(Amount::new_dash(0.5)) + .with_label("Amount (DASH):") + .with_min_amount(Some(1000)), + ); + self.funding_address = None; + self.funding_utxo = None; + self.core_has_funding_address = None; + self.asset_lock_tx_id = None; + self.error_message = None; + self.show_advanced_options = false; + *self.step.write().unwrap() = WalletFundedScreenStep::WaitingOnFunds; + } + + ui.add_space(100.0); + }); + + action + } +} + +impl ScreenWithWalletUnlock for CreateAssetLockScreen { + fn selected_wallet_ref(&self) -> &Option>> { + &self.selected_wallet + } + + fn wallet_password_ref(&self) -> &String { + &self.wallet_password + } + + fn wallet_password_mut(&mut self) -> &mut String { + &mut self.wallet_password + } + + fn show_password(&self) -> bool { + self.show_password + } + + fn show_password_mut(&mut self) -> &mut bool { + &mut self.show_password + } + + fn set_error_message(&mut self, error_message: Option) { + self.error_message = error_message; + } + + fn error_message(&self) -> Option<&String> { + self.error_message.as_ref() + } + + fn app_context(&self) -> Arc { + self.app_context.clone() + } +} + +impl ScreenLike for CreateAssetLockScreen { + fn ui(&mut self, ctx: &Context) -> AppAction { + self.check_message_expiration(); + + let wallet_name = self + .wallet + .read() + .ok() + .and_then(|w| w.alias.clone()) + .unwrap_or_else(|| "Unknown Wallet".to_string()); + + let mut action = add_top_panel( + ctx, + &self.app_context, + vec![ + ( + "Wallets", + AppAction::SetMainScreenThenGoToMainScreen( + RootScreenType::RootScreenWalletsBalances, + ), + ), + ("Create Asset Lock", AppAction::None), + ], + vec![], + ); + + action |= add_left_panel( + ctx, + &self.app_context, + RootScreenType::RootScreenWalletsBalances, + ); + + action |= island_central_panel(ctx, |ui| { + let mut inner_action = AppAction::None; + let dark_mode = ui.ctx().style().visuals.dark_mode; + + // Header with Back button and Advanced Options checkbox (outside ScrollArea) + ui.horizontal(|ui| { + ui.heading( + RichText::new("Create Asset Lock") + .color(DashColors::text_primary(dark_mode)) + .size(24.0), + ); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Back").clicked() { + inner_action = AppAction::PopScreenAndRefresh; + } + ui.add_space(10.0); + ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); + }); + }); + + // Show wallet name + ui.label( + RichText::new(format!("Wallet: {}", wallet_name)) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(10.0); + + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + + // Show success screen + if *self.step.read().unwrap() == WalletFundedScreenStep::Success { + inner_action |= self.show_success(ui); + return; + } + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Wallet unlock section + let (needs_unlock, unlocked) = self.render_wallet_unlock_if_needed(ui); + + if !needs_unlock || unlocked { + // First, select the purpose of the asset lock + if self.asset_lock_purpose.is_none() { + ui.heading(RichText::new("Select Asset Lock Purpose").color(DashColors::text_primary(dark_mode))); + + ui.add_space(10.0); + + ui.horizontal(|ui| { + if ui.button("Registration").clicked() { + self.asset_lock_purpose = Some(AssetLockPurpose::Registration); + } + + ui.add_space(5.0); + + if ui.button("Top Up").clicked() { + self.asset_lock_purpose = Some(AssetLockPurpose::TopUp); + } + }); + + ui.add_space(10.0); + + // Show explanation + ui.group(|ui| { + ui.label(RichText::new("Information").strong().color(DashColors::text_primary(dark_mode))); + ui.add_space(5.0); + ui.label(RichText::new("• Registration: Create an asset lock for a new identity registration").color(DashColors::text_secondary(dark_mode))); + ui.label(RichText::new("• Top Up: Add credits to an existing identity").color(DashColors::text_secondary(dark_mode))); + }); + + return; + } + + // Show selected purpose + ui.horizontal(|ui| { + ui.label(RichText::new("Purpose:").strong().color(DashColors::text_primary(dark_mode))); + let purpose_text = match self.asset_lock_purpose { + Some(AssetLockPurpose::Registration) => "Registration", + Some(AssetLockPurpose::TopUp) => "Top Up", + None => "Not selected", + }; + ui.label(RichText::new(purpose_text).color(DashColors::text_secondary(dark_mode))); + }); + + // Only show Back button if a purpose has been selected + if self.asset_lock_purpose.is_some() + && ui.button("Change Purpose").clicked() { + self.asset_lock_purpose = None; + self.selected_identity = None; + self.selected_identity_string.clear(); + } + + // For top up, select identity + if self.asset_lock_purpose == Some(AssetLockPurpose::TopUp) { + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + ui.heading(RichText::new("1. Select Identity to Top Up").color(DashColors::text_primary(dark_mode))); + let identities = match self.app_context.load_local_qualified_identities() { + Ok(ids) => ids, + Err(e) => { + ui.label( + RichText::new(format!("Error loading identities: {}", e)) + .color(egui::Color32::RED) + ); + return; + } + }; + + if identities.is_empty() { + ui.label( + RichText::new("No identities found. Please create an identity first.") + .color(egui::Color32::from_rgb(255, 152, 0)) + ); + return; + } + + let identity_selector_response = ui.add(IdentitySelector::new( + "top_up_identity_selector", + &mut self.selected_identity_string, + &identities + ) + .selected_identity(&mut self.selected_identity).unwrap() + .label("Identity to top up:") + .width(300.0)); + + // Update top_up_index to next unused value when identity selection changes + if identity_selector_response.changed() + && let Some(selected) = &self.selected_identity { + self.top_up_index = selected + .top_ups + .keys() + .max() + .cloned() + .map(|i| i + 1) + .unwrap_or(0); + } + + if self.selected_identity.is_none() { + return; + } + + if self.show_advanced_options { + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + ui.heading(RichText::new("2. Top Up Index Selection").color(DashColors::text_primary(dark_mode))); + ui.add_space(10.0); + + // Get used top_up indices from selected identity + let used_top_up_indices: HashSet = self.selected_identity + .as_ref() + .map(|id| id.top_ups.keys().cloned().collect()) + .unwrap_or_default(); + + ui.horizontal(|ui| { + ui.label("Top Up Index:"); + let selected_text = if used_top_up_indices.contains(&self.top_up_index) { + format!("{} (used)", self.top_up_index) + } else { + format!("{}", self.top_up_index) + }; + egui::ComboBox::from_id_salt("top_up_index") + .selected_text(selected_text) + .show_ui(ui, |ui| { + for i in 0..MAX_IDENTITY_INDEX { + let is_used = used_top_up_indices.contains(&i); + let label = if is_used { + format!("{} (used)", i) + } else { + format!("{}", i) + }; + let is_selected = self.top_up_index == i; + let response = ui.add_enabled(!is_used, Button::new(label).selected(is_selected)); + if response.clicked() { + self.top_up_index = i; + } + } + }); + }); + } + } else if self.asset_lock_purpose == Some(AssetLockPurpose::Registration) + + && self.show_advanced_options { + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + ui.heading(RichText::new("1. Index Selection").color(DashColors::text_primary(dark_mode))); + ui.add_space(10.0); + + // Get used indices from wallet + let wallet_guard = self.wallet.read().unwrap(); + let used_indices: HashSet = wallet_guard.identities.keys().cloned().collect(); + drop(wallet_guard); + + egui::Grid::new("registration_advanced_options_grid") + .num_columns(2) + .spacing([10.0, 8.0]) + .show(ui, |ui| { + // Row 1: Identity Index + ui.label("Identity Index:"); + let selected_text = if used_indices.contains(&self.identity_index) { + format!("{} (used)", self.identity_index) + } else { + format!("{}", self.identity_index) + }; + ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { + egui::ComboBox::from_id_salt("registration_identity_index") + .selected_text(selected_text) + .show_ui(ui, |ui| { + for i in 0..MAX_IDENTITY_INDEX { + let is_used = used_indices.contains(&i); + let label = if is_used { + format!("{} (used)", i) + } else { + format!("{}", i) + }; + let is_selected = self.identity_index == i; + let response = ui.add_enabled(!is_used, Button::new(label).selected(is_selected)); + if response.clicked() { + self.identity_index = i; + } + } + }); + }); + ui.end_row(); + }); + } + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Check if funds have arrived at the funding address + if let Some(utxo) = funding_common::capture_qr_funding_utxo_if_available( + &self.step, + self.selected_wallet.as_ref(), + self.funding_address.as_ref(), + ) { + self.funding_utxo = Some(utxo); + } + + let step = *self.step.read().unwrap(); + + // Request periodic repaints while waiting for funds + if step == WalletFundedScreenStep::WaitingOnFunds { + ui.ctx().request_repaint_after(std::time::Duration::from_secs(1)); + } + + // Amount selection step number depends on purpose and advanced options + let step_num = match (self.asset_lock_purpose, self.show_advanced_options) { + (Some(AssetLockPurpose::TopUp), true) => "3", + (Some(AssetLockPurpose::TopUp), false) => "2", + (Some(AssetLockPurpose::Registration), true) => "2", + _ => "1", + }; + ui.heading(RichText::new(format!("{}. Select how much you would like to transfer?", step_num)).color(DashColors::text_primary(dark_mode))); + ui.add_space(10.0); + + // Show amount input using the component + let amount_response = self.amount_input.as_mut().map(|ai| ai.show(ui)); + ui.add_space(20.0); + + // Step 3: QR Code and address + let amount_valid = amount_response + .as_ref() + .and_then(|r| r.inner.parsed_amount.as_ref()) + .map(|a| a.value() > 0) + .unwrap_or(false); + if amount_valid { + let layout_action = ui.with_layout( + egui::Layout::top_down(egui::Align::Min).with_cross_align(egui::Align::Center), + |ui| { + if let Err(e) = self.render_qr_code(ui) { + self.error_message = Some(e); + } + + ui.add_space(20.0); + + if let Some(error_message) = self.error_message.as_ref() { + ui.colored_label(egui::Color32::DARK_RED, error_message); + ui.add_space(20.0); + } + + match step { + WalletFundedScreenStep::WaitingOnFunds => { + ui.heading(RichText::new("Waiting for funds...").color(DashColors::text_primary(dark_mode))); + AppAction::None + } + WalletFundedScreenStep::FundsReceived => { + ui.heading(RichText::new("Funds received! Creating asset lock...").color(DashColors::text_primary(dark_mode))); + + // Trigger asset lock creation - get credits from the amount input + let credits = self.amount_input + .as_ref() + .and_then(|ai| ai.current_value()) + .map(|a| a.value()); + if let Some(credits) = credits { + // Transition to WaitingForAssetLock BEFORE dispatching to prevent duplicate dispatches + { + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::WaitingForAssetLock; + } + + match self.asset_lock_purpose { + Some(AssetLockPurpose::Registration) => { + AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::CreateRegistrationAssetLock(self.wallet.clone(), credits, self.identity_index) + )) + } + Some(AssetLockPurpose::TopUp) => { + if let Some(identity) = &self.selected_identity { + if let Some(identity_index) = identity.wallet_index { + AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::CreateTopUpAssetLock(self.wallet.clone(), credits, identity_index, self.top_up_index) + )) + } else { + self.error_message = Some("Selected identity has no wallet index".to_string()); + AppAction::None + } + } else { + self.error_message = Some("No identity selected for top-up".to_string()); + AppAction::None + } + } + None => { + self.error_message = Some("No purpose selected".to_string()); + AppAction::None + } + } + } else { + self.error_message = Some("No amount specified".to_string()); + AppAction::None + } + } + WalletFundedScreenStep::WaitingForAssetLock => { + ui.heading(RichText::new("Waiting for Core Chain to produce proof of asset lock...").color(DashColors::text_primary(dark_mode))); + AppAction::None + } + WalletFundedScreenStep::Success => { + // Success screen will be shown below + AppAction::None + } + _ => AppAction::None + } + } + ); + + inner_action |= layout_action.inner; + } + } else { + // Wallet needs to be unlocked + } + }); + + // Display messages + if let Some((message, message_type, timestamp)) = &self.message { + let message_color = match message_type { + MessageType::Error => egui::Color32::DARK_RED, + MessageType::Info => DashColors::text_primary(dark_mode), + MessageType::Success => egui::Color32::DARK_GREEN, + }; + + ui.add_space(25.0); + ui.horizontal(|ui| { + ui.add_space(10.0); + + let now = Utc::now(); + let elapsed = now.signed_duration_since(*timestamp); + let remaining = (10 - elapsed.num_seconds()).max(0); + + let full_msg = format!("{} ({}s)", message, remaining); + ui.label(egui::RichText::new(full_msg).color(message_color)); + }); + ui.add_space(2.0); + } + + inner_action + }); + + action + } + + fn display_message(&mut self, message: &str, message_type: MessageType) { + self.message = Some((message.to_string(), message_type, Utc::now())); + } + + fn refresh_on_arrival(&mut self) { + self.is_creating = false; + } + + fn refresh(&mut self) {} + + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + let current_step = *self.step.read().unwrap(); + + match current_step { + WalletFundedScreenStep::WaitingOnFunds => { + if let BackendTaskSuccessResult::CoreItem( + CoreItem::ReceivedAvailableUTXOTransaction(_, outpoints_with_addresses), + ) = result + { + for utxo in outpoints_with_addresses { + let (_, _, address) = &utxo; + if let Some(funding_address) = &self.funding_address + && funding_address == address + { + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::FundsReceived; + self.funding_utxo = Some(utxo); + drop(step); // Release the lock before creating new action + + // Refresh wallet to create the asset lock + self.is_creating = true; + return; + } + } + } + } + WalletFundedScreenStep::FundsReceived => { + // Asset lock creation was triggered + match &result { + BackendTaskSuccessResult::Message(msg) => { + if msg.contains("Asset lock transaction broadcast successfully") { + // Extract TX ID from message + if let Some(tx_id_start) = msg.find("TX ID: ") { + let tx_id = msg[tx_id_start + 7..].trim().to_string(); + self.asset_lock_tx_id = Some(tx_id); + } + + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::Success; + drop(step); + self.display_message( + "Asset lock created successfully!", + MessageType::Success, + ); + } + } + BackendTaskSuccessResult::CoreItem( + CoreItem::ReceivedAvailableUTXOTransaction(tx, _), + ) => { + // This is the asset lock transaction from ZMQ + if tx.special_transaction_payload.is_some() { + self.asset_lock_tx_id = Some(tx.txid().to_string()); + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::Success; + drop(step); + self.display_message( + "Asset lock created successfully!", + MessageType::Success, + ); + } + } + _ => {} + } + } + WalletFundedScreenStep::WaitingForAssetLock => { + match &result { + BackendTaskSuccessResult::Message(msg) => { + if msg.contains("Asset lock transaction broadcast successfully") { + // Extract TX ID from message + if let Some(tx_id_start) = msg.find("TX ID: ") { + let tx_id = msg[tx_id_start + 7..].trim().to_string(); + self.asset_lock_tx_id = Some(tx_id); + } + + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::Success; + drop(step); + self.display_message( + "Asset lock created successfully!", + MessageType::Success, + ); + } + } + BackendTaskSuccessResult::CoreItem( + CoreItem::ReceivedAvailableUTXOTransaction(tx, _), + ) => { + // This is the asset lock transaction from ZMQ + if tx.special_transaction_payload.is_some() { + self.asset_lock_tx_id = Some(tx.txid().to_string()); + let mut step = self.step.write().unwrap(); + *step = WalletFundedScreenStep::Success; + drop(step); + self.display_message( + "Asset lock created successfully!", + MessageType::Success, + ); + } + } + _ => {} + } + } + _ => {} + } + + self.is_creating = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test that DASH amount parsing correctly converts to credits + #[test] + fn test_dash_to_credits_conversion() { + // 1 DASH = 100_000_000_000 credits (10^11) + // Test various DASH amounts + + // 0.5 DASH = 50_000_000_000 credits + let dash_amount = 0.5f64; + let credits = (dash_amount * 100_000_000_000.0) as u64; + assert_eq!(credits, 50_000_000_000); + + // 1 DASH = 100_000_000_000 credits + let dash_amount = 1.0f64; + let credits = (dash_amount * 100_000_000_000.0) as u64; + assert_eq!(credits, 100_000_000_000); + + // 0.1 DASH = 10_000_000_000 credits + let dash_amount = 0.1f64; + let credits = (dash_amount * 100_000_000_000.0) as u64; + assert_eq!(credits, 10_000_000_000); + + // 10 DASH = 1_000_000_000_000 credits + let dash_amount = 10.0f64; + let credits = (dash_amount * 100_000_000_000.0) as u64; + assert_eq!(credits, 1_000_000_000_000); + } + + /// Test that invalid amounts are handled correctly + #[test] + fn test_invalid_amount_parsing() { + // Test that negative amounts don't produce valid credits + let dash_amount = -1.0f64; + let is_valid = dash_amount >= 0.0; + assert!(!is_valid); + + // Test that parsing invalid strings returns None + let invalid_input = "not_a_number"; + let parsed: Result = invalid_input.parse(); + assert!(parsed.is_err()); + + // Test empty string + let empty_input = ""; + let parsed: Result = empty_input.parse(); + assert!(parsed.is_err()); + } + + /// Test step number calculation based on purpose and advanced options + #[test] + fn test_step_number_calculation() { + // Helper function that mimics the step number calculation in the UI + fn calculate_step_num( + purpose: Option, + show_advanced_options: bool, + ) -> &'static str { + match (purpose, show_advanced_options) { + (Some(AssetLockPurpose::TopUp), true) => "3", + (Some(AssetLockPurpose::TopUp), false) => "2", + (Some(AssetLockPurpose::Registration), true) => "2", + _ => "1", + } + } + + // Top Up with advanced options: step 3 (1: identity selection, 2: index selection, 3: amount) + assert_eq!(calculate_step_num(Some(AssetLockPurpose::TopUp), true), "3"); + + // Top Up without advanced options: step 2 (1: identity selection, 2: amount) + assert_eq!( + calculate_step_num(Some(AssetLockPurpose::TopUp), false), + "2" + ); + + // Registration with advanced options: step 2 (1: index selection, 2: amount) + assert_eq!( + calculate_step_num(Some(AssetLockPurpose::Registration), true), + "2" + ); + + // Registration without advanced options: step 1 (1: amount) + assert_eq!( + calculate_step_num(Some(AssetLockPurpose::Registration), false), + "1" + ); + + // No purpose selected: step 1 + assert_eq!(calculate_step_num(None, false), "1"); + assert_eq!(calculate_step_num(None, true), "1"); + } + + /// Test next unused identity index calculation + #[test] + fn test_next_unused_identity_index() { + use std::collections::BTreeMap; + + // Helper function that mimics the next index calculation + fn calculate_next_identity_index(used_indices: &BTreeMap) -> u32 { + used_indices + .keys() + .copied() + .max() + .map(|max| max + 1) + .unwrap_or(0) + } + + // No used indices -> next is 0 + let empty: BTreeMap = BTreeMap::new(); + assert_eq!(calculate_next_identity_index(&empty), 0); + + // Used indices: 0 -> next is 1 + let mut used = BTreeMap::new(); + used.insert(0, ()); + assert_eq!(calculate_next_identity_index(&used), 1); + + // Used indices: 0, 1, 2 -> next is 3 + used.insert(1, ()); + used.insert(2, ()); + assert_eq!(calculate_next_identity_index(&used), 3); + + // Non-contiguous indices: 0, 5 -> next is 6 (not 1) + let mut non_contiguous = BTreeMap::new(); + non_contiguous.insert(0, ()); + non_contiguous.insert(5, ()); + assert_eq!(calculate_next_identity_index(&non_contiguous), 6); + } + + /// Test next unused top_up index calculation + #[test] + fn test_next_unused_top_up_index() { + use std::collections::BTreeMap; + + // Helper function that mimics the next top_up index calculation + fn calculate_next_top_up_index(used_indices: &BTreeMap) -> u32 { + used_indices + .keys() + .max() + .cloned() + .map(|i| i + 1) + .unwrap_or(0) + } + + // No used indices -> next is 0 + let empty: BTreeMap = BTreeMap::new(); + assert_eq!(calculate_next_top_up_index(&empty), 0); + + // Used indices: 0 -> next is 1 + let mut used = BTreeMap::new(); + used.insert(0, ()); + assert_eq!(calculate_next_top_up_index(&used), 1); + + // Used indices: 0, 1, 2 -> next is 3 + used.insert(1, ()); + used.insert(2, ()); + assert_eq!(calculate_next_top_up_index(&used), 3); + } + + /// Test AssetLockPurpose enum values + #[test] + fn test_asset_lock_purpose_values() { + let registration = AssetLockPurpose::Registration; + let top_up = AssetLockPurpose::TopUp; + + // Test equality + assert_eq!(registration, AssetLockPurpose::Registration); + assert_eq!(top_up, AssetLockPurpose::TopUp); + assert_ne!(registration, top_up); + + // Test copy semantics + let registration_copy = registration; + assert_eq!(registration, registration_copy); + } + + /// Test TX ID extraction from success message + #[test] + fn test_tx_id_extraction() { + let msg = "Asset lock transaction broadcast successfully. TX ID: abc123def456"; + + // Extract TX ID from message + let tx_id = msg + .find("TX ID: ") + .map(|tx_id_start| msg[tx_id_start + 7..].trim().to_string()); + + assert_eq!(tx_id, Some("abc123def456".to_string())); + + // Test message without TX ID + let msg_without_id = "Some other message"; + let no_tx_id = msg_without_id + .find("TX ID: ") + .map(|tx_id_start| msg_without_id[tx_id_start + 7..].trim().to_string()); + + assert_eq!(no_tx_id, None); + } + + /// Test MAX_IDENTITY_INDEX constant + #[test] + fn test_max_identity_index_constant() { + assert_eq!(MAX_IDENTITY_INDEX, 30); + + // Verify reasonable range for iteration + let indices: Vec = (0..MAX_IDENTITY_INDEX).collect(); + assert_eq!(indices.len(), 30); + assert_eq!(indices[0], 0); + assert_eq!(indices[29], 29); + } + + /// Test default amount values + #[test] + fn test_default_amount_values() { + // Default amount is 0.5 DASH + let default_amount_input = "0.5"; + let parsed_amount: f64 = default_amount_input.parse().unwrap(); + assert_eq!(parsed_amount, 0.5); + + // Default credits for 0.5 DASH + let default_credits = 50_000_000_000u64; + let calculated_credits = (parsed_amount * 100_000_000_000.0) as u64; + assert_eq!(calculated_credits, default_credits); + } +} diff --git a/src/ui/wallets/mod.rs b/src/ui/wallets/mod.rs index 988edcf69..61eb3b228 100644 --- a/src/ui/wallets/mod.rs +++ b/src/ui/wallets/mod.rs @@ -1,5 +1,7 @@ pub mod account_summary; pub mod add_new_wallet_screen; +pub mod asset_lock_detail_screen; +pub mod create_asset_lock_screen; pub mod import_mnemonic_screen; pub mod send_screen; pub mod single_key_send_screen; diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 719b6c40f..b1084a90a 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1227,9 +1227,14 @@ impl WalletsBalancesScreen { .show(ui, |ui| { let dark_mode = ui.ctx().style().visuals.dark_mode; ui.horizontal(|ui| { - ui.heading(RichText::new("Unused Asset Locks").color(DashColors::text_primary(dark_mode))); + ui.heading(RichText::new("Asset Locks").color(DashColors::text_primary(dark_mode))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("Search for Unused Asset Locks").on_hover_text("Scan Core wallet for untracked asset locks").clicked() { + if ui.button("Create Asset Lock").clicked() { + app_action = AppAction::AddScreen( + ScreenType::CreateAssetLock(arc_wallet.clone()).create_screen(&self.app_context) + ); + } + if ui.button("Search for Unused").on_hover_text("Scan Core wallet for untracked asset locks").clicked() { recover_asset_locks_clicked = true; } }); @@ -1277,7 +1282,7 @@ impl WalletsBalancesScreen { .column(Column::initial(100.0)) // Amount (Duffs) .column(Column::initial(100.0)) // InstantLock status .column(Column::initial(100.0)) // Usable status - .column(Column::initial(150.0)) // Actions + .column(Column::initial(200.0)) // Actions .header(30.0, |mut header| { header.col(|ui| { ui.label("Transaction ID"); @@ -1299,7 +1304,7 @@ impl WalletsBalancesScreen { }); }) .body(|mut body| { - for (idx, (tx, address, amount, islock, proof)) in wallet.unused_asset_locks.iter().enumerate() { + for (index, (tx, address, amount, islock, proof)) in wallet.unused_asset_locks.iter().enumerate() { body.row(25.0, |mut row| { row.col(|ui| { ui.label(tx.txid().to_string()); @@ -1319,13 +1324,18 @@ impl WalletsBalancesScreen { ui.label(status); }); row.col(|ui| { - if proof.is_some() { - if ui.small_button("Fund Platform Addr").on_hover_text("Fund a Platform address with this asset lock").clicked() { - open_fund_dialog_for_idx = Some((idx, platform_addresses.clone())); - } - } else { - ui.label(RichText::new("Not ready").color(Color32::GRAY).size(11.0)); + if ui.small_button("View").on_hover_text("View full asset lock details").clicked() { + app_action = AppAction::AddScreen( + ScreenType::AssetLockDetail( + wallet.seed_hash(), + index + ).create_screen(&self.app_context) + ); } + if proof.is_some() + && ui.small_button("Fund").on_hover_text("Fund a Platform address with this asset lock").clicked() { + open_fund_dialog_for_idx = Some((index, platform_addresses.clone())); + } }); }); } diff --git a/tests/kittest/create_asset_lock_screen.rs b/tests/kittest/create_asset_lock_screen.rs new file mode 100644 index 000000000..be1d984f2 --- /dev/null +++ b/tests/kittest/create_asset_lock_screen.rs @@ -0,0 +1,57 @@ +use egui_kittest::Harness; + +/// Test that the create asset lock screen can be rendered +#[test] +fn test_create_asset_lock_screen_renders() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + harness.set_size(egui::vec2(1024.0, 768.0)); + harness.run_steps(10); +} + +/// Test that the create asset lock screen handles window resize gracefully +#[test] +fn test_create_asset_lock_screen_resize() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + // Test various window sizes + let sizes = [ + egui::vec2(800.0, 600.0), + egui::vec2(1200.0, 900.0), + egui::vec2(640.0, 480.0), + egui::vec2(1920.0, 1080.0), + ]; + + for size in sizes { + harness.set_size(size); + harness.run_steps(5); + } +} + +/// Test that the app remains responsive with multiple frame batches +#[test] +fn test_create_asset_lock_screen_frame_stability() { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(200).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + + harness.set_size(egui::vec2(1024.0, 768.0)); + + // Run multiple batches to test stability + for _ in 0..10 { + harness.run_steps(10); + } +} diff --git a/tests/kittest/main.rs b/tests/kittest/main.rs index e5cc0a94f..1562e7344 100644 --- a/tests/kittest/main.rs +++ b/tests/kittest/main.rs @@ -1,3 +1,4 @@ +mod create_asset_lock_screen; mod identities_screen; mod network_chooser; mod startup; From 79bfeca6ee4adf74b237b86d489d7879c5b7609d Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:08:15 +0700 Subject: [PATCH 050/106] fix: dev mode wouldnt toggle in left panel when unchecked in settings if config load failed (#488) --- src/ui/network_chooser_screen.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 3c4d5385b..8c0d54de9 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -851,14 +851,8 @@ impl NetworkChooserScreen { if StyledCheckbox::new(&mut self.developer_mode, "Developer mode") .show(ui) .clicked() - && let Ok(mut config) = Config::load() { - config.developer_mode = Some(self.developer_mode); - if let Err(e) = config.save() { - eprintln!("Failed to save config: {e}"); - } - - // Update all contexts + // Always update all contexts first to keep UI in sync self.mainnet_app_context .enable_developer_mode(self.developer_mode); if let Some(ref ctx) = self.testnet_app_context { @@ -871,6 +865,14 @@ impl NetworkChooserScreen { ctx.enable_developer_mode(self.developer_mode); } + // Persist to config file (non-blocking for UI) + if let Ok(mut config) = Config::load() { + config.developer_mode = Some(self.developer_mode); + if let Err(e) = config.save() { + eprintln!("Failed to save config: {e}"); + } + } + // TODO: When developer mode is disabled, stop SPV and switch to RPC. // Remove this block once SPV is production-ready. if !self.developer_mode { From ffe5de14271ba725b2aa2441f569e2e20376ecab Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:08:35 +0700 Subject: [PATCH 051/106] fix: force Core RPC mode on app start if not in dev mode (#489) --- src/context.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/context.rs b/src/context.rs index 475999600..da1c0aea4 100644 --- a/src/context.rs +++ b/src/context.rs @@ -220,13 +220,20 @@ impl AppContext { let use_local_spv_node = db.get_use_local_spv_node().unwrap_or(false); spv_manager.set_use_local_node(use_local_spv_node); - // Load the core backend mode from settings, defaulting to SPV if not set + // Load the core backend mode from settings, defaulting to RPC if not set let saved_core_backend_mode = db .get_settings() .ok() .flatten() .map(|s| s.7) // core_backend_mode is the 8th element (index 7) - .unwrap_or(CoreBackendMode::Spv.as_u8()); + .unwrap_or(CoreBackendMode::Rpc.as_u8()); + + // If not in developer mode, force RPC mode (SPV is gated behind dev mode) + let saved_core_backend_mode = if developer_mode_enabled { + saved_core_backend_mode + } else { + CoreBackendMode::Rpc.as_u8() + }; // Load saved wallet selection, validating that the wallets still exist let (saved_wallet_hash, saved_single_key_hash) = From 8480fe7a57b57b42d40ef56a7daa16360d68638b Mon Sep 17 00:00:00 2001 From: thephez Date: Fri, 23 Jan 2026 02:14:43 -0500 Subject: [PATCH 052/106] fix: display WIF format for manually added private keys (#496) * fix: display WIF format for manually added private keys Externally loaded identities with manually added private keys now show both WIF and hex formats, matching the display for wallet-derived keys. Co-Authored-By: Claude Opus 4.5 * clippy * fmt --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: pauldelucia --- src/ui/identities/keys/key_info_screen.rs | 15 ++++++++++++++ src/ui/identities/withdraw_screen.rs | 24 +++++++++++------------ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index 586c4c910..0fea93381 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -305,6 +305,21 @@ impl ScreenLike for KeyInfoScreen { .num_columns(2) .spacing([10.0, 10.0]) .show(ui, |ui| { + if let Ok(secret_key) = SecretKey::from_slice(clear) { + let private_key = + PrivateKey::new(secret_key, self.app_context.network); + ui.label( + RichText::new("Private Key (WIF):") + .strong() + .color(ui.visuals().text_color()), + ); + ui.label( + RichText::new(private_key.to_wif()) + .color(ui.visuals().text_color()), + ); + ui.end_row(); + } + ui.label( RichText::new("Private Key (Hex):") .strong() diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index 2ad49338c..7cc8bb8c4 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -171,20 +171,20 @@ impl WithdrawalScreen { }); // In dev mode with OWNER key, show hint about auto-selected payout address - if self.app_context.is_developer_mode() && is_owner_key { - if let Some(payout_address) = self + if self.app_context.is_developer_mode() + && is_owner_key + && let Some(payout_address) = self .identity .masternode_payout_address(self.app_context.network) - { - ui.label( - RichText::new(format!( - "Leave empty to use masternode payout address: {}", - payout_address - )) - .italics() - .color(Color32::GRAY), - ); - } + { + ui.label( + RichText::new(format!( + "Leave empty to use masternode payout address: {}", + payout_address + )) + .italics() + .color(Color32::GRAY), + ); } } else { ui.label(format!( From 84ab000343578f8d0e34cfb2a358b3b806f08d80 Mon Sep 17 00:00:00 2001 From: thephez Date: Fri, 23 Jan 2026 03:24:00 -0500 Subject: [PATCH 053/106] fix: security level validation for add key screen (#494) * fix: auto-set security level based on key purpose in add key screen When selecting a key purpose, automatically set the appropriate security level and restrict available options: ENCRYPTION/DECRYPTION only allow MEDIUM, TRANSFER only allows CRITICAL, and AUTHENTICATION allows CRITICAL/HIGH/MEDIUM. Co-Authored-By: Claude Opus 4.5 * fix: disable security level dropdown when only one option is valid Visually indicate to the user that the security level is fixed by the protocol for certain key purposes (ENCRYPTION, DECRYPTION, TRANSFER). Adds a hover tooltip explaining why the dropdown is disabled. Co-Authored-By: Claude Opus 4.5 * fix: remove redundant `contract_bounds_enabled` check for `has_multiple_security_levels` bool * fmt --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: pauldelucia --- src/ui/identities/keys/add_key_screen.rs | 118 ++++++++++++++++------- 1 file changed, 84 insertions(+), 34 deletions(-) diff --git a/src/ui/identities/keys/add_key_screen.rs b/src/ui/identities/keys/add_key_screen.rs index 722d09aaa..8a6927a25 100644 --- a/src/ui/identities/keys/add_key_screen.rs +++ b/src/ui/identities/keys/add_key_screen.rs @@ -404,6 +404,7 @@ impl ScreenLike for AddKeyScreen { .show(ui, |ui| { // Purpose ui.label("Purpose:"); + let prev_purpose = self.purpose; egui::ComboBox::from_id_salt("purpose_selector") .selected_text(format!("{:?}", self.purpose)) .show_ui(ui, |ui| { @@ -443,44 +444,93 @@ impl ScreenLike for AddKeyScreen { ); } }); + + // Auto-set security level when purpose changes + if self.purpose != prev_purpose { + match self.purpose { + Purpose::ENCRYPTION | Purpose::DECRYPTION => { + self.security_level = SecurityLevel::MEDIUM; + } + Purpose::TRANSFER => { + self.security_level = SecurityLevel::CRITICAL; + } + Purpose::AUTHENTICATION => { + // AUTHENTICATION allows multiple levels, keep current if valid + // otherwise default to CRITICAL + if self.security_level != SecurityLevel::CRITICAL + && self.security_level != SecurityLevel::HIGH + && self.security_level != SecurityLevel::MEDIUM + { + self.security_level = SecurityLevel::CRITICAL; + } + } + _ => {} + } + } ui.end_row(); // Security Level ui.label("Security Level:"); - egui::ComboBox::from_id_salt("security_level_selector") - .selected_text(format!("{:?}", self.security_level)) - .show_ui(ui, |ui| { - if self.enable_contract_bounds { - // When contract bounds are enabled, only allow MEDIUM - ui.selectable_value( - &mut self.security_level, - SecurityLevel::MEDIUM, - "MEDIUM", - ); - } else if self.purpose == Purpose::AUTHENTICATION { - ui.selectable_value( - &mut self.security_level, - SecurityLevel::CRITICAL, - "CRITICAL", - ); - ui.selectable_value( - &mut self.security_level, - SecurityLevel::HIGH, - "HIGH", - ); - ui.selectable_value( - &mut self.security_level, - SecurityLevel::MEDIUM, - "MEDIUM", - ); - } else { - ui.selectable_value( - &mut self.security_level, - SecurityLevel::CRITICAL, - "CRITICAL", - ); - } - }); + // Only AUTHENTICATION has multiple security level options + let has_multiple_security_levels = self.purpose == Purpose::AUTHENTICATION; + let inner_response = ui.add_enabled_ui(has_multiple_security_levels, |ui| { + egui::ComboBox::from_id_salt("security_level_selector") + .selected_text(format!("{:?}", self.security_level)) + .show_ui(ui, |ui| { + if self.enable_contract_bounds { + // When contract bounds are enabled, only allow MEDIUM + ui.selectable_value( + &mut self.security_level, + SecurityLevel::MEDIUM, + "MEDIUM", + ); + } else if self.purpose == Purpose::AUTHENTICATION { + ui.selectable_value( + &mut self.security_level, + SecurityLevel::CRITICAL, + "CRITICAL", + ); + ui.selectable_value( + &mut self.security_level, + SecurityLevel::HIGH, + "HIGH", + ); + ui.selectable_value( + &mut self.security_level, + SecurityLevel::MEDIUM, + "MEDIUM", + ); + } else if self.purpose == Purpose::ENCRYPTION + || self.purpose == Purpose::DECRYPTION + { + // ENCRYPTION and DECRYPTION only allow MEDIUM + ui.selectable_value( + &mut self.security_level, + SecurityLevel::MEDIUM, + "MEDIUM", + ); + } else { + // TRANSFER only allows CRITICAL + ui.selectable_value( + &mut self.security_level, + SecurityLevel::CRITICAL, + "CRITICAL", + ); + } + }) + }); + if !has_multiple_security_levels { + // Use interact with hover sense to detect hover on disabled widget + let hover_response = ui.interact( + inner_response.response.rect, + egui::Id::new("security_level_tooltip"), + egui::Sense::hover(), + ); + hover_response.on_hover_text(format!( + "{:?} purpose requires {:?} security level", + self.purpose, self.security_level + )); + } ui.end_row(); // Key Type From 2194fca01c897ac269e4b6107be85f1bac957276 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:40:27 +0700 Subject: [PATCH 054/106] fix: identity registration default key was using wrong `ContractBounds` (#487) * fix: identity registration default key was using wrong ContractBounds * feat: add tests * fmt * doc: update key descriptions --- .../identities/add_new_identity_screen/mod.rs | 289 +++++++++++++++--- 1 file changed, 245 insertions(+), 44 deletions(-) diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index 8258164aa..db7301789 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -205,51 +205,9 @@ impl AddNewIdentityScreen { let app_context = &self.app_context; let identity_id_number = self.identity_id_number; - // Create DashPay contract bounds for ENCRYPTION/DECRYPTION keys + // Get default key configuration let dashpay_contract_id = app_context.dashpay_contract.id(); - let dashpay_bounds = Some(ContractBounds::SingleContract { - id: dashpay_contract_id, - }); - - // Default keys per DIP-11: - // - AUTHENTICATION CRITICAL (general platform operations) - // - AUTHENTICATION HIGH (general platform operations) - // - TRANSFER CRITICAL (credit transfers) - // - ENCRYPTION MEDIUM with DashPay bounds (for contact requests per DIP-15) - // - DECRYPTION MEDIUM with DashPay bounds (for contact requests per DIP-15) - // Note: Platform enforces MEDIUM security level for ENCRYPTION/DECRYPTION keys - let default_keys: Vec<(KeyType, Purpose, SecurityLevel, Option)> = vec![ - ( - KeyType::ECDSA_HASH160, - Purpose::AUTHENTICATION, - SecurityLevel::CRITICAL, - None, - ), - ( - KeyType::ECDSA_HASH160, - Purpose::AUTHENTICATION, - SecurityLevel::HIGH, - None, - ), - ( - KeyType::ECDSA_HASH160, - Purpose::TRANSFER, - SecurityLevel::CRITICAL, - None, - ), - ( - KeyType::ECDSA_SECP256K1, // ECDH requires secp256k1 - Purpose::ENCRYPTION, - SecurityLevel::MEDIUM, // Platform enforces MEDIUM for ENCRYPTION - dashpay_bounds.clone(), - ), - ( - KeyType::ECDSA_SECP256K1, // ECDH requires secp256k1 - Purpose::DECRYPTION, - SecurityLevel::MEDIUM, - dashpay_bounds, - ), - ]; + let default_keys = default_identity_key_specs(dashpay_contract_id); let mut wallet = wallet_lock.write().expect("wallet lock failed"); let master_key = wallet.identity_authentication_ecdsa_private_key( @@ -1353,3 +1311,246 @@ impl ScreenLike for AddNewIdentityScreen { action } } + +/// Returns the default key specifications for a new identity. +/// +/// The returned vector contains tuples of (KeyType, Purpose, SecurityLevel, Option): +/// - AUTHENTICATION CRITICAL: General platform operations (actions should require PIN) +/// - AUTHENTICATION HIGH: General platform operations +/// - TRANSFER CRITICAL: Credit transfers +/// - ENCRYPTION MEDIUM with DashPay contactRequest bounds: For contact requests per DIP-15 +/// - DECRYPTION MEDIUM with DashPay contactRequest bounds: For contact requests per DIP-15 +/// +/// Note: ENCRYPTION and DECRYPTION keys must use `SingleContractDocumentType` with "contactRequest" +/// document type, not just `SingleContract`. The platform requires encryption key bounds to specify +/// the exact document type for proper validation. +pub fn default_identity_key_specs( + dashpay_contract_id: Identifier, +) -> Vec<(KeyType, Purpose, SecurityLevel, Option)> { + let dashpay_bounds = Some(ContractBounds::SingleContractDocumentType { + id: dashpay_contract_id, + document_type_name: "contactRequest".to_string(), + }); + + vec![ + ( + KeyType::ECDSA_HASH160, + Purpose::AUTHENTICATION, + SecurityLevel::CRITICAL, + None, + ), + ( + KeyType::ECDSA_HASH160, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + None, + ), + ( + KeyType::ECDSA_HASH160, + Purpose::TRANSFER, + SecurityLevel::CRITICAL, + None, + ), + ( + KeyType::ECDSA_SECP256K1, // ECDH requires secp256k1 + Purpose::ENCRYPTION, + SecurityLevel::MEDIUM, // Platform enforces MEDIUM for ENCRYPTION + dashpay_bounds.clone(), + ), + ( + KeyType::ECDSA_SECP256K1, // ECDH requires secp256k1 + Purpose::DECRYPTION, + SecurityLevel::MEDIUM, + dashpay_bounds, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Test that the default identity keys include the correct number of keys + #[test] + fn test_default_identity_keys_count() { + let contract_id = Identifier::random(); + let keys = default_identity_key_specs(contract_id); + assert_eq!(keys.len(), 5, "Should have 5 default keys"); + } + + /// Test that AUTHENTICATION keys have correct configuration + #[test] + fn test_authentication_keys_configuration() { + let contract_id = Identifier::random(); + let keys = default_identity_key_specs(contract_id); + + // First key: AUTHENTICATION CRITICAL + let (key_type, purpose, security_level, contract_bounds) = &keys[0]; + assert_eq!(*key_type, KeyType::ECDSA_HASH160); + assert_eq!(*purpose, Purpose::AUTHENTICATION); + assert_eq!(*security_level, SecurityLevel::CRITICAL); + assert!( + contract_bounds.is_none(), + "AUTHENTICATION keys should have no contract bounds" + ); + + // Second key: AUTHENTICATION HIGH + let (key_type, purpose, security_level, contract_bounds) = &keys[1]; + assert_eq!(*key_type, KeyType::ECDSA_HASH160); + assert_eq!(*purpose, Purpose::AUTHENTICATION); + assert_eq!(*security_level, SecurityLevel::HIGH); + assert!( + contract_bounds.is_none(), + "AUTHENTICATION keys should have no contract bounds" + ); + } + + /// Test that TRANSFER key has correct configuration + #[test] + fn test_transfer_key_configuration() { + let contract_id = Identifier::random(); + let keys = default_identity_key_specs(contract_id); + + // Third key: TRANSFER CRITICAL + let (key_type, purpose, security_level, contract_bounds) = &keys[2]; + assert_eq!(*key_type, KeyType::ECDSA_HASH160); + assert_eq!(*purpose, Purpose::TRANSFER); + assert_eq!(*security_level, SecurityLevel::CRITICAL); + assert!( + contract_bounds.is_none(), + "TRANSFER keys should have no contract bounds" + ); + } + + /// Test that ENCRYPTION key uses SingleContractDocumentType with contactRequest + /// + /// This is critical for DashPay compatibility - the platform requires encryption keys + /// to specify the exact document type (contactRequest) not just the contract ID. + /// Using SingleContract instead of SingleContractDocumentType will cause: + /// "key bounds expected but not present error: expected encryption key bounds for encryption" + #[test] + fn test_encryption_key_uses_single_contract_document_type() { + let contract_id = Identifier::random(); + let keys = default_identity_key_specs(contract_id); + + // Fourth key: ENCRYPTION MEDIUM + let (key_type, purpose, security_level, contract_bounds) = &keys[3]; + assert_eq!( + *key_type, + KeyType::ECDSA_SECP256K1, + "ENCRYPTION key must use ECDSA_SECP256K1 for ECDH" + ); + assert_eq!(*purpose, Purpose::ENCRYPTION); + assert_eq!( + *security_level, + SecurityLevel::MEDIUM, + "Platform enforces MEDIUM for ENCRYPTION" + ); + + // Verify contract bounds uses SingleContractDocumentType, NOT SingleContract + match contract_bounds { + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name, + }) => { + assert_eq!( + *id, contract_id, + "Contract ID should match DashPay contract" + ); + assert_eq!( + document_type_name, "contactRequest", + "Document type must be 'contactRequest' for DashPay" + ); + } + Some(ContractBounds::SingleContract { .. }) => { + panic!( + "ENCRYPTION key must use SingleContractDocumentType, not SingleContract. \ + Using SingleContract causes 'key bounds expected but not present' error." + ); + } + None => { + panic!("ENCRYPTION key must have DashPay contract bounds for contactRequest"); + } + } + } + + /// Test that DECRYPTION key uses SingleContractDocumentType with contactRequest + /// + /// This is critical for DashPay compatibility - the platform requires decryption keys + /// to specify the exact document type (contactRequest) not just the contract ID. + #[test] + fn test_decryption_key_uses_single_contract_document_type() { + let contract_id = Identifier::random(); + let keys = default_identity_key_specs(contract_id); + + // Fifth key: DECRYPTION MEDIUM + let (key_type, purpose, security_level, contract_bounds) = &keys[4]; + assert_eq!( + *key_type, + KeyType::ECDSA_SECP256K1, + "DECRYPTION key must use ECDSA_SECP256K1 for ECDH" + ); + assert_eq!(*purpose, Purpose::DECRYPTION); + assert_eq!(*security_level, SecurityLevel::MEDIUM); + + // Verify contract bounds uses SingleContractDocumentType, NOT SingleContract + match contract_bounds { + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name, + }) => { + assert_eq!( + *id, contract_id, + "Contract ID should match DashPay contract" + ); + assert_eq!( + document_type_name, "contactRequest", + "Document type must be 'contactRequest' for DashPay" + ); + } + Some(ContractBounds::SingleContract { .. }) => { + panic!( + "DECRYPTION key must use SingleContractDocumentType, not SingleContract. \ + Using SingleContract causes 'key bounds expected but not present' error." + ); + } + None => { + panic!("DECRYPTION key must have DashPay contract bounds for contactRequest"); + } + } + } + + /// Test that encryption and decryption keys have matching contract bounds + #[test] + fn test_encryption_decryption_keys_have_matching_bounds() { + let contract_id = Identifier::random(); + let keys = default_identity_key_specs(contract_id); + + let encryption_bounds = &keys[3].3; + let decryption_bounds = &keys[4].3; + + assert_eq!( + encryption_bounds, decryption_bounds, + "ENCRYPTION and DECRYPTION keys should have identical contract bounds" + ); + } + + /// Test that the contract ID is correctly propagated to key bounds + #[test] + fn test_contract_id_propagation() { + let contract_id = Identifier::random(); + let keys = default_identity_key_specs(contract_id); + + for (i, (_, purpose, _, contract_bounds)) in keys.iter().enumerate() { + if (*purpose == Purpose::ENCRYPTION || *purpose == Purpose::DECRYPTION) + && let Some(ContractBounds::SingleContractDocumentType { id, .. }) = contract_bounds + { + assert_eq!( + *id, contract_id, + "Key {} contract bounds should use the provided contract ID", + i + ); + } + } + } +} From 390bf01383e3b6e5e2515d2f4d0b52f62c2ac277 Mon Sep 17 00:00:00 2001 From: pauldelucia Date: Fri, 23 Jan 2026 17:54:04 +0700 Subject: [PATCH 055/106] feat: add coderabbit configuration file --- .coderabbit.yaml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..93ce469db --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + enabled: true + base_branches: + - master + - v1.0-dev From ea77d2c3eeea738ccaf4e0b0a5199b220b9b816f Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Fri, 23 Jan 2026 18:12:56 +0700 Subject: [PATCH 056/106] fix: document actions were auto selecting master keys but shouldnt (#493) --- .../document_action_screen.rs | 9 ++++- .../identities/register_dpns_name_screen.rs | 33 +++++++++++++++---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index fab65ae25..33cce0944 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -248,12 +248,19 @@ impl DocumentActionScreen { if response.changed() { if let Some(identity) = &self.selected_identity { // Auto-select a suitable key for document actions + // Note: MASTER keys cannot be used for document operations, + // only MEDIUM, HIGH, or CRITICAL security levels are allowed use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; self.selected_key = identity .identity .get_first_public_key_matching( Purpose::AUTHENTICATION, - SecurityLevel::full_range().into(), + [ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ] + .into(), KeyType::all_key_types().into(), false, ) diff --git a/src/ui/identities/register_dpns_name_screen.rs b/src/ui/identities/register_dpns_name_screen.rs index ae3db6fa3..86356b2b0 100644 --- a/src/ui/identities/register_dpns_name_screen.rs +++ b/src/ui/identities/register_dpns_name_screen.rs @@ -77,13 +77,20 @@ impl RegisterDpnsNameScreen { }; // Auto-select a suitable key for DPNS registration + // Note: MASTER keys cannot be used for document operations, + // only MEDIUM, HIGH, or CRITICAL security levels are allowed let selected_key = selected_qualified_identity.as_ref().and_then(|identity| { - use dash_sdk::dpp::identity::KeyType; + use dash_sdk::dpp::identity::{KeyType, SecurityLevel}; identity .identity .get_first_public_key_matching( Purpose::AUTHENTICATION, - dash_sdk::dpp::identity::SecurityLevel::full_range().into(), + [ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ] + .into(), KeyType::all_key_types().into(), false, ) @@ -133,12 +140,19 @@ impl RegisterDpnsNameScreen { .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); // Auto-select a suitable key for DPNS registration - use dash_sdk::dpp::identity::KeyType; + // Note: MASTER keys cannot be used for document operations, + // only MEDIUM, HIGH, or CRITICAL security levels are allowed + use dash_sdk::dpp::identity::{KeyType, SecurityLevel}; self.selected_key = qi .identity .get_first_public_key_matching( Purpose::AUTHENTICATION, - dash_sdk::dpp::identity::SecurityLevel::full_range().into(), + [ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ] + .into(), KeyType::all_key_types().into(), false, ) @@ -178,12 +192,19 @@ impl RegisterDpnsNameScreen { if response.changed() { if let Some(identity) = &self.selected_qualified_identity { // Auto-select a suitable key for DPNS registration - use dash_sdk::dpp::identity::KeyType; + // Note: MASTER keys cannot be used for document operations, + // only MEDIUM, HIGH, or CRITICAL security levels are allowed + use dash_sdk::dpp::identity::{KeyType, SecurityLevel}; self.selected_key = identity .identity .get_first_public_key_matching( Purpose::AUTHENTICATION, - dash_sdk::dpp::identity::SecurityLevel::full_range().into(), + [ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ] + .into(), KeyType::all_key_types().into(), false, ) From c8b8d7490b25c19ea99d5f84777271569a68e931 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Fri, 23 Jan 2026 18:13:10 +0700 Subject: [PATCH 057/106] fix: top up with QR was validating against wallet max balance (#492) --- .../identities/top_up_identity_screen/mod.rs | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index 59242ef2c..3b1ea0001 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -367,25 +367,33 @@ impl TopUpIdentityScreen { } fn top_up_funding_amount_input(&mut self, ui: &mut egui::Ui) { - // Get max amount from the selected wallet's balance (in Duffs, convert to Credits) - let max_amount_duffs = self - .wallet - .as_ref() - .map(|w| w.read().unwrap().total_balance_duffs()) - .unwrap_or(0); - // Convert Duffs to Credits (1 Duff = 1000 Credits) - let max_amount_credits = max_amount_duffs * 1000; + let funding_method = *self.funding_method.read().unwrap(); + + // Only apply max amount restriction when using wallet balance + // For QR code funding, funds come from external source so no max applies + let (max_amount, show_max_button) = if funding_method == FundingMethod::UseWalletBalance { + let max_amount_duffs = self + .wallet + .as_ref() + .map(|w| w.read().unwrap().total_balance_duffs()) + .unwrap_or(0); + // Convert Duffs to Credits (1 Duff = 1000 Credits) + (Some(max_amount_duffs * 1000), true) + } else { + (None, false) + }; // Lazy initialization of the AmountInput component let amount_input = self.funding_amount_input.get_or_insert_with(|| { AmountInput::new(Amount::new_dash(0.0)) .with_label("Amount:") - .with_max_button(true) - .with_max_amount(Some(max_amount_credits)) + .with_max_button(show_max_button) + .with_max_amount(max_amount) }); - // Update max amount in case wallet balance changed - amount_input.set_max_amount(Some(max_amount_credits)); + // Update max amount and button visibility in case funding method or wallet balance changed + amount_input.set_max_amount(max_amount); + amount_input.set_show_max_button(show_max_button); let response = amount_input.show(ui); From 691375eb4132488995478499ef7f191be98af8c5 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:52:18 +0700 Subject: [PATCH 058/106] fix: view platform address popup showing wrong address format (#500) * fix: view platform address popup showing wrong address format * fix: use helper --- src/ui/wallets/wallets_screen/mod.rs | 39 +++++++++++++++------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index b1084a90a..bd8653f26 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -23,7 +23,7 @@ use crate::ui::wallets::account_summary::{ }; use crate::ui::{MessageType, RootScreenType, ScreenLike, ScreenType}; use chrono::{DateTime, Utc}; -use dash_sdk::dashcore_rpc::dashcore::Address; +use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; use eframe::egui::{self, ComboBox, Context, Ui}; @@ -146,6 +146,21 @@ struct AddressData { account_index: Option, } +impl AddressData { + /// Returns the address formatted for display. + /// Platform Payment addresses are shown in DIP-18 Bech32m format (e.g., tdashevo...). + fn display_address(&self, network: Network) -> String { + if self.account_category == AccountCategory::PlatformPayment { + use dash_sdk::dpp::address_funds::PlatformAddress; + PlatformAddress::try_from(self.address.clone()) + .map(|pa| pa.to_bech32m_string(network)) + .unwrap_or_else(|_| self.address.to_string()) + } else { + self.address.to_string() + } + } +} + #[derive(Default)] struct SendDialogState { is_open: bool, @@ -939,19 +954,7 @@ impl WalletsBalancesScreen { for data in &address_data { body.row(25.0, |mut row| { row.col(|ui| { - // For Platform Payment addresses, display in DIP-18 Bech32m format - if data.account_category == AccountCategory::PlatformPayment { - use dash_sdk::dpp::address_funds::PlatformAddress; - if let Ok(platform_addr) = - PlatformAddress::try_from(data.address.clone()) - { - ui.label(platform_addr.to_bech32m_string(network)); - } else { - ui.label(data.address.to_string()); - } - } else { - ui.label(data.address.to_string()); - } + ui.label(data.display_address(network)); }); row.col(|ui| { // These address types are used for key derivation/proofs, not holding funds @@ -1049,19 +1052,19 @@ impl WalletsBalancesScreen { }) .unwrap_or(false); + let display_address = data.display_address(network); + if wallet_locked { // Store pending info and show unlock popup self.private_key_dialog.pending_derivation_path = Some(data.derivation_path.clone()); - self.private_key_dialog.pending_address = - Some(data.address.to_string()); + self.private_key_dialog.pending_address = Some(display_address); self.wallet_unlock_popup.open(); } else { match self.derive_private_key_wif(&data.derivation_path) { Ok(key) => { self.private_key_dialog.is_open = true; - self.private_key_dialog.address = - data.address.to_string(); + self.private_key_dialog.address = display_address; self.private_key_dialog.private_key_wif = key; self.private_key_dialog.show_key = false; } From b313405a94b0cb4d455206375d82446177ae6682 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:03:28 +0700 Subject: [PATCH 059/106] feat: update sdk v3.0.1 hotfix.1 (#503) * chore: update dash-sdk to v3.0.1-hotfix.1 Updates dash-sdk to v3.0.1-hotfix.1 to match testnet. Breaking API changes addressed: - WalletBalance.unconfirmed and .total are now fields instead of methods - RequestSettings now requires max_decoding_message_size field Co-Authored-By: Claude Opus 4.5 * fmt --------- Co-authored-by: Claude Opus 4.5 --- Cargo.lock | 143 +++++++++++++++++++++------------------------ Cargo.toml | 2 +- src/context.rs | 12 ++-- src/sdk_wrapper.rs | 1 + 4 files changed, 72 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a13239e4b..65f0d403c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1731,8 +1731,8 @@ dependencies = [ [[package]] name = "dapi-grpc" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "dash-platform-macros", "futures-core", @@ -1799,8 +1799,8 @@ dependencies = [ [[package]] name = "dash-context-provider" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "dpp", "drive", @@ -1878,7 +1878,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" dependencies = [ "bincode 2.0.0-rc.3", "bincode_derive", @@ -1888,8 +1888,8 @@ dependencies = [ [[package]] name = "dash-platform-macros" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "heck", "quote", @@ -1898,8 +1898,8 @@ dependencies = [ [[package]] name = "dash-sdk" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "arc-swap", "async-trait", @@ -1921,7 +1921,6 @@ dependencies = [ "js-sys", "lru", "rs-dapi-client", - "rustls-pemfile", "serde", "serde_json", "thiserror 2.0.18", @@ -1934,7 +1933,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" dependencies = [ "anyhow", "async-trait", @@ -1965,7 +1964,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" dependencies = [ "anyhow", "base64-compat", @@ -1991,12 +1990,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" [[package]] name = "dashcore-rpc" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" dependencies = [ "dashcore-rpc-json", "hex", @@ -2009,7 +2008,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" dependencies = [ "bincode 2.0.0-rc.3", "dashcore", @@ -2024,7 +2023,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" dependencies = [ "bincode 2.0.0-rc.3", "dashcore-private", @@ -2048,8 +2047,8 @@ dependencies = [ [[package]] name = "dashpay-contract" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "platform-value", "platform-version", @@ -2059,8 +2058,8 @@ dependencies = [ [[package]] name = "data-contracts" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2323,8 +2322,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "platform-value", "platform-version", @@ -2334,8 +2333,8 @@ dependencies = [ [[package]] name = "dpp" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "anyhow", "async-trait", @@ -2383,8 +2382,8 @@ dependencies = [ [[package]] name = "drive" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "bincode 2.0.0-rc.3", "byteorder", @@ -2408,8 +2407,8 @@ dependencies = [ [[package]] name = "drive-proof-verifier" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "bincode 2.0.0-rc.3", "dapi-grpc", @@ -2984,8 +2983,8 @@ dependencies = [ [[package]] name = "feature-flags-contract" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "platform-value", "platform-version", @@ -3529,7 +3528,7 @@ dependencies = [ [[package]] name = "grovedb" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" +source = "git+https://github.com/dashpay/grovedb?tag=v4.1.0#86562f65d9ec08bea28dc9981663cd2a63dc7f3b" dependencies = [ "bincode 2.0.0-rc.3", "bincode_derive", @@ -3562,7 +3561,7 @@ dependencies = [ [[package]] name = "grovedb-costs" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" +source = "git+https://github.com/dashpay/grovedb?tag=v4.1.0#86562f65d9ec08bea28dc9981663cd2a63dc7f3b" dependencies = [ "integer-encoding", "intmap", @@ -3572,7 +3571,7 @@ dependencies = [ [[package]] name = "grovedb-element" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" +source = "git+https://github.com/dashpay/grovedb?tag=v4.1.0#86562f65d9ec08bea28dc9981663cd2a63dc7f3b" dependencies = [ "bincode 2.0.0-rc.3", "bincode_derive", @@ -3586,7 +3585,7 @@ dependencies = [ [[package]] name = "grovedb-epoch-based-storage-flags" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" +source = "git+https://github.com/dashpay/grovedb?tag=v4.1.0#86562f65d9ec08bea28dc9981663cd2a63dc7f3b" dependencies = [ "grovedb-costs 4.0.0", "hex", @@ -3623,7 +3622,7 @@ dependencies = [ [[package]] name = "grovedb-merk" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" +source = "git+https://github.com/dashpay/grovedb?tag=v4.1.0#86562f65d9ec08bea28dc9981663cd2a63dc7f3b" dependencies = [ "bincode 2.0.0-rc.3", "bincode_derive", @@ -3653,7 +3652,7 @@ dependencies = [ [[package]] name = "grovedb-path" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" +source = "git+https://github.com/dashpay/grovedb?tag=v4.1.0#86562f65d9ec08bea28dc9981663cd2a63dc7f3b" dependencies = [ "hex", ] @@ -3691,7 +3690,7 @@ dependencies = [ [[package]] name = "grovedb-version" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" +source = "git+https://github.com/dashpay/grovedb?tag=v4.1.0#86562f65d9ec08bea28dc9981663cd2a63dc7f3b" dependencies = [ "thiserror 2.0.18", "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -3710,7 +3709,7 @@ dependencies = [ [[package]] name = "grovedb-visualize" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=a7bc60a6760c395e90489c655eee84ae75003af4#a7bc60a6760c395e90489c655eee84ae75003af4" +source = "git+https://github.com/dashpay/grovedb?tag=v4.1.0#86562f65d9ec08bea28dc9981663cd2a63dc7f3b" dependencies = [ "hex", "itertools 0.14.0", @@ -3811,8 +3810,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -3822,6 +3819,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -4504,7 +4503,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" dependencies = [ "async-trait", "base58ck", @@ -4531,7 +4530,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=56e802189ef6c7169fd635190ee382b94cc89870#56e802189ef6c7169fd635190ee382b94cc89870" +source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" dependencies = [ "async-trait", "bincode 2.0.0-rc.3", @@ -4539,14 +4538,13 @@ dependencies = [ "dashcore_hashes", "key-wallet", "secp256k1", - "tokio", "zeroize", ] [[package]] name = "keyword-search-contract" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "platform-value", "platform-version", @@ -4734,11 +4732,11 @@ dependencies = [ [[package]] name = "lru" -version = "0.12.5" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -4762,8 +4760,8 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "platform-value", "platform-version", @@ -5834,8 +5832,8 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "platform-serialization" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "bincode 2.0.0-rc.3", "platform-version", @@ -5843,8 +5841,8 @@ dependencies = [ [[package]] name = "platform-serialization-derive" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "proc-macro2", "quote", @@ -5854,8 +5852,8 @@ dependencies = [ [[package]] name = "platform-value" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "base64 0.22.1", "bincode 2.0.0-rc.3", @@ -5874,8 +5872,8 @@ dependencies = [ [[package]] name = "platform-version" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "bincode 2.0.0-rc.3", "grovedb-version 4.0.0", @@ -5886,8 +5884,8 @@ dependencies = [ [[package]] name = "platform-versioning" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "proc-macro2", "quote", @@ -6496,8 +6494,8 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rs-dapi-client" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "backon", "chrono", @@ -6653,15 +6651,6 @@ dependencies = [ "security-framework 3.5.1", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -7626,8 +7615,8 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "platform-value", "platform-version", @@ -8367,8 +8356,8 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "platform-value", "platform-version", @@ -9623,8 +9612,8 @@ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "withdrawals-contract" -version = "3.0.0-rc.1" -source = "git+https://github.com/dashpay/platform?rev=7b2669c0b250504a4cded9e2b9e78551583e6744#7b2669c0b250504a4cded9e2b9e78551583e6744" +version = "3.0.1-hotfix.1" +source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index 45fc9dc69..647057449 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.30.1", features = ["signal"] } eframe = { version = "0.32.0", features = ["persistence"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "7b2669c0b250504a4cded9e2b9e78551583e6744", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "07f7eb649bdc1485b3a43f384da7f96232fcfbba", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", diff --git a/src/context.rs b/src/context.rs index da1c0aea4..b79984111 100644 --- a/src/context.rs +++ b/src/context.rs @@ -754,7 +754,7 @@ impl AppContext { let balance = wm .get_wallet_balance(wallet_id) .map_err(|e| format!("get_wallet_balance failed: {e}"))?; - tracing::debug!(wallet = %hex::encode(seed_hash), spendable = balance.spendable(), unconfirmed = balance.unconfirmed(), total = balance.total(), "SPV balance snapshot"); + tracing::debug!(wallet = %hex::encode(seed_hash), spendable = balance.spendable(), unconfirmed = balance.unconfirmed, total = balance.total, "SPV balance snapshot"); let Some(wallet_info) = wm.get_wallet_info(wallet_id) else { continue; @@ -767,17 +767,13 @@ impl AppContext { self.sync_spv_account_addresses(wallet_info, &wallet_arc); if let Ok(mut wallet) = wallet_arc.write() { - wallet.update_spv_balances( - balance.spendable(), - balance.unconfirmed(), - balance.total(), - ); + wallet.update_spv_balances(balance.spendable(), balance.unconfirmed, balance.total); // Persist balances to database if let Err(e) = self.db.update_wallet_balances( seed_hash, balance.spendable(), - balance.unconfirmed(), - balance.total(), + balance.unconfirmed, + balance.total, ) { tracing::warn!(wallet = %hex::encode(seed_hash), error = %e, "Failed to persist wallet balances"); } diff --git a/src/sdk_wrapper.rs b/src/sdk_wrapper.rs index 58c62c426..36be904c9 100644 --- a/src/sdk_wrapper.rs +++ b/src/sdk_wrapper.rs @@ -18,6 +18,7 @@ pub fn initialize_sdk( timeout: Some(Duration::from_secs(10)), retries: Some(6), ban_failed_address: Some(true), + max_decoding_message_size: None, }; let platform_version = default_platform_version(&network); From b4fbe93d25ebe8a3246d17404fea7afff3671dbc Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:46:32 +0700 Subject: [PATCH 060/106] fix: ensure dev mode is off and spv sync off for new users (#504) * fix: ensure dev mode is off and spv sync off for new users * fix tests * fix --- .env.example | 3 --- src/app.rs | 4 ++-- src/database/initialization.rs | 2 +- src/database/settings.rs | 14 +++++++------- src/ui/network_chooser_screen.rs | 2 +- 5 files changed, 11 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index c7f0bb182..9e49c25c6 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,6 @@ MAINNET_core_rpc_password=password MAINNET_insight_api_url=https://insight.dash.org/insight-api MAINNET_core_zmq_endpoint=tcp://127.0.0.1:23708 MAINNET_show_in_ui=true -MAINNET_developer_mode=true TESTNET_dapi_addresses=https://34.214.48.68:1443,https://52.12.176.90:1443,https://52.34.144.50:1443,https://44.240.98.102:1443,https://54.201.32.131:1443,https://52.10.229.11:1443,https://52.13.132.146:1443,https://52.40.219.41:1443,https://54.149.33.167:1443,https://35.164.23.245:1443,https://52.33.28.47:1443,https://52.43.13.92:1443,https://52.89.154.48:1443,https://52.24.124.162:1443,https://35.85.21.179:1443,https://54.187.14.232:1443,https://54.68.235.201:1443,https://52.13.250.182:1443 TESTNET_core_host=127.0.0.1 @@ -20,7 +19,6 @@ TESTNET_core_rpc_password=password TESTNET_insight_api_url=https://testnet-insight.dash.org/insight-api TESTNET_core_zmq_endpoint=tcp://127.0.0.1:23709 TESTNET_show_in_ui=true -TESTNET_developer_mode=false DEVNET_dapi_addresses=http://54.203.116.91:1443,http://52.88.16.46:1443,http://34.222.214.170:1443,http://54.214.77.108:1443,http://52.40.186.234:1443,http://54.202.231.20:1443,http://35.89.246.86:1443,http://18.246.227.21:1443,http://44.245.96.164:1443,http://44.247.160.52:1443 DEVNET_core_host=127.0.0.1 @@ -30,7 +28,6 @@ DEVNET_core_rpc_password=password DEVNET_insight_api_url= DEVNET_core_zmq_endpoint=tcp://127.0.0.1:23710 DEVNET_show_in_ui=true -DEVNET_developer_mode=false LOCAL_dapi_addresses=http://127.0.0.1:2443,http://127.0.0.1:2543,http://127.0.0.1:2643 LOCAL_core_host=127.0.0.1 diff --git a/src/app.rs b/src/app.rs index 2b7e4207e..cecc1a608 100644 --- a/src/app.rs +++ b/src/app.rs @@ -632,7 +632,7 @@ impl AppState { // TODO: SPV auto-start is gated behind developer mode while SPV is in development. // Remove the is_developer_mode() check once SPV is production-ready. let current_context = app_state.current_app_context(); - let auto_start_spv = db.get_auto_start_spv().unwrap_or(true); + let auto_start_spv = db.get_auto_start_spv().unwrap_or(false); if auto_start_spv && current_context.is_developer_mode() && current_context.core_backend_mode() == crate::spv::CoreBackendMode::Spv @@ -1081,7 +1081,7 @@ impl App for AppState { // TODO: SPV auto-start is gated behind developer mode while SPV is in development. // Remove the is_developer_mode() check once SPV is production-ready. let current_context = self.current_app_context(); - let auto_start_spv = current_context.db.get_auto_start_spv().unwrap_or(true); + let auto_start_spv = current_context.db.get_auto_start_spv().unwrap_or(false); if auto_start_spv && current_context.is_developer_mode() && current_context.core_backend_mode() == crate::spv::CoreBackendMode::Spv diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 8581b6a90..02f747576 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -282,7 +282,7 @@ impl Database { show_evonode_tools INTEGER DEFAULT 0, user_mode TEXT DEFAULT 'Advanced', use_local_spv_node INTEGER DEFAULT 0, - auto_start_spv INTEGER DEFAULT 1, + auto_start_spv INTEGER DEFAULT 0, close_dash_qt_on_exit INTEGER DEFAULT 1, selected_wallet_hash BLOB, selected_single_key_hash BLOB diff --git a/src/database/settings.rs b/src/database/settings.rs index d2cdd7055..1853eeb4f 100644 --- a/src/database/settings.rs +++ b/src/database/settings.rs @@ -316,9 +316,9 @@ impl Database { )?; if !column_exists { - // Default to true - auto-start SPV on startup + // Default to false - don't auto-start SPV on startup conn.execute( - "ALTER TABLE settings ADD COLUMN auto_start_spv INTEGER DEFAULT 1;", + "ALTER TABLE settings ADD COLUMN auto_start_spv INTEGER DEFAULT 0;", (), )?; } @@ -363,7 +363,7 @@ impl Database { [], |row| row.get(0), )?; - Ok(result.unwrap_or(true)) // Default to true + Ok(result.unwrap_or(false)) // Default to false } /// Adds the close_dash_qt_on_exit column to the settings table. @@ -796,18 +796,18 @@ mod tests { fn test_spv_settings() { let db = create_test_database().expect("Failed to create test database"); - // Test auto_start_spv (default true) + // Test auto_start_spv (default false) let auto_start = db .get_auto_start_spv() .expect("Failed to get auto_start_spv"); - assert!(auto_start); + assert!(!auto_start); - db.update_auto_start_spv(false) + db.update_auto_start_spv(true) .expect("Failed to update auto_start_spv"); let auto_start = db .get_auto_start_spv() .expect("Failed to get auto_start_spv"); - assert!(!auto_start); + assert!(auto_start); // Test use_local_spv_node (default false) let use_local = db diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 8c0d54de9..527fafa98 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -107,7 +107,7 @@ impl NetworkChooserScreen { .db .get_use_local_spv_node() .unwrap_or(false); - let auto_start_spv = mainnet_app_context.db.get_auto_start_spv().unwrap_or(true); + let auto_start_spv = mainnet_app_context.db.get_auto_start_spv().unwrap_or(false); let close_dash_qt_on_exit = mainnet_app_context .db .get_close_dash_qt_on_exit() From 1aa80c2c1f152e389c9e6902eba8b604eb1bafb7 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:07:40 +0700 Subject: [PATCH 061/106] fix: update platform address prefix from dashevo to evo (#505) * fix: update platform address prefix from dashevo to evo Update address detection and UI hints to use the new shorter platform address prefixes (evo1/tevo1) instead of the old format (dashevo1/tdashevo1) to match SDK v3.0.1 changes. Co-Authored-By: Claude Opus 4.5 * fix: validate platform address network matches app network Add network validation when parsing Bech32m Platform addresses to ensure the address network prefix matches the app's configured network, showing a descriptive error on mismatch. Co-Authored-By: Claude Opus 4.5 * fmt --------- Co-authored-by: Claude Opus 4.5 --- src/database/wallet.rs | 2 +- src/model/wallet/mod.rs | 4 ++-- src/ui/identities/transfer_screen.rs | 4 ++-- src/ui/tools/address_balance_screen.rs | 4 ++-- src/ui/wallets/account_summary.rs | 4 ++-- src/ui/wallets/send_screen.rs | 10 +++++----- src/ui/wallets/wallets_screen/mod.rs | 23 +++++++++++++++++------ 7 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/database/wallet.rs b/src/database/wallet.rs index eae4c5b3a..a0918cc68 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -527,7 +527,7 @@ impl Database { ) })?; - // Parse address - Platform addresses (DIP-17/18) use Bech32m encoding with dashevo/tdashevo prefix + // Parse address - Platform addresses (DIP-17/18) use Bech32m encoding with evo/tevo prefix // and need special handling when stored (we store as Core address format internally) let address = if path_reference == DerivationPathReference::PlatformPayment { // Platform addresses are stored as Core P2PKH format for efficient internal lookup. diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 9be4def6e..346feac24 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -1223,7 +1223,7 @@ impl Wallet { Ok(()) } - /// Bootstrap DIP-17 Platform payment addresses (dashevo/tdashevo Bech32m prefix per DIP-18) + /// Bootstrap DIP-17 Platform payment addresses (evo/tevo Bech32m prefix) /// These addresses are for receiving Dash Credits on Platform, independent of identities. fn bootstrap_platform_payment_addresses( &mut self, @@ -2006,7 +2006,7 @@ impl Signer for Wallet { // The Signer trait doesn't pass network info, so we try each network. // This is safe because: // 1. A wallet instance only stores keys for ONE network (set at creation) - // 2. Platform addresses encode their network in the bech32m prefix (dashevo/tdashevo) + // 2. Platform addresses encode their network in the bech32m prefix (evo/tevo) // 3. get_platform_address_private_key will only succeed for the correct network // 4. Only one network's derivation will match the wallet's seed let private_key = self diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index 65a6bdef6..661bfe23a 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -261,8 +261,8 @@ impl TransferScreen { let input = self.platform_address_input.trim(); - // Try to parse as Bech32m Platform address first (DIP-18 format: dashevo1.../tdashevo1...) - if input.starts_with("dashevo1") || input.starts_with("tdashevo1") { + // Try to parse as Bech32m Platform address first (evo1.../tevo1...) + if input.starts_with("evo1") || input.starts_with("tevo1") { let (addr, _network) = PlatformAddress::from_bech32m_string(input) .map_err(|e| format!("Invalid Bech32m address: {}", e))?; return Ok(addr); diff --git a/src/ui/tools/address_balance_screen.rs b/src/ui/tools/address_balance_screen.rs index d9f1f9f54..1f8a75b7c 100644 --- a/src/ui/tools/address_balance_screen.rs +++ b/src/ui/tools/address_balance_screen.rs @@ -58,11 +58,11 @@ impl AddressBalanceScreen { ui.heading("Platform Address Balance Lookup"); ui.add_space(10.0); - ui.label("Enter a Platform address (dashevo1... or tdashevo1...):"); + ui.label("Enter a Platform address (evo1... or tevo1...):"); ui.add_space(5.0); let text_edit = TextEdit::singleline(&mut self.address_input) - .hint_text("dashevo1... or tdashevo1...") + .hint_text("evo1... or tevo1...") .desired_width(500.0); let response = ui.add(text_edit); diff --git a/src/ui/wallets/account_summary.rs b/src/ui/wallets/account_summary.rs index 27dc134f1..2167ecb07 100644 --- a/src/ui/wallets/account_summary.rs +++ b/src/ui/wallets/account_summary.rs @@ -17,7 +17,7 @@ pub enum AccountCategory { ProviderOwner, ProviderOperator, ProviderPlatform, - /// DIP-17: Platform Payment Addresses (dashevo/tdashevo Bech32m prefix per DIP-18) + /// DIP-17: Platform Payment Addresses (evo/tevo Bech32m prefix per DIP-18) PlatformPayment, Other(DerivationPathReference), } @@ -124,7 +124,7 @@ impl AccountCategory { Some("Platform service key branch used by masternode platform nodes.") } AccountCategory::PlatformPayment => Some( - "DIP-17 Platform payment addresses (dashevo/tdashevo prefix). Hold Dash Credits on Platform, independent of identities.", + "DIP-17 Platform payment addresses (evo/tevo prefix). Hold Dash Credits on Platform, independent of identities.", ), AccountCategory::Other(_) => None, } diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 7cdc2fa6e..4a5b5e340 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -231,7 +231,7 @@ impl WalletSendScreen { } // Check for Platform address (Bech32m format) - if trimmed.starts_with("dashevo1") || trimmed.starts_with("tdashevo1") { + if trimmed.starts_with("evo1") || trimmed.starts_with("tevo1") { return AddressType::Platform; } @@ -363,7 +363,7 @@ impl WalletSendScreen { let dest_type = self.detect_address_type(&self.destination_address); if dest_type == AddressType::Unknown { return Err( - "Invalid destination address. Use a Dash address (X.../y...) or Platform address (dashevo1.../tdashevo1...)" + "Invalid destination address. Use a Dash address (X.../y...) or Platform address (evo1.../tevo1...)" .to_string(), ); } @@ -853,7 +853,7 @@ impl WalletSendScreen { .show(ui, |ui| { ui.add( egui::TextEdit::singleline(&mut self.destination_address) - .hint_text("Enter address (X.../y.../dashevo1.../tdashevo1...)") + .hint_text("Enter address (X.../y.../evo1.../tevo1...)") .desired_width(f32::INFINITY), ); }); @@ -1479,7 +1479,7 @@ impl WalletSendScreen { ui.label("To:"); ui.add( egui::TextEdit::singleline(&mut self.advanced_outputs[idx].address) - .hint_text("Enter address (X.../y.../dashevo1.../tdashevo1...)") + .hint_text("Enter address (X.../y.../evo1.../tevo1...)") .desired_width(350.0), ); @@ -1550,7 +1550,7 @@ impl WalletSendScreen { } // Check for Platform address (Bech32m format) - if trimmed.starts_with("dashevo1") || trimmed.starts_with("tdashevo1") { + if trimmed.starts_with("evo1") || trimmed.starts_with("tevo1") { return AddressType::Platform; } diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index bd8653f26..62316172c 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -148,7 +148,7 @@ struct AddressData { impl AddressData { /// Returns the address formatted for display. - /// Platform Payment addresses are shown in DIP-18 Bech32m format (e.g., tdashevo...). + /// Platform Payment addresses are shown in DIP-18 Bech32m format (e.g., tevo1...). fn display_address(&self, network: Network) -> String { if self.account_category == AccountCategory::PlatformPayment { use dash_sdk::dpp::address_funds::PlatformAddress; @@ -2287,7 +2287,7 @@ impl WalletsBalancesScreen { } /// Generate a new Platform address for the wallet. - /// Returns the address in DIP-18 Bech32m format (e.g., tdashevo1... for testnet) + /// Returns the address in Bech32m format (e.g., tevo1... for testnet) fn generate_platform_address(&self, wallet: &Arc>) -> Result { use dash_sdk::dpp::address_funds::PlatformAddress; let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; @@ -2697,13 +2697,24 @@ impl WalletsBalancesScreen { return AppAction::None; }; - // Parse the Platform address (Bech32m format: dashevo1.../tdashevo1...) + // Parse the Platform address (Bech32m format: evo1.../tevo1...) use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; - let platform_addr = if selected_addr.starts_with("dashevo1") - || selected_addr.starts_with("tdashevo1") + let platform_addr = if selected_addr.starts_with("evo1") + || selected_addr.starts_with("tevo1") { match PlatformAddress::from_bech32m_string(selected_addr) { - Ok((addr, _network)) => addr, + Ok((addr, network)) => { + // Validate that address network matches app network + if network != self.app_context.network { + self.fund_platform_dialog.status = Some(format!( + "Address network mismatch: address is for {:?} but app is on {:?}", + network, self.app_context.network + )); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + } + addr + } Err(e) => { self.fund_platform_dialog.status = Some(format!("Invalid Bech32m address: {}", e)); From 72166f3bddeaa5def1a6188c52c2a5a7d39b576d Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:32:03 +0700 Subject: [PATCH 062/106] fix: platform address balance doubling after transfer and refresh (#502) * fix: platform address balance doubling after transfer and refresh * fix --- .../wallet/fetch_platform_address_balances.rs | 85 ++++++++++++------- 1 file changed, 52 insertions(+), 33 deletions(-) diff --git a/src/backend_task/wallet/fetch_platform_address_balances.rs b/src/backend_task/wallet/fetch_platform_address_balances.rs index 65ead0b35..7dd036a19 100644 --- a/src/backend_task/wallet/fetch_platform_address_balances.rs +++ b/src/backend_task/wallet/fetch_platform_address_balances.rs @@ -81,7 +81,7 @@ impl AppContext { guard.clone() }; - let checkpoint_height = if needs_full_sync { + let (_checkpoint_height, highest_block_processed) = if needs_full_sync { tracing::info!( "Performing full platform address sync (last sync: {} seconds ago)", now.saturating_sub(last_full_sync) @@ -129,15 +129,24 @@ impl AppContext { result.checkpoint_height ); - // Apply terminal updates + // Apply terminal updates and capture the highest block processed let terminal_start_height = result.checkpoint_height.max(last_terminal_block); - self.apply_recent_balance_changes( - &sdk, - &wallet_arc, - &mut provider, + let terminal_sync_start = std::time::Instant::now(); + let highest_block_processed = self + .apply_recent_balance_changes( + &sdk, + &wallet_arc, + &mut provider, + terminal_start_height, + ) + .await?; + let terminal_sync_duration = terminal_sync_start.elapsed(); + tracing::info!( + "Terminal balance updates complete: duration={:?}, start_height={}, end_height={}", + terminal_sync_duration, terminal_start_height, - ) - .await?; + highest_block_processed + ); tracing::info!( "Full sync complete: duration={:?}, found={}, absent={}, highest_index={:?}, checkpoint_height={}", @@ -170,7 +179,7 @@ impl AppContext { tracing::warn!("Failed to save platform sync info: {}", e); } - result.checkpoint_height + (result.checkpoint_height, highest_block_processed) } else { let terminal_only_start = std::time::Instant::now(); tracing::info!( @@ -213,25 +222,29 @@ impl AppContext { pre_populated_count ); - stored_checkpoint - }; + // For terminal-only sync, fetch recent balance changes + // Use the higher of checkpoint_height or last_terminal_block to avoid + // re-applying changes we've already processed. + let terminal_start_height = stored_checkpoint.max(last_terminal_block); + let terminal_sync_start = std::time::Instant::now(); + let highest_block_processed = self + .apply_recent_balance_changes( + &sdk, + &wallet_arc, + &mut provider, + terminal_start_height, + ) + .await?; + let terminal_sync_duration = terminal_sync_start.elapsed(); + tracing::info!( + "Terminal balance updates complete: duration={:?}, start_height={}, end_height={}", + terminal_sync_duration, + terminal_start_height, + highest_block_processed + ); - // Fetch recent balance changes (terminal updates after checkpoint) - // This catches any balance changes that happened after the checkpoint. - // Use the higher of checkpoint_height or last_terminal_block to avoid - // re-applying changes we've already processed. - let terminal_start_height = checkpoint_height.max(last_terminal_block); - let terminal_sync_start = std::time::Instant::now(); - let highest_block_processed = self - .apply_recent_balance_changes(&sdk, &wallet_arc, &mut provider, terminal_start_height) - .await?; - let terminal_sync_duration = terminal_sync_start.elapsed(); - tracing::info!( - "Terminal balance updates complete: duration={:?}, start_height={}, end_height={}", - terminal_sync_duration, - terminal_start_height, - highest_block_processed - ); + (stored_checkpoint, highest_block_processed) + }; // Save the highest block we've processed to avoid re-applying the same changes if highest_block_processed > last_terminal_block @@ -336,8 +349,13 @@ impl AppContext { // We query for compacted changes starting from that start height, // then query recent non-compacted changes starting from where compacted ends. + // Query from start_height + 1 because start_height was already processed + // in the previous sync (last_terminal_block is the highest block we've seen) + let query_from_height = start_height.saturating_add(1); + tracing::debug!( - "Fetching terminal balance updates from height {}", + "Fetching terminal balance updates from height {} (start_height={})", + query_from_height, start_height ); @@ -358,9 +376,9 @@ impl AppContext { let mut highest_block_seen = start_height; // Step 1: Fetch compacted balance changes (merged changes for ranges of blocks) - // Start from start_height to get changes since the last sync + // Start from query_from_height (start_height + 1) to get changes since the last sync let compacted_fetch_start = std::time::Instant::now(); - let compacted_query = RecentCompactedAddressBalanceChangesQuery::new(start_height); + let compacted_query = RecentCompactedAddressBalanceChangesQuery::new(query_from_height); let compacted_result = tokio::time::timeout( std::time::Duration::from_secs(30), RecentCompactedAddressBalanceChanges::fetch(sdk, compacted_query), @@ -370,7 +388,7 @@ impl AppContext { tracing::info!( "Compacted balance changes fetch: duration={:?}, from_height={}", compacted_duration, - start_height + query_from_height ); let compacted_result = match compacted_result { Ok(result) => result, @@ -411,10 +429,11 @@ impl AppContext { credits } BlockAwareCreditOperation::AddToCreditsOperations(operations) => { - // Only apply credits from blocks AFTER our start height + // Only apply credits from blocks at or after our query height + // (since we query from start_height + 1, all results should be valid) let total_to_add: u64 = operations .iter() - .filter(|(height, _)| **height > start_height) + .filter(|(height, _)| **height >= query_from_height) .map(|(_, credits)| *credits) .sum(); tracing::debug!( From 15c7a3d0a3be35e5b6b3b930dc1d6b4a8382be91 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:24:09 +0700 Subject: [PATCH 063/106] feat: add fee multiplier caching and use it across all UI screens (#506) * feat: add fee multiplier caching and use it across all UI screens - Add fee_multiplier_permille support to PlatformFeeEstimator - Add fee multiplier caching in AppContext with getter/setter - Add fee_estimator() helper method on AppContext - Cache the fee multiplier when epoch info is fetched - Display note when fee multiplier cache is updated - Update all UI screens to use app_context.fee_estimator() Co-Authored-By: Claude Opus 4.5 * fmt * fix: apply fee multiplier to all estimation functions Update all estimate_* functions to apply the fee multiplier to the combined total (storage + processing/registration) rather than to individual components, ensuring consistent fee calculation across all state transition types per the Dash Platform spec. Co-Authored-By: Claude Opus 4.5 * refactor: reuse fee_estimator instance in token screens Avoid duplicate construction and keep consistent multiplier snapshot within the frame by reusing the existing fee_estimator variable. Co-Authored-By: Claude Opus 4.5 * refactor: use DEFAULT_FEE_MULTIPLIER_PERMILLE constant in AppContext Keep the AppContext default in sync with the estimator's canonical value instead of hardcoding 1000. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- src/backend_task/platform_info.rs | 11 +- src/context.rs | 27 ++- src/model/fee_estimation.rs | 172 ++++++++++++------ .../document_action_screen.rs | 4 +- .../register_contract_screen.rs | 4 +- src/ui/dashpay/profile_screen.rs | 4 +- .../by_platform_address.rs | 11 +- .../by_using_unused_asset_lock.rs | 7 +- .../by_using_unused_balance.rs | 7 +- src/ui/identities/keys/add_key_screen.rs | 4 +- .../identities/register_dpns_name_screen.rs | 4 +- .../by_platform_address.rs | 4 +- .../by_using_unused_asset_lock.rs | 4 +- .../by_using_unused_balance.rs | 4 +- src/ui/identities/transfer_screen.rs | 4 +- src/ui/identities/withdraw_screen.rs | 4 +- src/ui/tokens/burn_tokens_screen.rs | 6 +- src/ui/tokens/claim_tokens_screen.rs | 4 +- src/ui/tokens/destroy_frozen_funds_screen.rs | 4 +- src/ui/tokens/direct_token_purchase_screen.rs | 4 +- src/ui/tokens/freeze_tokens_screen.rs | 6 +- src/ui/tokens/mint_tokens_screen.rs | 6 +- src/ui/tokens/pause_tokens_screen.rs | 4 +- src/ui/tokens/resume_tokens_screen.rs | 4 +- src/ui/tokens/set_token_price_screen.rs | 4 +- src/ui/tokens/transfer_tokens_screen.rs | 4 +- src/ui/tokens/unfreeze_tokens_screen.rs | 4 +- src/ui/tokens/update_token_config.rs | 4 +- 28 files changed, 219 insertions(+), 110 deletions(-) diff --git a/src/backend_task/platform_info.rs b/src/backend_task/platform_info.rs index 38014eb87..6cbf246f0 100644 --- a/src/backend_task/platform_info.rs +++ b/src/backend_task/platform_info.rs @@ -367,7 +367,16 @@ impl AppContext { PlatformInfoTaskRequestType::CurrentEpochInfo => { match ExtendedEpochInfo::fetch_current(&sdk).await { Ok(epoch_info) => { - let formatted = format_extended_epoch_info(epoch_info, self.network, true); + // Cache the fee multiplier for UI fee estimation + let fee_multiplier = epoch_info.fee_multiplier_permille(); + self.set_fee_multiplier_permille(fee_multiplier); + + let mut formatted = + format_extended_epoch_info(epoch_info, self.network, true); + formatted.push_str(&format!( + "\n\n(Fee multiplier cache updated: {}x)", + fee_multiplier as f64 / 1000.0 + )); Ok(BackendTaskSuccessResult::PlatformInfo( PlatformInfoTaskResult::TextResult(formatted), )) diff --git a/src/context.rs b/src/context.rs index b79984111..cb96b06d6 100644 --- a/src/context.rs +++ b/src/context.rs @@ -6,6 +6,7 @@ use crate::context_provider::Provider as RpcProvider; use crate::context_provider_spv::SpvProvider; use crate::database::Database; use crate::model::contested_name::ContestedName; +use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::password_info::PasswordInfo; use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::{DPNSNameInfo, QualifiedIdentity}; @@ -49,7 +50,7 @@ use dash_sdk::query_types::IndexMap; use egui::Context; use rusqlite::Result; use std::collections::{BTreeMap, HashMap}; -use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock, RwLockWriteGuard}; const ANIMATION_REFRESH_TIME: std::time::Duration = std::time::Duration::from_millis(100); @@ -106,6 +107,9 @@ pub struct AppContext { pub(crate) selected_wallet_hash: Mutex>, /// Currently selected single key wallet (persisted across screen navigation) pub(crate) selected_single_key_hash: Mutex>, + /// Cached fee multiplier permille from current epoch (1000 = 1x, 2000 = 2x) + /// Updated when epoch info is fetched from Platform + fee_multiplier_permille: AtomicU64, } impl AppContext { @@ -275,6 +279,9 @@ impl AppContext { pending_wallet_selection: Mutex::new(None), selected_wallet_hash: Mutex::new(selected_wallet_hash), selected_single_key_hash: Mutex::new(selected_single_key_hash), + fee_multiplier_permille: AtomicU64::new( + PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, + ), }; let app_context = Arc::new(app_context); @@ -395,6 +402,24 @@ impl AppContext { } } + /// Get the cached fee multiplier permille (1000 = 1x, 2000 = 2x) + pub fn fee_multiplier_permille(&self) -> u64 { + self.fee_multiplier_permille.load(Ordering::Relaxed) + } + + /// Update the cached fee multiplier from epoch info + pub fn set_fee_multiplier_permille(&self, multiplier: u64) { + self.fee_multiplier_permille + .store(multiplier, Ordering::Relaxed); + } + + /// Get a fee estimator configured with the cached fee multiplier. + /// Use this instead of `PlatformFeeEstimator::new()` to get accurate fee estimates + /// that reflect the current network fee multiplier. + pub fn fee_estimator(&self) -> PlatformFeeEstimator { + PlatformFeeEstimator::with_fee_multiplier(self.fee_multiplier_permille()) + } + pub fn spv_manager(&self) -> &Arc { &self.spv_manager } diff --git a/src/model/fee_estimation.rs b/src/model/fee_estimation.rs index 1f079c5a7..6c5f19e5f 100644 --- a/src/model/fee_estimation.rs +++ b/src/model/fee_estimation.rs @@ -141,6 +141,9 @@ pub struct PlatformFeeEstimator { min_fees: StateTransitionMinFees, storage_fees: StorageFeeConstants, registration_fees: DataContractRegistrationFees, + /// Fee multiplier in permille (1000 = 1x, 2000 = 2x, etc.) + /// This comes from the current epoch's fee_multiplier_permille() + fee_multiplier_permille: u64, } impl Default for PlatformFeeEstimator { @@ -150,11 +153,25 @@ impl Default for PlatformFeeEstimator { } impl PlatformFeeEstimator { + /// Default fee multiplier (1x = 1000 permille) + pub const DEFAULT_FEE_MULTIPLIER_PERMILLE: u64 = 1000; + pub fn new() -> Self { Self { min_fees: StateTransitionMinFees::default(), storage_fees: StorageFeeConstants::default(), registration_fees: DataContractRegistrationFees::default(), + fee_multiplier_permille: Self::DEFAULT_FEE_MULTIPLIER_PERMILLE, + } + } + + /// Create an estimator with a specific fee multiplier (from epoch info) + pub fn with_fee_multiplier(fee_multiplier_permille: u64) -> Self { + Self { + min_fees: StateTransitionMinFees::default(), + storage_fees: StorageFeeConstants::default(), + registration_fees: DataContractRegistrationFees::default(), + fee_multiplier_permille, } } @@ -164,6 +181,19 @@ impl PlatformFeeEstimator { Self::new() } + /// Apply the fee multiplier to a base fee amount. + /// Multiplier is in permille: 1000 = 1x, 1500 = 1.5x, 2000 = 2x + fn apply_multiplier(&self, base_fee: u64) -> u64 { + base_fee + .saturating_mul(self.fee_multiplier_permille) + .saturating_div(1000) + } + + /// Get the current fee multiplier permille + pub fn fee_multiplier_permille(&self) -> u64 { + self.fee_multiplier_permille + } + /// Calculate storage fee for a given number of bytes. /// This is the main cost component for storing data on Platform. pub fn calculate_storage_fee(&self, bytes: usize) -> u64 { @@ -181,36 +211,45 @@ impl PlatformFeeEstimator { (seek_count as u64).saturating_mul(self.storage_fees.storage_seek_cost) } - /// Estimate total storage-based fee for storing data. + /// Calculate total storage-based fee for storing data (without fee multiplier). /// Includes storage, processing, and estimated seek costs. - pub fn estimate_storage_based_fee(&self, bytes: usize, estimated_seeks: usize) -> u64 { + /// This is a building block used by other estimation functions. + fn calculate_storage_based_fee(&self, bytes: usize, estimated_seeks: usize) -> u64 { self.calculate_storage_fee(bytes) .saturating_add(self.calculate_processing_fee(bytes)) .saturating_add(self.calculate_seek_fee(estimated_seeks)) } + /// Estimate total storage-based fee for storing data. + /// Includes storage, processing, and estimated seek costs. + /// Applies the current fee multiplier. + pub fn estimate_storage_based_fee(&self, bytes: usize, estimated_seeks: usize) -> u64 { + self.apply_multiplier(self.calculate_storage_based_fee(bytes, estimated_seeks)) + } + /// Estimate fee for credit transfer between identities pub fn estimate_credit_transfer(&self) -> u64 { - self.min_fees.credit_transfer + self.apply_multiplier(self.min_fees.credit_transfer) } /// Estimate fee for credit transfer to platform addresses pub fn estimate_credit_transfer_to_addresses(&self, output_count: usize) -> u64 { - self.min_fees.credit_transfer_to_addresses.saturating_add( + let base_fee = self.min_fees.credit_transfer_to_addresses.saturating_add( self.min_fees .address_funds_transfer_output_cost .saturating_mul(output_count as u64), - ) + ); + self.apply_multiplier(base_fee) } /// Estimate fee for credit withdrawal to core chain pub fn estimate_credit_withdrawal(&self) -> u64 { - self.min_fees.credit_withdrawal + self.apply_multiplier(self.min_fees.credit_withdrawal) } /// Estimate fee for address-based credit withdrawal pub fn estimate_address_credit_withdrawal(&self) -> u64 { - self.min_fees.address_credit_withdrawal + self.apply_multiplier(self.min_fees.address_credit_withdrawal) } /// Estimate fee for funding a platform address from an asset lock. @@ -229,20 +268,22 @@ impl PlatformFeeEstimator { /// Estimate fee for identity update (adding/disabling keys) pub fn estimate_identity_update(&self) -> u64 { - self.min_fees.identity_update + self.apply_multiplier(self.min_fees.identity_update) } /// Estimate fee for identity creation. /// This includes base cost, asset lock cost, and per-key costs. pub fn estimate_identity_create(&self, key_count: usize) -> u64 { - self.min_fees + let base_fee = self + .min_fees .identity_create_base_cost .saturating_add(self.min_fees.identity_create_asset_lock_cost) .saturating_add( self.min_fees .identity_key_in_creation_cost .saturating_mul(key_count as u64), - ) + ); + self.apply_multiplier(base_fee) } /// Estimate fee for identity creation from addresses (asset lock). @@ -254,7 +295,8 @@ impl PlatformFeeEstimator { key_count: usize, ) -> u64 { let output_count = if has_output { 1 } else { 0 }; - self.min_fees + let base_fee = self + .min_fees .identity_create_base_cost .saturating_add(self.min_fees.address_funding_asset_lock_cost) .saturating_add( @@ -271,22 +313,27 @@ impl PlatformFeeEstimator { self.min_fees .identity_key_in_creation_cost .saturating_mul(key_count as u64), - ) + ); + self.apply_multiplier(base_fee) } /// Estimate fee for identity top-up. /// This includes base cost and asset lock cost. pub fn estimate_identity_topup(&self) -> u64 { - self.min_fees + let base_fee = self + .min_fees .identity_topup_base_cost - .saturating_add(self.min_fees.identity_topup_asset_lock_cost) + .saturating_add(self.min_fees.identity_topup_asset_lock_cost); + self.apply_multiplier(base_fee) } /// Estimate fee for document batch transition pub fn estimate_document_batch(&self, transition_count: usize) -> u64 { - self.min_fees + let base_fee = self + .min_fees .document_batch_sub_transition - .saturating_mul(transition_count.max(1) as u64) + .saturating_mul(transition_count.max(1) as u64); + self.apply_multiplier(base_fee) } /// Estimate fee for document creation with known size. @@ -294,9 +341,11 @@ impl PlatformFeeEstimator { /// Estimated seeks: ~10 for tree traversal and insertion. pub fn estimate_document_create_with_size(&self, document_bytes: usize) -> u64 { const ESTIMATED_SEEKS: usize = 10; - self.min_fees + let base_fee = self + .min_fees .document_batch_sub_transition - .saturating_add(self.estimate_storage_based_fee(document_bytes, ESTIMATED_SEEKS)) + .saturating_add(self.calculate_storage_based_fee(document_bytes, ESTIMATED_SEEKS)); + self.apply_multiplier(base_fee) } /// Estimate fee for document creation (uses default estimate of ~200 bytes). @@ -309,17 +358,21 @@ impl PlatformFeeEstimator { pub fn estimate_document_delete(&self) -> u64 { // Deletion involves seeks but no storage addition const ESTIMATED_SEEKS: usize = 8; - self.min_fees + let base_fee = self + .min_fees .document_batch_sub_transition - .saturating_add(self.calculate_seek_fee(ESTIMATED_SEEKS)) + .saturating_add(self.calculate_seek_fee(ESTIMATED_SEEKS)); + self.apply_multiplier(base_fee) } /// Estimate fee for document replacement with known size. pub fn estimate_document_replace_with_size(&self, document_bytes: usize) -> u64 { const ESTIMATED_SEEKS: usize = 10; - self.min_fees + let base_fee = self + .min_fees .document_batch_sub_transition - .saturating_add(self.estimate_storage_based_fee(document_bytes, ESTIMATED_SEEKS)) + .saturating_add(self.calculate_storage_based_fee(document_bytes, ESTIMATED_SEEKS)); + self.apply_multiplier(base_fee) } /// Estimate fee for document replacement (uses default estimate of ~200 bytes). @@ -332,27 +385,31 @@ impl PlatformFeeEstimator { pub fn estimate_document_transfer(&self) -> u64 { const ESTIMATED_SEEKS: usize = 8; const OWNERSHIP_UPDATE_BYTES: usize = 64; - self.min_fees.document_batch_sub_transition.saturating_add( - self.estimate_storage_based_fee(OWNERSHIP_UPDATE_BYTES, ESTIMATED_SEEKS), - ) + let base_fee = self.min_fees.document_batch_sub_transition.saturating_add( + self.calculate_storage_based_fee(OWNERSHIP_UPDATE_BYTES, ESTIMATED_SEEKS), + ); + self.apply_multiplier(base_fee) } /// Estimate fee for document purchase. pub fn estimate_document_purchase(&self) -> u64 { const ESTIMATED_SEEKS: usize = 10; const PURCHASE_UPDATE_BYTES: usize = 100; - self.min_fees - .document_batch_sub_transition - .saturating_add(self.estimate_storage_based_fee(PURCHASE_UPDATE_BYTES, ESTIMATED_SEEKS)) + let base_fee = self.min_fees.document_batch_sub_transition.saturating_add( + self.calculate_storage_based_fee(PURCHASE_UPDATE_BYTES, ESTIMATED_SEEKS), + ); + self.apply_multiplier(base_fee) } /// Estimate fee for document set price. pub fn estimate_document_set_price(&self) -> u64 { const ESTIMATED_SEEKS: usize = 8; const PRICE_UPDATE_BYTES: usize = 32; - self.min_fees + let base_fee = self + .min_fees .document_batch_sub_transition - .saturating_add(self.estimate_storage_based_fee(PRICE_UPDATE_BYTES, ESTIMATED_SEEKS)) + .saturating_add(self.calculate_storage_based_fee(PRICE_UPDATE_BYTES, ESTIMATED_SEEKS)); + self.apply_multiplier(base_fee) } /// Estimate fee for token transition (mint, burn, transfer, freeze, etc.). @@ -360,9 +417,11 @@ impl PlatformFeeEstimator { pub fn estimate_token_transition(&self) -> u64 { const ESTIMATED_SEEKS: usize = 8; const TOKEN_OP_BYTES: usize = 100; - self.min_fees + let base_fee = self + .min_fees .document_batch_sub_transition - .saturating_add(self.estimate_storage_based_fee(TOKEN_OP_BYTES, ESTIMATED_SEEKS)) + .saturating_add(self.calculate_storage_based_fee(TOKEN_OP_BYTES, ESTIMATED_SEEKS)); + self.apply_multiplier(base_fee) } /// Estimate fee for data contract creation with known size. @@ -370,10 +429,12 @@ impl PlatformFeeEstimator { /// For contracts with tokens, document types, or indexes, use the detailed method. pub fn estimate_contract_create_with_size(&self, contract_bytes: usize) -> u64 { const ESTIMATED_SEEKS: usize = 20; - self.registration_fees + let base_fee = self + .registration_fees .base_contract_registration_fee .saturating_add(self.min_fees.contract_create) - .saturating_add(self.estimate_storage_based_fee(contract_bytes, ESTIMATED_SEEKS)) + .saturating_add(self.calculate_storage_based_fee(contract_bytes, ESTIMATED_SEEKS)); + self.apply_multiplier(base_fee) } /// Estimate fee for data contract creation with detailed component counts. @@ -393,27 +454,27 @@ impl PlatformFeeEstimator { ) -> u64 { const ESTIMATED_SEEKS: usize = 20; - let mut fee = self.registration_fees.base_contract_registration_fee; + let mut base_fee = self.registration_fees.base_contract_registration_fee; // Document type fees - fee = fee.saturating_add( + base_fee = base_fee.saturating_add( self.registration_fees .document_type_registration_fee .saturating_mul(document_type_count as u64), ); // Index fees - fee = fee.saturating_add( + base_fee = base_fee.saturating_add( self.registration_fees .document_type_base_non_unique_index_registration_fee .saturating_mul(non_unique_index_count as u64), ); - fee = fee.saturating_add( + base_fee = base_fee.saturating_add( self.registration_fees .document_type_base_unique_index_registration_fee .saturating_mul(unique_index_count as u64), ); - fee = fee.saturating_add( + base_fee = base_fee.saturating_add( self.registration_fees .document_type_base_contested_index_registration_fee .saturating_mul(contested_index_count as u64), @@ -421,30 +482,32 @@ impl PlatformFeeEstimator { // Token fees if has_token { - fee = fee.saturating_add(self.registration_fees.token_registration_fee); + base_fee = base_fee.saturating_add(self.registration_fees.token_registration_fee); } if has_perpetual_distribution { - fee = fee.saturating_add(self.registration_fees.token_uses_perpetual_distribution_fee); + base_fee = base_fee + .saturating_add(self.registration_fees.token_uses_perpetual_distribution_fee); } if has_pre_programmed_distribution { - fee = fee.saturating_add( + base_fee = base_fee.saturating_add( self.registration_fees .token_uses_pre_programmed_distribution_fee, ); } // Search keyword fees - fee = fee.saturating_add( + base_fee = base_fee.saturating_add( self.registration_fees .search_keyword_fee .saturating_mul(search_keyword_count as u64), ); // Add state transition minimum and storage fees - fee = fee.saturating_add(self.min_fees.contract_create); - fee = fee.saturating_add(self.estimate_storage_based_fee(contract_bytes, ESTIMATED_SEEKS)); + base_fee = base_fee.saturating_add(self.min_fees.contract_create); + base_fee = base_fee + .saturating_add(self.calculate_storage_based_fee(contract_bytes, ESTIMATED_SEEKS)); - fee + self.apply_multiplier(base_fee) } /// Estimate fee for data contract creation (uses base registration fee only). @@ -458,9 +521,11 @@ impl PlatformFeeEstimator { /// Estimate fee for data contract update with known size of changes. pub fn estimate_contract_update_with_size(&self, update_bytes: usize) -> u64 { const ESTIMATED_SEEKS: usize = 15; - self.min_fees + let base_fee = self + .min_fees .contract_update - .saturating_add(self.estimate_storage_based_fee(update_bytes, ESTIMATED_SEEKS)) + .saturating_add(self.calculate_storage_based_fee(update_bytes, ESTIMATED_SEEKS)); + self.apply_multiplier(base_fee) } /// Estimate fee for data contract update (uses default estimate). @@ -475,19 +540,22 @@ impl PlatformFeeEstimator { /// Estimate fee for masternode vote pub fn estimate_masternode_vote(&self) -> u64 { - self.min_fees.masternode_vote + self.apply_multiplier(self.min_fees.masternode_vote) } - /// Estimate fee for address funds transfer + /// Estimate fee for address funds transfer. + /// Applies the current fee multiplier. pub fn estimate_address_funds_transfer(&self, input_count: usize, output_count: usize) -> u64 { - self.min_fees + let base_fee = self + .min_fees .address_funds_transfer_input_cost .saturating_mul(input_count as u64) .saturating_add( self.min_fees .address_funds_transfer_output_cost .saturating_mul(output_count.max(1) as u64), - ) + ); + self.apply_multiplier(base_fee) } /// Get the raw minimum fees structure diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index 33cce0944..5aaab004d 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -3,7 +3,7 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::FeeResult; use crate::backend_task::{BackendTask, document::DocumentTask}; use crate::context::AppContext; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; @@ -871,7 +871,7 @@ impl DocumentActionScreen { let mut action = AppAction::None; // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = match self.action_type { DocumentActionType::Create => fee_estimator.estimate_document_create(), DocumentActionType::Delete => fee_estimator.estimate_document_delete(), diff --git a/src/ui/contracts_documents/register_contract_screen.rs b/src/ui/contracts_documents/register_contract_screen.rs index 10bdd2648..7b16edcc8 100644 --- a/src/ui/contracts_documents/register_contract_screen.rs +++ b/src/ui/contracts_documents/register_contract_screen.rs @@ -215,7 +215,9 @@ impl RegisterDataContractScreen { let registration_fee = contract.registration_cost(platform_version).unwrap_or(0); // Add storage and processing fees for the contract data let contract_size = self.contract_json_input.len(); - let storage_fee = crate::model::fee_estimation::PlatformFeeEstimator::new() + let storage_fee = self + .app_context + .fee_estimator() .estimate_storage_based_fee(contract_size, 20); // ~20 seeks for tree operations let estimated_fee = registration_fee.saturating_add(storage_fee); ui.add_space(10.0); diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index 1071b0ea2..b5997aa34 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -2,7 +2,7 @@ use crate::app::AppAction; use crate::backend_task::dashpay::DashPayTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::MessageType; @@ -905,7 +905,7 @@ impl ProfileScreen { }); } else { // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); // Profile creation/update is a document operation let estimated_fee = if self.profile.is_some() { fee_estimator.estimate_document_replace() diff --git a/src/ui/identities/add_new_identity_screen/by_platform_address.rs b/src/ui/identities/add_new_identity_screen/by_platform_address.rs index 6f46ee6de..6ef7a3335 100644 --- a/src/ui/identities/add_new_identity_screen/by_platform_address.rs +++ b/src/ui/identities/add_new_identity_screen/by_platform_address.rs @@ -1,6 +1,6 @@ use crate::app::AppAction; use crate::model::amount::Amount; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; use crate::ui::identities::add_new_identity_screen::{ @@ -197,11 +197,10 @@ impl AddNewIdentityScreen { } else { 0 }; - let estimated_fee = PlatformFeeEstimator::new().estimate_identity_create_from_addresses( - input_count, - false, - key_count, - ); + let estimated_fee = self + .app_context + .fee_estimator() + .estimate_identity_create_from_addresses(input_count, false, key_count); let dark_mode = ui.ctx().style().visuals.dark_mode; egui::Frame::new() .fill(crate::ui::theme::DashColors::surface(dark_mode)) diff --git a/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs b/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs index 8e54268d9..faa61f56e 100644 --- a/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs +++ b/src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs @@ -1,5 +1,5 @@ use crate::app::AppAction; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::ui::identities::add_new_identity_screen::{ AddNewIdentityScreen, FundingMethod, WalletFundedScreenStep, }; @@ -104,7 +104,10 @@ impl AddNewIdentityScreen { // Display estimated fee before action button let key_count = self.identity_keys.keys_input.len() + 1; // +1 for master key - let estimated_fee = PlatformFeeEstimator::new().estimate_identity_create(key_count); + let estimated_fee = self + .app_context + .fee_estimator() + .estimate_identity_create(key_count); ui.add_space(10.0); let dark_mode = ui.ctx().style().visuals.dark_mode; egui::Frame::new() diff --git a/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs b/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs index f736eb3dd..e06a0a97c 100644 --- a/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs +++ b/src/ui/identities/add_new_identity_screen/by_using_unused_balance.rs @@ -1,5 +1,5 @@ use crate::app::AppAction; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::ui::identities::add_new_identity_screen::{ AddNewIdentityScreen, FundingMethod, WalletFundedScreenStep, }; @@ -57,7 +57,10 @@ impl AddNewIdentityScreen { // Display estimated fee before action button let key_count = self.identity_keys.keys_input.len() + 1; // +1 for master key - let estimated_fee = PlatformFeeEstimator::new().estimate_identity_create(key_count); + let estimated_fee = self + .app_context + .fee_estimator() + .estimate_identity_create(key_count); ui.add_space(10.0); let dark_mode = ui.ctx().style().visuals.dark_mode; egui::Frame::new() diff --git a/src/ui/identities/keys/add_key_screen.rs b/src/ui/identities/keys/add_key_screen.rs index 8a6927a25..d284f543a 100644 --- a/src/ui/identities/keys/add_key_screen.rs +++ b/src/ui/identities/keys/add_key_screen.rs @@ -2,7 +2,7 @@ use crate::app::AppAction; use crate::backend_task::identity::IdentityTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; use crate::model::wallet::Wallet; @@ -607,7 +607,7 @@ impl ScreenLike for AddKeyScreen { ui.add_space(20.0); // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_identity_update(); let dark_mode = ui.ctx().style().visuals.dark_mode; diff --git a/src/ui/identities/register_dpns_name_screen.rs b/src/ui/identities/register_dpns_name_screen.rs index 86356b2b0..6016a8458 100644 --- a/src/ui/identities/register_dpns_name_screen.rs +++ b/src/ui/identities/register_dpns_name_screen.rs @@ -2,7 +2,7 @@ use crate::app::AppAction; use crate::backend_task::identity::{IdentityTask, RegisterDpnsNameInput}; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::identity_selector::IdentitySelector; @@ -473,7 +473,7 @@ impl ScreenLike for RegisterDpnsNameScreen { ui.add_space(10.0); // Fee estimation - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_document_create(); let dark_mode = ui.ctx().style().visuals.dark_mode; diff --git a/src/ui/identities/top_up_identity_screen/by_platform_address.rs b/src/ui/identities/top_up_identity_screen/by_platform_address.rs index 1a589bd46..90e83820e 100644 --- a/src/ui/identities/top_up_identity_screen/by_platform_address.rs +++ b/src/ui/identities/top_up_identity_screen/by_platform_address.rs @@ -2,7 +2,7 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::identity::IdentityTask; use crate::model::amount::Amount; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::wallet::WalletSeedHash; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::{Component, ComponentResponse}; @@ -131,7 +131,7 @@ impl TopUpIdentityScreen { ui.add_space(10.0); // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_identity_topup(); Frame::new() diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs index 9d0137023..f6fc658df 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs @@ -1,5 +1,5 @@ use crate::app::AppAction; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::ui::identities::add_new_identity_screen::FundingMethod; use crate::ui::identities::top_up_identity_screen::{TopUpIdentityScreen, WalletFundedScreenStep}; use crate::ui::theme::DashColors; @@ -95,7 +95,7 @@ impl TopUpIdentityScreen { ui.add_space(10.0); // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_identity_topup(); let dark_mode = ui.ctx().style().visuals.dark_mode; diff --git a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs index 3cf524855..1bcd0227b 100644 --- a/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs +++ b/src/ui/identities/top_up_identity_screen/by_using_unused_balance.rs @@ -1,5 +1,5 @@ use crate::app::AppAction; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::ui::identities::add_new_identity_screen::FundingMethod; use crate::ui::identities::top_up_identity_screen::{TopUpIdentityScreen, WalletFundedScreenStep}; use crate::ui::theme::DashColors; @@ -48,7 +48,7 @@ impl TopUpIdentityScreen { }; // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_identity_topup(); let dark_mode = ui.ctx().style().visuals.dark_mode; diff --git a/src/ui/identities/transfer_screen.rs b/src/ui/identities/transfer_screen.rs index 661bfe23a..9d4f35ee9 100644 --- a/src/ui/identities/transfer_screen.rs +++ b/src/ui/identities/transfer_screen.rs @@ -3,7 +3,7 @@ use crate::backend_task::identity::IdentityTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; @@ -667,7 +667,7 @@ impl ScreenLike for TransferScreen { ui.add_space(10.0); // Fee estimation - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = match self.destination_type { TransferDestinationType::Identity => fee_estimator.estimate_credit_transfer(), TransferDestinationType::PlatformAddress => { diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index 7cc8bb8c4..1ea5aebee 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -3,7 +3,7 @@ use crate::backend_task::identity::IdentityTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::encrypted_key_storage::PrivateKeyData; use crate::model::qualified_identity::{IdentityType, PrivateKeyTarget, QualifiedIdentity}; use crate::model::wallet::Wallet; @@ -520,7 +520,7 @@ impl ScreenLike for WithdrawalScreen { ui.add_space(10.0); // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_credit_withdrawal(); let dark_mode = ui.ctx().style().visuals.dark_mode; diff --git a/src/ui/tokens/burn_tokens_screen.rs b/src/ui/tokens/burn_tokens_screen.rs index 8373679f4..f4aaf7dcb 100644 --- a/src/ui/tokens/burn_tokens_screen.rs +++ b/src/ui/tokens/burn_tokens_screen.rs @@ -1,5 +1,5 @@ use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; @@ -562,7 +562,7 @@ impl ScreenLike for BurnTokensScreen { } // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions Frame::new() @@ -593,7 +593,7 @@ impl ScreenLike for BurnTokensScreen { ); // Display estimated fee before action button - let estimated_fee = PlatformFeeEstimator::new().estimate_token_transition(); + let estimated_fee = fee_estimator.estimate_token_transition(); ui.add_space(10.0); let dark_mode = ui.ctx().style().visuals.dark_mode; egui::Frame::new() diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 40d1a8c44..4d95f23d2 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -1,5 +1,5 @@ use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::ui::components::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; @@ -512,7 +512,7 @@ impl ScreenLike for ClaimTokensScreen { } // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions let dark_mode = ui.ctx().style().visuals.dark_mode; diff --git a/src/ui/tokens/destroy_frozen_funds_screen.rs b/src/ui/tokens/destroy_frozen_funds_screen.rs index 50435ae88..11d5f8784 100644 --- a/src/ui/tokens/destroy_frozen_funds_screen.rs +++ b/src/ui/tokens/destroy_frozen_funds_screen.rs @@ -3,7 +3,7 @@ use crate::app::AppAction; use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::component_trait::Component; @@ -548,7 +548,7 @@ impl ScreenLike for DestroyFrozenFundsScreen { } // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions Frame::new() diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index c118995b6..997dc7c76 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -16,7 +16,7 @@ use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; @@ -546,7 +546,7 @@ impl ScreenLike for PurchaseTokenScreen { ui.add_space(10.0); // Display estimated fee before action button - let estimated_fee = PlatformFeeEstimator::new().estimate_token_transition(); + let estimated_fee = self.app_context.fee_estimator().estimate_token_transition(); let dark_mode = ui.ctx().style().visuals.dark_mode; egui::Frame::new() .fill(crate::ui::theme::DashColors::surface(dark_mode)) diff --git a/src/ui/tokens/freeze_tokens_screen.rs b/src/ui/tokens/freeze_tokens_screen.rs index 41d9ca2f3..35ac3d6e4 100644 --- a/src/ui/tokens/freeze_tokens_screen.rs +++ b/src/ui/tokens/freeze_tokens_screen.rs @@ -3,7 +3,7 @@ use crate::app::AppAction; use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::component_trait::Component; @@ -532,7 +532,7 @@ impl ScreenLike for FreezeTokensScreen { } // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions let dark_mode = ui.ctx().style().visuals.dark_mode; @@ -564,7 +564,7 @@ impl ScreenLike for FreezeTokensScreen { ); // Display estimated fee before action button - let estimated_fee = PlatformFeeEstimator::new().estimate_token_transition(); + let estimated_fee = fee_estimator.estimate_token_transition(); ui.add_space(10.0); let dark_mode = ui.ctx().style().visuals.dark_mode; egui::Frame::new() diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index c6c96ef24..0244d6797 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -4,7 +4,7 @@ use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; @@ -611,7 +611,7 @@ impl ScreenLike for MintTokensScreen { } // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions Frame::new() @@ -642,7 +642,7 @@ impl ScreenLike for MintTokensScreen { ); // Display estimated fee before action button - let estimated_fee = PlatformFeeEstimator::new().estimate_token_transition(); + let estimated_fee = fee_estimator.estimate_token_transition(); ui.add_space(10.0); let dark_mode = ui.ctx().style().visuals.dark_mode; egui::Frame::new() diff --git a/src/ui/tokens/pause_tokens_screen.rs b/src/ui/tokens/pause_tokens_screen.rs index 00a8cd976..c6624398c 100644 --- a/src/ui/tokens/pause_tokens_screen.rs +++ b/src/ui/tokens/pause_tokens_screen.rs @@ -3,7 +3,7 @@ use crate::app::AppAction; use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::Component; @@ -459,7 +459,7 @@ impl ScreenLike for PauseTokensScreen { } // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions let dark_mode = ui.ctx().style().visuals.dark_mode; diff --git a/src/ui/tokens/resume_tokens_screen.rs b/src/ui/tokens/resume_tokens_screen.rs index f447e6ec7..7e20dac1e 100644 --- a/src/ui/tokens/resume_tokens_screen.rs +++ b/src/ui/tokens/resume_tokens_screen.rs @@ -3,7 +3,7 @@ use crate::app::AppAction; use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::Component; @@ -460,7 +460,7 @@ impl ScreenLike for ResumeTokensScreen { } // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions let dark_mode = ui.ctx().style().visuals.dark_mode; diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index b3a25a102..97392675b 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -4,7 +4,7 @@ use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::wallet::Wallet; use crate::ui::components::ComponentResponse; use crate::ui::components::amount_input::AmountInput; @@ -1080,7 +1080,7 @@ impl ScreenLike for SetTokenPriceScreen { }; // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions let dark_mode = ui.ctx().style().visuals.dark_mode; diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 77f010d71..8fffe040b 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -3,7 +3,7 @@ use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; @@ -455,7 +455,7 @@ impl ScreenLike for TransferTokensScreen { ui.add_space(10.0); // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_document_batch(1); // Token transfers are document batch transitions Frame::new() diff --git a/src/ui/tokens/unfreeze_tokens_screen.rs b/src/ui/tokens/unfreeze_tokens_screen.rs index 7e211227a..a0b97f777 100644 --- a/src/ui/tokens/unfreeze_tokens_screen.rs +++ b/src/ui/tokens/unfreeze_tokens_screen.rs @@ -3,7 +3,7 @@ use crate::app::AppAction; use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::component_trait::Component; @@ -535,7 +535,7 @@ impl ScreenLike for UnfreezeTokensScreen { } // Fee estimation display - let fee_estimator = PlatformFeeEstimator::new(); + let fee_estimator = self.app_context.fee_estimator(); let estimated_fee = fee_estimator.estimate_document_batch(1); // Token operations are document batch transitions let dark_mode = ui.ctx().style().visuals.dark_mode; diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index fdf783a3a..2aff65d1c 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -3,7 +3,7 @@ use crate::app::AppAction; use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; -use crate::model::fee_estimation::{PlatformFeeEstimator, format_credits_as_dash}; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::left_panel::add_left_panel; @@ -711,7 +711,7 @@ impl UpdateTokenConfigScreen { } // Display estimated fee before action button - let estimated_fee = PlatformFeeEstimator::new().estimate_token_transition(); + let estimated_fee = self.app_context.fee_estimator().estimate_token_transition(); ui.add_space(10.0); let dark_mode = ui.ctx().style().visuals.dark_mode; egui::Frame::new() From 6b4919c2bda16078bd998bc76ec6298ee5587cc4 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:08:51 +0700 Subject: [PATCH 064/106] fix: take from multiple platform addresses in simple send flow (#501) * fix: take from multiple platform addresses in simple send flow * address selection algorithm * fix * fix: fee payer index lookup * cleanup * fix: use PlatformFeeEstimator for address transfer fees - Replace custom fee calculation with PlatformFeeEstimator - Simplify allocation logic by calculating fee upfront - Use 20% safety buffer on fee estimates - Remove complex iterative allocation loop Co-Authored-By: Claude Opus 4.5 * fmt * Add fee deficit check in platform address allocation The fee payer must have enough remaining balance after their contribution to cover the transaction fee. This change calculates the fee deficit (if fee payer's remaining balance < estimated fee) and adds it to the shortfall, preventing impossible sends from being attempted. Co-Authored-By: Claude Opus 4.5 * Use actual input count for fee estimation in error messages Changed estimate_platform_fee calls to use addresses_available (the actual number of available inputs) instead of MAX_PLATFORM_INPUTS. This provides more accurate fee estimates and max sendable amounts in the error messages when transactions exceed available balance. Co-Authored-By: Claude Opus 4.5 * Filter destination address from preview allocation The preview allocation now parses the destination platform address and passes it to allocate_platform_addresses, ensuring the destination is never shown as an input source. This matches the behavior of the actual send logic. Co-Authored-By: Claude Opus 4.5 * fmt * Filter destination from max calculation and improve warning message The max amount calculation now excludes the destination address, matching the allocation logic. This prevents showing an inflated max when the destination is one of your own addresses. Also improved the warning message to distinguish between exceeding the address limit vs insufficient balance (including fees). Co-Authored-By: Claude Opus 4.5 * fmt * feat: use cached fee multiplier in send_screen Update estimate_platform_fee and allocate_platform_addresses to accept a PlatformFeeEstimator parameter, allowing the send screen to use the cached network fee multiplier from app_context.fee_estimator(). Co-Authored-By: Claude Opus 4.5 * fix: address review comments in send_screen - Add early return for empty sorted_addresses in allocate_platform_addresses - Use safe string slicing in render_platform_source_breakdown to avoid panic - Remove duplicate doc comment Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- src/backend_task/mod.rs | 5 +- src/backend_task/wallet/mod.rs | 5 + .../wallet/transfer_platform_credits.rs | 17 +- .../wallet/withdraw_from_platform_address.rs | 7 +- src/ui/components/amount_input.rs | 31 +- src/ui/wallets/send_screen.rs | 563 ++++++++++++++++-- 6 files changed, 553 insertions(+), 75 deletions(-) diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 7afd08192..7d8413fad 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -374,8 +374,9 @@ impl AppContext { seed_hash, inputs, outputs, + fee_payer_index, } => { - self.transfer_platform_credits(seed_hash, inputs, outputs) + self.transfer_platform_credits(seed_hash, inputs, outputs, fee_payer_index) .await } WalletTask::FundPlatformAddressFromAssetLock { @@ -397,12 +398,14 @@ impl AppContext { inputs, output_script, core_fee_per_byte, + fee_payer_index, } => { self.withdraw_from_platform_address( seed_hash, inputs, output_script, core_fee_per_byte, + fee_payer_index, ) .await } diff --git a/src/backend_task/wallet/mod.rs b/src/backend_task/wallet/mod.rs index 649c12f85..413dea742 100644 --- a/src/backend_task/wallet/mod.rs +++ b/src/backend_task/wallet/mod.rs @@ -42,6 +42,9 @@ pub enum WalletTask { inputs: BTreeMap, /// Destination addresses with amounts outputs: BTreeMap, + /// Index of the input to deduct fees from (in BTreeMap order). + /// Should be the input with the highest balance to ensure sufficient funds for fees. + fee_payer_index: u16, }, /// Fund Platform addresses from an asset lock FundPlatformAddressFromAssetLock { @@ -62,6 +65,8 @@ pub enum WalletTask { output_script: CoreScript, /// Core fee per byte core_fee_per_byte: u32, + /// Index of the input to deduct fees from (in BTreeMap order). + fee_payer_index: u16, }, /// Fund a platform address directly from wallet UTXOs /// Creates asset lock, broadcasts, waits for proof, then funds platform address diff --git a/src/backend_task/wallet/transfer_platform_credits.rs b/src/backend_task/wallet/transfer_platform_credits.rs index f9762f25a..3549af4be 100644 --- a/src/backend_task/wallet/transfer_platform_credits.rs +++ b/src/backend_task/wallet/transfer_platform_credits.rs @@ -13,6 +13,7 @@ impl AppContext { seed_hash: WalletSeedHash, inputs: BTreeMap, outputs: BTreeMap, + fee_payer_index: u16, ) -> Result { use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; use dash_sdk::platform::transition::transfer_address_funds::TransferAddressFunds; @@ -31,8 +32,20 @@ impl AppContext { (wallet, sdk) }; - // Deduct fee from the first input address (not output, which may be too small) - let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + // Deduct fee from the specified input address (should be the one with highest balance). + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput( + fee_payer_index, + )]; + + tracing::info!( + "transfer_platform_credits: fee_payer_index={}, inputs={}, outputs={}", + fee_payer_index, + inputs.len(), + outputs.len() + ); + for (idx, (addr, amount)) in inputs.iter().enumerate() { + tracing::info!(" Input {}: {:?} -> {}", idx, addr, amount); + } // Use the SDK to transfer - returns proof-verified updated address infos let address_infos = sdk diff --git a/src/backend_task/wallet/withdraw_from_platform_address.rs b/src/backend_task/wallet/withdraw_from_platform_address.rs index 65268ff66..a12b8948f 100644 --- a/src/backend_task/wallet/withdraw_from_platform_address.rs +++ b/src/backend_task/wallet/withdraw_from_platform_address.rs @@ -16,6 +16,7 @@ impl AppContext { inputs: BTreeMap, output_script: CoreScript, core_fee_per_byte: u32, + fee_payer_index: u16, ) -> Result { use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; use dash_sdk::dpp::withdrawal::Pooling; @@ -35,8 +36,10 @@ impl AppContext { (wallet, sdk) }; - // Simple fee strategy: deduct from first input - let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + // Deduct fee from the specified input (should be the one with highest balance) + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput( + fee_payer_index, + )]; // Use the SDK to withdraw let _result = sdk diff --git a/src/ui/components/amount_input.rs b/src/ui/components/amount_input.rs index 31d5d09d2..687bb216f 100644 --- a/src/ui/components/amount_input.rs +++ b/src/ui/components/amount_input.rs @@ -91,6 +91,8 @@ pub struct AmountInput { show_validation_errors: bool, // When true, we enforce that the input was changed, even if text edit didn't change. changed: bool, + /// Optional hint explaining why the maximum is set (e.g., "fees reserved") + max_exceeded_hint: Option, } impl AmountInput { @@ -120,6 +122,7 @@ impl AmountInput { desired_width: None, show_validation_errors: true, // Default to showing validation errors changed: true, // Start as changed to force initial validation + max_exceeded_hint: None, } } @@ -216,6 +219,20 @@ impl AmountInput { self } + /// Sets a hint explaining why the maximum is limited (e.g., "fees reserved"). + /// This hint is appended to the error message when the max is exceeded. + pub fn with_max_exceeded_hint(mut self, hint: impl Into) -> Self { + self.max_exceeded_hint = Some(hint.into()); + self + } + + /// Sets a hint explaining why the maximum is limited (mutable reference version). + /// Use this for dynamic configuration when the hint changes at runtime. + pub fn set_max_exceeded_hint(&mut self, hint: Option) -> &mut Self { + self.max_exceeded_hint = hint; + self + } + /// Sets the minimum amount allowed. Defaults to 1 (must be greater than zero). /// Set to Some(0) to allow zero amounts, or None to disable minimum validation. pub fn with_min_amount(mut self, min_amount: Option) -> Self { @@ -279,11 +296,15 @@ impl AmountInput { if let Some(max_amount) = self.max_amount && amount.value() > max_amount { - return Err(format!( - "Amount {} exceeds allowed maximum {}", - amount, - Amount::new(max_amount, self.decimal_places) - )); + let max_formatted = Amount::new(max_amount, self.decimal_places); + return Err(if let Some(ref hint) = self.max_exceeded_hint { + format!( + "Amount {} exceeds maximum {}. {}", + amount, max_formatted, hint + ) + } else { + format!("Amount {} exceeds maximum {}", amount, max_formatted) + }); } // Check if amount is below minimum diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 4a5b5e340..f9da86e3e 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -27,6 +27,159 @@ use std::collections::BTreeMap; use std::sync::{Arc, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; +/// Maximum number of platform address inputs allowed per state transition +const MAX_PLATFORM_INPUTS: usize = 16; + +use crate::model::fee_estimation::PlatformFeeEstimator; + +/// Estimated serialized bytes per input (address + signature/witness data) +const ESTIMATED_BYTES_PER_INPUT: usize = 225; + +/// Calculate the estimated fee for a platform address funds transfer. +/// +/// Uses PlatformFeeEstimator for base costs (input/output fees) plus storage fees. +fn estimate_platform_fee(estimator: &PlatformFeeEstimator, input_count: usize) -> u64 { + let inputs = input_count.max(1); + + // Base fee from Platform's min fee structure + // - 500,000 credits per input (address_funds_transfer_input_cost) + // - 6,000,000 credits per output (address_funds_transfer_output_cost) + let base_fee = estimator.estimate_address_funds_transfer(inputs, 1); + + // Add storage fees for serialized input bytes only + // (outputs don't add significant serialization overhead) + let estimated_bytes = inputs * ESTIMATED_BYTES_PER_INPUT; + let storage_fee = estimator.estimate_storage_based_fee(estimated_bytes, inputs); + + // Total with 20% safety buffer + let total = base_fee.saturating_add(storage_fee); + total.saturating_add(total / 5) +} + +/// Result of allocating platform addresses for a transfer. +#[derive(Debug, Clone)] +struct AddressAllocationResult { + /// Map of platform address to amount to transfer from each + inputs: BTreeMap, + /// Index of the fee payer in BTreeMap iteration order + fee_payer_index: u16, + /// Estimated fee for this transaction + estimated_fee: u64, + /// Amount that couldn't be covered (0 if fully covered) + shortfall: u64, + /// Addresses sorted by balance descending (for UI display) + sorted_addresses: Vec<(PlatformAddress, Address, u64)>, +} + +/// Allocates platform addresses for a transfer, selecting which addresses to use +/// and how much from each. +/// +/// Algorithm: +/// 1. Filters out the destination address (can't be both input and output) +/// 2. Sorts addresses by balance descending (largest first) +/// 3. The highest-balance address pays the fee +/// 4. Iteratively allocates until fee estimate converges +/// 5. Fee payer is always included in inputs (even with 0 contribution) so fee can be deducted +/// +/// Returns the allocation result with inputs, fee payer index, and any shortfall. +fn allocate_platform_addresses( + estimator: &PlatformFeeEstimator, + addresses: &[(PlatformAddress, Address, u64)], + amount_credits: u64, + destination: Option<&PlatformAddress>, +) -> AddressAllocationResult { + // Filter out the destination address if provided (protocol doesn't allow same address as input and output) + let filtered: Vec<_> = addresses + .iter() + .filter(|(platform_addr, _, _)| destination != Some(platform_addr)) + .cloned() + .collect(); + + // Sort addresses by balance descending so the largest balance is used first + let mut sorted_addresses = filtered; + sorted_addresses.sort_by(|a, b| b.2.cmp(&a.2)); + + // Early return if no addresses available after filtering + if sorted_addresses.is_empty() { + return AddressAllocationResult { + inputs: BTreeMap::new(), + fee_payer_index: 0, + estimated_fee: estimate_platform_fee(estimator, 1), + shortfall: amount_credits, + sorted_addresses: vec![], + }; + } + + // The highest-balance address (first in sorted order) will pay the fee + let fee_payer_addr = sorted_addresses.first().map(|(addr, _, _)| *addr); + + // Calculate fee based on expected number of inputs (use worst-case for safety) + // This matches what the Max button calculation uses + let max_inputs = sorted_addresses.len().min(MAX_PLATFORM_INPUTS); + let estimated_fee = estimate_platform_fee(estimator, max_inputs.max(1)); + + // Allocate inputs = outputs (protocol requires equality). + // Fee payer must reserve enough remaining balance to pay the fee separately. + let mut inputs = BTreeMap::new(); + let mut remaining = amount_credits; + + for (idx, (platform_addr, _, balance)) in sorted_addresses.iter().enumerate() { + if remaining == 0 || inputs.len() >= MAX_PLATFORM_INPUTS { + break; + } + // Fee payer (idx 0, highest balance) must keep fee in reserve + let is_fee_payer = idx == 0; + let available = if is_fee_payer { + balance.saturating_sub(estimated_fee) + } else { + *balance + }; + let use_amount = remaining.min(available); + // Fee payer must always be in inputs so fee can be deducted from their balance + if use_amount > 0 || is_fee_payer { + inputs.insert(*platform_addr, use_amount); + remaining = remaining.saturating_sub(use_amount); + } + } + + // Calculate shortfall (amount we couldn't allocate) + let total_allocated: u64 = inputs.values().sum(); + let allocation_shortfall = amount_credits.saturating_sub(total_allocated); + + // Check if fee payer can actually afford the fee from their remaining balance. + // Fee payer's remaining balance = their original balance - their contribution. + // If remaining < estimated_fee, we have a fee deficit. + let fee_deficit = if let Some(fee_payer) = fee_payer_addr { + let fee_payer_balance = sorted_addresses.first().map(|(_, _, b)| *b).unwrap_or(0); + let fee_payer_contribution = inputs.get(&fee_payer).copied().unwrap_or(0); + let fee_payer_remaining = fee_payer_balance.saturating_sub(fee_payer_contribution); + estimated_fee.saturating_sub(fee_payer_remaining) + } else { + estimated_fee // No fee payer means we can't pay the fee at all + }; + + let shortfall = allocation_shortfall.saturating_add(fee_deficit); + + // Find the index of the fee payer in BTreeMap order (required by backend) + let fee_payer_index = fee_payer_addr + .and_then(|payer| { + inputs + .keys() + .enumerate() + .find(|(_, addr)| **addr == payer) + .map(|(idx, _)| idx as u16) + }) + .unwrap_or(0); + + AddressAllocationResult { + inputs, + fee_payer_index, + estimated_fee, + shortfall, + sorted_addresses, + } +} + /// Detected address type #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AddressType { @@ -40,8 +193,8 @@ pub enum AddressType { pub enum SourceSelection { /// Use Core wallet UTXOs CoreWallet, - /// Use a specific Platform address (stores both platform address and original core address for lookup) - PlatformAddress(PlatformAddress, Address), + /// Use all Platform addresses (stores list of platform address, core address, and balance) + PlatformAddresses(Vec<(PlatformAddress, Address, u64)>), } /// Status of the send operation @@ -332,10 +485,10 @@ impl WalletSendScreen { match (&self.selected_source, dest_type) { (Some(SourceSelection::CoreWallet), AddressType::Core) => "Core Transaction", (Some(SourceSelection::CoreWallet), AddressType::Platform) => "Fund Platform Address", - (Some(SourceSelection::PlatformAddress(_, _)), AddressType::Platform) => { + (Some(SourceSelection::PlatformAddresses(_)), AddressType::Platform) => { "Platform Transfer" } - (Some(SourceSelection::PlatformAddress(_, _)), AddressType::Core) => "Withdraw to Core", + (Some(SourceSelection::PlatformAddresses(_)), AddressType::Core) => "Withdraw to Core", _ => "Send", } } @@ -385,11 +538,11 @@ impl WalletSendScreen { (SourceSelection::CoreWallet, AddressType::Platform) => { self.send_core_to_platform(seed_hash) } - (SourceSelection::PlatformAddress(platform_addr, core_addr), AddressType::Platform) => { - self.send_platform_to_platform(seed_hash, platform_addr, core_addr) + (SourceSelection::PlatformAddresses(addresses), AddressType::Platform) => { + self.send_platform_to_platform(seed_hash, addresses) } - (SourceSelection::PlatformAddress(platform_addr, core_addr), AddressType::Core) => { - self.send_platform_to_core(seed_hash, platform_addr, core_addr, network) + (SourceSelection::PlatformAddresses(addresses), AddressType::Core) => { + self.send_platform_to_core(seed_hash, addresses, network) } _ => Err("Invalid source/destination combination".to_string()), } @@ -492,8 +645,7 @@ impl WalletSendScreen { fn send_platform_to_platform( &mut self, seed_hash: WalletSeedHash, - source_addr: PlatformAddress, - source_core_addr: Address, + addresses: Vec<(PlatformAddress, Address, u64)>, ) -> Result { // Amount in credits (Amount stores in credits for DASH with 11 decimal places) let amount_credits = self @@ -505,23 +657,26 @@ impl WalletSendScreen { return Err("Amount must be greater than 0".to_string()); } - // Check balance using the original core address - let wallet = self.selected_wallet.as_ref().ok_or("No wallet")?; - let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + // Get fee estimator with current network multiplier + let fee_estimator = self.app_context.fee_estimator(); - let balance = wallet_guard - .get_platform_address_info(&source_core_addr) - .map(|info| info.balance) - .unwrap_or(0); + // Calculate total balance across all platform addresses + let total_balance: u64 = addresses.iter().map(|(_, _, balance)| *balance).sum(); + + tracing::debug!( + "Platform transfer: {} requested, {} total balance across {} addresses", + Self::format_credits(amount_credits), + Self::format_credits(total_balance), + addresses.len() + ); - if amount_credits > balance { + if amount_credits > total_balance { return Err(format!( "Insufficient balance. Need {} but have {}", Self::format_credits(amount_credits), - Self::format_credits(balance) + Self::format_credits(total_balance) )); } - drop(wallet_guard); // Parse destination platform address let address_str = self.destination_address.trim(); @@ -529,12 +684,76 @@ impl WalletSendScreen { .map(|(addr, _)| addr) .map_err(|e| format!("Invalid platform address: {}", e))?; - let mut inputs = BTreeMap::new(); - inputs.insert(source_addr, amount_credits); + // Allocate addresses using the helper function + let allocation = allocate_platform_addresses( + &fee_estimator, + &addresses, + amount_credits, + Some(&destination), + ); + + if allocation.sorted_addresses.is_empty() { + return Err( + "Cannot send to your own address. The destination must be different from your source addresses." + .to_string(), + ); + } + + // Check available balance after filtering out destination + let available_balance: u64 = allocation.sorted_addresses.iter().map(|(_, _, b)| *b).sum(); + if amount_credits > available_balance { + return Err(format!( + "Insufficient balance from other addresses. Need {} but have {} (excluding destination address)", + Self::format_credits(amount_credits), + Self::format_credits(available_balance) + )); + } + + if allocation.shortfall > 0 { + // Calculate the max we can send with MAX_PLATFORM_INPUTS addresses (minus fees) + let addresses_available = allocation.sorted_addresses.len().min(MAX_PLATFORM_INPUTS); + let max_balance: u64 = allocation + .sorted_addresses + .iter() + .take(MAX_PLATFORM_INPUTS) + .map(|(_, _, b)| *b) + .sum(); + let max_fee = estimate_platform_fee(&fee_estimator, addresses_available); + let max_sendable = max_balance.saturating_sub(max_fee); + + return Err(format!( + "Requested amount {} exceeds maximum {} for a single transaction.\n\n\ + Details:\n\ + • You have {} addresses with a combined balance of {}\n\ + • Protocol limit: {} input addresses per transaction\n\ + • Estimated fee: {} (for {} inputs)\n\ + • Shortfall: {}\n\n\ + Try reducing the amount slightly to account for fees.", + Self::format_credits(amount_credits), + Self::format_credits(max_sendable), + addresses_available, + Self::format_credits(max_balance), + MAX_PLATFORM_INPUTS, + Self::format_credits(allocation.estimated_fee), + allocation.inputs.len(), + Self::format_credits(allocation.shortfall) + )); + } let mut outputs = BTreeMap::new(); outputs.insert(destination, amount_credits); + // Log transfer summary + let total_input: u64 = allocation.inputs.values().sum(); + tracing::debug!( + "Platform transfer: {} inputs totaling {}, output {}, fee {} (payer idx {})", + allocation.inputs.len(), + Self::format_credits(total_input), + Self::format_credits(amount_credits), + Self::format_credits(allocation.estimated_fee), + allocation.fee_payer_index + ); + let now = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Time went backwards") @@ -544,8 +763,9 @@ impl WalletSendScreen { Ok(AppAction::BackendTask(BackendTask::WalletTask( WalletTask::TransferPlatformCredits { seed_hash, - inputs, + inputs: allocation.inputs, outputs, + fee_payer_index: allocation.fee_payer_index, }, ))) } @@ -553,8 +773,7 @@ impl WalletSendScreen { fn send_platform_to_core( &mut self, seed_hash: WalletSeedHash, - source_addr: PlatformAddress, - source_core_addr: Address, + addresses: Vec<(PlatformAddress, Address, u64)>, network: dash_sdk::dpp::dashcore::Network, ) -> Result { // Amount in credits @@ -567,23 +786,26 @@ impl WalletSendScreen { return Err("Amount must be greater than 0".to_string()); } - // Check balance using the original core address - let wallet = self.selected_wallet.as_ref().ok_or("No wallet")?; - let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + // Get fee estimator with current network multiplier + let fee_estimator = self.app_context.fee_estimator(); - let balance = wallet_guard - .get_platform_address_info(&source_core_addr) - .map(|info| info.balance) - .unwrap_or(0); + // Calculate total balance across all platform addresses + let total_balance: u64 = addresses.iter().map(|(_, _, balance)| *balance).sum(); + + tracing::debug!( + "Platform withdrawal: {} requested, {} total balance across {} addresses", + Self::format_credits(amount_credits), + Self::format_credits(total_balance), + addresses.len() + ); - if amount_credits > balance { + if amount_credits > total_balance { return Err(format!( "Insufficient balance. Need {} but have {}", Self::format_credits(amount_credits), - Self::format_credits(balance) + Self::format_credits(total_balance) )); } - drop(wallet_guard); // Parse destination Core address let address_str = self.destination_address.trim(); @@ -596,8 +818,51 @@ impl WalletSendScreen { let output_script = CoreScript::new(dest_address.script_pubkey()); - let mut inputs = BTreeMap::new(); - inputs.insert(source_addr, amount_credits); + // Allocate addresses using the helper function (no destination filter for withdrawals) + let allocation = + allocate_platform_addresses(&fee_estimator, &addresses, amount_credits, None); + + if allocation.shortfall > 0 { + // Calculate the max we can send with MAX_PLATFORM_INPUTS addresses (minus fees) + let addresses_available = allocation.sorted_addresses.len().min(MAX_PLATFORM_INPUTS); + let max_balance: u64 = allocation + .sorted_addresses + .iter() + .take(MAX_PLATFORM_INPUTS) + .map(|(_, _, b)| *b) + .sum(); + let max_fee = estimate_platform_fee(&fee_estimator, addresses_available); + let max_sendable = max_balance.saturating_sub(max_fee); + + return Err(format!( + "Requested withdrawal {} exceeds maximum {} for a single transaction.\n\n\ + Details:\n\ + • You have {} Platform addresses with a combined balance of {}\n\ + • Protocol limit: {} input addresses per transaction\n\ + • Estimated fee: {} (for {} inputs)\n\ + • Shortfall: {}\n\n\ + Try reducing the amount slightly to account for fees.", + Self::format_credits(amount_credits), + Self::format_credits(max_sendable), + addresses_available, + Self::format_credits(max_balance), + MAX_PLATFORM_INPUTS, + Self::format_credits(allocation.estimated_fee), + allocation.inputs.len(), + Self::format_credits(allocation.shortfall) + )); + } + + // Log withdrawal summary + let total_input: u64 = allocation.inputs.values().sum(); + tracing::debug!( + "Platform withdrawal: {} inputs totaling {}, withdraw {}, fee {} (payer idx {})", + allocation.inputs.len(), + Self::format_credits(total_input), + Self::format_credits(amount_credits), + Self::format_credits(allocation.estimated_fee), + allocation.fee_payer_index + ); let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -608,9 +873,10 @@ impl WalletSendScreen { Ok(AppAction::BackendTask(BackendTask::WalletTask( WalletTask::WithdrawFromPlatformAddress { seed_hash, - inputs, + inputs: allocation.inputs, output_script, core_fee_per_byte: 1, + fee_payer_index: allocation.fee_payer_index, }, ))) } @@ -665,6 +931,11 @@ impl WalletSendScreen { // Amount self.render_amount_input(ui); + ui.add_space(10.0); + + // Platform source breakdown (shows which addresses will be used) + self.render_platform_source_breakdown(ui); + ui.add_space(10.0); ui.separator(); ui.add_space(10.0); @@ -767,10 +1038,10 @@ impl WalletSendScreen { // Calculate total platform balance let total_platform_balance: u64 = platform_addresses.iter().map(|(_, _, b)| *b).sum(); - // Check if any platform address is selected + // Check if platform addresses are selected let is_platform_selected = matches!( &self.selected_source, - Some(SourceSelection::PlatformAddress(_, _)) + Some(SourceSelection::PlatformAddresses(_)) ); Frame::group(ui.style()) @@ -790,14 +1061,15 @@ impl WalletSendScreen { ui.horizontal(|ui| { let mut selected = is_platform_selected; if ui.radio_value(&mut selected, true, "").changed() && selected { - // Select the first platform address with balance - if let Some((core_addr, platform_addr, _)) = platform_addresses.first() - { - self.selected_source = Some(SourceSelection::PlatformAddress( - *platform_addr, - core_addr.clone(), - )); - } + // Select all platform addresses + let addresses_with_balances: Vec<_> = platform_addresses + .iter() + .map(|(core_addr, platform_addr, balance)| { + (*platform_addr, core_addr.clone(), *balance) + }) + .collect(); + self.selected_source = + Some(SourceSelection::PlatformAddresses(addresses_with_balances)); } ui.label( RichText::new("Platform Addresses") @@ -871,6 +1143,7 @@ impl WalletSendScreen { fn render_amount_input(&mut self, ui: &mut Ui) { let dark_mode = ui.ctx().style().visuals.dark_mode; + let fee_estimator = self.app_context.fee_estimator(); ui.label( RichText::new("Amount") @@ -881,23 +1154,53 @@ impl WalletSendScreen { ui.add_space(8.0); - // Get max amount based on source selection - let max_amount_credits = match &self.selected_source { - Some(SourceSelection::CoreWallet) => self.selected_wallet.as_ref().and_then(|w| { - w.read() - .ok() - .map(|wallet| wallet.total_balance_duffs() * 1000) // duffs to credits - }), - Some(SourceSelection::PlatformAddress(_, core_addr)) => { - self.selected_wallet.as_ref().and_then(|w| { - w.read().ok().and_then(|wallet| { - wallet - .get_platform_address_info(core_addr) - .map(|info| info.balance) - }) - }) + // Get max amount and hint based on source selection + let (max_amount_credits, max_hint) = match &self.selected_source { + Some(SourceSelection::CoreWallet) => { + let max = self.selected_wallet.as_ref().and_then(|w| { + w.read() + .ok() + .map(|wallet| wallet.total_balance_duffs() * 1000) // duffs to credits + }); + (max, None) + } + Some(SourceSelection::PlatformAddresses(addresses)) => { + // Parse destination to exclude it from max calculation (can't send to yourself) + let destination = + PlatformAddress::from_bech32m_string(self.destination_address.trim()) + .map(|(addr, _)| addr) + .ok(); + + // Filter out destination and sort by balance descending + let mut sorted_addresses: Vec<_> = addresses + .iter() + .filter(|(addr, _, _)| destination.as_ref() != Some(addr)) + .cloned() + .collect(); + sorted_addresses.sort_by(|a, b| b.2.cmp(&a.2)); + + // Sum balances from top addresses, limited by MAX_PLATFORM_INPUTS. + let usable_count = sorted_addresses.len().min(MAX_PLATFORM_INPUTS); + let total: u64 = sorted_addresses + .iter() + .take(MAX_PLATFORM_INPUTS) + .map(|(_, _, balance)| *balance) + .sum(); + let max_fee = estimate_platform_fee(&fee_estimator, usable_count); + + // Build hint explaining the limit + let hint = if sorted_addresses.len() > MAX_PLATFORM_INPUTS { + format!( + "Limited to {} input addresses per transaction, ~{} reserved for fees", + MAX_PLATFORM_INPUTS, + Self::format_credits(max_fee) + ) + } else { + format!("~{} reserved for fees", Self::format_credits(max_fee)) + }; + (Some(total.saturating_sub(max_fee)), Some(hint)) } - None => None, + None => (None, None), }; Frame::group(ui.style()) @@ -912,8 +1215,9 @@ impl WalletSendScreen { .with_desired_width(150.0) }); - // Update max amount dynamically + // Update max amount and hint dynamically amount_input.set_max_amount(max_amount_credits); + amount_input.set_max_exceeded_hint(max_hint); let response = amount_input.show(ui); response.inner.update(&mut self.amount); @@ -959,6 +1263,113 @@ impl WalletSendScreen { } } + /// Renders a breakdown of which platform addresses will be used and how much from each. + /// Uses the same allocation algorithm as the actual send logic. + fn render_platform_source_breakdown(&self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let network = self.app_context.network; + let fee_estimator = self.app_context.fee_estimator(); + + // Only show for platform address sources with a valid amount + let addresses = match &self.selected_source { + Some(SourceSelection::PlatformAddresses(addrs)) if !addrs.is_empty() => addrs, + _ => return, + }; + + let amount_credits = match self.amount.as_ref() { + Some(a) if a.value() > 0 => a.value(), + _ => return, + }; + + // Parse destination platform address (if valid) to exclude it from inputs + let destination = PlatformAddress::from_bech32m_string(self.destination_address.trim()) + .map(|(addr, _)| addr) + .ok(); + + // Use the same allocation algorithm as the send logic, filtering out the destination + let allocation = allocate_platform_addresses( + &fee_estimator, + addresses, + amount_credits, + destination.as_ref(), + ); + + if allocation.inputs.is_empty() { + return; + } + + let hit_limit = allocation.shortfall > 0; + + Frame::group(ui.style()) + .fill(DashColors::surface(dark_mode).gamma_multiply(0.5)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(4.0) + .show(ui, |ui| { + ui.label( + RichText::new("Source breakdown:") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + ui.add_space(4.0); + + for (platform_addr, use_amount) in &allocation.inputs { + let addr_str = platform_addr.to_bech32m_string(network); + let short_addr = if addr_str.len() >= 18 { + format!("{}...{}", &addr_str[..12], &addr_str[addr_str.len() - 6..]) + } else { + addr_str.clone() + }; + ui.horizontal(|ui| { + ui.label( + RichText::new(&short_addr) + .monospace() + .color(DashColors::text_primary(dark_mode)) + .size(11.0), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new(Self::format_credits(*use_amount)) + .color(DashColors::SUCCESS) + .size(11.0), + ); + }); + }); + } + + ui.add_space(4.0); + + if hit_limit { + // Determine if the shortfall is due to address limit or insufficient balance + let exceeds_address_limit = + allocation.sorted_addresses.len() > MAX_PLATFORM_INPUTS; + let warning_msg = if exceeds_address_limit { + format!( + "Warning: Amount requires more than {} addresses. \ + Reduce amount or use multiple transactions.", + MAX_PLATFORM_INPUTS + ) + } else { + "Warning: Amount exceeds available balance (including fees).".to_string() + }; + ui.label( + RichText::new(warning_msg) + .color(DashColors::WARNING) + .size(10.0), + ); + ui.add_space(2.0); + } + + ui.label( + RichText::new( + "Use Advanced Options to customize which addresses to send from.", + ) + .color(DashColors::text_secondary(dark_mode)) + .italics() + .size(10.0), + ); + }); + } + fn render_send_button(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; @@ -1860,6 +2271,16 @@ impl WalletSendScreen { return Err("No valid Platform outputs specified".to_string()); } + // Find the input with the highest amount to be the fee payer. + // In advanced mode, user specifies amounts (we don't know balances), so we pick + // the input with the largest contribution as fee payer. + let fee_payer_index = inputs + .iter() + .enumerate() + .max_by_key(|(_, (_, amount))| *amount) + .map(|(idx, _)| idx as u16) + .unwrap_or(0); + let now = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Time went backwards") @@ -1871,6 +2292,7 @@ impl WalletSendScreen { seed_hash, inputs, outputs, + fee_payer_index, }, ))) } @@ -1911,6 +2333,16 @@ impl WalletSendScreen { let output_script = CoreScript::new(dest_address.script_pubkey()); + // Find the input with the highest amount to be the fee payer. + // In advanced mode, user specifies amounts (we don't know balances), so we pick + // the input with the largest contribution as fee payer. + let fee_payer_index = inputs + .iter() + .enumerate() + .max_by_key(|(_, (_, amount))| *amount) + .map(|(idx, _)| idx as u16) + .unwrap_or(0); + let now = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Time went backwards") @@ -1923,6 +2355,7 @@ impl WalletSendScreen { inputs, output_script, core_fee_per_byte: 1, + fee_payer_index, }, ))) } From 8980f352f9d3c194c0c93c9759ea9fcccba9694c Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Wed, 28 Jan 2026 16:14:33 +0700 Subject: [PATCH 065/106] fix: prevent platform address balance doubling after internal updates (#507) * fix: prevent platform address balance doubling after internal updates Adds last_full_sync_balance column to track the balance checkpoint for terminal sync pre-population, separate from the current balance. Key changes: - Add DB migration (v26) for last_full_sync_balance column - Sync operations update last_full_sync_balance for next sync baseline - Internal updates (after transfers) preserve last_full_sync_balance - Terminal sync pre-populates with last_full_sync_balance, applies AddToCredits correctly without double-counting This fixes the scenario where: 1. Transfer completes, balance updated internally 2. App restarts, refresh triggers terminal sync 3. Same AddToCredits was being re-applied because the pre-population baseline wasn't distinguishing between sync and internal updates Co-Authored-By: Claude Opus 4.5 * docs: fix docblocks for is_sync_operation and last_full_sync_balance - Remove duplicate/obsolete is_full_sync wording from set_platform_address_info - Update last_full_sync_balance field doc to accurately describe when it's updated (during syncs) vs preserved (during internal updates) Co-Authored-By: Claude Opus 4.5 * fix: preserve last_full_sync_balance when removing duplicate address entries Search for last_full_sync_balance from any canonical-equivalent entry BEFORE removing duplicates, so the value isn't lost when the existing entry has a different Address representation. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .../wallet/fetch_platform_address_balances.rs | 16 +-- src/context.rs | 4 +- src/database/initialization.rs | 40 +++++++- src/database/wallet.rs | 90 ++++++++++++----- src/model/wallet/mod.rs | 97 ++++++++++++------- 5 files changed, 179 insertions(+), 68 deletions(-) diff --git a/src/backend_task/wallet/fetch_platform_address_balances.rs b/src/backend_task/wallet/fetch_platform_address_balances.rs index 7dd036a19..d07f7e030 100644 --- a/src/backend_task/wallet/fetch_platform_address_balances.rs +++ b/src/backend_task/wallet/fetch_platform_address_balances.rs @@ -196,20 +196,21 @@ impl AppContext { let wallet = wallet_arc.read().map_err(|e| e.to_string())?; for (core_addr, platform_addr) in wallet.platform_addresses(self.network) { if let Some(info) = wallet.get_platform_address_info(&core_addr) { - // Only pre-populate if we have a last_synced_balance + // Only pre-populate if we have a last_full_sync_balance // (meaning this address was found in a previous full sync) - if let Some(synced_balance) = info.last_synced_balance { + // This prevents double-counting AddToCredits after app restart + if let Some(full_sync_balance) = info.last_full_sync_balance { let lookup_addr = platform_addr.to_address_with_network(self.network); - provider.update_balance(&lookup_addr, synced_balance); + provider.update_balance(&lookup_addr, full_sync_balance); pre_populated_count += 1; tracing::debug!( - "Pre-populated balance for {}: {} (last synced)", + "Pre-populated balance for {}: {} (from last full sync)", platform_addr.to_bech32m_string(self.network), - synced_balance + full_sync_balance ); } else { tracing::debug!( - "Skipping pre-population for {} (no last_synced_balance, likely from proof)", + "Skipping pre-population for {} (no last_full_sync_balance, needs full sync)", platform_addr.to_bech32m_string(self.network) ); } @@ -259,6 +260,7 @@ impl AppContext { let balances = { let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; + // Update wallet with synced balances (also updates last_full_sync_balance for next sync) provider.apply_results_to_wallet(&mut wallet); // Persist addresses and balances to database @@ -284,12 +286,14 @@ impl AppContext { // Persist balance to platform_address_balances table // Use the nonce from AddressFunds which comes directly from SDK sync + // This is a sync operation, so update last_full_sync_balance if let Err(e) = self.db.set_platform_address_info( &seed_hash, address, funds.balance, funds.nonce, &self.network, + true, // Sync operation - update last_full_sync_balance ) { tracing::warn!("Failed to persist Platform address info: {}", e); } diff --git a/src/context.rs b/src/context.rs index cb96b06d6..200ef77fa 100644 --- a/src/context.rs +++ b/src/context.rs @@ -572,13 +572,15 @@ impl AppContext { // Update in-memory wallet state wallet.set_platform_address_info(core_addr.clone(), info.balance, info.nonce); - // Update database + // Update database (not a sync operation - preserve last_full_sync_balance + // so the next terminal sync can correctly apply any pending AddToCredits) if let Err(e) = self.db.set_platform_address_info( &seed_hash, &core_addr, info.balance, info.nonce, &self.network, + false, // Not a sync operation ) { tracing::warn!("Failed to store Platform address info in database: {}", e); } diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 02f747576..b09c7b071 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -4,7 +4,7 @@ use rusqlite::{Connection, params}; use std::fs; use std::path::Path; -pub const DEFAULT_DB_VERSION: u16 = 25; +pub const DEFAULT_DB_VERSION: u16 = 26; pub const DEFAULT_NETWORK: &str = "dash"; @@ -51,6 +51,9 @@ impl Database { fn apply_version_changes(&self, version: u16, tx: &Connection) -> rusqlite::Result<()> { match version { + 26 => { + self.add_last_full_sync_balance_column(tx)?; + } 25 => { self.add_avatar_bytes_column(tx)?; } @@ -342,6 +345,7 @@ impl Database { nonce INTEGER NOT NULL DEFAULT 0, network TEXT NOT NULL, updated_at INTEGER NOT NULL DEFAULT 0, + last_full_sync_balance INTEGER DEFAULT NULL, PRIMARY KEY (seed_hash, address, network), FOREIGN KEY (seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE )", @@ -784,6 +788,40 @@ impl Database { Ok(()) } + + /// Migration: Add last_full_sync_balance column to platform_address_balances table (version 26). + /// Stores the balance from the last FULL sync (checkpoint), separate from the current balance + /// which includes terminal sync updates. This prevents double-counting AddToCredits during + /// terminal-only syncs after app restart. + fn add_last_full_sync_balance_column(&self, conn: &Connection) -> rusqlite::Result<()> { + // Check if platform_address_balances table exists + let table_exists: bool = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='platform_address_balances'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + )?; + + if table_exists { + // Check if last_full_sync_balance column already exists + let has_column: bool = conn + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('platform_address_balances') WHERE name='last_full_sync_balance'", + [], + |row| row.get::<_, i32>(0).map(|count| count > 0), + ) + .unwrap_or(false); + + if !has_column { + // Add column with NULL default - existing rows will need a full sync to populate + conn.execute( + "ALTER TABLE platform_address_balances ADD COLUMN last_full_sync_balance INTEGER DEFAULT NULL", + [], + )?; + } + } + + Ok(()) + } } #[cfg(test)] diff --git a/src/database/wallet.rs b/src/database/wallet.rs index a0918cc68..92226981d 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -837,20 +837,27 @@ impl Database { ); // Load platform address info for each wallet (using existing connection to avoid deadlock) let mut platform_stmt = conn.prepare( - "SELECT seed_hash, address, balance, nonce FROM platform_address_balances WHERE network = ?", + "SELECT seed_hash, address, balance, nonce, last_full_sync_balance FROM platform_address_balances WHERE network = ?", )?; let platform_rows = platform_stmt.query_map([network_str.clone()], |row| { let seed_hash: Vec = row.get(0)?; let address_str: String = row.get(1)?; let balance: i64 = row.get(2)?; let nonce: i64 = row.get(3)?; + let last_full_sync_balance: Option = row.get(4)?; let seed_hash_array: [u8; 32] = seed_hash.try_into().expect("Seed hash should be 32 bytes"); - Ok((seed_hash_array, address_str, balance as u64, nonce as u32)) + Ok(( + seed_hash_array, + address_str, + balance as u64, + nonce as u32, + last_full_sync_balance.map(|b| b as u64), + )) })?; for row in platform_rows { - if let Ok((seed_hash, address_str, balance, nonce)) = row + if let Ok((seed_hash, address_str, balance, nonce, last_full_sync_balance)) = row && let Some(wallet) = wallets_map.get_mut(&seed_hash) && let Ok(address) = Address::::from_str(&address_str) { @@ -869,8 +876,9 @@ impl Database { crate::model::wallet::PlatformAddressInfo { balance, nonce, - // Assume database balance is from sync (safe default) - last_synced_balance: Some(balance), + // Use the stored last_full_sync_balance from the database + // This is the balance from the last FULL sync checkpoint, not including terminal updates + last_full_sync_balance, }, ); } @@ -880,7 +888,12 @@ impl Database { Ok(wallets_map.into_values().collect()) } - /// Store or update Platform address balance and nonce + /// Store or update Platform address balance and nonce. + /// + /// When `is_sync_operation` is true, also updates `last_full_sync_balance` to the current + /// balance. This should be true for sync operations (full or terminal) and false for + /// internal updates (e.g., after a transfer completes), so that subsequent terminal syncs + /// can correctly apply any pending AddToCredits. pub fn set_platform_address_info( &self, seed_hash: &[u8; 32], @@ -888,6 +901,7 @@ impl Database { balance: u64, nonce: u32, network: &Network, + is_sync_operation: bool, ) -> rusqlite::Result<()> { let network_str = network.to_string(); let canonical_address = Wallet::canonical_address(address, *network); @@ -897,19 +911,49 @@ impl Database { .unwrap_or_default() .as_secs() as i64; - self.execute( - "INSERT OR REPLACE INTO platform_address_balances - (seed_hash, address, balance, nonce, network, updated_at) - VALUES (?, ?, ?, ?, ?, ?)", - params![ - seed_hash, - address_str, - balance as i64, - nonce as i64, - network_str, - updated_at - ], - )?; + if is_sync_operation { + // Sync operation: update both balance and last_full_sync_balance + // last_full_sync_balance becomes the baseline for pre-population in the next sync + self.execute( + "INSERT INTO platform_address_balances + (seed_hash, address, balance, nonce, network, updated_at, last_full_sync_balance) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(seed_hash, address, network) DO UPDATE SET + balance = excluded.balance, + nonce = excluded.nonce, + updated_at = excluded.updated_at, + last_full_sync_balance = excluded.last_full_sync_balance", + params![ + seed_hash, + address_str, + balance as i64, + nonce as i64, + network_str, + updated_at, + balance as i64 + ], + )?; + } else { + // Internal update (e.g., after transfer): update balance but preserve last_full_sync_balance + // This ensures the next terminal sync correctly applies any pending AddToCredits + self.execute( + "INSERT INTO platform_address_balances + (seed_hash, address, balance, nonce, network, updated_at, last_full_sync_balance) + VALUES (?, ?, ?, ?, ?, ?, NULL) + ON CONFLICT(seed_hash, address, network) DO UPDATE SET + balance = excluded.balance, + nonce = excluded.nonce, + updated_at = excluded.updated_at", + params![ + seed_hash, + address_str, + balance as i64, + nonce as i64, + network_str, + updated_at + ], + )?; + } Ok(()) } @@ -1228,7 +1272,7 @@ mod tests { assert!(info.is_none()); // Set platform address info - db.set_platform_address_info(&seed_hash, &address, 10_000_000, 5, &network) + db.set_platform_address_info(&seed_hash, &address, 10_000_000, 5, &network, true) .expect("Failed to set platform address info"); // Retrieve it @@ -1241,7 +1285,7 @@ mod tests { assert_eq!(info.1, 5); // nonce // Update it - db.set_platform_address_info(&seed_hash, &address, 20_000_000, 10, &network) + db.set_platform_address_info(&seed_hash, &address, 20_000_000, 10, &network, true) .expect("Failed to update platform address info"); let info = db @@ -1339,7 +1383,7 @@ mod tests { // Add a single valid platform address using the helper function let address = create_test_address(network); - db.set_platform_address_info(&seed_hash, &address, 5_000_000, 3, &network) + db.set_platform_address_info(&seed_hash, &address, 5_000_000, 3, &network, true) .expect("Failed to set platform address info"); // Get all addresses @@ -1377,7 +1421,7 @@ mod tests { } // Set platform address info - db.set_platform_address_info(&seed_hash, &address, 10_000_000, 5, &network) + db.set_platform_address_info(&seed_hash, &address, 10_000_000, 5, &network, true) .expect("Failed to set platform address info"); // Verify it exists diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 346feac24..f2c3209a9 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -288,9 +288,10 @@ impl PartialEq for WalletArcRef { pub struct PlatformAddressInfo { pub balance: Credits, pub nonce: AddressNonce, - /// Balance as of last full sync (used for terminal-only sync pre-population) - /// This prevents double-counting when proof-verified updates happen between syncs - pub last_synced_balance: Option, + /// Balance recorded at the last sync checkpoint. Updated by `set_platform_address_info_from_sync` + /// during both full and terminal syncs; preserved by `set_platform_address_info` during internal + /// updates (e.g., after transfers) to avoid double-counting AddToCredits on subsequent syncs. + pub last_full_sync_balance: Option, } #[derive(Debug, Clone, PartialEq)] @@ -1890,49 +1891,70 @@ impl Wallet { nonce: AddressNonce, ) { // Convert the incoming address to PlatformAddress for canonical comparison - if let Ok(platform_addr) = PlatformAddress::try_from(address.clone()) { - let canonical_bytes = platform_addr.to_bytes(); - - // Find and remove any existing entry that represents the same platform address - // but might have a different Address representation - let keys_to_remove: Vec
      = self - .platform_address_info - .keys() - .filter(|existing_addr| { - if let Ok(existing_platform) = - PlatformAddress::try_from((*existing_addr).clone()) - { - existing_platform.to_bytes() == canonical_bytes - && *existing_addr != &address - } else { - false - } - }) - .cloned() - .collect(); - - for key in keys_to_remove { - self.platform_address_info.remove(&key); - } + let (keys_to_remove, last_full_sync_balance) = + if let Ok(platform_addr) = PlatformAddress::try_from(address.clone()) { + let canonical_bytes = platform_addr.to_bytes(); + + // First, find last_full_sync_balance from any canonical-equivalent entry + // (must be done BEFORE removing duplicates) + let last_full_sync_balance = + self.platform_address_info + .iter() + .find_map(|(existing_addr, info)| { + if let Ok(existing_platform) = + PlatformAddress::try_from(existing_addr.clone()) + && existing_platform.to_bytes() == canonical_bytes + { + return info.last_full_sync_balance; + } + None + }); + + // Find duplicate entries to remove (same platform address, different key) + let keys_to_remove: Vec
      = self + .platform_address_info + .keys() + .filter(|existing_addr| { + if let Ok(existing_platform) = + PlatformAddress::try_from((*existing_addr).clone()) + { + existing_platform.to_bytes() == canonical_bytes + && *existing_addr != &address + } else { + false + } + }) + .cloned() + .collect(); + + (keys_to_remove, last_full_sync_balance) + } else { + // Fallback: try direct lookup if canonical conversion fails + let last_full_sync_balance = self + .platform_address_info + .get(&address) + .and_then(|info| info.last_full_sync_balance); + (vec![], last_full_sync_balance) + }; + + // Remove duplicate entries + for key in keys_to_remove { + self.platform_address_info.remove(&key); } - // Preserve last_synced_balance if it exists - let last_synced_balance = self - .platform_address_info - .get(&address) - .and_then(|info| info.last_synced_balance); - self.platform_address_info.insert( address, PlatformAddressInfo { balance, nonce, - last_synced_balance, + last_full_sync_balance, }, ); } - /// Set platform address info from a sync operation (updates last_synced_balance) + /// Set platform address info from a sync operation. + /// Always updates `last_full_sync_balance` to the current balance, as this becomes + /// the baseline for pre-population in the next terminal sync. pub fn set_platform_address_info_from_sync( &mut self, address: Address, @@ -1944,7 +1966,8 @@ impl Wallet { PlatformAddressInfo { balance, nonce, - last_synced_balance: Some(balance), + // Always update to current balance - this is the baseline for next sync + last_full_sync_balance: Some(balance), }, ); } @@ -2216,7 +2239,7 @@ impl WalletAddressProvider { for (address, funds) in &self.found_balances { let canonical_address = Wallet::canonical_address(address, self.network); - // Use sync-specific method that also updates last_synced_balance + // Update wallet with synced balance (also updates last_full_sync_balance for next sync) wallet.set_platform_address_info_from_sync( canonical_address.clone(), funds.balance, From 5b832616e42f4324ba858f40629e5fbe2b6e5d8e Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Thu, 29 Jan 2026 17:23:52 +0700 Subject: [PATCH 066/106] fix: fetch fresh nonces from platform for identity creation (#509) When creating an identity with Platform Address funding, the cached nonce could be stale (terminal-only sync only updates balance, not nonce). This caused "Invalid address nonce" errors. Now fetches fresh nonces from platform using AddressInfo::fetch_many() before identity creation. Note: The SDK's put_with_address_funding requires caller-provided nonces, unlike transfer_address_funds which handles nonces internally. This inconsistency should be addressed in the SDK. Co-authored-by: Claude Opus 4.5 --- .../identity/register_identity.rs | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index ae015bb2d..55d529848 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -18,7 +18,7 @@ use dash_sdk::dpp::prelude::{AddressNonce, AssetLockProof}; use dash_sdk::dpp::state_transition::identity_create_transition::IdentityCreateTransition; use dash_sdk::dpp::state_transition::identity_create_transition::methods::IdentityCreateTransitionMethodsV0; use dash_sdk::platform::transition::put_identity::PutIdentity; -use dash_sdk::platform::{Fetch, Identity}; +use dash_sdk::platform::{Fetch, FetchMany, Identity}; use dash_sdk::query_types::AddressInfo; use dash_sdk::{Error, Sdk}; use std::collections::BTreeMap; @@ -227,17 +227,34 @@ impl AppContext { inputs, wallet_seed_hash, } => { - // inputs with nonces, incremented by 1 from current nonce + // Fetch fresh nonces from platform to ensure we have current values + let addresses_to_fetch: std::collections::BTreeSet = + inputs.keys().cloned().collect(); + + let fetched_address_infos = + AddressInfo::fetch_many(&sdk, addresses_to_fetch.clone()) + .await + .map_err(|e| { + format!("Failed to fetch address info from platform: {}", e) + })?; + + // Build inputs with fresh nonces incremented by 1 let inputs_with_nonces = inputs .into_iter() .map(|(addr, credits)| { - self.get_platform_address_best_info(&addr, self.network) - .map(|info| (addr, (info.nonce.saturating_add(1), credits))) + // Get the fetched info, falling back to cached info if not found on platform + let nonce = fetched_address_infos + .get(&addr) + .and_then(|opt| opt.as_ref()) + .map(|info| info.nonce) + .or_else(|| { + self.get_platform_address_best_info(&addr, self.network) + .map(|info| info.nonce) + }) + .unwrap_or(0); + (addr, (nonce.saturating_add(1), credits)) }) - .collect::>>() - .ok_or(String::from( - "Each input platform address must be present in at least one wallet", - ))?; + .collect::>(); return self .register_identity_from_platform_addresses( From d63bf3568084e2f969fe89a269485251a2a242ce Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:47:21 +0700 Subject: [PATCH 067/106] fix: don't auto-generate platform address when opening receive dialog (#508) Use platform_addresses() which checks watched_addresses for derived Platform addresses, instead of only checking platform_address_info which only contains synced addresses with balances. A new address is now only generated if there are truly no Platform addresses derived for the wallet, not just because they haven't been synced yet. Co-authored-by: Claude Opus 4.5 --- src/ui/wallets/wallets_screen/mod.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 62316172c..9b447eaab 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -2898,15 +2898,18 @@ impl WalletsBalancesScreen { }; // Collect Platform addresses with their balances (using DIP-18 Bech32m format) + // Use platform_addresses() which checks watched_addresses, not just platform_address_info + // This includes addresses that have been derived but may not have been synced yet let network = self.app_context.network; let platform_addresses: Vec<(String, u64)> = wallet_guard - .platform_address_info - .iter() - .filter_map(|(addr, info)| { - use dash_sdk::dpp::address_funds::PlatformAddress; - PlatformAddress::try_from(addr.clone()) - .ok() - .map(|pa| (pa.to_bech32m_string(network), info.balance)) + .platform_addresses(network) + .into_iter() + .map(|(core_addr, platform_addr)| { + let balance = wallet_guard + .get_platform_address_info(&core_addr) + .map(|info| info.balance) + .unwrap_or(0); + (platform_addr.to_bech32m_string(network), balance) }) .collect(); From a48f94702261ddb4542923a35e8d3929096d5eca Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:47:34 +0700 Subject: [PATCH 068/106] fix: show N/A for UTXOs and Total Received columns on Platform addresses (#510) Platform addresses don't have UTXOs (they have credits on Platform layer) and we don't track historical received amounts for them. Showing 0 was misleading - N/A is more accurate. Co-authored-by: Claude Opus 4.5 --- src/ui/wallets/wallets_screen/mod.rs | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 9b447eaab..3d8373fd3 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -984,8 +984,8 @@ impl WalletsBalancesScreen { } }); row.col(|ui| { - // Key-only addresses don't hold UTXOs - let is_key_only_address = matches!( + // Key-only addresses and Platform addresses don't hold UTXOs + let no_utxos = matches!( data.account_category, AccountCategory::IdentityRegistration | AccountCategory::IdentityTopup @@ -995,17 +995,18 @@ impl WalletsBalancesScreen { | AccountCategory::ProviderOwner | AccountCategory::ProviderOperator | AccountCategory::ProviderPlatform + | AccountCategory::PlatformPayment ); - if is_key_only_address { + if no_utxos { ui.label("N/A"); } else { ui.label(format!("{}", data.utxo_count)); } }); row.col(|ui| { - // These address types are used for key derivation/proofs, not receiving funds - let is_key_only_address = matches!( + // These address types don't track historical received amounts + let no_total_received = matches!( data.account_category, AccountCategory::IdentityRegistration | AccountCategory::IdentityTopup @@ -1015,16 +1016,11 @@ impl WalletsBalancesScreen { | AccountCategory::ProviderOwner | AccountCategory::ProviderOperator | AccountCategory::ProviderPlatform + | AccountCategory::PlatformPayment ); - if is_key_only_address { + if no_total_received { ui.label("N/A"); - } else if data.account_category == AccountCategory::PlatformPayment { - // For Platform addresses, show platform credits balance - // (since we don't track historical Platform received) - let dash_received = - data.platform_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; - ui.label(format!("{:.8}", dash_received)); } else { let dash_received = data.total_received as f64 * 1e-8; ui.label(format!("{:.8}", dash_received)); From 00deda1f82d7b18874a3c81dcdc38bfb318ef045 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:28:06 +0700 Subject: [PATCH 069/106] fix: reserve fees when using max button for platform address identity funding (#511) * fix: reserve fees when using max button for platform address identity funding When clicking the max button to fund identity creation or top-up from a platform address, the full balance was used without reserving room for transaction fees, causing "Insufficient combined address balances" errors. Changes: - Subtract estimated fee from max amount in identity creation screen - Subtract estimated fee from max amount in identity top-up screen - Fix fee estimation to use identity_create_asset_lock_cost (200M credits) instead of address_funding_asset_lock_cost (50M credits) - Add storage-based fees to identity creation/top-up fee estimates - Add new estimate_identity_topup_from_addresses() for platform address top-ups - Add 20% safety buffer to account for fee variability - Show hint explaining fee reservation when max is used Co-Authored-By: Claude Opus 4.5 * docs: fix docstrings to correctly state 20% safety buffer The docstrings incorrectly stated 10% safety buffer while the code applies 20% via total / 5. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- src/model/fee_estimation.rs | 70 +++++++++++++++++-- .../by_platform_address.rs | 36 ++++++---- .../by_platform_address.rs | 21 ++++-- 3 files changed, 104 insertions(+), 23 deletions(-) diff --git a/src/model/fee_estimation.rs b/src/model/fee_estimation.rs index 6c5f19e5f..07bf082af 100644 --- a/src/model/fee_estimation.rs +++ b/src/model/fee_estimation.rs @@ -287,22 +287,36 @@ impl PlatformFeeEstimator { } /// Estimate fee for identity creation from addresses (asset lock). - /// This includes base cost, asset lock cost, input/output costs, and per-key costs. + /// This includes base cost, asset lock cost, input/output costs, per-key costs, + /// storage-based fees, and a 20% safety buffer to account for fee variability. pub fn estimate_identity_create_from_addresses( &self, input_count: usize, has_output: bool, key_count: usize, ) -> u64 { + // Estimated serialized bytes per input (address + signature/witness data) + const ESTIMATED_BYTES_PER_INPUT: usize = 225; + // Estimated bytes for identity structure + keys + const ESTIMATED_IDENTITY_BASE_BYTES: usize = 100; + const ESTIMATED_BYTES_PER_KEY: usize = 50; + // Estimated seek operations for tree traversal + const ESTIMATED_SEEKS_BASE: usize = 10; + let output_count = if has_output { 1 } else { 0 }; + let inputs = input_count.max(1); + + // Base fee from min fee structure + // Note: identity creation requires the full identity_create_asset_lock_cost, + // not the smaller address_funding_asset_lock_cost used for simple transfers let base_fee = self .min_fees .identity_create_base_cost - .saturating_add(self.min_fees.address_funding_asset_lock_cost) + .saturating_add(self.min_fees.identity_create_asset_lock_cost) .saturating_add( self.min_fees .address_funds_transfer_input_cost - .saturating_mul(input_count as u64), + .saturating_mul(inputs as u64), ) .saturating_add( self.min_fees @@ -314,7 +328,19 @@ impl PlatformFeeEstimator { .identity_key_in_creation_cost .saturating_mul(key_count as u64), ); - self.apply_multiplier(base_fee) + + // Add storage-based fees for serialized transaction data + let estimated_bytes = inputs * ESTIMATED_BYTES_PER_INPUT + + ESTIMATED_IDENTITY_BASE_BYTES + + key_count * ESTIMATED_BYTES_PER_KEY; + let estimated_seeks = ESTIMATED_SEEKS_BASE + inputs; + let storage_fee = self.calculate_storage_based_fee(estimated_bytes, estimated_seeks); + + // Total with fee multiplier + let total = self.apply_multiplier(base_fee.saturating_add(storage_fee)); + + // Add 20% safety buffer to account for fee variability + total.saturating_add(total / 5) } /// Estimate fee for identity top-up. @@ -327,6 +353,42 @@ impl PlatformFeeEstimator { self.apply_multiplier(base_fee) } + /// Estimate fee for identity top-up from platform addresses. + /// This includes base cost, asset lock cost, input costs, storage-based fees, + /// and a 20% safety buffer to account for fee variability. + pub fn estimate_identity_topup_from_addresses(&self, input_count: usize) -> u64 { + // Estimated serialized bytes per input (address + signature/witness data) + const ESTIMATED_BYTES_PER_INPUT: usize = 225; + // Estimated bytes for top-up transaction structure + const ESTIMATED_TOPUP_BASE_BYTES: usize = 100; + // Estimated seek operations for tree traversal + const ESTIMATED_SEEKS_BASE: usize = 8; + + let inputs = input_count.max(1); + + // Base fee from min fee structure + let base_fee = self + .min_fees + .identity_topup_base_cost + .saturating_add(self.min_fees.address_funding_asset_lock_cost) + .saturating_add( + self.min_fees + .address_funds_transfer_input_cost + .saturating_mul(inputs as u64), + ); + + // Add storage-based fees for serialized transaction data + let estimated_bytes = inputs * ESTIMATED_BYTES_PER_INPUT + ESTIMATED_TOPUP_BASE_BYTES; + let estimated_seeks = ESTIMATED_SEEKS_BASE + inputs; + let storage_fee = self.calculate_storage_based_fee(estimated_bytes, estimated_seeks); + + // Total with fee multiplier + let total = self.apply_multiplier(base_fee.saturating_add(storage_fee)); + + // Add 20% safety buffer to account for fee variability + total.saturating_add(total / 5) + } + /// Estimate fee for document batch transition pub fn estimate_document_batch(&self, transition_count: usize) -> u64 { let base_fee = self diff --git a/src/ui/identities/add_new_identity_screen/by_platform_address.rs b/src/ui/identities/add_new_identity_screen/by_platform_address.rs index 6ef7a3335..72cee4d83 100644 --- a/src/ui/identities/add_new_identity_screen/by_platform_address.rs +++ b/src/ui/identities/add_new_identity_screen/by_platform_address.rs @@ -150,6 +150,22 @@ impl AddNewIdentityScreen { .map(|(_, _, balance)| *balance) }); + // Calculate estimated fee for identity creation (needed for max amount calculation) + let key_count = self.identity_keys.keys_input.len() + 1; // +1 for master key + let input_count = if self.selected_platform_address_for_funding.is_some() { + 1 + } else { + 0 + }; + let estimated_fee = self + .app_context + .fee_estimator() + .estimate_identity_create_from_addresses(input_count, false, key_count); + + // Calculate max amount with fee reserved + let max_amount_with_fee_reserved = + max_balance_credits.map(|balance| balance.saturating_sub(estimated_fee)); + // Amount input using AmountInput component let amount_input = self.platform_funding_amount_input.get_or_insert_with(|| { AmountInput::new(Amount::new_dash(0.0)) @@ -159,8 +175,12 @@ impl AddNewIdentityScreen { .with_desired_width(150.0) }); - // Update max amount dynamically based on selected platform address - amount_input.set_max_amount(max_balance_credits); + // Update max amount dynamically based on selected platform address (with fee reserved) + amount_input.set_max_amount(max_amount_with_fee_reserved); + amount_input.set_max_exceeded_hint(Some(format!( + "~{} reserved for fees", + format_credits_as_dash(estimated_fee) + ))); let response = amount_input.show(ui); response.inner.update(&mut self.platform_funding_amount); @@ -190,17 +210,7 @@ impl AddNewIdentityScreen { // Extract the step from the RwLock to minimize borrow scope let step = *self.step.read().unwrap(); - // Display estimated fee before action button - let key_count = self.identity_keys.keys_input.len() + 1; // +1 for master key - let input_count = if self.selected_platform_address_for_funding.is_some() { - 1 - } else { - 0 - }; - let estimated_fee = self - .app_context - .fee_estimator() - .estimate_identity_create_from_addresses(input_count, false, key_count); + // Display estimated fee before action button (reuse already calculated value) let dark_mode = ui.ctx().style().visuals.dark_mode; egui::Frame::new() .fill(crate::ui::theme::DashColors::surface(dark_mode)) diff --git a/src/ui/identities/top_up_identity_screen/by_platform_address.rs b/src/ui/identities/top_up_identity_screen/by_platform_address.rs index 90e83820e..b10723693 100644 --- a/src/ui/identities/top_up_identity_screen/by_platform_address.rs +++ b/src/ui/identities/top_up_identity_screen/by_platform_address.rs @@ -96,6 +96,14 @@ impl TopUpIdentityScreen { .as_ref() .map(|(_, _, balance)| *balance); + // Calculate estimated fee for top-up from platform address (needed for max amount calculation) + let fee_estimator = self.app_context.fee_estimator(); + let estimated_fee = fee_estimator.estimate_identity_topup_from_addresses(1); + + // Calculate max amount with fee reserved + let max_amount_with_fee_reserved = + max_balance_credits.map(|balance| balance.saturating_sub(estimated_fee)); + Frame::group(ui.style()) .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(12, 10)) @@ -111,8 +119,12 @@ impl TopUpIdentityScreen { .with_desired_width(150.0) }); - // Update max amount dynamically based on selected platform address - amount_input.set_max_amount(max_balance_credits); + // Update max amount dynamically based on selected platform address (with fee reserved) + amount_input.set_max_amount(max_amount_with_fee_reserved); + amount_input.set_max_exceeded_hint(Some(format!( + "~{} reserved for fees", + format_credits_as_dash(estimated_fee) + ))); let response = amount_input.show(ui); response.inner.update(&mut self.platform_top_up_amount); @@ -130,10 +142,7 @@ impl TopUpIdentityScreen { ui.add_space(10.0); - // Fee estimation display - let fee_estimator = self.app_context.fee_estimator(); - let estimated_fee = fee_estimator.estimate_identity_topup(); - + // Fee estimation display (reuse already calculated value) Frame::new() .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(10, 8)) From d19c5dc3e7f9ce52f6190411065dfce4ec2ad459 Mon Sep 17 00:00:00 2001 From: PastaPastaPasta <6443210+PastaPastaPasta@users.noreply.github.com> Date: Tue, 3 Feb 2026 14:48:28 -0600 Subject: [PATCH 070/106] Release SPV storage lock on shutdown (#517) --- src/spv/manager.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/spv/manager.rs b/src/spv/manager.rs index 3e4620597..ebad6b7ee 100644 --- a/src/spv/manager.rs +++ b/src/spv/manager.rs @@ -796,6 +796,15 @@ impl SpvManager { let mut nm_guard = self.network_manager.write().await; *nm_guard = None; } + { + // Drop shared storage/request handles so the disk lock is released before restart. + if let Ok(mut storage_guard) = self.storage.lock() { + *storage_guard = None; + } + if let Ok(mut guard) = self.request_tx.lock() { + *guard = None; + } + } result } From 6899b8420182b43711ab5d72772af061ef992a54 Mon Sep 17 00:00:00 2001 From: PastaPastaPasta <6443210+PastaPastaPasta@users.noreply.github.com> Date: Wed, 4 Feb 2026 00:12:15 -0600 Subject: [PATCH 071/106] Fix dark mode text colors (#515) * Fix dark mode text and borders * style: apply cargo fmt formatting Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- src/ui/identities/identities_screen.rs | 3 +- src/ui/identities/keys/key_info_screen.rs | 77 ++++----- src/ui/tokens/set_token_price_screen.rs | 8 +- src/ui/tokens/tokens_screen/mod.rs | 3 +- src/ui/tools/masternode_list_diff_screen.rs | 167 ++++++++++---------- src/ui/tools/proof_log_screen.rs | 18 ++- src/ui/wallets/add_new_wallet_screen.rs | 15 +- 7 files changed, 156 insertions(+), 135 deletions(-) diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 4f8fe1fb3..963ed4c9d 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -1227,9 +1227,10 @@ impl ScreenLike for IdentitiesScreen { }); ui.add_space(2.0); // Space below } else if let Some((message, message_type, timestamp)) = self.backend_message.clone() { + let dark_mode = ui.ctx().style().visuals.dark_mode; let message_color = match message_type { MessageType::Error => egui::Color32::DARK_RED, - MessageType::Info => egui::Color32::BLACK, + MessageType::Info => DashColors::text_primary(dark_mode), MessageType::Success => egui::Color32::DARK_GREEN, }; diff --git a/src/ui/identities/keys/key_info_screen.rs b/src/ui/identities/keys/key_info_screen.rs index 0fea93381..22f9ac7de 100644 --- a/src/ui/identities/keys/key_info_screen.rs +++ b/src/ui/identities/keys/key_info_screen.rs @@ -13,6 +13,7 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; +use crate::ui::theme::DashColors; use base64::Engine; use base64::engine::general_purpose::STANDARD; use dash_sdk::dashcore_rpc::dashcore::PrivateKey as RPCPrivateKey; @@ -88,7 +89,8 @@ impl ScreenLike for KeyInfoScreen { let inner_action = AppAction::None; ScrollArea::vertical().show(ui, |ui| { - ui.heading(RichText::new("Key Information").color(Color32::BLACK)); + let text_primary = DashColors::text_primary(ui.ctx().style().visuals.dark_mode); + ui.heading(RichText::new("Key Information").color(text_primary)); ui.add_space(10.0); egui::Grid::new("key_info_grid") @@ -97,15 +99,14 @@ impl ScreenLike for KeyInfoScreen { .striped(false) .show(ui, |ui| { // Key ID - ui.label(RichText::new("Key ID:").strong().color(Color32::BLACK)); - ui.label(RichText::new(format!("{}", self.key.id())).color(Color32::BLACK)); + ui.label(RichText::new("Key ID:").strong().color(text_primary)); + ui.label(RichText::new(format!("{}", self.key.id())).color(text_primary)); ui.end_row(); // Purpose - ui.label(RichText::new("Purpose:").strong().color(Color32::BLACK)); + ui.label(RichText::new("Purpose:").strong().color(text_primary)); ui.label( - RichText::new(format!("{:?}", self.key.purpose())) - .color(Color32::BLACK), + RichText::new(format!("{:?}", self.key.purpose())).color(text_primary), ); ui.end_row(); @@ -113,27 +114,25 @@ impl ScreenLike for KeyInfoScreen { ui.label( RichText::new("Security Level:") .strong() - .color(Color32::BLACK), + .color(text_primary), ); ui.label( RichText::new(format!("{:?}", self.key.security_level())) - .color(Color32::BLACK), + .color(text_primary), ); ui.end_row(); // Type - ui.label(RichText::new("Type:").strong().color(Color32::BLACK)); + ui.label(RichText::new("Type:").strong().color(text_primary)); ui.label( - RichText::new(format!("{:?}", self.key.key_type())) - .color(Color32::BLACK), + RichText::new(format!("{:?}", self.key.key_type())).color(text_primary), ); ui.end_row(); // Read Only - ui.label(RichText::new("Read Only:").strong().color(Color32::BLACK)); + ui.label(RichText::new("Read Only:").strong().color(text_primary)); ui.label( - RichText::new(format!("{}", self.key.read_only())) - .color(Color32::BLACK), + RichText::new(format!("{}", self.key.read_only())).color(text_primary), ); ui.end_row(); @@ -141,12 +140,12 @@ impl ScreenLike for KeyInfoScreen { ui.label( RichText::new("Active/Disabled:") .strong() - .color(Color32::BLACK), + .color(text_primary), ); if !self.key.is_disabled() { - ui.label(RichText::new("Active").color(Color32::BLACK)); + ui.label(RichText::new("Active").color(text_primary)); } else { - ui.label(RichText::new("Disabled").color(Color32::BLACK)); + ui.label(RichText::new("Disabled").color(text_primary)); } ui.end_row(); @@ -157,7 +156,7 @@ impl ScreenLike for KeyInfoScreen { ui.label( RichText::new("In local Wallet") .strong() - .color(Color32::BLACK), + .color(text_primary), ); ui.label( RichText::new(format!( @@ -165,7 +164,7 @@ impl ScreenLike for KeyInfoScreen { wallet_derivation_path.derivation_path )) .strong() - .color(Color32::BLACK), + .color(text_primary), ); ui.end_row(); } @@ -175,13 +174,13 @@ impl ScreenLike for KeyInfoScreen { ui.label( RichText::new("Contract Bounds:") .strong() - .color(Color32::BLACK), + .color(text_primary), ); match contract_bounds { ContractBounds::SingleContract { id } => { ui.label( RichText::new(format!("Contract ID: {}", id)) - .color(Color32::BLACK), + .color(text_primary), ); } ContractBounds::SingleContractDocumentType { @@ -193,7 +192,7 @@ impl ScreenLike for KeyInfoScreen { "Contract ID: {}\nDocument Type: {}", id, document_type_name )) - .color(Color32::BLACK), + .color(text_primary), ); } } @@ -208,7 +207,7 @@ impl ScreenLike for KeyInfoScreen { ui.add_space(10.0); // Display the public key information - ui.heading(RichText::new("Public Key Information").color(Color32::BLACK)); + ui.heading(RichText::new("Public Key Information").color(text_primary)); ui.add_space(10.0); egui::Grid::new("public_key_info_grid") @@ -222,11 +221,11 @@ impl ScreenLike for KeyInfoScreen { ui.label( RichText::new("Public Key (Hex):") .strong() - .color(Color32::BLACK), + .color(text_primary), ); ui.label( RichText::new(self.key.data().to_string(Encoding::Hex)) - .color(Color32::BLACK), + .color(text_primary), ); ui.end_row(); @@ -234,11 +233,11 @@ impl ScreenLike for KeyInfoScreen { ui.label( RichText::new("Public Key (Base64):") .strong() - .color(Color32::BLACK), + .color(text_primary), ); ui.label( RichText::new(self.key.data().to_string(Encoding::Base64)) - .color(Color32::BLACK), + .color(text_primary), ); ui.end_row(); } @@ -249,12 +248,12 @@ impl ScreenLike for KeyInfoScreen { ui.label( RichText::new("Public Key Hash:") .strong() - .color(Color32::BLACK), + .color(text_primary), ); match self.key.public_key_hash() { Ok(hash) => { let hash_hex = hex::encode(hash); - ui.label(RichText::new(hash_hex).color(Color32::BLACK)); + ui.label(RichText::new(hash_hex).color(text_primary)); } Err(e) => { ui.colored_label(egui::Color32::RED, format!("Error: {}", e)); @@ -263,7 +262,7 @@ impl ScreenLike for KeyInfoScreen { if self.key.key_type().is_core_address_key_type() { // Public Key Hash - ui.label(RichText::new("Address:").strong().color(Color32::BLACK)); + ui.label(RichText::new("Address:").strong().color(text_primary)); match self.key.public_key_hash() { Ok(hash) => { let address = if self.key.key_type() == BIP13_SCRIPT_HASH { @@ -278,7 +277,7 @@ impl ScreenLike for KeyInfoScreen { ) }; ui.label( - RichText::new(address.to_string()).color(Color32::BLACK), + RichText::new(address.to_string()).color(text_primary), ); } Err(e) => { @@ -296,7 +295,7 @@ impl ScreenLike for KeyInfoScreen { // Display the private key if available if let Some((private_key, _)) = self.private_key_data.as_mut() { - ui.heading(RichText::new("Private Key").color(Color32::BLACK)); + ui.heading(RichText::new("Private Key").color(text_primary)); ui.add_space(10.0); match private_key { @@ -339,7 +338,7 @@ impl ScreenLike for KeyInfoScreen { self.render_sign_input(ui); } PrivateKeyData::Encrypted(_) => { - ui.label(RichText::new("Key is encrypted").color(Color32::BLACK)); + ui.label(RichText::new("Key is encrypted").color(text_primary)); ui.add_space(10.0); //todo decrypt key @@ -499,7 +498,7 @@ impl ScreenLike for KeyInfoScreen { } } } else { - ui.label(RichText::new("Enter Private Key:").color(Color32::BLACK)); + ui.label(RichText::new("Enter Private Key:").color(text_primary)); ui.text_edit_singleline(&mut self.private_key_input); if ui.button("Add Private Key").clicked() { @@ -675,12 +674,13 @@ impl KeyInfoScreen { } fn render_sign_input(&mut self, ui: &mut egui::Ui) { + let text_primary = DashColors::text_primary(ui.ctx().style().visuals.dark_mode); ui.add_space(10.0); ui.separator(); ui.add_space(10.0); ui.horizontal(|ui| { - ui.heading(RichText::new("Sign").color(Color32::BLACK)); + ui.heading(RichText::new("Sign").color(text_primary)); // Create an info icon button let response = crate::ui::helpers::info_icon_button(ui, "Enter a message and click Sign to encrypt it with your private key. You can send the encrypted message to someone and they can decrypt it using your public key. This is useful for proving you own the private key."); @@ -692,7 +692,7 @@ impl KeyInfoScreen { }); ui.add_space(5.0); - ui.label(RichText::new("Enter message to sign:").color(Color32::BLACK)); + ui.label(RichText::new("Enter message to sign:").color(text_primary)); ui.add_space(5.0); ui.add( egui::TextEdit::multiline(&mut self.message_input) @@ -731,7 +731,7 @@ impl KeyInfoScreen { ui.separator(); ui.add_space(10.0); - ui.label(RichText::new("Signed Message (Base64):").color(Color32::BLACK)); + ui.label(RichText::new("Signed Message (Base64):").color(text_primary)); ui.add_space(5.0); ui.add( egui::TextEdit::multiline(&mut signed_message.as_str().to_owned()) @@ -789,13 +789,14 @@ impl KeyInfoScreen { } fn render_remove_private_key_confirm(&mut self, ui: &mut egui::Ui) { + let text_primary = DashColors::text_primary(ui.ctx().style().visuals.dark_mode); egui::Window::new("Remove Private Key") .collapsible(false) // Prevent collapsing .resizable(false) // Prevent resizing .show(ui.ctx(), |ui| { ui.label( RichText::new("Are you sure you want to remove the private key?") - .color(Color32::BLACK), + .color(text_primary), ); ui.add_space(10.0); diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 97392675b..2e026ea99 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -408,6 +408,8 @@ impl SetTokenPriceScreen { } } PricingType::TieredPricing => { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let text_primary = DashColors::text_primary(dark_mode); ui.label("Add pricing tiers to offer volume discounts"); ui.add_space(10.0); @@ -429,7 +431,7 @@ impl SetTokenPriceScreen { header.col(|ui| { ui.label( RichText::new("Minimum Amount") - .color(Color32::BLACK) + .color(text_primary) .strong() .underline(), ); @@ -437,7 +439,7 @@ impl SetTokenPriceScreen { header.col(|ui| { ui.label( RichText::new("Price per Token") - .color(Color32::BLACK) + .color(text_primary) .strong() .underline(), ); @@ -445,7 +447,7 @@ impl SetTokenPriceScreen { header.col(|ui| { ui.label( RichText::new("Remove") - .color(Color32::BLACK) + .color(text_primary) .strong() .underline(), ); diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index c287975a0..123ff9355 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -2758,9 +2758,10 @@ impl ScreenLike for TokensScreen { ui.add_space(2.0); // Space below } else if let Some((msg, msg_type, timestamp)) = self.backend_message.clone() { ui.add_space(25.0); // Same space as refreshing indicator + let dark_mode = ui.ctx().style().visuals.dark_mode; let color = match msg_type { MessageType::Error => Color32::DARK_RED, - MessageType::Info => Color32::BLACK, + MessageType::Info => DashColors::text_primary(dark_mode), MessageType::Success => Color32::DARK_GREEN, }; ui.horizontal(|ui| { diff --git a/src/ui/tools/masternode_list_diff_screen.rs b/src/ui/tools/masternode_list_diff_screen.rs index 713b00c21..80d2186ca 100644 --- a/src/ui/tools/masternode_list_diff_screen.rs +++ b/src/ui/tools/masternode_list_diff_screen.rs @@ -8,6 +8,7 @@ use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; +use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dashcore_rpc::json::QuorumType; @@ -2464,13 +2465,15 @@ impl MasternodeListDiffScreen { /// Render the details for the selected quorum fn render_quorum_details(&mut self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let border = DashColors::border(dark_mode); ui.heading("Quorum Details"); if let Some(dml_key) = self.selected_dml_diff_key { if let Some(dml) = self.mnlist_diffs.get(&dml_key) { if let Some(q_index) = self.selected_quorum_in_diff_index { if let Some(quorum) = dml.new_quorums.get(q_index) { Frame::NONE - .stroke(Stroke::new(1.0, Color32::BLACK)) + .stroke(Stroke::new(1.0, border)) .show(ui, |ui| { ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); let height = self.get_height(&quorum.quorum_hash).ok(); @@ -2630,7 +2633,7 @@ impl MasternodeListDiffScreen { }; Frame::NONE - .stroke(Stroke::new(1.0, Color32::BLACK)) + .stroke(Stroke::new(1.0, border)) .show(ui, |ui| { ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); ScrollArea::vertical().id_salt("render_quorum_details_2").show(ui, |ui| { @@ -2663,21 +2666,21 @@ impl MasternodeListDiffScreen { /// Render the details for the selected Masternode fn render_mn_details(&mut self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let border = DashColors::border(dark_mode); ui.heading("Masternode Details"); if let Some(dml_key) = self.selected_dml_diff_key { if let Some(dml) = self.mnlist_diffs.get(&dml_key) { if let Some(mn_index) = self.selected_masternode_in_diff_index { if let Some(masternode) = dml.new_masternodes.get(mn_index) { - Frame::NONE - .stroke(Stroke::new(1.0, Color32::BLACK)) - .show(ui, |ui| { - ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); - ScrollArea::vertical().id_salt("render_mn_details").show( - ui, - |ui| { - ui.label(format!( - "Version: {}\n\ + Frame::NONE.stroke(Stroke::new(1.0, border)).show(ui, |ui| { + ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); + ScrollArea::vertical() + .id_salt("render_mn_details") + .show(ui, |ui| { + ui.label(format!( + "Version: {}\n\ ProRegTxHash: {}\n\ Confirmed Hash: {}\n\ Service Address: {}:{}\n\ @@ -2685,35 +2688,33 @@ impl MasternodeListDiffScreen { Voting Key ID: {}\n\ Is Valid: {}\n\ Masternode Type: {}", - masternode.version, - masternode.pro_reg_tx_hash.reverse(), - match masternode.confirmed_hash { - None => "No confirmed hash".to_string(), - Some(confirmed_hash) => - confirmed_hash.reverse().to_string(), - }, - masternode.service_address.ip(), - masternode.service_address.port(), - masternode.operator_public_key, - masternode.key_id_voting, - masternode.is_valid, - match masternode.mn_type { - EntryMasternodeType::Regular => - "Regular".to_string(), - EntryMasternodeType::HighPerformance { - platform_http_port, - platform_node_id, - } => { - format!( - "High Performance (Port: {}, Node ID: {})", - platform_http_port, platform_node_id - ) - } + masternode.version, + masternode.pro_reg_tx_hash.reverse(), + match masternode.confirmed_hash { + None => "No confirmed hash".to_string(), + Some(confirmed_hash) => + confirmed_hash.reverse().to_string(), + }, + masternode.service_address.ip(), + masternode.service_address.port(), + masternode.operator_public_key, + masternode.key_id_voting, + masternode.is_valid, + match masternode.mn_type { + EntryMasternodeType::Regular => "Regular".to_string(), + EntryMasternodeType::HighPerformance { + platform_http_port, + platform_node_id, + } => { + format!( + "High Performance (Port: {}, Node ID: {})", + platform_http_port, platform_node_id + ) } - )); - }, - ); - }); + } + )); + }); + }); } } else { ui.label("Select a Masternode to view details."); @@ -2728,15 +2729,13 @@ impl MasternodeListDiffScreen { && let Some(qualified_masternode) = mn_list.masternodes.get(&selected_pro_tx_hash) { let masternode = &qualified_masternode.masternode_list_entry; - Frame::NONE - .stroke(Stroke::new(1.0, Color32::BLACK)) - .show(ui, |ui| { - ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); - ScrollArea::vertical() - .id_salt("render_mn_details_2") - .show(ui, |ui| { - ui.label(format!( - "Version: {}\n\ + Frame::NONE.stroke(Stroke::new(1.0, border)).show(ui, |ui| { + ui.set_min_size(Vec2::new(ui.available_width(), 300.0)); + ScrollArea::vertical() + .id_salt("render_mn_details_2") + .show(ui, |ui| { + ui.label(format!( + "Version: {}\n\ ProRegTxHash: {}\n\ Confirmed Hash: {}\n\ Service Address: {}:{}\n\ @@ -2746,41 +2745,40 @@ impl MasternodeListDiffScreen { Masternode Type: {}\n\ Entry Hash: {}\n\ Confirmed Hash hashed with ProRegTx: {}\n", - masternode.version, - masternode.pro_reg_tx_hash.reverse(), - match masternode.confirmed_hash { - None => "No confirmed hash".to_string(), - Some(confirmed_hash) => - confirmed_hash.reverse().to_string(), - }, - masternode.service_address.ip(), - masternode.service_address.port(), - masternode.operator_public_key, - masternode.key_id_voting, - masternode.is_valid, - match masternode.mn_type { - EntryMasternodeType::Regular => "Regular".to_string(), - EntryMasternodeType::HighPerformance { - platform_http_port, - platform_node_id, - } => { - format!( - "High Performance (Port: {}, Node ID: {})", - platform_http_port, platform_node_id - ) - } - }, - hex::encode(qualified_masternode.entry_hash), - if let Some(hash) = - qualified_masternode.confirmed_hash_hashed_with_pro_reg_tx - { - hash.reverse().to_string() - } else { - "None".to_string() - }, - )); - }); - }); + masternode.version, + masternode.pro_reg_tx_hash.reverse(), + match masternode.confirmed_hash { + None => "No confirmed hash".to_string(), + Some(confirmed_hash) => confirmed_hash.reverse().to_string(), + }, + masternode.service_address.ip(), + masternode.service_address.port(), + masternode.operator_public_key, + masternode.key_id_voting, + masternode.is_valid, + match masternode.mn_type { + EntryMasternodeType::Regular => "Regular".to_string(), + EntryMasternodeType::HighPerformance { + platform_http_port, + platform_node_id, + } => { + format!( + "High Performance (Port: {}, Node ID: {})", + platform_http_port, platform_node_id + ) + } + }, + hex::encode(qualified_masternode.entry_hash), + if let Some(hash) = + qualified_masternode.confirmed_hash_hashed_with_pro_reg_tx + { + hash.reverse().to_string() + } else { + "None".to_string() + }, + )); + }); + }); } } else { ui.label("Select a block height and Masternode."); @@ -4384,7 +4382,8 @@ impl ScreenLike for MasternodeListDiffScreen { PendingTask::QrInfoWithDmls => "Fetching QR info + DMLs…", PendingTask::ChainLocks => "Fetching chain locks…", }; - ui.colored_label(Color32::BLACK, label); + let text_primary = DashColors::text_primary(ui.ctx().style().visuals.dark_mode); + ui.colored_label(text_primary, label); }); ui.add_space(6.0); } diff --git a/src/ui/tools/proof_log_screen.rs b/src/ui/tools/proof_log_screen.rs index a32f87e8c..05130dfc1 100644 --- a/src/ui/tools/proof_log_screen.rs +++ b/src/ui/tools/proof_log_screen.rs @@ -5,6 +5,7 @@ use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; use crate::ui::components::top_panel::add_top_panel; +use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::drive::grovedb::operations::proof::GroveDBProof; use dash_sdk::drive::query::PathQuery; @@ -210,7 +211,12 @@ impl ProofLogScreen { }); } - fn highlight_proof_text(proof_text: &str, hashes: &[String], font_id: FontId) -> LayoutJob { + fn highlight_proof_text( + proof_text: &str, + hashes: &[String], + font_id: FontId, + text_color: Color32, + ) -> LayoutJob { let mut job = LayoutJob::default(); let mut remaining_text = proof_text; @@ -231,7 +237,7 @@ impl ProofLogScreen { 0.0, TextFormat { font_id: font_id.clone(), - color: Color32::BLACK, + color: text_color, ..Default::default() }, ); @@ -329,10 +335,14 @@ impl ProofLogScreen { // Create the layout job with highlighted hashes let font_id = TextStyle::Monospace.resolve(ui.style()); - let layout_job = Self::highlight_proof_text(&proof_display, &hashes, font_id); + let dark_mode = ui.ctx().style().visuals.dark_mode; + let text_primary = DashColors::text_primary(dark_mode); + let border = DashColors::border(dark_mode); + let layout_job = + Self::highlight_proof_text(&proof_display, &hashes, font_id, text_primary); let frame = Frame::new() - .stroke(Stroke::new(1.0, Color32::BLACK)) + .stroke(Stroke::new(1.0, border)) .fill(Color32::TRANSPARENT) .corner_radius(2.0); // Set margins to zero diff --git a/src/ui/wallets/add_new_wallet_screen.rs b/src/ui/wallets/add_new_wallet_screen.rs index 661a8473a..22b9c64f6 100644 --- a/src/ui/wallets/add_new_wallet_screen.rs +++ b/src/ui/wallets/add_new_wallet_screen.rs @@ -11,6 +11,7 @@ use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::identities::add_new_identity_screen::AddNewIdentityScreen; use crate::ui::identities::funding_common::generate_qr_code_image; +use crate::ui::theme::DashColors; use crate::ui::{RootScreenType, Screen, ScreenLike}; use bip39::{Language, Mnemonic}; use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; @@ -538,6 +539,12 @@ impl AddNewWalletScreen { } fn render_seed_phrase_input(&mut self, ui: &mut Ui) { + let dark_mode = ui.ctx().style().visuals.dark_mode; + let surface = DashColors::surface(dark_mode); + let border = DashColors::border(dark_mode); + let text_primary = DashColors::text_primary(dark_mode); + let text_secondary = DashColors::text_secondary(dark_mode); + ui.add_space(15.0); // Add spacing from the top ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { ui.add_space(-6.0); @@ -657,8 +664,8 @@ impl AddNewWalletScreen { egui::Layout::top_down(egui::Align::Center), |ui| { Frame::new() - .fill(Color32::WHITE) - .stroke(Stroke::new(1.0, Color32::BLACK)) + .fill(surface) + .stroke(Stroke::new(1.0, border)) .corner_radius(5.0) .inner_margin(Margin::same(10)) .show(ui, |ui| { @@ -675,11 +682,11 @@ impl AddNewWalletScreen { for (i, word) in mnemonic.words().enumerate() { let number_text = RichText::new(format!("{} ", i + 1)) .size(row_height * 0.3) - .color(Color32::GRAY); + .color(text_secondary); let word_text = RichText::new(word) .size(row_height * 0.5) - .color(Color32::BLACK); + .color(text_primary); ui.with_layout( Layout::left_to_right(Align::Min), From 0d347e3df14872535bba4766c8ceb96a72ba9d6f Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:50:58 +0100 Subject: [PATCH 072/106] fix: invalid min amount when transferring funds (#523) * fix: invalid min amount when transferring funds * chore: rabbit's feedback --- src/ui/wallets/send_screen.rs | 45 +++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index f9da86e3e..ccf9063d3 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -19,7 +19,7 @@ use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; use dash_sdk::dpp::address_funds::PlatformAddress; -use dash_sdk::dpp::balances::credits::Credits; +use dash_sdk::dpp::balances::credits::{CREDITS_PER_DUFF, Credits}; use dash_sdk::dpp::identity::core_script::CoreScript; use eframe::egui::{self, Context, RichText, Ui}; use egui::{Color32, Frame, Margin}; @@ -396,6 +396,33 @@ impl WalletSendScreen { AddressType::Unknown } + fn min_output_amount( + &self, + input_type: AddressType, + output_type: AddressType, + ) -> Option { + let core_min = 5460_u64 * CREDITS_PER_DUFF; + let platform_min = self + .app_context + .platform_version() + .dpp + .state_transitions + .address_funds + .min_output_amount; + + match (input_type, output_type) { + (AddressType::Unknown, AddressType::Unknown) => None, + (AddressType::Core, AddressType::Core) => Some(core_min), + (AddressType::Platform, AddressType::Platform) => Some(platform_min), + (AddressType::Core, AddressType::Platform) => Some(56000000), // needed for asset locks + (AddressType::Platform, AddressType::Core) => Some(core_min.max(platform_min)), + (AddressType::Unknown, AddressType::Core) => Some(core_min), + (AddressType::Unknown, AddressType::Platform) => Some(platform_min), + (AddressType::Core, AddressType::Unknown) => Some(core_min), + (AddressType::Platform, AddressType::Unknown) => Some(platform_min), + } + } + /// Get available Platform addresses with balances /// Deduplicates addresses based on their canonical Bech32m string representation, /// preferring the entry with the highest nonce (most recent update) @@ -1160,8 +1187,9 @@ impl WalletSendScreen { let max = self.selected_wallet.as_ref().and_then(|w| { w.read() .ok() - .map(|wallet| wallet.total_balance_duffs() * 1000) // duffs to credits + .map(|wallet| wallet.total_balance_duffs() * CREDITS_PER_DUFF) // duffs to credits }); + (max, None) } Some(SourceSelection::PlatformAddresses(addresses)) => { @@ -1203,6 +1231,14 @@ impl WalletSendScreen { None => (None, None), }; + let input_type = match self.selected_source { + Some(SourceSelection::CoreWallet) => AddressType::Core, + Some(SourceSelection::PlatformAddresses(_)) => AddressType::Platform, + None => AddressType::Unknown, + }; + let output_type = self.detect_address_type(&self.destination_address); + let min_amount = self.min_output_amount(input_type, output_type); + Frame::group(ui.style()) .fill(DashColors::surface(dark_mode)) .inner_margin(Margin::symmetric(12, 10)) @@ -1215,9 +1251,10 @@ impl WalletSendScreen { .with_desired_width(150.0) }); - // Update max amount and hint dynamically + // Update max/min amount and hint dynamically amount_input.set_max_amount(max_amount_credits); amount_input.set_max_exceeded_hint(max_hint); + amount_input.set_min_amount(min_amount); let response = amount_input.show(ui); response.inner.update(&mut self.amount); @@ -2108,7 +2145,7 @@ impl WalletSendScreen { /// Advanced Core to Core send (multiple outputs) fn send_advanced_core_to_core(&mut self) -> Result { - let wallet = self + let wallet: Arc> = self .selected_wallet .as_ref() .ok_or("No wallet selected")? From 4c1f40d4c13d63d844061ca92ff8a6256ccb1b2f Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Wed, 4 Feb 2026 22:55:23 +0700 Subject: [PATCH 073/106] chore: update bincode to 2.0.1, dash-sdk to v3.1-dev, and grovestark (#526) * chore: update bincode to 2.0.1, dash-sdk to v3.1-dev, and grovestark - Update bincode from 2.0.0-rc.3 to 2.0.1 - Update dash-sdk to latest v3.1-dev (98a0ebec) - Update grovestark to a70bdb1a - Fix Decode/BorrowDecode implementations for bincode 2.0.1 API changes Co-Authored-By: Claude Opus 4.5 * chore: migrate to using dashpay/grovestark --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: pasta --- Cargo.lock | 568 ++++++++---------- Cargo.toml | 6 +- .../encrypted_key_storage.rs | 4 +- src/model/qualified_identity/mod.rs | 2 +- 4 files changed, 240 insertions(+), 340 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 65f0d403c..fe7c0b67d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -274,15 +274,6 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "arboard" version = "3.6.1" @@ -305,9 +296,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" dependencies = [ "rustversion", ] @@ -817,21 +808,22 @@ dependencies = [ [[package]] name = "bincode" -version = "2.0.0-rc.3" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f11ea1a0346b94ef188834a65c068a03aec181c94896d481d7a0a40d85b0ce95" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" dependencies = [ "bincode_derive", "serde", + "unty", ] [[package]] name = "bincode_derive" -version = "2.0.0-rc.3" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e30759b3b99a1b802a7a3aa21c85c3ded5c28e1c83170d82d70f08bbf7f3e4c" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" dependencies = [ - "virtue 0.0.13", + "virtue 0.0.18", ] [[package]] @@ -1128,9 +1120,9 @@ checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" dependencies = [ "bytemuck_derive", ] @@ -1160,9 +1152,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] @@ -1239,9 +1231,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.53" +version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ "find-msvc-tools", "jobserver", @@ -1390,9 +1382,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.54" +version = "4.5.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" dependencies = [ "clap_builder", "clap_derive", @@ -1400,9 +1392,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.5.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" dependencies = [ "anstream", "anstyle", @@ -1412,9 +1404,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", @@ -1731,8 +1723,8 @@ dependencies = [ [[package]] name = "dapi-grpc" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "dash-platform-macros", "futures-core", @@ -1799,8 +1791,8 @@ dependencies = [ [[package]] name = "dash-context-provider" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "dpp", "drive", @@ -1817,7 +1809,7 @@ dependencies = [ "arboard", "argon2", "base64 0.22.1", - "bincode 2.0.0-rc.3", + "bincode 2.0.1", "bip39", "bitflags 2.10.0", "cbc", @@ -1878,9 +1870,9 @@ dependencies = [ [[package]] name = "dash-network" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" dependencies = [ - "bincode 2.0.0-rc.3", + "bincode 2.0.1", "bincode_derive", "hex", "serde", @@ -1888,8 +1880,8 @@ dependencies = [ [[package]] name = "dash-platform-macros" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "heck", "quote", @@ -1898,8 +1890,8 @@ dependencies = [ [[package]] name = "dash-sdk" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "arc-swap", "async-trait", @@ -1933,11 +1925,11 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" dependencies = [ "anyhow", "async-trait", - "bincode 1.3.3", + "bincode 2.0.1", "blsful", "chrono", "clap", @@ -1964,12 +1956,12 @@ dependencies = [ [[package]] name = "dashcore" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" dependencies = [ "anyhow", "base64-compat", "bech32 0.9.1", - "bincode 2.0.0-rc.3", + "bincode 2.0.1", "bincode_derive", "bitvec", "blake3", @@ -1990,12 +1982,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" [[package]] name = "dashcore-rpc" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" dependencies = [ "dashcore-rpc-json", "hex", @@ -2008,9 +2000,9 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" dependencies = [ - "bincode 2.0.0-rc.3", + "bincode 2.0.1", "dashcore", "hex", "key-wallet", @@ -2023,9 +2015,9 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" dependencies = [ - "bincode 2.0.0-rc.3", + "bincode 2.0.1", "dashcore-private", "rs-x11-hash", "secp256k1", @@ -2047,8 +2039,8 @@ dependencies = [ [[package]] name = "dashpay-contract" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "platform-value", "platform-version", @@ -2058,8 +2050,8 @@ dependencies = [ [[package]] name = "data-contracts" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2118,17 +2110,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "derive_builder" version = "0.20.2" @@ -2322,8 +2303,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "platform-value", "platform-version", @@ -2333,15 +2314,14 @@ dependencies = [ [[package]] name = "dpp" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "anyhow", "async-trait", "base64 0.22.1", "bech32 0.11.1", - "bincode 2.0.0-rc.3", - "bincode_derive", + "bincode 2.0.1", "bs58", "byteorder", "chrono", @@ -2382,18 +2362,18 @@ dependencies = [ [[package]] name = "drive" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ - "bincode 2.0.0-rc.3", + "bincode 2.0.1", "byteorder", "derive_more 1.0.0", "dpp", - "grovedb 4.0.0", - "grovedb-costs 4.0.0", + "grovedb", + "grovedb-costs", "grovedb-epoch-based-storage-flags", - "grovedb-path 4.0.0", - "grovedb-version 4.0.0", + "grovedb-path", + "grovedb-version", "hex", "indexmap 2.13.0", "integer-encoding", @@ -2407,10 +2387,10 @@ dependencies = [ [[package]] name = "drive-proof-verifier" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ - "bincode 2.0.0-rc.3", + "bincode 2.0.1", "dapi-grpc", "dash-context-provider", "derive_more 1.0.0", @@ -2983,8 +2963,8 @@ dependencies = [ [[package]] name = "feature-flags-contract" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "platform-value", "platform-version", @@ -3011,9 +2991,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "find-msvc-tools" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" @@ -3023,9 +3003,9 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -3500,21 +3480,21 @@ dependencies = [ [[package]] name = "grovedb" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12b2378c5eda5b7cadceb34fc6e0a8fd87fe03fc04841a7d32a74ff73ccef71" +version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ - "bincode 2.0.0-rc.3", + "bincode 2.0.1", "bincode_derive", "blake3", - "grovedb-costs 3.1.0", - "grovedb-merk 3.1.0", - "grovedb-path 3.1.0", + "grovedb-costs", + "grovedb-element", + "grovedb-merk", + "grovedb-path", "grovedb-storage", - "grovedb-version 3.1.0", - "grovedb-visualize 3.1.0", + "grovedb-version", + "grovedb-visualize", "hex", - "hex-literal 0.4.1", + "hex-literal", "indexmap 2.13.0", "integer-encoding", "intmap", @@ -3525,43 +3505,10 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "grovedb" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v4.1.0#86562f65d9ec08bea28dc9981663cd2a63dc7f3b" -dependencies = [ - "bincode 2.0.0-rc.3", - "bincode_derive", - "blake3", - "grovedb-costs 4.0.0", - "grovedb-element", - "grovedb-merk 4.0.0", - "grovedb-path 4.0.0", - "grovedb-version 4.0.0", - "hex", - "hex-literal 1.1.0", - "indexmap 2.13.0", - "integer-encoding", - "reqwest", - "sha2", - "thiserror 2.0.18", -] - -[[package]] -name = "grovedb-costs" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e74fafe53bf5ae27128799856e557ef5cb2d7109f1f7bc7f4440bbd0f97c7072" -dependencies = [ - "integer-encoding", - "intmap", - "thiserror 2.0.18", -] - [[package]] name = "grovedb-costs" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v4.1.0#86562f65d9ec08bea28dc9981663cd2a63dc7f3b" +source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ "integer-encoding", "intmap", @@ -3571,12 +3518,13 @@ dependencies = [ [[package]] name = "grovedb-element" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v4.1.0#86562f65d9ec08bea28dc9981663cd2a63dc7f3b" +source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ - "bincode 2.0.0-rc.3", + "bincode 2.0.1", "bincode_derive", - "grovedb-path 4.0.0", - "grovedb-version 4.0.0", + "grovedb-path", + "grovedb-version", + "grovedb-visualize", "hex", "integer-encoding", "thiserror 2.0.18", @@ -3585,9 +3533,9 @@ dependencies = [ [[package]] name = "grovedb-epoch-based-storage-flags" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v4.1.0#86562f65d9ec08bea28dc9981663cd2a63dc7f3b" +source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ - "grovedb-costs 4.0.0", + "grovedb-costs", "hex", "integer-encoding", "intmap", @@ -3596,21 +3544,21 @@ dependencies = [ [[package]] name = "grovedb-merk" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6dd6f733e9d5c15c98e05b68a2028e00b7f177baa51e8d8c1541102942a72b7" +version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ - "bincode 2.0.0-rc.3", + "bincode 2.0.1", "bincode_derive", "blake3", "byteorder", "colored", "ed", - "grovedb-costs 3.1.0", - "grovedb-path 3.1.0", + "grovedb-costs", + "grovedb-element", + "grovedb-path", "grovedb-storage", - "grovedb-version 3.1.0", - "grovedb-visualize 3.1.0", + "grovedb-version", + "grovedb-visualize", "hex", "indexmap 2.13.0", "integer-encoding", @@ -3619,54 +3567,23 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "grovedb-merk" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v4.1.0#86562f65d9ec08bea28dc9981663cd2a63dc7f3b" -dependencies = [ - "bincode 2.0.0-rc.3", - "bincode_derive", - "blake3", - "byteorder", - "ed", - "grovedb-costs 4.0.0", - "grovedb-element", - "grovedb-path 4.0.0", - "grovedb-version 4.0.0", - "grovedb-visualize 4.0.0", - "hex", - "indexmap 2.13.0", - "integer-encoding", - "thiserror 2.0.18", -] - -[[package]] -name = "grovedb-path" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01f716520d6c6b0f25dc4a68bc7dded645826ed57d38a06a80716a487c09d23c" -dependencies = [ - "hex", -] - [[package]] name = "grovedb-path" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v4.1.0#86562f65d9ec08bea28dc9981663cd2a63dc7f3b" +source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ "hex", ] [[package]] name = "grovedb-storage" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d04f3831fe210543a7246f2a60ae068f23eac5f9d53200d5a82785750f68fd" +version = "4.0.0" +source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ "blake3", - "grovedb-costs 3.1.0", - "grovedb-path 3.1.0", - "grovedb-visualize 3.1.0", + "grovedb-costs", + "grovedb-path", + "grovedb-visualize", "hex", "integer-encoding", "lazy_static", @@ -3677,39 +3594,19 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "grovedb-version" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdc855662f05f41b10dd022226cb78e345a33f35c390e25338d21dedd45966ae" -dependencies = [ - "thiserror 2.0.18", - "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", -] - [[package]] name = "grovedb-version" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v4.1.0#86562f65d9ec08bea28dc9981663cd2a63dc7f3b" +source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ "thiserror 2.0.18", "versioned-feature-core 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", ] -[[package]] -name = "grovedb-visualize" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34fa6f41c110d1d141bf912175f187ef51ac5d2a8f163dfd229be007461a548f" -dependencies = [ - "hex", - "itertools 0.14.0", -] - [[package]] name = "grovedb-visualize" version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?tag=v4.1.0#86562f65d9ec08bea28dc9981663cd2a63dc7f3b" +source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" dependencies = [ "hex", "itertools 0.14.0", @@ -3718,20 +3615,20 @@ dependencies = [ [[package]] name = "grovestark" version = "0.1.0" -source = "git+https://www.github.com/pauldelucia/grovestark?rev=c5823c8239792f75f93f59f025aa335ab6d42c36#c5823c8239792f75f93f59f025aa335ab6d42c36" +source = "git+https://www.github.com/dashpay/grovestark?rev=a70bdb1adac080d4f1dc10f2f4480d9dacd0f0da#a70bdb1adac080d4f1dc10f2f4480d9dacd0f0da" dependencies = [ "ark-ff", "base64 0.22.1", "bincode 1.3.3", - "bincode 2.0.0-rc.3", + "bincode 2.0.1", "blake3", "bs58", "curve25519-dalek", "ed25519-dalek", "env_logger", - "grovedb 3.1.0", - "grovedb-costs 3.1.0", - "grovedb-merk 3.1.0", + "grovedb", + "grovedb-costs", + "grovedb-merk", "hex", "log", "num-bigint", @@ -3873,12 +3770,6 @@ dependencies = [ "arrayvec", ] -[[package]] -name = "hex-literal" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" - [[package]] name = "hex-literal" version = "1.1.0" @@ -4101,14 +3992,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -4117,7 +4007,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.6.2", "system-configuration", "tokio", "tower-service", @@ -4127,9 +4017,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -4270,7 +4160,7 @@ dependencies = [ "png 0.18.0", "tiff", "zune-core 0.5.1", - "zune-jpeg 0.5.11", + "zune-jpeg 0.5.12", ] [[package]] @@ -4503,11 +4393,11 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" dependencies = [ "async-trait", "base58ck", - "bincode 2.0.0-rc.3", + "bincode 2.0.1", "bincode_derive", "bip39", "bitflags 2.10.0", @@ -4530,10 +4420,10 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.41.0" -source = "git+https://github.com/dashpay/rust-dashcore?tag=v0.41.0#c0da3509c695351e6dc300723ded35b4f0df16a3" +source = "git+https://github.com/dashpay/rust-dashcore?rev=53d699c9b551ac7d3644e11ca46dc3819277ff87#53d699c9b551ac7d3644e11ca46dc3819277ff87" dependencies = [ "async-trait", - "bincode 2.0.0-rc.3", + "bincode 2.0.1", "dashcore", "dashcore_hashes", "key-wallet", @@ -4543,8 +4433,8 @@ dependencies = [ [[package]] name = "keyword-search-contract" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "platform-value", "platform-version", @@ -4636,9 +4526,9 @@ dependencies = [ [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" @@ -4653,9 +4543,8 @@ dependencies = [ [[package]] name = "librocksdb-sys" -version = "0.17.3+10.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cef2a00ee60fe526157c9023edab23943fae1ce2ab6f4abb2a807c1746835de9" +version = "0.18.0+10.7.5" +source = "git+https://github.com/QuantumExplorer/rust-rocksdb.git?rev=52772eea7bcd214d1d07d80aa538b1d24e5015b7#52772eea7bcd214d1d07d80aa538b1d24e5015b7" dependencies = [ "bindgen 0.72.1", "bzip2-sys", @@ -4760,8 +4649,8 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "platform-value", "platform-version", @@ -4876,9 +4765,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.12" +version = "0.12.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3dec6bd31b08944e08b58fd99373893a6c17054d6f3ea5006cc894f4f4eee2a" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" dependencies = [ "crossbeam-channel", "crossbeam-epoch", @@ -4954,9 +4843,9 @@ dependencies = [ [[package]] name = "native-dialog" -version = "0.9.4" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89853bb05334e192e6646290ea94ca31bcb80443f25ad40ebf478b6dafb08d6c" +checksum = "3b2373fb763ea6962e8c586571c7f9c5d8c995f9777cbdebbe0180263691bd4b" dependencies = [ "ascii", "block2 0.6.2", @@ -5117,9 +5006,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-derive" @@ -5564,9 +5453,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-probe" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" @@ -5832,17 +5721,17 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "platform-serialization" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ - "bincode 2.0.0-rc.3", + "bincode 2.0.1", "platform-version", ] [[package]] name = "platform-serialization-derive" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "proc-macro2", "quote", @@ -5852,11 +5741,11 @@ dependencies = [ [[package]] name = "platform-value" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "base64 0.22.1", - "bincode 2.0.0-rc.3", + "bincode 2.0.1", "bs58", "ciborium", "hex", @@ -5872,11 +5761,11 @@ dependencies = [ [[package]] name = "platform-version" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ - "bincode 2.0.0-rc.3", - "grovedb-version 4.0.0", + "bincode 2.0.1", + "grovedb-version", "once_cell", "thiserror 2.0.18", "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", @@ -5884,8 +5773,8 @@ dependencies = [ [[package]] name = "platform-versioning" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "proc-macro2", "quote", @@ -5952,15 +5841,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" dependencies = [ "portable-atomic", ] @@ -6026,9 +5915,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -6148,9 +6037,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -6316,9 +6205,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -6328,9 +6217,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -6339,9 +6228,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "renderdoc-sys" @@ -6466,8 +6355,7 @@ dependencies = [ [[package]] name = "rocksdb" version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddb7af00d2b17dbd07d82c0063e25411959748ff03e8d4f96134c2ff41fce34f" +source = "git+https://github.com/QuantumExplorer/rust-rocksdb.git?rev=52772eea7bcd214d1d07d80aa538b1d24e5015b7#52772eea7bcd214d1d07d80aa538b1d24e5015b7" dependencies = [ "libc", "librocksdb-sys", @@ -6494,8 +6382,8 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rs-dapi-client" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "backon", "chrono", @@ -6645,7 +6533,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.0", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", "security-framework 3.5.1", @@ -7039,15 +6927,15 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slotmap" @@ -7148,9 +7036,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -7340,9 +7228,9 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.10.0", "core-foundation 0.9.4", @@ -7405,8 +7293,8 @@ dependencies = [ [[package]] name = "tenderdash-abci" -version = "1.5.0-dev.2" -source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.0-dev.2#3f6ac716c42125a01caceb42cc5997efa41c88fc" +version = "1.5.0" +source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.0#7a6b7433f780938c244e4d201bf8e66aa02cd9c2" dependencies = [ "bytes", "hex", @@ -7420,8 +7308,8 @@ dependencies = [ [[package]] name = "tenderdash-proto" -version = "1.5.0-dev.2" -source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.0-dev.2#3f6ac716c42125a01caceb42cc5997efa41c88fc" +version = "1.5.0" +source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.0#7a6b7433f780938c244e4d201bf8e66aa02cd9c2" dependencies = [ "bytes", "chrono", @@ -7438,8 +7326,8 @@ dependencies = [ [[package]] name = "tenderdash-proto-compiler" -version = "1.5.0-dev.2" -source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.0-dev.2#3f6ac716c42125a01caceb42cc5997efa41c88fc" +version = "1.5.0" +source = "git+https://github.com/dashpay/rs-tenderdash-abci?tag=v1.5.0#7a6b7433f780938c244e4d201bf8e66aa02cd9c2" dependencies = [ "fs_extra", "prost-build", @@ -7533,9 +7421,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.45" +version = "0.3.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" dependencies = [ "deranged", "itoa", @@ -7548,15 +7436,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.25" +version = "0.2.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" dependencies = [ "num-conv", "time-core", @@ -7615,8 +7503,8 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "platform-value", "platform-version", @@ -7636,7 +7524,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.2", "tokio-macros", "windows-sys 0.61.2", ] @@ -7783,9 +7671,9 @@ dependencies = [ [[package]] name = "tonic" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" +checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a" dependencies = [ "async-trait", "base64 0.22.1", @@ -7800,7 +7688,7 @@ dependencies = [ "percent-encoding", "pin-project", "rustls-native-certs", - "socket2 0.6.1", + "socket2 0.6.2", "sync_wrapper", "tokio", "tokio-rustls", @@ -7814,9 +7702,9 @@ dependencies = [ [[package]] name = "tonic-build" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c40aaccc9f9eccf2cd82ebc111adc13030d23e887244bc9cfa5d1d636049de3" +checksum = "27aac809edf60b741e2d7db6367214d078856b8a5bff0087e94ff330fb97b6fc" dependencies = [ "prettyplease", "proc-macro2", @@ -7826,9 +7714,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" +checksum = "d6c55a2d6a14174563de34409c9f92ff981d006f56da9c6ecd40d9d4a31500b0" dependencies = [ "bytes", "prost", @@ -7837,9 +7725,9 @@ dependencies = [ [[package]] name = "tonic-prost-build" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a16cba4043dc3ff43fcb3f96b4c5c154c64cbd18ca8dce2ab2c6a451d058a2" +checksum = "a4556786613791cfef4ed134aa670b61a85cfcacf71543ef33e8d801abae988f" dependencies = [ "prettyplease", "proc-macro2", @@ -8029,6 +7917,12 @@ dependencies = [ "rustc-hash 2.1.1", ] +[[package]] +name = "typed-path" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3015e6ce46d5ad8751e4a772543a30c7511468070e98e64e20165f8f81155b64" + [[package]] name = "typenum" version = "1.19.0" @@ -8037,9 +7931,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "tz-rs" -version = "0.7.1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14eff19b8dc1ace5bf7e4d920b2628ae3837f422ff42210cb1567cbf68b5accf" +checksum = "4fc6c929ffa10fb34f4a3c7e9a73620a83ef2e85e47f9ec3381b8289e6762f42" [[package]] name = "uds_windows" @@ -8158,6 +8052,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "ureq" version = "3.1.4" @@ -8253,9 +8153,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ "getrandom 0.3.4", "js-sys", @@ -8316,15 +8216,15 @@ dependencies = [ [[package]] name = "virtue" -version = "0.0.13" +version = "0.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcc60c0624df774c82a0ef104151231d37da4962957d691c011c852b2473314" +checksum = "7302ac74a033bf17b6e609ceec0f891ca9200d502d31f02dc7908d3d98767c9d" [[package]] name = "virtue" -version = "0.0.17" +version = "0.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7302ac74a033bf17b6e609ceec0f891ca9200d502d31f02dc7908d3d98767c9d" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" [[package]] name = "vsss-rs" @@ -8356,8 +8256,8 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "platform-value", "platform-version", @@ -8634,9 +8534,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] @@ -9612,8 +9512,8 @@ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "withdrawals-contract" -version = "3.0.1-hotfix.1" -source = "git+https://github.com/dashpay/platform?rev=07f7eb649bdc1485b3a43f384da7f96232fcfbba#07f7eb649bdc1485b3a43f384da7f96232fcfbba" +version = "3.0.0" +source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" dependencies = [ "num_enum 0.5.11", "platform-value", @@ -9830,18 +9730,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.33" +version = "0.8.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.33" +version = "0.8.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" dependencies = [ "proc-macro2", "quote", @@ -9962,29 +9862,29 @@ dependencies = [ [[package]] name = "zip" -version = "5.1.1" +version = "7.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f852905151ac8d4d06fdca66520a661c09730a74c6d4e2b0f27b436b382e532" +checksum = "268bf6f9ceb991e07155234071501490bb41fd1e39c6a588106dad10ae2a5804" dependencies = [ - "arbitrary", "crc32fast", "flate2", "indexmap 2.13.0", "memchr", + "typed-path", "zopfli", ] [[package]] name = "zlib-rs" -version = "0.5.5" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" +checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" [[package]] name = "zmij" -version = "1.0.16" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" +checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" [[package]] name = "zmq" @@ -10053,9 +9953,9 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.5.11" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2959ca473aae96a14ecedf501d20b3608d2825ba280d5adb57d651721885b0c2" +checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" dependencies = [ "zune-core 0.5.1", ] diff --git a/Cargo.toml b/Cargo.toml index 647057449..f535fb180 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ qrcode = "0.14.1" nix = { version = "0.30.1", features = ["signal"] } eframe = { version = "0.32.0", features = ["persistence"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "07f7eb649bdc1485b3a43f384da7f96232fcfbba", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -27,14 +27,14 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "07f7eb649bdc148 "core_rpc_client", "core_spv", ] } -grovestark = { git = "https://www.github.com/pauldelucia/grovestark", rev = "c5823c8239792f75f93f59f025aa335ab6d42c36" } +grovestark = { git = "https://www.github.com/dashpay/grovestark", rev = "a70bdb1adac080d4f1dc10f2f4480d9dacd0f0da" } rayon = "1.8" thiserror = "2.0.12" serde = "1.0.219" serde_json = "1.0.140" serde_yaml = { version = "0.9.34-deprecated" } tokio = { version = "1.46.1", features = ["full"] } -bincode = { version = "=2.0.0-rc.3", features = ["serde"] } +bincode = { version = "=2.0.1", features = ["serde"] } hex = { version = "0.4.3" } itertools = "0.14.0" enum-iterator = "2.1.0" diff --git a/src/model/qualified_identity/encrypted_key_storage.rs b/src/model/qualified_identity/encrypted_key_storage.rs index eb0f0c7f3..a19eed6d7 100644 --- a/src/model/qualified_identity/encrypted_key_storage.rs +++ b/src/model/qualified_identity/encrypted_key_storage.rs @@ -54,7 +54,7 @@ impl Encode for WalletDerivationPath { } } -impl Decode for WalletDerivationPath { +impl Decode for WalletDerivationPath { fn decode(decoder: &mut D) -> Result { // Decode `wallet_seed_hash` let wallet_seed_hash = WalletSeedHash::decode(decoder)?; @@ -92,7 +92,7 @@ impl Decode for WalletDerivationPath { } } -impl<'de> BorrowDecode<'de> for WalletDerivationPath { +impl<'de, Context> BorrowDecode<'de, Context> for WalletDerivationPath { fn borrow_decode>(decoder: &mut D) -> Result { // Decode `wallet_seed_hash` let wallet_seed_hash = WalletSeedHash::decode(decoder)?; diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 70838e467..4152843ab 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -270,7 +270,7 @@ impl Encode for QualifiedIdentity { } // Implement Decode manually for QualifiedIdentity, excluding decrypted_wallets -impl Decode for QualifiedIdentity { +impl Decode for QualifiedIdentity { fn decode( decoder: &mut D, ) -> Result { From c13772be661b68c778ee1a2501381cefc0094116 Mon Sep 17 00:00:00 2001 From: PastaPastaPasta <6443210+PastaPastaPasta@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:53:08 -0600 Subject: [PATCH 074/106] feat: use DAPI instead of Core RPC for historical asset lock transaction info (#528) Replace core_client.get_raw_transaction_info() calls with a new get_transaction_info_via_dapi() function that queries transaction status via DAPI gRPC. This removes the dependency on a local Core RPC node for checking whether asset lock transactions have been chainlocked, which is needed for identity registration, top-up, and platform address funding from asset locks. Co-authored-by: Claude Opus 4.5 --- .../identity/register_identity.rs | 42 +++++++------------ src/backend_task/identity/top_up_identity.rs | 41 +++++++----------- .../fund_platform_address_from_asset_lock.rs | 21 +++------- src/context.rs | 33 +++++++++++++++ 4 files changed, 70 insertions(+), 67 deletions(-) diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index 55d529848..855f08387 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -1,6 +1,6 @@ use crate::backend_task::identity::{IdentityRegistrationInfo, RegisterIdentityFundingMethod}; use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; -use crate::context::AppContext; +use crate::context::{AppContext, get_transaction_info_via_dapi}; use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::proof_log_item::{ProofLogItem, RequestType}; use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; @@ -53,30 +53,23 @@ impl AppContext { RegisterIdentityFundingMethod::UseAssetLock(address, asset_lock_proof, transaction) => { let tx_id = transaction.txid(); - // eprintln!("UseAssetLock: transaction id for {:#?} is {}", transaction, tx_id); - let wallet = wallet.read().unwrap(); - wallet_id = wallet.seed_hash(); - let private_key = wallet - .private_key_for_address(&address, self.network)? - .ok_or("Asset Lock not valid for wallet")?; + // Scope the read guard so it's dropped before the async DAPI call below + let private_key = { + let wallet = wallet.read().unwrap(); + wallet_id = wallet.seed_hash(); + wallet + .private_key_for_address(&address, self.network)? + .ok_or("Asset Lock not valid for wallet")? + }; let asset_lock_proof = if let AssetLockProof::Instant(instant_asset_lock_proof) = asset_lock_proof.as_ref() { // we need to make sure the instant send asset lock is recent - let raw_transaction_info = self - .core_client - .read() - .expect("Core client lock was poisoned") - .get_raw_transaction_info(&tx_id, None) - .map_err(|e| e.to_string())?; + let tx_info = get_transaction_info_via_dapi(&sdk, &tx_id).await?; - if raw_transaction_info.chainlock - && raw_transaction_info.height.is_some() - && raw_transaction_info.confirmations.is_some() - && raw_transaction_info.confirmations.unwrap() > 8 - { + if tx_info.is_chain_locked && tx_info.height > 0 && tx_info.confirmations > 8 { // Transaction is old enough that instant lock may have expired - let tx_block_height = raw_transaction_info.height.unwrap() as u32; + let tx_block_height = tx_info.height; if tx_block_height <= metadata.core_chain_locked_height { // Platform has verified this Core block, use chain lock proof @@ -488,15 +481,10 @@ impl AppContext { || e.contains("wasn't created recently") { // Try to use chain asset lock proof instead - let raw_transaction_info = self - .core_client - .read() - .expect("Core client lock was poisoned") - .get_raw_transaction_info(&tx_id, None) - .map_err(|e| e.to_string())?; + let tx_info = get_transaction_info_via_dapi(&sdk, &tx_id).await?; - if raw_transaction_info.chainlock && raw_transaction_info.height.is_some() { - let tx_block_height = raw_transaction_info.height.unwrap() as u32; + if tx_info.is_chain_locked && tx_info.height > 0 { + let tx_block_height = tx_info.height; if tx_block_height <= metadata.core_chain_locked_height { // Platform has verified this Core block, use chain lock proof diff --git a/src/backend_task/identity/top_up_identity.rs b/src/backend_task/identity/top_up_identity.rs index 6e068f43b..5a309e89f 100644 --- a/src/backend_task/identity/top_up_identity.rs +++ b/src/backend_task/identity/top_up_identity.rs @@ -1,6 +1,6 @@ use crate::backend_task::identity::{IdentityTopUpInfo, TopUpIdentityFundingMethod}; use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; -use crate::context::AppContext; +use crate::context::{AppContext, get_transaction_info_via_dapi}; use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::proof_log_item::{ProofLogItem, RequestType}; use dash_sdk::Error; @@ -47,30 +47,26 @@ impl AppContext { ) => { let tx_id = transaction.txid(); - // eprintln!("UseAssetLock: transaction id for {:#?} is {}", transaction, tx_id); - let wallet = wallet.read().unwrap(); - let private_key = wallet - .private_key_for_address(&address, self.network)? - .ok_or("Asset Lock not valid for wallet")?; + // Scope the read guard so it's dropped before the async DAPI call below + let private_key = { + let wallet = wallet.read().unwrap(); + wallet + .private_key_for_address(&address, self.network)? + .ok_or("Asset Lock not valid for wallet")? + }; let asset_lock_proof = if let AssetLockProof::Instant( instant_asset_lock_proof, ) = asset_lock_proof.as_ref() { // we need to make sure the instant send asset lock is recent - let raw_transaction_info = self - .core_client - .read() - .expect("Core client lock was poisoned") - .get_raw_transaction_info(&tx_id, None) - .map_err(|e| e.to_string())?; + let tx_info = get_transaction_info_via_dapi(&sdk, &tx_id).await?; - if raw_transaction_info.chainlock - && raw_transaction_info.height.is_some() - && raw_transaction_info.confirmations.is_some() - && raw_transaction_info.confirmations.unwrap() > 8 + if tx_info.is_chain_locked + && tx_info.height > 0 + && tx_info.confirmations > 8 { // Transaction is old enough that instant lock may have expired - let tx_block_height = raw_transaction_info.height.unwrap() as u32; + let tx_block_height = tx_info.height; if tx_block_height <= metadata.core_chain_locked_height { // Platform has verified this Core block, use chain lock proof @@ -410,15 +406,10 @@ impl AppContext { || error_string.contains("wasn't created recently") { // Try to use chain asset lock proof instead - let raw_transaction_info = self - .core_client - .read() - .expect("Core client lock was poisoned") - .get_raw_transaction_info(&tx_id, None) - .map_err(|e| e.to_string())?; + let tx_info = get_transaction_info_via_dapi(&sdk, &tx_id).await?; - if raw_transaction_info.chainlock && raw_transaction_info.height.is_some() { - let tx_block_height = raw_transaction_info.height.unwrap() as u32; + if tx_info.is_chain_locked && tx_info.height > 0 { + let tx_block_height = tx_info.height; if tx_block_height <= metadata.core_chain_locked_height { // Platform has verified this Core block, use chain lock proof diff --git a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs index ea593fa36..5da5ff228 100644 --- a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs +++ b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs @@ -46,7 +46,7 @@ impl AppContext { }; // Check if we need to convert an old instant lock proof to a chain lock proof - use dash_sdk::dashcore_rpc::RpcApi; + use crate::context::get_transaction_info_via_dapi; use dash_sdk::dpp::block::extended_epoch_info::ExtendedEpochInfo; use dash_sdk::platform::Fetch; @@ -56,21 +56,12 @@ impl AppContext { // Get the transaction ID from the instant lock proof let tx_id = instant_asset_lock_proof.transaction().txid(); - // Query the core client to check if the transaction has been chain-locked - let raw_transaction_info = self - .core_client - .read() - .expect("Core client lock was poisoned") - .get_raw_transaction_info(&tx_id, None) - .map_err(|e| format!("Failed to get transaction info: {}", e))?; - - if raw_transaction_info.chainlock - && raw_transaction_info.height.is_some() - && raw_transaction_info.confirmations.is_some() - && raw_transaction_info.confirmations.unwrap() > 8 - { + // Query DAPI to check if the transaction has been chain-locked + let tx_info = get_transaction_info_via_dapi(&sdk, &tx_id).await?; + + if tx_info.is_chain_locked && tx_info.height > 0 && tx_info.confirmations > 8 { // Transaction has been chain-locked with sufficient confirmations - let tx_block_height = raw_transaction_info.height.unwrap() as u32; + let tx_block_height = tx_info.height; // Check if the platform has caught up to this block height let (_, metadata) = ExtendedEpochInfo::fetch_with_metadata(&sdk, 0, None) diff --git a/src/context.rs b/src/context.rs index 200ef77fa..4d7be12ff 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1708,6 +1708,39 @@ impl AppContext { } } +pub(crate) struct DapiTransactionInfo { + pub is_chain_locked: bool, + pub height: u32, + pub confirmations: u32, +} + +/// Query transaction info from DAPI. Works in both SPV and RPC modes +/// since DAPI (platform gRPC) is always available via the SDK. +pub(crate) async fn get_transaction_info_via_dapi( + sdk: &Sdk, + tx_id: &Txid, +) -> Result { + use dash_sdk::dapi_client::{DapiRequestExecutor, IntoInner, RequestSettings}; + use dash_sdk::dapi_grpc::core::v0::GetTransactionRequest; + + let response = sdk + .execute( + GetTransactionRequest { + id: tx_id.to_string(), + }, + RequestSettings::default(), + ) + .await + .into_inner() + .map_err(|e| format!("DAPI GetTransaction failed: {}", e))?; + + Ok(DapiTransactionInfo { + is_chain_locked: response.is_chain_locked, + height: response.height, + confirmations: response.confirmations, + }) +} + /// Returns the default platform version for the given network. pub(crate) const fn default_platform_version(network: &Network) -> &'static PlatformVersion { // TODO: Use self.sdk.read().unwrap().version() instead of hardcoding From 2260d0884113d9a34a9b1faee2b09040772608d7 Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Feb 2026 04:13:55 +0100 Subject: [PATCH 075/106] fix: not enough balance to pay a fee in platform to core transfers (#513) * fix: fee estimation for AddressCreditWithdrawalTransition * chore: max button * refactor: remove redundant code * chore: better hint in platform-to-core --- src/ui/wallets/send_screen.rs | 285 +++++++++++++++++++++++++++------- 1 file changed, 225 insertions(+), 60 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index ccf9063d3..909b8dfee 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -18,9 +18,18 @@ use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; +use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::{CREDITS_PER_DUFF, Credits}; use dash_sdk::dpp::identity::core_script::CoreScript; +use dash_sdk::dpp::prelude::AddressNonce; +use dash_sdk::dpp::prelude::AssetLockProof; +use dash_sdk::dpp::state_transition::StateTransitionEstimatedFeeValidation; +use dash_sdk::dpp::state_transition::address_credit_withdrawal_transition::AddressCreditWithdrawalTransition; +use dash_sdk::dpp::state_transition::address_credit_withdrawal_transition::v0::AddressCreditWithdrawalTransitionV0; +use dash_sdk::dpp::state_transition::address_funding_from_asset_lock_transition::AddressFundingFromAssetLockTransition; +use dash_sdk::dpp::state_transition::address_funding_from_asset_lock_transition::v0::AddressFundingFromAssetLockTransitionV0; +use dash_sdk::dpp::withdrawal::Pooling; use eframe::egui::{self, Context, RichText, Ui}; use egui::{Color32, Frame, Margin}; use std::collections::BTreeMap; @@ -56,6 +65,56 @@ fn estimate_platform_fee(estimator: &PlatformFeeEstimator, input_count: usize) - total.saturating_add(total / 5) } +/// Calculate the estimated fee for a Platform address withdrawal using a constructed state transition. +fn estimate_withdrawal_fee_from_transition( + platform_version: &dash_sdk::dpp::version::PlatformVersion, + inputs: &BTreeMap, + output_script: &CoreScript, +) -> u64 { + let inputs_with_nonce: BTreeMap = inputs + .iter() + .map(|(addr, amount)| (*addr, (0, *amount))) + .collect(); + + let transition = AddressCreditWithdrawalTransition::V0(AddressCreditWithdrawalTransitionV0 { + inputs: inputs_with_nonce, + output: None, + fee_strategy: vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + core_fee_per_byte: 1, + pooling: Pooling::Never, + output_script: output_script.clone(), + user_fee_increase: 0, + input_witnesses: Vec::new(), + }); + + transition + .calculate_min_required_fee(platform_version) + .unwrap_or(0) +} + +/// Calculate the estimated fee for funding a Platform address from an asset lock. +fn estimate_address_funding_fee_from_transition( + platform_version: &dash_sdk::dpp::version::PlatformVersion, + destination: &PlatformAddress, +) -> u64 { + let mut outputs = BTreeMap::new(); + outputs.insert(*destination, None); + + let transition = + AddressFundingFromAssetLockTransition::V0(AddressFundingFromAssetLockTransitionV0 { + asset_lock_proof: AssetLockProof::default(), + inputs: BTreeMap::new(), + outputs, + fee_strategy: vec![AddressFundsFeeStrategyStep::ReduceOutput(0)], + user_fee_increase: 0, + ..Default::default() + }); + + transition + .calculate_min_required_fee(platform_version) + .unwrap_or(0) +} + /// Result of allocating platform addresses for a transfer. #[derive(Debug, Clone)] struct AddressAllocationResult { @@ -71,23 +130,16 @@ struct AddressAllocationResult { sorted_addresses: Vec<(PlatformAddress, Address, u64)>, } -/// Allocates platform addresses for a transfer, selecting which addresses to use -/// and how much from each. -/// -/// Algorithm: -/// 1. Filters out the destination address (can't be both input and output) -/// 2. Sorts addresses by balance descending (largest first) -/// 3. The highest-balance address pays the fee -/// 4. Iteratively allocates until fee estimate converges -/// 5. Fee payer is always included in inputs (even with 0 contribution) so fee can be deducted -/// -/// Returns the allocation result with inputs, fee payer index, and any shortfall. -fn allocate_platform_addresses( - estimator: &PlatformFeeEstimator, +/// Allocates platform addresses for a transfer, using a custom fee calculator. +fn allocate_platform_addresses_with_fee( addresses: &[(PlatformAddress, Address, u64)], amount_credits: u64, destination: Option<&PlatformAddress>, -) -> AddressAllocationResult { + fee_for_inputs: F, +) -> AddressAllocationResult +where + F: Fn(&BTreeMap) -> u64, +{ // Filter out the destination address if provided (protocol doesn't allow same address as input and output) let filtered: Vec<_> = addresses .iter() @@ -104,7 +156,7 @@ fn allocate_platform_addresses( return AddressAllocationResult { inputs: BTreeMap::new(), fee_payer_index: 0, - estimated_fee: estimate_platform_fee(estimator, 1), + estimated_fee: fee_for_inputs(&BTreeMap::new()), shortfall: amount_credits, sorted_addresses: vec![], }; @@ -113,33 +165,36 @@ fn allocate_platform_addresses( // The highest-balance address (first in sorted order) will pay the fee let fee_payer_addr = sorted_addresses.first().map(|(addr, _, _)| *addr); - // Calculate fee based on expected number of inputs (use worst-case for safety) - // This matches what the Max button calculation uses - let max_inputs = sorted_addresses.len().min(MAX_PLATFORM_INPUTS); - let estimated_fee = estimate_platform_fee(estimator, max_inputs.max(1)); + let mut estimated_fee = fee_for_inputs(&BTreeMap::new()); + let mut inputs: BTreeMap = BTreeMap::new(); - // Allocate inputs = outputs (protocol requires equality). - // Fee payer must reserve enough remaining balance to pay the fee separately. - let mut inputs = BTreeMap::new(); - let mut remaining = amount_credits; + // Iterate until fee estimate stabilizes (input count affects fee) + for _ in 0..=MAX_PLATFORM_INPUTS { + inputs.clear(); + let mut remaining = amount_credits; - for (idx, (platform_addr, _, balance)) in sorted_addresses.iter().enumerate() { - if remaining == 0 || inputs.len() >= MAX_PLATFORM_INPUTS { - break; + for (idx, (platform_addr, _, balance)) in sorted_addresses.iter().enumerate() { + if remaining == 0 || inputs.len() >= MAX_PLATFORM_INPUTS { + break; + } + let is_fee_payer = idx == 0; + let available = if is_fee_payer { + balance.saturating_sub(estimated_fee) + } else { + *balance + }; + let use_amount = remaining.min(available); + if use_amount > 0 || is_fee_payer { + inputs.insert(*platform_addr, use_amount); + remaining = remaining.saturating_sub(use_amount); + } } - // Fee payer (idx 0, highest balance) must keep fee in reserve - let is_fee_payer = idx == 0; - let available = if is_fee_payer { - balance.saturating_sub(estimated_fee) - } else { - *balance - }; - let use_amount = remaining.min(available); - // Fee payer must always be in inputs so fee can be deducted from their balance - if use_amount > 0 || is_fee_payer { - inputs.insert(*platform_addr, use_amount); - remaining = remaining.saturating_sub(use_amount); + + let new_fee = fee_for_inputs(&inputs); + if new_fee == estimated_fee { + break; } + estimated_fee = new_fee; } // Calculate shortfall (amount we couldn't allocate) @@ -147,15 +202,13 @@ fn allocate_platform_addresses( let allocation_shortfall = amount_credits.saturating_sub(total_allocated); // Check if fee payer can actually afford the fee from their remaining balance. - // Fee payer's remaining balance = their original balance - their contribution. - // If remaining < estimated_fee, we have a fee deficit. let fee_deficit = if let Some(fee_payer) = fee_payer_addr { let fee_payer_balance = sorted_addresses.first().map(|(_, _, b)| *b).unwrap_or(0); let fee_payer_contribution = inputs.get(&fee_payer).copied().unwrap_or(0); let fee_payer_remaining = fee_payer_balance.saturating_sub(fee_payer_contribution); estimated_fee.saturating_sub(fee_payer_remaining) } else { - estimated_fee // No fee payer means we can't pay the fee at all + estimated_fee }; let shortfall = allocation_shortfall.saturating_add(fee_deficit); @@ -180,6 +233,35 @@ fn allocate_platform_addresses( } } +/// Allocates platform addresses for a transfer, selecting which addresses to use +/// and how much from each. +/// +/// Algorithm: +/// 1. Filters out the destination address (can't be both input and output) +/// 2. Sorts addresses by balance descending (largest first) +/// 3. The highest-balance address pays the fee +/// 4. Iteratively allocates until fee estimate converges +/// 5. Fee payer is always included in inputs (even with 0 contribution) so fee can be deducted +/// +/// Returns the allocation result with inputs, fee payer index, and any shortfall. +fn allocate_platform_addresses( + estimator: &PlatformFeeEstimator, + addresses: &[(PlatformAddress, Address, u64)], + amount_credits: u64, + destination: Option<&PlatformAddress>, +) -> AddressAllocationResult { + let max_inputs = addresses + .iter() + .filter(|(platform_addr, _, _)| destination != Some(platform_addr)) + .count() + .min(MAX_PLATFORM_INPUTS); + + allocate_platform_addresses_with_fee(addresses, amount_credits, destination, |_| { + // Keep the legacy behavior: use a worst-case fee based on max possible inputs. + estimate_platform_fee(estimator, max_inputs.max(1)) + }) +} + /// Detected address type #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AddressType { @@ -340,6 +422,50 @@ impl WalletSendScreen { } } + fn estimate_max_fee_for_platform_send( + &self, + fee_estimator: &PlatformFeeEstimator, + addresses: &[(PlatformAddress, Address, u64)], + destination: Option<&PlatformAddress>, + ) -> u64 { + let mut sorted_addresses: Vec<_> = addresses + .iter() + .filter(|(addr, _, _)| destination != Some(addr)) + .cloned() + .collect(); + sorted_addresses.sort_by(|a, b| b.2.cmp(&a.2)); + + let usable_count = sorted_addresses.len().min(MAX_PLATFORM_INPUTS); + if usable_count == 0 { + return estimate_platform_fee(fee_estimator, 1); + } + + let dest_type = self.detect_address_type(&self.destination_address); + if dest_type == AddressType::Core { + let output_script = self + .destination_address + .trim() + .parse::>() + .ok() + .and_then(|addr| addr.require_network(self.app_context.network).ok()) + .map(|addr| CoreScript::new(addr.script_pubkey())); + if let Some(output_script) = output_script { + let max_fee_inputs: BTreeMap = sorted_addresses + .iter() + .take(usable_count) + .map(|(addr, _, _)| (*addr, 0)) + .collect(); + return estimate_withdrawal_fee_from_transition( + self.app_context.platform_version(), + &max_fee_inputs, + &output_script, + ); + } + } + + estimate_platform_fee(fee_estimator, usable_count) + } + fn reset_form(&mut self) { self.destination_address.clear(); self.amount = None; @@ -635,8 +761,14 @@ impl WalletSendScreen { return Err("Amount must be greater than 0".to_string()); } - // Check balance (include fee for asset lock) - let required = amount_duffs.saturating_add(3000); + // Parse platform address + let address_str = self.destination_address.trim(); + let destination = PlatformAddress::from_bech32m_string(address_str) + .map(|(addr, _)| addr) + .map_err(|e| format!("Invalid platform address: {}", e))?; + + // Check balance; fees will be subtracted from amount + let required = amount_duffs; let balance = self.get_core_balance(); if required > balance { return Err(format!( @@ -646,12 +778,6 @@ impl WalletSendScreen { )); } - // Parse platform address - let address_str = self.destination_address.trim(); - let destination = PlatformAddress::from_bech32m_string(address_str) - .map(|(addr, _)| addr) - .map_err(|e| format!("Invalid platform address: {}", e))?; - let now = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Time went backwards") @@ -745,7 +871,11 @@ impl WalletSendScreen { .take(MAX_PLATFORM_INPUTS) .map(|(_, _, b)| *b) .sum(); - let max_fee = estimate_platform_fee(&fee_estimator, addresses_available); + let max_fee = self.estimate_max_fee_for_platform_send( + &fee_estimator, + &allocation.sorted_addresses, + Some(&destination), + ); let max_sendable = max_balance.saturating_sub(max_fee); return Err(format!( @@ -813,9 +943,6 @@ impl WalletSendScreen { return Err("Amount must be greater than 0".to_string()); } - // Get fee estimator with current network multiplier - let fee_estimator = self.app_context.fee_estimator(); - // Calculate total balance across all platform addresses let total_balance: u64 = addresses.iter().map(|(_, _, balance)| *balance).sum(); @@ -845,9 +972,13 @@ impl WalletSendScreen { let output_script = CoreScript::new(dest_address.script_pubkey()); - // Allocate addresses using the helper function (no destination filter for withdrawals) + let platform_version = self.app_context.platform_version(); + + // Allocate addresses using state-transition-based fee estimation (no destination filter) let allocation = - allocate_platform_addresses(&fee_estimator, &addresses, amount_credits, None); + allocate_platform_addresses_with_fee(&addresses, amount_credits, None, |inputs| { + estimate_withdrawal_fee_from_transition(platform_version, inputs, &output_script) + }); if allocation.shortfall > 0 { // Calculate the max we can send with MAX_PLATFORM_INPUTS addresses (minus fees) @@ -858,7 +989,17 @@ impl WalletSendScreen { .take(MAX_PLATFORM_INPUTS) .map(|(_, _, b)| *b) .sum(); - let max_fee = estimate_platform_fee(&fee_estimator, addresses_available); + let max_fee_inputs: BTreeMap = allocation + .sorted_addresses + .iter() + .take(addresses_available) + .map(|(addr, _, _)| (*addr, 0)) + .collect(); + let max_fee = estimate_withdrawal_fee_from_transition( + platform_version, + &max_fee_inputs, + &output_script, + ); let max_sendable = max_balance.saturating_sub(max_fee); return Err(format!( @@ -1189,8 +1330,29 @@ impl WalletSendScreen { .ok() .map(|wallet| wallet.total_balance_duffs() * CREDITS_PER_DUFF) // duffs to credits }); - - (max, None) + let dest_type = self.detect_address_type(&self.destination_address); + let hint = if dest_type == AddressType::Platform { + let destination = + PlatformAddress::from_bech32m_string(self.destination_address.trim()) + .map(|(addr, _)| addr) + .ok(); + if let Some(destination) = destination { + let estimated_fee = estimate_address_funding_fee_from_transition( + self.app_context.platform_version(), + &destination, + ); + // max = max.map(|amount| amount.saturating_sub(estimated_fee)); + Some(format!( + "Estimated platform fee ~{} (deducted from amount)", + Self::format_credits(estimated_fee) + )) + } else { + None + } + } else { + None + }; + (max, hint) } Some(SourceSelection::PlatformAddresses(addresses)) => { // Parse destination to exclude it from max calculation (can't send to yourself) @@ -1208,13 +1370,16 @@ impl WalletSendScreen { sorted_addresses.sort_by(|a, b| b.2.cmp(&a.2)); // Sum balances from top addresses, limited by MAX_PLATFORM_INPUTS. - let usable_count = sorted_addresses.len().min(MAX_PLATFORM_INPUTS); let total: u64 = sorted_addresses .iter() .take(MAX_PLATFORM_INPUTS) .map(|(_, _, balance)| *balance) .sum(); - let max_fee = estimate_platform_fee(&fee_estimator, usable_count); + let max_fee = self.estimate_max_fee_for_platform_send( + &fee_estimator, + &sorted_addresses, + destination.as_ref(), + ); // Build hint explaining the limit let hint = if sorted_addresses.len() > MAX_PLATFORM_INPUTS { From 29d40ea3b592c33c12e64e70cf2a5ea2fe2657e1 Mon Sep 17 00:00:00 2001 From: PastaPastaPasta <6443210+PastaPastaPasta@users.noreply.github.com> Date: Thu, 5 Feb 2026 12:52:41 -0600 Subject: [PATCH 076/106] Refactor token creator helpers (#518) * refactor(tokens): simplify token creator helpers * style: cargo fmt --- src/ui/tokens/tokens_screen/mod.rs | 173 ++++++ src/ui/tokens/tokens_screen/token_creator.rs | 539 ++++++++----------- 2 files changed, 402 insertions(+), 310 deletions(-) diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 123ff9355..c9f1fa25b 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -941,6 +941,179 @@ impl std::fmt::Display for TokenNameLanguage { } } +impl TokenNameLanguage { + pub fn iso_code(self) -> &'static str { + match self { + TokenNameLanguage::English => "en", + TokenNameLanguage::Arabic => "ar", + TokenNameLanguage::Bengali => "bn", + TokenNameLanguage::Burmese => "my", + TokenNameLanguage::Chinese => "zh", + TokenNameLanguage::Czech => "cs", + TokenNameLanguage::Dutch => "nl", + TokenNameLanguage::Farsi => "fa", + TokenNameLanguage::Filipino => "fil", + TokenNameLanguage::French => "fr", + TokenNameLanguage::German => "de", + TokenNameLanguage::Greek => "el", + TokenNameLanguage::Gujarati => "gu", + TokenNameLanguage::Hausa => "ha", + TokenNameLanguage::Hebrew => "he", + TokenNameLanguage::Hindi => "hi", + TokenNameLanguage::Hungarian => "hu", + TokenNameLanguage::Igbo => "ig", + TokenNameLanguage::Indonesian => "id", + TokenNameLanguage::Italian => "it", + TokenNameLanguage::Japanese => "ja", + TokenNameLanguage::Javanese => "jv", + TokenNameLanguage::Kannada => "kn", + TokenNameLanguage::Khmer => "km", + TokenNameLanguage::Korean => "ko", + TokenNameLanguage::Malay => "ms", + TokenNameLanguage::Malayalam => "ml", + TokenNameLanguage::Mandarin => "zh", + TokenNameLanguage::Marathi => "mr", + TokenNameLanguage::Nepali => "ne", + TokenNameLanguage::Oriya => "or", + TokenNameLanguage::Pashto => "ps", + TokenNameLanguage::Polish => "pl", + TokenNameLanguage::Portuguese => "pt", + TokenNameLanguage::Punjabi => "pa", + TokenNameLanguage::Romanian => "ro", + TokenNameLanguage::Russian => "ru", + TokenNameLanguage::Serbian => "sr", + TokenNameLanguage::Sindhi => "sd", + TokenNameLanguage::Sinhala => "si", + TokenNameLanguage::Somali => "so", + TokenNameLanguage::Spanish => "es", + TokenNameLanguage::Swahili => "sw", + TokenNameLanguage::Swedish => "sv", + TokenNameLanguage::Tamil => "ta", + TokenNameLanguage::Telugu => "te", + TokenNameLanguage::Thai => "th", + TokenNameLanguage::Turkish => "tr", + TokenNameLanguage::Ukrainian => "uk", + TokenNameLanguage::Urdu => "ur", + TokenNameLanguage::Vietnamese => "vi", + TokenNameLanguage::Yoruba => "yo", + } + } + + pub fn ui_label(self) -> &'static str { + match self { + TokenNameLanguage::English => "English", + TokenNameLanguage::Arabic => "Arabic", + TokenNameLanguage::Bengali => "Bengali", + TokenNameLanguage::Burmese => "Burmese", + TokenNameLanguage::Chinese => "Chinese", + TokenNameLanguage::Czech => "Czech", + TokenNameLanguage::Dutch => "Dutch", + TokenNameLanguage::Farsi => "Farsi (Persian)", + TokenNameLanguage::Filipino => "Filipino (Tagalog)", + TokenNameLanguage::French => "French", + TokenNameLanguage::German => "German", + TokenNameLanguage::Greek => "Greek", + TokenNameLanguage::Gujarati => "Gujarati", + TokenNameLanguage::Hausa => "Hausa", + TokenNameLanguage::Hebrew => "Hebrew", + TokenNameLanguage::Hindi => "Hindi", + TokenNameLanguage::Hungarian => "Hungarian", + TokenNameLanguage::Igbo => "Igbo", + TokenNameLanguage::Indonesian => "Indonesian", + TokenNameLanguage::Italian => "Italian", + TokenNameLanguage::Japanese => "Japanese", + TokenNameLanguage::Javanese => "Javanese", + TokenNameLanguage::Kannada => "Kannada", + TokenNameLanguage::Khmer => "Khmer", + TokenNameLanguage::Korean => "Korean", + TokenNameLanguage::Malay => "Malay", + TokenNameLanguage::Malayalam => "Malayalam", + TokenNameLanguage::Mandarin => "Mandarin Chinese", + TokenNameLanguage::Marathi => "Marathi", + TokenNameLanguage::Nepali => "Nepali", + TokenNameLanguage::Oriya => "Oriya", + TokenNameLanguage::Pashto => "Pashto", + TokenNameLanguage::Polish => "Polish", + TokenNameLanguage::Portuguese => "Portuguese", + TokenNameLanguage::Punjabi => "Punjabi", + TokenNameLanguage::Romanian => "Romanian", + TokenNameLanguage::Russian => "Russian", + TokenNameLanguage::Serbian => "Serbian", + TokenNameLanguage::Sindhi => "Sindhi", + TokenNameLanguage::Sinhala => "Sinhala", + TokenNameLanguage::Somali => "Somali", + TokenNameLanguage::Spanish => "Spanish", + TokenNameLanguage::Swahili => "Swahili", + TokenNameLanguage::Swedish => "Swedish", + TokenNameLanguage::Tamil => "Tamil", + TokenNameLanguage::Telugu => "Telugu", + TokenNameLanguage::Thai => "Thai", + TokenNameLanguage::Turkish => "Turkish", + TokenNameLanguage::Ukrainian => "Ukrainian", + TokenNameLanguage::Urdu => "Urdu", + TokenNameLanguage::Vietnamese => "Vietnamese", + TokenNameLanguage::Yoruba => "Yoruba", + } + } + + pub fn selection_order() -> &'static [TokenNameLanguage] { + &[ + TokenNameLanguage::English, + TokenNameLanguage::Arabic, + TokenNameLanguage::Bengali, + TokenNameLanguage::Burmese, + TokenNameLanguage::Chinese, + TokenNameLanguage::Czech, + TokenNameLanguage::Dutch, + TokenNameLanguage::Farsi, + TokenNameLanguage::Filipino, + TokenNameLanguage::French, + TokenNameLanguage::German, + TokenNameLanguage::Greek, + TokenNameLanguage::Gujarati, + TokenNameLanguage::Hausa, + TokenNameLanguage::Hebrew, + TokenNameLanguage::Hindi, + TokenNameLanguage::Hungarian, + TokenNameLanguage::Igbo, + TokenNameLanguage::Indonesian, + TokenNameLanguage::Italian, + TokenNameLanguage::Japanese, + TokenNameLanguage::Javanese, + TokenNameLanguage::Kannada, + TokenNameLanguage::Khmer, + TokenNameLanguage::Korean, + TokenNameLanguage::Malay, + TokenNameLanguage::Malayalam, + TokenNameLanguage::Mandarin, + TokenNameLanguage::Marathi, + TokenNameLanguage::Nepali, + TokenNameLanguage::Oriya, + TokenNameLanguage::Pashto, + TokenNameLanguage::Polish, + TokenNameLanguage::Portuguese, + TokenNameLanguage::Punjabi, + TokenNameLanguage::Romanian, + TokenNameLanguage::Russian, + TokenNameLanguage::Serbian, + TokenNameLanguage::Sindhi, + TokenNameLanguage::Sinhala, + TokenNameLanguage::Somali, + TokenNameLanguage::Spanish, + TokenNameLanguage::Swahili, + TokenNameLanguage::Swedish, + TokenNameLanguage::Tamil, + TokenNameLanguage::Telugu, + TokenNameLanguage::Thai, + TokenNameLanguage::Turkish, + TokenNameLanguage::Ukrainian, + TokenNameLanguage::Urdu, + TokenNameLanguage::Vietnamese, + TokenNameLanguage::Yoruba, + ] + } +} + #[derive(Clone, Debug)] /// All arguments needed by `build_data_contract_v1_with_one_token`. pub struct TokenBuildArgs { diff --git a/src/ui/tokens/tokens_screen/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index 9c0f5a68b..3b4dc2fc8 100644 --- a/src/ui/tokens/tokens_screen/token_creator.rs +++ b/src/ui/tokens/tokens_screen/token_creator.rs @@ -202,40 +202,14 @@ impl TokensScreen { } // Set wallet reference for the auto-selected key - if let (Some(qid), Some(key)) = (&self.selected_identity, &self.selected_key) { - self.selected_wallet = crate::ui::identities::get_selected_wallet( - qid, - None, - Some(key), - &mut self.token_creator_error_message, - ); - } + self.update_selected_wallet(); ui.add_space(10.0); ui.separator(); // Wallet unlock check for simple mode - if let Some(wallet) = &self.selected_wallet { - use crate::ui::components::wallet_unlock_popup::{ - wallet_needs_unlock, try_open_wallet_no_password, - }; - - if let Err(e) = try_open_wallet_no_password(wallet) { - self.token_creator_error_message = Some(e); - } - - if wallet_needs_unlock(wallet) { - ui.add_space(10.0); - ui.colored_label( - egui::Color32::from_rgb(200, 150, 50), - "Wallet is locked. Please unlock to continue.", - ); - ui.add_space(8.0); - if ui.button("Unlock Wallet").clicked() { - self.wallet_unlock_popup.open(); - } - return; - } + if !self.ensure_wallet_unlocked(ui) { + return; } } else { // ===================================================== @@ -257,14 +231,7 @@ impl TokensScreen { ui.add_space(5.0); // If a key was selected, set the wallet reference - if let (Some(qid), Some(key)) = (&self.selected_identity, &self.selected_key) { - self.selected_wallet = crate::ui::identities::get_selected_wallet( - qid, - None, - Some(key), - &mut self.token_creator_error_message, - ); - } + self.update_selected_wallet(); if self.selected_key.is_none() { return; @@ -274,27 +241,8 @@ impl TokensScreen { ui.separator(); // Wallet unlock check for advanced mode - if let Some(wallet) = &self.selected_wallet { - use crate::ui::components::wallet_unlock_popup::{ - wallet_needs_unlock, try_open_wallet_no_password, - }; - - if let Err(e) = try_open_wallet_no_password(wallet) { - self.token_creator_error_message = Some(e); - } - - if wallet_needs_unlock(wallet) { - ui.add_space(10.0); - ui.colored_label( - egui::Color32::from_rgb(200, 150, 50), - "Wallet is locked. Please unlock to continue.", - ); - ui.add_space(8.0); - if ui.button("Unlock Wallet").clicked() { - self.wallet_unlock_popup.open(); - } - return; - } + if !self.ensure_wallet_unlocked(ui) { + return; } } @@ -524,92 +472,16 @@ impl TokensScreen { ui.label("Token Name (singular)*:"); ui.text_edit_singleline(&mut self.token_names_input[i].0); ui.horizontal(|ui| { - if i == 0 { - ui.push_id(format!("combo_{}", i), |ui| { - ui.style_mut().spacing.combo_height = 10.0; - ui.style_mut().spacing.button_padding = egui::vec2(3.0, 0.0); - ui.style_mut().visuals.widgets.inactive.fg_stroke.width = 1.0; - ui.style_mut().text_styles.get_mut(&egui::TextStyle::Body).unwrap().size = 12.0; - let combo_resp = ComboBox::from_id_salt(format!("token_name_language_selector_{}", i)) - .selected_text(format!( - "{}", - self.token_names_input[i].2 - )) - .width(100.0); - combo_resp.show_ui(ui, |ui| { - ui.style_mut().text_styles.get_mut(&egui::TextStyle::Body).unwrap().size = 12.0; - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::English, "English"); - }); - }); - } else { - ui.push_id(format!("combo_{}", i), |ui| { - ui.style_mut().spacing.combo_height = 10.0; - ui.style_mut().spacing.button_padding = egui::vec2(3.0, 0.0); - ui.style_mut().visuals.widgets.inactive.fg_stroke.width = 1.0; - ui.style_mut().text_styles.get_mut(&egui::TextStyle::Body).unwrap().size = 12.0; - let combo_resp = ComboBox::from_id_salt(format!("token_name_language_selector_{}", i)) - .selected_text(format!( - "{}", - self.token_names_input[i].2 - )) - .width(100.0); - combo_resp.show_ui(ui, |ui| { - ui.style_mut().text_styles.get_mut(&egui::TextStyle::Body).unwrap().size = 12.0; - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::English, "English"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Arabic, "Arabic"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Bengali, "Bengali"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Burmese, "Burmese"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Chinese, "Chinese"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Czech, "Czech"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Dutch, "Dutch"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Farsi, "Farsi (Persian)"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Filipino, "Filipino (Tagalog)"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::French, "French"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::German, "German"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Greek, "Greek"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Gujarati, "Gujarati"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Hausa, "Hausa"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Hebrew, "Hebrew"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Hindi, "Hindi"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Hungarian, "Hungarian"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Igbo, "Igbo"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Indonesian, "Indonesian"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Italian, "Italian"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Japanese, "Japanese"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Javanese, "Javanese"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Kannada, "Kannada"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Khmer, "Khmer"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Korean, "Korean"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Malay, "Malay"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Malayalam, "Malayalam"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Mandarin, "Mandarin Chinese"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Marathi, "Marathi"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Nepali, "Nepali"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Oriya, "Oriya"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Pashto, "Pashto"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Polish, "Polish"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Portuguese, "Portuguese"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Punjabi, "Punjabi"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Romanian, "Romanian"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Russian, "Russian"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Serbian, "Serbian"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Sindhi, "Sindhi"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Sinhala, "Sinhala"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Somali, "Somali"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Spanish, "Spanish"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Swahili, "Swahili"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Swedish, "Swedish"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Tamil, "Tamil"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Telugu, "Telugu"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Thai, "Thai"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Turkish, "Turkish"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Ukrainian, "Ukrainian"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Urdu, "Urdu"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Vietnamese, "Vietnamese"); - ui.selectable_value(&mut self.token_names_input[i].2, TokenNameLanguage::Yoruba, "Yoruba"); - }); - }); - } + let allow_all_languages = i != 0; + ui.push_id(format!("combo_{}", i), |ui| { + let combo_id = format!("token_name_language_selector_{}", i); + Self::render_token_name_language_selector( + ui, + &mut self.token_names_input[i].2, + allow_all_languages, + &combo_id, + ); + }); if ui.add(egui::Button::new("➕ Add Language").small()).clicked() { let used_languages: HashSet<_> = self.token_names_input.iter().map(|(_, _, lang, _)| *lang).collect(); @@ -1068,28 +940,7 @@ impl TokensScreen { // Reset the expanded states after processing if self.should_reset_collapsing_states { - self.token_creator_advanced_expanded = false; - self.token_creator_action_rules_expanded = false; - self.token_creator_main_control_expanded = false; - self.token_creator_distribution_expanded = false; - self.token_creator_groups_expanded = false; - self.token_creator_document_schemas_expanded = false; - // Individual action rules - self.token_creator_manual_mint_expanded = false; - self.token_creator_manual_burn_expanded = false; - self.token_creator_freeze_expanded = false; - self.token_creator_unfreeze_expanded = false; - self.token_creator_destroy_frozen_expanded = false; - self.token_creator_emergency_action_expanded = false; - self.token_creator_max_supply_change_expanded = false; - self.token_creator_conventions_change_expanded = false; - self.token_creator_marketplace_expanded = false; - self.token_creator_direct_purchase_pricing_expanded = false; - // Nested rules - self.token_creator_new_tokens_destination_expanded = false; - self.token_creator_minting_allow_choosing_expanded = false; - self.token_creator_perpetual_distribution_rules_expanded = false; - self.should_reset_collapsing_states = false; + self.reset_token_creator_collapsing_states(); } } // Close advanced mode else block @@ -1150,6 +1001,102 @@ impl TokensScreen { action } + fn update_selected_wallet(&mut self) { + if let (Some(qid), Some(key)) = (&self.selected_identity, &self.selected_key) { + self.selected_wallet = crate::ui::identities::get_selected_wallet( + qid, + None, + Some(key), + &mut self.token_creator_error_message, + ); + } + } + + fn ensure_wallet_unlocked(&mut self, ui: &mut Ui) -> bool { + if let Some(wallet) = &self.selected_wallet { + use crate::ui::components::wallet_unlock_popup::{ + try_open_wallet_no_password, wallet_needs_unlock, + }; + + if let Err(e) = try_open_wallet_no_password(wallet) { + self.token_creator_error_message = Some(e); + } + + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + return false; + } + } + + true + } + + fn render_token_name_language_selector( + ui: &mut Ui, + current_language: &mut TokenNameLanguage, + allow_all_languages: bool, + id_salt: &str, + ) { + ui.style_mut().spacing.combo_height = 10.0; + ui.style_mut().spacing.button_padding = egui::vec2(3.0, 0.0); + ui.style_mut().visuals.widgets.inactive.fg_stroke.width = 1.0; + ui.style_mut() + .text_styles + .get_mut(&egui::TextStyle::Body) + .unwrap() + .size = 12.0; + + ComboBox::from_id_salt(id_salt) + .selected_text(format!("{}", current_language)) + .width(100.0) + .show_ui(ui, |ui| { + ui.style_mut() + .text_styles + .get_mut(&egui::TextStyle::Body) + .unwrap() + .size = 12.0; + for &language in TokenNameLanguage::selection_order() { + if !allow_all_languages && language != TokenNameLanguage::English { + continue; + } + ui.selectable_value(current_language, language, language.ui_label()); + } + }); + } + + fn reset_token_creator_collapsing_states(&mut self) { + self.token_creator_advanced_expanded = false; + self.token_creator_action_rules_expanded = false; + self.token_creator_main_control_expanded = false; + self.token_creator_distribution_expanded = false; + self.token_creator_groups_expanded = false; + self.token_creator_document_schemas_expanded = false; + // Individual action rules + self.token_creator_manual_mint_expanded = false; + self.token_creator_manual_burn_expanded = false; + self.token_creator_freeze_expanded = false; + self.token_creator_unfreeze_expanded = false; + self.token_creator_destroy_frozen_expanded = false; + self.token_creator_emergency_action_expanded = false; + self.token_creator_max_supply_change_expanded = false; + self.token_creator_conventions_change_expanded = false; + self.token_creator_marketplace_expanded = false; + self.token_creator_direct_purchase_pricing_expanded = false; + // Nested rules + self.token_creator_new_tokens_destination_expanded = false; + self.token_creator_minting_allow_choosing_expanded = false; + self.token_creator_perpetual_distribution_rules_expanded = false; + self.should_reset_collapsing_states = false; + } + /// Gathers user input and produces the arguments needed by /// `build_data_contract_v1_with_one_token`. /// Returns Err(error_msg) on invalid input. @@ -1162,156 +1109,25 @@ impl TokensScreen { let identity_id = identity.identity.id(); // Remove whitespace and parse the comma separated string into a vec - let mut contract_keywords = if self.contract_keywords_input.trim().is_empty() { - Vec::new() - } else { - self.contract_keywords_input - .split(',') - .map(|s| { - let trimmed = s.trim().to_string(); - if trimmed.len() < 3 || trimmed.len() > 50 { - Err(format!("Invalid contract keyword {}, keyword must be between 3 and 50 characters", trimmed)) - } else { - Ok(trimmed) - } - }) - .collect::, String>>()? - }; + let mut contract_keywords = self.parse_contract_keywords()?; // 2) Basic fields - if self.token_names_input.is_empty() { - return Err("Please enter a token name".to_string()); - } - // If any name languages are duplicated, return an error - let mut seen_languages = HashSet::new(); - for name_with_language in self.token_names_input.iter() { - if seen_languages.contains(&name_with_language.2) { - return Err(format!( - "Duplicate token name language: {:?}", - name_with_language.1 - )); - } - seen_languages.insert(name_with_language.2); - } - let mut token_names: Vec<(String, String, String)> = Vec::new(); - for name_with_language in self.token_names_input.iter() { - let language = match name_with_language.2 { - TokenNameLanguage::English => "en", - TokenNameLanguage::Arabic => "ar", - TokenNameLanguage::Bengali => "bn", - TokenNameLanguage::Burmese => "my", - TokenNameLanguage::Chinese => "zh", - TokenNameLanguage::Czech => "cs", - TokenNameLanguage::Dutch => "nl", - TokenNameLanguage::Farsi => "fa", - TokenNameLanguage::Filipino => "fil", - TokenNameLanguage::French => "fr", - TokenNameLanguage::German => "de", - TokenNameLanguage::Greek => "el", - TokenNameLanguage::Gujarati => "gu", - TokenNameLanguage::Hausa => "ha", - TokenNameLanguage::Hebrew => "he", - TokenNameLanguage::Hindi => "hi", - TokenNameLanguage::Hungarian => "hu", - TokenNameLanguage::Igbo => "ig", - TokenNameLanguage::Indonesian => "id", - TokenNameLanguage::Italian => "it", - TokenNameLanguage::Japanese => "ja", - TokenNameLanguage::Javanese => "jv", - TokenNameLanguage::Kannada => "kn", - TokenNameLanguage::Khmer => "km", - TokenNameLanguage::Korean => "ko", - TokenNameLanguage::Malay => "ms", - TokenNameLanguage::Malayalam => "ml", - TokenNameLanguage::Mandarin => "zh", // synonym for Chinese - TokenNameLanguage::Marathi => "mr", - TokenNameLanguage::Nepali => "ne", - TokenNameLanguage::Oriya => "or", - TokenNameLanguage::Pashto => "ps", - TokenNameLanguage::Polish => "pl", - TokenNameLanguage::Portuguese => "pt", - TokenNameLanguage::Punjabi => "pa", - TokenNameLanguage::Romanian => "ro", - TokenNameLanguage::Russian => "ru", - TokenNameLanguage::Serbian => "sr", - TokenNameLanguage::Sindhi => "sd", - TokenNameLanguage::Sinhala => "si", - TokenNameLanguage::Somali => "so", - TokenNameLanguage::Spanish => "es", - TokenNameLanguage::Swahili => "sw", - TokenNameLanguage::Swedish => "sv", - TokenNameLanguage::Tamil => "ta", - TokenNameLanguage::Telugu => "te", - TokenNameLanguage::Thai => "th", - TokenNameLanguage::Turkish => "tr", - TokenNameLanguage::Ukrainian => "uk", - TokenNameLanguage::Urdu => "ur", - TokenNameLanguage::Vietnamese => "vi", - TokenNameLanguage::Yoruba => "yo", - }; - - if name_with_language.0.len() < 3 || name_with_language.0.len() > 50 { - return Err(format!( - "The name in {:?} must be between 3 and 50 characters", - name_with_language.2 - )); - } - - if name_with_language.1.len() < 3 || name_with_language.1.len() > 50 { - return Err(format!( - "The plural form in {:?} must be between 3 and 50 characters", - name_with_language.2 - )); - } - - token_names.push(( - name_with_language.0.clone(), - name_with_language.1.clone(), - language.to_owned(), - )); - - // are we searchable? - if name_with_language.3 { - contract_keywords.push(name_with_language.0.clone()); - } - } + let token_names = self.parse_token_names(&mut contract_keywords)?; let token_description = if !self.token_description_input.is_empty() { Some(self.token_description_input.clone()) } else { None }; - let decimals = self - .decimals_input - .parse::() - .map_err(|_| "Invalid decimal places amount".to_string())?; - let base_supply = self - .base_supply_amount - .as_ref() - .map(|amount| amount.value()) - .ok_or_else(|| "Please enter a valid base supply amount".to_string())?; - let max_supply = self - .max_supply_amount - .as_ref() - .map(|amount| { - let value = amount.value(); - if value > 0 { Some(value) } else { None } - }) - .unwrap_or(None); + let decimals = self.parse_decimals()?; + let base_supply = self.parse_base_supply()?; + let max_supply = self.parse_max_supply(); let start_paused = self.start_as_paused_input; let allow_transfers_to_frozen_identities = self.allow_transfers_to_frozen_identities; let keeps_history = self.token_advanced_keeps_history.into(); - let main_control_group = if self.main_control_group_input.is_empty() { - None - } else { - Some( - self.main_control_group_input - .parse::() - .map_err(|_| "Invalid main control group".to_string())?, - ) - }; + let main_control_group = self.parse_main_control_group()?; // 3) Convert your ActionChangeControlUI fields to real rules // (or do the manual parse for each if needed) @@ -1393,6 +1209,108 @@ impl TokensScreen { }) } + fn parse_contract_keywords(&self) -> Result, String> { + if self.contract_keywords_input.trim().is_empty() { + return Ok(Vec::new()); + } + + self.contract_keywords_input + .split(',') + .map(|s| { + let trimmed = s.trim().to_string(); + if trimmed.len() < 3 || trimmed.len() > 50 { + Err(format!( + "Invalid contract keyword {}, keyword must be between 3 and 50 characters", + trimmed + )) + } else { + Ok(trimmed) + } + }) + .collect::, String>>() + } + + fn parse_token_names( + &self, + contract_keywords: &mut Vec, + ) -> Result, String> { + if self.token_names_input.is_empty() { + return Err("Please enter a token name".to_string()); + } + + let mut seen_languages = HashSet::new(); + for name_with_language in &self.token_names_input { + if seen_languages.contains(&name_with_language.2) { + return Err(format!( + "Duplicate token name language: {:?}", + name_with_language.1 + )); + } + seen_languages.insert(name_with_language.2); + } + + let mut token_names = Vec::with_capacity(self.token_names_input.len()); + for name_with_language in &self.token_names_input { + if name_with_language.0.len() < 3 || name_with_language.0.len() > 50 { + return Err(format!( + "The name in {:?} must be between 3 and 50 characters", + name_with_language.2 + )); + } + + if name_with_language.1.len() < 3 || name_with_language.1.len() > 50 { + return Err(format!( + "The plural form in {:?} must be between 3 and 50 characters", + name_with_language.2 + )); + } + + token_names.push(( + name_with_language.0.clone(), + name_with_language.1.clone(), + name_with_language.2.iso_code().to_owned(), + )); + + // are we searchable? + if name_with_language.3 { + contract_keywords.push(name_with_language.0.clone()); + } + } + + Ok(token_names) + } + + fn parse_decimals(&self) -> Result { + self.decimals_input + .parse::() + .map_err(|_| "Invalid decimal places amount".to_string()) + } + + fn parse_base_supply(&self) -> Result { + self.base_supply_amount + .as_ref() + .map(|amount| amount.value()) + .ok_or_else(|| "Please enter a valid base supply amount".to_string()) + } + + fn parse_max_supply(&self) -> Option { + self.max_supply_amount.as_ref().and_then(|amount| { + let value = amount.value(); + if value > 0 { Some(value) } else { None } + }) + } + + fn parse_main_control_group(&self) -> Result, String> { + if self.main_control_group_input.is_empty() { + return Ok(None); + } + + self.main_control_group_input + .parse::() + .map(Some) + .map_err(|_| "Invalid main control group".to_string()) + } + /// Example of pulling out the logic to parse main_control_group_change_authorized fn parse_main_control_group_change_authorized( &mut self, @@ -1483,6 +1401,11 @@ impl TokensScreen { self.selected_token_preset = Some(preset.features); } + fn close_token_creator_confirmation_popup(&mut self) { + self.show_token_creator_confirmation_popup = false; + self.token_creator_confirmation_dialog = None; + } + /// Shows a popup "Are you sure?" for creating the token contract fn render_token_creator_confirmation_popup(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; @@ -1554,8 +1477,7 @@ impl TokensScreen { Ok(a) => a, Err(err) => { self.token_creator_error_message = Some(err); - self.show_token_creator_confirmation_popup = false; - self.token_creator_confirmation_dialog = None; + self.close_token_creator_confirmation_popup(); return AppAction::None; } } @@ -1601,15 +1523,12 @@ impl TokensScreen { ]; action = AppAction::BackendTasks(tasks, BackendTasksExecutionMode::Sequential); - self.show_token_creator_confirmation_popup = false; let now = Utc::now().timestamp() as u64; self.token_creator_status = TokenCreatorStatus::WaitingForResult(now); - self.show_token_creator_confirmation_popup = false; - self.token_creator_confirmation_dialog = None; + self.close_token_creator_confirmation_popup(); } ConfirmationStatus::Canceled => { - self.show_token_creator_confirmation_popup = false; - self.token_creator_confirmation_dialog = None; + self.close_token_creator_confirmation_popup(); action = AppAction::None; } } From 9da3a608e17f12afb5a77ce851bfbbdcece6f776 Mon Sep 17 00:00:00 2001 From: PastaPastaPasta <6443210+PastaPastaPasta@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:24:46 -0600 Subject: [PATCH 077/106] Simplify wallets UI helpers and send flow (#519) * Simplify wallets UI helpers and send flow * Fix wallets screen helper placement * Fix refresh_on_arrival borrow * Stabilize token UI tests config * Simplify wallet send address type helper --- src/ui/tokens/tokens_screen/mod.rs | 38 ++- src/ui/wallets/send_screen.rs | 370 +++++++++++-------------- src/ui/wallets/wallets_screen/mod.rs | 392 ++++++++++----------------- 3 files changed, 332 insertions(+), 468 deletions(-) diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index c9f1fa25b..96ce52304 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -3158,13 +3158,15 @@ impl ScreenLike for TokensScreen { #[cfg(test)] mod tests { use std::path::Path; + use std::sync::Once; use crate::app_dir::copy_env_file_if_not_exists; use crate::database::Database; use crate::model::qualified_identity::IdentityStatus; use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; - use super::*; use dash_sdk::dpp::dashcore::Network; + use super::*; + use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::data_contract::associated_token::token_configuration_convention::TokenConfigurationConvention; use dash_sdk::dpp::data_contract::associated_token::token_configuration_localization::accessors::v0::TokenConfigurationLocalizationV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_keeps_history_rules::TokenKeepsHistoryRules; @@ -3173,6 +3175,34 @@ mod tests { use dash_sdk::dpp::identifier::Identifier; use dash_sdk::platform::{DataContract, Identity}; + fn ensure_test_env() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + copy_env_file_if_not_exists(); // required by AppContext::new() + + // Ensure minimum required configs exist even if .env isn't loaded. + // Safety: tests set env vars once to ensure deterministic config. + // No other test mutates these values. + unsafe { + std::env::set_var("MAINNET_dapi_addresses", "http://127.0.0.1:1443"); + std::env::set_var("MAINNET_core_host", "127.0.0.1"); + std::env::set_var("MAINNET_core_rpc_port", "9998"); + std::env::set_var("MAINNET_core_rpc_user", "dashrpc"); + std::env::set_var("MAINNET_core_rpc_password", "password"); + std::env::set_var("MAINNET_insight_api_url", "http://127.0.0.1:3001"); + std::env::set_var("MAINNET_show_in_ui", "true"); + + std::env::set_var("LOCAL_dapi_addresses", "http://127.0.0.1:2443"); + std::env::set_var("LOCAL_core_host", "127.0.0.1"); + std::env::set_var("LOCAL_core_rpc_port", "20302"); + std::env::set_var("LOCAL_core_rpc_user", "dashmate"); + std::env::set_var("LOCAL_core_rpc_password", "password"); + std::env::set_var("LOCAL_insight_api_url", "http://127.0.0.1:3001"); + std::env::set_var("LOCAL_show_in_ui", "true"); + } + }); + } + impl ChangeControlRulesUI { /// Sets every field to some dummy/test value to ensure coverage in tests. pub fn set_all_fields_for_testing(&mut self) { @@ -3199,7 +3229,7 @@ mod tests { let db = Arc::new(Database::new(db_file_path).unwrap()); db.initialize(Path::new(&db_file_path)).unwrap(); - copy_env_file_if_not_exists(); // Required by AppContext::new() + ensure_test_env(); let app_context = AppContext::new(Network::Regtest, db, None, Default::default()) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); @@ -3505,7 +3535,7 @@ mod tests { let db = Arc::new(Database::new(db_file_path).unwrap()); db.initialize(Path::new(&db_file_path)).unwrap(); - copy_env_file_if_not_exists(); // required by AppContext::new() + ensure_test_env(); let app_context = AppContext::new(Network::Regtest, db, None, Default::default()) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); @@ -3625,7 +3655,7 @@ mod tests { let db = Arc::new(Database::new(db_file_path).unwrap()); db.initialize(Path::new(&db_file_path)).unwrap(); - copy_env_file_if_not_exists(); // required by AppContext::new() + ensure_test_env(); let app_context = AppContext::new(Network::Regtest, db, None, Default::default()) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 909b8dfee..622b62a2b 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -440,7 +440,7 @@ impl WalletSendScreen { return estimate_platform_fee(fee_estimator, 1); } - let dest_type = self.detect_address_type(&self.destination_address); + let dest_type = Self::detect_address_type(&self.destination_address); if dest_type == AddressType::Core { let output_script = self .destination_address @@ -482,6 +482,17 @@ impl WalletSendScreen { self.send_status = SendStatus::NotStarted; } + fn now_epoch_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs() + } + + fn mark_sending(&mut self) { + self.send_status = SendStatus::WaitingForResult(Self::now_epoch_secs()); + } + fn format_dash(amount_duffs: u64) -> String { Amount::dash_from_duffs(amount_duffs).to_string() } @@ -503,7 +514,7 @@ impl WalletSendScreen { } /// Detect address type from the address string - fn detect_address_type(&self, address: &str) -> AddressType { + fn detect_address_type(address: &str) -> AddressType { let trimmed = address.trim(); if trimmed.is_empty() { return AddressType::Unknown; @@ -634,7 +645,7 @@ impl WalletSendScreen { /// Get description of transaction type based on source and destination fn get_transaction_type_description(&self) -> &'static str { - let dest_type = self.detect_address_type(&self.destination_address); + let dest_type = Self::detect_address_type(&self.destination_address); match (&self.selected_source, dest_type) { (Some(SourceSelection::CoreWallet), AddressType::Core) => "Core Transaction", (Some(SourceSelection::CoreWallet), AddressType::Platform) => "Fund Platform Address", @@ -666,7 +677,7 @@ impl WalletSendScreen { .ok_or("Please select a source")?; // Validate destination - let dest_type = self.detect_address_type(&self.destination_address); + let dest_type = Self::detect_address_type(&self.destination_address); if dest_type == AddressType::Unknown { return Err( "Invalid destination address. Use a Dash address (X.../y...) or Platform address (evo1.../tevo1...)" @@ -732,11 +743,7 @@ impl WalletSendScreen { amount_duffs, }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.send_status = SendStatus::WaitingForResult(now); + self.mark_sending(); Ok(AppAction::BackendTask(BackendTask::CoreTask( CoreTask::SendWalletPayment { @@ -778,11 +785,7 @@ impl WalletSendScreen { )); } - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.send_status = SendStatus::WaitingForResult(now); + self.mark_sending(); Ok(AppAction::BackendTask(BackendTask::WalletTask( WalletTask::FundPlatformAddressFromWalletUtxos { @@ -911,11 +914,7 @@ impl WalletSendScreen { allocation.fee_payer_index ); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.send_status = SendStatus::WaitingForResult(now); + self.mark_sending(); Ok(AppAction::BackendTask(BackendTask::WalletTask( WalletTask::TransferPlatformCredits { @@ -1032,11 +1031,7 @@ impl WalletSendScreen { allocation.fee_payer_index ); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.send_status = SendStatus::WaitingForResult(now); + self.mark_sending(); Ok(AppAction::BackendTask(BackendTask::WalletTask( WalletTask::WithdrawFromPlatformAddress { @@ -1049,35 +1044,141 @@ impl WalletSendScreen { ))) } - fn render_unified_send(&mut self, ui: &mut Ui) -> AppAction { - let mut action = AppAction::None; - - // Wallet info - self.render_wallet_info(ui); - - // Wallet unlock if needed + fn render_unlock_gate(&mut self, ui: &mut Ui) -> bool { let wallet_is_open = self .selected_wallet .as_ref() .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); - if !wallet_is_open && let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); + if wallet_is_open { + return true; + } + + let Some(wallet) = &self.selected_wallet else { + return true; + }; + + if let Err(e) = try_open_wallet_no_password(wallet) { + self.error_message = Some(e); + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); } - if wallet_needs_unlock(wallet) { - ui.add_space(10.0); - ui.colored_label( - egui::Color32::from_rgb(200, 150, 50), - "Wallet is locked. Please unlock to continue.", - ); - ui.add_space(8.0); - if ui.button("Unlock Wallet").clicked() { - self.wallet_unlock_popup.open(); + ui.add_space(10.0); + return false; + } + + true + } + + fn format_elapsed_time(start_time: u64) -> String { + let elapsed_seconds = Self::now_epoch_secs().saturating_sub(start_time); + if elapsed_seconds < 60 { + format!( + "{} second{}", + elapsed_seconds, + if elapsed_seconds == 1 { "" } else { "s" } + ) + } else { + let minutes = elapsed_seconds / 60; + let seconds = elapsed_seconds % 60; + format!( + "{} minute{} {} second{}", + minutes, + if minutes == 1 { "" } else { "s" }, + seconds, + if seconds == 1 { "" } else { "s" } + ) + } + } + + fn render_send_status(&mut self, ui: &mut Ui, dark_mode: bool) -> Option { + match self.send_status.clone() { + SendStatus::Complete(message) => { + let mut action = AppAction::None; + ui.vertical_centered(|ui| { + ui.add_space(100.0); + ui.heading("🎉"); + ui.heading(&message); + ui.add_space(20.0); + + if ui.button("Send Another").clicked() { + self.reset_form(); + } + ui.add_space(8.0); + if ui.button("Back to Wallet").clicked() { + action = AppAction::PopScreenAndRefresh; + } + + ui.add_space(100.0); + }); + Some(action) + } + SendStatus::WaitingForResult(start_time) => { + ui.vertical_centered(|ui| { + ui.add_space(100.0); + ui.add(egui::Spinner::new().size(40.0)); + ui.add_space(20.0); + ui.heading("Sending..."); + ui.add_space(10.0); + ui.label( + RichText::new(format!( + "Time elapsed: {}", + Self::format_elapsed_time(start_time) + )) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(100.0); + }); + Some(AppAction::None) + } + SendStatus::Error(error_msg) => { + let mut dismiss = false; + ui.horizontal(|ui| { + Frame::new() + .fill(Color32::from_rgb(255, 100, 100).gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, Color32::from_rgb(255, 100, 100))) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(&error_msg) + .color(Color32::from_rgb(255, 100, 100)), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + dismiss = true; + } + }); + }); + }); + if dismiss { + self.send_status = SendStatus::NotStarted; } ui.add_space(10.0); - return AppAction::None; + None } + SendStatus::NotStarted => None, + } + } + + fn render_unified_send(&mut self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + + // Wallet info + self.render_wallet_info(ui); + + // Wallet unlock if needed + if !self.render_unlock_gate(ui) { + return AppAction::None; } ui.add_space(10.0); @@ -1258,7 +1359,7 @@ impl WalletSendScreen { fn render_destination_input(&mut self, ui: &mut Ui) { let dark_mode = ui.ctx().style().visuals.dark_mode; - let dest_type = self.detect_address_type(&self.destination_address); + let dest_type = Self::detect_address_type(&self.destination_address); ui.horizontal(|ui| { ui.label( @@ -1330,7 +1431,7 @@ impl WalletSendScreen { .ok() .map(|wallet| wallet.total_balance_duffs() * CREDITS_PER_DUFF) // duffs to credits }); - let dest_type = self.detect_address_type(&self.destination_address); + let dest_type = Self::detect_address_type(&self.destination_address); let hint = if dest_type == AddressType::Platform { let destination = PlatformAddress::from_bech32m_string(self.destination_address.trim()) @@ -1401,7 +1502,7 @@ impl WalletSendScreen { Some(SourceSelection::PlatformAddresses(_)) => AddressType::Platform, None => AddressType::Unknown, }; - let output_type = self.detect_address_type(&self.destination_address); + let output_type = Self::detect_address_type(&self.destination_address); let min_amount = self.min_output_amount(input_type, output_type); Frame::group(ui.style()) @@ -1446,7 +1547,7 @@ impl WalletSendScreen { } // Show subtract fee checkbox for Core wallet to Core address transactions - let dest_type = self.detect_address_type(&self.destination_address); + let dest_type = Self::detect_address_type(&self.destination_address); if matches!(self.selected_source, Some(SourceSelection::CoreWallet)) && dest_type == AddressType::Core { @@ -1580,7 +1681,7 @@ impl WalletSendScreen { .as_ref() .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); - let dest_type = self.detect_address_type(&self.destination_address); + let dest_type = Self::detect_address_type(&self.destination_address); let has_destination = dest_type != AddressType::Unknown; let has_amount = self.amount.as_ref().map(|a| a.value() > 0).unwrap_or(false); let has_source = self.selected_source.is_some(); @@ -1634,28 +1735,8 @@ impl WalletSendScreen { self.render_wallet_info(ui); // Wallet unlock if needed - let wallet_is_open = self - .selected_wallet - .as_ref() - .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); - - if !wallet_is_open && let Some(wallet) = &self.selected_wallet { - if let Err(e) = try_open_wallet_no_password(wallet) { - self.error_message = Some(e); - } - if wallet_needs_unlock(wallet) { - ui.add_space(10.0); - ui.colored_label( - egui::Color32::from_rgb(200, 150, 50), - "Wallet is locked. Please unlock to continue.", - ); - ui.add_space(8.0); - if ui.button("Unlock Wallet").clicked() { - self.wallet_unlock_popup.open(); - } - ui.add_space(10.0); - return AppAction::None; - } + if !self.render_unlock_gate(ui) { + return AppAction::None; } ui.add_space(10.0); @@ -1766,7 +1847,7 @@ impl WalletSendScreen { // ========== FEE STRATEGY SECTION ========== // Only show for platform source or platform outputs let has_platform_output = self.advanced_outputs.iter().any(|o| { - let addr_type = Self::detect_address_type_static(&o.address); + let addr_type = Self::detect_address_type(&o.address); addr_type == AddressType::Platform }); @@ -2078,7 +2159,7 @@ impl WalletSendScreen { let addr_types: Vec = self .advanced_outputs .iter() - .map(|o| Self::detect_address_type_static(&o.address)) + .map(|o| Self::detect_address_type(&o.address)) .collect(); for (idx, &addr_type) in addr_types.iter().enumerate() { @@ -2155,26 +2236,6 @@ impl WalletSendScreen { } } - /// Static version of detect_address_type that doesn't need self - fn detect_address_type_static(address: &str) -> AddressType { - let trimmed = address.trim(); - if trimmed.is_empty() { - return AddressType::Unknown; - } - - // Check for Platform address (Bech32m format) - if trimmed.starts_with("evo1") || trimmed.starts_with("tevo1") { - return AddressType::Platform; - } - - // Try to parse as Core address - if trimmed.parse::>().is_ok() { - return AddressType::Core; - } - - AddressType::Unknown - } - /// Render the send button for advanced mode fn render_advanced_send_button(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; @@ -2262,7 +2323,7 @@ impl WalletSendScreen { let output_types: Vec = self .advanced_outputs .iter() - .map(|o| Self::detect_address_type_static(&o.address)) + .map(|o| Self::detect_address_type(&o.address)) .collect(); let has_core_output = output_types.contains(&AddressType::Core); @@ -2356,11 +2417,7 @@ impl WalletSendScreen { )); } - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.send_status = SendStatus::WaitingForResult(now); + self.mark_sending(); Ok(AppAction::BackendTask(BackendTask::CoreTask( CoreTask::SendWalletPayment { @@ -2423,11 +2480,7 @@ impl WalletSendScreen { PlatformFeeStrategy::ReduceFirstOutput | PlatformFeeStrategy::ReduceLastOutput ); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.send_status = SendStatus::WaitingForResult(now); + self.mark_sending(); Ok(AppAction::BackendTask(BackendTask::WalletTask( WalletTask::FundPlatformAddressFromWalletUtxos { @@ -2483,11 +2536,7 @@ impl WalletSendScreen { .map(|(idx, _)| idx as u16) .unwrap_or(0); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.send_status = SendStatus::WaitingForResult(now); + self.mark_sending(); Ok(AppAction::BackendTask(BackendTask::WalletTask( WalletTask::TransferPlatformCredits { @@ -2545,11 +2594,7 @@ impl WalletSendScreen { .map(|(idx, _)| idx as u16) .unwrap_or(0); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - self.send_status = SendStatus::WaitingForResult(now); + self.mark_sending(); Ok(AppAction::BackendTask(BackendTask::WalletTask( WalletTask::WithdrawFromPlatformAddress { @@ -2582,101 +2627,8 @@ impl ScreenLike for WalletSendScreen { let mut inner_action = AppAction::None; let dark_mode = ui.ctx().style().visuals.dark_mode; - // Handle different states - clone to avoid borrow issues - let current_status = self.send_status.clone(); - match current_status { - SendStatus::Complete(message) => { - // Show custom success screen - ui.vertical_centered(|ui| { - ui.add_space(100.0); - ui.heading("🎉"); - ui.heading(&message); - ui.add_space(20.0); - - if ui.button("Send Another").clicked() { - self.reset_form(); - } - ui.add_space(8.0); - if ui.button("Back to Wallet").clicked() { - inner_action = AppAction::PopScreenAndRefresh; - } - - ui.add_space(100.0); - }); - - return inner_action; - } - SendStatus::WaitingForResult(start_time) => { - // Show sending spinner - ui.vertical_centered(|ui| { - ui.add_space(100.0); - ui.add(egui::Spinner::new().size(40.0)); - ui.add_space(20.0); - ui.heading("Sending..."); - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - let elapsed_seconds = now.saturating_sub(start_time); - - let display_time = if elapsed_seconds < 60 { - format!( - "{} second{}", - elapsed_seconds, - if elapsed_seconds == 1 { "" } else { "s" } - ) - } else { - let minutes = elapsed_seconds / 60; - let seconds = elapsed_seconds % 60; - format!( - "{} minute{} {} second{}", - minutes, - if minutes == 1 { "" } else { "s" }, - seconds, - if seconds == 1 { "" } else { "s" } - ) - }; - - ui.add_space(10.0); - ui.label( - RichText::new(format!("Time elapsed: {}", display_time)) - .color(DashColors::text_secondary(dark_mode)), - ); - ui.add_space(100.0); - }); - return inner_action; - } - SendStatus::Error(error_msg) => { - // Show error at the top - let mut dismiss = false; - ui.horizontal(|ui| { - Frame::new() - .fill(Color32::from_rgb(255, 100, 100).gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, Color32::from_rgb(255, 100, 100))) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(&error_msg) - .color(Color32::from_rgb(255, 100, 100)), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - dismiss = true; - } - }); - }); - }); - if dismiss { - self.send_status = SendStatus::NotStarted; - } - ui.add_space(10.0); - } - SendStatus::NotStarted => { - // Normal flow - continue to render the form - } + if let Some(status_action) = self.render_send_status(ui, dark_mode) { + return status_action; } egui::ScrollArea::vertical() diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 3d8373fd3..62a756543 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -322,6 +322,49 @@ impl WalletsBalancesScreen { } } + fn persist_selected_wallet_hash(&self, hash: Option) { + if let Ok(mut guard) = self.app_context.selected_wallet_hash.lock() { + *guard = hash; + } + let _ = self + .app_context + .db + .update_selected_wallet_hash(hash.as_ref()); + } + + fn persist_selected_single_key_hash(&self, hash: Option<[u8; 32]>) { + if let Ok(mut guard) = self.app_context.selected_single_key_hash.lock() { + *guard = hash; + } + let _ = self + .app_context + .db + .update_selected_single_key_hash(hash.as_ref()); + } + + fn select_hd_wallet(&mut self, wallet: Arc>) { + self.selected_wallet = Some(wallet.clone()); + self.selected_single_key_wallet = None; + self.selected_account = None; + + if let Ok(hash) = wallet.read().map(|g| g.seed_hash()) { + self.persist_selected_wallet_hash(Some(hash)); + } + self.persist_selected_single_key_hash(None); + } + + fn select_single_key_wallet(&mut self, wallet: Arc>) { + self.selected_single_key_wallet = Some(wallet.clone()); + self.selected_wallet = None; + self.selected_account = None; + self.utxo_page = 0; + + if let Ok(hash) = wallet.read().map(|g| g.key_hash) { + self.persist_selected_single_key_hash(Some(hash)); + } + self.persist_selected_wallet_hash(None); + } + pub(crate) fn update_selected_wallet_for_network(&mut self) { // Check if HD wallet selection is still valid if let Some(wallet_arc) = &self.selected_wallet { @@ -537,58 +580,12 @@ impl WalletsBalancesScreen { if ui.selectable_label(is_selected, label).clicked() { match wallet_item { WalletItem::Hd(w) => { - self.selected_wallet = Some(w.clone()); - self.selected_single_key_wallet = None; - // Persist selection to AppContext and database - if let Ok(hash) = w.read().map(|g| g.seed_hash()) - && let Ok(mut guard) = - self.app_context.selected_wallet_hash.lock() - { - *guard = Some(hash); - // Save to database for persistence across restarts - let _ = self - .app_context - .db - .update_selected_wallet_hash(Some(&hash)); - } - if let Ok(mut guard) = - self.app_context.selected_single_key_hash.lock() - { - *guard = None; - let _ = self - .app_context - .db - .update_selected_single_key_hash(None); - } + self.select_hd_wallet(w.clone()); } WalletItem::SingleKey(w) => { - self.selected_single_key_wallet = Some(w.clone()); - self.selected_wallet = None; - self.utxo_page = 0; // Reset pagination - // Persist selection to AppContext and database - if let Ok(hash) = w.read().map(|g| g.key_hash) - && let Ok(mut guard) = - self.app_context.selected_single_key_hash.lock() - { - *guard = Some(hash); - // Save to database for persistence across restarts - let _ = self - .app_context - .db - .update_selected_single_key_hash(Some(&hash)); - } - if let Ok(mut guard) = - self.app_context.selected_wallet_hash.lock() - { - *guard = None; - let _ = self - .app_context - .db - .update_selected_wallet_hash(None); - } + self.select_single_key_wallet(w.clone()); } } - self.selected_account = None; } } }); @@ -698,12 +695,7 @@ impl WalletsBalancesScreen { } self.selected_single_key_wallet = None; // Clear persisted selection in AppContext and database - if let Ok(mut guard) = - self.app_context.selected_single_key_hash.lock() - { - *guard = None; - } - let _ = self.app_context.db.update_selected_single_key_hash(None); + self.persist_selected_single_key_hash(None); self.display_message("Wallet removed", MessageType::Success); } } @@ -953,26 +945,17 @@ impl WalletsBalancesScreen { let network = self.app_context.network; for data in &address_data { body.row(25.0, |mut row| { + let is_key_only = data.account_category.is_key_only(); + let is_platform_payment = + data.account_category == AccountCategory::PlatformPayment; + row.col(|ui| { ui.label(data.display_address(network)); }); row.col(|ui| { - // These address types are used for key derivation/proofs, not holding funds - let is_key_only_address = matches!( - data.account_category, - AccountCategory::IdentityRegistration - | AccountCategory::IdentityTopup - | AccountCategory::IdentityInvitation - | AccountCategory::IdentitySystem - | AccountCategory::ProviderVoting - | AccountCategory::ProviderOwner - | AccountCategory::ProviderOperator - | AccountCategory::ProviderPlatform - ); - - if is_key_only_address { + if is_key_only { ui.label("N/A"); - } else if data.account_category == AccountCategory::PlatformPayment { + } else if is_platform_payment { // Platform credits: convert from credits to DASH // Credits are in duffs * 1000, so divide by 1000 then by 1e8 let dash_balance = @@ -985,20 +968,7 @@ impl WalletsBalancesScreen { }); row.col(|ui| { // Key-only addresses and Platform addresses don't hold UTXOs - let no_utxos = matches!( - data.account_category, - AccountCategory::IdentityRegistration - | AccountCategory::IdentityTopup - | AccountCategory::IdentityInvitation - | AccountCategory::IdentitySystem - | AccountCategory::ProviderVoting - | AccountCategory::ProviderOwner - | AccountCategory::ProviderOperator - | AccountCategory::ProviderPlatform - | AccountCategory::PlatformPayment - ); - - if no_utxos { + if is_key_only || is_platform_payment { ui.label("N/A"); } else { ui.label(format!("{}", data.utxo_count)); @@ -1006,20 +976,7 @@ impl WalletsBalancesScreen { }); row.col(|ui| { // These address types don't track historical received amounts - let no_total_received = matches!( - data.account_category, - AccountCategory::IdentityRegistration - | AccountCategory::IdentityTopup - | AccountCategory::IdentityInvitation - | AccountCategory::IdentitySystem - | AccountCategory::ProviderVoting - | AccountCategory::ProviderOwner - | AccountCategory::ProviderOperator - | AccountCategory::ProviderPlatform - | AccountCategory::PlatformPayment - ); - - if no_total_received { + if is_key_only || is_platform_payment { ui.label("N/A"); } else { let dash_received = data.total_received as f64 * 1e-8; @@ -1181,14 +1138,7 @@ impl WalletsBalancesScreen { let new_hash = next_wallet .as_ref() .and_then(|w| w.read().ok().map(|g| g.seed_hash())); - if let Ok(mut guard) = self.app_context.selected_wallet_hash.lock() { - *guard = new_hash; - } - // Persist to database - let _ = self - .app_context - .db - .update_selected_wallet_hash(new_hash.as_ref()); + self.persist_selected_wallet_hash(new_hash); self.show_rename_dialog = false; self.rename_input.clear(); @@ -1436,6 +1386,10 @@ impl WalletsBalancesScreen { // Messages no longer auto-expire, they must be dismissed manually } + fn set_message(&mut self, message: String, message_type: MessageType) { + self.message = Some((message, message_type, Utc::now())); + } + fn format_dash(amount_duffs: u64) -> String { Amount::dash_from_duffs(amount_duffs).to_string() } @@ -1825,6 +1779,38 @@ impl WalletsBalancesScreen { action } + fn draw_modal_overlay(ctx: &Context, id: &str) { + let screen_rect = ctx.screen_rect(); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new(id), + )); + painter.rect_filled( + screen_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), + ); + } + + fn modal_frame(ctx: &Context) -> Frame { + Frame { + inner_margin: egui::Margin::same(20), + outer_margin: egui::Margin::same(0), + corner_radius: egui::CornerRadius::same(8), + shadow: egui::epaint::Shadow { + offset: [0, 8], + blur: 16, + spread: 0, + color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), + }, + fill: ctx.style().visuals.window_fill, + stroke: egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), + ), + } + } + fn render_send_dialog(&mut self, ctx: &Context) -> AppAction { if !self.send_dialog.is_open { return AppAction::None; @@ -1946,16 +1932,7 @@ impl WalletsBalancesScreen { // Draw dark overlay behind the dialog (only when open) if open { - let screen_rect = ctx.screen_rect(); - let painter = ctx.layer_painter(egui::LayerId::new( - egui::Order::Background, - egui::Id::new("receive_dialog_overlay"), - )); - painter.rect_filled( - screen_rect, - 0.0, - egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), - ); + Self::draw_modal_overlay(ctx, "receive_dialog_overlay"); } egui::Window::new("Receive") @@ -1963,22 +1940,7 @@ impl WalletsBalancesScreen { .resizable(false) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .open(&mut open) - .frame(egui::Frame { - inner_margin: egui::Margin::same(20), - outer_margin: egui::Margin::same(0), - corner_radius: egui::CornerRadius::same(8), - shadow: egui::epaint::Shadow { - offset: [0, 8], - blur: 16, - spread: 0, - color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), - }, - fill: ctx.style().visuals.window_fill, - stroke: egui::Stroke::new( - 1.0, - egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), - ), - }) + .frame(Self::modal_frame(ctx)) .show(ctx, |ui| { ui.set_min_width(350.0); ui.vertical_centered(|ui| { @@ -2326,38 +2288,14 @@ impl WalletsBalancesScreen { let dark_mode = ctx.style().visuals.dark_mode; // Draw dark overlay behind the popup - let screen_rect = ctx.screen_rect(); - let painter = ctx.layer_painter(egui::LayerId::new( - egui::Order::Background, - egui::Id::new("fund_platform_dialog_overlay"), - )); - painter.rect_filled( - screen_rect, - 0.0, - egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), - ); + Self::draw_modal_overlay(ctx, "fund_platform_dialog_overlay"); egui::Window::new("Fund Platform Address from Asset Lock") .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .open(&mut open) - .frame(egui::Frame { - inner_margin: egui::Margin::same(20), - outer_margin: egui::Margin::same(0), - corner_radius: egui::CornerRadius::same(8), - shadow: egui::epaint::Shadow { - offset: [0, 8], - blur: 16, - spread: 0, - color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), - }, - fill: ctx.style().visuals.window_fill, - stroke: egui::Stroke::new( - 1.0, - egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), - ), - }) + .frame(Self::modal_frame(ctx)) .show(ctx, |ui| { ui.set_min_width(400.0); @@ -2525,16 +2463,7 @@ impl WalletsBalancesScreen { // Draw dark overlay behind the dialog if open { - let screen_rect = ctx.screen_rect(); - let painter = ctx.layer_painter(egui::LayerId::new( - egui::Order::Background, - egui::Id::new("private_key_dialog_overlay"), - )); - painter.rect_filled( - screen_rect, - 0.0, - egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), - ); + Self::draw_modal_overlay(ctx, "private_key_dialog_overlay"); } egui::Window::new("Private Key") @@ -2542,22 +2471,7 @@ impl WalletsBalancesScreen { .resizable(false) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .open(&mut open) - .frame(egui::Frame { - inner_margin: egui::Margin::same(20), - outer_margin: egui::Margin::same(0), - corner_radius: egui::CornerRadius::same(8), - shadow: egui::epaint::Shadow { - offset: [0, 8], - blur: 16, - spread: 0, - color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), - }, - fill: ctx.style().visuals.window_fill, - stroke: egui::Stroke::new( - 1.0, - egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), - ), - }) + .frame(Self::modal_frame(ctx)) .show(ctx, |ui| { ui.set_min_width(400.0); ui.vertical_centered(|ui| { @@ -3181,6 +3095,19 @@ impl WalletsBalancesScreen { /// Creates the appropriate refresh action based on the current refresh mode fn create_refresh_action(&self, wallet_arc: &Arc>) -> AppAction { + self.create_refresh_action_for_mode(wallet_arc, self.refresh_mode) + } + + /// Creates the appropriate refresh action using the pending refresh mode + fn create_pending_refresh_action(&self, wallet_arc: &Arc>) -> AppAction { + self.create_refresh_action_for_mode(wallet_arc, self.pending_refresh_mode) + } + + fn create_refresh_action_for_mode( + &self, + wallet_arc: &Arc>, + mode: RefreshMode, + ) -> AppAction { use crate::backend_task::wallet::PlatformSyncMode; let seed_hash = wallet_arc @@ -3189,7 +3116,7 @@ impl WalletsBalancesScreen { .map(|w| w.seed_hash()) .unwrap_or_default(); - match self.refresh_mode { + match mode { RefreshMode::All => { // Default behavior: Core + Platform (Auto) AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( @@ -3238,47 +3165,6 @@ impl WalletsBalancesScreen { } } } - - /// Creates the appropriate refresh action using the pending refresh mode - fn create_pending_refresh_action(&self, wallet_arc: &Arc>) -> AppAction { - use crate::backend_task::wallet::PlatformSyncMode; - - let seed_hash = wallet_arc - .read() - .ok() - .map(|w| w.seed_hash()) - .unwrap_or_default(); - - match self.pending_refresh_mode { - RefreshMode::All => AppAction::BackendTask(BackendTask::CoreTask( - CoreTask::RefreshWalletInfo(wallet_arc.clone(), Some(PlatformSyncMode::Auto)), - )), - RefreshMode::CoreOnly => AppAction::BackendTask(BackendTask::CoreTask( - CoreTask::RefreshWalletInfo(wallet_arc.clone(), None), - )), - RefreshMode::PlatformFull => AppAction::BackendTask(BackendTask::WalletTask( - crate::backend_task::wallet::WalletTask::FetchPlatformAddressBalances { - seed_hash, - sync_mode: PlatformSyncMode::ForceFull, - }, - )), - RefreshMode::PlatformTerminal => AppAction::BackendTask(BackendTask::WalletTask( - crate::backend_task::wallet::WalletTask::FetchPlatformAddressBalances { - seed_hash, - sync_mode: PlatformSyncMode::TerminalOnly, - }, - )), - RefreshMode::CoreAndPlatformFull => AppAction::BackendTask(BackendTask::CoreTask( - CoreTask::RefreshWalletInfo(wallet_arc.clone(), Some(PlatformSyncMode::ForceFull)), - )), - RefreshMode::CoreAndPlatformTerminal => { - AppAction::BackendTask(BackendTask::CoreTask(CoreTask::RefreshWalletInfo( - wallet_arc.clone(), - Some(PlatformSyncMode::TerminalOnly), - ))) - } - } - } } impl ScreenLike for WalletsBalancesScreen { @@ -3756,7 +3642,7 @@ impl ScreenLike for WalletsBalancesScreen { return; } } - self.message = Some((message.to_string(), message_type, Utc::now())) + self.set_message(message.to_string(), message_type); } fn display_task_result( @@ -3767,17 +3653,15 @@ impl ScreenLike for WalletsBalancesScreen { crate::ui::BackendTaskSuccessResult::RefreshedWallet { warning } => { self.refreshing = false; if let Some(warn_msg) = warning { - self.message = Some(( + self.set_message( format!("Wallet refreshed with warning: {}", warn_msg), MessageType::Info, - Utc::now(), - )); + ); } else { - self.message = Some(( + self.set_message( "Successfully refreshed wallet".to_string(), MessageType::Success, - Utc::now(), - )); + ); } } crate::ui::BackendTaskSuccessResult::RecoveredAssetLocks { @@ -3873,11 +3757,10 @@ impl ScreenLike for WalletsBalancesScreen { wallet.set_platform_address_info(addr, balance, nonce); } } - self.message = Some(( + self.set_message( "Successfully synced Platform balances".to_string(), MessageType::Success, - Utc::now(), - )); + ); } crate::ui::BackendTaskSuccessResult::Message(msg) => { self.refreshing = false; @@ -3889,27 +3772,26 @@ impl ScreenLike for WalletsBalancesScreen { fn refresh_on_arrival(&mut self) { // Check if there's a pending wallet selection (e.g., from wallet creation/import) - if let Ok(mut pending) = self.app_context.pending_wallet_selection.lock() - && let Some(seed_hash) = pending.take() - && let Ok(wallets) = self.app_context.wallets.read() - && let Some(wallet) = wallets.get(&seed_hash) - { - self.selected_wallet = Some(wallet.clone()); - self.selected_single_key_wallet = None; // Clear SK selection - self.selected_account = None; - // Persist selection to AppContext and database - if let Ok(mut guard) = self.app_context.selected_wallet_hash.lock() { - *guard = Some(seed_hash); - } - if let Ok(mut guard) = self.app_context.selected_single_key_hash.lock() { - *guard = None; - } - let _ = self + let pending_seed_hash = self + .app_context + .pending_wallet_selection + .lock() + .ok() + .and_then(|mut pending| pending.take()); + + if let Some(seed_hash) = pending_seed_hash { + let selected_wallet = self .app_context - .db - .update_selected_wallet_hash(Some(&seed_hash)); - let _ = self.app_context.db.update_selected_single_key_hash(None); - return; + .wallets + .read() + .ok() + .and_then(|wallets| wallets.get(&seed_hash).cloned()); + + if let Some(wallet) = selected_wallet { + self.select_hd_wallet(wallet); + self.persist_selected_wallet_hash(Some(seed_hash)); + return; + } } // If no wallet of either type is selected but wallets exist, select the first HD wallet From 76475983a8272997260486569f57de3465b0a057 Mon Sep 17 00:00:00 2001 From: Paul DeLucia <69597248+pauldelucia@users.noreply.github.com> Date: Sat, 7 Feb 2026 01:25:56 +0700 Subject: [PATCH 078/106] fix: handle RegisteredTokenContract result to clear token creation spinner (#529) The backend task returned RegisteredTokenContract on success but the tokens screen had no handler for it, causing the spinner to spin forever. Co-authored-by: Claude Opus 4.5 --- src/ui/tokens/tokens_screen/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 96ce52304..652feb847 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -3150,6 +3150,9 @@ impl ScreenLike for TokensScreen { ); self.refreshing_status = RefreshingStatus::NotRefreshing; } + BackendTaskSuccessResult::RegisteredTokenContract => { + self.token_creator_status = TokenCreatorStatus::Complete; + } _ => {} } } From 45c4f0339652cc5cec8fd807944371c9faa5bfc4 Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Mon, 9 Feb 2026 06:57:35 +0100 Subject: [PATCH 079/106] fix: build fails for windows target (#527) * fix: build fails for windows target * build: only create a release when tag is set * chore: remove redundant settings from release.yml * doc: windows requirements * fix: windows icon * build: change grovestark revision * chore: fmt * fix: don't open console window on windows * build: grovestark recent commit --- .cargo/config.toml | 10 ++ .github/workflows/release.yml | 95 ++++++++-------- Cargo.lock | 151 +------------------------ Cargo.toml | 2 +- README.md | 9 ++ assets/DET_LOGO.ico | Bin 0 -> 41249 bytes build.rs | 62 ++++++++++ src/backend_task/core/start_dash_qt.rs | 2 +- src/main.rs | 4 + 9 files changed, 138 insertions(+), 197 deletions(-) create mode 100644 assets/DET_LOGO.ico diff --git a/.cargo/config.toml b/.cargo/config.toml index 630c55231..3d438521b 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -6,3 +6,13 @@ rustflags = ["-C", "target-feature=-crt-static", "-C", "target-cpu=x86-64"] [target.x86_64-unknown-linux-gnu] rustflags = ["-C", "target-feature=-crt-static", "-C", "target-cpu=x86-64"] + +[target.x86_64-pc-windows-gnu] +linker = "x86_64-w64-mingw32-gcc-posix" +ar = "x86_64-w64-mingw32-ar" + +[env] +CC_x86_64_pc_windows_gnu = "x86_64-w64-mingw32-gcc-posix" +CXX_x86_64_pc_windows_gnu = "x86_64-w64-mingw32-g++-posix" +AR_x86_64_pc_windows_gnu = "x86_64-w64-mingw32-ar" +CFLAGS_x86_64_pc_windows_gnu = "-O2" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd296c688..5d40e103c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,8 @@ name: Release Dash Evo Tool on: push: tags: - - 'v*' - - 'v*-dev.*' + - "v*" + - "v*-dev.*" release: types: - published @@ -12,7 +12,7 @@ on: inputs: tag: description: "Version (i.e. v0.1.0)" - required: true + required: false permissions: id-token: write @@ -93,7 +93,7 @@ jobs: - name: Install essentials if: ${{ runner.os == 'Linux' }} - run: sudo apt-get update && sudo apt-get install -y build-essential pkg-config clang cmake unzip libsqlite3-dev gcc-mingw-w64 mingw-w64 libsqlite3-dev mingw-w64-x86-64-dev gcc-aarch64-linux-gnu zip && uname -a && cargo clean + run: sudo apt-get update && sudo apt-get install -y build-essential pkg-config clang cmake unzip libsqlite3-dev gcc-mingw-w64 mingw-w64 libsqlite3-dev mingw-w64-x86-64-dev binutils-mingw-w64-x86-64 gcc-aarch64-linux-gnu zip && uname -a && cargo clean - name: Install protoc (ARM) if: ${{ matrix.platform == 'linux-arm64' }} @@ -121,10 +121,6 @@ jobs: run: | cargo build --release --target ${{ matrix.target }} mv target/${{ matrix.target }}/release/dash-evo-tool${{ matrix.ext }} dash-evo-tool/dash-evo-tool${{ matrix.ext }} - env: - CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc - AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar - CFLAGS_x86_64_pc_windows_gnu: "-O2" - name: Package release run: | @@ -133,7 +129,7 @@ jobs: - name: Attest uses: actions/attest-build-provenance@v1 with: - subject-path: 'dash-evo-tool-${{ matrix.platform }}.zip' + subject-path: "dash-evo-tool-${{ matrix.platform }}.zip" - name: Upload build artifact uses: actions/upload-artifact@v4 @@ -158,20 +154,20 @@ jobs: run: | echo "Disk usage before initial cleanup:" df -h - + # Clean up homebrew cache brew cleanup --prune=all || true - + # Remove Xcode caches sudo rm -rf ~/Library/Developer/CoreSimulator/Caches/dyld 2>/dev/null || true sudo rm -rf ~/Library/Developer/Xcode/DerivedData 2>/dev/null || true sudo rm -rf ~/Library/Caches/com.apple.dt.Xcode 2>/dev/null || true - + # Clean system caches sudo rm -rf /Library/Caches/* 2>/dev/null || true sudo rm -rf /System/Library/Caches/* 2>/dev/null || true sudo rm -rf /private/var/folders/* 2>/dev/null || true - + echo "Disk usage after initial cleanup:" df -h @@ -194,7 +190,7 @@ jobs: rm -rf target/aarch64-apple-darwin/release/.fingerprint rm -rf target/aarch64-apple-darwin/debug rm -rf target/debug - + # Remove the actual binary from target since we copied it rm -f target/aarch64-apple-darwin/release/dash-evo-tool @@ -300,37 +296,37 @@ jobs: echo "Disk usage before cleanup:" df -h du -sh ~/* 2>/dev/null | sort -rh | head -20 - + # Remove the ENTIRE target directory since we already copied the binary rm -rf target - + # Remove the entire Cargo directory rm -rf ~/.cargo - + # Clean up homebrew completely brew cleanup --prune=all rm -rf $(brew --cache) - + # Remove any unnecessary Xcode simulators and caches sudo rm -rf ~/Library/Developer/CoreSimulator 2>/dev/null || true sudo rm -rf ~/Library/Developer/Xcode 2>/dev/null || true sudo rm -rf ~/Library/Caches 2>/dev/null || true - + # Remove temporary icon files after creating the app bundle rm -rf AppIcon.iconset 2>/dev/null || true - + # Clean system caches more aggressively sudo rm -rf /Library/Caches/* 2>/dev/null || true sudo rm -rf /System/Library/Caches/* 2>/dev/null || true sudo rm -rf /private/var/folders/* 2>/dev/null || true sudo rm -rf /Users/runner/Library/Caches/* 2>/dev/null || true - + # Remove any iOS simulators and SDKs we don't need sudo rm -rf /Library/Developer/CoreSimulator 2>/dev/null || true sudo rm -rf /Applications/Xcode*.app/Contents/Developer/Platforms/iPhoneOS.platform 2>/dev/null || true sudo rm -rf /Applications/Xcode*.app/Contents/Developer/Platforms/AppleTVOS.platform 2>/dev/null || true sudo rm -rf /Applications/Xcode*.app/Contents/Developer/Platforms/WatchOS.platform 2>/dev/null || true - + echo "Disk usage after cleanup:" df -h du -sh ~/* 2>/dev/null | sort -rh | head -20 @@ -340,14 +336,14 @@ jobs: # Get app size for sparse image APP_SIZE=$(du -sm "build/Dash Evo Tool.app" | cut -f1) DMG_SIZE=$((APP_SIZE + 50)) # Add 50MB padding - + # Create a sparse image instead of using srcfolder # Sparse images only use disk space as needed hdiutil create -size ${DMG_SIZE}m -type SPARSE -fs HFS+ -volname "Dash Evo Tool" temp.sparseimage - + # Mount the sparse image hdiutil mount temp.sparseimage -mountpoint /Volumes/"Dash Evo Tool" - + # Copy app to mounted volume cp -r "build/Dash Evo Tool.app" /Volumes/"Dash Evo Tool"/ ln -s /Applications /Volumes/"Dash Evo Tool"/Applications @@ -356,13 +352,13 @@ jobs: rm -rf /Volumes/"Dash Evo Tool"/.fseventsd rm -rf /Volumes/"Dash Evo Tool"/.Spotlight-V100 rm -f /Volumes/"Dash Evo Tool"/.DS_Store - + # Unmount hdiutil detach /Volumes/"Dash Evo Tool" - + # Convert sparse image to compressed DMG hdiutil convert temp.sparseimage -format UDZO -o dash-evo-tool-macos-arm64.dmg - + # Clean up sparse image rm -f temp.sparseimage @@ -400,7 +396,7 @@ jobs: - name: Attest uses: actions/attest-build-provenance@v1 with: - subject-path: 'dash-evo-tool-macos-arm64.dmg' + subject-path: "dash-evo-tool-macos-arm64.dmg" - name: Upload build artifact uses: actions/upload-artifact@v4 @@ -425,20 +421,20 @@ jobs: run: | echo "Disk usage before initial cleanup:" df -h - + # Clean up homebrew cache brew cleanup --prune=all || true - + # Remove Xcode caches sudo rm -rf ~/Library/Developer/CoreSimulator/Caches/dyld 2>/dev/null || true sudo rm -rf ~/Library/Developer/Xcode/DerivedData 2>/dev/null || true sudo rm -rf ~/Library/Caches/com.apple.dt.Xcode 2>/dev/null || true - + # Clean system caches sudo rm -rf /Library/Caches/* 2>/dev/null || true sudo rm -rf /System/Library/Caches/* 2>/dev/null || true sudo rm -rf /private/var/folders/* 2>/dev/null || true - + echo "Disk usage after initial cleanup:" df -h @@ -461,7 +457,7 @@ jobs: rm -rf target/x86_64-apple-darwin/release/.fingerprint rm -rf target/x86_64-apple-darwin/debug rm -rf target/debug - + # Remove the actual binary from target since we copied it rm -f target/x86_64-apple-darwin/release/dash-evo-tool @@ -567,37 +563,37 @@ jobs: echo "Disk usage before cleanup:" df -h du -sh ~/* 2>/dev/null | sort -rh | head -20 - + # Remove the ENTIRE target directory since we already copied the binary rm -rf target - + # Remove the entire Cargo directory rm -rf ~/.cargo - + # Clean up homebrew completely brew cleanup --prune=all rm -rf $(brew --cache) - + # Remove any unnecessary Xcode simulators and caches sudo rm -rf ~/Library/Developer/CoreSimulator 2>/dev/null || true sudo rm -rf ~/Library/Developer/Xcode 2>/dev/null || true sudo rm -rf ~/Library/Caches 2>/dev/null || true - + # Remove temporary icon files after creating the app bundle rm -rf AppIcon.iconset 2>/dev/null || true - + # Clean system caches more aggressively sudo rm -rf /Library/Caches/* 2>/dev/null || true sudo rm -rf /System/Library/Caches/* 2>/dev/null || true sudo rm -rf /private/var/folders/* 2>/dev/null || true sudo rm -rf /Users/runner/Library/Caches/* 2>/dev/null || true - + # Remove any iOS simulators and SDKs we don't need sudo rm -rf /Library/Developer/CoreSimulator 2>/dev/null || true sudo rm -rf /Applications/Xcode*.app/Contents/Developer/Platforms/iPhoneOS.platform 2>/dev/null || true sudo rm -rf /Applications/Xcode*.app/Contents/Developer/Platforms/AppleTVOS.platform 2>/dev/null || true sudo rm -rf /Applications/Xcode*.app/Contents/Developer/Platforms/WatchOS.platform 2>/dev/null || true - + echo "Disk usage after cleanup:" df -h du -sh ~/* 2>/dev/null | sort -rh | head -20 @@ -607,24 +603,24 @@ jobs: # Get app size for sparse image APP_SIZE=$(du -sm "build/Dash Evo Tool.app" | cut -f1) DMG_SIZE=$((APP_SIZE + 50)) # Add 50MB padding - + # Create a sparse image instead of using srcfolder # Sparse images only use disk space as needed hdiutil create -size ${DMG_SIZE}m -type SPARSE -fs HFS+ -volname "Dash Evo Tool" temp.sparseimage - + # Mount the sparse image hdiutil mount temp.sparseimage -mountpoint /Volumes/"Dash Evo Tool" - + # Copy app to mounted volume cp -r "build/Dash Evo Tool.app" /Volumes/"Dash Evo Tool"/ ln -s /Applications /Volumes/"Dash Evo Tool"/Applications - + # Unmount hdiutil detach /Volumes/"Dash Evo Tool" - + # Convert sparse image to compressed DMG hdiutil convert temp.sparseimage -format UDZO -o dash-evo-tool-macos-x86_64.dmg - + # Clean up sparse image rm -f temp.sparseimage @@ -662,7 +658,7 @@ jobs: - name: Attest uses: actions/attest-build-provenance@v1 with: - subject-path: 'dash-evo-tool-macos-x86_64.dmg' + subject-path: "dash-evo-tool-macos-x86_64.dmg" - name: Upload build artifact uses: actions/upload-artifact@v4 @@ -700,6 +696,7 @@ jobs: uses: softprops/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + if: ${{ github.event.inputs.tag != '' }} with: tag_name: ${{ github.event.inputs.tag }} files: | diff --git a/Cargo.lock b/Cargo.lock index fe7c0b67d..938872ea0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -849,24 +849,6 @@ dependencies = [ "which 4.4.2", ] -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.10.0", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.1", - "shlex", - "syn 2.0.114", -] - [[package]] name = "bip37-bloom-filter" version = "0.1.0" @@ -1159,16 +1141,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "calloop" version = "0.13.0" @@ -1452,15 +1424,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "colored" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "combine" version = "4.6.7" @@ -2355,7 +2318,7 @@ dependencies = [ "serde_json", "serde_repr", "sha2", - "strum 0.26.3", + "strum", "thiserror 2.0.18", "tracing", ] @@ -3490,18 +3453,13 @@ dependencies = [ "grovedb-element", "grovedb-merk", "grovedb-path", - "grovedb-storage", "grovedb-version", - "grovedb-visualize", "hex", "hex-literal", "indexmap 2.13.0", "integer-encoding", - "intmap", - "itertools 0.14.0", "reqwest", "sha2", - "tempfile", "thiserror 2.0.18", ] @@ -3524,7 +3482,6 @@ dependencies = [ "bincode_derive", "grovedb-path", "grovedb-version", - "grovedb-visualize", "hex", "integer-encoding", "thiserror 2.0.18", @@ -3551,19 +3508,15 @@ dependencies = [ "bincode_derive", "blake3", "byteorder", - "colored", "ed", "grovedb-costs", "grovedb-element", "grovedb-path", - "grovedb-storage", "grovedb-version", "grovedb-visualize", "hex", "indexmap 2.13.0", "integer-encoding", - "num_cpus", - "rand 0.8.5", "thiserror 2.0.18", ] @@ -3575,25 +3528,6 @@ dependencies = [ "hex", ] -[[package]] -name = "grovedb-storage" -version = "4.0.0" -source = "git+https://github.com/dashpay/grovedb?rev=33dfd48a1718160cb333fa95424be491785f1897#33dfd48a1718160cb333fa95424be491785f1897" -dependencies = [ - "blake3", - "grovedb-costs", - "grovedb-path", - "grovedb-visualize", - "hex", - "integer-encoding", - "lazy_static", - "num_cpus", - "rocksdb", - "strum 0.27.2", - "tempfile", - "thiserror 2.0.18", -] - [[package]] name = "grovedb-version" version = "4.0.0" @@ -3615,7 +3549,7 @@ dependencies = [ [[package]] name = "grovestark" version = "0.1.0" -source = "git+https://www.github.com/dashpay/grovestark?rev=a70bdb1adac080d4f1dc10f2f4480d9dacd0f0da#a70bdb1adac080d4f1dc10f2f4480d9dacd0f0da" +source = "git+https://www.github.com/dashpay/grovestark?rev=5b9e289cca54c79b1305d5f4f40bf1148f1eb0e3#5b9e289cca54c79b1305d5f4f40bf1148f1eb0e3" dependencies = [ "ark-ff", "base64 0.22.1", @@ -4541,20 +4475,6 @@ dependencies = [ "redox_syscall 0.7.0", ] -[[package]] -name = "librocksdb-sys" -version = "0.18.0+10.7.5" -source = "git+https://github.com/QuantumExplorer/rust-rocksdb.git?rev=52772eea7bcd214d1d07d80aa538b1d24e5015b7#52772eea7bcd214d1d07d80aa538b1d24e5015b7" -dependencies = [ - "bindgen 0.72.1", - "bzip2-sys", - "cc", - "libc", - "libz-sys", - "lz4-sys", - "zstd-sys", -] - [[package]] name = "libsqlite3-sys" version = "0.35.0" @@ -4566,17 +4486,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libz-sys" -version = "1.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4628,16 +4537,6 @@ dependencies = [ "hashbrown 0.16.1", ] -[[package]] -name = "lz4-sys" -version = "1.11.1+lz4-1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "malloc_buf" version = "0.0.6" @@ -4836,7 +4735,7 @@ dependencies = [ "once_cell", "rustc-hash 1.1.0", "spirv", - "strum 0.26.3", + "strum", "thiserror 2.0.18", "unicode-ident", ] @@ -6352,15 +6251,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rocksdb" -version = "0.24.0" -source = "git+https://github.com/QuantumExplorer/rust-rocksdb.git?rev=52772eea7bcd214d1d07d80aa538b1d24e5015b7#52772eea7bcd214d1d07d80aa538b1d24e5015b7" -dependencies = [ - "libc", - "librocksdb-sys", -] - [[package]] name = "ron" version = "0.10.1" @@ -6412,7 +6302,7 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94ea852806513d6f5fd7750423300375bc8481a18ed033756c1a836257893a30" dependencies = [ - "bindgen 0.65.1", + "bindgen", "cc", "libc", ] @@ -7122,16 +7012,7 @@ version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "strum_macros 0.26.4", -] - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", + "strum_macros", ] [[package]] @@ -7147,18 +7028,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.114", -] - [[package]] name = "subtle" version = "2.6.1" @@ -9920,16 +9789,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index f535fb180..e94de8b28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "98a0ebeca4e6a9a "core_rpc_client", "core_spv", ] } -grovestark = { git = "https://www.github.com/dashpay/grovestark", rev = "a70bdb1adac080d4f1dc10f2f4480d9dacd0f0da" } +grovestark = { git = "https://www.github.com/dashpay/grovestark", rev = "5b9e289cca54c79b1305d5f4f40bf1148f1eb0e3" } rayon = "1.8" thiserror = "2.0.12" serde = "1.0.219" diff --git a/README.md b/README.md index 84cc820e5..0c9cd47ce 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ The tool supports both Mainnet and Testnet networks. Check out the [documentatio - [Prerequisites](#prerequisites) - [Rust Installation](#rust-installation) - [Dependencies](#dependencies) + - [Windows Runtime Dependencies](#windows-runtime-dependencies) - [Dash Core Wallet Setup](#dash-core-wallet-setup) - [Installation](#installation) - [Getting Started](#getting-started) @@ -67,6 +68,14 @@ system, unzip, and install: sudo unzip protoc-*-linux-x86_64.zip -d /usr/local ``` +### Windows Runtime Dependencies + +If you use the prebuilt Windows binary, make sure the target machine has: + +- Microsoft Visual C++ Redistributable (vc_redist x64): https://aka.ms/vc14/vc_redist.x64.exe +- OpenGL 2.0 support. If OpenGL 2.0 is not available (or the app fails to start with OpenGL-related errors), install the OpenCL, OpenGL, and Vulkan Compatibility Pack: + https://apps.microsoft.com/detail/9nqpsl29bfff?ocid=webpdpshare + ### Dash Core Wallet Setup - **Dash Core Wallet**: Download and install from [dash.org/wallets](https://www.dash.org/wallets/). diff --git a/assets/DET_LOGO.ico b/assets/DET_LOGO.ico new file mode 100644 index 0000000000000000000000000000000000000000..54200221b498c4ba833fb242d4d205714273617a GIT binary patch literal 41249 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 GasLCgG{cJk literal 0 HcmV?d00001 diff --git a/build.rs b/build.rs index 4e6bfb5c6..d5c18016c 100644 --- a/build.rs +++ b/build.rs @@ -1,8 +1,11 @@ use std::env; use std::path::Path; +use std::process::Command; fn main() { println!("cargo:rerun-if-changed=assets/DET_LOGO.ico"); + println!("cargo:rerun-if-changed=assets/DET_LOGO.png"); + println!("cargo:rerun-if-env-changed=WINDRES"); let target = env::var("TARGET").unwrap_or_default(); if !target.contains("windows") { @@ -24,6 +27,22 @@ fn main() { .expect("icon path must be valid UTF-8 for the resource compiler"); res.set_icon(icon_str); + let windres_cmd = resolve_windres(); + if let Some(cmd) = windres_cmd { + res.set_windres_path(&cmd); + let ar_cmd = resolve_ar(&target).unwrap_or_else(|| { + panic!( + "Required tool not found: ar. Set AR_{} or AR, or install x86_64-w64-mingw32-ar.", + target.replace('-', "_") + ) + }); + res.set_ar_path(&ar_cmd); + } else { + panic!( + "Required tool not found: windres. Set WINDRES or install x86_64-w64-mingw32-windres." + ); + } + if let Ok(version) = env::var("CARGO_PKG_VERSION") { res.set("FileVersion", &version); res.set("ProductVersion", &version); @@ -37,3 +56,46 @@ fn main() { panic!("Failed to embed Windows resources: {err}"); } } + +fn resolve_windres() -> Option { + resolve_tool( + &[String::from("WINDRES")], + &["x86_64-w64-mingw32-windres", "windres"], + ) +} + +fn resolve_ar(target: &str) -> Option { + let ar_target_key = format!("AR_{}", target.replace('-', "_")); + resolve_tool( + &[ar_target_key, String::from("AR")], + &["x86_64-w64-mingw32-ar", "ar"], + ) +} + +fn resolve_tool(env_keys: &[String], candidates: &[&str]) -> Option { + for key in env_keys { + if let Ok(cmd) = env::var(key) + && is_available(&cmd) + { + return Some(cmd); + } + } + + for &cmd in candidates { + if is_available(cmd) { + return Some(cmd.to_string()); + } + } + + None +} + +fn is_available(cmd: &str) -> bool { + Command::new(cmd) + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} diff --git a/src/backend_task/core/start_dash_qt.rs b/src/backend_task/core/start_dash_qt.rs index 08d54f8f9..b8b4fc825 100644 --- a/src/backend_task/core/start_dash_qt.rs +++ b/src/backend_task/core/start_dash_qt.rs @@ -133,7 +133,7 @@ fn signal_term(child: &Child) -> Result<(), String> { } #[cfg(windows)] -fn signal_term(child: &Child) -> Result<(), String> { +fn signal_term(_child: &Child) -> Result<(), String> { // TODO: Implement graceful termination for Dash-Qt on Windows. tracing::warn!( "SIGTERM signal is not supported on Windows. Dash-Qt process will not be gracefully terminated." diff --git a/src/main.rs b/src/main.rs index b26319f3f..0dae520d3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_os = "windows", windows_subsystem = "windows")] + use dash_evo_tool::*; use crate::app_dir::{app_user_data_dir_path, create_app_user_data_directory_if_not_exists}; @@ -30,6 +32,8 @@ fn load_icon() -> egui::IconData { let image = image::load_from_memory(icon_bytes) .expect("Failed to load icon") .to_rgba8(); + // Windows can ignore overly large icons; keep a reasonable size. + let image = image::imageops::resize(&image, 64, 64, image::imageops::FilterType::Lanczos3); let (width, height) = image.dimensions(); egui::IconData { rgba: image.into_raw(), From 12a2826fe3af263b4a3330e94918c91f6bf7d366 Mon Sep 17 00:00:00 2001 From: PastaPastaPasta <6443210+PastaPastaPasta@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:04:29 -0600 Subject: [PATCH 080/106] fix: critical wallet bugs (GH#522, GH#476, GH#478, GH#85) (#534) * fix: auto-refresh wallet UTXOs on app startup (GH#522) On startup, bootstrap_loaded_wallets now spawns background tasks to refresh UTXOs from Core RPC for all HD and single-key wallets. This ensures balances are current without requiring the user to manually click Refresh. Only applies in RPC mode; SPV handles UTXOs via reconciliation. Task: 1.1a * fix: respect fee_deduct_from_output flag in platform address funding (GH#476) When the user selects "deduct from input", use an explicit output amount for the destination and a separate change address for the fee remainder, so the destination receives the exact requested amount. Task: 1.1b * fix: reserve estimated fees in wallet balance top-up max button (GH#478) Task: 1.1c * fix: prevent funding address reuse across identities (GH#85) Task: 1.1d * fix: add 5-minute timeout to asset lock proof wait loop (wallet-008) Task: 1.1e * fix: use wallet-controlled change address and epoch-aware fee estimator Use a fresh wallet receive address for platform funding change output instead of an ephemeral key-derived address, so surplus credits remain spendable. Also switch to epoch-aware fee estimator for accurate fee estimation when the network fee multiplier is above 1x. Co-Authored-By: Claude Opus 4.6 * fix: address critical review issues in wallet funding - Use try_lock() in tokio::select! timeout arm to avoid blocking the async runtime when another thread holds the mutex - Use change_address() (BIP44 internal path) instead of receive_address() for deriving change addresses, ensuring proper path separation - Replace .unwrap_or(0) with .ok_or() on BTreeMap index lookup to surface errors instead of silently targeting the wrong output Co-Authored-By: Claude Opus 4.6 * fix: auto-refresh wallet UTXOs on asset lock proof timeout (RPC mode) When the 5-minute timeout fires, the asset lock tx has already been broadcast and UTXOs removed locally. Trigger a background wallet refresh in RPC mode so spent inputs are reconciled against chain state. SPV mode handles its own reconciliation. Co-Authored-By: Claude Opus 4.6 * style: fix rustfmt formatting in fee estimator chain Co-Authored-By: Claude Opus 4.6 * fix: replace saturating_mul with checked_mul for duffs-to-credits conversion Overflow now returns an explicit error with diagnostic info instead of silently clamping to u64::MAX. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- ...fund_platform_address_from_wallet_utxos.rs | 113 +++++++++++++++--- .../wallet/generate_receive_address.rs | 2 +- src/context.rs | 51 +++++++- .../by_wallet_qr_code.rs | 2 +- .../by_wallet_qr_code.rs | 2 +- .../identities/top_up_identity_screen/mod.rs | 37 ++++-- src/ui/wallets/create_asset_lock_screen.rs | 2 +- 7 files changed, 173 insertions(+), 36 deletions(-) diff --git a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs index 12edbfa43..ac132b754 100644 --- a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs +++ b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs @@ -1,9 +1,10 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::wallet::PlatformSyncMode; use crate::context::AppContext; -use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::wallet::WalletSeedHash; +use crate::spv::CoreBackendMode; use dash_sdk::dpp::address_funds::PlatformAddress; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::prelude::AssetLockProof; use std::sync::Arc; use std::time::Duration; @@ -31,9 +32,12 @@ impl AppContext { // Fees deducted from output: use the requested amount, allow core fee to be taken from it (amount, true) } else { - // Fees paid from wallet: add estimated platform fee to asset lock amount - let estimated_platform_fee_duffs = - PlatformFeeEstimator::new().estimate_address_funding_from_asset_lock_duffs(1); + // Fees paid from wallet: add estimated platform fee to asset lock amount. + // We use 2 outputs: the destination (explicit amount) and a change address + // (remainder recipient that absorbs the fee). + let estimated_platform_fee_duffs = self + .fee_estimator() + .estimate_address_funding_from_asset_lock_duffs(2); let asset_lock_amount = amount.saturating_add(estimated_platform_fee_duffs); (asset_lock_amount, false) }; @@ -134,17 +138,48 @@ impl AppContext { } } - // Step 5: Wait for asset lock proof (InstantLock or ChainLock) + // Step 5: Wait for asset lock proof (InstantLock or ChainLock) with timeout let asset_lock_proof: AssetLockProof; + let timeout = tokio::time::sleep(Duration::from_secs(300)); // 5 minute timeout + tokio::pin!(timeout); + loop { - { - let proofs = self.transactions_waiting_for_finality.lock().unwrap(); - if let Some(Some(proof)) = proofs.get(&tx_id) { - asset_lock_proof = proof.clone(); - break; + tokio::select! { + _ = &mut timeout => { + // Best-effort cleanup: use try_lock to avoid blocking the + // async runtime if another thread holds the mutex. + if let Ok(mut proofs) = self.transactions_waiting_for_finality.try_lock() { + proofs.remove(&tx_id); + } + + // Auto-refresh wallet UTXOs in RPC mode so the broadcast tx's + // spent inputs are reconciled (the tx was already broadcast and + // may confirm later). SPV handles its own reconciliation. + if self.core_backend_mode() == CoreBackendMode::Rpc + && let Some(wallet_arc) = self.wallets.read().ok() + .and_then(|w| w.get(&seed_hash).cloned()) + { + let ctx = Arc::clone(self); + // Fire-and-forget — don't block the error return on refresh + tokio::task::spawn_blocking(move || { + if let Err(e) = ctx.refresh_wallet_info(wallet_arc) { + tracing::warn!("Failed to auto-refresh wallet after timeout: {}", e); + } + }); + } + + return Err("Timeout waiting for asset lock proof — no InstantLock or ChainLock received within 5 minutes".to_string()); + } + _ = tokio::time::sleep(Duration::from_millis(200)) => { + // Brief lock to check for proof — acquired and released quickly + // so contention is minimal. + let proofs = self.transactions_waiting_for_finality.lock().unwrap(); + if let Some(Some(proof)) = proofs.get(&tx_id) { + asset_lock_proof = proof.clone(); + break; + } } } - tokio::time::sleep(Duration::from_millis(200)).await; } // Step 6: Clean up the finality tracking @@ -153,8 +188,8 @@ impl AppContext { proofs.remove(&tx_id); } - // Step 7: Get wallet and SDK for the platform funding operation - let (wallet, sdk) = { + // Step 7: Get wallet, SDK, and derive a fresh change address if needed + let (wallet, sdk, change_platform_address) = { let wallet_arc = { let wallets = self.wallets.read().unwrap(); wallets @@ -162,16 +197,62 @@ impl AppContext { .cloned() .ok_or_else(|| "Wallet not found".to_string())? }; + + // Derive a fresh change address from the BIP44 internal (change) path + // while we have write access (only needed when fees are NOT deducted + // from the output). Using change_address() ensures proper BIP44 + // separation between receive and change addresses. + let change_platform_address = if !fee_deduct_from_output { + let mut wallet_w = wallet_arc.write().map_err(|e| e.to_string())?; + let addr = wallet_w.change_address(self.network, Some(self))?; + Some( + PlatformAddress::try_from(addr) + .map_err(|e| format!("Failed to convert change address: {}", e))?, + ) + } else { + None + }; + let wallet = wallet_arc.read().map_err(|e| e.to_string())?.clone(); let sdk = self.sdk.read().map_err(|e| e.to_string())?.clone(); - (wallet, sdk) + (wallet, sdk, change_platform_address) }; // Step 8: Fund the destination platform address let mut outputs = std::collections::BTreeMap::new(); - outputs.insert(destination, None); // None means use all available funds - let fee_strategy = vec![AddressFundsFeeStrategyStep::ReduceOutput(0)]; + let fee_strategy = if fee_deduct_from_output { + // Fee deducted from output: destination is the remainder recipient (gets + // asset lock value minus fee). ReduceOutput(0) tells Platform to deduct + // the fee from the single output. + outputs.insert(destination, None); + vec![AddressFundsFeeStrategyStep::ReduceOutput(0)] + } else { + // Fee NOT deducted from output: destination receives the exact requested + // amount. We use a fresh wallet-controlled change address to absorb the + // fee estimate surplus, keeping it spendable. + let amount_credits = amount.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { + format!( + "Overflow converting {amount} duffs to credits (CREDITS_PER_DUFF = {CREDITS_PER_DUFF})" + ) + })?; + + if let Some(change_address) = change_platform_address { + outputs.insert(destination, Some(amount_credits)); + outputs.insert(change_address, None); // Remainder recipient + + // Determine the BTreeMap index of the change address to target it + // with the fee strategy (BTreeMap iterates in key order). + let change_index = outputs + .keys() + .position(|k| *k == change_address) + .ok_or("Change address not found in outputs map")? + as u16; + vec![AddressFundsFeeStrategyStep::ReduceOutput(change_index)] + } else { + return Err("Failed to derive a change address for platform funding".to_string()); + } + }; outputs .top_up( diff --git a/src/backend_task/wallet/generate_receive_address.rs b/src/backend_task/wallet/generate_receive_address.rs index 90b3c3b07..f627a035b 100644 --- a/src/backend_task/wallet/generate_receive_address.rs +++ b/src/backend_task/wallet/generate_receive_address.rs @@ -35,7 +35,7 @@ impl AppContext { } else { let mut wallet = wallet_arc.write().map_err(|e| e.to_string())?; wallet - .receive_address(self.network, false, Some(self))? + .receive_address(self.network, true, Some(self))? .to_string() }; diff --git a/src/context.rs b/src/context.rs index 4d7be12ff..980053129 100644 --- a/src/context.rs +++ b/src/context.rs @@ -464,8 +464,8 @@ impl AppContext { pub fn handle_wallet_unlocked(self: &Arc, wallet: &Arc>) { if let Some((seed_hash, seed_bytes)) = Self::wallet_seed_snapshot(wallet) { self.queue_spv_wallet_load(seed_hash, seed_bytes); - // Note: Platform address sync and Core UTXO refresh are NOT done automatically on unlock. - // User must explicitly click Refresh to update balances. + // Note: Platform address sync is not done here. + // Core UTXO refresh is handled at startup in bootstrap_loaded_wallets. } } @@ -541,9 +541,50 @@ impl AppContext { guard.values().cloned().collect() }; - for wallet in wallets { - self.bootstrap_wallet_addresses(&wallet); - self.handle_wallet_unlocked(&wallet); + for wallet in wallets.iter() { + self.bootstrap_wallet_addresses(wallet); + self.handle_wallet_unlocked(wallet); + } + + // Auto-refresh UTXOs from Core on startup so balances are current + // without requiring the user to manually click Refresh (fixes GH#522). + // Only in RPC mode — SPV mode handles UTXO loading via reconciliation. + if self.core_backend_mode() == CoreBackendMode::Rpc { + for wallet in wallets { + let ctx = Arc::clone(self); + self.subtasks.spawn_sync(async move { + if let Err(e) = + tokio::task::spawn_blocking(move || ctx.refresh_wallet_info(wallet)) + .await + .map_err(|e| format!("Task join error: {}", e)) + .and_then(|r| r.map(|_| ())) + { + tracing::warn!("Failed to auto-refresh wallet UTXOs on startup: {}", e); + } + }); + } + + let single_key_wallets: Vec<_> = { + let guard = self.single_key_wallets.read().unwrap(); + guard.values().cloned().collect() + }; + for wallet in single_key_wallets { + let ctx = Arc::clone(self); + self.subtasks.spawn_sync(async move { + if let Err(e) = tokio::task::spawn_blocking(move || { + ctx.refresh_single_key_wallet_info(wallet) + }) + .await + .map_err(|e| format!("Task join error: {}", e)) + .and_then(|r| r) + { + tracing::warn!( + "Failed to auto-refresh single key wallet UTXOs on startup: {}", + e + ); + } + }); + } } } diff --git a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs index 332e82f3a..5c8b06a9a 100644 --- a/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/add_new_identity_screen/by_wallet_qr_code.rs @@ -23,7 +23,7 @@ impl AddNewIdentityScreen { let mut wallet = wallet_guard.write().unwrap(); let receive_address = wallet.receive_address( self.app_context.network, - false, + true, Some(&self.app_context), )?; diff --git a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs index ec069f2d7..12339a127 100644 --- a/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs +++ b/src/ui/identities/top_up_identity_screen/by_wallet_qr_code.rs @@ -17,7 +17,7 @@ impl TopUpIdentityScreen { let mut wallet = wallet_guard.write().unwrap(); let receive_address = wallet.receive_address( self.app_context.network, - false, + true, Some(&self.app_context), )?; diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index 3b1ea0001..7bc8fae6f 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -10,6 +10,7 @@ use crate::backend_task::identity::{IdentityTask, IdentityTopUpInfo, TopUpIdenti use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; +use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; @@ -371,17 +372,30 @@ impl TopUpIdentityScreen { // Only apply max amount restriction when using wallet balance // For QR code funding, funds come from external source so no max applies - let (max_amount, show_max_button) = if funding_method == FundingMethod::UseWalletBalance { - let max_amount_duffs = self - .wallet - .as_ref() - .map(|w| w.read().unwrap().total_balance_duffs()) - .unwrap_or(0); - // Convert Duffs to Credits (1 Duff = 1000 Credits) - (Some(max_amount_duffs * 1000), true) - } else { - (None, false) - }; + let (max_amount, show_max_button, fee_hint) = + if funding_method == FundingMethod::UseWalletBalance { + let max_amount_duffs = self + .wallet + .as_ref() + .map(|w| w.read().unwrap().total_balance_duffs()) + .unwrap_or(0); + // Convert Duffs to Credits (1 Duff = 1000 Credits) + let total_credits = max_amount_duffs * 1000; + // Reserve estimated fees so "Max" doesn't exceed spendable amount + let fee_estimator = self.app_context.fee_estimator(); + let estimated_fee = fee_estimator.estimate_identity_topup(); + let max_with_fee_reserved = total_credits.saturating_sub(estimated_fee); + ( + Some(max_with_fee_reserved), + true, + Some(format!( + "~{} reserved for fees", + format_credits_as_dash(estimated_fee) + )), + ) + } else { + (None, false, None) + }; // Lazy initialization of the AmountInput component let amount_input = self.funding_amount_input.get_or_insert_with(|| { @@ -394,6 +408,7 @@ impl TopUpIdentityScreen { // Update max amount and button visibility in case funding method or wallet balance changed amount_input.set_max_amount(max_amount); amount_input.set_show_max_button(show_max_button); + amount_input.set_max_exceeded_hint(fee_hint); let response = amount_input.show(ui); diff --git a/src/ui/wallets/create_asset_lock_screen.rs b/src/ui/wallets/create_asset_lock_screen.rs index c581f7e84..eea9978b7 100644 --- a/src/ui/wallets/create_asset_lock_screen.rs +++ b/src/ui/wallets/create_asset_lock_screen.rs @@ -107,7 +107,7 @@ impl CreateAssetLockScreen { // Generate a new asset lock funding address let receive_address = - wallet.receive_address(self.app_context.network, false, Some(&self.app_context))?; + wallet.receive_address(self.app_context.network, true, Some(&self.app_context))?; // Import address to core if needed if let Some(has_address) = self.core_has_funding_address { From 3dc268b803b352486d5b9bd990a4dd4635ed30e7 Mon Sep 17 00:00:00 2001 From: PastaPastaPasta <6443210+PastaPastaPasta@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:11:01 -0600 Subject: [PATCH 081/106] fix(tokens): restore token reorder assignment and distribution field checks (#535) --- src/ui/tokens/tokens_screen/mod.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index 652feb847..5ca8ffcae 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -1795,8 +1795,15 @@ impl TokensScreen { } } + // Append any tokens not present in the saved order (e.g., newly added tokens) + for (key, value) in &self.my_tokens { + if !reordered.contains_key(key) { + reordered.insert(*key, value.clone()); + } + } + // Replace the original with the reordered map - //self.my_tokens = reordered; + self.my_tokens = reordered; } /// Save the current map's order of token IDs to the DB @@ -2041,7 +2048,7 @@ impl TokensScreen { .step_decreasing_initial_emission_input .parse::() .unwrap_or(0), - min_value: if self.step_decreasing_start_period_offset_input.is_empty() { + min_value: if self.step_decreasing_min_value_input.is_empty() { None } else { match self.step_decreasing_min_value_input.parse::() { @@ -2055,7 +2062,7 @@ impl TokensScreen { } } }, - max_interval_count: if self.step_decreasing_start_period_offset_input.is_empty() + max_interval_count: if self.step_decreasing_max_interval_count_input.is_empty() { None } else { From 9c970de306989f37d9f0a291416cf5b2c4da9a90 Mon Sep 17 00:00:00 2001 From: PastaPastaPasta <6443210+PastaPastaPasta@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:31:29 -0600 Subject: [PATCH 082/106] Extract dialog rendering into wallets_screen/dialogs.rs module (#539) Moved 4 dialog rendering functions and 10+ helper methods (~1151 lines) from wallets_screen/mod.rs into a new dialogs.rs module. Includes send, receive, fund platform, and private key dialogs plus their state structs. mod.rs reduced from 3824 to 2673 lines. Task: 3.2a --- src/ui/wallets/wallets_screen/dialogs.rs | 1177 ++++++++++++++++++++++ src/ui/wallets/wallets_screen/mod.rs | 1167 +-------------------- 2 files changed, 1185 insertions(+), 1159 deletions(-) create mode 100644 src/ui/wallets/wallets_screen/dialogs.rs diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs new file mode 100644 index 000000000..05ecb731d --- /dev/null +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -0,0 +1,1177 @@ +use crate::app::AppAction; +use crate::backend_task::BackendTask; +use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; +use crate::backend_task::wallet::WalletTask; +use crate::model::amount::Amount; +use crate::model::wallet::{DerivationPathHelpers, Wallet}; +use crate::ui::components::amount_input::AmountInput; +use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::helpers::copy_text_to_clipboard; +use crate::ui::identities::funding_common::generate_qr_code_image; +use crate::ui::theme::DashColors; +use dash_sdk::dashcore_rpc::dashcore::Address; +use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use eframe::egui::{self, ComboBox, Context}; +use eframe::epaint::TextureHandle; +use egui::load::SizedTexture; +use egui::{Color32, Frame, Margin, RichText, TextureOptions}; +use std::sync::{Arc, RwLock}; + +use super::WalletsBalancesScreen; + +#[derive(Default)] +pub(super) struct SendDialogState { + pub is_open: bool, + pub address: String, + pub amount: Option, + pub amount_input: Option, + pub subtract_fee: bool, + pub memo: String, + pub error: Option, +} + +/// Type of address to receive to +#[derive(Default, Clone, Copy, PartialEq, Eq)] +pub(super) enum ReceiveAddressType { + /// Core (L1) address for receiving Dash + #[default] + Core, + /// Platform address for receiving credits + Platform, +} + +/// Unified state for the receive dialog (Core and Platform) +#[derive(Default)] +pub(super) struct ReceiveDialogState { + pub is_open: bool, + /// Selected address type (Core or Platform) + pub address_type: ReceiveAddressType, + /// Core addresses with balances: (address, balance_duffs) + pub core_addresses: Vec<(String, u64)>, + /// Currently selected Core address index + pub selected_core_index: usize, + /// Platform addresses with balances: (display_address, balance_credits) + pub platform_addresses: Vec<(String, u64)>, + /// Currently selected Platform address index + pub selected_platform_index: usize, + pub qr_texture: Option, + pub qr_address: Option, + pub status: Option, +} + +/// State for the Fund Platform Address from Asset Lock dialog +#[derive(Default)] +pub(super) struct FundPlatformAddressDialogState { + pub is_open: bool, + /// Selected asset lock index + pub selected_asset_lock_index: Option, + /// Selected Platform address to fund + pub selected_platform_address: Option, + /// List of Platform addresses available + pub platform_addresses: Vec<(String, u64)>, + pub status: Option, + /// Whether the current status is an error message + pub status_is_error: bool, + pub is_processing: bool, + /// Whether we should continue funding after the wallet is unlocked + pub pending_fund_after_unlock: bool, +} + +/// State for the Private Key dialog +#[derive(Default)] +pub(super) struct PrivateKeyDialogState { + pub is_open: bool, + /// The address being displayed + pub address: String, + /// The private key in WIF format + pub private_key_wif: String, + /// Whether to show the private key (hidden by default) + pub show_key: bool, + /// Pending derivation path (when wallet needs unlock first) + pub pending_derivation_path: Option, + /// Pending address string (when wallet needs unlock first) + pub pending_address: Option, +} + +impl WalletsBalancesScreen { + pub(super) fn draw_modal_overlay(ctx: &Context, id: &str) { + let screen_rect = ctx.screen_rect(); + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new(id), + )); + painter.rect_filled( + screen_rect, + 0.0, + egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), + ); + } + + pub(super) fn modal_frame(ctx: &Context) -> Frame { + Frame { + inner_margin: egui::Margin::same(20), + outer_margin: egui::Margin::same(0), + corner_radius: egui::CornerRadius::same(8), + shadow: egui::epaint::Shadow { + offset: [0, 8], + blur: 16, + spread: 0, + color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), + }, + fill: ctx.style().visuals.window_fill, + stroke: egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), + ), + } + } + + pub(super) fn render_send_dialog(&mut self, ctx: &Context) -> AppAction { + if !self.send_dialog.is_open { + return AppAction::None; + } + + let mut action = AppAction::None; + let mut open = self.send_dialog.is_open; + egui::Window::new("Send Dash") + .collapsible(false) + .resizable(false) + .open(&mut open) + .show(ctx, |ui| { + ui.label("Recipient Address"); + ui.add(egui::TextEdit::singleline(&mut self.send_dialog.address).hint_text("y...")); + + ui.add_space(8.0); + + // Amount input using AmountInput component + let amount_input = self.send_dialog.amount_input.get_or_insert_with(|| { + AmountInput::new(Amount::new_dash(0.0)) + .with_label("Amount (DASH):") + .with_hint_text("Enter amount (e.g., 0.01)") + .with_desired_width(150.0) + }); + + let response = amount_input.show(ui); + response.inner.update(&mut self.send_dialog.amount); + + ui.checkbox( + &mut self.send_dialog.subtract_fee, + "Subtract fee from amount", + ); + + ui.label("Memo (optional)"); + ui.add(egui::TextEdit::singleline(&mut self.send_dialog.memo)); + + if let Some(error) = self.send_dialog.error.clone() { + let error_color = Color32::from_rgb(255, 100, 100); + Frame::new() + .fill(error_color.gamma_multiply(0.1)) + .inner_margin(Margin::symmetric(10, 8)) + .corner_radius(5.0) + .stroke(egui::Stroke::new(1.0, error_color)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Error: {}", error)).color(error_color), + ); + ui.add_space(10.0); + if ui.small_button("Dismiss").clicked() { + self.send_dialog.error = None; + } + }); + }); + } + + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Send").clicked() { + match self.prepare_send_action() { + Ok(app_action) => { + action = app_action; + self.send_dialog = SendDialogState::default(); + } + Err(err) => self.send_dialog.error = Some(err), + } + } + }); + }); + + self.send_dialog.is_open = open; + action + } + + pub(super) fn render_receive_dialog(&mut self, ctx: &Context) -> AppAction { + if !self.receive_dialog.is_open { + return AppAction::None; + } + + let dark_mode = ctx.style().visuals.dark_mode; + + // Determine current address based on selected type + let current_address = match self.receive_dialog.address_type { + ReceiveAddressType::Core => self + .receive_dialog + .core_addresses + .get(self.receive_dialog.selected_core_index) + .map(|(addr, _)| addr.clone()), + ReceiveAddressType::Platform => self + .receive_dialog + .platform_addresses + .get(self.receive_dialog.selected_platform_index) + .map(|(addr, _)| addr.clone()), + }; + + // Generate QR texture if needed + if let Some(address) = current_address.clone() { + let needs_texture = self.receive_dialog.qr_texture.is_none() + || self.receive_dialog.qr_address.as_deref() != Some(&address); + if needs_texture { + match generate_qr_code_image(&address) { + Ok(image) => { + let texture = ctx.load_texture( + format!("receive_{}", address), + image, + TextureOptions::LINEAR, + ); + self.receive_dialog.qr_texture = Some(texture); + self.receive_dialog.qr_address = Some(address); + } + Err(err) => { + self.receive_dialog.status = Some(err.to_string()); + } + } + } + } + + let mut open = self.receive_dialog.is_open; + + // Draw dark overlay behind the dialog (only when open) + if open { + Self::draw_modal_overlay(ctx, "receive_dialog_overlay"); + } + + egui::Window::new("Receive") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .open(&mut open) + .frame(Self::modal_frame(ctx)) + .show(ctx, |ui| { + ui.set_min_width(350.0); + ui.vertical_centered(|ui| { + ui.add_space(5.0); + + // Address type selector at the top + ui.horizontal(|ui| { + ui.selectable_value( + &mut self.receive_dialog.address_type, + ReceiveAddressType::Core, + RichText::new("Core").color(DashColors::text_primary(dark_mode)), + ); + ui.selectable_value( + &mut self.receive_dialog.address_type, + ReceiveAddressType::Platform, + RichText::new("Platform").color(DashColors::text_primary(dark_mode)), + ); + }); + + // Clear QR when switching types + let type_label = match self.receive_dialog.address_type { + ReceiveAddressType::Core => "Core Address", + ReceiveAddressType::Platform => "Platform Address", + }; + + ui.add_space(5.0); + ui.label( + RichText::new(type_label) + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + ui.add_space(10.0); + + // Show QR code + if let Some(texture) = &self.receive_dialog.qr_texture { + ui.image(SizedTexture::new(texture.id(), egui::vec2(220.0, 220.0))); + } else if current_address.is_some() { + ui.label("Generating QR code..."); + } + + ui.add_space(10.0); + + match self.receive_dialog.address_type { + ReceiveAddressType::Core => { + // Core address selector (if multiple addresses) + if self.receive_dialog.core_addresses.len() > 1 { + ui.horizontal(|ui| { + ui.label("Address:"); + ComboBox::from_id_salt("core_addr_selector") + .selected_text( + self.receive_dialog + .core_addresses + .get(self.receive_dialog.selected_core_index) + .map(|(addr, balance)| { + let balance_dash = *balance as f64 / 1e8; + format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + balance_dash + ) + }) + .unwrap_or_default(), + ) + .show_ui(ui, |ui| { + for (idx, (addr, balance)) in + self.receive_dialog.core_addresses.iter().enumerate() + { + let balance_dash = *balance as f64 / 1e8; + let label = format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + balance_dash + ); + if ui + .selectable_label( + idx == self.receive_dialog.selected_core_index, + label, + ) + .clicked() + { + self.receive_dialog.selected_core_index = idx; + // Clear QR so it regenerates + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + } + } + }); + }); + ui.add_space(5.0); + } + + // Show selected Core address + if let Some((address, balance)) = self + .receive_dialog + .core_addresses + .get(self.receive_dialog.selected_core_index) + .cloned() + { + ui.label( + RichText::new(&address) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ); + + let balance_dash = balance as f64 / 1e8; + ui.label( + RichText::new(format!("Balance: {:.8} DASH", balance_dash)) + .color(DashColors::text_secondary(dark_mode)), + ); + + ui.add_space(8.0); + + let mut copy_status: Option = None; + let mut generate_new = false; + + ui.horizontal(|ui| { + if ui.button("Copy Address").clicked() { + if let Err(err) = copy_text_to_clipboard(&address) { + copy_status = Some(format!("Error: {}", err)); + } else { + copy_status = Some("Address copied!".to_string()); + } + } + + if ui.button("New Address").clicked() { + generate_new = true; + } + }); + + if let Some(status) = copy_status { + self.receive_dialog.status = Some(status); + } + + if generate_new + && let Some(wallet) = &self.selected_wallet { + match self.generate_new_core_receive_address(wallet) { + Ok((new_addr, new_balance)) => { + self.receive_dialog.core_addresses.push((new_addr, new_balance)); + self.receive_dialog.selected_core_index = + self.receive_dialog.core_addresses.len() - 1; + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + self.receive_dialog.status = Some("New address generated!".to_string()); + } + Err(err) => { + self.receive_dialog.status = Some(err); + } + } + } + } + + ui.add_space(10.0); + ui.label( + RichText::new("Send Dash to this address to add funds to your wallet.") + .color(DashColors::text_secondary(dark_mode)) + .size(11.0) + .italics(), + ); + } + ReceiveAddressType::Platform => { + // Platform address selector (if multiple addresses) + if self.receive_dialog.platform_addresses.len() > 1 { + ui.horizontal(|ui| { + ui.label("Address:"); + ComboBox::from_id_salt("platform_addr_selector") + .selected_text( + self.receive_dialog + .platform_addresses + .get(self.receive_dialog.selected_platform_index) + .map(|(addr, balance)| { + let credits_as_dash = + *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + credits_as_dash + ) + }) + .unwrap_or_default(), + ) + .show_ui(ui, |ui| { + for (idx, (addr, balance)) in + self.receive_dialog.platform_addresses.iter().enumerate() + { + let credits_as_dash = + *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + let label = format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + credits_as_dash + ); + if ui + .selectable_label( + idx == self.receive_dialog.selected_platform_index, + label, + ) + .clicked() + { + self.receive_dialog.selected_platform_index = idx; + // Clear QR so it regenerates + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + } + } + }); + }); + ui.add_space(5.0); + } + + // Show selected Platform address + let selected_addr_data = self + .receive_dialog + .platform_addresses + .get(self.receive_dialog.selected_platform_index) + .cloned(); + + if let Some((address, balance)) = selected_addr_data { + ui.label( + RichText::new(&address) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ); + + let credits_as_dash = balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.label( + RichText::new(format!("Balance: {:.8} DASH", credits_as_dash)) + .color(DashColors::text_secondary(dark_mode)), + ); + + ui.add_space(8.0); + + let mut copy_status: Option = None; + let mut new_addr_result: Option> = None; + + ui.horizontal(|ui| { + if ui.button("Copy Address").clicked() { + if let Err(err) = copy_text_to_clipboard(&address) { + copy_status = Some(format!("Error: {}", err)); + } else { + copy_status = Some("Address copied!".to_string()); + } + } + + // Button to add new Platform address + if let Some(wallet) = &self.selected_wallet + && ui.button("New Address").clicked() + { + new_addr_result = Some(self.generate_platform_address(wallet)); + } + }); + + // Handle copy status after the closure + if let Some(status) = copy_status { + self.receive_dialog.status = Some(status); + } + + // Handle new address generation after the closure + if let Some(result) = new_addr_result { + match result { + Ok(new_addr) => { + self.receive_dialog.platform_addresses.push((new_addr, 0)); + self.receive_dialog.selected_platform_index = + self.receive_dialog.platform_addresses.len() - 1; + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + self.receive_dialog.status = + Some("New address generated!".to_string()); + } + Err(err) => { + self.receive_dialog.status = Some(err); + } + } + } + } + + ui.add_space(10.0); + ui.label( + RichText::new( + "Send credits from an identity or another Platform address to fund this address.", + ) + .color(DashColors::text_secondary(dark_mode)) + .size(11.0) + .italics(), + ); + } + } + + if let Some(status) = &self.receive_dialog.status { + ui.add_space(8.0); + ui.label( + RichText::new(status).color(DashColors::text_secondary(dark_mode)), + ); + } + }); + }); + + self.receive_dialog.is_open = open; + if !self.receive_dialog.is_open { + self.receive_dialog = ReceiveDialogState::default(); + } + AppAction::None + } + + /// Generate a new Platform address for the wallet. + /// Returns the address in Bech32m format (e.g., tevo1... for testnet) + pub(super) fn generate_platform_address( + &self, + wallet: &Arc>, + ) -> Result { + use dash_sdk::dpp::address_funds::PlatformAddress; + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + // Pass true to skip known addresses and generate a new one + let address = wallet_guard + .platform_receive_address(self.app_context.network, true, Some(&self.app_context)) + .map_err(|e| e.to_string())?; + // Convert to PlatformAddress and encode as Bech32m per DIP-18 + let platform_addr = + PlatformAddress::try_from(address).map_err(|e| format!("Invalid address: {}", e))?; + Ok(platform_addr.to_bech32m_string(self.app_context.network)) + } + + /// Generate a new Core receive address for the wallet + /// Returns (address_string, balance_duffs) + pub(super) fn generate_new_core_receive_address( + &self, + wallet: &Arc>, + ) -> Result<(String, u64), String> { + let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; + let address = wallet_guard + .receive_address(self.app_context.network, true, Some(&self.app_context)) + .map_err(|e| e.to_string())?; + let balance = wallet_guard + .address_balances + .get(&address) + .copied() + .unwrap_or(0); + Ok((address.to_string(), balance)) + } + + /// Render the Fund Platform Address from Asset Lock dialog + pub(super) fn render_fund_platform_dialog(&mut self, ctx: &Context) -> AppAction { + if !self.fund_platform_dialog.is_open { + return AppAction::None; + } + + let mut action = AppAction::None; + let mut open = self.fund_platform_dialog.is_open; + let dark_mode = ctx.style().visuals.dark_mode; + + // Draw dark overlay behind the popup + Self::draw_modal_overlay(ctx, "fund_platform_dialog_overlay"); + + egui::Window::new("Fund Platform Address from Asset Lock") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .open(&mut open) + .frame(Self::modal_frame(ctx)) + .show(ctx, |ui| { + ui.set_min_width(400.0); + + ui.vertical(|ui| { + ui.label( + RichText::new("Select a Platform address to fund:") + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(10.0); + + // Platform address selector + if self.fund_platform_dialog.platform_addresses.is_empty() { + ui.label( + RichText::new("No Platform addresses found. Generate one first.") + .color(DashColors::text_secondary(dark_mode)) + .italics(), + ); + } else { + ComboBox::from_id_salt("fund_platform_addr_selector") + .selected_text( + self.fund_platform_dialog + .selected_platform_address + .as_deref() + .map(|addr| { + let balance = self + .fund_platform_dialog + .platform_addresses + .iter() + .find(|(a, _)| a == addr) + .map(|(_, b)| *b) + .unwrap_or(0); + let credits_as_dash = + balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + credits_as_dash + ) + }) + .unwrap_or_else(|| "Select an address".to_string()), + ) + .show_ui(ui, |ui| { + for (addr, balance) in &self.fund_platform_dialog.platform_addresses + { + let credits_as_dash = + *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + let label = format!( + "{}... ({:.4} DASH)", + &addr[..12.min(addr.len())], + credits_as_dash + ); + let is_selected = self + .fund_platform_dialog + .selected_platform_address + .as_deref() + == Some(addr.as_str()); + if ui.selectable_label(is_selected, label).clicked() { + self.fund_platform_dialog.selected_platform_address = + Some(addr.clone()); + } + } + }); + } + + ui.add_space(15.0); + + // Status message + if let Some(status) = &self.fund_platform_dialog.status { + let status_color = if self.fund_platform_dialog.status_is_error { + egui::Color32::from_rgb(220, 50, 50) + } else { + DashColors::text_secondary(dark_mode) + }; + ui.label(RichText::new(status).color(status_color)); + ui.add_space(10.0); + } + + // Buttons + ui.horizontal(|ui| { + let can_fund = self.fund_platform_dialog.selected_platform_address.is_some() + && self.fund_platform_dialog.selected_asset_lock_index.is_some() + && !self.fund_platform_dialog.is_processing; + + // Cancel button + let cancel_button = egui::Button::new( + RichText::new("Cancel").color(DashColors::text_primary(dark_mode)), + ) + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::new(1.0, DashColors::text_secondary(dark_mode))) + .corner_radius(egui::CornerRadius::same(4)) + .min_size(egui::Vec2::new(80.0, 32.0)); + + if ui.add(cancel_button).clicked() { + self.fund_platform_dialog.is_open = false; + } + + ui.add_space(8.0); + + // Fund button + let fund_button = egui::Button::new( + RichText::new(if self.fund_platform_dialog.is_processing { + "Funding..." + } else { + "Fund Address" + }) + .color(egui::Color32::WHITE), + ) + .fill(if can_fund { + DashColors::DASH_BLUE + } else { + DashColors::text_secondary(dark_mode) + }) + .corner_radius(egui::CornerRadius::same(4)) + .min_size(egui::Vec2::new(100.0, 32.0)); + + if ui.add_enabled(can_fund, fund_button).clicked() { + // Check if wallet is locked + let is_locked = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|w| !w.is_open()) + .unwrap_or(false); + + if is_locked { + // Wallet is locked - open unlock popup and set pending flag + self.fund_platform_dialog.pending_fund_after_unlock = true; + self.wallet_unlock_popup.open(); + } else { + action = self.prepare_fund_platform_action(); + } + } + }); + + ui.add_space(10.0); + ui.label( + RichText::new( + "The entire asset lock amount will be used to fund the Platform address.", + ) + .color(DashColors::text_secondary(dark_mode)) + .size(11.0) + .italics(), + ); + }); + }); + + // Only update from `open` if we didn't manually close via cancel button + if self.fund_platform_dialog.is_open { + self.fund_platform_dialog.is_open = open; + } + if !self.fund_platform_dialog.is_open { + self.fund_platform_dialog = FundPlatformAddressDialogState::default(); + } + action + } + + /// Render the Private Key dialog + pub(super) fn render_private_key_dialog(&mut self, ctx: &Context) { + if !self.private_key_dialog.is_open { + return; + } + + let dark_mode = ctx.style().visuals.dark_mode; + let mut open = self.private_key_dialog.is_open; + + // Draw dark overlay behind the dialog + if open { + Self::draw_modal_overlay(ctx, "private_key_dialog_overlay"); + } + + egui::Window::new("Private Key") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .open(&mut open) + .frame(Self::modal_frame(ctx)) + .show(ctx, |ui| { + ui.set_min_width(400.0); + ui.vertical_centered(|ui| { + ui.add_space(5.0); + + // Address label + ui.label( + RichText::new("Address") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + ui.add_space(5.0); + + // Address value + ui.label( + RichText::new(&self.private_key_dialog.address) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ); + + ui.add_space(5.0); + + // Copy address button + if ui.button("Copy Address").clicked() { + let _ = copy_text_to_clipboard(&self.private_key_dialog.address); + } + + ui.add_space(15.0); + ui.separator(); + ui.add_space(15.0); + + // Private key label + ui.label( + RichText::new("Private Key (WIF)") + .color(DashColors::text_secondary(dark_mode)) + .size(12.0), + ); + ui.add_space(5.0); + + // Private key value (hidden by default) + if self.private_key_dialog.show_key { + ui.label( + RichText::new(&self.private_key_dialog.private_key_wif) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ); + } else { + ui.label( + RichText::new("••••••••••••••••••••••••••••••••••••••••••••••••••••") + .monospace() + .color(DashColors::text_secondary(dark_mode)), + ); + } + + ui.add_space(10.0); + + // Show/Hide and Copy buttons + ui.horizontal(|ui| { + if ui + .button(if self.private_key_dialog.show_key { + "Hide Key" + } else { + "Show Key" + }) + .clicked() + { + self.private_key_dialog.show_key = !self.private_key_dialog.show_key; + } + + if ui.button("Copy Key").clicked() { + let _ = + copy_text_to_clipboard(&self.private_key_dialog.private_key_wif); + } + }); + + ui.add_space(15.0); + + // Warning message + ui.label( + RichText::new("Keep your private key secure. Never share it with anyone.") + .color(DashColors::error_color(dark_mode)) + .size(11.0) + .italics(), + ); + }); + }); + + self.private_key_dialog.is_open = open; + if !self.private_key_dialog.is_open { + self.private_key_dialog = PrivateKeyDialogState::default(); + } + } + + /// Prepare the backend task for funding a Platform address from asset lock + pub(super) fn prepare_fund_platform_action(&mut self) -> AppAction { + use dash_sdk::dpp::address_funds::PlatformAddress; + use std::collections::BTreeMap; + + let Some(wallet_arc) = &self.selected_wallet else { + self.fund_platform_dialog.status = Some("No wallet selected".to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + }; + + let Some(selected_addr) = &self.fund_platform_dialog.selected_platform_address else { + self.fund_platform_dialog.status = Some("Select a Platform address".to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + }; + + let Some(asset_lock_idx) = self.fund_platform_dialog.selected_asset_lock_index else { + self.fund_platform_dialog.status = Some("No asset lock selected".to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + }; + + // Get the asset lock proof and address from the wallet + let (seed_hash, asset_lock_proof, asset_lock_address, platform_addr) = { + let wallet = match wallet_arc.read() { + Ok(guard) => guard, + Err(e) => { + self.fund_platform_dialog.status = Some(e.to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + } + }; + + let asset_lock = wallet.unused_asset_locks.get(asset_lock_idx); + let Some((_, addr, _, _, Some(proof))) = asset_lock else { + self.fund_platform_dialog.status = + Some("Asset lock not found or not ready".to_string()); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + }; + + // Parse the Platform address (Bech32m format: evo1.../tevo1...) + let platform_addr = if selected_addr.starts_with("evo1") + || selected_addr.starts_with("tevo1") + { + match PlatformAddress::from_bech32m_string(selected_addr) { + Ok((addr, network)) => { + // Validate that address network matches app network + if network != self.app_context.network { + self.fund_platform_dialog.status = Some(format!( + "Address network mismatch: address is for {:?} but app is on {:?}", + network, self.app_context.network + )); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + } + addr + } + Err(e) => { + self.fund_platform_dialog.status = + Some(format!("Invalid Bech32m address: {}", e)); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + } + } + } else { + // Fall back to base58 parsing for backwards compatibility + match selected_addr + .parse::>() + .map_err(|e| e.to_string()) + .and_then(|a: Address| { + PlatformAddress::try_from(a.assume_checked()) + .map_err(|e| format!("Invalid Platform address: {}", e)) + }) { + Ok(addr) => addr, + Err(e) => { + self.fund_platform_dialog.status = Some(e); + self.fund_platform_dialog.status_is_error = true; + return AppAction::None; + } + } + }; + + ( + wallet.seed_hash(), + Box::new(proof.clone()), + addr.clone(), + platform_addr, + ) + }; + + // Build outputs - fund the entire asset lock to the selected Platform address + let mut outputs: BTreeMap> = BTreeMap::new(); + outputs.insert(platform_addr, None); // None = take the full amount + + self.fund_platform_dialog.is_processing = true; + self.fund_platform_dialog.status = Some("Processing...".to_string()); + self.fund_platform_dialog.status_is_error = false; + + AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::FundPlatformAddressFromAssetLock { + seed_hash, + asset_lock_proof, + asset_lock_address, + outputs, + }, + )) + } + + pub(super) fn prepare_send_action(&mut self) -> Result { + let wallet = self + .selected_wallet + .as_ref() + .ok_or_else(|| "Select a wallet first".to_string())?; + + let amount_duffs = self + .send_dialog + .amount + .as_ref() + .ok_or_else(|| "Enter an amount".to_string())? + .dash_to_duffs()?; + + if amount_duffs == 0 { + return Err("Amount must be greater than 0".to_string()); + } + + { + let wallet_guard = wallet.read().map_err(|e| e.to_string())?; + if amount_duffs > wallet_guard.confirmed_balance_duffs() { + return Err("Insufficient confirmed balance".to_string()); + } + } + + if self.send_dialog.address.trim().is_empty() { + return Err("Enter a recipient address".to_string()); + } + + let memo = self.send_dialog.memo.trim(); + let request = WalletPaymentRequest { + recipients: vec![PaymentRecipient { + address: self.send_dialog.address.trim().to_string(), + amount_duffs, + }], + subtract_fee_from_amount: self.send_dialog.subtract_fee, + memo: if memo.is_empty() { + None + } else { + Some(memo.to_string()) + }, + override_fee: None, + }; + + Ok(AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::SendWalletPayment { + wallet: wallet.clone(), + request, + }, + ))) + } + + pub(super) fn open_receive_dialog(&mut self, _ctx: &Context) -> AppAction { + let Some(wallet) = self.selected_wallet.clone() else { + self.receive_dialog.status = Some("Select a wallet first".to_string()); + self.receive_dialog.core_addresses.clear(); + self.receive_dialog.platform_addresses.clear(); + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + self.receive_dialog.is_open = true; + return AppAction::None; + }; + + self.receive_dialog.is_open = true; + self.receive_dialog.qr_texture = None; + self.receive_dialog.qr_address = None; + + // Load Core addresses (works with locked wallet - uses existing addresses) + self.load_core_addresses_for_receive(&wallet); + + // Load Platform addresses (works with locked wallet - uses existing addresses) + self.load_platform_addresses_for_receive(&wallet); + + AppAction::None + } + + /// Load Core addresses into the receive dialog + fn load_core_addresses_for_receive(&mut self, wallet: &Arc>) { + let wallet_guard = match wallet.read() { + Ok(guard) => guard, + Err(err) => { + self.receive_dialog.status = Some(err.to_string()); + return; + } + }; + + // Collect all BIP44 external (receive) addresses with their balances + let network = self.app_context.network; + let core_addresses: Vec<(String, u64)> = wallet_guard + .watched_addresses + .iter() + .filter(|(path, _)| path.is_bip44_external(network)) + .map(|(_, info)| { + let balance = wallet_guard + .address_balances + .get(&info.address) + .copied() + .unwrap_or(0); + (info.address.to_string(), balance) + }) + .collect(); + + drop(wallet_guard); + + if core_addresses.is_empty() { + // Generate a new Core address if none exists + match self.generate_new_core_receive_address(wallet) { + Ok((address, balance)) => { + self.receive_dialog.core_addresses = vec![(address, balance)]; + self.receive_dialog.selected_core_index = 0; + } + Err(err) => { + self.receive_dialog.status = Some(err); + self.receive_dialog.core_addresses.clear(); + } + } + } else { + self.receive_dialog.core_addresses = core_addresses; + self.receive_dialog.selected_core_index = 0; + } + } + + /// Load Platform addresses into the receive dialog + fn load_platform_addresses_for_receive(&mut self, wallet: &Arc>) { + let wallet_guard = match wallet.read() { + Ok(guard) => guard, + Err(err) => { + self.receive_dialog.status = Some(err.to_string()); + return; + } + }; + + // Collect Platform addresses with their balances (using DIP-18 Bech32m format) + // Use platform_addresses() which checks watched_addresses, not just platform_address_info + // This includes addresses that have been derived but may not have been synced yet + let network = self.app_context.network; + let platform_addresses: Vec<(String, u64)> = wallet_guard + .platform_addresses(network) + .into_iter() + .map(|(core_addr, platform_addr)| { + let balance = wallet_guard + .get_platform_address_info(&core_addr) + .map(|info| info.balance) + .unwrap_or(0); + (platform_addr.to_bech32m_string(network), balance) + }) + .collect(); + + drop(wallet_guard); + + if platform_addresses.is_empty() { + // Generate a new Platform address if none exists + match self.generate_platform_address(wallet) { + Ok(address) => { + self.receive_dialog.platform_addresses = vec![(address, 0)]; + self.receive_dialog.selected_platform_index = 0; + } + Err(err) => { + self.receive_dialog.status = Some(err); + self.receive_dialog.platform_addresses.clear(); + } + } + } else { + self.receive_dialog.platform_addresses = platform_addresses; + self.receive_dialog.selected_platform_index = 0; + } + } + + pub(super) fn derive_private_key_wif(&self, path: &DerivationPath) -> Result { + let wallet_arc = self + .selected_wallet + .clone() + .ok_or_else(|| "Select a wallet first".to_string())?; + let wallet = wallet_arc.read().map_err(|e| e.to_string())?; + if wallet.uses_password && !wallet.is_open() { + return Err("Unlock this wallet to view private keys.".to_string()); + } + let private_key = wallet.private_key_at_derivation_path(path, self.app_context.network)?; + Ok(private_key.to_wif()) + } +} diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 62a756543..3fb3ce019 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1,22 +1,21 @@ +mod dialogs; + use crate::app::{AppAction, DesiredAppAction}; use crate::backend_task::BackendTask; -use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest}; -use crate::backend_task::wallet::WalletTask; +use crate::backend_task::core::CoreTask; use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::wallet::{ DerivationPathHelpers, DerivationPathReference, Wallet, WalletSeedHash, WalletTransaction, }; use crate::spv::CoreBackendMode; -use crate::ui::components::amount_input::AmountInput; -use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{WalletUnlockPopup, WalletUnlockResult}; use crate::ui::helpers::copy_text_to_clipboard; -use crate::ui::identities::funding_common::generate_qr_code_image; use crate::ui::theme::DashColors; use crate::ui::wallets::account_summary::{ AccountCategory, AccountSummary, collect_account_summaries, @@ -27,13 +26,14 @@ use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath}; use eframe::egui::{self, ComboBox, Context, Ui}; -use eframe::epaint::TextureHandle; -use egui::load::SizedTexture; -use egui::{Color32, Frame, Margin, RichText, TextureOptions}; +use egui::{Color32, Frame, Margin, RichText}; use egui_extras::{Column, TableBuilder}; use std::sync::{Arc, RwLock}; use crate::model::wallet::single_key::SingleKeyWallet; +use dialogs::{ + FundPlatformAddressDialogState, PrivateKeyDialogState, ReceiveDialogState, SendDialogState, +}; #[derive(Clone, Copy, PartialEq, Eq)] enum SortColumn { @@ -161,80 +161,6 @@ impl AddressData { } } -#[derive(Default)] -struct SendDialogState { - is_open: bool, - address: String, - amount: Option, - amount_input: Option, - subtract_fee: bool, - memo: String, - error: Option, -} - -/// Type of address to receive to -#[derive(Default, Clone, Copy, PartialEq, Eq)] -enum ReceiveAddressType { - /// Core (L1) address for receiving Dash - #[default] - Core, - /// Platform address for receiving credits - Platform, -} - -/// Unified state for the receive dialog (Core and Platform) -#[derive(Default)] -struct ReceiveDialogState { - is_open: bool, - /// Selected address type (Core or Platform) - address_type: ReceiveAddressType, - /// Core addresses with balances: (address, balance_duffs) - core_addresses: Vec<(String, u64)>, - /// Currently selected Core address index - selected_core_index: usize, - /// Platform addresses with balances: (display_address, balance_credits) - platform_addresses: Vec<(String, u64)>, - /// Currently selected Platform address index - selected_platform_index: usize, - qr_texture: Option, - qr_address: Option, - status: Option, -} - -/// State for the Fund Platform Address from Asset Lock dialog -#[derive(Default)] -struct FundPlatformAddressDialogState { - is_open: bool, - /// Selected asset lock index - selected_asset_lock_index: Option, - /// Selected Platform address to fund - selected_platform_address: Option, - /// List of Platform addresses available - platform_addresses: Vec<(String, u64)>, - status: Option, - /// Whether the current status is an error message - status_is_error: bool, - is_processing: bool, - /// Whether we should continue funding after the wallet is unlocked - pending_fund_after_unlock: bool, -} - -/// State for the Private Key dialog -#[derive(Default)] -struct PrivateKeyDialogState { - is_open: bool, - /// The address being displayed - address: String, - /// The private key in WIF format - private_key_wif: String, - /// Whether to show the private key (hidden by default) - show_key: bool, - /// Pending derivation path (when wallet needs unlock first) - pending_derivation_path: Option, - /// Pending address string (when wallet needs unlock first) - pending_address: Option, -} - impl WalletsBalancesScreen { pub fn new(app_context: &Arc) -> Self { // Try to restore previously selected wallet from AppContext @@ -1779,1070 +1705,6 @@ impl WalletsBalancesScreen { action } - fn draw_modal_overlay(ctx: &Context, id: &str) { - let screen_rect = ctx.screen_rect(); - let painter = ctx.layer_painter(egui::LayerId::new( - egui::Order::Background, - egui::Id::new(id), - )); - painter.rect_filled( - screen_rect, - 0.0, - egui::Color32::from_rgba_unmultiplied(0, 0, 0, 120), - ); - } - - fn modal_frame(ctx: &Context) -> Frame { - Frame { - inner_margin: egui::Margin::same(20), - outer_margin: egui::Margin::same(0), - corner_radius: egui::CornerRadius::same(8), - shadow: egui::epaint::Shadow { - offset: [0, 8], - blur: 16, - spread: 0, - color: egui::Color32::from_rgba_unmultiplied(0, 0, 0, 100), - }, - fill: ctx.style().visuals.window_fill, - stroke: egui::Stroke::new( - 1.0, - egui::Color32::from_rgba_unmultiplied(255, 255, 255, 30), - ), - } - } - - fn render_send_dialog(&mut self, ctx: &Context) -> AppAction { - if !self.send_dialog.is_open { - return AppAction::None; - } - - let mut action = AppAction::None; - let mut open = self.send_dialog.is_open; - egui::Window::new("Send Dash") - .collapsible(false) - .resizable(false) - .open(&mut open) - .show(ctx, |ui| { - ui.label("Recipient Address"); - ui.add(egui::TextEdit::singleline(&mut self.send_dialog.address).hint_text("y...")); - - ui.add_space(8.0); - - // Amount input using AmountInput component - let amount_input = self.send_dialog.amount_input.get_or_insert_with(|| { - AmountInput::new(Amount::new_dash(0.0)) - .with_label("Amount (DASH):") - .with_hint_text("Enter amount (e.g., 0.01)") - .with_desired_width(150.0) - }); - - let response = amount_input.show(ui); - response.inner.update(&mut self.send_dialog.amount); - - ui.checkbox( - &mut self.send_dialog.subtract_fee, - "Subtract fee from amount", - ); - - ui.label("Memo (optional)"); - ui.add(egui::TextEdit::singleline(&mut self.send_dialog.memo)); - - if let Some(error) = self.send_dialog.error.clone() { - let error_color = Color32::from_rgb(255, 100, 100); - Frame::new() - .fill(error_color.gamma_multiply(0.1)) - .inner_margin(Margin::symmetric(10, 8)) - .corner_radius(5.0) - .stroke(egui::Stroke::new(1.0, error_color)) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(format!("Error: {}", error)).color(error_color), - ); - ui.add_space(10.0); - if ui.small_button("Dismiss").clicked() { - self.send_dialog.error = None; - } - }); - }); - } - - ui.add_space(8.0); - ui.horizontal(|ui| { - if ui.button("Send").clicked() { - match self.prepare_send_action() { - Ok(app_action) => { - action = app_action; - self.send_dialog = SendDialogState::default(); - } - Err(err) => self.send_dialog.error = Some(err), - } - } - }); - }); - - self.send_dialog.is_open = open; - action - } - - fn render_receive_dialog(&mut self, ctx: &Context) -> AppAction { - if !self.receive_dialog.is_open { - return AppAction::None; - } - - let dark_mode = ctx.style().visuals.dark_mode; - - // Determine current address based on selected type - let current_address = match self.receive_dialog.address_type { - ReceiveAddressType::Core => self - .receive_dialog - .core_addresses - .get(self.receive_dialog.selected_core_index) - .map(|(addr, _)| addr.clone()), - ReceiveAddressType::Platform => self - .receive_dialog - .platform_addresses - .get(self.receive_dialog.selected_platform_index) - .map(|(addr, _)| addr.clone()), - }; - - // Generate QR texture if needed - if let Some(address) = current_address.clone() { - let needs_texture = self.receive_dialog.qr_texture.is_none() - || self.receive_dialog.qr_address.as_deref() != Some(&address); - if needs_texture { - match generate_qr_code_image(&address) { - Ok(image) => { - let texture = ctx.load_texture( - format!("receive_{}", address), - image, - TextureOptions::LINEAR, - ); - self.receive_dialog.qr_texture = Some(texture); - self.receive_dialog.qr_address = Some(address); - } - Err(err) => { - self.receive_dialog.status = Some(err.to_string()); - } - } - } - } - - let mut open = self.receive_dialog.is_open; - - // Draw dark overlay behind the dialog (only when open) - if open { - Self::draw_modal_overlay(ctx, "receive_dialog_overlay"); - } - - egui::Window::new("Receive") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .open(&mut open) - .frame(Self::modal_frame(ctx)) - .show(ctx, |ui| { - ui.set_min_width(350.0); - ui.vertical_centered(|ui| { - ui.add_space(5.0); - - // Address type selector at the top - ui.horizontal(|ui| { - ui.selectable_value( - &mut self.receive_dialog.address_type, - ReceiveAddressType::Core, - RichText::new("Core").color(DashColors::text_primary(dark_mode)), - ); - ui.selectable_value( - &mut self.receive_dialog.address_type, - ReceiveAddressType::Platform, - RichText::new("Platform").color(DashColors::text_primary(dark_mode)), - ); - }); - - // Clear QR when switching types - let type_label = match self.receive_dialog.address_type { - ReceiveAddressType::Core => "Core Address", - ReceiveAddressType::Platform => "Platform Address", - }; - - ui.add_space(5.0); - ui.label( - RichText::new(type_label) - .color(DashColors::text_secondary(dark_mode)) - .size(12.0), - ); - ui.add_space(10.0); - - // Show QR code - if let Some(texture) = &self.receive_dialog.qr_texture { - ui.image(SizedTexture::new(texture.id(), egui::vec2(220.0, 220.0))); - } else if current_address.is_some() { - ui.label("Generating QR code..."); - } - - ui.add_space(10.0); - - match self.receive_dialog.address_type { - ReceiveAddressType::Core => { - // Core address selector (if multiple addresses) - if self.receive_dialog.core_addresses.len() > 1 { - ui.horizontal(|ui| { - ui.label("Address:"); - ComboBox::from_id_salt("core_addr_selector") - .selected_text( - self.receive_dialog - .core_addresses - .get(self.receive_dialog.selected_core_index) - .map(|(addr, balance)| { - let balance_dash = *balance as f64 / 1e8; - format!( - "{}... ({:.4} DASH)", - &addr[..12.min(addr.len())], - balance_dash - ) - }) - .unwrap_or_default(), - ) - .show_ui(ui, |ui| { - for (idx, (addr, balance)) in - self.receive_dialog.core_addresses.iter().enumerate() - { - let balance_dash = *balance as f64 / 1e8; - let label = format!( - "{}... ({:.4} DASH)", - &addr[..12.min(addr.len())], - balance_dash - ); - if ui - .selectable_label( - idx == self.receive_dialog.selected_core_index, - label, - ) - .clicked() - { - self.receive_dialog.selected_core_index = idx; - // Clear QR so it regenerates - self.receive_dialog.qr_texture = None; - self.receive_dialog.qr_address = None; - } - } - }); - }); - ui.add_space(5.0); - } - - // Show selected Core address - if let Some((address, balance)) = self - .receive_dialog - .core_addresses - .get(self.receive_dialog.selected_core_index) - .cloned() - { - ui.label( - RichText::new(&address) - .monospace() - .color(DashColors::text_primary(dark_mode)), - ); - - let balance_dash = balance as f64 / 1e8; - ui.label( - RichText::new(format!("Balance: {:.8} DASH", balance_dash)) - .color(DashColors::text_secondary(dark_mode)), - ); - - ui.add_space(8.0); - - let mut copy_status: Option = None; - let mut generate_new = false; - - ui.horizontal(|ui| { - if ui.button("Copy Address").clicked() { - if let Err(err) = copy_text_to_clipboard(&address) { - copy_status = Some(format!("Error: {}", err)); - } else { - copy_status = Some("Address copied!".to_string()); - } - } - - if ui.button("New Address").clicked() { - generate_new = true; - } - }); - - if let Some(status) = copy_status { - self.receive_dialog.status = Some(status); - } - - if generate_new - && let Some(wallet) = &self.selected_wallet { - match self.generate_new_core_receive_address(wallet) { - Ok((new_addr, new_balance)) => { - self.receive_dialog.core_addresses.push((new_addr, new_balance)); - self.receive_dialog.selected_core_index = - self.receive_dialog.core_addresses.len() - 1; - self.receive_dialog.qr_texture = None; - self.receive_dialog.qr_address = None; - self.receive_dialog.status = Some("New address generated!".to_string()); - } - Err(err) => { - self.receive_dialog.status = Some(err); - } - } - } - } - - ui.add_space(10.0); - ui.label( - RichText::new("Send Dash to this address to add funds to your wallet.") - .color(DashColors::text_secondary(dark_mode)) - .size(11.0) - .italics(), - ); - } - ReceiveAddressType::Platform => { - // Platform address selector (if multiple addresses) - if self.receive_dialog.platform_addresses.len() > 1 { - ui.horizontal(|ui| { - ui.label("Address:"); - ComboBox::from_id_salt("platform_addr_selector") - .selected_text( - self.receive_dialog - .platform_addresses - .get(self.receive_dialog.selected_platform_index) - .map(|(addr, balance)| { - let credits_as_dash = - *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - format!( - "{}... ({:.4} DASH)", - &addr[..12.min(addr.len())], - credits_as_dash - ) - }) - .unwrap_or_default(), - ) - .show_ui(ui, |ui| { - for (idx, (addr, balance)) in - self.receive_dialog.platform_addresses.iter().enumerate() - { - let credits_as_dash = - *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - let label = format!( - "{}... ({:.4} DASH)", - &addr[..12.min(addr.len())], - credits_as_dash - ); - if ui - .selectable_label( - idx == self.receive_dialog.selected_platform_index, - label, - ) - .clicked() - { - self.receive_dialog.selected_platform_index = idx; - // Clear QR so it regenerates - self.receive_dialog.qr_texture = None; - self.receive_dialog.qr_address = None; - } - } - }); - }); - ui.add_space(5.0); - } - - // Show selected Platform address - let selected_addr_data = self - .receive_dialog - .platform_addresses - .get(self.receive_dialog.selected_platform_index) - .cloned(); - - if let Some((address, balance)) = selected_addr_data { - ui.label( - RichText::new(&address) - .monospace() - .color(DashColors::text_primary(dark_mode)), - ); - - let credits_as_dash = balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - ui.label( - RichText::new(format!("Balance: {:.8} DASH", credits_as_dash)) - .color(DashColors::text_secondary(dark_mode)), - ); - - ui.add_space(8.0); - - let mut copy_status: Option = None; - let mut new_addr_result: Option> = None; - - ui.horizontal(|ui| { - if ui.button("Copy Address").clicked() { - if let Err(err) = copy_text_to_clipboard(&address) { - copy_status = Some(format!("Error: {}", err)); - } else { - copy_status = Some("Address copied!".to_string()); - } - } - - // Button to add new Platform address - if let Some(wallet) = &self.selected_wallet - && ui.button("New Address").clicked() - { - new_addr_result = Some(self.generate_platform_address(wallet)); - } - }); - - // Handle copy status after the closure - if let Some(status) = copy_status { - self.receive_dialog.status = Some(status); - } - - // Handle new address generation after the closure - if let Some(result) = new_addr_result { - match result { - Ok(new_addr) => { - self.receive_dialog.platform_addresses.push((new_addr, 0)); - self.receive_dialog.selected_platform_index = - self.receive_dialog.platform_addresses.len() - 1; - self.receive_dialog.qr_texture = None; - self.receive_dialog.qr_address = None; - self.receive_dialog.status = - Some("New address generated!".to_string()); - } - Err(err) => { - self.receive_dialog.status = Some(err); - } - } - } - } - - ui.add_space(10.0); - ui.label( - RichText::new( - "Send credits from an identity or another Platform address to fund this address.", - ) - .color(DashColors::text_secondary(dark_mode)) - .size(11.0) - .italics(), - ); - } - } - - if let Some(status) = &self.receive_dialog.status { - ui.add_space(8.0); - ui.label( - RichText::new(status).color(DashColors::text_secondary(dark_mode)), - ); - } - }); - }); - - self.receive_dialog.is_open = open; - if !self.receive_dialog.is_open { - self.receive_dialog = ReceiveDialogState::default(); - } - AppAction::None - } - - /// Generate a new Platform address for the wallet. - /// Returns the address in Bech32m format (e.g., tevo1... for testnet) - fn generate_platform_address(&self, wallet: &Arc>) -> Result { - use dash_sdk::dpp::address_funds::PlatformAddress; - let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; - // Pass true to skip known addresses and generate a new one - let address = wallet_guard - .platform_receive_address(self.app_context.network, true, Some(&self.app_context)) - .map_err(|e| e.to_string())?; - // Convert to PlatformAddress and encode as Bech32m per DIP-18 - let platform_addr = - PlatformAddress::try_from(address).map_err(|e| format!("Invalid address: {}", e))?; - Ok(platform_addr.to_bech32m_string(self.app_context.network)) - } - - /// Generate a new Core receive address for the wallet - /// Returns (address_string, balance_duffs) - fn generate_new_core_receive_address( - &self, - wallet: &Arc>, - ) -> Result<(String, u64), String> { - let mut wallet_guard = wallet.write().map_err(|e| e.to_string())?; - let address = wallet_guard - .receive_address(self.app_context.network, true, Some(&self.app_context)) - .map_err(|e| e.to_string())?; - let balance = wallet_guard - .address_balances - .get(&address) - .copied() - .unwrap_or(0); - Ok((address.to_string(), balance)) - } - - /// Render the Fund Platform Address from Asset Lock dialog - fn render_fund_platform_dialog(&mut self, ctx: &Context) -> AppAction { - if !self.fund_platform_dialog.is_open { - return AppAction::None; - } - - let mut action = AppAction::None; - let mut open = self.fund_platform_dialog.is_open; - let dark_mode = ctx.style().visuals.dark_mode; - - // Draw dark overlay behind the popup - Self::draw_modal_overlay(ctx, "fund_platform_dialog_overlay"); - - egui::Window::new("Fund Platform Address from Asset Lock") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .open(&mut open) - .frame(Self::modal_frame(ctx)) - .show(ctx, |ui| { - ui.set_min_width(400.0); - - ui.vertical(|ui| { - ui.label( - RichText::new("Select a Platform address to fund:") - .color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(10.0); - - // Platform address selector - if self.fund_platform_dialog.platform_addresses.is_empty() { - ui.label( - RichText::new("No Platform addresses found. Generate one first.") - .color(DashColors::text_secondary(dark_mode)) - .italics(), - ); - } else { - ComboBox::from_id_salt("fund_platform_addr_selector") - .selected_text( - self.fund_platform_dialog - .selected_platform_address - .as_deref() - .map(|addr| { - let balance = self - .fund_platform_dialog - .platform_addresses - .iter() - .find(|(a, _)| a == addr) - .map(|(_, b)| *b) - .unwrap_or(0); - let credits_as_dash = - balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - format!( - "{}... ({:.4} DASH)", - &addr[..12.min(addr.len())], - credits_as_dash - ) - }) - .unwrap_or_else(|| "Select an address".to_string()), - ) - .show_ui(ui, |ui| { - for (addr, balance) in &self.fund_platform_dialog.platform_addresses - { - let credits_as_dash = - *balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - let label = format!( - "{}... ({:.4} DASH)", - &addr[..12.min(addr.len())], - credits_as_dash - ); - let is_selected = self - .fund_platform_dialog - .selected_platform_address - .as_deref() - == Some(addr.as_str()); - if ui.selectable_label(is_selected, label).clicked() { - self.fund_platform_dialog.selected_platform_address = - Some(addr.clone()); - } - } - }); - } - - ui.add_space(15.0); - - // Status message - if let Some(status) = &self.fund_platform_dialog.status { - let status_color = if self.fund_platform_dialog.status_is_error { - egui::Color32::from_rgb(220, 50, 50) - } else { - DashColors::text_secondary(dark_mode) - }; - ui.label(RichText::new(status).color(status_color)); - ui.add_space(10.0); - } - - // Buttons - ui.horizontal(|ui| { - let can_fund = self.fund_platform_dialog.selected_platform_address.is_some() - && self.fund_platform_dialog.selected_asset_lock_index.is_some() - && !self.fund_platform_dialog.is_processing; - - // Cancel button - let cancel_button = egui::Button::new( - RichText::new("Cancel").color(DashColors::text_primary(dark_mode)), - ) - .fill(egui::Color32::TRANSPARENT) - .stroke(egui::Stroke::new(1.0, DashColors::text_secondary(dark_mode))) - .corner_radius(egui::CornerRadius::same(4)) - .min_size(egui::Vec2::new(80.0, 32.0)); - - if ui.add(cancel_button).clicked() { - self.fund_platform_dialog.is_open = false; - } - - ui.add_space(8.0); - - // Fund button - let fund_button = egui::Button::new( - RichText::new(if self.fund_platform_dialog.is_processing { - "Funding..." - } else { - "Fund Address" - }) - .color(egui::Color32::WHITE), - ) - .fill(if can_fund { - DashColors::DASH_BLUE - } else { - DashColors::text_secondary(dark_mode) - }) - .corner_radius(egui::CornerRadius::same(4)) - .min_size(egui::Vec2::new(100.0, 32.0)); - - if ui.add_enabled(can_fund, fund_button).clicked() { - // Check if wallet is locked - let is_locked = self - .selected_wallet - .as_ref() - .and_then(|w| w.read().ok()) - .map(|w| !w.is_open()) - .unwrap_or(false); - - if is_locked { - // Wallet is locked - open unlock popup and set pending flag - self.fund_platform_dialog.pending_fund_after_unlock = true; - self.wallet_unlock_popup.open(); - } else { - action = self.prepare_fund_platform_action(); - } - } - }); - - ui.add_space(10.0); - ui.label( - RichText::new( - "The entire asset lock amount will be used to fund the Platform address.", - ) - .color(DashColors::text_secondary(dark_mode)) - .size(11.0) - .italics(), - ); - }); - }); - - // Only update from `open` if we didn't manually close via cancel button - if self.fund_platform_dialog.is_open { - self.fund_platform_dialog.is_open = open; - } - if !self.fund_platform_dialog.is_open { - self.fund_platform_dialog = FundPlatformAddressDialogState::default(); - } - action - } - - /// Render the Private Key dialog - fn render_private_key_dialog(&mut self, ctx: &Context) { - if !self.private_key_dialog.is_open { - return; - } - - let dark_mode = ctx.style().visuals.dark_mode; - let mut open = self.private_key_dialog.is_open; - - // Draw dark overlay behind the dialog - if open { - Self::draw_modal_overlay(ctx, "private_key_dialog_overlay"); - } - - egui::Window::new("Private Key") - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .open(&mut open) - .frame(Self::modal_frame(ctx)) - .show(ctx, |ui| { - ui.set_min_width(400.0); - ui.vertical_centered(|ui| { - ui.add_space(5.0); - - // Address label - ui.label( - RichText::new("Address") - .color(DashColors::text_secondary(dark_mode)) - .size(12.0), - ); - ui.add_space(5.0); - - // Address value - ui.label( - RichText::new(&self.private_key_dialog.address) - .monospace() - .color(DashColors::text_primary(dark_mode)), - ); - - ui.add_space(5.0); - - // Copy address button - if ui.button("Copy Address").clicked() { - let _ = copy_text_to_clipboard(&self.private_key_dialog.address); - } - - ui.add_space(15.0); - ui.separator(); - ui.add_space(15.0); - - // Private key label - ui.label( - RichText::new("Private Key (WIF)") - .color(DashColors::text_secondary(dark_mode)) - .size(12.0), - ); - ui.add_space(5.0); - - // Private key value (hidden by default) - if self.private_key_dialog.show_key { - ui.label( - RichText::new(&self.private_key_dialog.private_key_wif) - .monospace() - .color(DashColors::text_primary(dark_mode)), - ); - } else { - ui.label( - RichText::new("••••••••••••••••••••••••••••••••••••••••••••••••••••") - .monospace() - .color(DashColors::text_secondary(dark_mode)), - ); - } - - ui.add_space(10.0); - - // Show/Hide and Copy buttons - ui.horizontal(|ui| { - if ui - .button(if self.private_key_dialog.show_key { - "Hide Key" - } else { - "Show Key" - }) - .clicked() - { - self.private_key_dialog.show_key = !self.private_key_dialog.show_key; - } - - if ui.button("Copy Key").clicked() { - let _ = - copy_text_to_clipboard(&self.private_key_dialog.private_key_wif); - } - }); - - ui.add_space(15.0); - - // Warning message - ui.label( - RichText::new("Keep your private key secure. Never share it with anyone.") - .color(DashColors::error_color(dark_mode)) - .size(11.0) - .italics(), - ); - }); - }); - - self.private_key_dialog.is_open = open; - if !self.private_key_dialog.is_open { - self.private_key_dialog = PrivateKeyDialogState::default(); - } - } - - /// Prepare the backend task for funding a Platform address from asset lock - fn prepare_fund_platform_action(&mut self) -> AppAction { - use dash_sdk::dpp::address_funds::PlatformAddress; - use std::collections::BTreeMap; - - let Some(wallet_arc) = &self.selected_wallet else { - self.fund_platform_dialog.status = Some("No wallet selected".to_string()); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - }; - - let Some(selected_addr) = &self.fund_platform_dialog.selected_platform_address else { - self.fund_platform_dialog.status = Some("Select a Platform address".to_string()); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - }; - - let Some(asset_lock_idx) = self.fund_platform_dialog.selected_asset_lock_index else { - self.fund_platform_dialog.status = Some("No asset lock selected".to_string()); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - }; - - // Get the asset lock proof and address from the wallet - let (seed_hash, asset_lock_proof, asset_lock_address, platform_addr) = { - let wallet = match wallet_arc.read() { - Ok(guard) => guard, - Err(e) => { - self.fund_platform_dialog.status = Some(e.to_string()); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - } - }; - - let asset_lock = wallet.unused_asset_locks.get(asset_lock_idx); - let Some((_, addr, _, _, Some(proof))) = asset_lock else { - self.fund_platform_dialog.status = - Some("Asset lock not found or not ready".to_string()); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - }; - - // Parse the Platform address (Bech32m format: evo1.../tevo1...) - use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; - let platform_addr = if selected_addr.starts_with("evo1") - || selected_addr.starts_with("tevo1") - { - match PlatformAddress::from_bech32m_string(selected_addr) { - Ok((addr, network)) => { - // Validate that address network matches app network - if network != self.app_context.network { - self.fund_platform_dialog.status = Some(format!( - "Address network mismatch: address is for {:?} but app is on {:?}", - network, self.app_context.network - )); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - } - addr - } - Err(e) => { - self.fund_platform_dialog.status = - Some(format!("Invalid Bech32m address: {}", e)); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - } - } - } else { - // Fall back to base58 parsing for backwards compatibility - match selected_addr - .parse::>() - .map_err(|e| e.to_string()) - .and_then(|a| { - PlatformAddress::try_from(a.assume_checked()) - .map_err(|e| format!("Invalid Platform address: {}", e)) - }) { - Ok(addr) => addr, - Err(e) => { - self.fund_platform_dialog.status = Some(e); - self.fund_platform_dialog.status_is_error = true; - return AppAction::None; - } - } - }; - - ( - wallet.seed_hash(), - Box::new(proof.clone()), - addr.clone(), - platform_addr, - ) - }; - - // Build outputs - fund the entire asset lock to the selected Platform address - let mut outputs: BTreeMap> = BTreeMap::new(); - outputs.insert(platform_addr, None); // None = take the full amount - - self.fund_platform_dialog.is_processing = true; - self.fund_platform_dialog.status = Some("Processing...".to_string()); - self.fund_platform_dialog.status_is_error = false; - - AppAction::BackendTask(BackendTask::WalletTask( - WalletTask::FundPlatformAddressFromAssetLock { - seed_hash, - asset_lock_proof, - asset_lock_address, - outputs, - }, - )) - } - - fn prepare_send_action(&mut self) -> Result { - let wallet = self - .selected_wallet - .as_ref() - .ok_or_else(|| "Select a wallet first".to_string())?; - - let amount_duffs = self - .send_dialog - .amount - .as_ref() - .ok_or_else(|| "Enter an amount".to_string())? - .dash_to_duffs()?; - - if amount_duffs == 0 { - return Err("Amount must be greater than 0".to_string()); - } - - { - let wallet_guard = wallet.read().map_err(|e| e.to_string())?; - if amount_duffs > wallet_guard.confirmed_balance_duffs() { - return Err("Insufficient confirmed balance".to_string()); - } - } - - if self.send_dialog.address.trim().is_empty() { - return Err("Enter a recipient address".to_string()); - } - - let memo = self.send_dialog.memo.trim(); - let request = WalletPaymentRequest { - recipients: vec![PaymentRecipient { - address: self.send_dialog.address.trim().to_string(), - amount_duffs, - }], - subtract_fee_from_amount: self.send_dialog.subtract_fee, - memo: if memo.is_empty() { - None - } else { - Some(memo.to_string()) - }, - override_fee: None, - }; - - Ok(AppAction::BackendTask(BackendTask::CoreTask( - CoreTask::SendWalletPayment { - wallet: wallet.clone(), - request, - }, - ))) - } - - fn open_receive_dialog(&mut self, _ctx: &Context) -> AppAction { - let Some(wallet) = self.selected_wallet.clone() else { - self.receive_dialog.status = Some("Select a wallet first".to_string()); - self.receive_dialog.core_addresses.clear(); - self.receive_dialog.platform_addresses.clear(); - self.receive_dialog.qr_texture = None; - self.receive_dialog.qr_address = None; - self.receive_dialog.is_open = true; - return AppAction::None; - }; - - self.receive_dialog.is_open = true; - self.receive_dialog.qr_texture = None; - self.receive_dialog.qr_address = None; - - // Load Core addresses (works with locked wallet - uses existing addresses) - self.load_core_addresses_for_receive(&wallet); - - // Load Platform addresses (works with locked wallet - uses existing addresses) - self.load_platform_addresses_for_receive(&wallet); - - AppAction::None - } - - /// Load Core addresses into the receive dialog - fn load_core_addresses_for_receive(&mut self, wallet: &Arc>) { - let wallet_guard = match wallet.read() { - Ok(guard) => guard, - Err(err) => { - self.receive_dialog.status = Some(err.to_string()); - return; - } - }; - - // Collect all BIP44 external (receive) addresses with their balances - let network = self.app_context.network; - let core_addresses: Vec<(String, u64)> = wallet_guard - .watched_addresses - .iter() - .filter(|(path, _)| path.is_bip44_external(network)) - .map(|(_, info)| { - let balance = wallet_guard - .address_balances - .get(&info.address) - .copied() - .unwrap_or(0); - (info.address.to_string(), balance) - }) - .collect(); - - drop(wallet_guard); - - if core_addresses.is_empty() { - // Generate a new Core address if none exists - match self.generate_new_core_receive_address(wallet) { - Ok((address, balance)) => { - self.receive_dialog.core_addresses = vec![(address, balance)]; - self.receive_dialog.selected_core_index = 0; - } - Err(err) => { - self.receive_dialog.status = Some(err); - self.receive_dialog.core_addresses.clear(); - } - } - } else { - self.receive_dialog.core_addresses = core_addresses; - self.receive_dialog.selected_core_index = 0; - } - } - - /// Load Platform addresses into the receive dialog - fn load_platform_addresses_for_receive(&mut self, wallet: &Arc>) { - let wallet_guard = match wallet.read() { - Ok(guard) => guard, - Err(err) => { - self.receive_dialog.status = Some(err.to_string()); - return; - } - }; - - // Collect Platform addresses with their balances (using DIP-18 Bech32m format) - // Use platform_addresses() which checks watched_addresses, not just platform_address_info - // This includes addresses that have been derived but may not have been synced yet - let network = self.app_context.network; - let platform_addresses: Vec<(String, u64)> = wallet_guard - .platform_addresses(network) - .into_iter() - .map(|(core_addr, platform_addr)| { - let balance = wallet_guard - .get_platform_address_info(&core_addr) - .map(|info| info.balance) - .unwrap_or(0); - (platform_addr.to_bech32m_string(network), balance) - }) - .collect(); - - drop(wallet_guard); - - if platform_addresses.is_empty() { - // Generate a new Platform address if none exists - match self.generate_platform_address(wallet) { - Ok(address) => { - self.receive_dialog.platform_addresses = vec![(address, 0)]; - self.receive_dialog.selected_platform_index = 0; - } - Err(err) => { - self.receive_dialog.status = Some(err); - self.receive_dialog.platform_addresses.clear(); - } - } - } else { - self.receive_dialog.platform_addresses = platform_addresses; - self.receive_dialog.selected_platform_index = 0; - } - } - fn categorize_path( path: &DerivationPath, reference: DerivationPathReference, @@ -2874,19 +1736,6 @@ impl WalletsBalancesScreen { } } - fn derive_private_key_wif(&self, path: &DerivationPath) -> Result { - let wallet_arc = self - .selected_wallet - .clone() - .ok_or_else(|| "Select a wallet first".to_string())?; - let wallet = wallet_arc.read().map_err(|e| e.to_string())?; - if wallet.uses_password && !wallet.is_open() { - return Err("Unlock this wallet to view private keys.".to_string()); - } - let private_key = wallet.private_key_at_derivation_path(path, self.app_context.network)?; - Ok(private_key.to_wif()) - } - fn lock_selected_wallet(&mut self) { let Some(wallet_arc) = self.selected_wallet.clone() else { return; From 428ff1db5bcffeee52e3827b1c27f239cb5a1a1b Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:50:55 +0100 Subject: [PATCH 083/106] build: update all dependencies (#531) * fix: build fails for windows target * build: only create a release when tag is set * chore: remove redundant settings from release.yml * doc: windows requirements * fix: windows icon * build: change grovestark revision * chore: fmt * fix: don't open console window on windows * build: grovestark recent commit * build: update dependencies * chore: clippy * chore: fmt * build: update platform to most recent v3.1-dev --- Cargo.lock | 607 ++++++++++++------ Cargo.toml | 33 +- src/backend_task/dashpay/profile.rs | 2 +- src/components/core_p2p_handler.rs | 8 +- src/main.rs | 2 +- src/model/wallet/asset_lock_transaction.rs | 2 +- src/ui/components/confirmation_dialog.rs | 2 +- src/ui/components/info_popup.rs | 2 +- src/ui/components/left_panel.rs | 9 +- src/ui/components/left_wallet_panel.rs | 7 +- src/ui/components/top_panel.rs | 3 +- src/ui/components/wallet_unlock_popup.rs | 2 +- src/ui/identities/identities_screen.rs | 4 +- .../data_contract_json_pop_up.rs | 2 +- src/ui/wallets/add_new_wallet_screen.rs | 2 +- src/ui/wallets/asset_lock_detail_screen.rs | 2 +- src/ui/wallets/wallets_screen/dialogs.rs | 2 +- 17 files changed, 443 insertions(+), 248 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 938872ea0..80818d6d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,9 +20,9 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "accesskit" -version = "0.19.0" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e25ae84c0260bdf5df07796d7cc4882460de26a2b406ec0e6c42461a723b271b" +checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" dependencies = [ "enumn", "serde", @@ -30,12 +30,12 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.12.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bd41de2e54451a8ca0dd95ebf45b54d349d29ebceb7f20be264eee14e3d477" +checksum = "890d241cf51fc784f0ac5ac34dfc847421f8d39da6c7c91a0fcc987db62a8267" dependencies = [ "accesskit", - "accesskit_consumer", + "accesskit_consumer 0.31.0", "atspi-common", "serde", "thiserror 1.0.69", @@ -44,9 +44,19 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.28.0" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd06f5fea9819250fffd4debf926709f3593ac22f8c1541a2573e5ee0ca01cd" +dependencies = [ + "accesskit", + "hashbrown 0.15.5", +] + +[[package]] +name = "accesskit_consumer" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bfae7c152994a31dc7d99b8eeac7784a919f71d1b306f4b83217e110fd3824c" +checksum = "db81010a6895d8707f9072e6ce98070579b43b717193d2614014abd5cb17dd43" dependencies = [ "accesskit", "hashbrown 0.15.5", @@ -54,12 +64,12 @@ dependencies = [ [[package]] name = "accesskit_macos" -version = "0.20.0" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692dd318ff8a7a0ffda67271c4bd10cf32249656f4e49390db0b26ca92b095f2" +checksum = "a0089e5c0ac0ca281e13ea374773898d9354cc28d15af9f0f7394d44a495b575" dependencies = [ "accesskit", - "accesskit_consumer", + "accesskit_consumer 0.31.0", "hashbrown 0.15.5", "objc2 0.5.2", "objc2-app-kit 0.2.2", @@ -68,9 +78,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.15.0" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f7474c36606d0fe4f438291d667bae7042ea2760f506650ad2366926358fc8" +checksum = "301e55b39cfc15d9c48943ce5f572204a551646700d0e8efa424585f94fec528" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -86,12 +96,12 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.27.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a042b62c9c05bf7b616f015515c17d2813f3ba89978d6f4fc369735d60700a" +checksum = "d2d63dd5041e49c363d83f5419a896ecb074d309c414036f616dc0b04faca971" dependencies = [ "accesskit", - "accesskit_consumer", + "accesskit_consumer 0.31.0", "hashbrown 0.15.5", "static_assertions", "windows 0.61.3", @@ -100,9 +110,9 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.27.0" +version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1f0d3d13113d8857542a4f8d1a1c24d1dc1527b77aee8426127f4901588708" +checksum = "c8cfabe59d0eaca7412bfb1f70198dd31e3b0496fee7e15b066f9c36a1a140a0" dependencies = [ "accesskit", "accesskit_macos", @@ -198,7 +208,7 @@ dependencies = [ "log", "ndk", "ndk-context", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "num_enum 0.7.5", "thiserror 1.0.69", ] @@ -430,28 +440,6 @@ dependencies = [ "zbus", ] -[[package]] -name = "ashpd" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" -dependencies = [ - "async-fs", - "async-net", - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.2", - "raw-window-handle", - "serde", - "serde_repr", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - [[package]] name = "async-broadcast" version = "0.7.2" @@ -732,6 +720,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "backon" version = "1.6.0" @@ -1401,6 +1411,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "codespan-reporting" version = "0.12.0" @@ -1498,7 +1517,7 @@ checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types", + "core-graphics-types 0.1.3", "foreign-types 0.5.0", "libc", ] @@ -1514,6 +1533,17 @@ dependencies = [ "libc", ] +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "libc", +] + [[package]] name = "core2" version = "0.4.0" @@ -1686,8 +1716,8 @@ dependencies = [ [[package]] name = "dapi-grpc" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "dash-platform-macros", "futures-core", @@ -1709,7 +1739,7 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18e1a09f280e29a8b00bc7e81eca5ac87dca0575639c9422a5fa25a07bb884b8" dependencies = [ - "ashpd 0.10.3", + "ashpd", "async-std", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -1754,8 +1784,8 @@ dependencies = [ [[package]] name = "dash-context-provider" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "dpp", "drive", @@ -1798,15 +1828,14 @@ dependencies = [ "humantime", "image", "itertools 0.14.0", - "libsqlite3-sys", "native-dialog", "nix", "qrcode", - "rand 0.8.5", + "rand 0.9.2", "raw-cpuid", "rayon", "regex", - "reqwest", + "reqwest 0.13.1", "resvg", "rfd", "rusqlite", @@ -1821,7 +1850,6 @@ dependencies = [ "tokio-util", "tracing", "tracing-subscriber", - "tz-rs", "which 8.0.0", "winres", "zeroize", @@ -1843,8 +1871,8 @@ dependencies = [ [[package]] name = "dash-platform-macros" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "heck", "quote", @@ -1853,8 +1881,8 @@ dependencies = [ [[package]] name = "dash-sdk" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "arc-swap", "async-trait", @@ -2002,8 +2030,8 @@ dependencies = [ [[package]] name = "dashpay-contract" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "platform-value", "platform-version", @@ -2013,8 +2041,8 @@ dependencies = [ [[package]] name = "data-contracts" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "dashpay-contract", "dpns-contract", @@ -2266,8 +2294,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "platform-value", "platform-version", @@ -2277,8 +2305,8 @@ dependencies = [ [[package]] name = "dpp" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "anyhow", "async-trait", @@ -2325,8 +2353,8 @@ dependencies = [ [[package]] name = "drive" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "bincode 2.0.1", "byteorder", @@ -2350,8 +2378,8 @@ dependencies = [ [[package]] name = "drive-proof-verifier" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -2369,11 +2397,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ecolor" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bdf37f8d5bd9aa7f753573fdda9cf7343afa73dd28d7bfe9593bd9798fc07e" +checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" dependencies = [ "bytemuck", "emath", @@ -2430,9 +2464,9 @@ dependencies = [ [[package]] name = "eframe" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14d1c15e7bd136b309bd3487e6ffe5f668b354cd9768636a836dd738ac90eb0b" +checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" dependencies = [ "ahash", "bytemuck", @@ -2462,16 +2496,15 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "web-time", - "winapi", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5d0306cd61ca75e29682926d71f2390160247f135965242e904a636f51c0dc" +checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" dependencies = [ "accesskit", "ahash", @@ -2489,9 +2522,9 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c12eca13293f8eba27a32aaaa1c765bfbf31acd43e8d30d5881dcbe5e99ca0c7" +checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236" dependencies = [ "ahash", "bytemuck", @@ -2500,7 +2533,7 @@ dependencies = [ "epaint", "log", "profiling", - "thiserror 1.0.69", + "thiserror 2.0.18", "type-map", "web-time", "wgpu", @@ -2509,16 +2542,18 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f95d0a91f9cb0dc2e732d49c2d521ac8948e1f0b758f306fb7b14d6f5db3927f" +checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29" dependencies = [ "accesskit_winit", - "ahash", "arboard", "bytemuck", "egui", "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", "profiling", "raw-window-handle", "serde", @@ -2530,9 +2565,9 @@ dependencies = [ [[package]] name = "egui_commonmark" -version = "0.21.1" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c9caff9c964af1e3d913acd85e86d2170e3169a43cf4ff84eea3106691c14d" +checksum = "d5246a4e9b83c345ec8230933bd0dca16d1c3c11db0edd4fd9c1a90683240b49" dependencies = [ "egui", "egui_commonmark_backend", @@ -2542,9 +2577,9 @@ dependencies = [ [[package]] name = "egui_commonmark_backend" -version = "0.21.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e317aa4031f27be77d4c1c33cb038cdf02d77790c28e5cf1283a66cceb88695" +checksum = "d3cff846279556f57af8ea606f2e4ceaf83e60b81db014c126dfb926fa06c75b" dependencies = [ "egui", "egui_extras", @@ -2553,9 +2588,9 @@ dependencies = [ [[package]] name = "egui_extras" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dddbceddf39805fc6c62b1f7f9c05e23590b40844dc9ed89c6dc6dbc886e3e3b" +checksum = "d01d34e845f01c62e3fded726961092e70417d66570c499b9817ab24674ca4ed" dependencies = [ "ahash", "egui", @@ -2568,11 +2603,10 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7037813341727937f9e22f78d912f3e29bc3c46e2f40a9e82bb51cbf5e4cfb" +checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb" dependencies = [ - "ahash", "bytemuck", "egui", "glow", @@ -2586,9 +2620,9 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bb00f16e00af09092c117515246732adba4ca4649463bdbc2ab6114a2944765" +checksum = "43afb5f968dfa9e6c8f5e609ab9039e11a2c4af79a326f4cb1b99cf6875cb6a0" dependencies = [ "eframe", "egui", @@ -2638,9 +2672,9 @@ dependencies = [ [[package]] name = "emath" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45fd7bc25f769a3c198fe1cf183124bf4de3bd62ef7b4f1eaf6b08711a3af8db" +checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" dependencies = [ "bytemuck", "serde", @@ -2785,9 +2819,9 @@ dependencies = [ [[package]] name = "epaint" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63adcea970b7a13094fe97a36ab9307c35a750f9e24bf00bb7ef3de573e0fddb" +checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" dependencies = [ "ab_glyph", "ahash", @@ -2804,9 +2838,9 @@ dependencies = [ [[package]] name = "epaint_default_fonts" -version = "0.32.3" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1537accc50c9cab5a272c39300bdd0dd5dca210f6e5e8d70be048df9596e7ca2" +checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" [[package]] name = "equivalent" @@ -2926,8 +2960,8 @@ dependencies = [ [[package]] name = "feature-flags-contract" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "platform-value", "platform-version", @@ -3005,7 +3039,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -3245,9 +3279,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -3262,9 +3298,9 @@ dependencies = [ [[package]] name = "gif" -version = "0.13.3" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" dependencies = [ "color_quant", "weezl", @@ -3458,7 +3494,7 @@ dependencies = [ "hex-literal", "indexmap 2.13.0", "integer-encoding", - "reqwest", + "reqwest 0.12.28", "sha2", "thiserror 2.0.18", ] @@ -3657,11 +3693,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -4109,9 +4145,9 @@ dependencies = [ [[package]] name = "imagesize" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "indexmap" @@ -4367,8 +4403,8 @@ dependencies = [ [[package]] name = "keyword-search-contract" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "platform-value", "platform-version", @@ -4395,20 +4431,20 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c1bfc4cb16136b6f00fb85a281e4b53d026401cf5dff9a427c466bde5891f0b" +checksum = "01fd6dd2cce251a360101038acb9334e3a50cd38cd02fefddbf28aa975f043c8" dependencies = [ "accesskit", - "accesskit_consumer", + "accesskit_consumer 0.30.1", "parking_lot", ] [[package]] name = "kurbo" -version = "0.11.3" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb" dependencies = [ "arrayvec", "euclid", @@ -4477,11 +4513,10 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.35.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ - "cc", "pkg-config", "vcpkg", ] @@ -4537,6 +4572,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "malloc_buf" version = "0.0.6" @@ -4548,8 +4589,8 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "platform-value", "platform-version", @@ -4604,13 +4645,13 @@ dependencies = [ [[package]] name = "metal" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" dependencies = [ "bitflags 2.10.0", "block", - "core-graphics-types", + "core-graphics-types 0.2.0", "foreign-types 0.5.0", "log", "objc", @@ -4717,25 +4758,26 @@ checksum = "9252111cf132ba0929b6f8e030cac2a24b507f3a4d6db6fb2896f27b354c714b" [[package]] name = "naga" -version = "25.0.1" +version = "27.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" dependencies = [ "arrayvec", "bit-set 0.8.0", "bitflags 2.10.0", + "cfg-if", "cfg_aliases", "codespan-reporting", "half", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "hexf-parse", "indexmap 2.13.0", + "libm", "log", "num-traits", "once_cell", "rustc-hash 1.1.0", "spirv", - "strum", "thiserror 2.0.18", "unicode-ident", ] @@ -4790,7 +4832,7 @@ dependencies = [ "bitflags 2.10.0", "jni-sys", "log", - "ndk-sys 0.6.0+11769913", + "ndk-sys", "num_enum 0.7.5", "raw-window-handle", "thiserror 1.0.69", @@ -4802,15 +4844,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" -[[package]] -name = "ndk-sys" -version = "0.5.0+25.2.9519653" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" -dependencies = [ - "jni-sys", -] - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -4822,9 +4855,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.30.1" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66" dependencies = [ "bitflags 2.10.0", "cfg-if", @@ -5620,8 +5653,8 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "platform-serialization" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "bincode 2.0.1", "platform-version", @@ -5629,8 +5662,8 @@ dependencies = [ [[package]] name = "platform-serialization-derive" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "proc-macro2", "quote", @@ -5640,8 +5673,8 @@ dependencies = [ [[package]] name = "platform-value" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -5660,20 +5693,19 @@ dependencies = [ [[package]] name = "platform-version" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "bincode 2.0.1", "grovedb-version", - "once_cell", "thiserror 2.0.18", "versioned-feature-core 1.0.0 (git+https://github.com/dashpay/versioned-feature-core)", ] [[package]] name = "platform-versioning" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "proc-macro2", "quote", @@ -5934,6 +5966,62 @@ dependencies = [ "serde", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.44" @@ -6170,6 +6258,47 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -6189,9 +6318,9 @@ checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "resvg" -version = "0.45.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" dependencies = [ "gif", "image-webp", @@ -6201,31 +6330,34 @@ dependencies = [ "svgtypes", "tiny-skia", "usvg", - "zune-jpeg 0.4.21", + "zune-jpeg 0.5.12", ] [[package]] name = "rfd" -version = "0.15.4" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" dependencies = [ - "ashpd 0.11.1", "block2 0.6.2", "dispatch2", "js-sys", + "libc", "log", "objc2 0.6.3", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", + "percent-encoding", "pollster", "raw-window-handle", - "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", "web-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6253,9 +6385,9 @@ dependencies = [ [[package]] name = "ron" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beceb6f7bf81c73e73aeef6dd1356d9a1b2b4909e1f0fc3e59b034f9572d7b7f" +checksum = "db09040cc89e461f1a265139777a2bde7f8d8c67c4936f700c63ce3e2904d468" dependencies = [ "base64 0.22.1", "bitflags 2.10.0", @@ -6270,10 +6402,19 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rs-dapi-client" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "backon", "chrono", @@ -6307,11 +6448,21 @@ dependencies = [ "libc", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + [[package]] name = "rusqlite" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ "bitflags 2.10.0", "fallible-iterator", @@ -6319,6 +6470,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] @@ -6408,6 +6560,7 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -6435,15 +6588,44 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -6959,6 +7141,18 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "sqlparser" version = "0.38.0" @@ -7045,9 +7239,9 @@ dependencies = [ [[package]] name = "svgtypes" -version = "0.15.3" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ "kurbo", "siphasher", @@ -7372,8 +7566,8 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "platform-value", "platform-version", @@ -7798,12 +7992,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" -[[package]] -name = "tz-rs" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fc6c929ffa10fb34f4a3c7e9a73620a83ef2e85e47f9ec3381b8289e6762f42" - [[package]] name = "uds_windows" version = "1.1.0" @@ -7969,17 +8157,11 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "usvg" -version = "0.45.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" dependencies = [ "base64 0.22.1", "data-url", @@ -7989,7 +8171,7 @@ dependencies = [ "kurbo", "log", "pico-args", - "roxmltree", + "roxmltree 0.21.1", "rustybuzz", "simplecss", "siphasher", @@ -8125,8 +8307,8 @@ dependencies = [ [[package]] name = "wallet-utils-contract" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "platform-value", "platform-version", @@ -8401,6 +8583,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -8428,15 +8619,16 @@ dependencies = [ [[package]] name = "wgpu" -version = "25.0.2" +version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8fb398f119472be4d80bc3647339f56eb63b2a331f6a3d16e25d8144197dd9" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" dependencies = [ "arrayvec", "bitflags 2.10.0", + "cfg-if", "cfg_aliases", "document-features", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "js-sys", "log", "naga", @@ -8456,17 +8648,18 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "25.0.2" +version = "27.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7b882196f8368511d613c6aeec80655160db6646aebddf8328879a88d54e500" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" dependencies = [ "arrayvec", "bit-set 0.8.0", "bit-vec 0.8.0", "bitflags 2.10.0", + "bytemuck", "cfg_aliases", "document-features", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "indexmap 2.13.0", "log", "naga", @@ -8487,36 +8680,36 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "25.0.0" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd488b3239b6b7b185c3b045c39ca6bf8af34467a4c5de4e0b1a564135d093d" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "25.0.0" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09ad7aceb3818e52539acc679f049d3475775586f3f4e311c30165cf2c00445" +checksum = "b06ac3444a95b0813ecfd81ddb2774b66220b264b3e2031152a4a29fda4da6b5" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "25.0.0" +version = "27.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cba5fb5f7f9c98baa7c889d444f63ace25574833df56f5b817985f641af58e46" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "25.0.2" +version = "27.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f968767fe4d3d33747bbd1473ccd55bf0f6451f55d733b5597e67b5deab4ad17" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" dependencies = [ "android_system_properties", "arrayvec", @@ -8527,13 +8720,13 @@ dependencies = [ "bytemuck", "cfg-if", "cfg_aliases", - "core-graphics-types", + "core-graphics-types 0.2.0", "glow", "glutin_wgl_sys", "gpu-alloc", "gpu-allocator", "gpu-descriptor", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "js-sys", "khronos-egl", "libc", @@ -8541,11 +8734,13 @@ dependencies = [ "log", "metal", "naga", - "ndk-sys 0.5.0+25.2.9519653", + "ndk-sys", "objc", + "once_cell", "ordered-float", "parking_lot", "portable-atomic", + "portable-atomic-util", "profiling", "range-alloc", "raw-window-handle", @@ -8561,9 +8756,9 @@ dependencies = [ [[package]] name = "wgpu-types" -version = "25.0.0" +version = "27.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aa49460c2a8ee8edba3fca54325540d904dd85b2e086ada762767e17d06e8bc" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" dependencies = [ "bitflags 2.10.0", "bytemuck", @@ -9381,8 +9576,8 @@ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "withdrawals-contract" -version = "3.0.0" -source = "git+https://github.com/dashpay/platform?rev=98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9#98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9" +version = "3.0.1" +source = "git+https://github.com/dashpay/platform?rev=806322247caf1037c6d73d3620804e27a2170a3d#806322247caf1037c6d73d3620804e27a2170a3d" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index e94de8b28..376c8d6d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,16 +9,16 @@ rust-version = "1.92" [dependencies] tokio-util = { version = "0.7.15" } bip39 = { version = "2.2.0", features = ["all-languages", "rand"] } -derive_more = "2.0.1" -egui = "0.32.0" -egui_extras = "0.32.0" -egui_commonmark = "0.21.1" -rfd = "0.15.4" +derive_more = "2.1.1" +egui = "0.33.3" +egui_extras = "0.33.3" +egui_commonmark = "0.22.0" +rfd = "0.17.2" qrcode = "0.14.1" -nix = { version = "0.30.1", features = ["signal"] } -eframe = { version = "0.32.0", features = ["persistence"] } +nix = { version = "0.31.1", features = ["signal"] } +eframe = { version = "0.33.3", features = ["persistence"] } base64 = "0.22.1" -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "98a0ebeca4e6a9ae000d316c4c9c5b64f6fd3dd9", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "806322247caf1037c6d73d3620804e27a2170a3d", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -29,7 +29,7 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "98a0ebeca4e6a9a ] } grovestark = { git = "https://www.github.com/dashpay/grovestark", rev = "5b9e289cca54c79b1305d5f4f40bf1148f1eb0e3" } rayon = "1.8" -thiserror = "2.0.12" +thiserror = "2.0.18" serde = "1.0.219" serde_json = "1.0.140" serde_yaml = { version = "0.9.34-deprecated" } @@ -40,7 +40,7 @@ itertools = "0.14.0" enum-iterator = "2.1.0" futures = "0.3.31" tracing = "0.1.41" -rand = "0.8" +rand = "0.9" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } dotenvy = "0.15.7" envy = "0.4.2" @@ -52,16 +52,15 @@ arboard = { version = "3.6.0", default-features = false, features = [ "windows-sys", ] } directories = "6.0.0" -rusqlite = { version = "0.37.0", features = ["functions"] } +rusqlite = { version = "0.38.0", features = ["functions", "fallible_uint"] } dark-light = "2.0.0" image = { version = "0.25.6", default-features = false, features = [ "png", "jpeg", ] } -resvg = "0.45" -reqwest = { version = "0.12", features = ["json", "stream"] } -bitflags = "2.9.1" -libsqlite3-sys = { version = "0.35.0", features = ["bundled"] } +resvg = "0.46" +reqwest = { version = "0.13", features = ["json", "stream"] } +bitflags = "2.10" rust-embed = "8.7.2" zeroize = "1.8.1" zxcvbn = "3.1.0" @@ -72,7 +71,7 @@ crossbeam-channel = "0.5.15" regex = "1.11.1" humantime = "2.2.0" which = { version = "8.0.0" } -tz-rs = { version = "0.7.0" } + [target.'cfg(not(target_os = "windows"))'.dependencies] zmq = "0.10.0" @@ -86,7 +85,7 @@ raw-cpuid = "11.5.0" [dev-dependencies] tempfile = { version = "3.20.0" } -egui_kittest = { version = "0.32.0", features = ["eframe"] } +egui_kittest = { version = "0.33.3", features = ["eframe"] } [build-dependencies] winres = "0.1" diff --git a/src/backend_task/dashpay/profile.rs b/src/backend_task/dashpay/profile.rs index 66d387781..ee4b325f0 100644 --- a/src/backend_task/dashpay/profile.rs +++ b/src/backend_task/dashpay/profile.rs @@ -262,7 +262,7 @@ pub async fn update_profile( // Create new profile using DocumentCreateTransitionBuilder // Generate random entropy for document ID (security: prevents predictable IDs) let mut entropy = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut entropy); + rand::rng().fill_bytes(&mut entropy); let profile_doc_id = Document::generate_document_id_v0( &dashpay_contract.id(), diff --git a/src/components/core_p2p_handler.rs b/src/components/core_p2p_handler.rs index 6b354598b..dab0ef3c3 100644 --- a/src/components/core_p2p_handler.rs +++ b/src/components/core_p2p_handler.rs @@ -7,7 +7,7 @@ use dash_sdk::dpp::dashcore::network::message::{NetworkMessage, RawNetworkMessag use dash_sdk::dpp::dashcore::network::message_qrinfo::QRInfo; use dash_sdk::dpp::dashcore::network::message_sml::{GetMnListDiff, MnListDiff}; use dash_sdk::dpp::dashcore::network::{Address, message_network, message_qrinfo}; -use rand::prelude::StdRng; +use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; use sha2::{Digest, Sha256}; use std::io::{ErrorKind, Read, Write}; @@ -250,7 +250,7 @@ impl CoreP2PHandler { // Note: get_dml_diff and get_qr_info are already defined above (lines ~351 and ~364) /// Perform the handshake (version/verack exchange) with the peer. pub fn handshake(&mut self) -> Result<(), String> { - let mut rng = StdRng::from_entropy(); + let mut rng = StdRng::from_os_rng(); // Build a version message. let version_msg = NetworkMessage::Version(message_network::VersionMessage { @@ -267,11 +267,11 @@ impl CoreP2PHandler { address: Default::default(), port: self.stream.local_addr().map_err(|e| e.to_string())?.port(), }, - nonce: rng.r#gen(), + nonce: rng.random(), user_agent: "/dash-evo-tool:0.9/".to_string(), start_height: 0, relay: false, - mn_auth_challenge: rng.r#gen(), + mn_auth_challenge: rng.random(), masternode_connection: false, }); diff --git a/src/main.rs b/src/main.rs index 0dae520d3..50d1065db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,7 +36,7 @@ fn load_icon() -> egui::IconData { let image = image::imageops::resize(&image, 64, 64, image::imageops::FilterType::Lanczos3); let (width, height) = image.dimensions(); egui::IconData { - rgba: image.into_raw(), + rgba: image.to_vec(), width, height, } diff --git a/src/model/wallet/asset_lock_transaction.rs b/src/model/wallet/asset_lock_transaction.rs index 55cf609be..fbc73f1fb 100644 --- a/src/model/wallet/asset_lock_transaction.rs +++ b/src/model/wallet/asset_lock_transaction.rs @@ -95,7 +95,7 @@ impl Wallet { ), String, > { - use rand::rngs::OsRng; + use bip39::rand::rngs::OsRng; // Generate a random private key for the asset lock let secp = Secp256k1::new(); diff --git a/src/ui/components/confirmation_dialog.rs b/src/ui/components/confirmation_dialog.rs index 6f04f2a2b..1dcf86ff3 100644 --- a/src/ui/components/confirmation_dialog.rs +++ b/src/ui/components/confirmation_dialog.rs @@ -145,7 +145,7 @@ impl ConfirmationDialog { } // Draw dark overlay behind the dialog for better visibility - let screen_rect = ui.ctx().screen_rect(); + let screen_rect = ui.ctx().content_rect(); let painter = ui.ctx().layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new("confirmation_dialog_overlay"), diff --git a/src/ui/components/info_popup.rs b/src/ui/components/info_popup.rs index 8fc393afd..0119fe64b 100644 --- a/src/ui/components/info_popup.rs +++ b/src/ui/components/info_popup.rs @@ -56,7 +56,7 @@ impl InfoPopup { } // Draw dark overlay behind the popup for better visibility - let screen_rect = ui.ctx().screen_rect(); + let screen_rect = ui.ctx().content_rect(); let painter = ui.ctx().layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new("info_popup_overlay"), diff --git a/src/ui/components/left_panel.rs b/src/ui/components/left_panel.rs index 235677d67..216e708cd 100644 --- a/src/ui/components/left_panel.rs +++ b/src/ui/components/left_panel.rs @@ -5,7 +5,7 @@ use crate::ui::components::styled::GradientButton; use crate::ui::theme::{DashColors, Shadow, Shape, Spacing}; use dash_sdk::dashcore_rpc::dashcore::Network; use eframe::epaint::Margin; -use egui::{Color32, Context, Frame, ImageButton, RichText, SidePanel, TextureHandle}; +use egui::{Color32, Context, Frame, Image, RichText, SidePanel, TextureHandle}; use egui_extras::{Size, StripBuilder}; use rust_embed::RustEmbed; use std::sync::Arc; @@ -249,9 +249,10 @@ pub fn add_left_panel( }; if let Some(ref texture) = texture { - let button = ImageButton::new(texture) - .frame(false) - .tint(button_color); + let button = egui::Button::image( + Image::new(texture).tint(button_color), + ) + .frame(false); let added = ui.add(button); if added.clicked() { diff --git a/src/ui/components/left_wallet_panel.rs b/src/ui/components/left_wallet_panel.rs index f5922d3ba..b81ac1eb7 100644 --- a/src/ui/components/left_wallet_panel.rs +++ b/src/ui/components/left_wallet_panel.rs @@ -2,7 +2,7 @@ use crate::app::AppAction; use crate::context::AppContext; use crate::ui::RootScreenType; use eframe::epaint::{Color32, Margin}; -use egui::{Context, Frame, ImageButton, SidePanel, TextureHandle}; +use egui::{Context, Frame, Image, SidePanel, TextureHandle}; use rust_embed::RustEmbed; use std::sync::Arc; @@ -92,9 +92,8 @@ pub fn add_left_panel( // Add icon-based button if texture is loaded if let Some(ref texture) = texture { - let button = ImageButton::new(texture) - .frame(false) // Remove button frame - .tint(button_color); + let button = egui::Button::image(Image::new(texture).tint(button_color)) + .frame(false); // Remove button frame if ui.add(button).clicked() { action = AppAction::SetMainScreen(*screen_type); diff --git a/src/ui/components/top_panel.rs b/src/ui/components/top_panel.rs index 031817d92..4dfde7ad5 100644 --- a/src/ui/components/top_panel.rs +++ b/src/ui/components/top_panel.rs @@ -397,7 +397,8 @@ pub fn add_top_panel( ui.add_space(3.0); let font = egui::FontId::proportional(16.0); let text_size = ui - .fonts(|f| { + .ctx() + .fonts_mut(|f| { f.layout_no_wrap( text.to_string(), font.clone(), diff --git a/src/ui/components/wallet_unlock_popup.rs b/src/ui/components/wallet_unlock_popup.rs index afdf00464..0f8d65a48 100644 --- a/src/ui/components/wallet_unlock_popup.rs +++ b/src/ui/components/wallet_unlock_popup.rs @@ -75,7 +75,7 @@ impl WalletUnlockPopup { } // Draw dark overlay behind the popup - let screen_rect = ctx.screen_rect(); + let screen_rect = ctx.content_rect(); let painter = ctx.layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new("wallet_unlock_popup_overlay"), diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 963ed4c9d..816b5606a 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -843,7 +843,7 @@ impl IdentitiesScreen { let action = AppAction::None; // Draw dark overlay behind the popup - let screen_rect = ctx.screen_rect(); + let screen_rect = ctx.content_rect(); let painter = ctx.layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new("confirm_removal_overlay"), @@ -969,7 +969,7 @@ impl IdentitiesScreen { let identity_id = self.editing_alias_identity.unwrap(); // Draw dark overlay behind the popup - let screen_rect = ctx.screen_rect(); + let screen_rect = ctx.content_rect(); let painter = ctx.layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new("edit_alias_overlay"), diff --git a/src/ui/tokens/tokens_screen/data_contract_json_pop_up.rs b/src/ui/tokens/tokens_screen/data_contract_json_pop_up.rs index 80d5521ac..44a71c11a 100644 --- a/src/ui/tokens/tokens_screen/data_contract_json_pop_up.rs +++ b/src/ui/tokens/tokens_screen/data_contract_json_pop_up.rs @@ -9,7 +9,7 @@ impl TokensScreen { let mut is_open = true; // Draw dark overlay behind the dialog for better visibility - let screen_rect = ui.ctx().screen_rect(); + let screen_rect = ui.ctx().content_rect(); let painter = ui.ctx().layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new("json_popup_overlay"), diff --git a/src/ui/wallets/add_new_wallet_screen.rs b/src/ui/wallets/add_new_wallet_screen.rs index 22b9c64f6..2a508bb06 100644 --- a/src/ui/wallets/add_new_wallet_screen.rs +++ b/src/ui/wallets/add_new_wallet_screen.rs @@ -467,7 +467,7 @@ impl AddNewWalletScreen { } // Draw dark overlay behind the dialog - let screen_rect = ctx.screen_rect(); + let screen_rect = ctx.content_rect(); let painter = ctx.layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new("receive_funds_overlay"), diff --git a/src/ui/wallets/asset_lock_detail_screen.rs b/src/ui/wallets/asset_lock_detail_screen.rs index 7e65e4395..e52ead8b0 100644 --- a/src/ui/wallets/asset_lock_detail_screen.rs +++ b/src/ui/wallets/asset_lock_detail_screen.rs @@ -388,7 +388,7 @@ impl ScreenLike for AssetLockDetailScreen { // Private key popup if self.show_private_key_popup { // Draw dark overlay behind the popup - let screen_rect = ctx.screen_rect(); + let screen_rect = ctx.content_rect(); let painter = ctx.layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new("private_key_popup_overlay"), diff --git a/src/ui/wallets/wallets_screen/dialogs.rs b/src/ui/wallets/wallets_screen/dialogs.rs index 05ecb731d..43cd911b8 100644 --- a/src/ui/wallets/wallets_screen/dialogs.rs +++ b/src/ui/wallets/wallets_screen/dialogs.rs @@ -97,7 +97,7 @@ pub(super) struct PrivateKeyDialogState { impl WalletsBalancesScreen { pub(super) fn draw_modal_overlay(ctx: &Context, id: &str) { - let screen_rect = ctx.screen_rect(); + let screen_rect = ctx.content_rect(); let painter = ctx.layer_painter(egui::LayerId::new( egui::Order::Background, egui::Id::new(id), From 629ff4fd91e680fc771b4cbf459e1bbd6cc2606d Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 3 Feb 2026 14:42:19 -0600 Subject: [PATCH 084/106] feat: add key exchange protocol and state transition signing Add YAPPR key exchange flow for DashPay, QR scanner improvements, state transition signing screen, and refactor UI helpers. Co-Authored-By: Claude Opus 4.5 --- src/backend_task/core/mod.rs | 7 +- src/backend_task/dashpay.rs | 10 + src/backend_task/dashpay/key_exchange.rs | 695 ++++++++++++++ src/backend_task/mod.rs | 23 + .../sign_and_broadcast_state_transition.rs | 36 + src/model/key_exchange_request.rs | 469 +++++++++ src/model/mod.rs | 2 + .../encrypted_key_storage.rs | 10 +- src/model/qualified_identity/mod.rs | 4 +- src/model/state_transition_request.rs | 546 +++++++++++ src/ui/dashpay/dashpay_screen.rs | 8 +- src/ui/dashpay/key_exchange_confirmation.rs | 563 +++++++++++ src/ui/dashpay/mod.rs | 1 + src/ui/dashpay/qr_scanner.rs | 110 ++- src/ui/helpers.rs | 444 +++++---- src/ui/mod.rs | 55 ++ src/ui/tools/mod.rs | 1 + .../tools/state_transition_signing_screen.rs | 897 ++++++++++++++++++ 18 files changed, 3675 insertions(+), 206 deletions(-) create mode 100644 src/backend_task/dashpay/key_exchange.rs create mode 100644 src/backend_task/sign_and_broadcast_state_transition.rs create mode 100644 src/model/key_exchange_request.rs create mode 100644 src/model/state_transition_request.rs create mode 100644 src/ui/dashpay/key_exchange_confirmation.rs create mode 100644 src/ui/tools/state_transition_signing_screen.rs diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index bdad43d1f..b9be7d2e3 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -483,7 +483,12 @@ impl AppContext { const FALLBACK_STEP: u64 = 100; let network = self.wallet_network_key(); - let current_height = wm.current_height(); + let current_height = self + .spv_manager() + .status() + .sync_progress + .map(|p| p.header_height) + .unwrap_or(0); let total_amount: u64 = recipients.iter().map(|(_, amt)| *amt).sum(); let mut scale_factor = 1.0f64; let mut attempted_fallback = false; diff --git a/src/backend_task/dashpay.rs b/src/backend_task/dashpay.rs index 982c21a53..4bbb246c7 100644 --- a/src/backend_task/dashpay.rs +++ b/src/backend_task/dashpay.rs @@ -15,12 +15,14 @@ pub mod encryption_tests; pub mod errors; pub mod hd_derivation; pub mod incoming_payments; +pub mod key_exchange; pub mod payments; pub mod profile; pub mod validation; pub use contacts::ContactData; +use crate::model::key_exchange_request::KeyExchangeRequest; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::platform::{Identifier, IdentityPublicKey}; @@ -90,6 +92,11 @@ pub enum DashPayTask { RegisterDashPayAddresses { identity: QualifiedIdentity, }, + /// Handle a key exchange request (YAPPR protocol) + HandleKeyExchangeRequest { + identity: QualifiedIdentity, + request: KeyExchangeRequest, + }, } impl AppContext { @@ -233,6 +240,9 @@ impl AppContext { } ))) } + DashPayTask::HandleKeyExchangeRequest { identity, request } => { + key_exchange::handle_key_exchange_request(self, sdk, &identity, &request).await + } } } } diff --git a/src/backend_task/dashpay/key_exchange.rs b/src/backend_task/dashpay/key_exchange.rs new file mode 100644 index 000000000..9f85f05ed --- /dev/null +++ b/src/backend_task/dashpay/key_exchange.rs @@ -0,0 +1,695 @@ +//! Key Exchange Protocol (YAPPR) implementation +//! +//! This module implements the Dash Platform Application Key Exchange Protocol, +//! enabling web apps to securely obtain deterministic login keys from the wallet. +//! +//! Protocol flow: +//! 1. Web app generates ephemeral keypair and displays `dash-key:` URI +//! 2. User pastes URI into wallet, selects identity, approves +//! 3. Wallet derives login key (BIP32 + HKDF), encrypts with ECDH, publishes to Platform +//! 4. Web app polls for response document, decrypts login key + +use crate::backend_task::BackendTaskSuccessResult; +use crate::context::AppContext; +use crate::model::key_exchange_request::KeyExchangeRequest; +use crate::model::qualified_identity::QualifiedIdentity; +use aes_gcm::{ + Aes256Gcm, Nonce, + aead::{Aead, KeyInit}, +}; +use bip39::rand::{RngCore, SeedableRng, rngs::StdRng}; +use dash_sdk::Sdk; +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::document::{Document as DppDocument, DocumentV0}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; +use dash_sdk::dpp::platform_value::{Bytes32, Value}; +use dash_sdk::platform::Identifier; +use hkdf::Hkdf; +use sha2::Sha256; +use std::collections::{BTreeMap, HashSet}; +use std::str::FromStr; +use std::sync::Arc; + +/// Feature index for key exchange (m/9'/coin_type'/21'/...) +const KEY_EXCHANGE_FEATURE: u32 = 21; + +/// HKDF salt for deriving the shared secret +const ECDH_HKDF_SALT: &[u8] = b"dash:key-exchange:v1"; + +/// AES-GCM nonce size in bytes +const NONCE_SIZE: usize = 12; + +/// AES-GCM tag size in bytes +#[allow(dead_code)] +const TAG_SIZE: usize = 16; + +/// Handle a key exchange request from the user. +/// +/// This is the main entry point called when the user approves a key exchange request. +/// It: +/// 1. Derives the deterministic login key for the contract +/// 2. Generates an ephemeral keypair for ECDH +/// 3. Encrypts the login key with the shared secret +/// 4. Publishes the response document to Platform +pub async fn handle_key_exchange_request( + app_context: &Arc, + sdk: &Sdk, + identity: &QualifiedIdentity, + request: &KeyExchangeRequest, +) -> Result { + // Get wallet seed for key derivation + let wallet_seed = get_wallet_seed(identity)?; + + // Derive the login key + let login_key = derive_login_key( + &wallet_seed, + app_context.network, + &identity.identity.id(), + &request.contract_id, + request.key_index, + )?; + + // Generate wallet's ephemeral keypair for ECDH + let (wallet_ephemeral_priv, wallet_ephemeral_pub) = generate_ephemeral_keypair()?; + + // Parse app's ephemeral public key + let app_ephemeral_pub = PublicKey::from_slice(&request.app_ephemeral_pub_key) + .map_err(|e| format!("Invalid app ephemeral public key: {}", e))?; + + // Derive ECDH shared secret + let shared_secret = ecdh_shared_secret(&wallet_ephemeral_priv, &app_ephemeral_pub)?; + + // Encrypt the login key + let encrypted_payload = encrypt_login_key(&login_key, &shared_secret)?; + + // Publish the response document + publish_login_key_response( + app_context, + sdk, + identity, + &request.contract_id, + request.key_index, + &wallet_ephemeral_pub.serialize(), + &encrypted_payload, + ) + .await?; + + Ok(BackendTaskSuccessResult::KeyExchangeComplete { + contract_id: request.contract_id, + app_label: request.label.clone(), + }) +} + +/// Get the wallet seed for HD derivation. +fn get_wallet_seed(identity: &QualifiedIdentity) -> Result, String> { + // Use ENCRYPTION key for derivation (matches DashPay pattern) + let encryption_key = identity + .identity + .get_first_public_key_matching( + Purpose::ENCRYPTION, + HashSet::from([SecurityLevel::MEDIUM]), + HashSet::from([KeyType::ECDSA_SECP256K1]), + false, + ) + .ok_or("No suitable ENCRYPTION key found for key derivation")?; + + let wallets: Vec<_> = identity.associated_wallets.values().cloned().collect(); + + let seed = identity + .private_keys + .get_resolve( + &( + crate::model::qualified_identity::PrivateKeyTarget::PrivateKeyOnMainIdentity, + encryption_key.id(), + ), + &wallets, + identity.network, + ) + .map_err(|e| format!("Error resolving wallet seed: {}", e))? + .map(|(_, private_key)| private_key) + .ok_or("Wallet seed not found")?; + + Ok(seed.to_vec()) +} + +/// Derive the deterministic login key for a contract. +/// +/// Derivation path: m/9'/coin_type'/21'/account' +/// Then HKDF with identity_id as salt and contract_id || key_index as info +/// +/// Note: coin_type = 5 for mainnet, 1 for testnet/devnet +pub fn derive_login_key( + wallet_seed: &[u8], + network: Network, + identity_id: &Identifier, + contract_id: &Identifier, + key_index: u32, +) -> Result<[u8; 32], String> { + // Determine coin type based on network + let coin_type = match network { + Network::Dash => 5, + Network::Testnet | Network::Devnet | Network::Regtest => 1, + _ => return Err(format!("Unsupported network: {:?}", network)), + }; + + // Build derivation path: m/9'/coin_type'/21'/0' (account 0) + let path = + DerivationPath::from_str(&format!("m/9'/{}'/{}'/0'", coin_type, KEY_EXCHANGE_FEATURE)) + .map_err(|e| format!("Invalid derivation path: {}", e))?; + + // Create master key from seed + let master_key = ExtendedPrivKey::new_master(network, wallet_seed) + .map_err(|e| format!("Failed to create master key: {}", e))?; + + // Derive the base key + let secp = Secp256k1::new(); + let base_key = master_key + .derive_priv(&secp, &path) + .map_err(|e| format!("Failed to derive base key: {}", e))?; + + // Extract the private key bytes + let base_private_key = base_key.private_key.secret_bytes(); + + // Build HKDF info: contract_id (32 bytes) || key_index (4 bytes LE) + let mut info = Vec::with_capacity(36); + info.extend_from_slice(&contract_id.to_buffer()); + info.extend_from_slice(&key_index.to_le_bytes()); + + // Use identity_id as salt + let salt = identity_id.to_buffer(); + + // Derive the login key using HKDF-SHA256 + let hkdf = Hkdf::::new(Some(&salt), &base_private_key); + let mut login_key = [0u8; 32]; + hkdf.expand(&info, &mut login_key) + .map_err(|_| "HKDF expansion failed")?; + + Ok(login_key) +} + +/// Generate a fresh ephemeral keypair for ECDH. +pub fn generate_ephemeral_keypair() -> Result<(SecretKey, PublicKey), String> { + let secp = Secp256k1::new(); + let mut rng = StdRng::from_entropy(); + + // Generate random 32 bytes for the private key + let mut key_bytes = [0u8; 32]; + rng.fill_bytes(&mut key_bytes); + + let secret_key = SecretKey::from_slice(&key_bytes) + .map_err(|e| format!("Failed to create secret key: {}", e))?; + let public_key = PublicKey::from_secret_key(&secp, &secret_key); + + Ok((secret_key, public_key)) +} + +/// Derive ECDH shared secret from private key and public key. +/// +/// Uses HKDF to derive a 32-byte shared secret suitable for AES-256-GCM. +pub fn ecdh_shared_secret( + private_key: &SecretKey, + public_key: &PublicKey, +) -> Result<[u8; 32], String> { + // Perform ECDH multiplication + let shared_point = + dash_sdk::dpp::dashcore::secp256k1::ecdh::shared_secret_point(public_key, private_key); + + // Extract x coordinate (first 32 bytes) + let shared_x = &shared_point[..32]; + + // Derive the shared secret using HKDF + let hkdf = Hkdf::::new(Some(ECDH_HKDF_SALT), shared_x); + let mut shared_secret = [0u8; 32]; + hkdf.expand(&[], &mut shared_secret) + .map_err(|_| "HKDF expansion failed for shared secret")?; + + Ok(shared_secret) +} + +/// Encrypt the login key using AES-256-GCM. +/// +/// Returns: nonce (12 bytes) || ciphertext || tag (16 bytes) +pub fn encrypt_login_key( + login_key: &[u8; 32], + shared_secret: &[u8; 32], +) -> Result, String> { + // Create AES-256-GCM cipher + let cipher = Aes256Gcm::new_from_slice(shared_secret) + .map_err(|e| format!("Failed to create cipher: {}", e))?; + + // Generate random nonce + let mut rng = StdRng::from_entropy(); + let mut nonce_bytes = [0u8; NONCE_SIZE]; + rng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + // Encrypt the login key + let ciphertext = cipher + .encrypt(nonce, login_key.as_ref()) + .map_err(|e| format!("Encryption failed: {}", e))?; + + // Build output: nonce || ciphertext (includes tag) + let mut result = Vec::with_capacity(NONCE_SIZE + ciphertext.len()); + result.extend_from_slice(&nonce_bytes); + result.extend_from_slice(&ciphertext); + + Ok(result) +} + +/// Publish the login key response document to Platform. +/// +/// Document type: loginKeyResponse on the keyExchange contract +/// Index: unique on ($ownerId, contractId) +/// +/// Per YAPPR spec Section 7.3, this function checks for an existing document +/// and updates it if found, or creates a new one if not. +#[allow(clippy::too_many_arguments)] +async fn publish_login_key_response( + app_context: &Arc, + sdk: &Sdk, + identity: &QualifiedIdentity, + target_contract_id: &Identifier, + key_index: u32, + wallet_ephemeral_pub_key: &[u8; 33], + encrypted_payload: &[u8], +) -> Result<(), String> { + use dash_sdk::dpp::document::DocumentV0Setters; + use dash_sdk::drive::query::{WhereClause, WhereOperator}; + use dash_sdk::platform::{DataContract, Document, DocumentQuery, Fetch, FetchMany}; + + // Get the key exchange contract ID + let contract_id = app_context + .key_exchange_contract_id() + .ok_or("Key exchange contract ID not configured")?; + + tracing::info!( + "Fetching key exchange contract: {}", + contract_id.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) + ); + + // Fetch the key exchange contract from the network + let key_exchange_contract = DataContract::fetch(sdk, contract_id) + .await + .map_err(|e| format!("Failed to fetch key exchange contract: {}", e))? + .ok_or("Key exchange contract not found on network")?; + + // Store the contract in the database so the SDK's context provider can find it + // during document validation + app_context + .db + .insert_contract_if_not_exists( + &key_exchange_contract, + Some("keyExchange"), + crate::database::contracts::InsertTokensToo::NoTokensShouldBeAdded, + app_context, + ) + .map_err(|e| format!("Failed to store key exchange contract: {}", e))?; + + // Log contract details for debugging + tracing::info!( + "Fetched contract ID: {}, document types: {:?}", + key_exchange_contract + .id() + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58), + key_exchange_contract + .document_types() + .keys() + .collect::>() + ); + + let key_exchange_contract = Arc::new(key_exchange_contract); + + // Get signing key + let signing_key = identity + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ]), + HashSet::from([KeyType::ECDSA_SECP256K1]), + false, + ) + .ok_or("No suitable signing key found")? + .clone(); + + // Helper to query for existing document + let query_existing = || async { + let mut query = DocumentQuery::new(key_exchange_contract.clone(), "loginKeyResponse") + .map_err(|e| format!("Failed to create query: {}", e))?; + + query = query + .with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(identity.identity.id().to_buffer()), + }) + .with_where(WhereClause { + field: "contractId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(target_contract_id.to_buffer()), + }); + query.limit = 1; + + let existing_docs = Document::fetch_many(sdk, query) + .await + .map_err(|e| format!("Error checking for existing response: {}", e))?; + + Ok::<_, String>(existing_docs.into_iter().find_map(|(_, doc)| doc)) + }; + + // Try to update with retry on revision conflict + const MAX_RETRIES: u32 = 3; + let mut last_error: Option = None; + + for attempt in 1..=MAX_RETRIES { + // Query for existing document (fresh query each attempt) + let existing_doc = query_existing().await?; + + if let Some(mut doc) = existing_doc { + // Update existing document + use dash_sdk::dpp::document::DocumentV0Getters; + + let current_revision = doc.revision(); + tracing::info!( + "Attempt {}/{}: Found existing loginKeyResponse document with revision {:?}, updating it", + attempt, + MAX_RETRIES, + current_revision + ); + + // Update properties + doc.set( + "walletEphemeralPubKey", + Value::Bytes(wallet_ephemeral_pub_key.to_vec()), + ); + doc.set("encryptedPayload", Value::Bytes(encrypted_payload.to_vec())); + doc.set("keyIndex", Value::U32(key_index)); + + // Bump revision - this should increment by 1 + doc.bump_revision(); + + let new_revision = doc.revision(); + tracing::info!("After bump_revision: revision is now {:?}", new_revision); + + // Create replacement transition + use dash_sdk::platform::documents::transitions::DocumentReplaceTransitionBuilder; + let mut builder = DocumentReplaceTransitionBuilder::new( + key_exchange_contract.clone(), + "loginKeyResponse".to_string(), + doc, + ); + + // Add state transition options if available + if let Some(options) = app_context.state_transition_options() { + builder = builder.with_state_transition_creation_options(options); + } + + // Submit to Platform + match sdk.document_replace(builder, &signing_key, identity).await { + Ok(_) => { + tracing::info!( + "Updated key exchange response for contract {}", + target_contract_id.to_string( + dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58 + ) + ); + return Ok(()); + } + Err(e) => { + let error_str = e.to_string(); + // Check if this is a revision conflict error + if error_str.contains("invalid revision") + || error_str.contains("desired revision") + { + tracing::warn!( + "Attempt {}/{}: Revision conflict, will retry: {}", + attempt, + MAX_RETRIES, + error_str + ); + last_error = Some(error_str); + // Small delay before retry to allow Platform state to settle + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + continue; + } else { + return Err(format!("Failed to update key exchange response: {}", e)); + } + } + } + } else { + // No existing document - create new one + tracing::info!("No existing loginKeyResponse document found, creating new one"); + + // Build document properties + let mut properties = BTreeMap::new(); + properties.insert( + "contractId".to_string(), + Value::Identifier(target_contract_id.to_buffer()), + ); + properties.insert( + "walletEphemeralPubKey".to_string(), + Value::Bytes(wallet_ephemeral_pub_key.to_vec()), + ); + properties.insert( + "encryptedPayload".to_string(), + Value::Bytes(encrypted_payload.to_vec()), + ); + properties.insert("keyIndex".to_string(), Value::U32(key_index)); + + // Generate entropy for document ID + let mut rng = StdRng::from_entropy(); + let entropy = Bytes32::random_with_rng(&mut rng); + + // Generate document ID + let document_id = Document::generate_document_id_v0( + &key_exchange_contract.id(), + &identity.identity.id(), + "loginKeyResponse", + entropy.as_slice(), + ); + + // Create the document + let document = DppDocument::V0(DocumentV0 { + id: document_id, + owner_id: identity.identity.id(), + creator_id: None, + properties, + revision: Some(1), + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + }); + + // Build and submit the document + let mut builder = + dash_sdk::platform::documents::transitions::DocumentCreateTransitionBuilder::new( + key_exchange_contract.clone(), + "loginKeyResponse".to_string(), + document, + entropy + .as_slice() + .try_into() + .expect("entropy should be 32 bytes"), + ); + + // Add state transition options if available + if let Some(options) = app_context.state_transition_options() { + builder = builder.with_state_transition_creation_options(options); + } + + // Submit to Platform + sdk.document_create(builder, &signing_key, identity) + .await + .map_err(|e| format!("Failed to publish key exchange response: {}", e))?; + + tracing::info!( + "Published new key exchange response for contract {}", + target_contract_id + .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) + ); + + return Ok(()); + } + } + + // If we get here, all retries failed + Err(last_error + .unwrap_or_else(|| "Failed to update key exchange response after retries".to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_derive_login_key_deterministic() { + let seed = [0x42u8; 64]; + let identity_id = Identifier::from_bytes(&[0x11u8; 32]).unwrap(); + let contract_id = Identifier::from_bytes(&[0x22u8; 32]).unwrap(); + + // Derive twice with same inputs + let key1 = derive_login_key(&seed, Network::Testnet, &identity_id, &contract_id, 0) + .expect("Should derive"); + let key2 = derive_login_key(&seed, Network::Testnet, &identity_id, &contract_id, 0) + .expect("Should derive"); + + assert_eq!(key1, key2, "Same inputs should produce same key"); + } + + #[test] + fn test_derive_login_key_different_contracts() { + let seed = [0x42u8; 64]; + let identity_id = Identifier::from_bytes(&[0x11u8; 32]).unwrap(); + let contract_id_1 = Identifier::from_bytes(&[0x22u8; 32]).unwrap(); + let contract_id_2 = Identifier::from_bytes(&[0x33u8; 32]).unwrap(); + + let key1 = derive_login_key(&seed, Network::Testnet, &identity_id, &contract_id_1, 0) + .expect("Should derive"); + let key2 = derive_login_key(&seed, Network::Testnet, &identity_id, &contract_id_2, 0) + .expect("Should derive"); + + assert_ne!( + key1, key2, + "Different contracts should produce different keys" + ); + } + + #[test] + fn test_derive_login_key_different_indices() { + let seed = [0x42u8; 64]; + let identity_id = Identifier::from_bytes(&[0x11u8; 32]).unwrap(); + let contract_id = Identifier::from_bytes(&[0x22u8; 32]).unwrap(); + + let key1 = derive_login_key(&seed, Network::Testnet, &identity_id, &contract_id, 0) + .expect("Should derive"); + let key2 = derive_login_key(&seed, Network::Testnet, &identity_id, &contract_id, 1) + .expect("Should derive"); + + assert_ne!( + key1, key2, + "Different indices should produce different keys" + ); + } + + #[test] + fn test_derive_login_key_different_identities() { + let seed = [0x42u8; 64]; + let identity_id_1 = Identifier::from_bytes(&[0x11u8; 32]).unwrap(); + let identity_id_2 = Identifier::from_bytes(&[0x44u8; 32]).unwrap(); + let contract_id = Identifier::from_bytes(&[0x22u8; 32]).unwrap(); + + let key1 = derive_login_key(&seed, Network::Testnet, &identity_id_1, &contract_id, 0) + .expect("Should derive"); + let key2 = derive_login_key(&seed, Network::Testnet, &identity_id_2, &contract_id, 0) + .expect("Should derive"); + + assert_ne!( + key1, key2, + "Different identities should produce different keys" + ); + } + + #[test] + fn test_generate_ephemeral_keypair() { + let (priv1, pub1) = generate_ephemeral_keypair().expect("Should generate"); + let (priv2, pub2) = generate_ephemeral_keypair().expect("Should generate"); + + // Verify they're different (random) + assert_ne!( + priv1.secret_bytes(), + priv2.secret_bytes(), + "Different calls should produce different keys" + ); + assert_ne!( + pub1.serialize(), + pub2.serialize(), + "Different calls should produce different public keys" + ); + + // Verify public key is compressed (33 bytes starting with 02 or 03) + let pub_bytes = pub1.serialize(); + assert_eq!(pub_bytes.len(), 33); + assert!(pub_bytes[0] == 0x02 || pub_bytes[0] == 0x03); + } + + #[test] + fn test_ecdh_shared_secret() { + let (priv_a, pub_a) = generate_ephemeral_keypair().expect("Should generate"); + let (priv_b, pub_b) = generate_ephemeral_keypair().expect("Should generate"); + + // Both parties should derive the same shared secret + let secret_a = ecdh_shared_secret(&priv_a, &pub_b).expect("Should derive"); + let secret_b = ecdh_shared_secret(&priv_b, &pub_a).expect("Should derive"); + + assert_eq!(secret_a, secret_b, "ECDH should produce same shared secret"); + } + + #[test] + fn test_encrypt_login_key() { + let login_key = [0xAB; 32]; + let shared_secret = [0xCD; 32]; + + let encrypted = encrypt_login_key(&login_key, &shared_secret).expect("Should encrypt"); + + // Expected size: 12 (nonce) + 32 (ciphertext) + 16 (tag) = 60 bytes + assert_eq!(encrypted.len(), NONCE_SIZE + 32 + TAG_SIZE); + + // Encrypt again - should produce different result due to random nonce + let encrypted2 = encrypt_login_key(&login_key, &shared_secret).expect("Should encrypt"); + assert_ne!( + encrypted, encrypted2, + "Different nonces should produce different ciphertext" + ); + } + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let login_key = [0x42; 32]; + let shared_secret = [0xEF; 32]; + + let encrypted = encrypt_login_key(&login_key, &shared_secret).expect("Should encrypt"); + + // Manually decrypt to verify + let nonce = &encrypted[..NONCE_SIZE]; + let ciphertext = &encrypted[NONCE_SIZE..]; + + let cipher = Aes256Gcm::new_from_slice(&shared_secret).expect("Should create cipher"); + let decrypted = cipher + .decrypt(Nonce::from_slice(nonce), ciphertext) + .expect("Should decrypt"); + + assert_eq!(decrypted.as_slice(), &login_key); + } + + #[test] + fn test_coin_type_selection() { + let seed = [0x42u8; 64]; + let identity_id = Identifier::from_bytes(&[0x11u8; 32]).unwrap(); + let contract_id = Identifier::from_bytes(&[0x22u8; 32]).unwrap(); + + // Mainnet and testnet should produce different keys due to different coin types + let key_mainnet = derive_login_key(&seed, Network::Dash, &identity_id, &contract_id, 0) + .expect("Should derive"); + let key_testnet = derive_login_key(&seed, Network::Testnet, &identity_id, &contract_id, 0) + .expect("Should derive"); + + assert_ne!( + key_mainnet, key_testnet, + "Different networks should produce different keys" + ); + } +} diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 7d8413fad..8e2a85efa 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -51,6 +51,7 @@ pub mod identity; pub mod mnlist; pub mod platform_info; pub mod register_contract; +pub mod sign_and_broadcast_state_transition; pub mod system_task; pub mod tokens; pub mod update_data_contract; @@ -86,6 +87,11 @@ pub enum BackendTask { CoreTask(CoreTask), DashPayTask(Box), BroadcastStateTransition(StateTransition), + SignAndBroadcastStateTransition { + identity: QualifiedIdentity, + signing_key: dash_sdk::platform::IdentityPublicKey, + state_transition: Box, + }, TokenTask(Box), SystemTask(SystemTask), MnListTask(mnlist::MnListTask), @@ -166,6 +172,10 @@ pub enum BackendTaskSuccessResult { DashPayContactAlreadyEstablished(Identifier), // Contact ID that already exists DashPayContactInfoUpdated(Identifier), // Contact ID whose info was updated DashPayPaymentSent(String, String, f64), // (recipient, address, amount) + KeyExchangeComplete { + contract_id: Identifier, + app_label: Option, + }, GeneratedZKProof(ProofDataOutput), VerifiedZKProof(bool, ProofDataOutput), GeneratedReceiveAddress { @@ -337,6 +347,19 @@ impl AppContext { self.broadcast_state_transition(state_transition, &sdk) .await } + BackendTask::SignAndBroadcastStateTransition { + identity, + signing_key, + state_transition, + } => { + self.sign_and_broadcast_state_transition( + &sdk, + &identity, + &signing_key, + *state_transition, + ) + .await + } BackendTask::TokenTask(token_task) => { self.run_token_task(*token_task, &sdk, sender).await } diff --git a/src/backend_task/sign_and_broadcast_state_transition.rs b/src/backend_task/sign_and_broadcast_state_transition.rs new file mode 100644 index 000000000..f557f6645 --- /dev/null +++ b/src/backend_task/sign_and_broadcast_state_transition.rs @@ -0,0 +1,36 @@ +//! Sign and broadcast state transitions requested via dash-st: URIs + +use dash_sdk::Sdk; +use dash_sdk::dpp::state_transition::StateTransition; +use dash_sdk::platform::IdentityPublicKey; +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; + +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; + +use super::BackendTaskSuccessResult; + +impl AppContext { + /// Sign and broadcast a state transition on behalf of an external application + /// + /// This is used for dash-st: URI requests where an external app provides + /// an unsigned state transition that needs signing and broadcasting. + pub async fn sign_and_broadcast_state_transition( + &self, + sdk: &Sdk, + identity: &QualifiedIdentity, + signing_key: &IdentityPublicKey, + mut state_transition: StateTransition, + ) -> Result { + // Sign the state transition using the identity's key + state_transition + .sign_external(signing_key, identity, None:: _>) + .map_err(|e| format!("Failed to sign state transition: {}", e))?; + + // Broadcast the signed state transition + match state_transition.broadcast(sdk, None).await { + Ok(_) => Ok(BackendTaskSuccessResult::BroadcastedStateTransition), + Err(e) => Err(format!("Error broadcasting state transition: {}", e)), + } + } +} diff --git a/src/model/key_exchange_request.rs b/src/model/key_exchange_request.rs new file mode 100644 index 000000000..63c67c57d --- /dev/null +++ b/src/model/key_exchange_request.rs @@ -0,0 +1,469 @@ +//! Key Exchange Request data structures for YAPPR protocol +//! +//! This module handles parsing and validation of `dash-key:` URIs used for +//! web app key exchange (YAPPR - Yet Another Protocol for Platform Requests). + +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::dashcore::base58; +use dash_sdk::platform::Identifier; + +/// Maximum label length in bytes (per spec) +const MAX_LABEL_LENGTH: usize = 64; + +/// A parsed key exchange request from a `dash-key:` URI. +/// +/// Web apps generate these URIs (displayed as QR codes or copyable text) to request +/// a deterministic login key from the wallet. +#[derive(Debug, Clone, PartialEq)] +pub struct KeyExchangeRequest { + /// Protocol version (must be 1 for current implementation) + pub version: u8, + /// App's ephemeral public key for ECDH (33 bytes compressed secp256k1) + pub app_ephemeral_pub_key: [u8; 33], + /// Target contract ID that the login key is for + pub contract_id: Identifier, + /// Key derivation index + pub key_index: u32, + /// Optional display label for the app (0-64 chars) + pub label: Option, +} + +impl KeyExchangeRequest { + /// Parse a `dash-key:` URI into a KeyExchangeRequest and network. + /// + /// URI Format: `dash-key:?n=&v=` + /// + /// The base58 data contains (per YAPPR spec Section 8.1): + /// - Version (1 byte) - offset 0 + /// - App ephemeral public key (33 bytes) - offset 1-33 + /// - Contract ID (32 bytes) - offset 34-65 + /// - Key index (4 bytes, little-endian) - offset 66-69 + /// - Label length (1 byte) - offset 70 + /// - Label (0-64 bytes) - offset 71+ + /// + /// Query parameters: + /// - `n`: Network (mainnet, testnet, devnet) - required + /// - `v`: Version (must be 1) - required, must match payload version + /// + /// # Returns + /// A tuple of (KeyExchangeRequest, Network) on success, or an error string. + pub fn from_uri(uri: &str) -> Result<(Self, Network), String> { + // Check prefix + if !uri.starts_with("dash-key:") { + return Err("Invalid URI format - must start with 'dash-key:'".to_string()); + } + + // Split off the prefix + let rest = &uri[9..]; // Skip "dash-key:" + + // Split into data and query parts + let (data_part, query_part) = if let Some(pos) = rest.find('?') { + (&rest[..pos], Some(&rest[pos + 1..])) + } else { + (rest, None) + }; + + // Parse query parameters + let mut query_version: Option = None; + let mut network = Network::Dash; // Default to mainnet + + if let Some(query) = query_part { + for param in query.split('&') { + let parts: Vec<&str> = param.split('=').collect(); + if parts.len() != 2 { + continue; + } + + match parts[0] { + "v" => { + query_version = Some( + parts[1] + .parse::() + .map_err(|_| "Invalid version parameter")?, + ); + } + "n" => { + network = match parts[1].to_lowercase().as_str() { + "mainnet" | "dash" | "m" => Network::Dash, + "testnet" | "t" => Network::Testnet, + "devnet" | "d" => Network::Devnet, + "regtest" | "r" => Network::Regtest, + _ => return Err(format!("Unknown network: {}", parts[1])), + }; + } + _ => {} // Ignore unknown parameters + } + } + } + + // Decode base58 data + let data = + base58::decode(data_part).map_err(|e| format!("Invalid base58 encoding: {}", e))?; + + // Minimum data length: 1 (version) + 33 (pubkey) + 32 (contract_id) + 4 (key_index) + 1 (label_len) = 71 + if data.len() < 71 { + return Err(format!("Data too short: {} bytes (minimum 71)", data.len())); + } + + // Parse version from payload (1 byte at offset 0) + let payload_version = data[0]; + + // Validate version + if payload_version != 1 { + return Err(format!( + "Unsupported protocol version: {} (only version 1 is supported)", + payload_version + )); + } + + // If query version is specified, it must match payload version + if let Some(qv) = query_version + && qv != payload_version + { + return Err(format!( + "Version mismatch: query parameter v={} but payload version is {}", + qv, payload_version + )); + } + + // Parse app ephemeral public key (33 bytes at offset 1-33) + let app_ephemeral_pub_key: [u8; 33] = data[1..34] + .try_into() + .map_err(|_| "Failed to parse ephemeral public key")?; + + // Validate the public key format (must start with 0x02 or 0x03 for compressed) + if app_ephemeral_pub_key[0] != 0x02 && app_ephemeral_pub_key[0] != 0x03 { + return Err("Invalid ephemeral public key format (must be compressed)".to_string()); + } + + // Parse contract ID (32 bytes at offset 34-65) + let contract_id_bytes: [u8; 32] = data[34..66] + .try_into() + .map_err(|_| "Failed to parse contract ID")?; + let contract_id = Identifier::from_bytes(&contract_id_bytes) + .map_err(|e| format!("Invalid contract ID: {}", e))?; + + // Parse key index (4 bytes, little-endian at offset 66-69) + let key_index = u32::from_le_bytes( + data[66..70] + .try_into() + .map_err(|_| "Failed to parse key index")?, + ); + + // Parse label length (1 byte at offset 70) + let label_len = data[70] as usize; + + // Validate label length + if label_len > MAX_LABEL_LENGTH { + return Err(format!( + "Label too long: {} bytes (maximum {})", + label_len, MAX_LABEL_LENGTH + )); + } + + // Validate total length matches expected + let expected_len = 71 + label_len; + if data.len() != expected_len { + return Err(format!( + "Invalid data length: {} bytes (expected {})", + data.len(), + expected_len + )); + } + + // Parse label if present (offset 71+) + let label = if label_len > 0 { + let label_bytes = &data[71..71 + label_len]; + Some(String::from_utf8(label_bytes.to_vec()).map_err(|_| "Invalid UTF-8 in label")?) + } else { + None + }; + + Ok(( + Self { + version: payload_version, + app_ephemeral_pub_key, + contract_id, + key_index, + label, + }, + network, + )) + } + + /// Get the display name for this request (label or "Unknown App") + pub fn display_name(&self) -> &str { + self.label.as_deref().unwrap_or("Unknown App") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_uri( + contract_id: &[u8; 32], + key_index: u32, + pubkey: &[u8; 33], + label: Option<&str>, + network: Option<&str>, + version: Option, + ) -> String { + // Build data per YAPPR spec Section 8.1: + // - Version (1 byte) - offset 0 + // - App ephemeral public key (33 bytes) - offset 1-33 + // - Contract ID (32 bytes) - offset 34-65 + // - Key index (4 bytes, little-endian) - offset 66-69 + // - Label length (1 byte) - offset 70 + // - Label (0-64 bytes) - offset 71+ + let mut data = Vec::new(); + data.push(version.unwrap_or(1)); // Version byte + data.extend_from_slice(pubkey); // 33 bytes + data.extend_from_slice(contract_id); // 32 bytes + data.extend_from_slice(&key_index.to_le_bytes()); // 4 bytes, little-endian + + if let Some(l) = label { + data.push(l.len() as u8); + data.extend_from_slice(l.as_bytes()); + } else { + data.push(0); + } + + let encoded = base58::encode_slice(&data); + let mut uri = format!("dash-key:{}", encoded); + + let mut params = Vec::new(); + if let Some(n) = network { + params.push(format!("n={}", n)); + } + if let Some(v) = version { + params.push(format!("v={}", v)); + } + + if !params.is_empty() { + uri.push('?'); + uri.push_str(¶ms.join("&")); + } + + uri + } + + #[test] + fn test_parse_valid_uri_no_label() { + let contract_id = [0x11u8; 32]; + let pubkey = { + let mut p = [0u8; 33]; + p[0] = 0x02; // Compressed pubkey prefix + p + }; + + let uri = create_test_uri(&contract_id, 42, &pubkey, None, Some("testnet"), Some(1)); + + let (request, network) = KeyExchangeRequest::from_uri(&uri).expect("Should parse"); + + assert_eq!(request.version, 1); + assert_eq!(request.key_index, 42); + assert_eq!(request.app_ephemeral_pub_key, pubkey); + assert_eq!(request.label, None); + assert_eq!(network, Network::Testnet); + } + + #[test] + fn test_parse_valid_uri_with_label() { + let contract_id = [0x22u8; 32]; + let pubkey = { + let mut p = [0u8; 33]; + p[0] = 0x03; + p + }; + + let uri = create_test_uri( + &contract_id, + 100, + &pubkey, + Some("My Cool App"), + Some("mainnet"), + Some(1), + ); + + let (request, network) = KeyExchangeRequest::from_uri(&uri).expect("Should parse"); + + assert_eq!(request.version, 1); + assert_eq!(request.key_index, 100); + assert_eq!(request.label, Some("My Cool App".to_string())); + assert_eq!(request.display_name(), "My Cool App"); + assert_eq!(network, Network::Dash); + } + + #[test] + fn test_parse_default_network_and_version() { + let contract_id = [0x33u8; 32]; + let pubkey = { + let mut p = [0u8; 33]; + p[0] = 0x02; + p + }; + + // No query parameters - should default to mainnet and version 1 + let uri = create_test_uri(&contract_id, 0, &pubkey, None, None, None); + + let (request, network) = KeyExchangeRequest::from_uri(&uri).expect("Should parse"); + + assert_eq!(request.version, 1); + assert_eq!(network, Network::Dash); + } + + #[test] + fn test_parse_short_network_codes() { + let contract_id = [0x33u8; 32]; + let pubkey = { + let mut p = [0u8; 33]; + p[0] = 0x02; + p + }; + + // Test "t" for testnet + let uri = create_test_uri(&contract_id, 0, &pubkey, None, Some("t"), Some(1)); + let (_, network) = KeyExchangeRequest::from_uri(&uri).expect("Should parse"); + assert_eq!(network, Network::Testnet); + + // Test "m" for mainnet + let uri = create_test_uri(&contract_id, 0, &pubkey, None, Some("m"), Some(1)); + let (_, network) = KeyExchangeRequest::from_uri(&uri).expect("Should parse"); + assert_eq!(network, Network::Dash); + + // Test "d" for devnet + let uri = create_test_uri(&contract_id, 0, &pubkey, None, Some("d"), Some(1)); + let (_, network) = KeyExchangeRequest::from_uri(&uri).expect("Should parse"); + assert_eq!(network, Network::Devnet); + + // Test "r" for regtest + let uri = create_test_uri(&contract_id, 0, &pubkey, None, Some("r"), Some(1)); + let (_, network) = KeyExchangeRequest::from_uri(&uri).expect("Should parse"); + assert_eq!(network, Network::Regtest); + } + + #[test] + fn test_reject_unsupported_version() { + let contract_id = [0x44u8; 32]; + let pubkey = { + let mut p = [0u8; 33]; + p[0] = 0x02; + p + }; + + let uri = create_test_uri(&contract_id, 0, &pubkey, None, None, Some(2)); + + let result = KeyExchangeRequest::from_uri(&uri); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported protocol version")); + } + + #[test] + fn test_reject_invalid_prefix() { + let result = KeyExchangeRequest::from_uri("dash:?di=abc123"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("must start with 'dash-key:'")); + } + + #[test] + fn test_reject_invalid_pubkey_format() { + let contract_id = [0x55u8; 32]; + let pubkey = [0x04u8; 33]; // Invalid - 0x04 is uncompressed prefix + + let uri = create_test_uri(&contract_id, 0, &pubkey, None, None, None); + + let result = KeyExchangeRequest::from_uri(&uri); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("must be compressed")); + } + + #[test] + fn test_reject_label_too_long() { + let contract_id = [0x66u8; 32]; + let pubkey = { + let mut p = [0u8; 33]; + p[0] = 0x02; + p + }; + + // Create URI with 65-byte label (exceeds 64 byte limit) + let long_label = "x".repeat(65); + let uri = create_test_uri(&contract_id, 0, &pubkey, Some(&long_label), None, None); + + let result = KeyExchangeRequest::from_uri(&uri); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Label too long")); + } + + #[test] + fn test_display_name_with_label() { + let request = KeyExchangeRequest { + version: 1, + app_ephemeral_pub_key: [0x02; 33], + contract_id: Identifier::from_bytes(&[0u8; 32]).unwrap(), + key_index: 0, + label: Some("Test App".to_string()), + }; + + assert_eq!(request.display_name(), "Test App"); + } + + #[test] + fn test_display_name_without_label() { + let request = KeyExchangeRequest { + version: 1, + app_ephemeral_pub_key: [0x02; 33], + contract_id: Identifier::from_bytes(&[0u8; 32]).unwrap(), + key_index: 0, + label: None, + }; + + assert_eq!(request.display_name(), "Unknown App"); + } + #[test] + fn test_parse_real_yappr_uris() { + // Real URIs from Yappr app - both should parse to the same contract ID + let uri1 = "dash-key:KW76jFtvfVEtLAhZAhfAXMjS9kQHFTiuHg4v2yTyTEXurNUvDz14brC1YFKW72u5R1UgHQkfGS7GWdEYqRCVdk4XCEuieHq8xUDQBwmqwFxdyKGdPRT?n=t&v=1"; + let uri2 = "dash-key:KTeX5FvM4rd8jp3sSfZESA9TKokwH1nsRuZZNFno8j7aFitsuordJRdVu5NmieVd7YL77XMYrjm4GzGQS36fTfCK4rR5XiVVxnTTMM7EWqSR8VbybCD?n=t&v=1"; + + let (request1, network1) = KeyExchangeRequest::from_uri(uri1).expect("Should parse URI 1"); + let (request2, network2) = KeyExchangeRequest::from_uri(uri2).expect("Should parse URI 2"); + + // Both should be testnet + assert_eq!(network1, Network::Testnet); + assert_eq!(network2, Network::Testnet); + + // Both should have version 1 + assert_eq!(request1.version, 1); + assert_eq!(request2.version, 1); + + // Contract IDs should be the same (same app) + assert_eq!( + request1.contract_id, request2.contract_id, + "Contract IDs should match for the same app" + ); + + // Key indices should be 0 + assert_eq!(request1.key_index, 0); + assert_eq!(request2.key_index, 0); + + // Labels should be "Login to Yappr" + assert_eq!(request1.label, Some("Login to Yappr".to_string())); + assert_eq!(request2.label, Some("Login to Yappr".to_string())); + + // Ephemeral public keys should be different (fresh for each request) + assert_ne!( + request1.app_ephemeral_pub_key, request2.app_ephemeral_pub_key, + "Ephemeral keys should differ between requests" + ); + + // Both ephemeral keys should start with 0x02 or 0x03 (compressed pubkey) + assert!( + request1.app_ephemeral_pub_key[0] == 0x02 || request1.app_ephemeral_pub_key[0] == 0x03 + ); + assert!( + request2.app_ephemeral_pub_key[0] == 0x02 || request2.app_ephemeral_pub_key[0] == 0x03 + ); + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index de9df9441..b32c459d8 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -2,9 +2,11 @@ pub mod amount; pub mod contested_name; pub mod fee_estimation; pub mod grovestark_prover; +pub mod key_exchange_request; pub mod password_info; pub mod proof_log_item; pub mod qualified_contract; pub mod qualified_identity; pub mod settings; +pub mod state_transition_request; pub mod wallet; diff --git a/src/model/qualified_identity/encrypted_key_storage.rs b/src/model/qualified_identity/encrypted_key_storage.rs index a19eed6d7..055b154a6 100644 --- a/src/model/qualified_identity/encrypted_key_storage.rs +++ b/src/model/qualified_identity/encrypted_key_storage.rs @@ -54,8 +54,8 @@ impl Encode for WalletDerivationPath { } } -impl Decode for WalletDerivationPath { - fn decode(decoder: &mut D) -> Result { +impl Decode for WalletDerivationPath { + fn decode>(decoder: &mut D) -> Result { // Decode `wallet_seed_hash` let wallet_seed_hash = WalletSeedHash::decode(decoder)?; @@ -92,8 +92,10 @@ impl Decode for WalletDerivationPath { } } -impl<'de, Context> BorrowDecode<'de, Context> for WalletDerivationPath { - fn borrow_decode>(decoder: &mut D) -> Result { +impl<'de, C> BorrowDecode<'de, C> for WalletDerivationPath { + fn borrow_decode>( + decoder: &mut D, + ) -> Result { // Decode `wallet_seed_hash` let wallet_seed_hash = WalletSeedHash::decode(decoder)?; diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 4152843ab..510d10f0d 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -270,8 +270,8 @@ impl Encode for QualifiedIdentity { } // Implement Decode manually for QualifiedIdentity, excluding decrypted_wallets -impl Decode for QualifiedIdentity { - fn decode( +impl Decode for QualifiedIdentity { + fn decode>( decoder: &mut D, ) -> Result { Ok(Self { diff --git a/src/model/state_transition_request.rs b/src/model/state_transition_request.rs new file mode 100644 index 000000000..cca8bf278 --- /dev/null +++ b/src/model/state_transition_request.rs @@ -0,0 +1,546 @@ +//! State Transition Request data structures for DIP-signing-request protocol +//! +//! This module handles parsing and validation of `dash-st:` URIs used for +//! external applications to request state transition signing and broadcasting. + +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::dashcore::base58; +use dash_sdk::dpp::identity::KeyID; +use dash_sdk::dpp::serialization::PlatformDeserializable; +use dash_sdk::dpp::state_transition::StateTransition; +use dash_sdk::dpp::state_transition::batch_transition::BatchTransition; +use dash_sdk::dpp::state_transition::data_contract_create_transition::DataContractCreateTransition; +use dash_sdk::dpp::state_transition::data_contract_update_transition::DataContractUpdateTransition; +use dash_sdk::dpp::state_transition::identity_create_transition::IdentityCreateTransition; +use dash_sdk::dpp::state_transition::identity_credit_transfer_transition::IdentityCreditTransferTransition; +use dash_sdk::dpp::state_transition::identity_credit_withdrawal_transition::IdentityCreditWithdrawalTransition; +use dash_sdk::dpp::state_transition::identity_topup_transition::IdentityTopUpTransition; +use dash_sdk::dpp::state_transition::identity_update_transition::IdentityUpdateTransition; +use dash_sdk::dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; +use dash_sdk::platform::Identifier; + +/// Maximum label length in bytes (per spec) +const MAX_LABEL_LENGTH: usize = 64; + +/// A parsed state transition request from a `dash-st:` URI. +/// +/// External applications generate these URIs to request the wallet to sign +/// and broadcast state transitions on their behalf. +#[derive(Debug, Clone)] +pub struct StateTransitionRequest { + /// Protocol version (must be 1 for current implementation) + pub version: u8, + /// The state transition to be signed and broadcast + pub state_transition: StateTransition, + /// Optional identity ID hint (identity that should sign) + pub identity_hint: Option, + /// Optional key ID hint (specific key that should be used) + pub key_id_hint: Option, + /// Optional display label for the request (0-64 chars) + pub label: Option, +} + +impl StateTransitionRequest { + /// Parse a `dash-st:` URI into a StateTransitionRequest and network. + /// + /// URI Format: `dash-st:?n=&v=[&id=][&k=][&l=