From a6586496f188e52c3801000ed9399a8f0b5b8e25 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:24:58 +0100 Subject: [PATCH 1/4] fix: fee estimation for AddressCreditWithdrawalTransition --- src/ui/wallets/send_screen.rs | 159 ++++++++++++++++++++++++++++++++-- 1 file changed, 153 insertions(+), 6 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index f9da86e3e..283d50159 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -18,9 +18,15 @@ 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::withdrawal::Pooling; use eframe::egui::{self, Context, RichText, Ui}; use egui::{Color32, Frame, Margin}; use std::collections::BTreeMap; @@ -56,6 +62,33 @@ 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) +} + /// Result of allocating platform addresses for a transfer. #[derive(Debug, Clone)] struct AddressAllocationResult { @@ -71,6 +104,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, +) -> 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() + .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 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); + + 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. /// @@ -786,9 +922,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 +951,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) @@ -831,7 +968,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!( From 1a9e8f7efcfee88680785a57138633eff788d464 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:51:06 +0100 Subject: [PATCH 2/4] chore: max button --- src/ui/wallets/send_screen.rs | 56 +++++++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 283d50159..db8ad706b 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -476,6 +476,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; @@ -854,7 +898,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!( @@ -1333,7 +1381,11 @@ impl WalletSendScreen { .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 { From f5f891c1e9eb7dabd011bfcde2a5aad1bd9c8ce1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 3 Feb 2026 08:49:34 +0100 Subject: [PATCH 3/4] refactor: remove redundant code --- src/ui/wallets/send_screen.rs | 95 +++-------------------------------- 1 file changed, 7 insertions(+), 88 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index db8ad706b..7e5e918e1 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -224,96 +224,16 @@ fn allocate_platform_addresses( amount_credits: u64, destination: Option<&PlatformAddress>, ) -> AddressAllocationResult { - // Filter out the destination address if provided (protocol doesn't allow same address as input and output) - let filtered: Vec<_> = addresses + let max_inputs = 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 AddressAllocationResult { - inputs: BTreeMap::new(), - fee_payer_index: 0, - estimated_fee: estimate_platform_fee(estimator, 1), - 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); - - // 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)); - - // 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; - - for (idx, (platform_addr, _, balance)) in sorted_addresses.iter().enumerate() { - if remaining == 0 || inputs.len() >= MAX_PLATFORM_INPUTS { - break; - } - // 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); - } - } - - // 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. - // 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 - }; - - let shortfall = allocation_shortfall.saturating_add(fee_deficit); + .count() + .min(MAX_PLATFORM_INPUTS); - // 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); - - AddressAllocationResult { - inputs, - fee_payer_index, - estimated_fee, - shortfall, - sorted_addresses, - } + 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 @@ -1375,7 +1295,6 @@ 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) From aa7d15c0bde35d08be9812fa3be965fb18bc4541 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:44:40 +0100 Subject: [PATCH 4/4] chore: better hint in platform-to-core --- src/ui/wallets/send_screen.rs | 66 ++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 7e5e918e1..16db52948 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -23,9 +23,12 @@ 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::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}; @@ -89,6 +92,29 @@ fn estimate_withdrawal_fee_from_transition( .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 { @@ -708,8 +734,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!( @@ -719,12 +751,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") @@ -1277,7 +1303,29 @@ impl WalletSendScreen { .ok() .map(|wallet| wallet.total_balance_duffs() * 1000) // 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)