From 7e764d0f1b30cbc982a1e78c0767a79556a65b4a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:36:41 +0100 Subject: [PATCH 01/44] feat(ui): unify shield screens into single ShieldScreen with address selection Merge ShieldCreditsScreen and ShieldFromAssetLockScreen into a single ShieldScreen that uses AddressInput to let users pick the source. The screen adapts its behavior based on the selected address type: platform addresses use ShieldCredits, core addresses use ShieldFromAssetLock. One button in the shielded tab replaces two. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/mod.rs | 57 +- src/ui/wallets/mod.rs | 3 +- .../wallets/shield_from_asset_lock_screen.rs | 208 ------ ...eld_credits_screen.rs => shield_screen.rs} | 658 ++++++++++-------- src/ui/wallets/shielded_tab.rs | 24 +- 5 files changed, 388 insertions(+), 562 deletions(-) delete mode 100644 src/ui/wallets/shield_from_asset_lock_screen.rs rename src/ui/wallets/{shield_credits_screen.rs => shield_screen.rs} (52%) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 317a200b1..55bad6233 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -78,8 +78,7 @@ use tokens::unfreeze_tokens_screen::UnfreezeTokensScreen; use tokens::update_token_config::UpdateTokenConfigScreen; use tools::transition_visualizer_screen::TransitionVisualizerScreen; use wallets::add_new_wallet_screen::AddNewWalletScreen; -use wallets::shield_credits_screen::ShieldCreditsScreen; -use wallets::shield_from_asset_lock_screen::ShieldFromAssetLockScreen; +use wallets::shield_screen::ShieldScreen; use wallets::shielded_send_screen::ShieldedSendScreen; use wallets::unshield_credits_screen::UnshieldCreditsScreen; @@ -308,8 +307,7 @@ pub enum ScreenType { CreateAssetLock(Arc>), // Shielded screens - ShieldCreditsScreen(WalletSeedHash), - ShieldFromAssetLockScreen(WalletSeedHash), + ShieldScreen(WalletSeedHash), ShieldedSendScreen(WalletSeedHash), UnshieldCreditsScreen(WalletSeedHash), @@ -429,11 +427,7 @@ impl PartialEq for ScreenType { (ScreenType::DashPayQRGenerator, ScreenType::DashPayQRGenerator) => true, (ScreenType::DashPayProfileSearch, ScreenType::DashPayProfileSearch) => true, // Shielded screens - (ScreenType::ShieldCreditsScreen(a), ScreenType::ShieldCreditsScreen(b)) => a == b, - ( - ScreenType::ShieldFromAssetLockScreen(a), - ScreenType::ShieldFromAssetLockScreen(b), - ) => a == b, + (ScreenType::ShieldScreen(a), ScreenType::ShieldScreen(b)) => a == b, (ScreenType::ShieldedSendScreen(a), ScreenType::ShieldedSendScreen(b)) => a == b, (ScreenType::UnshieldCreditsScreen(a), ScreenType::UnshieldCreditsScreen(b)) => a == b, _ => false, @@ -692,12 +686,9 @@ impl ScreenType { Screen::DashPayProfileSearchScreen(ProfileSearchScreen::new(app_context.clone())) } // Shielded screens - ScreenType::ShieldCreditsScreen(seed_hash) => { - Screen::ShieldCreditsScreen(ShieldCreditsScreen::new(*seed_hash, app_context)) + ScreenType::ShieldScreen(seed_hash) => { + Screen::ShieldScreen(ShieldScreen::new(*seed_hash, app_context)) } - ScreenType::ShieldFromAssetLockScreen(seed_hash) => Screen::ShieldFromAssetLockScreen( - ShieldFromAssetLockScreen::new(*seed_hash, app_context), - ), ScreenType::ShieldedSendScreen(seed_hash) => { Screen::ShieldedSendScreen(ShieldedSendScreen::new(*seed_hash, app_context)) } @@ -763,8 +754,7 @@ pub enum Screen { CreateAssetLockScreen(CreateAssetLockScreen), // Shielded Screens - ShieldCreditsScreen(ShieldCreditsScreen), - ShieldFromAssetLockScreen(ShieldFromAssetLockScreen), + ShieldScreen(ShieldScreen), ShieldedSendScreen(ShieldedSendScreen), UnshieldCreditsScreen(UnshieldCreditsScreen), @@ -879,8 +869,7 @@ impl Screen { Screen::DashPayQRGeneratorScreen(screen) => screen.app_context = app_context, Screen::DashPayProfileSearchScreen(screen) => screen.app_context = app_context, // Shielded screens - Screen::ShieldCreditsScreen(screen) => screen.app_context = app_context.clone(), - Screen::ShieldFromAssetLockScreen(screen) => screen.app_context = app_context.clone(), + Screen::ShieldScreen(screen) => screen.app_context = app_context.clone(), Screen::ShieldedSendScreen(screen) => { screen.app_context = app_context.clone(); screen.invalidate_address_input(); @@ -1108,10 +1097,7 @@ impl Screen { Screen::DashPayQRGeneratorScreen(_) => ScreenType::DashPayQRGenerator, Screen::DashPayProfileSearchScreen(_) => ScreenType::DashPayProfileSearch, // Shielded screens - Screen::ShieldCreditsScreen(s) => ScreenType::ShieldCreditsScreen(s.seed_hash), - Screen::ShieldFromAssetLockScreen(s) => { - ScreenType::ShieldFromAssetLockScreen(s.seed_hash) - } + Screen::ShieldScreen(s) => ScreenType::ShieldScreen(s.seed_hash), Screen::ShieldedSendScreen(s) => ScreenType::ShieldedSendScreen(s.seed_hash), Screen::UnshieldCreditsScreen(s) => ScreenType::UnshieldCreditsScreen(s.seed_hash), } @@ -1183,8 +1169,7 @@ impl ScreenLike for Screen { Screen::DashPayQRGeneratorScreen(_) => {} Screen::DashPayProfileSearchScreen(screen) => screen.refresh(), // Shielded screens - Screen::ShieldCreditsScreen(_) => {} - Screen::ShieldFromAssetLockScreen(_) => {} + Screen::ShieldScreen(_) => {} Screen::ShieldedSendScreen(_) => {} Screen::UnshieldCreditsScreen(_) => {} } @@ -1254,8 +1239,7 @@ impl ScreenLike for Screen { Screen::DashPayQRGeneratorScreen(_) => {} Screen::DashPayProfileSearchScreen(screen) => screen.refresh_on_arrival(), // Shielded screens - Screen::ShieldCreditsScreen(_) => {} - Screen::ShieldFromAssetLockScreen(_) => {} + Screen::ShieldScreen(_) => {} Screen::ShieldedSendScreen(_) => {} Screen::UnshieldCreditsScreen(_) => {} } @@ -1325,8 +1309,7 @@ impl ScreenLike for Screen { Screen::DashPayQRGeneratorScreen(screen) => screen.ui(ctx), Screen::DashPayProfileSearchScreen(screen) => screen.ui(ctx), // Shielded screens - Screen::ShieldCreditsScreen(screen) => screen.ui(ctx), - Screen::ShieldFromAssetLockScreen(screen) => screen.ui(ctx), + Screen::ShieldScreen(screen) => screen.ui(ctx), Screen::ShieldedSendScreen(screen) => screen.ui(ctx), Screen::UnshieldCreditsScreen(screen) => screen.ui(ctx), } @@ -1430,10 +1413,7 @@ impl ScreenLike for Screen { screen.display_message(message, message_type) } // Shielded screens - Screen::ShieldCreditsScreen(screen) => screen.display_message(message, message_type), - Screen::ShieldFromAssetLockScreen(screen) => { - screen.display_message(message, message_type) - } + Screen::ShieldScreen(screen) => screen.display_message(message, message_type), Screen::ShieldedSendScreen(screen) => screen.display_message(message, message_type), Screen::UnshieldCreditsScreen(screen) => screen.display_message(message, message_type), } @@ -1607,12 +1587,7 @@ impl ScreenLike for Screen { screen.display_task_result(backend_task_success_result) } // Shielded screens - Screen::ShieldCreditsScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } - Screen::ShieldFromAssetLockScreen(screen) => { - screen.display_task_result(backend_task_success_result) - } + Screen::ShieldScreen(screen) => screen.display_task_result(backend_task_success_result), Screen::ShieldedSendScreen(screen) => { screen.display_task_result(backend_task_success_result) } @@ -1687,8 +1662,7 @@ impl ScreenLike for Screen { Screen::DashPayProfileSearchScreen(screen) => screen.display_task_error(error), // Shielded Screens - Screen::ShieldCreditsScreen(screen) => screen.display_task_error(error), - Screen::ShieldFromAssetLockScreen(screen) => screen.display_task_error(error), + Screen::ShieldScreen(screen) => screen.display_task_error(error), Screen::ShieldedSendScreen(screen) => screen.display_task_error(error), Screen::UnshieldCreditsScreen(screen) => screen.display_task_error(error), } @@ -1758,8 +1732,7 @@ impl ScreenLike for Screen { Screen::DashPayQRGeneratorScreen(_) => {} Screen::DashPayProfileSearchScreen(_) => {} // Shielded screens - Screen::ShieldCreditsScreen(_) => {} - Screen::ShieldFromAssetLockScreen(_) => {} + Screen::ShieldScreen(_) => {} Screen::ShieldedSendScreen(_) => {} Screen::UnshieldCreditsScreen(_) => {} } diff --git a/src/ui/wallets/mod.rs b/src/ui/wallets/mod.rs index 613863181..c021241ea 100644 --- a/src/ui/wallets/mod.rs +++ b/src/ui/wallets/mod.rs @@ -4,8 +4,7 @@ pub mod asset_lock_detail_screen; pub mod create_asset_lock_screen; pub mod import_mnemonic_screen; pub mod send_screen; -pub mod shield_credits_screen; -pub mod shield_from_asset_lock_screen; +pub mod shield_screen; pub mod shielded_send_screen; pub mod shielded_tab; pub mod single_key_send_screen; diff --git a/src/ui/wallets/shield_from_asset_lock_screen.rs b/src/ui/wallets/shield_from_asset_lock_screen.rs deleted file mode 100644 index ee0ba00a1..000000000 --- a/src/ui/wallets/shield_from_asset_lock_screen.rs +++ /dev/null @@ -1,208 +0,0 @@ -use crate::app::AppAction; -use crate::backend_task::shielded::ShieldedTask; -use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; -use crate::context::AppContext; -use crate::model::amount::Amount; -use crate::model::wallet::WalletSeedHash; -use crate::ui::components::ComponentResponse; -use crate::ui::components::amount_input::AmountInput; -use crate::ui::components::component_trait::Component; -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 dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; -use eframe::egui::{self, Context}; -use egui::{Color32, RichText}; -use std::sync::Arc; - -#[derive(PartialEq)] -enum Status { - NotStarted, - WaitingForResult, - Complete, -} - -pub struct ShieldFromAssetLockScreen { - pub app_context: Arc, - pub seed_hash: WalletSeedHash, - amount_input: Option, - amount: Option, - core_balance_duffs: u64, - status: Status, - error_message: Option, - success_message: Option, - /// Queued task to dispatch on next frame (e.g., sync notes after successful shield). - pending_refresh_task: Option, -} - -impl ShieldFromAssetLockScreen { - pub fn new(seed_hash: WalletSeedHash, app_context: &Arc) -> Self { - let core_balance_duffs = { - let wallets = app_context.wallets.read().unwrap(); - wallets - .get(&seed_hash) - .map(|w| { - let wallet = w.read().unwrap(); - wallet.total_balance_duffs() - }) - .unwrap_or(0) - }; - - Self { - app_context: app_context.clone(), - seed_hash, - amount_input: None, - amount: None, - core_balance_duffs, - status: Status::NotStarted, - error_message: None, - success_message: None, - pending_refresh_task: None, - } - } -} - -impl ScreenLike for ShieldFromAssetLockScreen { - fn ui(&mut self, ctx: &Context) -> AppAction { - let mut action = self - .pending_refresh_task - .take() - .map(AppAction::BackendTask) - .unwrap_or(AppAction::None); - - action |= add_top_panel( - ctx, - &self.app_context, - vec![ - ("Wallets", AppAction::PopScreen), - ("Shield from Core", AppAction::None), - ], - vec![], - ); - - action |= add_left_panel( - ctx, - &self.app_context, - RootScreenType::RootScreenWalletsBalances, - ); - - island_central_panel(ctx, |ui| { - ui.heading("Shield from Core Wallet"); - ui.add_space(10.0); - ui.label("Send core DASH directly into the shielded pool via an asset lock."); - ui.add_space(5.0); - - let dash_balance = self.core_balance_duffs as f64 / 1e8; - ui.label(format!( - "Available core wallet balance: {:.8} DASH", - dash_balance - )); - ui.add_space(15.0); - - // Error/success messages - if let Some(err) = &self.error_message { - ui.colored_label(Color32::from_rgb(255, 100, 100), err); - ui.add_space(5.0); - } - if let Some(msg) = &self.success_message { - ui.colored_label(Color32::DARK_GREEN, msg); - ui.add_space(10.0); - if ui.button("Done").clicked() { - action = AppAction::PopScreen; - } - return; - } - - // Amount input - let max_credits = self.core_balance_duffs * CREDITS_PER_DUFF; - let amount_input = self.amount_input.get_or_insert_with(|| { - AmountInput::new(Amount::new_dash(0.0)) - .with_label("Amount (DASH):") - .with_hint_text("Enter amount") - .with_max_button(true) - .with_desired_width(150.0) - }); - amount_input.set_max_amount(Some(max_credits)); - let response = amount_input.show(ui); - response.inner.update(&mut self.amount); - ui.add_space(15.0); - - // Confirm - let amount_ok = self.amount.is_some(); - let can_confirm = self.status == Status::NotStarted && amount_ok; - - if self.status == Status::WaitingForResult { - ui.horizontal(|ui| { - ui.add(egui::Spinner::new()); - ui.label("Creating asset lock and shielding... (this may take a few minutes)"); - }); - } else { - ui.horizontal(|ui| { - if ui - .add_enabled( - can_confirm, - egui::Button::new( - RichText::new("Shield from Core") - .color(Color32::WHITE) - .size(16.0), - ) - .fill(crate::ui::theme::DashColors::DASH_BLUE), - ) - .clicked() - && let Some(amount_credits) = self.amount.as_ref().map(|a| a.value()) - { - let amount_duffs = amount_credits / CREDITS_PER_DUFF; - self.status = Status::WaitingForResult; - self.error_message = None; - action = AppAction::BackendTask(BackendTask::ShieldedTask( - ShieldedTask::ShieldFromAssetLock { - seed_hash: self.seed_hash, - amount_duffs, - }, - )); - } - - ui.add_space(10.0); - if ui.button("Cancel").clicked() { - action = AppAction::PopScreen; - } - }); - } - }); - - action - } - - fn display_task_result(&mut self, result: BackendTaskSuccessResult) { - match result { - BackendTaskSuccessResult::ShieldedFromAssetLock { seed_hash, amount } - if seed_hash == self.seed_hash => - { - self.status = Status::Complete; - let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; - self.success_message = Some(format!( - "Successfully shielded {:.8} DASH from core wallet", - dash - )); - self.pending_refresh_task = - Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { - seed_hash: self.seed_hash, - })); - } - _ => {} - } - } - - fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Error => { - self.status = Status::NotStarted; - self.error_message = Some(message.to_string()); - } - _ => { - self.success_message = Some(message.to_string()); - } - } - } -} diff --git a/src/ui/wallets/shield_credits_screen.rs b/src/ui/wallets/shield_screen.rs similarity index 52% rename from src/ui/wallets/shield_credits_screen.rs rename to src/ui/wallets/shield_screen.rs index 51449121e..5692cd387 100644 --- a/src/ui/wallets/shield_credits_screen.rs +++ b/src/ui/wallets/shield_screen.rs @@ -3,9 +3,11 @@ use crate::backend_task::shielded::ShieldedTask; use crate::backend_task::shielded::bundle::ShieldStage; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::model::address::{AddressKind, ValidatedAddress}; use crate::model::amount::Amount; use crate::model::wallet::WalletSeedHash; use crate::ui::components::ComponentResponse; +use crate::ui::components::address_input::AddressInput; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::Component; use crate::ui::components::left_panel::add_left_panel; @@ -29,16 +31,17 @@ enum Status { Complete, } -pub struct ShieldCreditsScreen { +pub struct ShieldScreen { pub app_context: Arc, pub seed_hash: WalletSeedHash, + address_input: Option, + validated_source: Option, amount_input: Option, amount: Option, - from_address: Option, status: Status, error_message: Option, success_message: Option, - // Batch mode (dev only) + // Batch mode (dev only, Platform flow only) repeat_count_str: String, parallel: bool, batch_total: u32, @@ -55,27 +58,15 @@ pub struct ShieldCreditsScreen { json_preview: Option, } -impl ShieldCreditsScreen { +impl ShieldScreen { pub fn new(seed_hash: WalletSeedHash, app_context: &Arc) -> Self { - // Try to find the first platform address from the wallet - let from_address = { - let wallets = app_context.wallets.read().unwrap(); - wallets.get(&seed_hash).and_then(|w| { - let wallet = w.read().unwrap(); - wallet - .platform_address_info - .keys() - .next() - .and_then(|addr| PlatformAddress::try_from(addr.clone()).ok()) - }) - }; - Self { app_context: app_context.clone(), seed_hash, + address_input: None, + validated_source: None, amount_input: None, amount: None, - from_address, status: Status::NotStarted, error_message: None, success_message: None, @@ -100,9 +91,16 @@ impl ShieldCreditsScreen { .clamp(1, 1000) } - /// Read the current nonce for our from_address from the wallet. + /// Returns the selected platform address, if a platform source is selected. + fn selected_platform_address(&self) -> Option { + self.validated_source + .as_ref() + .and_then(|v| v.as_platform().copied()) + } + + /// Read the current nonce for the selected platform address from the wallet. fn read_base_nonce(&self) -> Option { - let from_address = self.from_address?; + let from_address = self.selected_platform_address()?; let wallets = self.app_context.wallets.read().unwrap(); let wallet_arc = wallets.get(&self.seed_hash)?; let wallet = wallet_arc.read().unwrap(); @@ -119,9 +117,9 @@ impl ShieldCreditsScreen { }) } - /// Read the current balance (in credits) for our from_address from the wallet. - fn read_address_balance(&self) -> Option { - let from_address = self.from_address?; + /// Read the current balance (in credits) for the selected platform address. + fn read_platform_balance(&self) -> Option { + let from_address = self.selected_platform_address()?; let wallets = self.app_context.wallets.read().unwrap(); let wallet_arc = wallets.get(&self.seed_hash)?; let wallet = wallet_arc.read().unwrap(); @@ -138,8 +136,20 @@ impl ShieldCreditsScreen { }) } + /// Read the core wallet balance in duffs. + fn read_core_balance_duffs(&self) -> u64 { + let wallets = self.app_context.wallets.read().unwrap(); + wallets + .get(&self.seed_hash) + .map(|w| { + let wallet = w.read().unwrap(); + wallet.total_balance_duffs() + }) + .unwrap_or(0) + } + /// Build a single ShieldCredits task with optional nonce override. - fn make_shield_task( + fn make_shield_credits_task( &self, amount: u64, addr: PlatformAddress, @@ -156,17 +166,18 @@ impl ShieldCreditsScreen { /// Queue the next sequential batch task if any remain. fn queue_next_sequential(&mut self) { if self.batch_remaining > 0 - && let (Some(amount), Some(addr)) = - (self.amount.as_ref().map(|a| a.value()), self.from_address) + && let (Some(amount), Some(addr)) = ( + self.amount.as_ref().map(|a| a.value()), + self.selected_platform_address(), + ) { self.batch_remaining -= 1; - self.pending_next_task = Some(self.make_shield_task(amount, addr, None)); + self.pending_next_task = Some(self.make_shield_credits_task(amount, addr, None)); } } /// Check if the sequential batch is complete and update status accordingly. fn check_batch_complete(&mut self) { - // Only for sequential mode (parallel mode detects completion from shared state) if self.batch_stages.is_none() && self.batch_succeeded + self.batch_failed >= self.batch_total { @@ -209,12 +220,10 @@ impl ShieldCreditsScreen { let app_ctx = self.app_context.clone(); let seed_hash = self.seed_hash; - // Single coordinating task: build in parallel, broadcast in order tokio::spawn(async move { use crate::backend_task::shielded::bundle; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; - // Phase 1: Build all proofs in parallel let build_futures: Vec<_> = (0..repeat) .map(|i| { let app_ctx = app_ctx.clone(); @@ -257,11 +266,6 @@ impl ShieldCreditsScreen { let build_results = futures::future::join_all(build_futures).await; - // Phase 2: Broadcast sequentially in nonce order. - // We use broadcast_and_wait so each nonce is committed on-chain before - // the next is submitted (the platform increments the address nonce only - // after a state transition is finalised, so broadcasting without waiting - // would cause "expected N, got N+1" errors). let sdk = { app_ctx.sdk.load().as_ref().clone() }; for (i, result) in build_results.into_iter().enumerate() { @@ -270,8 +274,6 @@ impl ShieldCreditsScreen { Ok(state_transition) => { *stage.lock().unwrap() = ShieldStage::Broadcasting; - // Serialize first (while we still own the value) so we can - // show the bytes in the error popup if broadcast fails. let st_repr: Option = serde_json::to_string_pretty(&state_transition) .ok() @@ -281,10 +283,6 @@ impl ShieldCreditsScreen { match state_transition.broadcast(&sdk, None).await { Ok(_) => { - // Wait for the state transition to be confirmed on-chain so - // the address nonce increments before we send the next nonce. - // If proof-response parsing fails (network version mismatch), - // fall back to a fixed delay — the broadcast still went through. let wait_ok = state_transition .wait_for_response::(&sdk, None) .await @@ -292,7 +290,6 @@ impl ShieldCreditsScreen { if !wait_ok { tokio::time::sleep(Duration::from_secs(3)).await; } - // Update stored nonce so the next batch reads the correct value app_ctx.bump_platform_address_nonce(&seed_hash, &addr); *stage.lock().unwrap() = ShieldStage::Complete; } @@ -301,7 +298,6 @@ impl ShieldCreditsScreen { error: format!("Broadcast failed: {e}"), st_json: st_repr, }; - // Nonces are sequential — all remaining will fail too for remaining in stages.iter().skip(i + 1) { let mut s = remaining.lock().unwrap(); if !s.is_terminal() { @@ -316,7 +312,6 @@ impl ShieldCreditsScreen { } } Err(_) => { - // Build failed — can't broadcast this or any subsequent nonce for remaining in stages.iter().skip(i + 1) { let mut s = remaining.lock().unwrap(); if !s.is_terminal() { @@ -332,16 +327,149 @@ impl ShieldCreditsScreen { } }); } + + /// Render the batch progress UI (used for Platform batch mode). + fn render_batch_progress(&mut self, ui: &mut egui::Ui, ctx: &Context, action: &mut AppAction) { + let stages_snapshot = self.batch_stages.clone(); + if let Some(stages) = stages_snapshot { + let all_done = stages.iter().all(|s| s.lock().unwrap().is_terminal()); + + if !all_done { + ctx.request_repaint_after(Duration::from_millis(100)); + } + + let succeeded = stages + .iter() + .filter(|s| matches!(*s.lock().unwrap(), ShieldStage::Complete)) + .count(); + let failed = stages + .iter() + .filter(|s| matches!(*s.lock().unwrap(), ShieldStage::Failed { .. })) + .count(); + + if all_done { + if failed > 0 { + ui.colored_label( + Color32::from_rgb(255, 100, 100), + format!( + "Batch complete: {} succeeded, {} failed out of {}", + succeeded, + failed, + stages.len(), + ), + ); + } else { + ui.colored_label( + Color32::from_rgb(50, 180, 50), + format!("Batch complete: all {} succeeded", stages.len()), + ); + } + } else { + ui.label(format!( + "Succeeded {}/{} Failed {}/{}", + succeeded, + stages.len(), + failed, + stages.len(), + )); + } + ui.add_space(5.0); + + let rows: Vec<(ShieldStage, Option)> = stages + .iter() + .map(|s| { + let s = s.lock().unwrap().clone(); + let json = if let ShieldStage::Failed { ref st_json, .. } = s { + st_json.clone() + } else { + None + }; + (s, json) + }) + .collect(); + + let total = rows.len(); + let mut pending_json: Option = None; + + egui::ScrollArea::vertical() + .max_height(400.0) + .show(ui, |ui| { + for (i, (stage, st_json)) in rows.iter().enumerate() { + let fraction = stage.progress_fraction(); + let text = format!("[{}/{}] {}", i + 1, total, stage.label()); + + let color = match stage { + ShieldStage::Queued => Color32::GRAY, + ShieldStage::BuildingProof { .. } => { + crate::ui::theme::DashColors::DASH_BLUE + } + ShieldStage::WaitingToBroadcast => Color32::from_rgb(100, 180, 255), + ShieldStage::Broadcasting => Color32::from_rgb(255, 165, 0), + ShieldStage::Complete => Color32::from_rgb(50, 180, 50), + ShieldStage::Failed { .. } => Color32::from_rgb(220, 60, 60), + }; + + if let Some(json_str) = st_json { + ui.horizontal(|ui| { + let btn_width = 100.0_f32; + let bar_width = (ui.available_width() - btn_width - 6.0).max(100.0); + ui.add_sized( + [bar_width, 20.0], + egui::ProgressBar::new(fraction).text(text).fill(color), + ); + let btn = egui::Button::new( + RichText::new("View JSON").color(Color32::WHITE).size(12.0), + ) + .fill(Color32::from_rgb(80, 80, 80)); + if ui + .add_sized([btn_width, 20.0], btn) + .on_hover_text("View state transition JSON") + .clicked() + { + pending_json = Some(json_str.clone()); + } + }); + } else { + let bar = egui::ProgressBar::new(fraction).text(text).fill(color); + if matches!(stage, ShieldStage::BuildingProof { .. }) { + ui.add(bar.animate(true)); + } else { + ui.add(bar); + } + } + } + }); + + if let Some(json) = pending_json { + self.json_preview = Some(json); + } + + if all_done { + ui.add_space(10.0); + if ui.button("Done").clicked() { + *action = AppAction::PopScreen; + } + } + } else { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new()); + ui.label(format!( + "Succeeded {}/{} Failed {}/{}", + self.batch_succeeded, self.batch_total, self.batch_failed, self.batch_total, + )); + }); + } + } } -impl ScreenLike for ShieldCreditsScreen { +impl ScreenLike for ShieldScreen { fn ui(&mut self, ctx: &Context) -> AppAction { let mut action = add_top_panel( ctx, &self.app_context, vec![ ("Wallets", AppAction::PopScreen), - ("Shield Credits", AppAction::None), + ("Shield", AppAction::None), ], vec![], ); @@ -363,9 +491,9 @@ impl ScreenLike for ShieldCreditsScreen { } island_central_panel(ctx, |ui| { - ui.heading("Shield Credits"); + ui.heading("Shield"); ui.add_space(10.0); - ui.label("Move credits from a platform address into the shielded pool."); + ui.label("Move funds from a platform or core address into the shielded pool."); ui.add_space(15.0); // Error/success messages @@ -382,64 +510,117 @@ impl ScreenLike for ShieldCreditsScreen { return; } - // Source address display - if let Some(addr) = &self.from_address { - ui.horizontal(|ui| { - ui.label("From platform address:"); - ui.monospace(format!("{}", addr)); - if let Some(nonce) = self.read_base_nonce() { - ui.label( - RichText::new(format!("(nonce: {})", nonce)) - .color(Color32::GRAY) - .small(), - ); - } - }); - ui.add_space(10.0); - } else { - ui.colored_label( - Color32::from_rgb(255, 100, 100), - "No platform address found. Register an identity first.", - ); - return; - } - - // Balance display - if let Some(balance_credits) = self.read_address_balance() { - let balance_dash = balance_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; - ui.label( - RichText::new(format!("Available: {:.8} DASH", balance_dash)) - .color(Color32::from_rgb(100, 180, 100)), - ); - ui.add_space(5.0); - } + // Source address selection via AddressInput + let addr_input = self.address_input.get_or_insert_with(|| { + let mut builder = AddressInput::new(self.app_context.network) + .with_address_kinds(&[AddressKind::Core, AddressKind::Platform]) + .with_label("From address") + .with_hint_text("Select a platform or core wallet address") + .with_selection_only(true) + .with_balance_range(1..) + .with_exclude_change(true); + + if let Ok(wallets) = self.app_context.wallets.read() + && let Some(wallet) = wallets.get(&self.seed_hash) + { + builder = builder.with_wallets(std::slice::from_ref(wallet)); + } - // Amount input - let balance_credits = self.read_address_balance(); - let amount_input = self.amount_input.get_or_insert_with(|| { - AmountInput::new(Amount::new_dash(0.0)) - .with_label("Amount (DASH):") - .with_hint_text("Enter amount") - .with_desired_width(150.0) + builder }); - if let Some(balance_credits) = balance_credits { - amount_input.set_max_amount(Some(balance_credits)); + let resp = addr_input.show(ui); + if resp.inner.has_changed() { + resp.inner.update(&mut self.validated_source); + // Reset amount input when source changes (different balance constraints) + self.amount_input = None; + self.amount = None; } - let response = amount_input.show(ui); - response.inner.update(&mut self.amount); ui.add_space(5.0); - // Dev-mode batch controls - if self.app_context.is_developer_mode() && self.status == Status::NotStarted { - ui.add_space(10.0); - ui.horizontal(|ui| { - ui.label("Repeat"); - let te = - egui::TextEdit::singleline(&mut self.repeat_count_str).desired_width(50.0); - ui.add(te); - ui.label("times"); + // Show source-specific info based on selected address type + let source_kind = self.validated_source.as_ref().map(|v| v.kind()); + + match source_kind { + Some(AddressKind::Platform) => { + // Platform flow: show balance and nonce + if let Some(balance_credits) = self.read_platform_balance() { + let balance_dash = balance_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Available: {:.8} DASH", balance_dash)) + .color(Color32::from_rgb(100, 180, 100)), + ); + if self.app_context.is_developer_mode() + && let Some(nonce) = self.read_base_nonce() + { + ui.label( + RichText::new(format!("(nonce: {})", nonce)) + .color(Color32::GRAY) + .small(), + ); + } + }); + ui.add_space(5.0); + } + } + Some(AddressKind::Core) => { + // Core flow: show wallet balance + let balance_duffs = self.read_core_balance_duffs(); + let dash_balance = balance_duffs as f64 / 1e8; + ui.label( + RichText::new(format!( + "Available core wallet balance: {:.8} DASH", + dash_balance + )) + .color(Color32::from_rgb(100, 180, 100)), + ); + ui.add_space(5.0); + } + _ => {} + } + + // Amount input (only when a source address is selected) + if self.validated_source.is_some() { + let max_credits = match source_kind { + Some(AddressKind::Platform) => self.read_platform_balance(), + Some(AddressKind::Core) => { + Some(self.read_core_balance_duffs() * CREDITS_PER_DUFF) + } + _ => None, + }; + + let amount_input = self.amount_input.get_or_insert_with(|| { + let mut builder = AmountInput::new(Amount::new_dash(0.0)) + .with_label("Amount (DASH):") + .with_hint_text("Enter amount") + .with_desired_width(150.0); + if source_kind == Some(AddressKind::Core) { + builder = builder.with_max_button(true); + } + builder }); - ui.checkbox(&mut self.parallel, "Parallel"); + if let Some(max) = max_credits { + amount_input.set_max_amount(Some(max)); + } + let response = amount_input.show(ui); + response.inner.update(&mut self.amount); + ui.add_space(5.0); + + // Dev-mode batch controls (Platform flow only) + if self.app_context.is_developer_mode() + && source_kind == Some(AddressKind::Platform) + && self.status == Status::NotStarted + { + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.label("Repeat"); + let te = egui::TextEdit::singleline(&mut self.repeat_count_str) + .desired_width(50.0); + ui.add(te); + ui.label("times"); + }); + ui.checkbox(&mut self.parallel, "Parallel"); + } } ui.add_space(15.0); @@ -449,215 +630,101 @@ impl ScreenLike for ShieldCreditsScreen { self.status == Status::WaitingForResult || self.status == Status::BatchInProgress; if self.status == Status::BatchInProgress { - // Clone Arc refs cheaply so we can mutate `self` inside the loop - let stages_snapshot = self.batch_stages.clone(); - if let Some(stages) = stages_snapshot { - // Parallel mode: per-operation progress bars - let all_done = stages.iter().all(|s| s.lock().unwrap().is_terminal()); - - if !all_done { - ctx.request_repaint_after(Duration::from_millis(100)); - } - - // Summary line - let succeeded = stages - .iter() - .filter(|s| matches!(*s.lock().unwrap(), ShieldStage::Complete)) - .count(); - let failed = stages - .iter() - .filter(|s| matches!(*s.lock().unwrap(), ShieldStage::Failed { .. })) - .count(); - - if all_done { - if failed > 0 { - ui.colored_label( - Color32::from_rgb(255, 100, 100), - format!( - "Batch complete: {} succeeded, {} failed out of {}", - succeeded, - failed, - stages.len(), - ), - ); - } else { - ui.colored_label( - Color32::from_rgb(50, 180, 50), - format!("Batch complete: all {} succeeded", stages.len()), - ); - } - } else { - ui.label(format!( - "Succeeded {}/{} Failed {}/{}", - succeeded, - stages.len(), - failed, - stages.len(), - )); - } - ui.add_space(5.0); - - // Collect (stage, st_json) to avoid borrow conflicts inside closure - let rows: Vec<(ShieldStage, Option)> = stages - .iter() - .map(|s| { - let s = s.lock().unwrap().clone(); - let json = if let ShieldStage::Failed { ref st_json, .. } = s { - st_json.clone() - } else { - None - }; - (s, json) - }) - .collect(); - - let total = rows.len(); - let mut pending_json: Option = None; - - egui::ScrollArea::vertical() - .max_height(400.0) - .show(ui, |ui| { - for (i, (stage, st_json)) in rows.iter().enumerate() { - let fraction = stage.progress_fraction(); - let text = format!("[{}/{}] {}", i + 1, total, stage.label()); - - let color = match stage { - ShieldStage::Queued => Color32::GRAY, - ShieldStage::BuildingProof { .. } => { - crate::ui::theme::DashColors::DASH_BLUE - } - ShieldStage::WaitingToBroadcast => { - Color32::from_rgb(100, 180, 255) - } - ShieldStage::Broadcasting => Color32::from_rgb(255, 165, 0), - ShieldStage::Complete => Color32::from_rgb(50, 180, 50), - ShieldStage::Failed { .. } => Color32::from_rgb(220, 60, 60), - }; - - if let Some(json_str) = st_json { - // Failed bar with viewable state transition: bar + button - ui.horizontal(|ui| { - let btn_width = 100.0_f32; - let bar_width = - (ui.available_width() - btn_width - 6.0).max(100.0); - ui.add_sized( - [bar_width, 20.0], - egui::ProgressBar::new(fraction).text(text).fill(color), - ); - let btn = egui::Button::new( - RichText::new("View JSON") - .color(Color32::WHITE) - .size(12.0), - ) - .fill(Color32::from_rgb(80, 80, 80)); - if ui - .add_sized([btn_width, 20.0], btn) - .on_hover_text("View state transition JSON") - .clicked() - { - pending_json = Some(json_str.clone()); - } - }); - } else { - let bar = - egui::ProgressBar::new(fraction).text(text).fill(color); - if matches!(stage, ShieldStage::BuildingProof { .. }) { - ui.add(bar.animate(true)); - } else { - ui.add(bar); - } - } - } - }); - - if let Some(json) = pending_json { - self.json_preview = Some(json); - } - - if all_done { - ui.add_space(10.0); - if ui.button("Done").clicked() { - action = AppAction::PopScreen; - } - } - } else { - // Sequential mode: simple counter - ui.horizontal(|ui| { - ui.add(egui::Spinner::new()); - ui.label(format!( - "Succeeded {}/{} Failed {}/{}", - self.batch_succeeded, - self.batch_total, - self.batch_failed, - self.batch_total, - )); - }); - } + self.render_batch_progress(ui, ctx, &mut action); } else if self.status == Status::WaitingForResult { + let spinner_msg = match source_kind { + Some(AddressKind::Core) => { + "Creating asset lock and shielding... (this may take a few minutes)" + } + _ => "Shielding credits...", + }; ui.horizontal(|ui| { ui.add(egui::Spinner::new()); - ui.label("Shielding credits..."); + ui.label(spinner_msg); }); } - // Buttons (only when not busy) - if !is_busy && self.status == Status::NotStarted { + // Buttons (only when not busy and source is selected) + if !is_busy && self.status == Status::NotStarted && self.validated_source.is_some() { let can_confirm = self.amount.as_ref().map(|a| a.value()).is_some(); ui.horizontal(|ui| { + let button_label = match source_kind { + Some(AddressKind::Core) => "Shield from Core", + _ => "Shield", + }; + if ui .add_enabled( can_confirm, egui::Button::new( - RichText::new("Shield").color(Color32::WHITE).size(16.0), + RichText::new(button_label) + .color(Color32::WHITE) + .size(16.0), ) .fill(crate::ui::theme::DashColors::DASH_BLUE), ) .clicked() - && let (Some(amount), Some(addr)) = - (self.amount.as_ref().map(|a| a.value()), self.from_address) + && let Some(amount) = self.amount.as_ref().map(|a| a.value()) { self.error_message = None; - let repeat = if self.app_context.is_developer_mode() { - self.parse_repeat_count() - } else { - 1 - }; - // Balance check: total cost must not exceed available balance - if let Some(balance) = self.read_address_balance() { - let total = amount.saturating_mul(repeat as u64); - if total > balance { - let total_dash = total as f64 / CREDITS_PER_DUFF as f64 / 1e8; - let balance_dash = - balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - self.error_message = Some(format!( - "Insufficient balance: {repeat}x {:.8} DASH = {:.8} DASH total, but only {:.8} DASH available", - amount as f64 / CREDITS_PER_DUFF as f64 / 1e8, - total_dash, - balance_dash, + match source_kind { + Some(AddressKind::Platform) => { + let addr = self.selected_platform_address().unwrap(); + let repeat = if self.app_context.is_developer_mode() { + self.parse_repeat_count() + } else { + 1 + }; + + // Balance check + if let Some(balance) = self.read_platform_balance() { + let total = amount.saturating_mul(repeat as u64); + if total > balance { + let total_dash = + total as f64 / CREDITS_PER_DUFF as f64 / 1e8; + let balance_dash = + balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; + self.error_message = Some(format!( + "Insufficient balance: {repeat}x {:.8} DASH = {:.8} DASH total, but only {:.8} DASH available", + amount as f64 / CREDITS_PER_DUFF as f64 / 1e8, + total_dash, + balance_dash, + )); + return; + } + } + + if repeat <= 1 { + self.status = Status::WaitingForResult; + action = AppAction::BackendTask( + self.make_shield_credits_task(amount, addr, None), + ); + } else if self.parallel { + self.spawn_parallel_batch(amount, addr, repeat); + } else { + self.batch_total = repeat; + self.batch_succeeded = 0; + self.batch_failed = 0; + self.batch_remaining = repeat - 1; + self.status = Status::BatchInProgress; + action = AppAction::BackendTask( + self.make_shield_credits_task(amount, addr, None), + ); + } + } + Some(AddressKind::Core) => { + let amount_duffs = amount / CREDITS_PER_DUFF; + self.status = Status::WaitingForResult; + action = AppAction::BackendTask(BackendTask::ShieldedTask( + ShieldedTask::ShieldFromAssetLock { + seed_hash: self.seed_hash, + amount_duffs, + }, )); - return; } - } - - if repeat <= 1 { - // Single operation - self.status = Status::WaitingForResult; - action = - AppAction::BackendTask(self.make_shield_task(amount, addr, None)); - } else if self.parallel { - // Parallel batch: spawn directly with progress tracking - self.spawn_parallel_batch(amount, addr, repeat); - } else { - // Sequential batch: fire first, queue rest - self.batch_total = repeat; - self.batch_succeeded = 0; - self.batch_failed = 0; - self.batch_remaining = repeat - 1; - self.status = Status::BatchInProgress; - action = - AppAction::BackendTask(self.make_shield_task(amount, addr, None)); + _ => {} } } @@ -703,13 +770,11 @@ impl ScreenLike for ShieldCreditsScreen { if seed_hash == self.seed_hash => { if self.status == Status::BatchInProgress { - // Sequential batch mode self.batch_succeeded += 1; self.check_batch_complete(); if self.status == Status::BatchInProgress { self.queue_next_sequential(); } else { - // Batch complete — sync shielded notes self.pending_refresh_task = Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { seed_hash: self.seed_hash, @@ -725,6 +790,20 @@ impl ScreenLike for ShieldCreditsScreen { })); } } + BackendTaskSuccessResult::ShieldedFromAssetLock { seed_hash, amount } + if seed_hash == self.seed_hash => + { + self.status = Status::Complete; + let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; + self.success_message = Some(format!( + "Successfully shielded {:.8} DASH from core wallet", + dash + )); + self.pending_refresh_task = + Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { + seed_hash: self.seed_hash, + })); + } _ => {} } } @@ -733,7 +812,6 @@ impl ScreenLike for ShieldCreditsScreen { match message_type { MessageType::Error => { if self.status == Status::BatchInProgress { - // Sequential batch mode self.batch_failed += 1; self.check_batch_complete(); if self.status == Status::BatchInProgress { diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index ee29a20d1..5b1f06d8c 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -428,29 +428,13 @@ impl ShieldedTabView { .fill(DashColors::DASH_BLUE); if ui .add_enabled(!self.syncing, shield_btn) - .on_hover_text("Shield credits from platform address into the shielded pool") + .on_hover_text( + "Shield funds from a platform or core address into the shielded pool", + ) .clicked() { action |= AppAction::AddScreen( - ScreenType::ShieldCreditsScreen(self.seed_hash) - .create_screen(&self.app_context), - ); - } - - let shield_core_btn = egui::Button::new( - RichText::new("Shield from Core") - .color(Color32::WHITE) - .size(14.0), - ) - .fill(DashColors::DASH_BLUE); - if ui - .add_enabled(!self.syncing, shield_core_btn) - .on_hover_text("Shield core DASH directly into the shielded pool via asset lock") - .clicked() - { - action |= AppAction::AddScreen( - ScreenType::ShieldFromAssetLockScreen(self.seed_hash) - .create_screen(&self.app_context), + ScreenType::ShieldScreen(self.seed_hash).create_screen(&self.app_context), ); } From 40e261432c193ca77dc5b269772ba1f3c6217678 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:12:09 +0100 Subject: [PATCH 02/44] feat: fetch epoch info on connection sync to populate protocol version When OverallConnectionState transitions to Synced, dispatch a CurrentEpochInfo backend task to fetch the protocol version and fee multiplier. This ensures supports_shielded() returns the correct value immediately after connection, without requiring the user to visit the Platform Info screen. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 4cfb6c058..531ca9330 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1058,7 +1058,16 @@ impl AppState { self.connection_banner_handle = Some(handle); } OverallConnectionState::Synced => { - // No banner needed for fully synced state + // No banner needed for fully synced state. + // Fetch epoch info on first sync to populate protocol version + // and fee multiplier — needed for feature gating (e.g., shielded + // tab requires protocol version >= 12). + if state_changed { + let task = BackendTask::PlatformInfo( + crate::backend_task::platform_info::PlatformInfoTaskRequestType::CurrentEpochInfo, + ); + self.handle_backend_task(task); + } } } self.previous_connection_state = Some(current_state); From ded7612f1acc0c932b2dd0b3feb19ccf4dc7b045 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:15:55 +0100 Subject: [PATCH 03/44] fix(ui): invalidate cached tx indices when transaction list changes Cached transaction indices could become out of bounds after a wallet refresh reduced the transaction count, causing an index-out-of-bounds panic during sort. Now validates cached indices against current transaction count and invalidates the cache when stale. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/wallets_screen/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 7662279c4..e5d2aa4fd 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1545,6 +1545,12 @@ impl WalletsBalancesScreen { // Filter to transactions involving this wallet's addresses. // The `is_ours` flag is set by both RPC and SPV paths for all // transactions that belong to this wallet (sends and receives). + // Invalidate cache if transaction count changed (wallet refreshed). + if let Some(ref cached) = self.cached_tx_indices { + if cached.iter().any(|&i| i >= wallet_guard.transactions.len()) { + self.cached_tx_indices = None; + } + } let relevant_indices = self.cached_tx_indices.get_or_insert_with(|| { (0..wallet_guard.transactions.len()) .filter(|&i| wallet_guard.transactions[i].is_ours) From b1aeb41d62d0141bb27c2ac841e62e3c47c11ea8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:26:28 +0100 Subject: [PATCH 04/44] fix(shielded): add fee headroom to note selection (#795 item 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit select_notes_for_amount now takes a fee_headroom parameter and selects notes covering amount + fee. This prevents "fee exceeds spendable" errors when sending the full shielded balance. All three callers (shielded_transfer, unshield_credits, shielded_withdrawal) use a 500M credit headroom constant — generous enough for the most expensive transition type (withdrawal at 400M). Any excess remains as change in the shielded pool. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/shielded/bundle.rs | 33 +++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 994e0c628..23f0965b6 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -19,6 +19,17 @@ use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; +/// Fee headroom for shielded note selection (in credits). +/// +/// When selecting notes to cover a send amount, we add this headroom so the +/// DPP builder has room for the transition fee. Without it, sending the full +/// shielded balance fails with "fee exceeds spendable". +/// +/// This is a generous estimate (500M credits ≈ 5 DASH) covering the largest +/// transition type (withdrawal). The actual fee is calculated by the builder +/// and any excess stays as change in the shielded pool. +const SHIELDED_FEE_HEADROOM: u64 = 500_000_000; + /// Wrapper around a cached `ProvingKey` that implements `OrchardProver`. struct CachedProver { key: &'static ProvingKey, @@ -246,7 +257,7 @@ pub async fn shielded_transfer( let recipient_addr = OrchardAddress::from_raw_bytes(&recipient_bytes) .map_err(|_| TaskError::ShieldedInvalidRecipientAddress)?; - let (spendable_notes, total_input_value) = select_notes_for_amount(shielded_state, amount)?; + let (spendable_notes, total_input_value) = select_notes_for_amount(shielded_state, amount, SHIELDED_FEE_HEADROOM)?; let change_amount = total_input_value.saturating_sub(amount); tracing::info!( @@ -339,7 +350,7 @@ pub async fn unshield_credits( key: get_proving_key(), }; - let (spendable_notes, total_input_value) = select_notes_for_amount(shielded_state, amount)?; + let (spendable_notes, total_input_value) = select_notes_for_amount(shielded_state, amount, SHIELDED_FEE_HEADROOM)?; let change_amount = total_input_value.saturating_sub(amount); tracing::info!( @@ -639,7 +650,7 @@ pub async fn shielded_withdrawal( let output_script = CoreScript::from_bytes(to_core_address.script_pubkey().to_bytes()); - let (spendable_notes, total_input_value) = select_notes_for_amount(shielded_state, amount)?; + let (spendable_notes, total_input_value) = select_notes_for_amount(shielded_state, amount, SHIELDED_FEE_HEADROOM)?; let change_amount = total_input_value.saturating_sub(amount); tracing::info!( @@ -719,9 +730,18 @@ pub async fn shielded_withdrawal( } /// Select notes to cover the requested amount using a greedy algorithm. +/// Select unspent notes to cover `amount + fee_headroom`. +/// +/// The `fee_headroom` ensures selected inputs cover both the send amount +/// and the transition fee. Without it, sending the full balance fails +/// because the DPP builder adds fees on top of the selected amount. +/// +/// The `required` amount in error messages includes the fee so the user +/// understands the total cost. fn select_notes_for_amount( shielded_state: &ShieldedWalletState, amount: u64, + fee_headroom: u64, ) -> Result<(Vec<&crate::model::wallet::shielded::ShieldedNote>, u64), TaskError> { let unspent: Vec<_> = shielded_state.unspent_notes(); @@ -729,11 +749,12 @@ fn select_notes_for_amount( return Err(TaskError::ShieldedNoUnspentNotes); } + let required = amount.saturating_add(fee_headroom); let total_available: u64 = unspent.iter().map(|n| n.value).sum(); - if total_available < amount { + if total_available < required { return Err(TaskError::ShieldedInsufficientBalance { available: total_available, - required: amount, + required, }); } @@ -746,7 +767,7 @@ fn select_notes_for_amount( for note in sorted { selected.push(note); accumulated += note.value; - if accumulated >= amount { + if accumulated >= required { break; } } From c4d3a564e33913e1747d63bec43e33246f07e05b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:27:38 +0100 Subject: [PATCH 05/44] fix(shielded): reduce fee headroom from 5 DASH to 0.1 DASH Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/shielded/bundle.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 23f0965b6..36330aaa1 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -25,10 +25,9 @@ use std::sync::{Arc, Mutex}; /// DPP builder has room for the transition fee. Without it, sending the full /// shielded balance fails with "fee exceeds spendable". /// -/// This is a generous estimate (500M credits ≈ 5 DASH) covering the largest -/// transition type (withdrawal). The actual fee is calculated by the builder -/// and any excess stays as change in the shielded pool. -const SHIELDED_FEE_HEADROOM: u64 = 500_000_000; +/// Estimated at ~0.1 DASH (10M credits). The actual fee is calculated by +/// the builder and any excess stays as change in the shielded pool. +const SHIELDED_FEE_HEADROOM: u64 = 10_000_000; /// Wrapper around a cached `ProvingKey` that implements `OrchardProver`. struct CachedProver { From f4d280e50c5986a4b9b9fc03be018cae028a4b81 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 08:22:31 +0100 Subject: [PATCH 06/44] fix(wallet): bootstrap platform addresses on wallet creation bootstrap_wallet_addresses only ran when known_addresses was empty, but new_from_seed already derives one Core address. Platform payment addresses were never bootstrapped for new wallets. Now checks for PlatformPayment addresses in watched_addresses and runs bootstrap if missing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context/wallet_lifecycle.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 97c15209d..54b2b29e3 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -137,11 +137,17 @@ impl AppContext { } 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); + if let Ok(mut guard) = wallet.write() { + // Bootstrap when no addresses exist (fresh wallet) or when + // platform payment addresses haven't been derived yet (wallet + // created with only a Core address via new_from_seed). + let has_platform_addresses = guard.watched_addresses.values().any(|info| { + info.path_reference == crate::model::wallet::DerivationPathReference::PlatformPayment + }); + if guard.known_addresses.is_empty() || !has_platform_addresses { + tracing::info!(wallet = %hex::encode(guard.seed_hash()), "Bootstrapping wallet addresses"); + guard.bootstrap_known_addresses(self); + } } } From 2507e9e07a9d211d26bb720c6fdac20fbfab42fc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:03:29 +0100 Subject: [PATCH 07/44] fix(logging): demote cookie auth fallback to trace level The "Failed to authenticate using .cookie file" message fires on every RPC client creation when no cookie file exists (normal for user/pass auth). Demoted from debug to trace to stop log spam. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/core/mod.rs | 4 ++-- src/context/mod.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 2136b401b..ef8d18296 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -457,8 +457,8 @@ impl AppContext { let client = match Client::new(&addr, Auth::CookieFile(cookie_path.clone())) { Ok(client) => client, Err(_) => { - tracing::debug!( - "Failed to authenticate using .cookie file at {:?}, falling back to user/pass", + tracing::trace!( + "Cookie auth unavailable at {:?}, using user/pass", cookie_path ); match Client::new( diff --git a/src/context/mod.rs b/src/context/mod.rs index 0eba4c4f3..c6e82c2e2 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -605,8 +605,8 @@ impl AppContext { if let Ok(client) = Client::new(url, Auth::CookieFile(cookie_path.clone())) { return Ok(client); } - tracing::debug!( - "Failed to authenticate using .cookie file at {:?}, falling back to user/pass", + tracing::trace!( + "Cookie auth unavailable at {:?}, using user/pass", cookie_path, ); } From cccdb3f76fd140e48eb8c4af6cee5563b0536908 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:07:53 +0100 Subject: [PATCH 08/44] fix(ui): add identities to Send screen destination autocomplete Identities (with alias/DPNS name) now appear in the Send screen's destination autocomplete. Typing an identity alias like "i1" or "identity" will match. Pre-loads identities and shielded info before the AddressInput closure to avoid double-borrow of self. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/send_screen.rs | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 70fed3e2d..0278d2b4c 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -2057,6 +2057,16 @@ impl WalletSendScreen { fn render_destination_input(&mut self, ui: &mut Ui) { let developer_mode = self.app_context.is_developer_mode(); + // Pre-load data outside the closure to avoid double-borrow of self + let loaded_identities = self.get_loaded_identities(); + let shielded_info: Option<(String, u64)> = self.selected_wallet_seed_hash.and_then(|sh| { + let states = self.app_context.shielded_states.lock().ok()?; + let state = states.get(&sh)?; + use dash_sdk::dpp::address_funds::OrchardAddress; + let raw = state.keys.default_address.to_raw_address_bytes(); + let addr = OrchardAddress::from_raw_bytes(&raw).ok()?; + Some((addr.to_bech32m_string(self.app_context.network), state.shielded_balance)) + }); let addr_input = self.address_input.get_or_insert_with(|| { let allowed_kinds = match &self.selected_source { Some(SourceSelection::CoreWallet) => { @@ -2107,17 +2117,14 @@ impl WalletSendScreen { } } + // Add identities for autocomplete (searchable by alias/DPNS name) + if !loaded_identities.is_empty() { + builder = builder.with_identities(&loaded_identities); + } + // Add shielded address for autocomplete (if wallet has shielded state) - if let Some(seed_hash) = self.selected_wallet_seed_hash - && let Ok(states) = self.app_context.shielded_states.lock() - && let Some(state) = states.get(&seed_hash) - { - use dash_sdk::dpp::address_funds::OrchardAddress; - let raw = state.keys.default_address.to_raw_address_bytes(); - if let Ok(orchard_addr) = OrchardAddress::from_raw_bytes(&raw) { - let addr_str = orchard_addr.to_bech32m_string(self.app_context.network); - builder = builder.with_shielded_balance(addr_str, state.shielded_balance); - } + if let Some((addr_str, balance)) = &shielded_info { + builder = builder.with_shielded_balance(addr_str.clone(), *balance); } builder From 8ac9b7e4acc46965cf30b86172b98f9d1ae0663e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:08:41 +0100 Subject: [PATCH 09/44] docs(ui): document that with_wallets() only extracts Core and Platform Callers must separately call with_identities() and with_shielded_balance() for those address types. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/components/address_input.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/ui/components/address_input.rs b/src/ui/components/address_input.rs index 0c9e51ca2..89ba2cb1a 100644 --- a/src/ui/components/address_input.rs +++ b/src/ui/components/address_input.rs @@ -211,14 +211,21 @@ impl AddressInput { self } - /// Provide wallet data for Core and Platform autocomplete. + /// Provide wallet data for **Core and Platform** autocomplete only. + /// + /// This extracts BIP44 (Core) addresses from `known_addresses` and + /// PlatformPayment addresses from `watched_addresses`. It does NOT + /// extract identities or shielded addresses — those live outside the + /// `Wallet` struct and must be added separately: + /// + /// - **Identities**: call [`with_identities()`] with `QualifiedIdentity` + /// data from `AppContext::load_local_qualified_identities()`. + /// - **Shielded**: call [`with_shielded_balance()`] with the address + /// string from `AppContext::shielded_states`. /// /// Entries are extracted immediately (read lock acquired once per wallet). /// Skips gracefully if a wallet lock is poisoned. /// When more than one wallet is provided, entries are prefixed with the wallet alias. - // TODO: Once shielded state is moved from AppContext::shielded_states into - // Wallet, extract shielded addresses here automatically (like Core and - // Platform) instead of requiring callers to call with_shielded_balance(). pub fn with_wallets(mut self, wallets: &[Arc>]) -> Self { let multi = wallets.len() > 1; for wallet in wallets { From a404376c99d004f1d5c5ebc7d3b2e1ccb4cda06f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:16:04 +0100 Subject: [PATCH 10/44] fix(ui): default identity source to highest balance identity When selecting Identity as send source, default to the identity with the most credits instead of the first one in the list. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/send_screen.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 0278d2b4c..b61c8fad5 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -1888,11 +1888,13 @@ impl WalletSendScreen { ui.horizontal(|ui| { let mut selected = is_identity_selected; if ui.radio_value(&mut selected, true, "").changed() && selected { - if let Some(identity) = self - .selected_identity - .clone() - .or_else(|| identities.first().cloned()) - { + if let Some(identity) = self.selected_identity.clone().or_else(|| { + // Default to the identity with the highest balance + identities + .iter() + .max_by_key(|qi| qi.identity.balance()) + .cloned() + }) { self.selected_source = Some(SourceSelection::Identity(Box::new(identity.clone()))); self.selected_identity = Some(identity); From e31203e840667f7aa9fd5a691eb235d0cdb2bbe6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:16:58 +0100 Subject: [PATCH 11/44] fix(ui): prevent self-send by filtering source identity from destinations When sending from an Identity source, the same identity is excluded from the destination autocomplete dropdown. This prevents the user from accidentally sending credits to themselves. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/send_screen.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index b61c8fad5..0b32cc879 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -2059,8 +2059,19 @@ impl WalletSendScreen { fn render_destination_input(&mut self, ui: &mut Ui) { let developer_mode = self.app_context.is_developer_mode(); - // Pre-load data outside the closure to avoid double-borrow of self - let loaded_identities = self.get_loaded_identities(); + // Pre-load data outside the closure to avoid double-borrow of self. + // Filter out the source identity (if any) to prevent self-sends. + let source_identity_id = if let Some(SourceSelection::Identity(qi)) = &self.selected_source + { + Some(qi.identity.id()) + } else { + None + }; + let loaded_identities: Vec<_> = self + .get_loaded_identities() + .into_iter() + .filter(|qi| Some(qi.identity.id()) != source_identity_id) + .collect(); let shielded_info: Option<(String, u64)> = self.selected_wallet_seed_hash.and_then(|sh| { let states = self.app_context.shielded_states.lock().ok()?; let state = states.get(&sh)?; From 6213ddbe6a28e0032e6e19eff854240e73b3044e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:27:53 +0100 Subject: [PATCH 12/44] =?UTF-8?q?feat(ui):=20split=20Platform=E2=86=92Shie?= =?UTF-8?q?lded=20transactions=20for=20privacy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add split transaction input on the Send screen when sending from Platform to Shielded. The total amount is divided into N randomized sub-amounts (±30% jitter, min 0.1 DASH each) that sum exactly to the requested total. Transactions are dispatched sequentially. The split_amount_randomized() helper is in model/amount.rs for reuse by the shield screen. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/model/amount.rs | 96 +++++++++++++++++++++++++++++++++++ src/ui/wallets/send_screen.rs | 85 ++++++++++++++++++++++++++++--- 2 files changed, 173 insertions(+), 8 deletions(-) diff --git a/src/model/amount.rs b/src/model/amount.rs index 2c3dd51e8..61741f5c4 100644 --- a/src/model/amount.rs +++ b/src/model/amount.rs @@ -757,3 +757,99 @@ mod tests { assert_eq!(empty_unit.to_string_opts(false, true), "123.45"); } } + +/// Split a total amount into `count` randomized sub-amounts that sum exactly +/// to `total`. Each sub-amount is at least `min_per_tx`. Returns `None` if +/// the total is too small to satisfy the minimum per-transaction constraint. +/// +/// The randomization adds ±30% jitter around the equal split, clamped to +/// `[min_per_tx, remaining]`. This makes individual transactions harder to +/// correlate while preserving the exact total. +/// +/// Used by shielding flows to split a large shield operation into multiple +/// smaller transactions for improved privacy. +pub fn split_amount_randomized(total: u64, count: u32, min_per_tx: u64) -> Option> { + if count == 0 || count == 1 { + return Some(vec![total]); + } + let count = count as usize; + + // Check if total can be split with the minimum constraint + if total < min_per_tx * count as u64 { + return None; + } + + use std::time::SystemTime; + // Simple deterministic seed from system time — not cryptographic, + // just enough to vary splits between invocations. + let seed = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + + let base = total / count as u64; + let mut amounts = Vec::with_capacity(count); + let mut remaining = total; + + for i in 0..count - 1 { + // Jitter: ±30% of base amount + let jitter_range = base * 30 / 100; + // Simple hash-based pseudo-random + let hash = seed.wrapping_mul(6364136223846793005).wrapping_add(i as u64 * 1442695040888963407); + let jitter = if jitter_range > 0 { + (hash % (jitter_range * 2 + 1)) as i64 - jitter_range as i64 + } else { + 0 + }; + let amount = (base as i64 + jitter).max(min_per_tx as i64) as u64; + // Ensure we leave enough for remaining transactions + let max_here = remaining - min_per_tx * (count - i - 1) as u64; + let amount = amount.min(max_here).max(min_per_tx); + amounts.push(amount); + remaining -= amount; + } + // Last transaction gets the remainder (exact sum guaranteed) + amounts.push(remaining); + + Some(amounts) +} + +#[cfg(test)] +mod split_tests { + use super::*; + + #[test] + fn split_single_returns_total() { + assert_eq!(split_amount_randomized(1000, 1, 100), Some(vec![1000])); + assert_eq!(split_amount_randomized(1000, 0, 100), Some(vec![1000])); + } + + #[test] + fn split_sums_to_total() { + for count in 2..=10 { + let result = split_amount_randomized(10_000_000_000, count, 10_000_000).unwrap(); + assert_eq!(result.len(), count as usize); + assert_eq!(result.iter().sum::(), 10_000_000_000); + for &amount in &result { + assert!(amount >= 10_000_000, "amount {amount} < min 10_000_000"); + } + } + } + + #[test] + fn split_too_small_returns_none() { + // 100 total, 3 splits, min 50 each = need 150, only have 100 + assert_eq!(split_amount_randomized(100, 3, 50), None); + } + + #[test] + fn split_exact_minimum() { + // Exactly enough for minimum per tx + let result = split_amount_randomized(300, 3, 100).unwrap(); + assert_eq!(result.iter().sum::(), 300); + assert_eq!(result.len(), 3); + for &a in &result { + assert!(a >= 100); + } + } +} diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 0b32cc879..6cce6b458 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -389,6 +389,9 @@ pub struct WalletSendScreen { // Identity source fields selected_identity: Option, + // Split transaction options (Platform → Shielded) + split_count_str: String, + // Common options subtract_fee: bool, @@ -426,6 +429,7 @@ impl WalletSendScreen { }], fee_strategy: PlatformFeeStrategy::default(), selected_identity: None, + split_count_str: "1".to_string(), subtract_fee: false, send_status: SendStatus::NotStarted, send_banner: None, @@ -1300,6 +1304,32 @@ impl WalletSendScreen { // Amount self.render_amount_input(ui); + // Split transaction option (Platform → Shielded only) + let is_platform_to_shielded = matches!( + (&self.selected_source, self.destination_kind()), + (Some(SourceSelection::PlatformAddresses(_)), Some(AddressKind::Shielded)) + ); + if is_platform_to_shielded { + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label("Split into"); + let te = egui::TextEdit::singleline(&mut self.split_count_str) + .desired_width(40.0); + ui.add(te); + ui.label("transactions"); + }); + let split_count = self.split_count_str.trim().parse::().unwrap_or(1).clamp(1, 100); + if split_count > 1 { + ui.label( + egui::RichText::new(format!( + "Amount will be split into {split_count} randomized transactions for privacy." + )) + .small() + .color(egui::Color32::GRAY), + ); + } + } + ui.add_space(10.0); // Platform source breakdown (shows which addresses will be used) @@ -1546,15 +1576,54 @@ impl WalletSendScreen { )); } + let split_count = self + .split_count_str + .trim() + .parse::() + .unwrap_or(1) + .clamp(1, 100); + self.mark_sending(); - Ok(AppAction::BackendTask(BackendTask::ShieldedTask( - crate::backend_task::shielded::ShieldedTask::ShieldCredits { - seed_hash, - amount: amount_credits, - from_address, - nonce_override: None, - }, - ))) + + if split_count <= 1 { + Ok(AppAction::BackendTask(BackendTask::ShieldedTask( + crate::backend_task::shielded::ShieldedTask::ShieldCredits { + seed_hash, + amount: amount_credits, + from_address, + nonce_override: None, + }, + ))) + } else { + // Split into randomized sub-amounts (min 0.1 DASH = 10M credits) + use crate::model::amount::split_amount_randomized; + let min_per_tx = 10_000_000u64; // 0.1 DASH in credits + let amounts = split_amount_randomized(amount_credits, split_count, min_per_tx) + .ok_or_else(|| { + format!( + "Cannot split into {split_count} transactions. Each must be at least 0.1 DASH." + ) + })?; + + let tasks: Vec = amounts + .into_iter() + .map(|amount| { + BackendTask::ShieldedTask( + crate::backend_task::shielded::ShieldedTask::ShieldCredits { + seed_hash, + amount, + from_address, + nonce_override: None, + }, + ) + }) + .collect(); + + Ok(AppAction::BackendTasks( + tasks, + crate::app::BackendTasksExecutionMode::Sequential, + )) + } } /// Top up an identity from Platform addresses (Platform -> Identity). From 89d335e79ca48fd71967a92ae2b353d62a24ab41 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:36:10 +0100 Subject: [PATCH 13/44] =?UTF-8?q?Revert=20"feat(ui):=20split=20Platform?= =?UTF-8?q?=E2=86=92Shielded=20transactions=20for=20privacy"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 6213ddbe6a28e0032e6e19eff854240e73b3044e. --- src/model/amount.rs | 96 ----------------------------------- src/ui/wallets/send_screen.rs | 85 +++---------------------------- 2 files changed, 8 insertions(+), 173 deletions(-) diff --git a/src/model/amount.rs b/src/model/amount.rs index 61741f5c4..2c3dd51e8 100644 --- a/src/model/amount.rs +++ b/src/model/amount.rs @@ -757,99 +757,3 @@ mod tests { assert_eq!(empty_unit.to_string_opts(false, true), "123.45"); } } - -/// Split a total amount into `count` randomized sub-amounts that sum exactly -/// to `total`. Each sub-amount is at least `min_per_tx`. Returns `None` if -/// the total is too small to satisfy the minimum per-transaction constraint. -/// -/// The randomization adds ±30% jitter around the equal split, clamped to -/// `[min_per_tx, remaining]`. This makes individual transactions harder to -/// correlate while preserving the exact total. -/// -/// Used by shielding flows to split a large shield operation into multiple -/// smaller transactions for improved privacy. -pub fn split_amount_randomized(total: u64, count: u32, min_per_tx: u64) -> Option> { - if count == 0 || count == 1 { - return Some(vec![total]); - } - let count = count as usize; - - // Check if total can be split with the minimum constraint - if total < min_per_tx * count as u64 { - return None; - } - - use std::time::SystemTime; - // Simple deterministic seed from system time — not cryptographic, - // just enough to vary splits between invocations. - let seed = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() as u64; - - let base = total / count as u64; - let mut amounts = Vec::with_capacity(count); - let mut remaining = total; - - for i in 0..count - 1 { - // Jitter: ±30% of base amount - let jitter_range = base * 30 / 100; - // Simple hash-based pseudo-random - let hash = seed.wrapping_mul(6364136223846793005).wrapping_add(i as u64 * 1442695040888963407); - let jitter = if jitter_range > 0 { - (hash % (jitter_range * 2 + 1)) as i64 - jitter_range as i64 - } else { - 0 - }; - let amount = (base as i64 + jitter).max(min_per_tx as i64) as u64; - // Ensure we leave enough for remaining transactions - let max_here = remaining - min_per_tx * (count - i - 1) as u64; - let amount = amount.min(max_here).max(min_per_tx); - amounts.push(amount); - remaining -= amount; - } - // Last transaction gets the remainder (exact sum guaranteed) - amounts.push(remaining); - - Some(amounts) -} - -#[cfg(test)] -mod split_tests { - use super::*; - - #[test] - fn split_single_returns_total() { - assert_eq!(split_amount_randomized(1000, 1, 100), Some(vec![1000])); - assert_eq!(split_amount_randomized(1000, 0, 100), Some(vec![1000])); - } - - #[test] - fn split_sums_to_total() { - for count in 2..=10 { - let result = split_amount_randomized(10_000_000_000, count, 10_000_000).unwrap(); - assert_eq!(result.len(), count as usize); - assert_eq!(result.iter().sum::(), 10_000_000_000); - for &amount in &result { - assert!(amount >= 10_000_000, "amount {amount} < min 10_000_000"); - } - } - } - - #[test] - fn split_too_small_returns_none() { - // 100 total, 3 splits, min 50 each = need 150, only have 100 - assert_eq!(split_amount_randomized(100, 3, 50), None); - } - - #[test] - fn split_exact_minimum() { - // Exactly enough for minimum per tx - let result = split_amount_randomized(300, 3, 100).unwrap(); - assert_eq!(result.iter().sum::(), 300); - assert_eq!(result.len(), 3); - for &a in &result { - assert!(a >= 100); - } - } -} diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 6cce6b458..0b32cc879 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -389,9 +389,6 @@ pub struct WalletSendScreen { // Identity source fields selected_identity: Option, - // Split transaction options (Platform → Shielded) - split_count_str: String, - // Common options subtract_fee: bool, @@ -429,7 +426,6 @@ impl WalletSendScreen { }], fee_strategy: PlatformFeeStrategy::default(), selected_identity: None, - split_count_str: "1".to_string(), subtract_fee: false, send_status: SendStatus::NotStarted, send_banner: None, @@ -1304,32 +1300,6 @@ impl WalletSendScreen { // Amount self.render_amount_input(ui); - // Split transaction option (Platform → Shielded only) - let is_platform_to_shielded = matches!( - (&self.selected_source, self.destination_kind()), - (Some(SourceSelection::PlatformAddresses(_)), Some(AddressKind::Shielded)) - ); - if is_platform_to_shielded { - ui.add_space(8.0); - ui.horizontal(|ui| { - ui.label("Split into"); - let te = egui::TextEdit::singleline(&mut self.split_count_str) - .desired_width(40.0); - ui.add(te); - ui.label("transactions"); - }); - let split_count = self.split_count_str.trim().parse::().unwrap_or(1).clamp(1, 100); - if split_count > 1 { - ui.label( - egui::RichText::new(format!( - "Amount will be split into {split_count} randomized transactions for privacy." - )) - .small() - .color(egui::Color32::GRAY), - ); - } - } - ui.add_space(10.0); // Platform source breakdown (shows which addresses will be used) @@ -1576,54 +1546,15 @@ impl WalletSendScreen { )); } - let split_count = self - .split_count_str - .trim() - .parse::() - .unwrap_or(1) - .clamp(1, 100); - self.mark_sending(); - - if split_count <= 1 { - Ok(AppAction::BackendTask(BackendTask::ShieldedTask( - crate::backend_task::shielded::ShieldedTask::ShieldCredits { - seed_hash, - amount: amount_credits, - from_address, - nonce_override: None, - }, - ))) - } else { - // Split into randomized sub-amounts (min 0.1 DASH = 10M credits) - use crate::model::amount::split_amount_randomized; - let min_per_tx = 10_000_000u64; // 0.1 DASH in credits - let amounts = split_amount_randomized(amount_credits, split_count, min_per_tx) - .ok_or_else(|| { - format!( - "Cannot split into {split_count} transactions. Each must be at least 0.1 DASH." - ) - })?; - - let tasks: Vec = amounts - .into_iter() - .map(|amount| { - BackendTask::ShieldedTask( - crate::backend_task::shielded::ShieldedTask::ShieldCredits { - seed_hash, - amount, - from_address, - nonce_override: None, - }, - ) - }) - .collect(); - - Ok(AppAction::BackendTasks( - tasks, - crate::app::BackendTasksExecutionMode::Sequential, - )) - } + Ok(AppAction::BackendTask(BackendTask::ShieldedTask( + crate::backend_task::shielded::ShieldedTask::ShieldCredits { + seed_hash, + amount: amount_credits, + from_address, + nonce_override: None, + }, + ))) } /// Top up an identity from Platform addresses (Platform -> Identity). From 174d510d0b8f18fead57639cd6128d813a69f960 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:56:49 +0100 Subject: [PATCH 14/44] fix(ui): shield credits from multiple platform addresses When the requested shield amount exceeds a single platform address balance, allocate across multiple addresses (highest balance first). Each address gets its own ShieldCredits task, dispatched sequentially. Matches the "Source breakdown" display which already shows the multi-address allocation. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/send_screen.rs | 62 ++++++++++++++++++++++++----------- 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 0b32cc879..97e8c98f5 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -1507,13 +1507,16 @@ impl WalletSendScreen { ))) } - /// Shield credits from Platform address to shielded pool (Platform -> Shielded). + /// Shield credits from Platform address(es) to shielded pool (Platform -> Shielded). + /// + /// When the requested amount exceeds a single address balance, multiple + /// addresses are used — one `ShieldCredits` task per address, dispatched + /// sequentially. fn send_platform_to_shielded( &mut self, seed_hash: WalletSeedHash, addresses: Vec<(PlatformAddress, Address, u64)>, ) -> Result { - // Shielding from Platform always deposits into the wallet's own shielded pool. if !matches!( &self.validated_destination, Some(ValidatedAddress::Shielded(_)) @@ -1530,31 +1533,50 @@ impl WalletSendScreen { return Err("Amount must be greater than 0".to_string()); } - // Select the highest-balance platform address as the source - let (from_address, from_balance) = addresses - .iter() - .max_by_key(|(_, _, balance)| *balance) - .map(|(platform_addr, _, balance)| (*platform_addr, *balance)) - .ok_or_else(|| "No platform addresses available".to_string())?; + // Sort addresses by balance descending (greedy allocation) + let mut sorted_addrs = addresses.clone(); + sorted_addrs.sort_by(|a, b| b.2.cmp(&a.2)); - // Check that the selected source address has sufficient balance - if amount_credits > from_balance { + let total_available: u64 = sorted_addrs.iter().map(|(_, _, b)| b).sum(); + if amount_credits > total_available { return Err(format!( - "Insufficient platform balance. Need {} but highest address has {}", + "Insufficient platform balance. Need {} but total available is {}.", format_credits_as_dash(amount_credits), - format_credits_as_dash(from_balance) + format_credits_as_dash(total_available) )); } + // Allocate amount across addresses (highest balance first) + let mut remaining = amount_credits; + let mut tasks: Vec = Vec::new(); + for (platform_addr, _, balance) in &sorted_addrs { + if remaining == 0 { + break; + } + let spend = remaining.min(*balance); + if spend == 0 { + continue; + } + tasks.push(BackendTask::ShieldedTask( + crate::backend_task::shielded::ShieldedTask::ShieldCredits { + seed_hash, + amount: spend, + from_address: *platform_addr, + nonce_override: None, + }, + )); + remaining -= spend; + } + self.mark_sending(); - Ok(AppAction::BackendTask(BackendTask::ShieldedTask( - crate::backend_task::shielded::ShieldedTask::ShieldCredits { - seed_hash, - amount: amount_credits, - from_address, - nonce_override: None, - }, - ))) + if tasks.len() == 1 { + Ok(AppAction::BackendTask(tasks.into_iter().next().unwrap())) + } else { + Ok(AppAction::BackendTasks( + tasks, + crate::app::BackendTasksExecutionMode::Sequential, + )) + } } /// Top up an identity from Platform addresses (Platform -> Identity). From a2555820e30c49d5446f4ac68ee5f0d3623b5c70 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:26:25 +0100 Subject: [PATCH 15/44] fix: resolve clippy and fmt CI failures - Format 3 long lines in bundle.rs (select_notes_for_amount calls) - Collapse nested if-let+if into if-let&&condition in wallets_screen/mod.rs Co-Authored-By: Claude Opus 4.6 --- src/backend_task/shielded/bundle.rs | 9 ++++++--- src/ui/wallets/wallets_screen/mod.rs | 8 ++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 36330aaa1..bd0430c6f 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -256,7 +256,8 @@ pub async fn shielded_transfer( let recipient_addr = OrchardAddress::from_raw_bytes(&recipient_bytes) .map_err(|_| TaskError::ShieldedInvalidRecipientAddress)?; - let (spendable_notes, total_input_value) = select_notes_for_amount(shielded_state, amount, SHIELDED_FEE_HEADROOM)?; + let (spendable_notes, total_input_value) = + select_notes_for_amount(shielded_state, amount, SHIELDED_FEE_HEADROOM)?; let change_amount = total_input_value.saturating_sub(amount); tracing::info!( @@ -349,7 +350,8 @@ pub async fn unshield_credits( key: get_proving_key(), }; - let (spendable_notes, total_input_value) = select_notes_for_amount(shielded_state, amount, SHIELDED_FEE_HEADROOM)?; + let (spendable_notes, total_input_value) = + select_notes_for_amount(shielded_state, amount, SHIELDED_FEE_HEADROOM)?; let change_amount = total_input_value.saturating_sub(amount); tracing::info!( @@ -649,7 +651,8 @@ pub async fn shielded_withdrawal( let output_script = CoreScript::from_bytes(to_core_address.script_pubkey().to_bytes()); - let (spendable_notes, total_input_value) = select_notes_for_amount(shielded_state, amount, SHIELDED_FEE_HEADROOM)?; + let (spendable_notes, total_input_value) = + select_notes_for_amount(shielded_state, amount, SHIELDED_FEE_HEADROOM)?; let change_amount = total_input_value.saturating_sub(amount); tracing::info!( diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index e5d2aa4fd..ceb5240eb 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1546,10 +1546,10 @@ impl WalletsBalancesScreen { // The `is_ours` flag is set by both RPC and SPV paths for all // transactions that belong to this wallet (sends and receives). // Invalidate cache if transaction count changed (wallet refreshed). - if let Some(ref cached) = self.cached_tx_indices { - if cached.iter().any(|&i| i >= wallet_guard.transactions.len()) { - self.cached_tx_indices = None; - } + if let Some(ref cached) = self.cached_tx_indices + && cached.iter().any(|&i| i >= wallet_guard.transactions.len()) + { + self.cached_tx_indices = None; } let relevant_indices = self.cached_tx_indices.get_or_insert_with(|| { (0..wallet_guard.transactions.len()) From 74e21145c3fcd148c3b7e1e5663cb8fd94e7001e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:27:57 +0100 Subject: [PATCH 16/44] refactor: address code review findings from PR #801 - Use |= for pending_next_task dispatch to avoid overwriting top-panel actions - Add invalidate_address_input() to ShieldScreen and call it on context change - Replace .expect() on core_client lock with TaskError::LockPoisoned propagation Co-Authored-By: Claude Opus 4.6 --- src/backend_task/shielded/bundle.rs | 4 +++- src/ui/mod.rs | 5 ++++- src/ui/wallets/shield_screen.rs | 10 +++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index bd0430c6f..be80a7f75 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -510,7 +510,9 @@ pub async fn shield_from_asset_lock( app_context .core_client .read() - .expect("Core client lock was poisoned") + .map_err(|_| TaskError::LockPoisoned { + resource: "core_client", + })? .send_raw_transaction(&asset_lock_transaction)?; // Step 4: Remove used UTXOs from wallet diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 55bad6233..08b6cd90a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -869,7 +869,10 @@ impl Screen { Screen::DashPayQRGeneratorScreen(screen) => screen.app_context = app_context, Screen::DashPayProfileSearchScreen(screen) => screen.app_context = app_context, // Shielded screens - Screen::ShieldScreen(screen) => screen.app_context = app_context.clone(), + Screen::ShieldScreen(screen) => { + screen.app_context = app_context.clone(); + screen.invalidate_address_input(); + } Screen::ShieldedSendScreen(screen) => { screen.app_context = app_context.clone(); screen.invalidate_address_input(); diff --git a/src/ui/wallets/shield_screen.rs b/src/ui/wallets/shield_screen.rs index 5692cd387..9a390a3e3 100644 --- a/src/ui/wallets/shield_screen.rs +++ b/src/ui/wallets/shield_screen.rs @@ -83,6 +83,14 @@ impl ShieldScreen { } } + /// Reset the address and amount inputs — called when AppContext switches network. + pub(crate) fn invalidate_address_input(&mut self) { + self.address_input = None; + self.validated_source = None; + self.amount_input = None; + self.amount = None; + } + fn parse_repeat_count(&self) -> u32 { self.repeat_count_str .trim() @@ -482,7 +490,7 @@ impl ScreenLike for ShieldScreen { // Dispatch pending sequential task from previous frame if let Some(task) = self.pending_next_task.take() { - action = AppAction::BackendTask(task); + action |= AppAction::BackendTask(task); } // Dispatch pending refresh task (sync notes after successful shield) From 1ce3fc4a00af3adfe9b5b9efa9a953c0ffb62534 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:35:53 +0100 Subject: [PATCH 17/44] perf(ui): avoid per-frame DB queries and unnecessary clone in Send screen - Move identity/shielded loading inside the address_input initialization guard so DB queries and mutex locks only run when building a new AddressInput, not every frame. - Remove unnecessary addresses.clone() in multi-address shielding. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/send_screen.rs | 42 ++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 97e8c98f5..5bc97d4bb 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -1534,7 +1534,7 @@ impl WalletSendScreen { } // Sort addresses by balance descending (greedy allocation) - let mut sorted_addrs = addresses.clone(); + let mut sorted_addrs = addresses; sorted_addrs.sort_by(|a, b| b.2.cmp(&a.2)); let total_available: u64 = sorted_addrs.iter().map(|(_, _, b)| b).sum(); @@ -2089,20 +2089,29 @@ impl WalletSendScreen { } else { None }; - let loaded_identities: Vec<_> = self - .get_loaded_identities() - .into_iter() - .filter(|qi| Some(qi.identity.id()) != source_identity_id) - .collect(); - let shielded_info: Option<(String, u64)> = self.selected_wallet_seed_hash.and_then(|sh| { - let states = self.app_context.shielded_states.lock().ok()?; - let state = states.get(&sh)?; - use dash_sdk::dpp::address_funds::OrchardAddress; - let raw = state.keys.default_address.to_raw_address_bytes(); - let addr = OrchardAddress::from_raw_bytes(&raw).ok()?; - Some((addr.to_bech32m_string(self.app_context.network), state.shielded_balance)) - }); - let addr_input = self.address_input.get_or_insert_with(|| { + // Only load identities and shielded state when building a new AddressInput + // (get_or_insert_with fires once). Avoids per-frame DB queries. + let addr_input = if self.address_input.is_some() { + self.address_input.as_mut().unwrap() + } else { + let loaded_identities: Vec<_> = self + .get_loaded_identities() + .into_iter() + .filter(|qi| Some(qi.identity.id()) != source_identity_id) + .collect(); + let shielded_info: Option<(String, u64)> = + self.selected_wallet_seed_hash.and_then(|sh| { + let states = self.app_context.shielded_states.lock().ok()?; + let state = states.get(&sh)?; + use dash_sdk::dpp::address_funds::OrchardAddress; + let raw = state.keys.default_address.to_raw_address_bytes(); + let addr = OrchardAddress::from_raw_bytes(&raw).ok()?; + Some(( + addr.to_bech32m_string(self.app_context.network), + state.shielded_balance, + )) + }); + self.address_input.get_or_insert_with(|| { let allowed_kinds = match &self.selected_source { Some(SourceSelection::CoreWallet) => { let mut kinds = vec![AddressKind::Core, AddressKind::Platform]; @@ -2163,7 +2172,8 @@ impl WalletSendScreen { } builder - }); + }) + }; let resp = addr_input.show(ui); resp.inner.update(&mut self.validated_destination); From 7b64db6d765f23d39a84f8ef3727a1c1b3379214 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:41:06 +0100 Subject: [PATCH 18/44] fix(ui): validate identity self-send at dispatch time The autocomplete filter prevents selecting the source identity in the dropdown, but users can still manually type their own identity ID. Now validates at send time: if source and destination identity IDs match, returns a clear error instead of dispatching. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/send_screen.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 5bc97d4bb..1d1aa4262 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -1768,6 +1768,14 @@ impl WalletSendScreen { .and_then(|v| v.as_identity_id().copied()) .ok_or_else(|| "Invalid identity ID".to_string())?; + // Prevent self-send (same identity as source and destination) + if to_identity_id == qualified_identity.identity.id() { + return Err( + "You cannot send credits to the same identity. Please choose a different destination." + .to_string(), + ); + } + self.mark_sending(); Ok(AppAction::BackendTask(BackendTask::IdentityTask( IdentityTask::Transfer(qualified_identity, to_identity_id, amount_credits, None), From 5551c0fd85a0538da4138457f192059827ac9bb0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:44:34 +0100 Subject: [PATCH 19/44] refactor(send): replace inverted if-let guard with idiomatic !matches! `send_core_to_shielded` used an if-let with an empty success branch and a return-error in the else, which is confusing and inconsistent with `send_platform_to_shielded` in the same file. Replace with the `if !matches!(...)` form used throughout the codebase. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/send_screen.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 70fed3e2d..70774ebef 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -1416,9 +1416,10 @@ impl WalletSendScreen { fn send_core_to_shielded(&mut self, seed_hash: WalletSeedHash) -> Result { // Shielding from Core always deposits into the wallet's own shielded pool. // Validate the destination is a shielded address (the address input already constrains this). - if let Some(ValidatedAddress::Shielded(_)) = &self.validated_destination { - // OK: destination is a shielded address (self-shielding) - } else { + if !matches!( + &self.validated_destination, + Some(ValidatedAddress::Shielded(_)) + ) { return Err("Please enter a valid shielded address".to_string()); } From 43485fb8590db12b7dc72c9e190e7532d41eceff Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:55:40 +0100 Subject: [PATCH 20/44] fix(review): address PR #801 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(shielded): correct SHIELDED_FEE_HEADROOM to 0.1 DASH (10_000_000_000 credits); previous value of 10_000_000 was 0.0001 DASH — 100x too small, insufficient to cover actual builder fees (~180M credits observed) (Copilot comment #3000464620) - fix(shield-screen): pass selected Core address to ShieldFromAssetLock task so asset lock UTXO selection is restricted to the user-chosen address; add source_address field to ShieldFromAssetLock enum variant, thread it through context/shielded dispatch and bundle.rs using a temporary UTXO swap that is restored before Step 4 removal (thepastaclaw BLOCKING #2997616620) - fix(shield-screen): read_core_balance_duffs now returns the per-address balance when a specific Core address is selected, keeping the Max button consistent with the UTXO selection constraint - fix(send-screen): prevent identity self-send in send_identity_to_identity; return an error early if to_identity_id equals the source identity id (coderabbit #3000255245) - fix(wallets-screen): fix cache invalidation for transaction list growth; add cached_tx_source_len field and clear cache when tx count changes, not only when indices go out-of-bounds (coderabbit #2999428009) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/shielded/bundle.rs | 78 ++++++++++++++++++++-------- src/backend_task/shielded/mod.rs | 2 + src/context/shielded.rs | 5 +- src/mcp/tools/shielded.rs | 1 + src/ui/wallets/send_screen.rs | 7 +++ src/ui/wallets/shield_screen.rs | 24 ++++++--- src/ui/wallets/wallets_screen/mod.rs | 20 +++++-- 7 files changed, 104 insertions(+), 33 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index be80a7f75..eabb6f238 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -8,6 +8,7 @@ use dash_sdk::dpp::address_funds::{ AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, OrchardAddress, PlatformAddress, }; use dash_sdk::dpp::dashcore::Address; +use dash_sdk::dpp::dashcore::{OutPoint, TxOut}; use dash_sdk::dpp::identity::core_script::CoreScript; use dash_sdk::dpp::shielded::builder::{ OrchardProver, SpendableNote, build_shield_transition, build_shielded_transfer_transition, @@ -16,7 +17,7 @@ use dash_sdk::dpp::shielded::builder::{ use dash_sdk::dpp::withdrawal::Pooling; use dash_sdk::grovedb_commitment_tree::{Nullifier, PaymentAddress, ProvingKey}; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, Mutex}; /// Fee headroom for shielded note selection (in credits). @@ -25,9 +26,9 @@ use std::sync::{Arc, Mutex}; /// DPP builder has room for the transition fee. Without it, sending the full /// shielded balance fails with "fee exceeds spendable". /// -/// Estimated at ~0.1 DASH (10M credits). The actual fee is calculated by -/// the builder and any excess stays as change in the shielded pool. -const SHIELDED_FEE_HEADROOM: u64 = 10_000_000; +/// Estimated at 0.1 DASH (10,000,000,000 credits). The actual fee is +/// calculated by the builder and any excess stays as change in the shielded pool. +const SHIELDED_FEE_HEADROOM: u64 = 10_000_000_000; /// Wrapper around a cached `ProvingKey` that implements `OrchardProver`. struct CachedProver { @@ -439,6 +440,7 @@ pub async fn shield_from_asset_lock( seed_hash: &WalletSeedHash, shielded_state: &ShieldedWalletState, amount_duffs: u64, + source_address: Option<&Address>, ) -> Result { use dash_sdk::dashcore_rpc::RpcApi; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; @@ -470,29 +472,63 @@ pub async fn shield_from_asset_lock( .write() .map_err(|_| TaskError::LockPoisoned { resource: "wallet" })?; - match wallet.generic_asset_lock_transaction( + // If a source address is specified, temporarily restrict the wallet's UTXO map + // to that address so `generic_asset_lock_transaction` only draws from it. + // We restore the full map after the transaction is built so that Step 4 + // (outpoint-based UTXO removal) operates on the complete set. + let restrict_utxos = |wallet: &mut crate::model::wallet::Wallet, + addr: &Address| + -> HashMap> { + let filtered = wallet + .utxos + .get(addr) + .map(|m| [(addr.clone(), m.clone())].into_iter().collect()) + .unwrap_or_default(); + std::mem::replace(&mut wallet.utxos, filtered) + }; + + // Apply address filter and save original UTXOs for later restoration. + let mut saved_utxos = source_address.map(|addr| restrict_utxos(&mut wallet, addr)); + + // First attempt + let first_result = wallet.generic_asset_lock_transaction( app_context.as_ref(), app_context.network, asset_lock_duffs, false, - ) { - Ok((tx, private_key, address, _change, utxos)) => (tx, private_key, address, utxos), - Err(_) => { - wallet - .reload_utxos(app_context.as_ref()) - .map_err(|detail| TaskError::WalletUtxoReloadFailed { detail })?; - - let (tx, private_key, address, _change, utxos) = wallet - .generic_asset_lock_transaction( - app_context.as_ref(), - app_context.network, - asset_lock_duffs, - false, - ) - .map_err(shielded_build_error)?; - (tx, private_key, address, utxos) + ); + + if first_result.is_err() { + // Restore full UTXOs before reload so reload can refresh the complete set. + if let Some(orig) = saved_utxos.take() { + wallet.utxos = orig; } + wallet + .reload_utxos(app_context.as_ref()) + .map_err(|detail| TaskError::WalletUtxoReloadFailed { detail })?; + // Re-apply address filter on the freshly reloaded UTXOs. + saved_utxos = source_address.map(|addr| restrict_utxos(&mut wallet, addr)); } + + let (tx, private_key, address, _change, utxos) = if let Ok(ok) = first_result { + ok + } else { + wallet + .generic_asset_lock_transaction( + app_context.as_ref(), + app_context.network, + asset_lock_duffs, + false, + ) + .map_err(shielded_build_error)? + }; + + // Restore full UTXO map; Step 4 removes used outpoints by key from the full set. + if let Some(orig) = saved_utxos { + wallet.utxos = orig; + } + + (tx, private_key, address, utxos) }; let tx_id = asset_lock_transaction.txid(); diff --git a/src/backend_task/shielded/mod.rs b/src/backend_task/shielded/mod.rs index c48c6987b..971d88363 100644 --- a/src/backend_task/shielded/mod.rs +++ b/src/backend_task/shielded/mod.rs @@ -45,6 +45,8 @@ pub enum ShieldedTask { ShieldFromAssetLock { seed_hash: WalletSeedHash, amount_duffs: u64, + /// If set, restrict UTXO selection to this Core address. + source_address: Option
, }, /// Withdraw from the shielded pool directly to a core L1 address (Type 19) diff --git a/src/context/shielded.rs b/src/context/shielded.rs index cb66895b4..68a217a47 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -80,8 +80,9 @@ impl AppContext { ShieldedTask::ShieldFromAssetLock { seed_hash, amount_duffs, + source_address, } => { - self.shield_from_asset_lock_task(seed_hash, amount_duffs) + self.shield_from_asset_lock_task(seed_hash, amount_duffs, source_address) .await } @@ -606,6 +607,7 @@ impl AppContext { self: &Arc, seed_hash: WalletSeedHash, amount_duffs: u64, + source_address: Option, ) -> Result { let state_ref = { let mut states = self.shielded_states.lock().unwrap(); @@ -617,6 +619,7 @@ impl AppContext { &seed_hash, &state_ref, amount_duffs, + source_address.as_ref(), ) .await; diff --git a/src/mcp/tools/shielded.rs b/src/mcp/tools/shielded.rs index 05c05c068..2cbb538a5 100644 --- a/src/mcp/tools/shielded.rs +++ b/src/mcp/tools/shielded.rs @@ -84,6 +84,7 @@ impl AsyncTool for ShieldedShieldFromCore { let task = BackendTask::ShieldedTask(ShieldedTask::ShieldFromAssetLock { seed_hash, amount_duffs: param.amount_duffs, + source_address: None, }); let result = dispatch_task(&ctx, task) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 70774ebef..4860f1c24 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -1446,6 +1446,7 @@ impl WalletSendScreen { crate::backend_task::shielded::ShieldedTask::ShieldFromAssetLock { seed_hash, amount_duffs, + source_address: None, }, ))) } @@ -1747,6 +1748,12 @@ impl WalletSendScreen { .and_then(|v| v.as_identity_id().copied()) .ok_or_else(|| "Invalid identity ID".to_string())?; + if to_identity_id == qualified_identity.identity.id() { + return Err( + "Cannot transfer to the same identity. Choose a different destination.".to_string(), + ); + } + self.mark_sending(); Ok(AppAction::BackendTask(BackendTask::IdentityTask( IdentityTask::Transfer(qualified_identity, to_identity_id, amount_credits, None), diff --git a/src/ui/wallets/shield_screen.rs b/src/ui/wallets/shield_screen.rs index 9a390a3e3..dc0882a3f 100644 --- a/src/ui/wallets/shield_screen.rs +++ b/src/ui/wallets/shield_screen.rs @@ -147,13 +147,17 @@ impl ShieldScreen { /// Read the core wallet balance in duffs. fn read_core_balance_duffs(&self) -> u64 { let wallets = self.app_context.wallets.read().unwrap(); - wallets - .get(&self.seed_hash) - .map(|w| { - let wallet = w.read().unwrap(); - wallet.total_balance_duffs() - }) - .unwrap_or(0) + let Some(wallet_arc) = wallets.get(&self.seed_hash) else { + return 0; + }; + let wallet = wallet_arc.read().unwrap(); + // If a specific Core address is selected, return its individual balance + // so the max-amount display matches the funds actually available for this address. + if let Some(addr) = self.validated_source.as_ref().and_then(|v| v.as_core()) { + wallet.address_balances.get(addr).copied().unwrap_or(0) + } else { + wallet.total_balance_duffs() + } } /// Build a single ShieldCredits task with optional nonce override. @@ -724,11 +728,17 @@ impl ScreenLike for ShieldScreen { } Some(AddressKind::Core) => { let amount_duffs = amount / CREDITS_PER_DUFF; + let source_address = self + .validated_source + .as_ref() + .and_then(|v| v.as_core()) + .cloned(); self.status = Status::WaitingForResult; action = AppAction::BackendTask(BackendTask::ShieldedTask( ShieldedTask::ShieldFromAssetLock { seed_hash: self.seed_hash, amount_duffs, + source_address, }, )); } diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index ceb5240eb..91afb10b6 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -159,6 +159,9 @@ pub struct WalletsBalancesScreen { /// Cached filtered transaction indices for the currently selected wallet. /// Invalidated (set to None) on wallet switch or transaction updates. cached_tx_indices: Option>, + /// Transaction count at the time `cached_tx_indices` was last built. + /// Used to detect list growth that doesn't make existing indices OOB. + cached_tx_source_len: Option, } impl WalletsBalancesScreen { @@ -268,6 +271,7 @@ impl WalletsBalancesScreen { pending_list_wallet_hash: None, pending_list_is_single_key: false, cached_tx_indices: None, + cached_tx_source_len: None, } } @@ -368,6 +372,7 @@ impl WalletsBalancesScreen { self.selected_account = None; self.selected_account_tab = AccountTab::default(); self.cached_tx_indices = None; + self.cached_tx_source_len = None; self.shielded_tab_view = seed_hash.map(|hash| ShieldedTabView::new(&self.app_context, hash)); @@ -469,6 +474,7 @@ impl WalletsBalancesScreen { self.mine_dialog.address_input = None; self.mine_dialog.validated_address = None; self.cached_tx_indices = None; + self.cached_tx_source_len = None; } fn add_receiving_address(&mut self) { @@ -1545,14 +1551,19 @@ impl WalletsBalancesScreen { // Filter to transactions involving this wallet's addresses. // The `is_ours` flag is set by both RPC and SPV paths for all // transactions that belong to this wallet (sends and receives). - // Invalidate cache if transaction count changed (wallet refreshed). - if let Some(ref cached) = self.cached_tx_indices - && cached.iter().any(|&i| i >= wallet_guard.transactions.len()) + // Invalidate cache when source tx count changes or indices go stale. + let tx_len = wallet_guard.transactions.len(); + if self.cached_tx_source_len != Some(tx_len) + || self + .cached_tx_indices + .as_ref() + .is_some_and(|cached| cached.iter().any(|&i| i >= tx_len)) { self.cached_tx_indices = None; + self.cached_tx_source_len = Some(tx_len); } let relevant_indices = self.cached_tx_indices.get_or_insert_with(|| { - (0..wallet_guard.transactions.len()) + (0..tx_len) .filter(|&i| wallet_guard.transactions[i].is_ours) .collect() }); @@ -2748,6 +2759,7 @@ impl ScreenLike for WalletsBalancesScreen { crate::ui::BackendTaskSuccessResult::RefreshedWallet { warning } => { self.refreshing = false; self.cached_tx_indices = None; + self.cached_tx_source_len = None; // Refresh the cached platform sync info so the panel shows // updated timestamps and block heights after a wallet sync. let seed_hash = self From 893f770cee972f54bcf65b252ff79f5f76d26dc1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:08:43 +0100 Subject: [PATCH 21/44] docs(shielded): consolidate duplicate doc comment summary lines The `select_notes_for_amount` function had two consecutive rustdoc summary lines. Merge into a single, complete sentence. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/shielded/bundle.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index eabb6f238..93e854b19 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -769,8 +769,7 @@ pub async fn shielded_withdrawal( Ok(spent_nullifiers) } -/// Select notes to cover the requested amount using a greedy algorithm. -/// Select unspent notes to cover `amount + fee_headroom`. +/// Select unspent notes to cover `amount + fee_headroom` using a greedy algorithm. /// /// The `fee_headroom` ensures selected inputs cover both the send amount /// and the transition fee. Without it, sending the full balance fails From bf176282a30ac7b94dca17f565f2581c6bde6ebe Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:04:35 +0100 Subject: [PATCH 22/44] refactor(shielded): extract restrict_utxos closure into a named helper function Pull the inline `restrict_utxos` closure from `shield_from_asset_lock` into a private standalone function with an explicit signature and doc comment. Both callsites are updated; behaviour is unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/shielded/bundle.rs | 31 +++++++++++++++-------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 93e854b19..85fb83e9a 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -429,6 +429,22 @@ pub async fn unshield_credits( Ok(spent_nullifiers) } +/// Temporarily swap the wallet's UTXO map to only those entries belonging to `addr`. +/// +/// Returns the original (full) UTXO map so the caller can restore it after the +/// operation that needs the filtered view completes. +fn restrict_utxos( + wallet: &mut crate::model::wallet::Wallet, + addr: &Address, +) -> HashMap> { + let filtered = wallet + .utxos + .get(addr) + .map(|m| [(addr.clone(), m.clone())].into_iter().collect()) + .unwrap_or_default(); + std::mem::replace(&mut wallet.utxos, filtered) +} + /// Build and broadcast a ShieldFromAssetLock transition (core DASH -> shielded pool via asset lock). /// /// Creates an asset lock transaction from wallet UTXOs, broadcasts it, waits for @@ -472,21 +488,6 @@ pub async fn shield_from_asset_lock( .write() .map_err(|_| TaskError::LockPoisoned { resource: "wallet" })?; - // If a source address is specified, temporarily restrict the wallet's UTXO map - // to that address so `generic_asset_lock_transaction` only draws from it. - // We restore the full map after the transaction is built so that Step 4 - // (outpoint-based UTXO removal) operates on the complete set. - let restrict_utxos = |wallet: &mut crate::model::wallet::Wallet, - addr: &Address| - -> HashMap> { - let filtered = wallet - .utxos - .get(addr) - .map(|m| [(addr.clone(), m.clone())].into_iter().collect()) - .unwrap_or_default(); - std::mem::replace(&mut wallet.utxos, filtered) - }; - // Apply address filter and save original UTXOs for later restoration. let mut saved_utxos = source_address.map(|addr| restrict_utxos(&mut wallet, addr)); From 8c8b07742bd67f8c35ceff71410329298ef4de6f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:16:40 +0100 Subject: [PATCH 23/44] feat(shielded): replace hardcoded fee headroom with dynamic estimation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use `compute_minimum_shielded_fee` from `dpp` instead of a hardcoded 0.1 DASH constant. The new estimate uses the Orchard minimum of 2 actions with a 2× safety multiplier, reducing headroom from ~0.1 DASH to ~0.0025 DASH while automatically adapting to protocol version changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/shielded/bundle.rs | 33 ++++++++++++++--------------- src/model/fee_estimation.rs | 32 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 85fb83e9a..3d76a5d6e 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -1,7 +1,7 @@ use crate::backend_task::error::{TaskError, shielded_broadcast_error, shielded_build_error}; use crate::context::AppContext; use crate::context::shielded::get_proving_key; -use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::fee_estimation::{estimate_shielded_fee_headroom, format_credits_as_dash}; use crate::model::wallet::WalletSeedHash; use crate::model::wallet::shielded::ShieldedWalletState; use dash_sdk::dpp::address_funds::{ @@ -20,16 +20,6 @@ use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, Mutex}; -/// Fee headroom for shielded note selection (in credits). -/// -/// When selecting notes to cover a send amount, we add this headroom so the -/// DPP builder has room for the transition fee. Without it, sending the full -/// shielded balance fails with "fee exceeds spendable". -/// -/// Estimated at 0.1 DASH (10,000,000,000 credits). The actual fee is -/// calculated by the builder and any excess stays as change in the shielded pool. -const SHIELDED_FEE_HEADROOM: u64 = 10_000_000_000; - /// Wrapper around a cached `ProvingKey` that implements `OrchardProver`. struct CachedProver { key: &'static ProvingKey, @@ -257,8 +247,11 @@ pub async fn shielded_transfer( let recipient_addr = OrchardAddress::from_raw_bytes(&recipient_bytes) .map_err(|_| TaskError::ShieldedInvalidRecipientAddress)?; - let (spendable_notes, total_input_value) = - select_notes_for_amount(shielded_state, amount, SHIELDED_FEE_HEADROOM)?; + let (spendable_notes, total_input_value) = select_notes_for_amount( + shielded_state, + amount, + estimate_shielded_fee_headroom(sdk.version()), + )?; let change_amount = total_input_value.saturating_sub(amount); tracing::info!( @@ -351,8 +344,11 @@ pub async fn unshield_credits( key: get_proving_key(), }; - let (spendable_notes, total_input_value) = - select_notes_for_amount(shielded_state, amount, SHIELDED_FEE_HEADROOM)?; + let (spendable_notes, total_input_value) = select_notes_for_amount( + shielded_state, + amount, + estimate_shielded_fee_headroom(sdk.version()), + )?; let change_amount = total_input_value.saturating_sub(amount); tracing::info!( @@ -690,8 +686,11 @@ pub async fn shielded_withdrawal( let output_script = CoreScript::from_bytes(to_core_address.script_pubkey().to_bytes()); - let (spendable_notes, total_input_value) = - select_notes_for_amount(shielded_state, amount, SHIELDED_FEE_HEADROOM)?; + let (spendable_notes, total_input_value) = select_notes_for_amount( + shielded_state, + amount, + estimate_shielded_fee_headroom(sdk.version()), + )?; let change_amount = total_input_value.saturating_sub(amount); tracing::info!( diff --git a/src/model/fee_estimation.rs b/src/model/fee_estimation.rs index bdd96fcd5..268e867f0 100644 --- a/src/model/fee_estimation.rs +++ b/src/model/fee_estimation.rs @@ -651,6 +651,21 @@ pub fn format_credits(credits: u64) -> String { } } +/// Estimate the fee headroom needed for shielded note selection. +/// +/// Uses `compute_minimum_shielded_fee` from `dpp` with a conservative estimate +/// of 2 Orchard actions (the privacy minimum) and a 2× safety multiplier. +/// This covers all realistic scenarios — to exceed the margin, a transaction +/// would need 11+ input notes. +/// +/// Returns the headroom in credits to pass to `select_notes_for_amount`. +pub fn estimate_shielded_fee_headroom(platform_version: &PlatformVersion) -> u64 { + use dash_sdk::dpp::shielded::compute_minimum_shielded_fee; + // 2 actions is the Orchard privacy minimum (handles 1-2 input notes). + // 2× multiplier provides margin for bundles with more inputs. + compute_minimum_shielded_fee(2, platform_version).saturating_mul(2) +} + #[cfg(test)] mod tests { use super::*; @@ -734,4 +749,21 @@ mod tests { assert_eq!(format_credits_as_dash(100_000_000), "0.001 DASH"); assert_eq!(format_credits_as_dash(100_000), "0.000001 DASH"); } + + #[test] + fn test_estimate_shielded_fee_headroom() { + let platform_version = PlatformVersion::latest(); + let headroom = estimate_shielded_fee_headroom(platform_version); + // Should be roughly 2× the minimum fee for 2 actions. + // At current constants: proof_verification(100M) + 2 × (processing(3M) + storage(~8.5M)) ≈ 123M + // 2× ≈ 246M. Allow for constant evolution. + assert!( + headroom > 100_000_000, + "headroom should be at least 100M credits (>0.001 DASH)" + ); + assert!( + headroom < 2_000_000_000, + "headroom should be under 2B credits (<0.02 DASH)" + ); + } } From 4d76762686b8c7728a8026c49589ed9cd52864ea Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:35:23 +0100 Subject: [PATCH 24/44] refactor(wallet): thread source_address filter through UTXO selection Add `source_address: Option<&Address>` to the UTXO selection chain (`select_unspent_utxos_for` -> `select_utxos_with_fee_retry` -> `asset_lock_transaction_from_private_key` -> `generic_asset_lock_transaction`) instead of temporarily swapping the wallet's UTXO map with `std::mem::replace`. This eliminates the `restrict_utxos` hack in `shield_from_asset_lock`, which mutated shared wallet state to work around the missing parameter. The save/restore/re-filter dance is replaced with a clean parameter pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/core/create_asset_lock.rs | 2 + .../identity/register_identity.rs | 2 + src/backend_task/identity/top_up_identity.rs | 2 + src/backend_task/shielded/bundle.rs | 66 +++++-------------- ...fund_platform_address_from_wallet_utxos.rs | 2 + src/model/wallet/asset_lock_transaction.rs | 19 +++++- src/model/wallet/mod.rs | 22 +++---- src/model/wallet/utxos.rs | 8 ++- 8 files changed, 59 insertions(+), 64 deletions(-) diff --git a/src/backend_task/core/create_asset_lock.rs b/src/backend_task/core/create_asset_lock.rs index dc5a0e26e..ff200672e 100644 --- a/src/backend_task/core/create_asset_lock.rs +++ b/src/backend_task/core/create_asset_lock.rs @@ -26,6 +26,7 @@ impl AppContext { amount_duffs, allow_take_fee_from_amount, identity_index, + None, ) .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })? }; @@ -78,6 +79,7 @@ impl AppContext { allow_take_fee_from_amount, identity_index, top_up_index, + None, ) .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })? }; diff --git a/src/backend_task/identity/register_identity.rs b/src/backend_task/identity/register_identity.rs index 7d93520d4..ca6a675d9 100644 --- a/src/backend_task/identity/register_identity.rs +++ b/src/backend_task/identity/register_identity.rs @@ -99,6 +99,7 @@ impl AppContext { amount, true, identity_index, + None, ) { Ok(transaction) => transaction, Err(e) => { @@ -119,6 +120,7 @@ impl AppContext { amount, true, identity_index, + None, ) .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e, diff --git a/src/backend_task/identity/top_up_identity.rs b/src/backend_task/identity/top_up_identity.rs index 7d66f8e23..6916074d4 100644 --- a/src/backend_task/identity/top_up_identity.rs +++ b/src/backend_task/identity/top_up_identity.rs @@ -108,6 +108,7 @@ impl AppContext { true, identity_index, top_up_index, + None, ) { Ok(transaction) => transaction, Err(e) => { @@ -129,6 +130,7 @@ impl AppContext { true, identity_index, top_up_index, + None, ) .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e, diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 3d76a5d6e..1c35c7094 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -8,7 +8,6 @@ use dash_sdk::dpp::address_funds::{ AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, OrchardAddress, PlatformAddress, }; use dash_sdk::dpp::dashcore::Address; -use dash_sdk::dpp::dashcore::{OutPoint, TxOut}; use dash_sdk::dpp::identity::core_script::CoreScript; use dash_sdk::dpp::shielded::builder::{ OrchardProver, SpendableNote, build_shield_transition, build_shielded_transfer_transition, @@ -17,7 +16,7 @@ use dash_sdk::dpp::shielded::builder::{ use dash_sdk::dpp::withdrawal::Pooling; use dash_sdk::grovedb_commitment_tree::{Nullifier, PaymentAddress, ProvingKey}; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; /// Wrapper around a cached `ProvingKey` that implements `OrchardProver`. @@ -425,22 +424,6 @@ pub async fn unshield_credits( Ok(spent_nullifiers) } -/// Temporarily swap the wallet's UTXO map to only those entries belonging to `addr`. -/// -/// Returns the original (full) UTXO map so the caller can restore it after the -/// operation that needs the filtered view completes. -fn restrict_utxos( - wallet: &mut crate::model::wallet::Wallet, - addr: &Address, -) -> HashMap> { - let filtered = wallet - .utxos - .get(addr) - .map(|m| [(addr.clone(), m.clone())].into_iter().collect()) - .unwrap_or_default(); - std::mem::replace(&mut wallet.utxos, filtered) -} - /// Build and broadcast a ShieldFromAssetLock transition (core DASH -> shielded pool via asset lock). /// /// Creates an asset lock transaction from wallet UTXOs, broadcasts it, waits for @@ -484,47 +467,32 @@ pub async fn shield_from_asset_lock( .write() .map_err(|_| TaskError::LockPoisoned { resource: "wallet" })?; - // Apply address filter and save original UTXOs for later restoration. - let mut saved_utxos = source_address.map(|addr| restrict_utxos(&mut wallet, addr)); - - // First attempt let first_result = wallet.generic_asset_lock_transaction( app_context.as_ref(), app_context.network, asset_lock_duffs, false, + source_address, ); - if first_result.is_err() { - // Restore full UTXOs before reload so reload can refresh the complete set. - if let Some(orig) = saved_utxos.take() { - wallet.utxos = orig; + let (tx, private_key, address, _change, utxos) = match first_result { + Ok(ok) => ok, + Err(_) => { + wallet + .reload_utxos(app_context.as_ref()) + .map_err(|detail| TaskError::WalletUtxoReloadFailed { detail })?; + wallet + .generic_asset_lock_transaction( + app_context.as_ref(), + app_context.network, + asset_lock_duffs, + false, + source_address, + ) + .map_err(shielded_build_error)? } - wallet - .reload_utxos(app_context.as_ref()) - .map_err(|detail| TaskError::WalletUtxoReloadFailed { detail })?; - // Re-apply address filter on the freshly reloaded UTXOs. - saved_utxos = source_address.map(|addr| restrict_utxos(&mut wallet, addr)); - } - - let (tx, private_key, address, _change, utxos) = if let Ok(ok) = first_result { - ok - } else { - wallet - .generic_asset_lock_transaction( - app_context.as_ref(), - app_context.network, - asset_lock_duffs, - false, - ) - .map_err(shielded_build_error)? }; - // Restore full UTXO map; Step 4 removes used outpoints by key from the full set. - if let Some(orig) = saved_utxos { - wallet.utxos = orig; - } - (tx, private_key, address, utxos) }; 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 a84d969c9..e786620e1 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 @@ -56,6 +56,7 @@ impl AppContext { self.network, asset_lock_amount, allow_take_fee_from_amount, + None, ) { Ok((tx, private_key, address, _change, utxos)) => (tx, private_key, address, utxos), Err(e) => { @@ -73,6 +74,7 @@ impl AppContext { self.network, asset_lock_amount, allow_take_fee_from_amount, + None, ) .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })?; (tx, private_key, address, utxos) diff --git a/src/model/wallet/asset_lock_transaction.rs b/src/model/wallet/asset_lock_transaction.rs index f9de609d2..ad9a751ac 100644 --- a/src/model/wallet/asset_lock_transaction.rs +++ b/src/model/wallet/asset_lock_transaction.rs @@ -117,12 +117,18 @@ impl Wallet { &self, amount: u64, allow_take_fee_from_amount: bool, + source_address: Option<&Address>, ) -> Result<(BTreeMap, AssetLockFeeResult), String> { let mut fee_estimate = MIN_ASSET_LOCK_FEE; for _ in 0..2 { let (utxos, _) = self - .select_unspent_utxos_for(amount, fee_estimate, allow_take_fee_from_amount) + .select_unspent_utxos_for( + amount, + fee_estimate, + allow_take_fee_from_amount, + source_address, + ) .ok_or_else(|| { format!( "Not enough spendable funds to create asset lock transaction: \ @@ -168,6 +174,7 @@ impl Wallet { amount: u64, allow_take_fee_from_amount: bool, identity_index: u32, + source_address: Option<&Address>, ) -> Result< ( Transaction, @@ -185,10 +192,11 @@ impl Wallet { amount, allow_take_fee_from_amount, private_key, + source_address, ) } - #[allow(clippy::type_complexity)] + #[allow(clippy::type_complexity, clippy::too_many_arguments)] pub fn top_up_asset_lock_transaction( &mut self, app_context: &AppContext, @@ -197,6 +205,7 @@ impl Wallet { allow_take_fee_from_amount: bool, identity_index: u32, top_up_index: u32, + source_address: Option<&Address>, ) -> Result< ( Transaction, @@ -218,6 +227,7 @@ impl Wallet { amount, allow_take_fee_from_amount, private_key, + source_address, ) } @@ -230,6 +240,7 @@ impl Wallet { network: Network, amount: u64, allow_take_fee_from_amount: bool, + source_address: Option<&Address>, ) -> Result< ( Transaction, @@ -258,6 +269,7 @@ impl Wallet { amount, allow_take_fee_from_amount, private_key, + source_address, )?; Ok(( @@ -277,6 +289,7 @@ impl Wallet { amount: u64, allow_take_fee_from_amount: bool, private_key: PrivateKey, + source_address: Option<&Address>, ) -> Result< ( Transaction, @@ -305,7 +318,7 @@ impl Wallet { // the selected UTXOs are insufficient, we retry once with the computed fee // so that marginal UTXOs are not missed. let (utxos, fee_result) = - self.select_utxos_with_fee_retry(amount, allow_take_fee_from_amount)?; + self.select_utxos_with_fee_retry(amount, allow_take_fee_from_amount, source_address)?; let actual_amount = fee_result.actual_amount; let change_option = fee_result.change; diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index f474c0155..6646ff1d8 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -1805,7 +1805,7 @@ impl Wallet { // the transaction is fully built and signed, so that a failure at any later // step cannot permanently drop UTXOs from the wallet. let (utxos, change_option) = self - .select_unspent_utxos_for(amount, fee, subtract_fee_from_amount) + .select_unspent_utxos_for(amount, fee, subtract_fee_from_amount, None) .ok_or_else(|| "Insufficient funds".to_string())?; let send_value = if change_option.is_none() && subtract_fee_from_amount { @@ -1939,7 +1939,7 @@ impl Wallet { // the transaction is fully built and signed, so that a failure at any later // step cannot permanently drop UTXOs from the wallet. let (utxos, change_option) = self - .select_unspent_utxos_for(total_amount, fee, subtract_fee_from_amount) + .select_unspent_utxos_for(total_amount, fee, subtract_fee_from_amount, None) .ok_or_else(|| "Insufficient funds".to_string())?; // Build outputs for each recipient @@ -2954,7 +2954,7 @@ mod tests { fn test_select_utxos_exact_amount() { let wallet = test_wallet_with_utxo(100_000); - let result = wallet.select_unspent_utxos_for(90_000, 10_000, false); + let result = wallet.select_unspent_utxos_for(90_000, 10_000, false, None); assert!(result.is_some()); let (utxos, change) = result.unwrap(); assert_eq!(utxos.len(), 1); @@ -2967,7 +2967,7 @@ mod tests { fn test_select_utxos_with_change() { let wallet = test_wallet_with_utxo(200_000); - let result = wallet.select_unspent_utxos_for(90_000, 10_000, false); + let result = wallet.select_unspent_utxos_for(90_000, 10_000, false, None); assert!(result.is_some()); let (utxos, change) = result.unwrap(); assert_eq!(utxos.len(), 1); @@ -2978,7 +2978,7 @@ mod tests { fn test_select_utxos_insufficient_funds() { let wallet = test_wallet_with_utxo(50_000); - let result = wallet.select_unspent_utxos_for(90_000, 10_000, false); + let result = wallet.select_unspent_utxos_for(90_000, 10_000, false, None); assert!(result.is_none()); } @@ -2991,7 +2991,7 @@ mod tests { add_utxo(&mut wallet, &addr2, 2, 0, 40_000); add_utxo(&mut wallet, &addr1, 3, 0, 50_000); - let result = wallet.select_unspent_utxos_for(100_000, 10_000, false); + let result = wallet.select_unspent_utxos_for(100_000, 10_000, false, None); assert!(result.is_some()); let (utxos, change) = result.unwrap(); let total_collected: u64 = utxos.values().map(|(tx_out, _)| tx_out.value).sum(); @@ -3007,7 +3007,7 @@ mod tests { // Request 100k amount + 10k fee = 110k total, but only 100k available // With allow_take_fee_from_amount=true, should still succeed since total >= amount - let result = wallet.select_unspent_utxos_for(100_000, 10_000, true); + let result = wallet.select_unspent_utxos_for(100_000, 10_000, true, None); assert!(result.is_some()); let (_utxos, change) = result.unwrap(); assert!(change.is_none()); @@ -3019,7 +3019,7 @@ mod tests { // Request 100k amount + 10k fee = 110k, only 50k available // Even with take_fee_from_amount, 50k < 100k amount, so should fail - let result = wallet.select_unspent_utxos_for(100_000, 10_000, true); + let result = wallet.select_unspent_utxos_for(100_000, 10_000, true, None); assert!(result.is_none()); } @@ -3027,7 +3027,7 @@ mod tests { fn test_select_utxos_zero_amount() { let wallet = test_wallet_with_utxo(50_000); - let result = wallet.select_unspent_utxos_for(0, 0, false); + let result = wallet.select_unspent_utxos_for(0, 0, false, None); assert!(result.is_some()); let (utxos, change) = result.unwrap(); assert!(utxos.is_empty()); @@ -3073,7 +3073,7 @@ mod tests { .expect("store test wallet"); register_test_address(&db, &wallet, &addr); let (selected, _) = wallet - .select_unspent_utxos_for(90_000, 10_000, false) + .select_unspent_utxos_for(90_000, 10_000, false, None) .unwrap(); wallet .remove_selected_utxos(&selected, &db, Network::Testnet) @@ -3095,7 +3095,7 @@ mod tests { .expect("store test wallet"); register_test_address(&db, &wallet, &addr); let (selected, _) = wallet - .select_unspent_utxos_for(90_000, 10_000, false) + .select_unspent_utxos_for(90_000, 10_000, false, None) .unwrap(); wallet .remove_selected_utxos(&selected, &db, Network::Testnet) diff --git a/src/model/wallet/utxos.rs b/src/model/wallet/utxos.rs index 5053b4a09..c000f25f5 100644 --- a/src/model/wallet/utxos.rs +++ b/src/model/wallet/utxos.rs @@ -29,12 +29,18 @@ impl Wallet { amount: u64, fee: u64, allow_take_fee_from_amount: bool, + source_address: Option<&Address>, ) -> Option<(BTreeMap, Option)> { let target = amount.checked_add(fee)?; let mut required: i64 = i64::try_from(target).ok()?; let mut selected_utxos = BTreeMap::new(); - for (address, outpoints) in self.utxos.iter() { + let iter: Box)>> = + match source_address { + Some(addr) => Box::new(self.utxos.get(addr).into_iter().map(move |m| (addr, m))), + None => Box::new(self.utxos.iter()), + }; + for (address, outpoints) in iter { for (outpoint, tx_out) in outpoints.iter() { if required <= 0 { break; From ab7da8ae34588d7d9ae089901cb719e78ed76392 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:52:13 +0100 Subject: [PATCH 25/44] refactor(shielded): replace fee headroom estimation with iterative note selection Instead of using a fixed 2x multiplier on the minimum fee as headroom, iteratively select notes and compute the exact fee based on the actual note count. The loop converges in 2-3 iterations and always produces the correct fee, even for edge cases with many small notes. Pass the pre-computed exact fee to the DPP builders via Some(exact_fee) instead of None, and correctly subtract the fee from change calculation. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/shielded/bundle.rs | 97 +++++++++++++++++++++-------- src/model/fee_estimation.rs | 52 ++++++++++------ 2 files changed, 104 insertions(+), 45 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 1c35c7094..815a170bf 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -1,7 +1,7 @@ use crate::backend_task::error::{TaskError, shielded_broadcast_error, shielded_build_error}; use crate::context::AppContext; use crate::context::shielded::get_proving_key; -use crate::model::fee_estimation::{estimate_shielded_fee_headroom, format_credits_as_dash}; +use crate::model::fee_estimation::{format_credits_as_dash, shielded_fee_for_actions}; use crate::model::wallet::WalletSeedHash; use crate::model::wallet::shielded::ShieldedWalletState; use dash_sdk::dpp::address_funds::{ @@ -13,6 +13,7 @@ use dash_sdk::dpp::shielded::builder::{ OrchardProver, SpendableNote, build_shield_transition, build_shielded_transfer_transition, build_shielded_withdrawal_transition, build_unshield_transition, }; +use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::dpp::withdrawal::Pooling; use dash_sdk::grovedb_commitment_tree::{Nullifier, PaymentAddress, ProvingKey}; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; @@ -246,17 +247,18 @@ pub async fn shielded_transfer( let recipient_addr = OrchardAddress::from_raw_bytes(&recipient_bytes) .map_err(|_| TaskError::ShieldedInvalidRecipientAddress)?; - let (spendable_notes, total_input_value) = select_notes_for_amount( - shielded_state, - amount, - estimate_shielded_fee_headroom(sdk.version()), - )?; - let change_amount = total_input_value.saturating_sub(amount); + let (spendable_notes, total_input_value, exact_fee) = + select_notes_with_fee(shielded_state, amount, 2, sdk.version())?; + let change_amount = total_input_value + .saturating_sub(amount) + .saturating_sub(exact_fee); tracing::info!( - "Shielded transfer: sending {} ({} credits), spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)", + "Shielded transfer: sending {} ({} credits), fee {} ({} credits), spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)", format_credits_as_dash(amount), amount, + format_credits_as_dash(exact_fee), + exact_fee, spendable_notes.len(), format_credits_as_dash(total_input_value), total_input_value, @@ -306,7 +308,7 @@ pub async fn shielded_transfer( anchor, &prover, [0u8; 36], - None, + Some(exact_fee), sdk.version(), ) .map_err(|e| shielded_build_error(e.to_string()))?; @@ -343,17 +345,18 @@ pub async fn unshield_credits( key: get_proving_key(), }; - let (spendable_notes, total_input_value) = select_notes_for_amount( - shielded_state, - amount, - estimate_shielded_fee_headroom(sdk.version()), - )?; - let change_amount = total_input_value.saturating_sub(amount); + let (spendable_notes, total_input_value, exact_fee) = + select_notes_with_fee(shielded_state, amount, 1, sdk.version())?; + let change_amount = total_input_value + .saturating_sub(amount) + .saturating_sub(exact_fee); tracing::info!( - "Unshield credits: {} ({} credits), spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)", + "Unshield credits: {} ({} credits), fee {} ({} credits), spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)", format_credits_as_dash(amount), amount, + format_credits_as_dash(exact_fee), + exact_fee, spendable_notes.len(), format_credits_as_dash(total_input_value), total_input_value, @@ -403,7 +406,7 @@ pub async fn unshield_credits( anchor, &prover, [0u8; 36], - None, + Some(exact_fee), sdk.version(), ) .map_err(|e| shielded_build_error(e.to_string()))?; @@ -654,17 +657,18 @@ pub async fn shielded_withdrawal( let output_script = CoreScript::from_bytes(to_core_address.script_pubkey().to_bytes()); - let (spendable_notes, total_input_value) = select_notes_for_amount( - shielded_state, - amount, - estimate_shielded_fee_headroom(sdk.version()), - )?; - let change_amount = total_input_value.saturating_sub(amount); + let (spendable_notes, total_input_value, exact_fee) = + select_notes_with_fee(shielded_state, amount, 1, sdk.version())?; + let change_amount = total_input_value + .saturating_sub(amount) + .saturating_sub(exact_fee); tracing::info!( - "Shielded withdrawal: {} ({} credits) to core address, spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)", + "Shielded withdrawal: {} ({} credits) to core address, fee {} ({} credits), spending {} input note(s) totalling {} ({} credits), change: {} ({} credits)", format_credits_as_dash(amount), amount, + format_credits_as_dash(exact_fee), + exact_fee, spendable_notes.len(), format_credits_as_dash(total_input_value), total_input_value, @@ -716,7 +720,7 @@ pub async fn shielded_withdrawal( anchor, &prover, [0u8; 36], - None, + Some(exact_fee), sdk.version(), ) .map_err(|e| shielded_build_error(e.to_string()))?; @@ -737,6 +741,49 @@ pub async fn shielded_withdrawal( Ok(spent_nullifiers) } +/// Select notes sufficient to cover `amount` plus the exact shielded fee. +/// +/// Uses an iterative approach: +/// 1. Estimate fee for `min_actions` (the builder's minimum action count) +/// 2. Select notes for amount + estimated fee +/// 3. Compute exact fee from actual note count +/// 4. If insufficient, re-select with exact fee; repeat (converges in 2-3 iterations) +/// +/// Returns the selected notes, total input value, and the exact fee. +fn select_notes_with_fee<'a>( + shielded_state: &'a ShieldedWalletState, + amount: u64, + min_actions: usize, + platform_version: &PlatformVersion, +) -> Result< + ( + Vec<&'a crate::model::wallet::shielded::ShieldedNote>, + u64, + u64, + ), + TaskError, +> { + let mut fee_estimate = shielded_fee_for_actions(min_actions, platform_version); + + for _ in 0..5 { + let (notes, total) = select_notes_for_amount(shielded_state, amount, fee_estimate)?; + let num_actions = notes.len().max(min_actions); + let exact_fee = shielded_fee_for_actions(num_actions, platform_version); + + if total >= amount.saturating_add(exact_fee) { + return Ok((notes, total, exact_fee)); + } + + fee_estimate = exact_fee; + } + + // Final attempt with last computed fee + let (notes, total) = select_notes_for_amount(shielded_state, amount, fee_estimate)?; + let num_actions = notes.len().max(min_actions); + let exact_fee = shielded_fee_for_actions(num_actions, platform_version); + Ok((notes, total, exact_fee)) +} + /// Select unspent notes to cover `amount + fee_headroom` using a greedy algorithm. /// /// The `fee_headroom` ensures selected inputs cover both the send amount diff --git a/src/model/fee_estimation.rs b/src/model/fee_estimation.rs index 268e867f0..b65d94cfd 100644 --- a/src/model/fee_estimation.rs +++ b/src/model/fee_estimation.rs @@ -651,19 +651,13 @@ pub fn format_credits(credits: u64) -> String { } } -/// Estimate the fee headroom needed for shielded note selection. +/// Compute the exact shielded fee for a given number of Orchard actions. /// -/// Uses `compute_minimum_shielded_fee` from `dpp` with a conservative estimate -/// of 2 Orchard actions (the privacy minimum) and a 2× safety multiplier. -/// This covers all realistic scenarios — to exceed the margin, a transaction -/// would need 11+ input notes. -/// -/// Returns the headroom in credits to pass to `select_notes_for_amount`. -pub fn estimate_shielded_fee_headroom(platform_version: &PlatformVersion) -> u64 { +/// Wraps `compute_minimum_shielded_fee` from `dpp`. Use this to calculate +/// the fee after note selection, when the action count is known. +pub fn shielded_fee_for_actions(num_actions: usize, platform_version: &PlatformVersion) -> u64 { use dash_sdk::dpp::shielded::compute_minimum_shielded_fee; - // 2 actions is the Orchard privacy minimum (handles 1-2 input notes). - // 2× multiplier provides margin for bundles with more inputs. - compute_minimum_shielded_fee(2, platform_version).saturating_mul(2) + compute_minimum_shielded_fee(num_actions, platform_version) } #[cfg(test)] @@ -751,19 +745,37 @@ mod tests { } #[test] - fn test_estimate_shielded_fee_headroom() { + fn test_shielded_fee_for_actions() { let platform_version = PlatformVersion::latest(); - let headroom = estimate_shielded_fee_headroom(platform_version); - // Should be roughly 2× the minimum fee for 2 actions. - // At current constants: proof_verification(100M) + 2 × (processing(3M) + storage(~8.5M)) ≈ 123M - // 2× ≈ 246M. Allow for constant evolution. + + let fee_2 = shielded_fee_for_actions(2, platform_version); + let fee_3 = shielded_fee_for_actions(3, platform_version); + let fee_5 = shielded_fee_for_actions(5, platform_version); + let fee_10 = shielded_fee_for_actions(10, platform_version); + + // Fees should be positive and increase with action count + assert!(fee_2 > 0, "fee for 2 actions should be positive"); + assert!(fee_3 > fee_2, "fee for 3 actions should exceed fee for 2"); + assert!(fee_5 > fee_3, "fee for 5 actions should exceed fee for 3"); + assert!(fee_10 > fee_5, "fee for 10 actions should exceed fee for 5"); + + // Sanity bounds: fee for 2 actions should be in a reasonable range assert!( - headroom > 100_000_000, - "headroom should be at least 100M credits (>0.001 DASH)" + fee_2 > 50_000_000, + "fee for 2 actions should be at least 50M credits" ); assert!( - headroom < 2_000_000_000, - "headroom should be under 2B credits (<0.02 DASH)" + fee_2 < 1_000_000_000, + "fee for 2 actions should be under 1B credits" + ); + + // Fee growth should be roughly linear (per-action cost is constant) + let per_action_cost_low = (fee_5 - fee_2) / 3; + let per_action_cost_high = (fee_10 - fee_5) / 5; + let ratio = per_action_cost_low as f64 / per_action_cost_high as f64; + assert!( + (0.8..=1.2).contains(&ratio), + "per-action cost should be roughly constant, got ratio {ratio}" ); } } From 1e9e4fe094893765e0b08d7dfcdb391959169feb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:53:41 +0100 Subject: [PATCH 26/44] fix(shielded): harden shield screen with graceful locks, theme colors, fee-aware max, and banners - Replace .lock().unwrap() in render_batch_progress with .lock().ok() fallback to prevent UI thread panics on poisoned mutex - Replace all hardcoded Color32::from_rgb values with DashColors semantic constants (ERROR, SUCCESS, GRAY, INFO, WARNING, BUTTON_DISABLED) - Deduct estimated platform fee and L1 tx fee from Core max amount, and shielded fee headroom from Platform max amount, so "Max" reflects the actual shieldable balance - Migrate error_message and success_message fields to MessageBanner, the project's standard centralized banner system Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/shield_screen.rs | 192 ++++++++++++++++++++------------ 1 file changed, 120 insertions(+), 72 deletions(-) diff --git a/src/ui/wallets/shield_screen.rs b/src/ui/wallets/shield_screen.rs index dc0882a3f..fd11669fe 100644 --- a/src/ui/wallets/shield_screen.rs +++ b/src/ui/wallets/shield_screen.rs @@ -5,21 +5,25 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::address::{AddressKind, ValidatedAddress}; use crate::model::amount::Amount; +use crate::model::fee_estimation::shielded_fee_for_actions; use crate::model::wallet::WalletSeedHash; use crate::ui::components::ComponentResponse; +use crate::ui::components::MessageBanner; use crate::ui::components::address_input::AddressInput; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::Component; 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 dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::serialization::PlatformSerializable; use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; +use dash_sdk::dpp::version::PlatformVersion; use eframe::egui::{self, Context}; -use egui::{Color32, RichText}; +use egui::RichText; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -39,8 +43,6 @@ pub struct ShieldScreen { amount_input: Option, amount: Option, status: Status, - error_message: Option, - success_message: Option, // Batch mode (dev only, Platform flow only) repeat_count_str: String, parallel: bool, @@ -68,8 +70,6 @@ impl ShieldScreen { amount_input: None, amount: None, status: Status::NotStarted, - error_message: None, - success_message: None, repeat_count_str: "1".to_string(), parallel: false, batch_total: 0, @@ -189,31 +189,53 @@ impl ShieldScreen { } /// Check if the sequential batch is complete and update status accordingly. - fn check_batch_complete(&mut self) { + fn check_batch_complete(&mut self, ctx: &Context) { if self.batch_stages.is_none() && self.batch_succeeded + self.batch_failed >= self.batch_total { self.status = Status::Complete; - self.success_message = Some(format!( - "Batch complete: {} succeeded, {} failed out of {}", - self.batch_succeeded, self.batch_failed, self.batch_total, - )); + MessageBanner::set_global( + ctx, + format!( + "Batch complete: {} succeeded, {} failed out of {}", + self.batch_succeeded, self.batch_failed, self.batch_total, + ), + if self.batch_failed > 0 { + MessageType::Warning + } else { + MessageType::Success + }, + ); } } /// Spawn parallel batch: build proofs in parallel, broadcast in nonce order. - fn spawn_parallel_batch(&mut self, amount: u64, addr: PlatformAddress, repeat: u32) { + fn spawn_parallel_batch( + &mut self, + ctx: &Context, + amount: u64, + addr: PlatformAddress, + repeat: u32, + ) { let base_nonce = match self.read_base_nonce() { Some(n) => n, None => { - self.error_message = Some("Could not read nonce from wallet".to_string()); + MessageBanner::set_global( + ctx, + "Could not read wallet data. Please try again.", + MessageType::Error, + ); return; } }; let default_address = match self.app_context.shielded_default_address(&self.seed_hash) { Some(a) => a, None => { - self.error_message = Some("Shielded wallet not initialized".to_string()); + MessageBanner::set_global( + ctx, + "Shielded wallet not initialized. Please set up the shielded wallet first.", + MessageType::Error, + ); return; } }; @@ -344,7 +366,17 @@ impl ShieldScreen { fn render_batch_progress(&mut self, ui: &mut egui::Ui, ctx: &Context, action: &mut AppAction) { let stages_snapshot = self.batch_stages.clone(); if let Some(stages) = stages_snapshot { - let all_done = stages.iter().all(|s| s.lock().unwrap().is_terminal()); + let lock_stage = |s: &Arc>| -> ShieldStage { + s.lock() + .ok() + .map(|guard| guard.clone()) + .unwrap_or(ShieldStage::Failed { + error: "Internal error: lock poisoned".to_string(), + st_json: None, + }) + }; + + let all_done = stages.iter().all(|s| lock_stage(s).is_terminal()); if !all_done { ctx.request_repaint_after(Duration::from_millis(100)); @@ -352,17 +384,17 @@ impl ShieldScreen { let succeeded = stages .iter() - .filter(|s| matches!(*s.lock().unwrap(), ShieldStage::Complete)) + .filter(|s| matches!(lock_stage(s), ShieldStage::Complete)) .count(); let failed = stages .iter() - .filter(|s| matches!(*s.lock().unwrap(), ShieldStage::Failed { .. })) + .filter(|s| matches!(lock_stage(s), ShieldStage::Failed { .. })) .count(); if all_done { if failed > 0 { ui.colored_label( - Color32::from_rgb(255, 100, 100), + DashColors::ERROR, format!( "Batch complete: {} succeeded, {} failed out of {}", succeeded, @@ -372,7 +404,7 @@ impl ShieldScreen { ); } else { ui.colored_label( - Color32::from_rgb(50, 180, 50), + DashColors::SUCCESS, format!("Batch complete: all {} succeeded", stages.len()), ); } @@ -390,7 +422,7 @@ impl ShieldScreen { let rows: Vec<(ShieldStage, Option)> = stages .iter() .map(|s| { - let s = s.lock().unwrap().clone(); + let s = lock_stage(s); let json = if let ShieldStage::Failed { ref st_json, .. } = s { st_json.clone() } else { @@ -411,14 +443,12 @@ impl ShieldScreen { let text = format!("[{}/{}] {}", i + 1, total, stage.label()); let color = match stage { - ShieldStage::Queued => Color32::GRAY, - ShieldStage::BuildingProof { .. } => { - crate::ui::theme::DashColors::DASH_BLUE - } - ShieldStage::WaitingToBroadcast => Color32::from_rgb(100, 180, 255), - ShieldStage::Broadcasting => Color32::from_rgb(255, 165, 0), - ShieldStage::Complete => Color32::from_rgb(50, 180, 50), - ShieldStage::Failed { .. } => Color32::from_rgb(220, 60, 60), + ShieldStage::Queued => DashColors::GRAY, + ShieldStage::BuildingProof { .. } => DashColors::DASH_BLUE, + ShieldStage::WaitingToBroadcast => DashColors::INFO, + ShieldStage::Broadcasting => DashColors::WARNING, + ShieldStage::Complete => DashColors::SUCCESS, + ShieldStage::Failed { .. } => DashColors::ERROR, }; if let Some(json_str) = st_json { @@ -430,9 +460,11 @@ impl ShieldScreen { egui::ProgressBar::new(fraction).text(text).fill(color), ); let btn = egui::Button::new( - RichText::new("View JSON").color(Color32::WHITE).size(12.0), + RichText::new("View JSON") + .color(DashColors::WHITE) + .size(12.0), ) - .fill(Color32::from_rgb(80, 80, 80)); + .fill(DashColors::BUTTON_DISABLED); if ui .add_sized([btn_width, 20.0], btn) .on_hover_text("View state transition JSON") @@ -508,13 +540,8 @@ impl ScreenLike for ShieldScreen { ui.label("Move funds from a platform or core address into the shielded pool."); ui.add_space(15.0); - // Error/success messages - if let Some(err) = &self.error_message { - ui.colored_label(Color32::from_rgb(255, 100, 100), err); - ui.add_space(5.0); - } - if let Some(msg) = &self.success_message { - ui.colored_label(Color32::DARK_GREEN, msg); + // When complete, show a Done button below the banner + if self.status == Status::Complete { ui.add_space(10.0); if ui.button("Done").clicked() { action = AppAction::PopScreen; @@ -560,14 +587,14 @@ impl ScreenLike for ShieldScreen { ui.horizontal(|ui| { ui.label( RichText::new(format!("Available: {:.8} DASH", balance_dash)) - .color(Color32::from_rgb(100, 180, 100)), + .color(DashColors::SUCCESS), ); if self.app_context.is_developer_mode() && let Some(nonce) = self.read_base_nonce() { ui.label( RichText::new(format!("(nonce: {})", nonce)) - .color(Color32::GRAY) + .color(DashColors::GRAY) .small(), ); } @@ -584,7 +611,7 @@ impl ScreenLike for ShieldScreen { "Available core wallet balance: {:.8} DASH", dash_balance )) - .color(Color32::from_rgb(100, 180, 100)), + .color(DashColors::SUCCESS), ); ui.add_space(5.0); } @@ -594,9 +621,27 @@ impl ScreenLike for ShieldScreen { // Amount input (only when a source address is selected) if self.validated_source.is_some() { let max_credits = match source_kind { - Some(AddressKind::Platform) => self.read_platform_balance(), + Some(AddressKind::Platform) => { + // Use fee for 2 actions (Orchard minimum) with 2× safety margin for UI display + let fee_headroom = shielded_fee_for_actions(2, PlatformVersion::latest()) + .saturating_mul(2); + self.read_platform_balance() + .map(|b| b.saturating_sub(fee_headroom)) + } Some(AddressKind::Core) => { - Some(self.read_core_balance_duffs() * CREDITS_PER_DUFF) + let balance_duffs = self.read_core_balance_duffs(); + let platform_fee_credits = self + .app_context + .fee_estimator() + .min_fees() + .address_funding_asset_lock_cost; + let platform_fee_duffs = + (platform_fee_credits / CREDITS_PER_DUFF).saturating_mul(120) / 100; + let l1_tx_fee_duffs = 500_u64; + let shieldable_duffs = balance_duffs + .saturating_sub(platform_fee_duffs) + .saturating_sub(l1_tx_fee_duffs); + Some(shieldable_duffs * CREDITS_PER_DUFF) } _ => None, }; @@ -671,16 +716,14 @@ impl ScreenLike for ShieldScreen { can_confirm, egui::Button::new( RichText::new(button_label) - .color(Color32::WHITE) + .color(DashColors::WHITE) .size(16.0), ) - .fill(crate::ui::theme::DashColors::DASH_BLUE), + .fill(DashColors::DASH_BLUE), ) .clicked() && let Some(amount) = self.amount.as_ref().map(|a| a.value()) { - self.error_message = None; - match source_kind { Some(AddressKind::Platform) => { let addr = self.selected_platform_address().unwrap(); @@ -698,12 +741,16 @@ impl ScreenLike for ShieldScreen { total as f64 / CREDITS_PER_DUFF as f64 / 1e8; let balance_dash = balance as f64 / CREDITS_PER_DUFF as f64 / 1e8; - self.error_message = Some(format!( - "Insufficient balance: {repeat}x {:.8} DASH = {:.8} DASH total, but only {:.8} DASH available", - amount as f64 / CREDITS_PER_DUFF as f64 / 1e8, - total_dash, - balance_dash, - )); + MessageBanner::set_global( + ctx, + format!( + "Insufficient balance: {repeat}x {:.8} DASH = {:.8} DASH total, but only {:.8} DASH available. Try a smaller amount.", + amount as f64 / CREDITS_PER_DUFF as f64 / 1e8, + total_dash, + balance_dash, + ), + MessageType::Error, + ); return; } } @@ -714,7 +761,7 @@ impl ScreenLike for ShieldScreen { self.make_shield_credits_task(amount, addr, None), ); } else if self.parallel { - self.spawn_parallel_batch(amount, addr, repeat); + self.spawn_parallel_batch(ctx, amount, addr, repeat); } else { self.batch_total = repeat; self.batch_succeeded = 0; @@ -783,13 +830,14 @@ impl ScreenLike for ShieldScreen { } fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + let ctx = self.app_context.egui_ctx().clone(); match result { BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash, amount } if seed_hash == self.seed_hash => { if self.status == Status::BatchInProgress { self.batch_succeeded += 1; - self.check_batch_complete(); + self.check_batch_complete(&ctx); if self.status == Status::BatchInProgress { self.queue_next_sequential(); } else { @@ -801,7 +849,11 @@ impl ScreenLike for ShieldScreen { } else { self.status = Status::Complete; let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; - self.success_message = Some(format!("Successfully shielded {:.8} DASH", dash)); + MessageBanner::set_global( + &ctx, + format!("Successfully shielded {:.8} DASH", dash), + MessageType::Success, + ); self.pending_refresh_task = Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { seed_hash: self.seed_hash, @@ -813,10 +865,11 @@ impl ScreenLike for ShieldScreen { { self.status = Status::Complete; let dash = amount as f64 / CREDITS_PER_DUFF as f64 / 1e8; - self.success_message = Some(format!( - "Successfully shielded {:.8} DASH from core wallet", - dash - )); + MessageBanner::set_global( + &ctx, + format!("Successfully shielded {:.8} DASH from core wallet", dash), + MessageType::Success, + ); self.pending_refresh_task = Some(BackendTask::ShieldedTask(ShieldedTask::SyncNotes { seed_hash: self.seed_hash, @@ -826,22 +879,17 @@ impl ScreenLike for ShieldScreen { } } - fn display_message(&mut self, message: &str, message_type: MessageType) { - match message_type { - MessageType::Error => { + fn display_message(&mut self, _message: &str, message_type: MessageType) { + let ctx = self.app_context.egui_ctx().clone(); + if message_type == MessageType::Error { + if self.status == Status::BatchInProgress { + self.batch_failed += 1; + self.check_batch_complete(&ctx); if self.status == Status::BatchInProgress { - self.batch_failed += 1; - self.check_batch_complete(); - if self.status == Status::BatchInProgress { - self.queue_next_sequential(); - } - } else { - self.status = Status::NotStarted; - self.error_message = Some(message.to_string()); + self.queue_next_sequential(); } - } - _ => { - self.success_message = Some(message.to_string()); + } else { + self.status = Status::NotStarted; } } } From 7d677bf20e9771100e4be15f5d6d74b4062a5558 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:24:15 +0100 Subject: [PATCH 27/44] fix(ui): use theme-aware colors in shield screen for dark mode support Replace static DashColors constants (ERROR, SUCCESS, WARNING, INFO, GRAY) with their theme-aware counterparts (error_color, success_color, etc.) that adapt to light/dark mode via the `dark_mode` boolean. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/shield_screen.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/ui/wallets/shield_screen.rs b/src/ui/wallets/shield_screen.rs index fd11669fe..63f07cdeb 100644 --- a/src/ui/wallets/shield_screen.rs +++ b/src/ui/wallets/shield_screen.rs @@ -364,6 +364,7 @@ impl ShieldScreen { /// Render the batch progress UI (used for Platform batch mode). fn render_batch_progress(&mut self, ui: &mut egui::Ui, ctx: &Context, action: &mut AppAction) { + let dark_mode = ui.ctx().style().visuals.dark_mode; let stages_snapshot = self.batch_stages.clone(); if let Some(stages) = stages_snapshot { let lock_stage = |s: &Arc>| -> ShieldStage { @@ -394,7 +395,7 @@ impl ShieldScreen { if all_done { if failed > 0 { ui.colored_label( - DashColors::ERROR, + DashColors::error_color(dark_mode), format!( "Batch complete: {} succeeded, {} failed out of {}", succeeded, @@ -404,7 +405,7 @@ impl ShieldScreen { ); } else { ui.colored_label( - DashColors::SUCCESS, + DashColors::success_color(dark_mode), format!("Batch complete: all {} succeeded", stages.len()), ); } @@ -443,12 +444,12 @@ impl ShieldScreen { let text = format!("[{}/{}] {}", i + 1, total, stage.label()); let color = match stage { - ShieldStage::Queued => DashColors::GRAY, + ShieldStage::Queued => DashColors::muted_color(dark_mode), ShieldStage::BuildingProof { .. } => DashColors::DASH_BLUE, - ShieldStage::WaitingToBroadcast => DashColors::INFO, - ShieldStage::Broadcasting => DashColors::WARNING, - ShieldStage::Complete => DashColors::SUCCESS, - ShieldStage::Failed { .. } => DashColors::ERROR, + ShieldStage::WaitingToBroadcast => DashColors::info_color(dark_mode), + ShieldStage::Broadcasting => DashColors::warning_color(dark_mode), + ShieldStage::Complete => DashColors::success_color(dark_mode), + ShieldStage::Failed { .. } => DashColors::error_color(dark_mode), }; if let Some(json_str) = st_json { @@ -535,6 +536,7 @@ impl ScreenLike for ShieldScreen { } island_central_panel(ctx, |ui| { + let dark_mode = ui.ctx().style().visuals.dark_mode; ui.heading("Shield"); ui.add_space(10.0); ui.label("Move funds from a platform or core address into the shielded pool."); @@ -587,14 +589,14 @@ impl ScreenLike for ShieldScreen { ui.horizontal(|ui| { ui.label( RichText::new(format!("Available: {:.8} DASH", balance_dash)) - .color(DashColors::SUCCESS), + .color(DashColors::success_color(dark_mode)), ); if self.app_context.is_developer_mode() && let Some(nonce) = self.read_base_nonce() { ui.label( RichText::new(format!("(nonce: {})", nonce)) - .color(DashColors::GRAY) + .color(DashColors::muted_color(dark_mode)) .small(), ); } @@ -611,7 +613,7 @@ impl ScreenLike for ShieldScreen { "Available core wallet balance: {:.8} DASH", dash_balance )) - .color(DashColors::SUCCESS), + .color(DashColors::success_color(dark_mode)), ); ui.add_space(5.0); } From 9a6f832fe132cc6161d872228fe5258d89259058 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:27:45 +0100 Subject: [PATCH 28/44] refactor(fees): centralize shield-from-core fee estimation in fee_estimation.rs Extract the duplicated platform fee + L1 tx fee calculation from `shield_from_asset_lock` (bundle.rs) and the shield screen UI into a shared `estimate_shield_from_core_fees_duffs` function. Both callsites now use the centralized function. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/shielded/bundle.rs | 6 ++---- src/model/fee_estimation.rs | 14 ++++++++++++++ src/ui/wallets/shield_screen.rs | 8 ++------ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 815a170bf..6694c92d2 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -449,11 +449,9 @@ pub async fn shield_from_asset_lock( let proving_key = crate::context::shielded::get_proving_key(); - let platform_fee_credits = app_context + let (platform_fee_duffs, _l1_fee_duffs) = app_context .fee_estimator() - .min_fees() - .address_funding_asset_lock_cost; - let platform_fee_duffs = (platform_fee_credits / CREDITS_PER_DUFF).saturating_mul(120) / 100; + .estimate_shield_from_core_fees_duffs(); let asset_lock_duffs = amount_duffs.saturating_add(platform_fee_duffs); // Step 1: Create the asset lock transaction diff --git a/src/model/fee_estimation.rs b/src/model/fee_estimation.rs index b65d94cfd..5d5544732 100644 --- a/src/model/fee_estimation.rs +++ b/src/model/fee_estimation.rs @@ -13,6 +13,7 @@ //! endpoint (when available). use crate::model::amount::Amount; +use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::version::PlatformVersion; /// Storage fee constants from FEE_STORAGE_VERSION1 in rs-platform-version. @@ -267,6 +268,19 @@ impl PlatformFeeEstimator { fee_duffs.saturating_add(fee_duffs / 2).max(10_000) } + /// Estimate fees (in duffs) for a shield-from-core asset lock operation. + /// + /// Returns `(platform_fee_duffs, l1_tx_fee_duffs)`: + /// - Platform fee: `address_funding_asset_lock_cost` converted to duffs with 20% buffer + /// - L1 tx fee: flat estimate for a typical 1-2 input Core transaction (~500 duffs) + pub fn estimate_shield_from_core_fees_duffs(&self) -> (u64, u64) { + let platform_fee_duffs = (self.min_fees.address_funding_asset_lock_cost / CREDITS_PER_DUFF) + .saturating_mul(120) + / 100; + let l1_tx_fee_duffs = 500_u64; + (platform_fee_duffs, l1_tx_fee_duffs) + } + /// Estimate fee for identity update (adding/disabling keys) pub fn estimate_identity_update(&self) -> u64 { self.apply_multiplier(self.min_fees.identity_update) diff --git a/src/ui/wallets/shield_screen.rs b/src/ui/wallets/shield_screen.rs index 63f07cdeb..83272ef1f 100644 --- a/src/ui/wallets/shield_screen.rs +++ b/src/ui/wallets/shield_screen.rs @@ -632,14 +632,10 @@ impl ScreenLike for ShieldScreen { } Some(AddressKind::Core) => { let balance_duffs = self.read_core_balance_duffs(); - let platform_fee_credits = self + let (platform_fee_duffs, l1_tx_fee_duffs) = self .app_context .fee_estimator() - .min_fees() - .address_funding_asset_lock_cost; - let platform_fee_duffs = - (platform_fee_credits / CREDITS_PER_DUFF).saturating_mul(120) / 100; - let l1_tx_fee_duffs = 500_u64; + .estimate_shield_from_core_fees_duffs(); let shieldable_duffs = balance_duffs .saturating_sub(platform_fee_duffs) .saturating_sub(l1_tx_fee_duffs); From b22f37018d829ac7b0d8dba40c7c9018157dbc42 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:30:25 +0100 Subject: [PATCH 29/44] docs: note fee estimation centralization rule in CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All fee estimation logic must live in model/fee_estimation.rs — never inline fee math in UI screens or backend task code. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9129114db..64cbfb3b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,7 @@ User-facing error messages (shown in `MessageBanner` via `Display`) must follow - **app.rs** - `AppState`: owns all screens, polls task results each frame, dispatches to visible screen - **ui/** - Screens and reusable components (`ui/components/`) - **backend_task/** - Async business logic, one submodule per domain (identity, wallet, contract, etc.) -- **model/** - Data types (amounts, fees, settings, wallet/identity models) +- **model/** - Data types (amounts, fees, settings, wallet/identity models). **All fee estimation logic must be centralized in `model/fee_estimation.rs`** — both platform state transition fees and shielded fee calculations. Never inline fee math in UI or backend task code. - **database/** - SQLite persistence (rusqlite), one module per domain - **context/** - `AppContext`: network config, SDK client, database, wallets, settings cache (split into submodules: `identity_db.rs`, `wallet_lifecycle.rs`, `settings_db.rs`, etc.) - **spv/** - Simplified Payment Verification for light wallet support From 063623e41809dbdd72349548bc89fb2d522b1b38 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 16:53:13 +0100 Subject: [PATCH 30/44] =?UTF-8?q?fix(shielded):=20address=20review=20findi?= =?UTF-8?q?ngs=20=E2=80=94=20fee=20guard,=20L1=20fee,=20lock=20safety,=20l?= =?UTF-8?q?abel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add sufficiency guard to `select_notes_with_fee` final fallback - Bump L1 tx fee estimate from 500 to 3000 duffs (Core minimum relay fee) - Replace `.lock().unwrap()` with graceful handling in `spawn_parallel_batch` - Use conditional label for per-address vs wallet balance display Note: ComponentResponse import (Fix 3) was a false positive — it is a trait import required by `.update()` and `.has_changed()` calls. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/shielded/bundle.rs | 6 +++ src/model/fee_estimation.rs | 4 +- src/ui/wallets/shield_screen.rs | 66 ++++++++++++++++++----------- 3 files changed, 50 insertions(+), 26 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 6694c92d2..6e63e8f50 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -779,6 +779,12 @@ fn select_notes_with_fee<'a>( let (notes, total) = select_notes_for_amount(shielded_state, amount, fee_estimate)?; let num_actions = notes.len().max(min_actions); let exact_fee = shielded_fee_for_actions(num_actions, platform_version); + if total < amount.saturating_add(exact_fee) { + return Err(TaskError::ShieldedInsufficientBalance { + available: total, + required: amount.saturating_add(exact_fee), + }); + } Ok((notes, total, exact_fee)) } diff --git a/src/model/fee_estimation.rs b/src/model/fee_estimation.rs index 5d5544732..07e8a528b 100644 --- a/src/model/fee_estimation.rs +++ b/src/model/fee_estimation.rs @@ -272,12 +272,12 @@ impl PlatformFeeEstimator { /// /// Returns `(platform_fee_duffs, l1_tx_fee_duffs)`: /// - Platform fee: `address_funding_asset_lock_cost` converted to duffs with 20% buffer - /// - L1 tx fee: flat estimate for a typical 1-2 input Core transaction (~500 duffs) + /// - L1 tx fee: flat estimate covering Core minimum relay fee (~3000 duffs) pub fn estimate_shield_from_core_fees_duffs(&self) -> (u64, u64) { let platform_fee_duffs = (self.min_fees.address_funding_asset_lock_cost / CREDITS_PER_DUFF) .saturating_mul(120) / 100; - let l1_tx_fee_duffs = 500_u64; + let l1_tx_fee_duffs = 3_000_u64; (platform_fee_duffs, l1_tx_fee_duffs) } diff --git a/src/ui/wallets/shield_screen.rs b/src/ui/wallets/shield_screen.rs index 83272ef1f..db69e0f11 100644 --- a/src/ui/wallets/shield_screen.rs +++ b/src/ui/wallets/shield_screen.rs @@ -265,7 +265,9 @@ impl ShieldScreen { let nonce = base_nonce + 1 + i; async move { - *stage.lock().unwrap() = ShieldStage::BuildingProof { nonce }; + if let Ok(mut guard) = stage.lock() { + *guard = ShieldStage::BuildingProof { nonce }; + } let result = tokio::task::spawn_blocking(move || { bundle::build_shield_credit( @@ -283,13 +285,17 @@ impl ShieldScreen { match &result { Ok(_) => { - *stage.lock().unwrap() = ShieldStage::WaitingToBroadcast; + if let Ok(mut guard) = stage.lock() { + *guard = ShieldStage::WaitingToBroadcast; + } } Err(e) => { - *stage.lock().unwrap() = ShieldStage::Failed { - error: e.clone(), - st_json: None, - }; + if let Ok(mut guard) = stage.lock() { + *guard = ShieldStage::Failed { + error: e.clone(), + st_json: None, + }; + } } } @@ -306,7 +312,9 @@ impl ShieldScreen { let stage = &stages[i]; match result { Ok(state_transition) => { - *stage.lock().unwrap() = ShieldStage::Broadcasting; + if let Ok(mut guard) = stage.lock() { + *guard = ShieldStage::Broadcasting; + } let st_repr: Option = serde_json::to_string_pretty(&state_transition) @@ -325,16 +333,21 @@ impl ShieldScreen { tokio::time::sleep(Duration::from_secs(3)).await; } app_ctx.bump_platform_address_nonce(&seed_hash, &addr); - *stage.lock().unwrap() = ShieldStage::Complete; + if let Ok(mut guard) = stage.lock() { + *guard = ShieldStage::Complete; + } } Err(e) => { - *stage.lock().unwrap() = ShieldStage::Failed { - error: format!("Broadcast failed: {e}"), - st_json: st_repr, - }; + if let Ok(mut guard) = stage.lock() { + *guard = ShieldStage::Failed { + error: format!("Broadcast failed: {e}"), + st_json: st_repr, + }; + } for remaining in stages.iter().skip(i + 1) { - let mut s = remaining.lock().unwrap(); - if !s.is_terminal() { + if let Ok(mut s) = remaining.lock() + && !s.is_terminal() + { *s = ShieldStage::Failed { error: "Skipped: earlier nonce failed".to_string(), st_json: None, @@ -347,8 +360,9 @@ impl ShieldScreen { } Err(_) => { for remaining in stages.iter().skip(i + 1) { - let mut s = remaining.lock().unwrap(); - if !s.is_terminal() { + if let Ok(mut s) = remaining.lock() + && !s.is_terminal() + { *s = ShieldStage::Failed { error: "Skipped: earlier nonce failed".to_string(), st_json: None, @@ -605,16 +619,20 @@ impl ScreenLike for ShieldScreen { } } Some(AddressKind::Core) => { - // Core flow: show wallet balance + // Core flow: show balance (per-address or whole wallet) let balance_duffs = self.read_core_balance_duffs(); let dash_balance = balance_duffs as f64 / 1e8; - ui.label( - RichText::new(format!( - "Available core wallet balance: {:.8} DASH", - dash_balance - )) - .color(DashColors::success_color(dark_mode)), - ); + let label = if self + .validated_source + .as_ref() + .and_then(|v| v.as_core()) + .is_some() + { + format!("Available address balance: {:.8} DASH", dash_balance) + } else { + format!("Available core wallet balance: {:.8} DASH", dash_balance) + }; + ui.label(RichText::new(label).color(DashColors::success_color(dark_mode))); ui.add_space(5.0); } _ => {} From d6af76beb83c7755039ef0f9c8cfff7c44cad7e7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:12:36 +0100 Subject: [PATCH 31/44] fix(shielded): apply fee multiplier, freeze batch inputs, preserve completion status - Apply `fee_multiplier_permille` to shield-from-core fee estimation so the platform fee scales with network conditions - Snapshot batch amount and address at batch start; use frozen values in `queue_next_sequential` to prevent mid-batch redirection - Disable address/amount inputs during batch execution via `add_enabled_ui` wrapper - Only reset status to NotStarted on primary operation failure, not on post-success SyncNotes refresh failure Co-Authored-By: Claude Opus 4.6 (1M context) --- src/model/fee_estimation.rs | 10 +- src/ui/wallets/shield_screen.rs | 279 +++++++++++++++++--------------- 2 files changed, 157 insertions(+), 132 deletions(-) diff --git a/src/model/fee_estimation.rs b/src/model/fee_estimation.rs index 07e8a528b..a102f4793 100644 --- a/src/model/fee_estimation.rs +++ b/src/model/fee_estimation.rs @@ -271,12 +271,14 @@ impl PlatformFeeEstimator { /// Estimate fees (in duffs) for a shield-from-core asset lock operation. /// /// Returns `(platform_fee_duffs, l1_tx_fee_duffs)`: - /// - Platform fee: `address_funding_asset_lock_cost` converted to duffs with 20% buffer + /// - Platform fee: `address_funding_asset_lock_cost` with fee multiplier applied, + /// converted to duffs, plus 20% buffer /// - L1 tx fee: flat estimate covering Core minimum relay fee (~3000 duffs) pub fn estimate_shield_from_core_fees_duffs(&self) -> (u64, u64) { - let platform_fee_duffs = (self.min_fees.address_funding_asset_lock_cost / CREDITS_PER_DUFF) - .saturating_mul(120) - / 100; + let platform_fee_credits = + self.apply_multiplier(self.min_fees.address_funding_asset_lock_cost); + let platform_fee_duffs = + (platform_fee_credits / CREDITS_PER_DUFF).saturating_mul(120) / 100; let l1_tx_fee_duffs = 3_000_u64; (platform_fee_duffs, l1_tx_fee_duffs) } diff --git a/src/ui/wallets/shield_screen.rs b/src/ui/wallets/shield_screen.rs index db69e0f11..2daec024f 100644 --- a/src/ui/wallets/shield_screen.rs +++ b/src/ui/wallets/shield_screen.rs @@ -58,6 +58,10 @@ pub struct ShieldScreen { batch_stages: Option>>>, /// JSON of a failed state transition to show in the popup. json_preview: Option, + /// Frozen amount for the current batch (set at batch start, cleared on completion). + batch_amount: Option, + /// Frozen platform address for the current batch. + batch_address: Option, } impl ShieldScreen { @@ -80,6 +84,8 @@ impl ShieldScreen { pending_refresh_task: None, batch_stages: None, json_preview: None, + batch_amount: None, + batch_address: None, } } @@ -175,13 +181,10 @@ impl ShieldScreen { }) } - /// Queue the next sequential batch task if any remain. + /// Queue the next sequential batch task if any remain, using frozen batch parameters. fn queue_next_sequential(&mut self) { if self.batch_remaining > 0 - && let (Some(amount), Some(addr)) = ( - self.amount.as_ref().map(|a| a.value()), - self.selected_platform_address(), - ) + && let (Some(amount), Some(addr)) = (self.batch_amount, self.batch_address) { self.batch_remaining -= 1; self.pending_next_task = Some(self.make_shield_credits_task(amount, addr, None)); @@ -194,6 +197,8 @@ impl ShieldScreen { && self.batch_succeeded + self.batch_failed >= self.batch_total { self.status = Status::Complete; + self.batch_amount = None; + self.batch_address = None; MessageBanner::set_global( ctx, format!( @@ -565,143 +570,155 @@ impl ScreenLike for ShieldScreen { return; } - // Source address selection via AddressInput - let addr_input = self.address_input.get_or_insert_with(|| { - let mut builder = AddressInput::new(self.app_context.network) - .with_address_kinds(&[AddressKind::Core, AddressKind::Platform]) - .with_label("From address") - .with_hint_text("Select a platform or core wallet address") - .with_selection_only(true) - .with_balance_range(1..) - .with_exclude_change(true); - - if let Ok(wallets) = self.app_context.wallets.read() - && let Some(wallet) = wallets.get(&self.seed_hash) - { - builder = builder.with_wallets(std::slice::from_ref(wallet)); - } + let is_busy = + self.status == Status::WaitingForResult || self.status == Status::BatchInProgress; - builder - }); - let resp = addr_input.show(ui); - if resp.inner.has_changed() { - resp.inner.update(&mut self.validated_source); - // Reset amount input when source changes (different balance constraints) - self.amount_input = None; - self.amount = None; - } - ui.add_space(5.0); + // Source address and amount inputs (disabled during batch) + let source_kind = ui + .add_enabled_ui(!is_busy, |ui| { + let addr_input = self.address_input.get_or_insert_with(|| { + let mut builder = AddressInput::new(self.app_context.network) + .with_address_kinds(&[AddressKind::Core, AddressKind::Platform]) + .with_label("From address") + .with_hint_text("Select a platform or core wallet address") + .with_selection_only(true) + .with_balance_range(1..) + .with_exclude_change(true); + + if let Ok(wallets) = self.app_context.wallets.read() + && let Some(wallet) = wallets.get(&self.seed_hash) + { + builder = builder.with_wallets(std::slice::from_ref(wallet)); + } - // Show source-specific info based on selected address type - let source_kind = self.validated_source.as_ref().map(|v| v.kind()); + builder + }); + let resp = addr_input.show(ui); + if resp.inner.has_changed() { + resp.inner.update(&mut self.validated_source); + // Reset amount input when source changes (different balance constraints) + self.amount_input = None; + self.amount = None; + } + ui.add_space(5.0); - match source_kind { - Some(AddressKind::Platform) => { - // Platform flow: show balance and nonce - if let Some(balance_credits) = self.read_platform_balance() { - let balance_dash = balance_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; - ui.horizontal(|ui| { + // Show source-specific info based on selected address type + let source_kind = self.validated_source.as_ref().map(|v| v.kind()); + + match source_kind { + Some(AddressKind::Platform) => { + // Platform flow: show balance and nonce + if let Some(balance_credits) = self.read_platform_balance() { + let balance_dash = + balance_credits as f64 / CREDITS_PER_DUFF as f64 / 1e8; + ui.horizontal(|ui| { + ui.label( + RichText::new(format!( + "Available: {:.8} DASH", + balance_dash + )) + .color(DashColors::success_color(dark_mode)), + ); + if self.app_context.is_developer_mode() + && let Some(nonce) = self.read_base_nonce() + { + ui.label( + RichText::new(format!("(nonce: {})", nonce)) + .color(DashColors::muted_color(dark_mode)) + .small(), + ); + } + }); + ui.add_space(5.0); + } + } + Some(AddressKind::Core) => { + // Core flow: show balance (per-address or whole wallet) + let balance_duffs = self.read_core_balance_duffs(); + let dash_balance = balance_duffs as f64 / 1e8; + let label = if self + .validated_source + .as_ref() + .and_then(|v| v.as_core()) + .is_some() + { + format!("Available address balance: {:.8} DASH", dash_balance) + } else { + format!("Available core wallet balance: {:.8} DASH", dash_balance) + }; ui.label( - RichText::new(format!("Available: {:.8} DASH", balance_dash)) - .color(DashColors::success_color(dark_mode)), + RichText::new(label).color(DashColors::success_color(dark_mode)), ); - if self.app_context.is_developer_mode() - && let Some(nonce) = self.read_base_nonce() - { - ui.label( - RichText::new(format!("(nonce: {})", nonce)) - .color(DashColors::muted_color(dark_mode)) - .small(), - ); + ui.add_space(5.0); + } + _ => {} + } + + // Amount input (only when a source address is selected) + if self.validated_source.is_some() { + let max_credits = match source_kind { + Some(AddressKind::Platform) => { + let fee_headroom = + shielded_fee_for_actions(2, PlatformVersion::latest()) + .saturating_mul(2); + self.read_platform_balance() + .map(|b| b.saturating_sub(fee_headroom)) + } + Some(AddressKind::Core) => { + let balance_duffs = self.read_core_balance_duffs(); + let (platform_fee_duffs, l1_tx_fee_duffs) = self + .app_context + .fee_estimator() + .estimate_shield_from_core_fees_duffs(); + let shieldable_duffs = balance_duffs + .saturating_sub(platform_fee_duffs) + .saturating_sub(l1_tx_fee_duffs); + Some(shieldable_duffs * CREDITS_PER_DUFF) + } + _ => None, + }; + + let amount_input = self.amount_input.get_or_insert_with(|| { + let mut builder = AmountInput::new(Amount::new_dash(0.0)) + .with_label("Amount (DASH):") + .with_hint_text("Enter amount") + .with_desired_width(150.0); + if source_kind == Some(AddressKind::Core) { + builder = builder.with_max_button(true); } + builder }); + if let Some(max) = max_credits { + amount_input.set_max_amount(Some(max)); + } + let response = amount_input.show(ui); + response.inner.update(&mut self.amount); ui.add_space(5.0); - } - } - Some(AddressKind::Core) => { - // Core flow: show balance (per-address or whole wallet) - let balance_duffs = self.read_core_balance_duffs(); - let dash_balance = balance_duffs as f64 / 1e8; - let label = if self - .validated_source - .as_ref() - .and_then(|v| v.as_core()) - .is_some() - { - format!("Available address balance: {:.8} DASH", dash_balance) - } else { - format!("Available core wallet balance: {:.8} DASH", dash_balance) - }; - ui.label(RichText::new(label).color(DashColors::success_color(dark_mode))); - ui.add_space(5.0); - } - _ => {} - } - // Amount input (only when a source address is selected) - if self.validated_source.is_some() { - let max_credits = match source_kind { - Some(AddressKind::Platform) => { - // Use fee for 2 actions (Orchard minimum) with 2× safety margin for UI display - let fee_headroom = shielded_fee_for_actions(2, PlatformVersion::latest()) - .saturating_mul(2); - self.read_platform_balance() - .map(|b| b.saturating_sub(fee_headroom)) - } - Some(AddressKind::Core) => { - let balance_duffs = self.read_core_balance_duffs(); - let (platform_fee_duffs, l1_tx_fee_duffs) = self - .app_context - .fee_estimator() - .estimate_shield_from_core_fees_duffs(); - let shieldable_duffs = balance_duffs - .saturating_sub(platform_fee_duffs) - .saturating_sub(l1_tx_fee_duffs); - Some(shieldable_duffs * CREDITS_PER_DUFF) + // Dev-mode batch controls (Platform flow only) + if self.app_context.is_developer_mode() + && source_kind == Some(AddressKind::Platform) + && self.status == Status::NotStarted + { + ui.add_space(10.0); + ui.horizontal(|ui| { + ui.label("Repeat"); + let te = egui::TextEdit::singleline(&mut self.repeat_count_str) + .desired_width(50.0); + ui.add(te); + ui.label("times"); + }); + ui.checkbox(&mut self.parallel, "Parallel"); + } } - _ => None, - }; - let amount_input = self.amount_input.get_or_insert_with(|| { - let mut builder = AmountInput::new(Amount::new_dash(0.0)) - .with_label("Amount (DASH):") - .with_hint_text("Enter amount") - .with_desired_width(150.0); - if source_kind == Some(AddressKind::Core) { - builder = builder.with_max_button(true); - } - builder - }); - if let Some(max) = max_credits { - amount_input.set_max_amount(Some(max)); - } - let response = amount_input.show(ui); - response.inner.update(&mut self.amount); - ui.add_space(5.0); - - // Dev-mode batch controls (Platform flow only) - if self.app_context.is_developer_mode() - && source_kind == Some(AddressKind::Platform) - && self.status == Status::NotStarted - { - ui.add_space(10.0); - ui.horizontal(|ui| { - ui.label("Repeat"); - let te = egui::TextEdit::singleline(&mut self.repeat_count_str) - .desired_width(50.0); - ui.add(te); - ui.label("times"); - }); - ui.checkbox(&mut self.parallel, "Parallel"); - } - } + source_kind + }) + .inner; ui.add_space(15.0); // Progress display - let is_busy = - self.status == Status::WaitingForResult || self.status == Status::BatchInProgress; - if self.status == Status::BatchInProgress { self.render_batch_progress(ui, ctx, &mut action); } else if self.status == Status::WaitingForResult { @@ -777,12 +794,16 @@ impl ScreenLike for ShieldScreen { self.make_shield_credits_task(amount, addr, None), ); } else if self.parallel { + self.batch_amount = Some(amount); + self.batch_address = Some(addr); self.spawn_parallel_batch(ctx, amount, addr, repeat); } else { self.batch_total = repeat; self.batch_succeeded = 0; self.batch_failed = 0; self.batch_remaining = repeat - 1; + self.batch_amount = Some(amount); + self.batch_address = Some(addr); self.status = Status::BatchInProgress; action = AppAction::BackendTask( self.make_shield_credits_task(amount, addr, None), @@ -904,9 +925,11 @@ impl ScreenLike for ShieldScreen { if self.status == Status::BatchInProgress { self.queue_next_sequential(); } - } else { + } else if self.status == Status::WaitingForResult { self.status = Status::NotStarted; } + // If status is Complete, leave it — the shield succeeded, a post-success + // refresh failure (e.g. SyncNotes) is non-critical. } } } From 46a274af2a5d560a15d5ce58dd422688c0f746fa Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:37:02 +0100 Subject: [PATCH 32/44] fix(shielded): skip stale-nonce items in parallel batch instead of cascade-failing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a parallel batch broadcast fails with AddressInvalidNonceError and our nonce is below Platform's expected nonce, fail only that item and continue to the next — it may have a valid nonce. Uses typed error chain matching (SdkError → StateTransitionBroadcastError → ConsensusError → AddressInvalidNonceError) instead of string parsing. Non-nonce errors and nonce-ahead errors still cascade-fail as before. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/shield_screen.rs | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/ui/wallets/shield_screen.rs b/src/ui/wallets/shield_screen.rs index 2daec024f..9d68a6d69 100644 --- a/src/ui/wallets/shield_screen.rs +++ b/src/ui/wallets/shield_screen.rs @@ -27,6 +27,27 @@ use egui::RichText; use std::sync::{Arc, Mutex}; use std::time::Duration; +/// Extract the expected nonce from an `AddressInvalidNonceError` buried in an SDK error. +/// +/// Walks the error chain: `SdkError::StateTransitionBroadcastError` → +/// `ConsensusError::StateError` → `StateError::AddressInvalidNonceError`. +fn extract_expected_nonce(error: &dash_sdk::Error) -> Option { + use dash_sdk::dpp::consensus::ConsensusError; + use dash_sdk::dpp::consensus::state::state_error::StateError; + + let broadcast_err = match error { + dash_sdk::Error::StateTransitionBroadcastError(e) => e, + _ => return None, + }; + let consensus = broadcast_err.cause.as_ref()?; + match consensus { + ConsensusError::StateError(StateError::AddressInvalidNonceError(e)) => { + Some(e.expected_nonce()) + } + _ => None, + } +} + #[derive(PartialEq)] enum Status { NotStarted, @@ -328,6 +349,7 @@ impl ShieldScreen { state_transition.serialize_to_bytes().map(hex::encode).ok() }); + let our_nonce = base_nonce + 1 + i as u32; match state_transition.broadcast(&sdk, None).await { Ok(_) => { let wait_ok = state_transition @@ -343,6 +365,31 @@ impl ShieldScreen { } } Err(e) => { + // Check for AddressInvalidNonceError via typed error chain. + // If our nonce is stale (Platform already past it), fail + // this item but continue — the next item may have a valid nonce. + if let Some(expected) = extract_expected_nonce(&e) + && our_nonce < expected + { + tracing::warn!( + "Batch item {} nonce {} is stale (Platform expects {}), skipping", + i + 1, + our_nonce, + expected + ); + if let Ok(mut guard) = stage.lock() { + *guard = ShieldStage::Failed { + error: format!( + "Nonce {} is stale (Platform expects {})", + our_nonce, expected + ), + st_json: st_repr, + }; + } + continue; + } + + // Non-nonce error or nonce-ahead — fail and cascade if let Ok(mut guard) = stage.lock() { *guard = ShieldStage::Failed { error: format!("Broadcast failed: {e}"), From 67344cae38628e7d5dbff9555fa0f1d1b5ca2fa3 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:37:28 +0100 Subject: [PATCH 33/44] docs: add typed error matching rule to CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Never parse error strings — always use typed error chains. Define new error variants if needed rather than relying on fragile string matching. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 64cbfb3b1..dedc9ef40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,7 @@ scripts/safe-cargo.sh +nightly fmt --all * When a method takes `&AppContext` (or `Option<&AppContext>`), place it as the first parameter after `self`. * Screen constructors handle errors internally via `MessageBanner` and return `Self` with degraded state. Keep `create_screen()` clean — no error handling at callsites. * **i18n-ready strings**: All user-facing strings (labels, messages, tooltips, errors) must be simple, complete sentences. Avoid concatenating fragments, positional assumptions, or grammar that breaks in other languages. Each string should be extractable as a single translation unit with named placeholders for dynamic values and no logic in the text itself. Current code uses standard Rust format specifiers (`{name}`, `{max}`). When i18n extraction happens later, these will become Fluent-style placeholders (`{ $name }`, `{ $max }`). +* **Never parse error strings** to extract information. Always use the typed error chain (downcast, match on variants, access structured fields). If no typed variant exists for the information you need, define a new `TaskError` variant or extend the existing error type. String parsing is fragile, breaks on message changes, and bypasses the type system. ### Error messages From fcc5ffb142dfb43765144f6afd713b11441c4c65 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:39:37 +0100 Subject: [PATCH 34/44] fix(ui): restore vibrant progress bar fill colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert progress bar fills to static DashColors constants (SUCCESS, ERROR, WARNING, INFO, GRAY). The theme-aware text-color functions (error_color, success_color, etc.) are too dark/muted for bar fills in light mode — they're designed for text on backgrounds, not fill areas. Keep theme-aware colors for text labels only. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/shield_screen.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/ui/wallets/shield_screen.rs b/src/ui/wallets/shield_screen.rs index 9d68a6d69..6554248b7 100644 --- a/src/ui/wallets/shield_screen.rs +++ b/src/ui/wallets/shield_screen.rs @@ -509,13 +509,16 @@ impl ShieldScreen { let fraction = stage.progress_fraction(); let text = format!("[{}/{}] {}", i + 1, total, stage.label()); + // Progress bar fills need vibrant, saturated colors for contrast + // against bar background — use static constants, not theme-aware + // text colors (which are muted/dark for readability on backgrounds). let color = match stage { - ShieldStage::Queued => DashColors::muted_color(dark_mode), + ShieldStage::Queued => DashColors::GRAY, ShieldStage::BuildingProof { .. } => DashColors::DASH_BLUE, - ShieldStage::WaitingToBroadcast => DashColors::info_color(dark_mode), - ShieldStage::Broadcasting => DashColors::warning_color(dark_mode), - ShieldStage::Complete => DashColors::success_color(dark_mode), - ShieldStage::Failed { .. } => DashColors::error_color(dark_mode), + ShieldStage::WaitingToBroadcast => DashColors::INFO, + ShieldStage::Broadcasting => DashColors::WARNING, + ShieldStage::Complete => DashColors::SUCCESS, + ShieldStage::Failed { .. } => DashColors::ERROR, }; if let Some(json_str) = st_json { From c03ccf1a04fb11d25f4c17042bf89bf007df76bc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:44:13 +0100 Subject: [PATCH 35/44] fix(shielded): don't cascade-fail on any nonce mismatch in parallel batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On AddressInvalidNonceError, fail only the current item and continue to the next — regardless of whether our nonce is ahead or behind Platform's expected nonce. The next item may succeed if Platform catches up. Only cascade-fail on non-nonce errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/shield_screen.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/ui/wallets/shield_screen.rs b/src/ui/wallets/shield_screen.rs index 6554248b7..11cecd3fb 100644 --- a/src/ui/wallets/shield_screen.rs +++ b/src/ui/wallets/shield_screen.rs @@ -366,13 +366,12 @@ impl ShieldScreen { } Err(e) => { // Check for AddressInvalidNonceError via typed error chain. - // If our nonce is stale (Platform already past it), fail - // this item but continue — the next item may have a valid nonce. - if let Some(expected) = extract_expected_nonce(&e) - && our_nonce < expected - { + // On any nonce mismatch, fail this item but continue to the + // next — Platform may catch up (nonce-ahead) or the next item + // may have a valid nonce (stale). Only cascade on non-nonce errors. + if let Some(expected) = extract_expected_nonce(&e) { tracing::warn!( - "Batch item {} nonce {} is stale (Platform expects {}), skipping", + "Batch item {} nonce mismatch: ours={}, Platform expects {}", i + 1, our_nonce, expected @@ -380,7 +379,7 @@ impl ShieldScreen { if let Ok(mut guard) = stage.lock() { *guard = ShieldStage::Failed { error: format!( - "Nonce {} is stale (Platform expects {})", + "Nonce mismatch: sent {}, Platform expects {}", our_nonce, expected ), st_json: st_repr, @@ -389,7 +388,7 @@ impl ShieldScreen { continue; } - // Non-nonce error or nonce-ahead — fail and cascade + // Non-nonce error — fail and cascade if let Ok(mut guard) = stage.lock() { *guard = ShieldStage::Failed { error: format!("Broadcast failed: {e}"), From 66227ac732c4ce3634c72466dc0e7ed0f9a58f1a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:47:54 +0100 Subject: [PATCH 36/44] fix(shielded): handle nonce error via Protocol path, not just BroadcastError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AddressInvalidNonceError arrives as `Error::Protocol( ProtocolError::ConsensusError(StateError::AddressInvalidNonceError))` — not through `StateTransitionBroadcastError`. Handle both paths in `extract_expected_nonce` so nonce mismatches are properly detected and the batch continues instead of cascade-failing. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/shield_screen.rs | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/ui/wallets/shield_screen.rs b/src/ui/wallets/shield_screen.rs index 11cecd3fb..1c139d062 100644 --- a/src/ui/wallets/shield_screen.rs +++ b/src/ui/wallets/shield_screen.rs @@ -29,21 +29,29 @@ use std::time::Duration; /// Extract the expected nonce from an `AddressInvalidNonceError` buried in an SDK error. /// -/// Walks the error chain: `SdkError::StateTransitionBroadcastError` → -/// `ConsensusError::StateError` → `StateError::AddressInvalidNonceError`. +/// The error can arrive via two paths: +/// 1. `Error::StateTransitionBroadcastError` → `cause: ConsensusError` → `AddressInvalidNonceError` +/// 2. `Error::Protocol(ProtocolError::ConsensusError(...))` → `AddressInvalidNonceError` fn extract_expected_nonce(error: &dash_sdk::Error) -> Option { + use dash_sdk::dpp::ProtocolError; use dash_sdk::dpp::consensus::ConsensusError; use dash_sdk::dpp::consensus::state::state_error::StateError; - let broadcast_err = match error { - dash_sdk::Error::StateTransitionBroadcastError(e) => e, - _ => return None, - }; - let consensus = broadcast_err.cause.as_ref()?; - match consensus { - ConsensusError::StateError(StateError::AddressInvalidNonceError(e)) => { - Some(e.expected_nonce()) + // Helper: extract from a ConsensusError + let from_consensus = |c: &ConsensusError| -> Option { + match c { + ConsensusError::StateError(StateError::AddressInvalidNonceError(e)) => { + Some(e.expected_nonce()) + } + _ => None, } + }; + + match error { + // Path 1: broadcast error with consensus cause + dash_sdk::Error::StateTransitionBroadcastError(e) => from_consensus(e.cause.as_ref()?), + // Path 2: protocol error wrapping consensus error (boxed) + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(c)) => from_consensus(c.as_ref()), _ => None, } } From c5b9a91d22e05e43a3ca50392fb9bb746c9ac0cc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:47:24 +0100 Subject: [PATCH 37/44] fix(ui): use shared fee estimation and theme colors in send screen - Uncomment and fix Core->Platform max amount fee deduction - Use PlatformFeeEstimator::estimate_shield_from_core_fees_duffs for Core->Shielded max amount (same as shield screen) - Deduct L1 tx fee estimate for Core->Core max amount - Replace static DashColors::SUCCESS with theme-aware success_color for balance text labels - Replace hardcoded Color32 values with DashColors theme functions Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/send_screen.rs | 74 ++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 4860f1c24..8eb497d31 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -1212,9 +1212,10 @@ impl WalletSendScreen { self.wallet_open_attempted = true; } if wallet_needs_unlock(wallet) { + let dark_mode = ui.ctx().style().visuals.dark_mode; ui.add_space(10.0); ui.colored_label( - egui::Color32::from_rgb(200, 150, 50), + DashColors::warning_color(dark_mode), "Wallet is locked. Please unlock to continue.", ); ui.add_space(8.0); @@ -1805,7 +1806,7 @@ impl WalletSendScreen { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label( RichText::new(Self::format_dash(core_balance)) - .color(DashColors::SUCCESS) + .color(DashColors::success_color(dark_mode)) .strong(), ); }); @@ -1863,7 +1864,7 @@ impl WalletSendScreen { 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) + .color(DashColors::success_color(dark_mode)) .strong(), ); }); @@ -1921,7 +1922,7 @@ impl WalletSendScreen { RichText::new(Self::format_credits( qi.identity.balance(), )) - .color(DashColors::SUCCESS) + .color(DashColors::success_color(dark_mode)) .strong(), ); }, @@ -1934,7 +1935,7 @@ impl WalletSendScreen { RichText::new(Self::format_credits( first.identity.balance(), )) - .color(DashColors::SUCCESS) + .color(DashColors::success_color(dark_mode)) .strong(), ); }, @@ -2054,7 +2055,7 @@ impl WalletSendScreen { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label( RichText::new(Self::format_credits(balance)) - .color(DashColors::SUCCESS) + .color(DashColors::success_color(dark_mode)) .strong(), ); }); @@ -2142,32 +2143,51 @@ impl WalletSendScreen { // 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| { + let mut max = self.selected_wallet.as_ref().and_then(|w| { w.read() .ok() .map(|wallet| wallet.total_balance_duffs() * CREDITS_PER_DUFF) // duffs to credits }); let dest_kind = self.destination_kind(); - let hint = if dest_kind == Some(AddressKind::Platform) { - let destination = self - .validated_destination - .as_ref() - .and_then(|v| v.as_platform().copied()); - 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)); + let hint = match dest_kind { + Some(AddressKind::Platform) => { + let destination = self + .validated_destination + .as_ref() + .and_then(|v| v.as_platform().copied()); + 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 + } + } + Some(AddressKind::Shielded) => { + let (platform_fee_duffs, l1_tx_fee_duffs) = + fee_estimator.estimate_shield_from_core_fees_duffs(); + let total_fee_credits = + (platform_fee_duffs + l1_tx_fee_duffs) * CREDITS_PER_DUFF; + max = max.map(|amount| amount.saturating_sub(total_fee_credits)); Some(format!( - "Estimated platform fee ~{} (deducted from amount)", - Self::format_credits(estimated_fee) + "~{} reserved for shield fees", + Self::format_credits(total_fee_credits) )) - } else { + } + Some(AddressKind::Core) => { + let (_, l1_tx_fee_duffs) = + fee_estimator.estimate_shield_from_core_fees_duffs(); + let l1_fee_credits = l1_tx_fee_duffs * CREDITS_PER_DUFF; + max = max.map(|amount| amount.saturating_sub(l1_fee_credits)); None } - } else { - None + _ => None, }; (max, hint) } @@ -2367,7 +2387,7 @@ impl WalletSendScreen { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label( RichText::new(Self::format_credits(*use_amount)) - .color(DashColors::SUCCESS) + .color(DashColors::success_color(dark_mode)) .size(11.0), ); }); @@ -2684,7 +2704,7 @@ impl WalletSendScreen { ); ui.label( RichText::new(format!("({})", Self::format_dash(balance))) - .color(DashColors::SUCCESS) + .color(DashColors::success_color(dark_mode)) .size(12.0), ); @@ -2812,7 +2832,7 @@ impl WalletSendScreen { ); ui.label( RichText::new(format!("({})", Self::format_credits(balance))) - .color(DashColors::SUCCESS) + .color(DashColors::success_color(dark_mode)) .size(12.0), ); @@ -2921,7 +2941,7 @@ impl WalletSendScreen { ("Platform", DashColors::PLATFORM_PURPLE) } AddressKind::Shielded => { - ("Shielded", Color32::from_rgb(0, 180, 120)) + ("Shielded", DashColors::success_color(dark_mode)) } AddressKind::Identity => { ("Identity", DashColors::PLATFORM_PURPLE) From d7b5dbc1b8dca1590b22a8b7db03d25dd7c6df4c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:02:32 +0100 Subject: [PATCH 38/44] fix(shielded): require block confirmation between parallel batch broadcasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address nonces are strictly sequential on Platform (no gap tolerance). Replace the fire-and-forget wait_for_response with mandatory confirmation (3 attempts, 5s between) before proceeding to the next broadcast. If confirmation fails, cascade-fail remaining items — the nonce chain is broken without confirmed state inclusion. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/wallets/shield_screen.rs | 66 ++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/src/ui/wallets/shield_screen.rs b/src/ui/wallets/shield_screen.rs index 1c139d062..63961b3fb 100644 --- a/src/ui/wallets/shield_screen.rs +++ b/src/ui/wallets/shield_screen.rs @@ -360,16 +360,64 @@ impl ShieldScreen { let our_nonce = base_nonce + 1 + i as u32; match state_transition.broadcast(&sdk, None).await { Ok(_) => { - let wait_ok = state_transition - .wait_for_response::(&sdk, None) - .await - .is_ok(); - if !wait_ok { - tokio::time::sleep(Duration::from_secs(3)).await; + // Address nonces are strictly sequential — Platform + // requires block confirmation before accepting the next + // nonce. Retry wait_for_response to ensure the state + // transition is included in a block before proceeding. + let mut confirmed = false; + for attempt in 0..3 { + match state_transition + .wait_for_response::(&sdk, None) + .await + { + Ok(_) => { + confirmed = true; + break; + } + Err(e) => { + tracing::warn!( + "Batch item {} wait_for_response attempt {}: {e}", + i + 1, + attempt + 1 + ); + if attempt < 2 { + tokio::time::sleep(Duration::from_secs(5)).await; + } + } + } } - app_ctx.bump_platform_address_nonce(&seed_hash, &addr); - if let Ok(mut guard) = stage.lock() { - *guard = ShieldStage::Complete; + + if confirmed { + app_ctx.bump_platform_address_nonce(&seed_hash, &addr); + if let Ok(mut guard) = stage.lock() { + *guard = ShieldStage::Complete; + } + } else { + // Cannot confirm — nonce chain is broken, cascade-fail + tracing::error!( + "Batch item {} broadcast succeeded but confirmation failed after 3 attempts", + i + 1 + ); + if let Ok(mut guard) = stage.lock() { + *guard = ShieldStage::Failed { + error: "Broadcast succeeded but could not confirm. \ + Remaining items skipped to avoid nonce errors." + .to_string(), + st_json: st_repr, + }; + } + for remaining in stages.iter().skip(i + 1) { + if let Ok(mut s) = remaining.lock() + && !s.is_terminal() + { + *s = ShieldStage::Failed { + error: "Skipped: previous item not confirmed" + .to_string(), + st_json: None, + }; + } + } + break; } } Err(e) => { From 108aa0855d402f7268ac1c914691ffa72742abfc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:50:43 +0200 Subject: [PATCH 39/44] style: fix formatting after merge Co-Authored-By: Claude Opus 4.6 --- src/context/wallet_lifecycle.rs | 3 +- src/ui/wallets/send_screen.rs | 108 ++++++++++++++++---------------- 2 files changed, 56 insertions(+), 55 deletions(-) diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 54b2b29e3..f5f9c52b9 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -142,7 +142,8 @@ impl AppContext { // platform payment addresses haven't been derived yet (wallet // created with only a Core address via new_from_seed). let has_platform_addresses = guard.watched_addresses.values().any(|info| { - info.path_reference == crate::model::wallet::DerivationPathReference::PlatformPayment + info.path_reference + == crate::model::wallet::DerivationPathReference::PlatformPayment }); if guard.known_addresses.is_empty() || !has_platform_addresses { tracing::info!(wallet = %hex::encode(guard.seed_hash()), "Bootstrapping wallet addresses"); diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 0fc9293da..197b59da5 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -2123,67 +2123,67 @@ impl WalletSendScreen { )) }); self.address_input.get_or_insert_with(|| { - let allowed_kinds = match &self.selected_source { - Some(SourceSelection::CoreWallet) => { - let mut kinds = vec![AddressKind::Core, AddressKind::Platform]; - if developer_mode { - kinds.push(AddressKind::Shielded); + let allowed_kinds = match &self.selected_source { + Some(SourceSelection::CoreWallet) => { + let mut kinds = vec![AddressKind::Core, AddressKind::Platform]; + if developer_mode { + kinds.push(AddressKind::Shielded); + } + kinds.push(AddressKind::Identity); + kinds } - kinds.push(AddressKind::Identity); - kinds - } - Some(SourceSelection::PlatformAddresses(_)) => { - let mut kinds = vec![AddressKind::Platform, AddressKind::Core]; - if developer_mode { - kinds.push(AddressKind::Shielded); + Some(SourceSelection::PlatformAddresses(_)) => { + let mut kinds = vec![AddressKind::Platform, AddressKind::Core]; + if developer_mode { + kinds.push(AddressKind::Shielded); + } + kinds.push(AddressKind::Identity); + kinds } - kinds.push(AddressKind::Identity); - kinds - } - Some(SourceSelection::Identity(_)) => { - vec![ - AddressKind::Core, - AddressKind::Platform, - AddressKind::Identity, - ] - } - Some(SourceSelection::Shielded(..)) => { - vec![ - AddressKind::Shielded, - AddressKind::Platform, - AddressKind::Core, - ] - } - None => AddressKind::ALL.to_vec(), - }; + Some(SourceSelection::Identity(_)) => { + vec![ + AddressKind::Core, + AddressKind::Platform, + AddressKind::Identity, + ] + } + Some(SourceSelection::Shielded(..)) => { + vec![ + AddressKind::Shielded, + AddressKind::Platform, + AddressKind::Core, + ] + } + None => AddressKind::ALL.to_vec(), + }; - let mut builder = AddressInput::new(self.app_context.network) - .with_label("Send to") - .with_hint_text("Enter address (X.../y.../dash1.../tdash1...)") - .with_address_kinds(&allowed_kinds) - .with_exclude_change(true); - - // Provide all wallet addresses for autocomplete - if let Ok(wallets_guard) = self.app_context.wallets.read() { - let all_wallets: Vec>> = - wallets_guard.values().cloned().collect(); - if !all_wallets.is_empty() { - builder = builder.with_wallets(&all_wallets); + let mut builder = AddressInput::new(self.app_context.network) + .with_label("Send to") + .with_hint_text("Enter address (X.../y.../dash1.../tdash1...)") + .with_address_kinds(&allowed_kinds) + .with_exclude_change(true); + + // Provide all wallet addresses for autocomplete + if let Ok(wallets_guard) = self.app_context.wallets.read() { + let all_wallets: Vec>> = + wallets_guard.values().cloned().collect(); + if !all_wallets.is_empty() { + builder = builder.with_wallets(&all_wallets); + } } - } - // Add identities for autocomplete (searchable by alias/DPNS name) - if !loaded_identities.is_empty() { - builder = builder.with_identities(&loaded_identities); - } + // Add identities for autocomplete (searchable by alias/DPNS name) + if !loaded_identities.is_empty() { + builder = builder.with_identities(&loaded_identities); + } - // Add shielded address for autocomplete (if wallet has shielded state) - if let Some((addr_str, balance)) = &shielded_info { - builder = builder.with_shielded_balance(addr_str.clone(), *balance); - } + // Add shielded address for autocomplete (if wallet has shielded state) + if let Some((addr_str, balance)) = &shielded_info { + builder = builder.with_shielded_balance(addr_str.clone(), *balance); + } - builder - }) + builder + }) }; let resp = addr_input.show(ui); From ae7b3471e4962c946d580dd12a3a176d831c0407 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:03:32 +0200 Subject: [PATCH 40/44] fix(review): address backend review findings from PR #802 triage - Document [0u8; 36] as empty memo parameter (not randomness) at all 6 call sites - Replace lock .unwrap() with ? in Result-returning functions (bundle.rs, shielded.rs) - Replace lock .unwrap() with .unwrap_or_else(|e| e.into_inner()) in non-Result functions - Extract extract_spends_and_anchor() helper to eliminate 3x duplicated witness blocks - Fix assume_checked() -> require_network() validation in MCP shielded/identity tools - Add source_address param to MCP ShieldFromCore tool with network validation - Add INTENTIONAL(CODE-006) comment for bootstrap address check Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/shielded/bundle.rs | 159 +++++++++++----------------- src/context/shielded.rs | 39 +++---- src/context/wallet_lifecycle.rs | 3 + src/mcp/tools/identity.rs | 13 ++- src/mcp/tools/shielded.rs | 35 ++++-- 5 files changed, 124 insertions(+), 125 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 6e63e8f50..05b0dc1a0 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -3,7 +3,7 @@ use crate::context::AppContext; use crate::context::shielded::get_proving_key; use crate::model::fee_estimation::{format_credits_as_dash, shielded_fee_for_actions}; use crate::model::wallet::WalletSeedHash; -use crate::model::wallet::shielded::ShieldedWalletState; +use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState}; use dash_sdk::dpp::address_funds::{ AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, OrchardAddress, PlatformAddress, }; @@ -15,10 +15,12 @@ use dash_sdk::dpp::shielded::builder::{ }; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::dpp::withdrawal::Pooling; -use dash_sdk::grovedb_commitment_tree::{Nullifier, PaymentAddress, ProvingKey}; +use dash_sdk::grovedb_commitment_tree::{ + Anchor, ClientPersistentCommitmentTree, Nullifier, PaymentAddress, ProvingKey, +}; use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use std::collections::BTreeMap; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard}; /// Wrapper around a cached `ProvingKey` that implements `OrchardProver`. struct CachedProver { @@ -98,7 +100,7 @@ pub fn build_shield_credit( let recipient_addr = payment_address_to_orchard(recipient_payment_address)?; let wallet_arc = { - let wallets = app_context.wallets.read().unwrap(); + let wallets = app_context.wallets.read()?; wallets .get(seed_hash) .cloned() @@ -111,7 +113,8 @@ pub fn build_shield_credit( let fee_strategy: AddressFundsFeeStrategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; - let wallet = wallet_arc.read().unwrap(); + let wallet = wallet_arc.read()?; + // memo: 36-byte structured memo (4-byte type tag + 32-byte payload); all zeros = empty memo build_shield_transition( &recipient_addr, amount, @@ -148,7 +151,7 @@ pub async fn shield_credits( let recipient_addr = payment_address_to_orchard(recipient_payment_address)?; let wallet_arc = { - let wallets = app_context.wallets.read().unwrap(); + let wallets = app_context.wallets.read()?; wallets .get(seed_hash) .cloned() @@ -158,7 +161,7 @@ pub async fn shield_credits( let nonce: u32 = if let Some(n) = nonce_override { n } else { - let wallet = wallet_arc.read().unwrap(); + let wallet = wallet_arc.read()?; wallet .platform_address_info .iter() @@ -187,11 +190,12 @@ pub async fn shield_credits( ); if let Some(s) = &stage { - *s.lock().unwrap() = ShieldStage::BuildingProof { nonce }; + *s.lock()? = ShieldStage::BuildingProof { nonce }; } let state_transition = { - let wallet = wallet_arc.read().unwrap(); + let wallet = wallet_arc.read()?; + // memo: 36-byte structured memo (4-byte type tag + 32-byte payload); all zeros = empty memo build_shield_transition( &recipient_addr, amount, @@ -207,7 +211,7 @@ pub async fn shield_credits( }; if let Some(s) = &stage { - *s.lock().unwrap() = ShieldStage::Broadcasting; + *s.lock()? = ShieldStage::Broadcasting; } tracing::trace!("Shield credits: state transition built, broadcasting..."); @@ -269,35 +273,13 @@ pub async fn shielded_transfer( let spent_nullifiers: Vec = spendable_notes.iter().map(|n| n.nullifier).collect(); let (spends, anchor) = { - let tree = shielded_state.commitment_tree.lock().unwrap(); - let spends = spendable_notes - .iter() - .map(|note| { - let merkle_path = tree - .witness(note.position, 0) - .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { - detail: e.to_string(), - })? - .ok_or(TaskError::ShieldedMerkleWitnessUnavailable { - detail: "No Merkle path available for note".into(), - })?; - Ok(SpendableNote { - note: note.note, - merkle_path, - }) - }) - .collect::, TaskError>>()?; - - let anchor = tree - .anchor() - .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { - detail: e.to_string(), - })?; - (spends, anchor) + let tree = shielded_state.commitment_tree.lock()?; + extract_spends_and_anchor(&tree, &spendable_notes)? }; let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address)?; + // memo: 36-byte structured memo (4-byte type tag + 32-byte payload); all zeros = empty memo let state_transition = build_shielded_transfer_transition( spends, &recipient_addr, @@ -367,35 +349,13 @@ pub async fn unshield_credits( let spent_nullifiers: Vec = spendable_notes.iter().map(|n| n.nullifier).collect(); let (spends, anchor) = { - let tree = shielded_state.commitment_tree.lock().unwrap(); - let spends = spendable_notes - .iter() - .map(|note| { - let merkle_path = tree - .witness(note.position, 0) - .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { - detail: e.to_string(), - })? - .ok_or(TaskError::ShieldedMerkleWitnessUnavailable { - detail: "No Merkle path available for note".into(), - })?; - Ok(SpendableNote { - note: note.note, - merkle_path, - }) - }) - .collect::, TaskError>>()?; - - let anchor = tree - .anchor() - .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { - detail: e.to_string(), - })?; - (spends, anchor) + let tree = shielded_state.commitment_tree.lock()?; + extract_spends_and_anchor(&tree, &spendable_notes)? }; let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address)?; + // memo: 36-byte structured memo (4-byte type tag + 32-byte payload); all zeros = empty memo let state_transition = build_unshield_transition( spends, to_platform_address, @@ -457,7 +417,7 @@ pub async fn shield_from_asset_lock( // 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 = app_context.wallets.read().unwrap(); + let wallets = app_context.wallets.read()?; wallets .get(seed_hash) .cloned() @@ -501,10 +461,7 @@ pub async fn shield_from_asset_lock( // Step 2: Register this transaction as waiting for finality { - let mut proofs = app_context - .transactions_waiting_for_finality - .lock() - .unwrap(); + let mut proofs = app_context.transactions_waiting_for_finality.lock()?; proofs.insert(tx_id, None); } @@ -520,7 +477,7 @@ pub async fn shield_from_asset_lock( // Step 4: Remove used UTXOs from wallet { let wallet_arc = { - let wallets = app_context.wallets.read().unwrap(); + let wallets = app_context.wallets.read()?; wallets .get(seed_hash) .cloned() @@ -573,7 +530,7 @@ pub async fn shield_from_asset_lock( return Err(TaskError::ShieldedAssetLockTimeout); } _ = tokio::time::sleep(Duration::from_millis(200)) => { - let proofs = app_context.transactions_waiting_for_finality.lock().unwrap(); + let proofs = app_context.transactions_waiting_for_finality.lock()?; if let Some(Some(proof)) = proofs.get(&tx_id) { asset_lock_proof = proof.clone(); break; @@ -584,10 +541,7 @@ pub async fn shield_from_asset_lock( // Step 6: Clean up the finality tracking { - let mut proofs = app_context - .transactions_waiting_for_finality - .lock() - .unwrap(); + let mut proofs = app_context.transactions_waiting_for_finality.lock()?; proofs.remove(&tx_id); } @@ -611,6 +565,7 @@ pub async fn shield_from_asset_lock( shield_amount_credits, ); + // memo: 36-byte structured memo (4-byte type tag + 32-byte payload); all zeros = empty memo let state_transition = build_shield_from_asset_lock_transition( &recipient, shield_amount_credits, @@ -677,35 +632,13 @@ pub async fn shielded_withdrawal( let spent_nullifiers: Vec = spendable_notes.iter().map(|n| n.nullifier).collect(); let (spends, anchor) = { - let tree = shielded_state.commitment_tree.lock().unwrap(); - let spends = spendable_notes - .iter() - .map(|note| { - let merkle_path = tree - .witness(note.position, 0) - .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { - detail: e.to_string(), - })? - .ok_or(TaskError::ShieldedMerkleWitnessUnavailable { - detail: "No Merkle path available for note".into(), - })?; - Ok(SpendableNote { - note: note.note, - merkle_path, - }) - }) - .collect::, TaskError>>()?; - - let anchor = tree - .anchor() - .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { - detail: e.to_string(), - })?; - (spends, anchor) + let tree = shielded_state.commitment_tree.lock()?; + extract_spends_and_anchor(&tree, &spendable_notes)? }; let change_addr = payment_address_to_orchard(&shielded_state.keys.default_address)?; + // memo: 36-byte structured memo (4-byte type tag + 32-byte payload); all zeros = empty memo let state_transition = build_shielded_withdrawal_transition( spends, amount, @@ -833,6 +766,40 @@ fn select_notes_for_amount( Ok((selected, accumulated)) } +/// Extract spendable notes with Merkle witnesses and the tree anchor. +/// +/// Locks the commitment tree, computes a Merkle path for each selected note, +/// and returns them alongside the current tree anchor for proof construction. +fn extract_spends_and_anchor( + tree: &MutexGuard<'_, ClientPersistentCommitmentTree>, + notes: &[&ShieldedNote], +) -> Result<(Vec, Anchor), TaskError> { + let spends = notes + .iter() + .map(|note| { + let merkle_path = tree + .witness(note.position, 0) + .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { + detail: e.to_string(), + })? + .ok_or(TaskError::ShieldedMerkleWitnessUnavailable { + detail: "No Merkle path available for note".into(), + })?; + Ok(SpendableNote { + note: note.note, + merkle_path, + }) + }) + .collect::, TaskError>>()?; + + let anchor = tree + .anchor() + .map_err(|e| TaskError::ShieldedMerkleWitnessUnavailable { + detail: e.to_string(), + })?; + Ok((spends, anchor)) +} + /// Convert a PaymentAddress to an OrchardAddress for the builder functions. fn payment_address_to_orchard(addr: &PaymentAddress) -> Result { let raw = addr.to_raw_address_bytes(); diff --git a/src/context/shielded.rs b/src/context/shielded.rs index 68a217a47..f61c05d7a 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -107,14 +107,14 @@ impl AppContext { seed_hash: &WalletSeedHash, from_address: &dash_sdk::dpp::address_funds::PlatformAddress, ) { - let wallets = self.wallets.read().unwrap(); + let wallets = self.wallets.read().unwrap_or_else(|e| e.into_inner()); let wallet_arc = match wallets.get(seed_hash) { Some(w) => w.clone(), None => return, }; drop(wallets); - let mut wallet = wallet_arc.write().unwrap(); + let mut wallet = wallet_arc.write().unwrap_or_else(|e| e.into_inner()); // Find the matching entry (platform_address_info is keyed by core Address) let mut found: Option<(dash_sdk::dpp::dashcore::Address, u64, u32)> = None; for (core_addr, info) in wallet.platform_address_info.iter_mut() { @@ -150,14 +150,14 @@ impl AppContext { from_address: &dash_sdk::dpp::address_funds::PlatformAddress, nonce: u32, ) { - let wallets = self.wallets.read().unwrap(); + let wallets = self.wallets.read().unwrap_or_else(|e| e.into_inner()); let wallet_arc = match wallets.get(seed_hash) { Some(w) => w.clone(), None => return, }; drop(wallets); - let mut wallet = wallet_arc.write().unwrap(); + let mut wallet = wallet_arc.write().unwrap_or_else(|e| e.into_inner()); for (core_addr, info) in wallet.platform_address_info.iter_mut() { if let Ok(pa) = dash_sdk::dpp::address_funds::PlatformAddress::try_from(core_addr.clone()) @@ -201,7 +201,10 @@ impl AppContext { &self, seed_hash: &WalletSeedHash, ) -> Option { - let states = self.shielded_states.lock().unwrap(); + let states = self + .shielded_states + .lock() + .unwrap_or_else(|e| e.into_inner()); states.get(seed_hash).map(|s| s.keys.default_address) } @@ -212,7 +215,7 @@ impl AppContext { ) -> Result { // Check if already initialized { - let states = self.shielded_states.lock().unwrap(); + let states = self.shielded_states.lock()?; if states.contains_key(&seed_hash) { let balance = states .get(&seed_hash) @@ -224,9 +227,9 @@ impl AppContext { // Get the wallet seed let seed_bytes = { - let wallets = self.wallets.read().unwrap(); + let wallets = self.wallets.read()?; let wallet_arc = wallets.get(&seed_hash).ok_or(TaskError::WalletNotFound)?; - let wallet = wallet_arc.read().unwrap(); + let wallet = wallet_arc.read()?; match &wallet.wallet_seed { crate::model::wallet::WalletSeed::Open(open) => open.seed, crate::model::wallet::WalletSeed::Closed(_) => { @@ -326,7 +329,7 @@ impl AppContext { let balance = state.shielded_balance; - let mut states = self.shielded_states.lock().unwrap(); + let mut states = self.shielded_states.lock()?; states.insert(seed_hash, state); Ok(BackendTaskSuccessResult::ShieldedInitialized { seed_hash, balance }) @@ -339,7 +342,7 @@ impl AppContext { ) -> Result { // Take the state temporarily for the async operation let mut state = { - let mut states = self.shielded_states.lock().unwrap(); + let mut states = self.shielded_states.lock()?; states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? }; @@ -357,7 +360,7 @@ impl AppContext { // Put state back { - let mut states = self.shielded_states.lock().unwrap(); + let mut states = self.shielded_states.lock()?; states.insert(seed_hash, state); } @@ -382,7 +385,7 @@ impl AppContext { nonce_override: Option, ) -> Result { let default_address = { - let states = self.shielded_states.lock().unwrap(); + let states = self.shielded_states.lock()?; let state = states.get(&seed_hash).ok_or(TaskError::WalletNotFound)?; state.keys.default_address }; @@ -531,7 +534,7 @@ impl AppContext { operation: impl AsyncFn(&ShieldedWalletState) -> Result, TaskError>, ) -> Result, TaskError> { let mut state = { - let mut states = self.shielded_states.lock().unwrap(); + let mut states = self.shielded_states.lock()?; states.remove(seed_hash).ok_or(TaskError::WalletNotFound)? }; @@ -591,7 +594,7 @@ impl AppContext { } { - let mut states = self.shielded_states.lock().unwrap(); + let mut states = self.shielded_states.lock()?; states.insert(*seed_hash, state); } @@ -610,7 +613,7 @@ impl AppContext { source_address: Option, ) -> Result { let state_ref = { - let mut states = self.shielded_states.lock().unwrap(); + let mut states = self.shielded_states.lock()?; states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? }; @@ -625,7 +628,7 @@ impl AppContext { // Always put state back { - let mut states = self.shielded_states.lock().unwrap(); + let mut states = self.shielded_states.lock()?; states.insert(seed_hash, state_ref); } @@ -642,7 +645,7 @@ impl AppContext { seed_hash: WalletSeedHash, ) -> Result { let mut state = { - let mut states = self.shielded_states.lock().unwrap(); + let mut states = self.shielded_states.lock()?; states.remove(&seed_hash).ok_or(TaskError::WalletNotFound)? }; @@ -660,7 +663,7 @@ impl AppContext { // Put state back { - let mut states = self.shielded_states.lock().unwrap(); + let mut states = self.shielded_states.lock()?; states.insert(seed_hash, state); } diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index f5f9c52b9..98009c7d7 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -141,6 +141,9 @@ impl AppContext { // Bootstrap when no addresses exist (fresh wallet) or when // platform payment addresses haven't been derived yet (wallet // created with only a Core address via new_from_seed). + // INTENTIONAL(CODE-006): Bootstrap checks only PlatformPayment address type. + // Other platform address types may trigger redundant re-derivation, but + // bootstrap_known_addresses() is idempotent so this is safe. let has_platform_addresses = guard.watched_addresses.values().any(|info| { info.path_reference == crate::model::wallet::DerivationPathReference::PlatformPayment diff --git a/src/mcp/tools/identity.rs b/src/mcp/tools/identity.rs index 771bf553b..15a17cbfa 100644 --- a/src/mcp/tools/identity.rs +++ b/src/mcp/tools/identity.rs @@ -460,15 +460,18 @@ impl AsyncTool for IdentityCreditsWithdraw { let qi = resolve::qualified_identity(&ctx, ¶m.identity_id)?; - let core_address: dash_sdk::dashcore_rpc::dashcore::Address< - dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked, - > = param + let core_address = param .to_address - .parse() + .parse::>() .map_err(|e| McpToolError::InvalidParam { message: format!("Invalid Core address: {e}"), + })? + .require_network(ctx.network()) + .map_err(|e| McpToolError::InvalidParam { + message: format!("Core address does not match active network: {e}"), })?; - let core_address = core_address.assume_checked(); let task = BackendTask::IdentityTask(IdentityTask::WithdrawFromIdentity( qi, diff --git a/src/mcp/tools/shielded.rs b/src/mcp/tools/shielded.rs index 2cbb538a5..59ad2b600 100644 --- a/src/mcp/tools/shielded.rs +++ b/src/mcp/tools/shielded.rs @@ -29,6 +29,8 @@ pub struct ShieldFromCoreParams { pub amount_duffs: u64, /// Expected network (required for destructive operations) pub network: String, + /// Optional Core address to fund from (restricts UTXO selection to this address) + pub source_address: Option, } #[derive(Serialize, schemars::JsonSchema)] @@ -81,10 +83,28 @@ impl AsyncTool for ShieldedShieldFromCore { let seed_hash = resolve::wallet(&ctx, ¶m.wallet_id)?; resolve::ensure_spv_synced(&ctx).await?; + let source_address = param + .source_address + .map(|addr_str| { + resolve::validate_address(&addr_str)?; + addr_str + .parse::>() + .map_err(|e| McpToolError::InvalidParam { + message: format!("Invalid source Core address: {e}"), + })? + .require_network(ctx.network()) + .map_err(|e| McpToolError::InvalidParam { + message: format!("Source address does not match active network: {e}"), + }) + }) + .transpose()?; + let task = BackendTask::ShieldedTask(ShieldedTask::ShieldFromAssetLock { seed_hash, amount_duffs: param.amount_duffs, - source_address: None, + source_address, }); let result = dispatch_task(&ctx, task) @@ -494,15 +514,18 @@ impl AsyncTool for ShieldedWithdrawTool { // (withdrawal is queued on Platform and settles after confirmation) let seed_hash = resolve::wallet(&ctx, ¶m.wallet_id)?; - let core_address: dash_sdk::dashcore_rpc::dashcore::Address< - dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked, - > = param + let core_address = param .to_address - .parse() + .parse::>() .map_err(|e| McpToolError::InvalidParam { message: format!("Invalid Core address: {e}"), + })? + .require_network(ctx.network()) + .map_err(|e| McpToolError::InvalidParam { + message: format!("Core address does not match active network: {e}"), })?; - let core_address = core_address.assume_checked(); let task = BackendTask::ShieldedTask(ShieldedTask::ShieldedWithdrawal { seed_hash, From f5edcd31a79b8ec7442a7b8305c72dfcdab4c537 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:01:31 +0200 Subject: [PATCH 41/44] fix(ui): address UI review findings from PR #802 triage - Fix multi-address shield allocation to deduct per-operation fees - Replace lock .unwrap() with .ok()? in shield_screen.rs helpers - Prevent zero-amount shield confirmation - Use context fee_multiplier_permille for shield fee headroom - Cache balance/nonce in ShieldScreen to avoid per-frame lock reads - Implement refresh_on_arrival() for shielded screens Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ui/mod.rs | 12 +-- src/ui/wallets/send_screen.rs | 16 ++- src/ui/wallets/shield_screen.rs | 126 +++++++++++++--------- src/ui/wallets/shielded_send_screen.rs | 21 ++-- src/ui/wallets/unshield_credits_screen.rs | 21 ++-- 5 files changed, 121 insertions(+), 75 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 08b6cd90a..e460c6c97 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1172,9 +1172,9 @@ impl ScreenLike for Screen { Screen::DashPayQRGeneratorScreen(_) => {} Screen::DashPayProfileSearchScreen(screen) => screen.refresh(), // Shielded screens - Screen::ShieldScreen(_) => {} - Screen::ShieldedSendScreen(_) => {} - Screen::UnshieldCreditsScreen(_) => {} + Screen::ShieldScreen(screen) => screen.refresh(), + Screen::ShieldedSendScreen(screen) => screen.refresh(), + Screen::UnshieldCreditsScreen(screen) => screen.refresh(), } } @@ -1242,9 +1242,9 @@ impl ScreenLike for Screen { Screen::DashPayQRGeneratorScreen(_) => {} Screen::DashPayProfileSearchScreen(screen) => screen.refresh_on_arrival(), // Shielded screens - Screen::ShieldScreen(_) => {} - Screen::ShieldedSendScreen(_) => {} - Screen::UnshieldCreditsScreen(_) => {} + Screen::ShieldScreen(screen) => screen.refresh_on_arrival(), + Screen::ShieldedSendScreen(screen) => screen.refresh_on_arrival(), + Screen::UnshieldCreditsScreen(screen) => screen.refresh_on_arrival(), } } diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 197b59da5..87d0304fc 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -628,7 +628,9 @@ impl WalletSendScreen { fn get_shielded_balance(&self) -> Option<(WalletSeedHash, u64)> { let seed_hash = self.selected_wallet_seed_hash?; // Try in-memory state first (most accurate, reflects optimistic spend marks) - let states = self.app_context.shielded_states.lock().unwrap(); + let Ok(states) = self.app_context.shielded_states.lock() else { + return None; + }; if let Some(state) = states.get(&seed_hash) { let balance = state.shielded_balance; return if balance > 0 { @@ -1549,17 +1551,23 @@ impl WalletSendScreen { )); } - // Allocate amount across addresses (highest balance first) + // Allocate amount across addresses (highest balance first), reserving + // per-operation fee headroom so each address can cover its own shield fee. + let per_op_fee = crate::model::fee_estimation::shielded_fee_for_actions( + 2, + dash_sdk::dpp::version::PlatformVersion::latest(), + ); let mut remaining = amount_credits; let mut tasks: Vec = Vec::new(); for (platform_addr, _, balance) in &sorted_addrs { if remaining == 0 { break; } - let spend = remaining.min(*balance); - if spend == 0 { + let available = balance.saturating_sub(per_op_fee); + if available == 0 { continue; } + let spend = remaining.min(available); tasks.push(BackendTask::ShieldedTask( crate::backend_task::shielded::ShieldedTask::ShieldCredits { seed_hash, diff --git a/src/ui/wallets/shield_screen.rs b/src/ui/wallets/shield_screen.rs index 63961b3fb..408b86c42 100644 --- a/src/ui/wallets/shield_screen.rs +++ b/src/ui/wallets/shield_screen.rs @@ -91,11 +91,15 @@ pub struct ShieldScreen { batch_amount: Option, /// Frozen platform address for the current batch. batch_address: Option, + // Cached wallet data to avoid per-frame RwLock reads (CODE-007) + cached_base_nonce: Option, + cached_platform_balance: Option, + cached_core_balance: Option, } impl ShieldScreen { pub fn new(seed_hash: WalletSeedHash, app_context: &Arc) -> Self { - Self { + let mut screen = Self { app_context: app_context.clone(), seed_hash, address_input: None, @@ -115,7 +119,12 @@ impl ShieldScreen { json_preview: None, batch_amount: None, batch_address: None, - } + cached_base_nonce: None, + cached_platform_balance: None, + cached_core_balance: None, + }; + screen.refresh_cached_balances(); + screen } /// Reset the address and amount inputs — called when AppContext switches network. @@ -124,6 +133,9 @@ impl ShieldScreen { self.validated_source = None; self.amount_input = None; self.amount = None; + self.cached_base_nonce = None; + self.cached_platform_balance = None; + self.cached_core_balance = None; } fn parse_repeat_count(&self) -> u32 { @@ -141,58 +153,58 @@ impl ShieldScreen { .and_then(|v| v.as_platform().copied()) } - /// Read the current nonce for the selected platform address from the wallet. + /// Refresh cached wallet data (balance, nonce) from the RwLock-protected wallet. + fn refresh_cached_balances(&mut self) { + let wallets = self.app_context.wallets.read().ok(); + let wallet_guard = wallets + .as_ref() + .and_then(|w| w.get(&self.seed_hash)) + .and_then(|arc| arc.read().ok()); + + if let Some(wallet) = &wallet_guard { + // Platform nonce and balance for selected address + if let Some(from_address) = self.selected_platform_address() { + let info = wallet + .platform_address_info + .iter() + .find_map(|(addr, info)| { + let platform_addr = PlatformAddress::try_from(addr.clone()).ok()?; + (platform_addr == from_address).then_some(info) + }); + self.cached_base_nonce = info.map(|i| i.nonce); + self.cached_platform_balance = info.map(|i| i.balance); + } else { + self.cached_base_nonce = None; + self.cached_platform_balance = None; + } + + // Core balance + if let Some(addr) = self.validated_source.as_ref().and_then(|v| v.as_core()) { + self.cached_core_balance = + Some(wallet.address_balances.get(addr).copied().unwrap_or(0)); + } else { + self.cached_core_balance = Some(wallet.total_balance_duffs()); + } + } else { + self.cached_base_nonce = None; + self.cached_platform_balance = None; + self.cached_core_balance = Some(0); + } + } + + /// Return the cached nonce for the selected platform address. fn read_base_nonce(&self) -> Option { - let from_address = self.selected_platform_address()?; - let wallets = self.app_context.wallets.read().unwrap(); - let wallet_arc = wallets.get(&self.seed_hash)?; - let wallet = wallet_arc.read().unwrap(); - wallet - .platform_address_info - .iter() - .find_map(|(addr, info)| { - let platform_addr = PlatformAddress::try_from(addr.clone()).ok()?; - if platform_addr == from_address { - Some(info.nonce) - } else { - None - } - }) + self.cached_base_nonce } - /// Read the current balance (in credits) for the selected platform address. + /// Return the cached balance (credits) for the selected platform address. fn read_platform_balance(&self) -> Option { - let from_address = self.selected_platform_address()?; - let wallets = self.app_context.wallets.read().unwrap(); - let wallet_arc = wallets.get(&self.seed_hash)?; - let wallet = wallet_arc.read().unwrap(); - wallet - .platform_address_info - .iter() - .find_map(|(addr, info)| { - let platform_addr = PlatformAddress::try_from(addr.clone()).ok()?; - if platform_addr == from_address { - Some(info.balance) - } else { - None - } - }) + self.cached_platform_balance } - /// Read the core wallet balance in duffs. + /// Return the cached core wallet balance in duffs. fn read_core_balance_duffs(&self) -> u64 { - let wallets = self.app_context.wallets.read().unwrap(); - let Some(wallet_arc) = wallets.get(&self.seed_hash) else { - return 0; - }; - let wallet = wallet_arc.read().unwrap(); - // If a specific Core address is selected, return its individual balance - // so the max-amount display matches the funds actually available for this address. - if let Some(addr) = self.validated_source.as_ref().and_then(|v| v.as_core()) { - wallet.address_balances.get(addr).copied().unwrap_or(0) - } else { - wallet.total_balance_duffs() - } + self.cached_core_balance.unwrap_or(0) } /// Build a single ShieldCredits task with optional nonce override. @@ -704,6 +716,7 @@ impl ScreenLike for ShieldScreen { // Reset amount input when source changes (different balance constraints) self.amount_input = None; self.amount = None; + self.refresh_cached_balances(); } ui.add_space(5.0); @@ -763,9 +776,11 @@ impl ScreenLike for ShieldScreen { if self.validated_source.is_some() { let max_credits = match source_kind { Some(AddressKind::Platform) => { - let fee_headroom = - shielded_fee_for_actions(2, PlatformVersion::latest()) - .saturating_mul(2); + let base_fee = + shielded_fee_for_actions(2, PlatformVersion::latest()); + let multiplier = + self.app_context.fee_multiplier_permille().max(1000); + let fee_headroom = base_fee.saturating_mul(multiplier) / 1000; self.read_platform_balance() .map(|b| b.saturating_sub(fee_headroom)) } @@ -841,7 +856,11 @@ impl ScreenLike for ShieldScreen { // Buttons (only when not busy and source is selected) if !is_busy && self.status == Status::NotStarted && self.validated_source.is_some() { - let can_confirm = self.amount.as_ref().map(|a| a.value()).is_some(); + let can_confirm = self + .amount + .as_ref() + .map(|a| a.value()) + .is_some_and(|v| v > 0); ui.horizontal(|ui| { let button_label = match source_kind { @@ -971,7 +990,12 @@ impl ScreenLike for ShieldScreen { action } + fn refresh_on_arrival(&mut self) { + self.refresh_cached_balances(); + } + fn display_task_result(&mut self, result: BackendTaskSuccessResult) { + self.refresh_cached_balances(); let ctx = self.app_context.egui_ctx().clone(); match result { BackendTaskSuccessResult::ShieldedCreditsShielded { seed_hash, amount } diff --git a/src/ui/wallets/shielded_send_screen.rs b/src/ui/wallets/shielded_send_screen.rs index cf6b4263f..43bc7b82e 100644 --- a/src/ui/wallets/shielded_send_screen.rs +++ b/src/ui/wallets/shielded_send_screen.rs @@ -41,13 +41,12 @@ pub struct ShieldedSendScreen { impl ShieldedSendScreen { pub fn new(seed_hash: WalletSeedHash, app_context: &Arc) -> Self { - let max_balance = { - let states = app_context.shielded_states.lock().unwrap(); - states - .get(&seed_hash) - .map(|s| s.shielded_balance) - .unwrap_or(0) - }; + let max_balance = app_context + .shielded_states + .lock() + .ok() + .and_then(|states| states.get(&seed_hash).map(|s| s.shielded_balance)) + .unwrap_or(0); Self { app_context: app_context.clone(), @@ -89,6 +88,14 @@ impl ShieldedSendScreen { } impl ScreenLike for ShieldedSendScreen { + fn refresh_on_arrival(&mut self) { + if let Ok(states) = self.app_context.shielded_states.lock() + && let Some(state) = states.get(&self.seed_hash) + { + self.max_balance = state.shielded_balance; + } + } + fn ui(&mut self, ctx: &Context) -> AppAction { let mut action = self .pending_refresh_task diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs index eafb8183a..c3ad1c2ee 100644 --- a/src/ui/wallets/unshield_credits_screen.rs +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -50,13 +50,12 @@ impl UnshieldCreditsScreen { } pub fn new(seed_hash: WalletSeedHash, app_context: &Arc) -> Self { - let max_balance = { - let states = app_context.shielded_states.lock().unwrap(); - states - .get(&seed_hash) - .map(|s| s.shielded_balance) - .unwrap_or(0) - }; + let max_balance = app_context + .shielded_states + .lock() + .ok() + .and_then(|states| states.get(&seed_hash).map(|s| s.shielded_balance)) + .unwrap_or(0); Self { app_context: app_context.clone(), @@ -76,6 +75,14 @@ impl UnshieldCreditsScreen { } impl ScreenLike for UnshieldCreditsScreen { + fn refresh_on_arrival(&mut self) { + if let Ok(states) = self.app_context.shielded_states.lock() + && let Some(state) = states.get(&self.seed_hash) + { + self.max_balance = state.shielded_balance; + } + } + fn ui(&mut self, ctx: &Context) -> AppAction { let mut action = self .pending_refresh_task From c82941420dd88bfbbca9e0c3072b625d339aae27 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:04:45 +0200 Subject: [PATCH 42/44] fix(review): address PR #802 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject partial Platform→Shielded allocation when remaining > 0 after fee-adjusted loop; reject empty task list when all balances below fee - Apply fee_multiplier_permille to per-operation fee in send_platform_to_shielded for consistency with ShieldScreen - Remove parser detail leakage ({e}) from MCP error messages in identity.rs and shielded.rs — use generic user-facing messages Co-Authored-By: Claude Opus 4.6 --- src/mcp/tools/identity.rs | 8 ++++---- src/mcp/tools/shielded.rs | 17 +++++++++-------- src/ui/wallets/send_screen.rs | 21 ++++++++++++++++++++- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/mcp/tools/identity.rs b/src/mcp/tools/identity.rs index 15a17cbfa..2dc21a096 100644 --- a/src/mcp/tools/identity.rs +++ b/src/mcp/tools/identity.rs @@ -465,12 +465,12 @@ impl AsyncTool for IdentityCreditsWithdraw { .parse::>() - .map_err(|e| McpToolError::InvalidParam { - message: format!("Invalid Core address: {e}"), + .map_err(|_| McpToolError::InvalidParam { + message: "The Core address is invalid.".to_owned(), })? .require_network(ctx.network()) - .map_err(|e| McpToolError::InvalidParam { - message: format!("Core address does not match active network: {e}"), + .map_err(|_| McpToolError::InvalidParam { + message: "The Core address does not match the active network.".to_owned(), })?; let task = BackendTask::IdentityTask(IdentityTask::WithdrawFromIdentity( diff --git a/src/mcp/tools/shielded.rs b/src/mcp/tools/shielded.rs index 59ad2b600..6f70a7b47 100644 --- a/src/mcp/tools/shielded.rs +++ b/src/mcp/tools/shielded.rs @@ -91,12 +91,13 @@ impl AsyncTool for ShieldedShieldFromCore { .parse::>() - .map_err(|e| McpToolError::InvalidParam { - message: format!("Invalid source Core address: {e}"), + .map_err(|_| McpToolError::InvalidParam { + message: "The source Core address is invalid.".to_owned(), })? .require_network(ctx.network()) - .map_err(|e| McpToolError::InvalidParam { - message: format!("Source address does not match active network: {e}"), + .map_err(|_| McpToolError::InvalidParam { + message: "The source Core address does not match the active network." + .to_owned(), }) }) .transpose()?; @@ -519,12 +520,12 @@ impl AsyncTool for ShieldedWithdrawTool { .parse::>() - .map_err(|e| McpToolError::InvalidParam { - message: format!("Invalid Core address: {e}"), + .map_err(|_| McpToolError::InvalidParam { + message: "The Core address is invalid.".to_owned(), })? .require_network(ctx.network()) - .map_err(|e| McpToolError::InvalidParam { - message: format!("Core address does not match active network: {e}"), + .map_err(|_| McpToolError::InvalidParam { + message: "The Core address does not match the active network.".to_owned(), })?; let task = BackendTask::ShieldedTask(ShieldedTask::ShieldedWithdrawal { diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 87d0304fc..a21f904dc 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -1553,10 +1553,13 @@ impl WalletSendScreen { // Allocate amount across addresses (highest balance first), reserving // per-operation fee headroom so each address can cover its own shield fee. - let per_op_fee = crate::model::fee_estimation::shielded_fee_for_actions( + // Apply the network fee multiplier for consistency with ShieldScreen. + let base_fee = crate::model::fee_estimation::shielded_fee_for_actions( 2, dash_sdk::dpp::version::PlatformVersion::latest(), ); + let multiplier = self.app_context.fee_multiplier_permille().max(1000); + let per_op_fee = base_fee.saturating_mul(multiplier) / 1000; let mut remaining = amount_credits; let mut tasks: Vec = Vec::new(); for (platform_addr, _, balance) in &sorted_addrs { @@ -1579,6 +1582,22 @@ impl WalletSendScreen { remaining -= spend; } + // Reject if allocation could not cover the full amount after fee deductions + if tasks.is_empty() { + return Err( + "Insufficient platform balance after fees. No address has enough to cover the shield operation fee." + .to_string(), + ); + } + if remaining > 0 { + let max_sendable = amount_credits.saturating_sub(remaining); + return Err(format!( + "Insufficient platform balance after fees. Need {} but only {} is available after estimated shield fees.", + format_credits_as_dash(amount_credits), + format_credits_as_dash(max_sendable), + )); + } + self.mark_sending(); if tasks.len() == 1 { Ok(AppAction::BackendTask(tasks.into_iter().next().unwrap())) From 47b9fc18d8c52a4ddf93f51f718b6bcd2acd136a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:32:36 +0200 Subject: [PATCH 43/44] fix(shielded): wait for block confirmation after broadcast before returning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shielded operations (transfer, unshield, shield, withdrawal) previously returned immediately after broadcast without waiting for block confirmation. This caused the subsequent SyncNotes (triggered by the Send screen) to find no updates because the state transition wasn't yet in a block. Now each operation calls wait_for_response() after broadcast to ensure the state transition is confirmed before the UI triggers a note resync. The wait is best-effort — broadcast success is still the primary success indicator. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/shielded/bundle.rs | 60 ++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/src/backend_task/shielded/bundle.rs b/src/backend_task/shielded/bundle.rs index 05b0dc1a0..a0fffbe71 100644 --- a/src/backend_task/shielded/bundle.rs +++ b/src/backend_task/shielded/bundle.rs @@ -13,6 +13,7 @@ use dash_sdk::dpp::shielded::builder::{ OrchardProver, SpendableNote, build_shield_transition, build_shielded_transfer_transition, build_shielded_withdrawal_transition, build_unshield_transition, }; +use dash_sdk::dpp::state_transition::proof_result::StateTransitionProofResult; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::dpp::withdrawal::Pooling; use dash_sdk::grovedb_commitment_tree::{ @@ -221,8 +222,16 @@ pub async fn shield_credits( .await .map_err(shielded_broadcast_error)?; + state_transition + .wait_for_response::(&sdk, None) + .await + .map_err(|e| { + tracing::warn!("Shield credits broadcast succeeded but confirmation wait failed: {e}"); + }) + .ok(); + tracing::info!( - "Shield credits broadcast succeeded: {} — balance will update after the next block is mined and notes are synced", + "Shield credits broadcast succeeded: {}", format_credits_as_dash(amount), ); @@ -302,8 +311,18 @@ pub async fn shielded_transfer( .await .map_err(shielded_broadcast_error)?; + state_transition + .wait_for_response::(&sdk, None) + .await + .map_err(|e| { + tracing::warn!( + "Shielded transfer broadcast succeeded but confirmation wait failed: {e}" + ); + }) + .ok(); + tracing::info!( - "Shielded transfer broadcast succeeded: {} nullifiers created, change={} — balance will update after the next block is mined and notes are synced", + "Shielded transfer broadcast succeeded: {} nullifiers created, change={}", spent_nullifiers.len(), change_amount > 0, ); @@ -378,8 +397,18 @@ pub async fn unshield_credits( .await .map_err(shielded_broadcast_error)?; + state_transition + .wait_for_response::(&sdk, None) + .await + .map_err(|e| { + tracing::warn!( + "Unshield credits broadcast succeeded but confirmation wait failed: {e}" + ); + }) + .ok(); + tracing::info!( - "Unshield credits broadcast succeeded: {} nullifiers created, change={} — balance will update after the next block is mined and notes are synced", + "Unshield credits broadcast succeeded: {} nullifiers created, change={}", spent_nullifiers.len(), change_amount > 0, ); @@ -404,7 +433,6 @@ pub async fn shield_from_asset_lock( use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use dash_sdk::dpp::prelude::AssetLockProof; use dash_sdk::dpp::shielded::builder::build_shield_from_asset_lock_transition; - use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use std::time::Duration; let proving_key = crate::context::shielded::get_proving_key(); @@ -584,8 +612,18 @@ pub async fn shield_from_asset_lock( .await .map_err(shielded_broadcast_error)?; + state_transition + .wait_for_response::(&sdk, None) + .await + .map_err(|e| { + tracing::warn!( + "Shield from asset lock broadcast succeeded but confirmation wait failed: {e}" + ); + }) + .ok(); + tracing::info!( - "Shield from asset lock broadcast succeeded: {} — balance will update after the next block is mined and notes are synced", + "Shield from asset lock broadcast succeeded: {}", format_credits_as_dash(shield_amount_credits), ); @@ -663,8 +701,18 @@ pub async fn shielded_withdrawal( .await .map_err(shielded_broadcast_error)?; + state_transition + .wait_for_response::(&sdk, None) + .await + .map_err(|e| { + tracing::warn!( + "Shielded withdrawal broadcast succeeded but confirmation wait failed: {e}" + ); + }) + .ok(); + tracing::info!( - "Shielded withdrawal broadcast succeeded: {} nullifiers created, change={} — balance will update after the next block is mined and notes are synced", + "Shielded withdrawal broadcast succeeded: {} nullifiers created, change={}", spent_nullifiers.len(), change_amount > 0, ); From ed08be789b4394e5bde777067cca8a6cce8285e6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:51:26 +0200 Subject: [PATCH 44/44] fix(review): lock ordering in shield_screen, theme colors in unshield_screen - Fix potential deadlock in refresh_cached_balances: clone wallet Arc and drop wallets map lock before acquiring per-wallet read lock - Replace hardcoded Color32::DARK_GREEN and Color32::from_rgb(255,100,100) with DashColors::success_color/error_color in unshield_credits_screen Co-Authored-By: Claude Opus 4.6 --- src/ui/wallets/shield_screen.rs | 19 ++++++++++++++----- src/ui/wallets/unshield_credits_screen.rs | 11 +++++++---- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/ui/wallets/shield_screen.rs b/src/ui/wallets/shield_screen.rs index 408b86c42..452bde06b 100644 --- a/src/ui/wallets/shield_screen.rs +++ b/src/ui/wallets/shield_screen.rs @@ -155,11 +155,20 @@ impl ShieldScreen { /// Refresh cached wallet data (balance, nonce) from the RwLock-protected wallet. fn refresh_cached_balances(&mut self) { - let wallets = self.app_context.wallets.read().ok(); - let wallet_guard = wallets - .as_ref() - .and_then(|w| w.get(&self.seed_hash)) - .and_then(|arc| arc.read().ok()); + // Clone the wallet Arc while holding the wallets map read lock, then + // drop the map lock before acquiring the per-wallet lock to avoid + // lock-order deadlocks with code that holds a wallet lock and needs + // wallets write access. + let wallet_arc = self + .app_context + .wallets + .read() + .ok() + .and_then(|w| w.get(&self.seed_hash).cloned()); + let Some(wallet_arc) = wallet_arc else { + return; + }; + let wallet_guard = wallet_arc.read().ok(); if let Some(wallet) = &wallet_guard { // Platform nonce and balance for selected address diff --git a/src/ui/wallets/unshield_credits_screen.rs b/src/ui/wallets/unshield_credits_screen.rs index c3ad1c2ee..baf766eda 100644 --- a/src/ui/wallets/unshield_credits_screen.rs +++ b/src/ui/wallets/unshield_credits_screen.rs @@ -12,6 +12,7 @@ use crate::ui::components::component_trait::Component; 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 dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use eframe::egui::{self, Context}; @@ -121,13 +122,15 @@ impl ScreenLike for UnshieldCreditsScreen { )); ui.add_space(15.0); + let dark_mode = ui.ctx().style().visuals.dark_mode; + // Error/success messages if let Some(err) = &self.error_message { - ui.colored_label(Color32::from_rgb(255, 100, 100), err); + ui.colored_label(DashColors::error_color(dark_mode), err); ui.add_space(5.0); } if let Some(msg) = &self.success_message { - ui.colored_label(Color32::DARK_GREEN, msg); + ui.colored_label(DashColors::success_color(dark_mode), msg); if self.balance_update_pending { ui.add_space(8.0); ui.label( @@ -164,13 +167,13 @@ impl ScreenLike for UnshieldCreditsScreen { match self.validated_destination.as_ref().map(|v| v.kind()) { Some(AddressKind::Platform) => { ui.colored_label( - Color32::DARK_GREEN, + DashColors::success_color(dark_mode), "Platform address — credits will be moved to this platform address", ); } Some(AddressKind::Core) => { ui.colored_label( - Color32::DARK_GREEN, + DashColors::success_color(dark_mode), "Core address — credits will be withdrawn as DASH to this address", ); }