Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions src/model/fee_estimation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
})
}

Expand Down Expand Up @@ -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);
}
}
5 changes: 3 additions & 2 deletions src/ui/wallets/send_screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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.
Expand Down Expand Up @@ -2830,6 +2830,7 @@ impl WalletSendScreen {
Some(estimate_platform_fee(
&self.app_context.fee_estimator(),
num_inputs,
num_outputs,
))
}
Comment on lines 2830 to 2835

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Count the Platform outputs actually submitted

This branch prices every row with a non-empty address, but send_advanced_platform_to_platform skips zero-credit rows and inserts outputs into a BTreeMap, which coalesces duplicate destinations. Two rows for the same Platform address therefore submit one transition output but are priced as two, and a populated zero-credit row is priced despite being omitted. Because the newly generalized fee calculation charges per output, derive the count from the same distinct, positive-credit destinations used by the send path.

Suggested change
Some(estimate_platform_fee(
&self.app_context.fee_estimator(),
num_inputs,
num_outputs,
))
}
AdvancedSourceType::Platform if has_platform_out && !has_core_out => {
let num_inputs = self
.platform_inputs
.iter()
.filter(|i| !i.amount.trim().is_empty())
.count()
.max(1);
let num_outputs = self
.advanced_outputs
.iter()
.filter_map(|output| {
let destination =
PlatformAddress::from_bech32m_string(output.address.trim()).ok()?;
let credits = Self::parse_amount_to_credits(&output.amount).ok()?;
(credits > 0).then_some(destination)
})
.collect::<std::collections::BTreeSet<_>>()
.len();
if num_outputs == 0 {
return None;
}
Some(estimate_platform_fee(
&self.app_context.fee_estimator(),
num_inputs,
num_outputs,
))
}

source: ['codex']

_ => None,
Expand Down
69 changes: 67 additions & 2 deletions src/wallet_backend/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,14 +143,41 @@ 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,
status: status_from_context(&record.context),
}
}

fn transaction_fee(record: &TransactionRecord) -> Option<u64> {
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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Comment on lines +882 to +916

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Cover the fee derivation's fail-closed behavior

The new test covers only a single-input happy path. The helper's user-visible correctness also depends on preserving an existing upstream fee and returning None for incomplete or misindexed input metadata and when outputs exceed inputs. Add regression cases for those branches so a future simplification cannot display a fabricated transaction fee.

source: ['codex']

}

#[test]
fn reseen_txid_upserts_in_place() {
let store = SnapshotStore::new();
Expand Down
Loading