diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index ccf9063d3..909b8dfee 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -18,9 +18,18 @@ use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked; +use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::{CREDITS_PER_DUFF, Credits}; use dash_sdk::dpp::identity::core_script::CoreScript; +use dash_sdk::dpp::prelude::AddressNonce; +use dash_sdk::dpp::prelude::AssetLockProof; +use dash_sdk::dpp::state_transition::StateTransitionEstimatedFeeValidation; +use dash_sdk::dpp::state_transition::address_credit_withdrawal_transition::AddressCreditWithdrawalTransition; +use dash_sdk::dpp::state_transition::address_credit_withdrawal_transition::v0::AddressCreditWithdrawalTransitionV0; +use dash_sdk::dpp::state_transition::address_funding_from_asset_lock_transition::AddressFundingFromAssetLockTransition; +use dash_sdk::dpp::state_transition::address_funding_from_asset_lock_transition::v0::AddressFundingFromAssetLockTransitionV0; +use dash_sdk::dpp::withdrawal::Pooling; use eframe::egui::{self, Context, RichText, Ui}; use egui::{Color32, Frame, Margin}; use std::collections::BTreeMap; @@ -56,6 +65,56 @@ fn estimate_platform_fee(estimator: &PlatformFeeEstimator, input_count: usize) - total.saturating_add(total / 5) } +/// Calculate the estimated fee for a Platform address withdrawal using a constructed state transition. +fn estimate_withdrawal_fee_from_transition( + platform_version: &dash_sdk::dpp::version::PlatformVersion, + inputs: &BTreeMap, + output_script: &CoreScript, +) -> u64 { + let inputs_with_nonce: BTreeMap = inputs + .iter() + .map(|(addr, amount)| (*addr, (0, *amount))) + .collect(); + + let transition = AddressCreditWithdrawalTransition::V0(AddressCreditWithdrawalTransitionV0 { + inputs: inputs_with_nonce, + output: None, + fee_strategy: vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + core_fee_per_byte: 1, + pooling: Pooling::Never, + output_script: output_script.clone(), + user_fee_increase: 0, + input_witnesses: Vec::new(), + }); + + transition + .calculate_min_required_fee(platform_version) + .unwrap_or(0) +} + +/// Calculate the estimated fee for funding a Platform address from an asset lock. +fn estimate_address_funding_fee_from_transition( + platform_version: &dash_sdk::dpp::version::PlatformVersion, + destination: &PlatformAddress, +) -> u64 { + let mut outputs = BTreeMap::new(); + outputs.insert(*destination, None); + + let transition = + AddressFundingFromAssetLockTransition::V0(AddressFundingFromAssetLockTransitionV0 { + asset_lock_proof: AssetLockProof::default(), + inputs: BTreeMap::new(), + outputs, + fee_strategy: vec![AddressFundsFeeStrategyStep::ReduceOutput(0)], + user_fee_increase: 0, + ..Default::default() + }); + + transition + .calculate_min_required_fee(platform_version) + .unwrap_or(0) +} + /// Result of allocating platform addresses for a transfer. #[derive(Debug, Clone)] struct AddressAllocationResult { @@ -71,23 +130,16 @@ struct AddressAllocationResult { sorted_addresses: Vec<(PlatformAddress, Address, u64)>, } -/// Allocates platform addresses for a transfer, selecting which addresses to use -/// and how much from each. -/// -/// Algorithm: -/// 1. Filters out the destination address (can't be both input and output) -/// 2. Sorts addresses by balance descending (largest first) -/// 3. The highest-balance address pays the fee -/// 4. Iteratively allocates until fee estimate converges -/// 5. Fee payer is always included in inputs (even with 0 contribution) so fee can be deducted -/// -/// Returns the allocation result with inputs, fee payer index, and any shortfall. -fn allocate_platform_addresses( - estimator: &PlatformFeeEstimator, +/// Allocates platform addresses for a transfer, using a custom fee calculator. +fn allocate_platform_addresses_with_fee( addresses: &[(PlatformAddress, Address, u64)], amount_credits: u64, destination: Option<&PlatformAddress>, -) -> AddressAllocationResult { + fee_for_inputs: F, +) -> AddressAllocationResult +where + F: Fn(&BTreeMap) -> u64, +{ // Filter out the destination address if provided (protocol doesn't allow same address as input and output) let filtered: Vec<_> = addresses .iter() @@ -104,7 +156,7 @@ fn allocate_platform_addresses( return AddressAllocationResult { inputs: BTreeMap::new(), fee_payer_index: 0, - estimated_fee: estimate_platform_fee(estimator, 1), + estimated_fee: fee_for_inputs(&BTreeMap::new()), shortfall: amount_credits, sorted_addresses: vec![], }; @@ -113,33 +165,36 @@ fn allocate_platform_addresses( // The highest-balance address (first in sorted order) will pay the fee let fee_payer_addr = sorted_addresses.first().map(|(addr, _, _)| *addr); - // Calculate fee based on expected number of inputs (use worst-case for safety) - // This matches what the Max button calculation uses - let max_inputs = sorted_addresses.len().min(MAX_PLATFORM_INPUTS); - let estimated_fee = estimate_platform_fee(estimator, max_inputs.max(1)); + let mut estimated_fee = fee_for_inputs(&BTreeMap::new()); + let mut inputs: BTreeMap = BTreeMap::new(); - // Allocate inputs = outputs (protocol requires equality). - // Fee payer must reserve enough remaining balance to pay the fee separately. - let mut inputs = BTreeMap::new(); - let mut remaining = amount_credits; + // Iterate until fee estimate stabilizes (input count affects fee) + for _ in 0..=MAX_PLATFORM_INPUTS { + inputs.clear(); + let mut remaining = amount_credits; - for (idx, (platform_addr, _, balance)) in sorted_addresses.iter().enumerate() { - if remaining == 0 || inputs.len() >= MAX_PLATFORM_INPUTS { - break; + for (idx, (platform_addr, _, balance)) in sorted_addresses.iter().enumerate() { + if remaining == 0 || inputs.len() >= MAX_PLATFORM_INPUTS { + break; + } + let is_fee_payer = idx == 0; + let available = if is_fee_payer { + balance.saturating_sub(estimated_fee) + } else { + *balance + }; + let use_amount = remaining.min(available); + if use_amount > 0 || is_fee_payer { + inputs.insert(*platform_addr, use_amount); + remaining = remaining.saturating_sub(use_amount); + } } - // Fee payer (idx 0, highest balance) must keep fee in reserve - let is_fee_payer = idx == 0; - let available = if is_fee_payer { - balance.saturating_sub(estimated_fee) - } else { - *balance - }; - let use_amount = remaining.min(available); - // Fee payer must always be in inputs so fee can be deducted from their balance - if use_amount > 0 || is_fee_payer { - inputs.insert(*platform_addr, use_amount); - remaining = remaining.saturating_sub(use_amount); + + let new_fee = fee_for_inputs(&inputs); + if new_fee == estimated_fee { + break; } + estimated_fee = new_fee; } // Calculate shortfall (amount we couldn't allocate) @@ -147,15 +202,13 @@ fn allocate_platform_addresses( let allocation_shortfall = amount_credits.saturating_sub(total_allocated); // Check if fee payer can actually afford the fee from their remaining balance. - // Fee payer's remaining balance = their original balance - their contribution. - // If remaining < estimated_fee, we have a fee deficit. let fee_deficit = if let Some(fee_payer) = fee_payer_addr { let fee_payer_balance = sorted_addresses.first().map(|(_, _, b)| *b).unwrap_or(0); let fee_payer_contribution = inputs.get(&fee_payer).copied().unwrap_or(0); let fee_payer_remaining = fee_payer_balance.saturating_sub(fee_payer_contribution); estimated_fee.saturating_sub(fee_payer_remaining) } else { - estimated_fee // No fee payer means we can't pay the fee at all + estimated_fee }; let shortfall = allocation_shortfall.saturating_add(fee_deficit); @@ -180,6 +233,35 @@ fn allocate_platform_addresses( } } +/// Allocates platform addresses for a transfer, selecting which addresses to use +/// and how much from each. +/// +/// Algorithm: +/// 1. Filters out the destination address (can't be both input and output) +/// 2. Sorts addresses by balance descending (largest first) +/// 3. The highest-balance address pays the fee +/// 4. Iteratively allocates until fee estimate converges +/// 5. Fee payer is always included in inputs (even with 0 contribution) so fee can be deducted +/// +/// Returns the allocation result with inputs, fee payer index, and any shortfall. +fn allocate_platform_addresses( + estimator: &PlatformFeeEstimator, + addresses: &[(PlatformAddress, Address, u64)], + amount_credits: u64, + destination: Option<&PlatformAddress>, +) -> AddressAllocationResult { + let max_inputs = addresses + .iter() + .filter(|(platform_addr, _, _)| destination != Some(platform_addr)) + .count() + .min(MAX_PLATFORM_INPUTS); + + allocate_platform_addresses_with_fee(addresses, amount_credits, destination, |_| { + // Keep the legacy behavior: use a worst-case fee based on max possible inputs. + estimate_platform_fee(estimator, max_inputs.max(1)) + }) +} + /// Detected address type #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AddressType { @@ -340,6 +422,50 @@ impl WalletSendScreen { } } + fn estimate_max_fee_for_platform_send( + &self, + fee_estimator: &PlatformFeeEstimator, + addresses: &[(PlatformAddress, Address, u64)], + destination: Option<&PlatformAddress>, + ) -> u64 { + let mut sorted_addresses: Vec<_> = addresses + .iter() + .filter(|(addr, _, _)| destination != Some(addr)) + .cloned() + .collect(); + sorted_addresses.sort_by(|a, b| b.2.cmp(&a.2)); + + let usable_count = sorted_addresses.len().min(MAX_PLATFORM_INPUTS); + if usable_count == 0 { + return estimate_platform_fee(fee_estimator, 1); + } + + let dest_type = self.detect_address_type(&self.destination_address); + if dest_type == AddressType::Core { + let output_script = self + .destination_address + .trim() + .parse::>() + .ok() + .and_then(|addr| addr.require_network(self.app_context.network).ok()) + .map(|addr| CoreScript::new(addr.script_pubkey())); + if let Some(output_script) = output_script { + let max_fee_inputs: BTreeMap = sorted_addresses + .iter() + .take(usable_count) + .map(|(addr, _, _)| (*addr, 0)) + .collect(); + return estimate_withdrawal_fee_from_transition( + self.app_context.platform_version(), + &max_fee_inputs, + &output_script, + ); + } + } + + estimate_platform_fee(fee_estimator, usable_count) + } + fn reset_form(&mut self) { self.destination_address.clear(); self.amount = None; @@ -635,8 +761,14 @@ impl WalletSendScreen { return Err("Amount must be greater than 0".to_string()); } - // Check balance (include fee for asset lock) - let required = amount_duffs.saturating_add(3000); + // Parse platform address + let address_str = self.destination_address.trim(); + let destination = PlatformAddress::from_bech32m_string(address_str) + .map(|(addr, _)| addr) + .map_err(|e| format!("Invalid platform address: {}", e))?; + + // Check balance; fees will be subtracted from amount + let required = amount_duffs; let balance = self.get_core_balance(); if required > balance { return Err(format!( @@ -646,12 +778,6 @@ impl WalletSendScreen { )); } - // Parse platform address - let address_str = self.destination_address.trim(); - let destination = PlatformAddress::from_bech32m_string(address_str) - .map(|(addr, _)| addr) - .map_err(|e| format!("Invalid platform address: {}", e))?; - let now = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("Time went backwards") @@ -745,7 +871,11 @@ impl WalletSendScreen { .take(MAX_PLATFORM_INPUTS) .map(|(_, _, b)| *b) .sum(); - let max_fee = estimate_platform_fee(&fee_estimator, addresses_available); + let max_fee = self.estimate_max_fee_for_platform_send( + &fee_estimator, + &allocation.sorted_addresses, + Some(&destination), + ); let max_sendable = max_balance.saturating_sub(max_fee); return Err(format!( @@ -813,9 +943,6 @@ impl WalletSendScreen { return Err("Amount must be greater than 0".to_string()); } - // Get fee estimator with current network multiplier - let fee_estimator = self.app_context.fee_estimator(); - // Calculate total balance across all platform addresses let total_balance: u64 = addresses.iter().map(|(_, _, balance)| *balance).sum(); @@ -845,9 +972,13 @@ impl WalletSendScreen { let output_script = CoreScript::new(dest_address.script_pubkey()); - // Allocate addresses using the helper function (no destination filter for withdrawals) + let platform_version = self.app_context.platform_version(); + + // Allocate addresses using state-transition-based fee estimation (no destination filter) let allocation = - allocate_platform_addresses(&fee_estimator, &addresses, amount_credits, None); + allocate_platform_addresses_with_fee(&addresses, amount_credits, None, |inputs| { + estimate_withdrawal_fee_from_transition(platform_version, inputs, &output_script) + }); if allocation.shortfall > 0 { // Calculate the max we can send with MAX_PLATFORM_INPUTS addresses (minus fees) @@ -858,7 +989,17 @@ impl WalletSendScreen { .take(MAX_PLATFORM_INPUTS) .map(|(_, _, b)| *b) .sum(); - let max_fee = estimate_platform_fee(&fee_estimator, addresses_available); + let max_fee_inputs: BTreeMap = allocation + .sorted_addresses + .iter() + .take(addresses_available) + .map(|(addr, _, _)| (*addr, 0)) + .collect(); + let max_fee = estimate_withdrawal_fee_from_transition( + platform_version, + &max_fee_inputs, + &output_script, + ); let max_sendable = max_balance.saturating_sub(max_fee); return Err(format!( @@ -1189,8 +1330,29 @@ impl WalletSendScreen { .ok() .map(|wallet| wallet.total_balance_duffs() * CREDITS_PER_DUFF) // duffs to credits }); - - (max, None) + let dest_type = self.detect_address_type(&self.destination_address); + let hint = if dest_type == AddressType::Platform { + let destination = + PlatformAddress::from_bech32m_string(self.destination_address.trim()) + .map(|(addr, _)| addr) + .ok(); + if let Some(destination) = destination { + let estimated_fee = estimate_address_funding_fee_from_transition( + self.app_context.platform_version(), + &destination, + ); + // max = max.map(|amount| amount.saturating_sub(estimated_fee)); + Some(format!( + "Estimated platform fee ~{} (deducted from amount)", + Self::format_credits(estimated_fee) + )) + } else { + None + } + } else { + None + }; + (max, hint) } Some(SourceSelection::PlatformAddresses(addresses)) => { // Parse destination to exclude it from max calculation (can't send to yourself) @@ -1208,13 +1370,16 @@ impl WalletSendScreen { sorted_addresses.sort_by(|a, b| b.2.cmp(&a.2)); // Sum balances from top addresses, limited by MAX_PLATFORM_INPUTS. - let usable_count = sorted_addresses.len().min(MAX_PLATFORM_INPUTS); let total: u64 = sorted_addresses .iter() .take(MAX_PLATFORM_INPUTS) .map(|(_, _, balance)| *balance) .sum(); - let max_fee = estimate_platform_fee(&fee_estimator, usable_count); + let max_fee = self.estimate_max_fee_for_platform_send( + &fee_estimator, + &sorted_addresses, + destination.as_ref(), + ); // Build hint explaining the limit let hint = if sorted_addresses.len() > MAX_PLATFORM_INPUTS {