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 aed28e3b2..d31126235 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;