From 9cf60eca7570069e0c5402e619f349b3780f2b2b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 9 Jul 2026 16:12:16 +0700 Subject: [PATCH 1/6] docs(platform-wallet): document the fee tuple element and out_fee_duffs contract CodeRabbit review nits on #4049: send_payment's # Returns now covers the third (fee) tuple element, and the FFI # Safety documents out_fee_duffs as a nullable out-pointer written only on success. Co-Authored-By: Claude Fable 5 --- packages/rs-platform-wallet-ffi/src/dashpay.rs | 2 ++ .../src/wallet/identity/network/payments.rs | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 166e84630a..3012024dad 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -533,6 +533,8 @@ pub unsafe extern "C" fn platform_wallet_fetch_sent_contact_requests( /// - `core_signer_handle` must be a valid, non-destroyed /// `*mut MnemonicResolverHandle`. Ownership is retained by the caller — /// this function does NOT destroy it. +/// - `out_fee_duffs` may be NULL to ignore the fee; when non-null it must +/// point to valid writable `u64` storage, written only on success. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_send_dashpay_payment( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index f542ab62e0..f61e295fff 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -538,8 +538,10 @@ impl DashPayView<'_, B> { /// /// # Returns /// - /// The `Txid` of the broadcast transaction and the newly created - /// [`PaymentEntry`] recording the outgoing payment. + /// The `Txid` of the broadcast transaction, the newly created + /// [`PaymentEntry`] recording the outgoing payment, and the exact + /// network fee in duffs (inputs − outputs) of the broadcast + /// transaction. pub async fn send_payment( &self, from_identity_id: &Identifier, From 26669c985097fba7db3f461627d3921165dd3470 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 9 Jul 2026 16:48:54 +0700 Subject: [PATCH 2/6] fix(platform-wallet): report the actual fee from send_payment, not the size fee Review finding on #4049: build_signed's returned fee is size-based (fee_rate x encoded_size) and under-reports whenever the builder drops a dust change remainder (<= 546 duffs) to miners instead of emitting the change output. send_payment now computes the fee it exposes as sum(selected input values) - sum(output values), reading input values from the account UTXO map (build reserves selected UTXOs but does not remove them). A selected input missing from the map is a hard error, never a fabricated fee. Co-Authored-By: Claude Fable 5 --- .../src/wallet/identity/network/payments.rs | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index f61e295fff..cbe163f834 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -667,13 +667,47 @@ impl DashPayView<'_, B> { // `impl TransactionSigner for S`) rather than the // resident `wallet`, so funding-input signatures are produced // from Keychain-derived keys without a resident seed. - let (tx, fee) = builder + let (tx, _size_based_fee) = builder .build_signed(signer, |addr| { managed_account.address_derivation_path(&addr) }) .await .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; + // Exact network fee: Σ(selected input values) − Σ(output + // values). The builder's returned fee is size-based + // (`fee_rate × encoded_size`) and under-reports whenever it + // drops a dust change remainder (≤ 546 duffs) to miners + // instead of emitting the change output — the dust is part + // of what the sender actually pays. Input values are read + // from the account UTXO map (the build reserves selected + // UTXOs but does not remove them; removal happens on spend + // detection). + let account_utxos = &info + .core_wallet + .accounts + .standard_bip44_accounts + .get(&0) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild( + "BIP-44 managed account 0 not found".to_string(), + ) + })? + .utxos; + let input_total = tx.input.iter().try_fold(0u64, |acc, txin| { + account_utxos + .get(&txin.previous_output) + .map(|utxo| acc.saturating_add(utxo.txout.value)) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "selected input {} missing from the account UTXO set", + txin.previous_output + )) + }) + })?; + let output_total: u64 = tx.output.iter().map(|o| o.value).sum(); + let fee = input_total.saturating_sub(output_total); + (payment_address, tx, fee) }; From 035436f3297ba65937c018b8a0361c9c2fdce94c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 12 Jul 2026 06:06:08 +0700 Subject: [PATCH 3/6] test(platform-wallet): pin send_payment's exact fee incl. the dust-drop case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two full end-to-end `send_payment` tests reach the fee block for the first time (no existing test did): they fund BIP-44 account 0 with a known UTXO, register the external contact account, sign with the seed signer, and broadcast through an accepting stub broadcaster injected via the `IdentityWallet` broadcaster generic (production `identity()` pins `SpvBroadcaster`, whose runtime is never started in tests). Build, sign, and the Σin − Σout fee computation all run for real. - dust case: change ≤ 546 is dropped, so the tx has a single output and the reported fee is the full V − A, folding the dropped dust into the fee (the bug this PR fixed). - control: change > 546 is emitted, so Σin − Σout equals the plain size-based fee, proving the computation isn't blindly inflating sends. Also reword the stale rs-platform-wallet-ffi/dashpay.rs comment that claimed the fee comes "straight from the builder" — it is now computed in send_payment as Σ(inputs) − Σ(outputs). Co-Authored-By: Claude Opus --- .../rs-platform-wallet-ffi/src/dashpay.rs | 8 +- .../src/wallet/identity/network/payments.rs | 263 ++++++++++++++++++ 2 files changed, 268 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 3012024dad..100bcaa1e5 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -596,9 +596,11 @@ pub unsafe extern "C" fn platform_wallet_send_dashpay_payment( }); let result = unwrap_option_or_return!(option); let (txid, _entry, fee_duffs) = unwrap_result_or_return!(result); - // Exact network fee of the broadcast transaction (inputs − outputs), - // straight from the builder — nullable so callers that don't care - // can pass NULL. + // Exact network fee of the broadcast transaction: `send_payment` + // computes it as Σ(selected input values) − Σ(output values), so any + // sub-dust change the builder folds into the fee is reflected here — + // not the builder's size-based estimate. Nullable so callers that + // don't care can pass NULL. if !out_fee_duffs.is_null() { unsafe { *out_fee_duffs = fee_duffs }; } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index cbe163f834..1ec9b9b7c1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -3436,4 +3436,267 @@ mod tests { ); } } + + /// Broadcaster stub that accepts every transaction, so a send-path test + /// can reach `send_payment`'s return value. The production `identity()` + /// pins [`SpvBroadcaster`], whose runtime is never started in tests + /// (`broadcast` returns `Rejected { "SPV client not started" }`); the + /// `IdentityWallet` broadcaster generic is the sanctioned injection + /// seam, exercised here with the same-crate `pub(crate)` fields. The + /// build + sign + fee computation all run for real — only the network + /// transport is stubbed. + struct AcceptingBroadcaster; + + #[async_trait::async_trait] + impl crate::broadcaster::TransactionBroadcaster for AcceptingBroadcaster { + async fn broadcast( + &self, + transaction: &dashcore::Transaction, + ) -> Result { + Ok(transaction.txid()) + } + } + + /// Re-specialize a live `IdentityWallet` onto the + /// accepting broadcaster, sharing every other Arc (wallet manager, SDK, + /// asset locks, persister, sdk_writer) so the two handles operate on the + /// same wallet state. + fn with_accepting_broadcaster( + real: &crate::wallet::identity::IdentityWallet, + ) -> crate::wallet::identity::IdentityWallet { + crate::wallet::identity::IdentityWallet { + sdk: Arc::clone(&real.sdk), + wallet_manager: Arc::clone(&real.wallet_manager), + wallet_id: real.wallet_id, + asset_locks: Arc::clone(&real.asset_locks), + persister: real.persister.clone(), + broadcaster: Arc::new(AcceptingBroadcaster), + sdk_writer: Arc::clone(&real.sdk_writer), + } + } + + /// Plant a single spendable UTXO of `value_duffs` on BIP-44 account 0's + /// first pool address (a real derived address, so its derivation path is + /// resolvable and [`SeedSigner`] can sign the funding input). + async fn fund_bip44_account_0( + manager: &Arc>, + wallet_id: WalletId, + txid_byte: u8, + value_duffs: u64, + ) { + use dashcore::hashes::Hash; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet registered"); + let iw = wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); + let account = info + .core_wallet + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("BIP-44 managed account 0"); + let address_info = account + .managed_account_type() + .address_pools() + .first() + .expect("BIP-44 account has an address pool") + .addresses + .values() + .next() + .expect("pool has at least one derived address") + .clone(); + let txid = dashcore::Txid::from_slice(&[txid_byte; 32]).expect("txid"); + let outpoint = dashcore::OutPoint { txid, vout: 0 }; + account.utxos.insert( + outpoint, + key_wallet::Utxo { + outpoint, + txout: dashcore::TxOut { + value: value_duffs, + script_pubkey: address_info.script_pubkey.clone(), + }, + address: address_info.address.clone(), + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }, + ); + } + + /// Create a testnet wallet, add the sender identity, and register the + /// external contact account for `(owner, contact)` via the faithful + /// precomputed-shared-key path — the shared setup the two full-send fee + /// tests build on (mirrors + /// `send_payment_passes_external_lookup_once_account_built` up to the send). + async fn register_sender_and_external_account() -> ( + Arc>, + WalletId, + Identifier, + Identifier, + ) { + let (manager, persister, wallet_id) = make_wallet().await; + let wallet_arc = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = wallet_arc.identity(); + + let owner_id = Identifier::from([0x11; 32]); + let contact_id = Identifier::from([0x22; 32]); + { + let mut wm = iw.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("info"); + info.identity_manager + .add_identity( + bare_identity([0x11; 32]), + 0, + wallet_id, + &WalletPersister::new(wallet_id, Arc::clone(&persister) as _), + ) + .expect("add owner"); + } + + let shared_key = [0x55u8; 32]; + let iv = [0x11u8; 16]; + let compact = { + let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + .expect("mnemonic") + .to_seed(""); + let w = key_wallet::wallet::Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::None, + ) + .expect("seed wallet"); + crate::wallet::identity::crypto::dip14::derive_contact_xpub( + &w, + Network::Testnet, + 0, + &owner_id, + &contact_id, + ) + .expect("derive a valid compact xpub") + .compact + .to_bytes() + }; + let encrypted = + platform_encryption::encrypt_extended_public_key(&shared_key, &iv, &compact); + let contact = bare_identity([0x22; 32]); + iw.dashpay() + .register_external_contact_account( + &owner_id, + &contact, + &encrypted, + zeroize::Zeroizing::new(shared_key), + ) + .await + .expect("register external account"); + + (manager, wallet_id, owner_id, contact_id) + } + + /// A fully-successful `send_payment` whose exact change would be dust + /// (≤ 546 duffs) must fold that remainder into the reported fee instead + /// of dropping it silently. With one funded UTXO of `V` and a payment of + /// `A` engineered so the change is dust, the builder emits a single + /// output (no change) and the returned fee is `V − A` — the exact + /// Σ(inputs) − Σ(outputs), strictly larger than the builder's + /// size-based fee by the dropped dust. Pins the bug this PR fixed. + #[tokio::test] + async fn send_payment_reports_exact_fee_folding_dropped_dust_change() { + use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; + + let (manager, wallet_id, owner_id, contact_id) = register_sender_and_external_account().await; + + // One UTXO of V = A + 526. The size-based fee for 1 input + 1 output + // is ~192 duffs and the coin selector reserves 226 (it sizes with a + // phantom change output); the resulting change (~300) is below the + // 546-duff dust threshold, so the builder drops it and emits ONLY the + // payment output. The real fee is therefore V − A = 526, NOT the + // ~192-duff size fee. + let amount = 50_000u64; + let funded = amount + 526; + fund_bip44_account_0(&manager, wallet_id, 0xA1, funded).await; + + let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + .expect("valid mnemonic") + .to_seed(""); + let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); + let signer = SeedSigner::new(seed, Network::Testnet); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = with_accepting_broadcaster(wallet.identity()); + + let (_txid, entry, fee) = iw + .dashpay() + .send_payment(&owner_id, &contact_id, amount, None, &signer, &provider) + .await + .expect("funded + signable send must succeed through the accepting broadcaster"); + + // Exact fee = Σ(selected input values) − Σ(output values). The single + // funded UTXO is the only input, and because the dust change was + // dropped the only output is the payment itself, so Σout == amount. + assert_eq!( + fee, + funded - amount, + "the reported fee must be Σin − Σout, folding the dropped dust \ + change into the fee (V − A)" + ); + // fee == funded − amount can ONLY hold when Σout == amount, i.e. no + // change output was emitted: any change output would make Σout larger + // and the fee strictly smaller. So this equality alone proves the + // dust-drop. + assert_eq!(entry.amount_duffs, amount, "recorded payment amount is the send amount"); + } + + /// The control case: when the change clears the dust threshold the + /// builder DOES emit a change output, and Σ(inputs) − Σ(outputs) then + /// equals the ordinary size-based fee — the change output absorbs the + /// remainder, so nothing is folded in. Confirms the fee computation is + /// not blindly inflating every send. + #[tokio::test] + async fn send_payment_reports_size_fee_when_change_is_emitted() { + use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; + + let (manager, wallet_id, owner_id, contact_id) = register_sender_and_external_account().await; + + // One UTXO of V = A + 1226. Change = V − A − size_fee = 1226 − 226 = + // 1000 > 546, so the builder emits a 1000-duff change output. Σout = + // A + 1000, so the fee is V − Σout = 226 — the size-based fee for a + // 1-input, 2-output type-3 tx at 1 duff/byte (base 78 + 148 input), + // with NO dust folded in. + let amount = 50_000u64; + let funded = amount + 1226; + fund_bip44_account_0(&manager, wallet_id, 0xB2, funded).await; + + let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + .expect("valid mnemonic") + .to_seed(""); + let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); + let signer = SeedSigner::new(seed, Network::Testnet); + + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + let iw = with_accepting_broadcaster(wallet.identity()); + + let (_txid, _entry, fee) = iw + .dashpay() + .send_payment(&owner_id, &contact_id, amount, None, &signer, &provider) + .await + .expect("funded + signable send must succeed through the accepting broadcaster"); + + // The change output absorbed the remainder: the fee is the plain + // size-based fee, NOT the full V − A. + assert!( + fee < funded - amount, + "with a change output emitted the fee must be smaller than V − A \ + (the change absorbs the remainder), got fee={fee}, V−A={}", + funded - amount + ); + assert_eq!( + fee, 226, + "Σin − Σout equals the size-based fee for a 1-input, 2-output \ + type-3 tx at 1 duff/byte when change is emitted" + ); + } } From b618286650270514f48fedefab4a677927bbe013 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 12 Jul 2026 06:34:48 +0700 Subject: [PATCH 4/6] style(platform-wallet): rustfmt the new send_payment fee tests The Wallet-tests CI job runs cargo fmt --check; the hand-written test fixtures in 6618881260 weren't rustfmt-clean. No logic change. Co-Authored-By: Claude Opus --- .../src/wallet/identity/network/payments.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index 1ec9b9b7c1..eefbfcaba7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -3486,7 +3486,10 @@ mod tests { ) { use dashcore::hashes::Hash; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - let wallet = manager.get_wallet(&wallet_id).await.expect("wallet registered"); + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("wallet registered"); let iw = wallet.identity(); let mut wm = iw.wallet_manager.write().await; let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); @@ -3607,7 +3610,8 @@ mod tests { async fn send_payment_reports_exact_fee_folding_dropped_dust_change() { use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; - let (manager, wallet_id, owner_id, contact_id) = register_sender_and_external_account().await; + let (manager, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; // One UTXO of V = A + 526. The size-based fee for 1 input + 1 output // is ~192 duffs and the coin selector reserves 226 (it sizes with a @@ -3647,7 +3651,10 @@ mod tests { // change output was emitted: any change output would make Σout larger // and the fee strictly smaller. So this equality alone proves the // dust-drop. - assert_eq!(entry.amount_duffs, amount, "recorded payment amount is the send amount"); + assert_eq!( + entry.amount_duffs, amount, + "recorded payment amount is the send amount" + ); } /// The control case: when the change clears the dust threshold the @@ -3659,7 +3666,8 @@ mod tests { async fn send_payment_reports_size_fee_when_change_is_emitted() { use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; - let (manager, wallet_id, owner_id, contact_id) = register_sender_and_external_account().await; + let (manager, wallet_id, owner_id, contact_id) = + register_sender_and_external_account().await; // One UTXO of V = A + 1226. Change = V − A − size_fee = 1226 − 226 = // 1000 > 546, so the builder emits a 1000-duff change output. Σout = From 18df0b5362a1276304b028d5a00f3bb327829f93 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 12 Jul 2026 06:36:45 +0700 Subject: [PATCH 5/6] docs(swift-sdk): correct sendDashPayPayment fee comment (not 'straight from the builder') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendDashPayPayment's doc still said the fee comes straight from the builder; send_payment now returns Σinputs − Σoutputs (dropped dust folded in). Comment-only. Co-Authored-By: Claude Opus --- .../SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 972875a2bd..01e4250819 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -2102,7 +2102,10 @@ extension ManagedPlatformWallet { /// Send a Dash payment to an established DashPay contact. /// `amountDuffs` is in duffs (1 DASH = 100_000_000 duffs). /// Returns the 32-byte transaction id plus the exact network fee - /// (duffs) of the broadcast transaction, straight from the builder. + /// (duffs) of the broadcast transaction, computed Rust-side as + /// Σ(selected input values) − Σ(output values) — so any sub-dust + /// change the builder folds into the fee is reflected, not the + /// builder's size-based estimate. /// /// Prerequisite: `register_external_contact_account` must have /// run for the `(fromIdentityId, toContactIdentityId)` pair on From fae738b391ea8855b9a6f3210a35e81085ec3592 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 13 Jul 2026 00:39:55 +0700 Subject: [PATCH 6/6] refactor(platform-wallet): take send_payment's fee from the builder (rust-dashcore#872) Bump the rust-dashcore pin one commit (d4692bb -> 234e3c43) to pick up rust-dashcore#872, where build/build_signed now return the fee the transaction actually pays (inputs - outputs, dropped dust included), and delete send_payment's caller-side recomputation block - the workaround for the old size-based return. The two end-to-end regression tests (dust-drop + emitted-change) are unchanged and now pin the builder's corrected value through the full send path: 31/31 payments-module tests pass, platform-wallet-ffi checks clean, fmt clean. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 24 +++++------ Cargo.toml | 16 ++++---- .../rs-platform-wallet-ffi/src/dashpay.rs | 8 ++-- .../src/wallet/identity/network/payments.rs | 41 +++---------------- 4 files changed, 30 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c5524b3f0..231e87a8b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=234e3c4333f3be6f7a1d01e5af90805ae49783c7#234e3c4333f3be6f7a1d01e5af90805ae49783c7" dependencies = [ "bincode", "bincode_derive", @@ -1648,7 +1648,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=234e3c4333f3be6f7a1d01e5af90805ae49783c7#234e3c4333f3be6f7a1d01e5af90805ae49783c7" dependencies = [ "dash-network", ] @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=234e3c4333f3be6f7a1d01e5af90805ae49783c7#234e3c4333f3be6f7a1d01e5af90805ae49783c7" dependencies = [ "async-trait", "chrono", @@ -1754,7 +1754,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=234e3c4333f3be6f7a1d01e5af90805ae49783c7#234e3c4333f3be6f7a1d01e5af90805ae49783c7" dependencies = [ "anyhow", "base64-compat", @@ -1780,12 +1780,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=234e3c4333f3be6f7a1d01e5af90805ae49783c7#234e3c4333f3be6f7a1d01e5af90805ae49783c7" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=234e3c4333f3be6f7a1d01e5af90805ae49783c7#234e3c4333f3be6f7a1d01e5af90805ae49783c7" dependencies = [ "dashcore-rpc-json", "hex", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=234e3c4333f3be6f7a1d01e5af90805ae49783c7#234e3c4333f3be6f7a1d01e5af90805ae49783c7" dependencies = [ "bincode", "dashcore", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=234e3c4333f3be6f7a1d01e5af90805ae49783c7#234e3c4333f3be6f7a1d01e5af90805ae49783c7" dependencies = [ "bincode", "dashcore-private", @@ -2867,7 +2867,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=234e3c4333f3be6f7a1d01e5af90805ae49783c7#234e3c4333f3be6f7a1d01e5af90805ae49783c7" [[package]] name = "glob" @@ -4034,7 +4034,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=234e3c4333f3be6f7a1d01e5af90805ae49783c7#234e3c4333f3be6f7a1d01e5af90805ae49783c7" dependencies = [ "aes", "async-trait", @@ -4063,7 +4063,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=234e3c4333f3be6f7a1d01e5af90805ae49783c7#234e3c4333f3be6f7a1d01e5af90805ae49783c7" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d4692bb29c626942ddf51204d50d90206c1a5b0c#d4692bb29c626942ddf51204d50d90206c1a5b0c" +source = "git+https://github.com/dashpay/rust-dashcore?rev=234e3c4333f3be6f7a1d01e5af90805ae49783c7#234e3c4333f3be6f7a1d01e5af90805ae49783c7" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index efb87c098d..8fe0d7e625 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,14 +50,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "d4692bb29c626942ddf51204d50d90206c1a5b0c" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "d4692bb29c626942ddf51204d50d90206c1a5b0c" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "d4692bb29c626942ddf51204d50d90206c1a5b0c" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "d4692bb29c626942ddf51204d50d90206c1a5b0c" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "d4692bb29c626942ddf51204d50d90206c1a5b0c" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "d4692bb29c626942ddf51204d50d90206c1a5b0c" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "d4692bb29c626942ddf51204d50d90206c1a5b0c" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "d4692bb29c626942ddf51204d50d90206c1a5b0c" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "234e3c4333f3be6f7a1d01e5af90805ae49783c7" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "234e3c4333f3be6f7a1d01e5af90805ae49783c7" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "234e3c4333f3be6f7a1d01e5af90805ae49783c7" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "234e3c4333f3be6f7a1d01e5af90805ae49783c7" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "234e3c4333f3be6f7a1d01e5af90805ae49783c7" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "234e3c4333f3be6f7a1d01e5af90805ae49783c7" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "234e3c4333f3be6f7a1d01e5af90805ae49783c7" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "234e3c4333f3be6f7a1d01e5af90805ae49783c7" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 100bcaa1e5..29dbb70442 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -596,10 +596,10 @@ pub unsafe extern "C" fn platform_wallet_send_dashpay_payment( }); let result = unwrap_option_or_return!(option); let (txid, _entry, fee_duffs) = unwrap_result_or_return!(result); - // Exact network fee of the broadcast transaction: `send_payment` - // computes it as Σ(selected input values) − Σ(output values), so any - // sub-dust change the builder folds into the fee is reflected here — - // not the builder's size-based estimate. Nullable so callers that + // Exact network fee of the broadcast transaction — Σ(selected input + // values) − Σ(output values), computed by the transaction builder + // itself since rust-dashcore#872, so a sub-dust change remainder + // folded into the fee is reflected here. Nullable so callers that // don't care can pass NULL. if !out_fee_duffs.is_null() { unsafe { *out_fee_duffs = fee_duffs }; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index eefbfcaba7..7105878ddb 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -667,47 +667,18 @@ impl DashPayView<'_, B> { // `impl TransactionSigner for S`) rather than the // resident `wallet`, so funding-input signatures are produced // from Keychain-derived keys without a resident seed. - let (tx, _size_based_fee) = builder + // `build_signed` returns the fee the transaction actually + // pays — Σ(selected input values) − Σ(output values), a + // dropped sub-dust change remainder included — since + // rust-dashcore#872 (pinned above). No caller-side + // recomputation needed. + let (tx, fee) = builder .build_signed(signer, |addr| { managed_account.address_derivation_path(&addr) }) .await .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; - // Exact network fee: Σ(selected input values) − Σ(output - // values). The builder's returned fee is size-based - // (`fee_rate × encoded_size`) and under-reports whenever it - // drops a dust change remainder (≤ 546 duffs) to miners - // instead of emitting the change output — the dust is part - // of what the sender actually pays. Input values are read - // from the account UTXO map (the build reserves selected - // UTXOs but does not remove them; removal happens on spend - // detection). - let account_utxos = &info - .core_wallet - .accounts - .standard_bip44_accounts - .get(&0) - .ok_or_else(|| { - PlatformWalletError::TransactionBuild( - "BIP-44 managed account 0 not found".to_string(), - ) - })? - .utxos; - let input_total = tx.input.iter().try_fold(0u64, |acc, txin| { - account_utxos - .get(&txin.previous_output) - .map(|utxo| acc.saturating_add(utxo.txout.value)) - .ok_or_else(|| { - PlatformWalletError::TransactionBuild(format!( - "selected input {} missing from the account UTXO set", - txin.previous_output - )) - }) - })?; - let output_total: u64 = tx.output.iter().map(|o| o.value).sum(); - let fee = input_total.saturating_sub(output_total); - (payment_address, tx, fee) };