diff --git a/src/model/fee_estimation.rs b/src/model/fee_estimation.rs index 4e3d5e4b3..ba536ee4c 100644 --- a/src/model/fee_estimation.rs +++ b/src/model/fee_estimation.rs @@ -762,13 +762,18 @@ pub fn format_credits(credits: u64) -> String { /// Calculate the estimated fee for a platform address funds transfer. /// /// Uses [`PlatformFeeEstimator`] for base costs (input/output fees) plus storage fees. -pub(crate) fn estimate_platform_fee(estimator: &PlatformFeeEstimator, input_count: usize) -> u64 { +pub(crate) fn estimate_platform_fee( + estimator: &PlatformFeeEstimator, + input_count: usize, + output_count: usize, +) -> u64 { let inputs = input_count.max(1); + let outputs = output_count.max(1); // Base fee from Platform's min fee structure // - 500,000 credits per input (address_funds_transfer_input_cost) // - 6,000,000 credits per output (address_funds_transfer_output_cost) - let base_fee = estimator.estimate_address_funds_transfer(inputs, 1); + let base_fee = estimator.estimate_address_funds_transfer(inputs, outputs); // Add storage fees for serialized input bytes only // (outputs don't add significant serialization overhead) @@ -973,7 +978,7 @@ pub(crate) fn allocate_platform_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)) + estimate_platform_fee(estimator, max_inputs.max(1), 1) }) } @@ -1552,9 +1557,21 @@ mod tests { let addresses = addrs(&[(1, 10_000_000_000)]); let result = allocate_platform_addresses(&estimator, &addresses, 1_000_000, None); - assert_eq!(result.estimated_fee, estimate_platform_fee(&estimator, 1)); + assert_eq!( + result.estimated_fee, + estimate_platform_fee(&estimator, 1, 1) + ); assert_eq!(result.shortfall, 0); assert_eq!(result.fee_payer_index, 0); assert_eq!(result.inputs.get(&pa(1)).copied(), Some(1_000_000)); } + + #[test] + fn platform_fee_accounts_for_every_output() { + let estimator = PlatformFeeEstimator::new(); + let one_output = estimate_platform_fee(&estimator, 1, 1); + let two_outputs = estimate_platform_fee(&estimator, 1, 2); + + assert!(two_outputs > one_output); + } } diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index e5c6ff713..286a08bb3 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -359,7 +359,7 @@ impl WalletSendScreen { let usable_count = sorted_addresses.len().min(MAX_PLATFORM_INPUTS); if usable_count == 0 { - return estimate_platform_fee(fee_estimator, 1); + return estimate_platform_fee(fee_estimator, 1, 1); } let dest_kind = self.validated_destination.as_ref().map(|v| v.kind()); @@ -383,7 +383,7 @@ impl WalletSendScreen { } } - estimate_platform_fee(fee_estimator, usable_count) + estimate_platform_fee(fee_estimator, usable_count, 1) } /// Clear the AddressInput widget so it picks up the new network on next frame. @@ -2830,6 +2830,7 @@ impl WalletSendScreen { Some(estimate_platform_fee( &self.app_context.fee_estimator(), num_inputs, + num_outputs, )) } _ => None, diff --git a/src/wallet_backend/snapshot.rs b/src/wallet_backend/snapshot.rs index 555fb7fed..d6a494f45 100644 --- a/src/wallet_backend/snapshot.rs +++ b/src/wallet_backend/snapshot.rs @@ -143,7 +143,7 @@ pub(super) fn map_transaction_record(record: &TransactionRecord) -> WalletTransa height: record.height(), block_hash: block_info.map(|bi| bi.block_hash()), net_amount: record.net_amount, - fee: record.fee, + fee: transaction_fee(record), label: Some(record.label.clone()).filter(|s| !s.is_empty()), // Per-wallet history — every record involves our addresses. is_ours: true, @@ -151,6 +151,33 @@ pub(super) fn map_transaction_record(record: &TransactionRecord) -> WalletTransa } } +fn transaction_fee(record: &TransactionRecord) -> Option { + if record.fee.is_some() { + return record.fee; + } + if record.transaction.input.is_empty() + || record.input_details.len() != record.transaction.input.len() + || record + .input_details + .iter() + .enumerate() + .any(|(index, detail)| detail.index as usize != index) + { + return None; + } + + let input_total = record + .input_details + .iter() + .try_fold(0u64, |total, input| total.checked_add(input.value))?; + let output_total = record + .transaction + .output + .iter() + .try_fold(0u64, |total, output| total.checked_add(output.value))?; + input_total.checked_sub(output_total) +} + /// The `(transaction, [(outpoint, txout, address)])` payload the asset-lock and /// identity-funding screens wait on, matching the /// `CoreItem::ReceivedAvailableUTXOTransaction` contract. @@ -615,7 +642,7 @@ mod tests { use dash_sdk::dpp::dashcore::{BlockHash, Network, PublicKey, Transaction, TxOut}; use dash_sdk::dpp::key_wallet::account::{AccountType, StandardAccountType}; use dash_sdk::dpp::key_wallet::managed_account::transaction_record::{ - OutputDetail, OutputRole, TransactionDirection, TransactionRecord, + InputDetail, OutputDetail, OutputRole, TransactionDirection, TransactionRecord, }; use dash_sdk::dpp::key_wallet::transaction_checking::BlockInfo; use dash_sdk::dpp::key_wallet::transaction_checking::transaction_router::TransactionType; @@ -851,6 +878,44 @@ mod tests { assert_eq!(snap.balance, DetWalletBalance::default()); } + #[test] + fn outgoing_transaction_fee_is_derived_from_known_inputs() { + use dash_sdk::dpp::dashcore::TxIn; + + let source = addr(10); + let destination = addr(11); + let mut tx = tx_with(10); + tx.input.push(TxIn::default()); + tx.output.push(TxOut { + value: 9_700, + script_pubkey: destination.script_pubkey(), + }); + let record = TransactionRecord::new( + tx, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Outgoing, + vec![InputDetail { + index: 0, + value: 10_000, + address: source, + }], + vec![OutputDetail { + index: 0, + role: OutputRole::Sent, + address: Some(destination), + value: 9_700, + }], + -10_000, + ); + + assert_eq!(map_transaction_record(&record).fee, Some(300)); + } + #[test] fn reseen_txid_upserts_in_place() { let store = SnapshotStore::new();