diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index f9da86e3e..b085ff4d7 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -18,9 +18,17 @@ use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; +use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::Credits; use dash_sdk::dpp::identity::core_script::CoreScript; +use dash_sdk::dpp::prelude::AddressNonce; +use dash_sdk::dpp::state_transition::StateTransitionEstimatedFeeValidation; +use dash_sdk::dpp::state_transition::address_credit_withdrawal_transition::AddressCreditWithdrawalTransition; +use dash_sdk::dpp::state_transition::address_credit_withdrawal_transition::v0::AddressCreditWithdrawalTransitionV0; +use dash_sdk::dpp::state_transition::address_funds_transfer_transition::AddressFundsTransferTransition; +use dash_sdk::dpp::state_transition::address_funds_transfer_transition::v0::AddressFundsTransferTransitionV0; +use dash_sdk::dpp::withdrawal::Pooling; use eframe::egui::{self, Context, RichText, Ui}; use egui::{Color32, Frame, Margin}; use std::collections::BTreeMap; @@ -38,6 +46,8 @@ const ESTIMATED_BYTES_PER_INPUT: usize = 225; /// Calculate the estimated fee for a platform address funds transfer. /// /// Uses PlatformFeeEstimator for base costs (input/output fees) plus storage fees. +/// +#[deprecated(note = "Use state-transition-based fee estimation instead")] fn estimate_platform_fee(estimator: &PlatformFeeEstimator, input_count: usize) -> u64 { let inputs = input_count.max(1); @@ -56,6 +66,60 @@ fn estimate_platform_fee(estimator: &PlatformFeeEstimator, input_count: usize) - total.saturating_add(total / 5) } +/// Calculate the estimated fee for a Platform address withdrawal using a constructed state transition. +fn estimate_fee_platform_to_core( + platform_version: &dash_sdk::dpp::version::PlatformVersion, + inputs: &BTreeMap, + output_script: &CoreScript, +) -> Result { + let inputs_with_nonce: BTreeMap = inputs + .iter() + .map(|(addr, amount)| (*addr, (0, *amount))) + .collect(); + + let transition = AddressCreditWithdrawalTransition::V0(AddressCreditWithdrawalTransitionV0 { + inputs: inputs_with_nonce, + output: None, + fee_strategy: vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + core_fee_per_byte: 1, + pooling: Pooling::Never, + output_script: output_script.clone(), + user_fee_increase: 0, + input_witnesses: Vec::new(), + }); + + transition + .calculate_min_required_fee(platform_version) + .map_err(|e| format!("Platform->Core fee estimation failed: {}", e)) +} + +/// Calculate the estimated fee for a Platform address transfer using a constructed state transition. +fn estimate_fee_platform_to_platform( + platform_version: &dash_sdk::dpp::version::PlatformVersion, + inputs: &BTreeMap, + destination: &PlatformAddress, +) -> Result { + let inputs_with_nonce: BTreeMap = inputs + .iter() + .map(|(addr, amount)| (*addr, (0, *amount))) + .collect(); + + let mut outputs = BTreeMap::new(); + outputs.insert(*destination, 1); + + let transition = AddressFundsTransferTransition::V0(AddressFundsTransferTransitionV0 { + inputs: inputs_with_nonce, + outputs, + fee_strategy: vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + user_fee_increase: 0, + input_witnesses: Vec::new(), + }); + + transition + .calculate_min_required_fee(platform_version) + .map_err(|e| format!("Platform->Platform fee estimation failed: {}", e)) +} + /// Result of allocating platform addresses for a transfer. #[derive(Debug, Clone)] struct AddressAllocationResult { @@ -71,6 +135,109 @@ struct AddressAllocationResult { sorted_addresses: Vec<(PlatformAddress, Address, u64)>, } +/// Allocates platform addresses for a transfer, using a custom fee calculator. +fn allocate_platform_addresses_with_fee( + addresses: &[(PlatformAddress, Address, u64)], + amount_credits: u64, + destination: Option<&PlatformAddress>, + fee_for_inputs: F, +) -> Result +where + F: Fn(&BTreeMap) -> Result, +{ + // Filter out the destination address if provided (protocol doesn't allow same address as input and output) + let filtered: Vec<_> = addresses + .iter() + .filter(|(platform_addr, _, _)| destination != Some(platform_addr)) + .cloned() + .collect(); + + // Sort addresses by balance descending so the largest balance is used first + let mut sorted_addresses = filtered; + sorted_addresses.sort_by(|a, b| b.2.cmp(&a.2)); + + // Early return if no addresses available after filtering + if sorted_addresses.is_empty() { + return Ok(AddressAllocationResult { + inputs: BTreeMap::new(), + fee_payer_index: 0, + estimated_fee: fee_for_inputs(&BTreeMap::new())?, + shortfall: amount_credits, + sorted_addresses: vec![], + }); + } + + // The highest-balance address (first in sorted order) will pay the fee + let fee_payer_addr = sorted_addresses.first().map(|(addr, _, _)| *addr); + + let mut estimated_fee = fee_for_inputs(&BTreeMap::new())?; + let mut inputs: BTreeMap = BTreeMap::new(); + + // Iterate until fee estimate stabilizes (input count affects fee) + for _ in 0..=MAX_PLATFORM_INPUTS { + inputs.clear(); + let mut remaining = amount_credits; + + for (idx, (platform_addr, _, balance)) in sorted_addresses.iter().enumerate() { + if remaining == 0 || inputs.len() >= MAX_PLATFORM_INPUTS { + break; + } + let is_fee_payer = idx == 0; + let available = if is_fee_payer { + balance.saturating_sub(estimated_fee) + } else { + *balance + }; + let use_amount = remaining.min(available); + if use_amount > 0 || is_fee_payer { + inputs.insert(*platform_addr, use_amount); + remaining = remaining.saturating_sub(use_amount); + } + } + + let new_fee = fee_for_inputs(&inputs)?; + if new_fee == estimated_fee { + break; + } + estimated_fee = new_fee; + } + + // Calculate shortfall (amount we couldn't allocate) + let total_allocated: u64 = inputs.values().sum(); + let allocation_shortfall = amount_credits.saturating_sub(total_allocated); + + // Check if fee payer can actually afford the fee from their remaining balance. + let fee_deficit = if let Some(fee_payer) = fee_payer_addr { + let fee_payer_balance = sorted_addresses.first().map(|(_, _, b)| *b).unwrap_or(0); + let fee_payer_contribution = inputs.get(&fee_payer).copied().unwrap_or(0); + let fee_payer_remaining = fee_payer_balance.saturating_sub(fee_payer_contribution); + estimated_fee.saturating_sub(fee_payer_remaining) + } else { + estimated_fee + }; + + let shortfall = allocation_shortfall.saturating_add(fee_deficit); + + // Find the index of the fee payer in BTreeMap order (required by backend) + let fee_payer_index = fee_payer_addr + .and_then(|payer| { + inputs + .keys() + .enumerate() + .find(|(_, addr)| **addr == payer) + .map(|(idx, _)| idx as u16) + }) + .unwrap_or(0); + + Ok(AddressAllocationResult { + inputs, + fee_payer_index, + estimated_fee, + shortfall, + sorted_addresses, + }) +} + /// Allocates platform addresses for a transfer, selecting which addresses to use /// and how much from each. /// @@ -340,6 +507,64 @@ impl WalletSendScreen { } } + fn estimate_max_fee_for_platform_send( + &self, + fee_estimator: &PlatformFeeEstimator, + addresses: &[(PlatformAddress, Address, u64)], + destination: Option<&PlatformAddress>, + ) -> Result { + let mut sorted_addresses: Vec<_> = addresses + .iter() + .filter(|(addr, _, _)| destination != Some(addr)) + .cloned() + .collect(); + sorted_addresses.sort_by(|a, b| b.2.cmp(&a.2)); + + let usable_count = sorted_addresses.len().min(MAX_PLATFORM_INPUTS); + if usable_count == 0 { + return Ok(estimate_platform_fee(fee_estimator, 1)); + } + + let dest_type = self.detect_address_type(&self.destination_address); + if dest_type == AddressType::Core { + let output_script = self + .destination_address + .trim() + .parse::>() + .ok() + .and_then(|addr| addr.require_network(self.app_context.network).ok()) + .map(|addr| CoreScript::new(addr.script_pubkey())); + if let Some(output_script) = output_script { + let max_fee_inputs: BTreeMap = sorted_addresses + .iter() + .take(usable_count) + .map(|(addr, _, _)| (*addr, 0)) + .collect(); + return estimate_fee_platform_to_core( + self.app_context.platform_version(), + &max_fee_inputs, + &output_script, + ); + } + } else if dest_type == AddressType::Platform { + if let Some(destination) = destination { + let max_fee_inputs: BTreeMap = sorted_addresses + .iter() + .take(usable_count) + .map(|(addr, _, _)| (*addr, 0)) + .collect(); + return estimate_fee_platform_to_platform( + self.app_context.platform_version(), + &max_fee_inputs, + destination, + ); + } + } + + // Fallback to legacy fee estimation + Ok(estimate_platform_fee(fee_estimator, usable_count)) + } + fn reset_form(&mut self) { self.destination_address.clear(); self.amount = None; @@ -684,13 +909,16 @@ impl WalletSendScreen { .map(|(addr, _)| addr) .map_err(|e| format!("Invalid platform address: {}", e))?; - // Allocate addresses using the helper function - let allocation = allocate_platform_addresses( - &fee_estimator, + let platform_version = self.app_context.platform_version(); + + // Allocate addresses using state-transition-based fee estimation + let allocation = allocate_platform_addresses_with_fee( &addresses, amount_credits, Some(&destination), - ); + |inputs| estimate_fee_platform_to_platform(platform_version, inputs, &destination), + ) + .map_err(|e| format!("Fee estimation failed: {}", e))?; if allocation.sorted_addresses.is_empty() { return Err( @@ -718,7 +946,13 @@ impl WalletSendScreen { .take(MAX_PLATFORM_INPUTS) .map(|(_, _, b)| *b) .sum(); - let max_fee = estimate_platform_fee(&fee_estimator, addresses_available); + let max_fee = self + .estimate_max_fee_for_platform_send( + &fee_estimator, + &allocation.sorted_addresses, + Some(&destination), + ) + .map_err(|e| format!("Fee estimation failed: {}", e))?; let max_sendable = max_balance.saturating_sub(max_fee); return Err(format!( @@ -786,9 +1020,6 @@ impl WalletSendScreen { return Err("Amount must be greater than 0".to_string()); } - // Get fee estimator with current network multiplier - let fee_estimator = self.app_context.fee_estimator(); - // Calculate total balance across all platform addresses let total_balance: u64 = addresses.iter().map(|(_, _, balance)| *balance).sum(); @@ -818,9 +1049,14 @@ impl WalletSendScreen { let output_script = CoreScript::new(dest_address.script_pubkey()); - // Allocate addresses using the helper function (no destination filter for withdrawals) + let platform_version = self.app_context.platform_version(); + + // Allocate addresses using state-transition-based fee estimation (no destination filter) let allocation = - allocate_platform_addresses(&fee_estimator, &addresses, amount_credits, None); + allocate_platform_addresses_with_fee(&addresses, amount_credits, None, |inputs| { + estimate_fee_platform_to_core(platform_version, inputs, &output_script) + }) + .map_err(|e| format!("Fee estimation failed: {}", e))?; if allocation.shortfall > 0 { // Calculate the max we can send with MAX_PLATFORM_INPUTS addresses (minus fees) @@ -831,7 +1067,15 @@ impl WalletSendScreen { .take(MAX_PLATFORM_INPUTS) .map(|(_, _, b)| *b) .sum(); - let max_fee = estimate_platform_fee(&fee_estimator, addresses_available); + let max_fee_inputs: BTreeMap = allocation + .sorted_addresses + .iter() + .take(addresses_available) + .map(|(addr, _, _)| (*addr, 0)) + .collect(); + let max_fee = + estimate_fee_platform_to_core(platform_version, &max_fee_inputs, &output_script) + .map_err(|e| format!("Fee estimation failed: {}", e))?; let max_sendable = max_balance.saturating_sub(max_fee); return Err(format!( @@ -1186,18 +1430,47 @@ impl WalletSendScreen { .take(MAX_PLATFORM_INPUTS) .map(|(_, _, balance)| *balance) .sum(); - let max_fee = estimate_platform_fee(&fee_estimator, usable_count); + let (max_fee, fee_error) = match self.estimate_max_fee_for_platform_send( + &fee_estimator, + &sorted_addresses, + destination.as_ref(), + ) { + Ok(fee) => (fee, None), + Err(e) => { + tracing::warn!("Fee estimation failed for Max calculation: {}", e); + (estimate_platform_fee(&fee_estimator, usable_count), Some(e)) + } + }; // Build hint explaining the limit let hint = if sorted_addresses.len() > MAX_PLATFORM_INPUTS { format!( - "Limited to {} input addresses per transaction, ~{} reserved for fees", + "Limited to {} input addresses per transaction, ~{} reserved for fees{}", MAX_PLATFORM_INPUTS, - Self::format_credits(max_fee) + Self::format_credits(max_fee), + if fee_error.is_some() { + " (fallback estimate)" + } else { + "" + } ) } else { - format!("~{} reserved for fees", Self::format_credits(max_fee)) + format!( + "~{} reserved for fees{}", + Self::format_credits(max_fee), + if fee_error.is_some() { + " (fallback estimate)" + } else { + "" + } + ) }; + tracing::debug!( + "Max amount calculation: total balance {} minus estimated fee {} = max {}", + Self::format_credits(total), + Self::format_credits(max_fee), + Self::format_credits(total.saturating_sub(max_fee)) + ); (Some(total.saturating_sub(max_fee)), Some(hint)) } None => (None, None),