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/27] 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/27] 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/27] 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/27] 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/27] 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 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 06/27] 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 07/27] 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 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 08/27] 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 09/27] 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 10/27] 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 11/27] 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 12/27] 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 13/27] 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 14/27] 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 15/27] 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 16/27] 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 17/27] 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 18/27] 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 19/27] =?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 20/27] 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 21/27] 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 22/27] 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 23/27] 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 24/27] 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 25/27] 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 26/27] 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 27/27] 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) => {