From 4538fc4fdf84b9b0a1f2e6b4c2b4da8eb8178074 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:47:26 +0300 Subject: [PATCH 1/8] feat(platform-wallet): let a Core build fund from only the inputs it was given MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet-aware finalizers add every unreserved UTXO of the funding account to the candidate pool, so seeding a subset through `core_wallet_tx_builder_add_inputs_from_outpoints` does not restrict what gets selected. A caller draining an account in batches that each stay under the standard-transaction input limit therefore achieves nothing: every batch sees the whole account and fails with a too-many-inputs error, and an account above the cap cannot be drained at all. That is the iOS CoinJoin sweep. A wallet with 589 mixed UTXOs reports "Too many inputs for a standard transaction: 589 (max 500)" on every attempt and every retry; its ~101 DASH cannot be moved by any route the app offers. Exposes key-wallet's opt-in through the FFI and the Swift SDK, and moves the rust-dashcore pin onto a branch carrying it. The pin continues the existing cherry-pick lineage rather than following dev: `chore/sync-fixes-filter-rescans-and-added-inputs` is the current pin (4db5c367) plus dash-spv #866 and #974 — committed-filter-range rescans for newly derived scripts, which address the launch-dependent balances seen on heavily mixed wallets — plus the four key-wallet commits. Pinning dev instead would drag in the sweep-event chain, whose platform-side handling is #4406's subject and which breaks this workspace on seven non-exhaustive matches today. --- Cargo.lock | 24 +++++++++---------- Cargo.toml | 16 ++++++------- .../src/core_wallet/transaction_builder.rs | 24 +++++++++++++++++++ .../CoreWallet/CoreTransactionBuilder.swift | 13 ++++++++++ 4 files changed, 57 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e20524bc2e..e3c84e0e6fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "bincode", "bincode_derive", @@ -1673,7 +1673,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "dash-network", ] @@ -1768,7 +1768,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "async-trait", "chrono", @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "anyhow", "base64-compat", @@ -1823,12 +1823,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "dashcore-rpc-json", "hex", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "bincode", "dashcore", @@ -1856,7 +1856,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "bincode", "dashcore-private", @@ -2925,7 +2925,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" [[package]] name = "glob" @@ -4137,7 +4137,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "aes", "async-trait", @@ -4166,7 +4166,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4182,7 +4182,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 2370ef4ea5d..d2ad9658fa3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,14 +53,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs index 2cefb0888a0..fcbd182bcac 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs @@ -616,6 +616,30 @@ pub unsafe extern "C" fn core_wallet_tx_builder_set_fee_rate( PlatformWalletFFIResult::ok() } +/// Fund the build from the inputs `core_wallet_tx_builder_add_inputs_from_outpoints` +/// supplied, and nothing else. +/// +/// Without this, the wallet-aware finalizers add every unreserved UTXO of the +/// funding account to the candidate pool, so seeding a subset does not restrict +/// what gets selected. A caller draining an account in batches that each stay +/// under the standard-transaction input limit needs this, or every batch sees +/// the whole account and fails with a too-many-inputs error. +/// +/// # Safety +/// `builder` must be a valid, non-destroyed pointer. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_tx_builder_use_only_added_inputs( + builder: *mut FFITransactionBuilder, +) -> PlatformWalletFFIResult { + check_ptr!(builder); + + let b = (*builder).take_builder(); + let b = b.use_only_added_inputs(); + (*builder).store_builder(b); + + PlatformWalletFFIResult::ok() +} + /// # Safety /// `builder` must be a valid, non-destroyed pointer. #[no_mangle] diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift index ce22efa0704..b0efd1a4093 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift @@ -295,6 +295,19 @@ public final class CoreTransactionBuilder { return self } + /// Fund the build from the inputs `addInputs` supplied, and nothing else. + /// + /// Without this, `finalizeAtomic` adds every unreserved UTXO of the funding + /// account to the candidate pool, so seeding a subset does not restrict what + /// gets selected. A caller draining an account in batches that each stay + /// under the standard-transaction input limit needs this, or every batch + /// sees the whole account and fails with a too-many-inputs error. + @discardableResult + public func useOnlyAddedInputs() throws -> CoreTransactionBuilder { + try core_wallet_tx_builder_use_only_added_inputs(handle).check() + return self + } + @discardableResult public func setCurrentHeight(_ height: UInt32) throws -> CoreTransactionBuilder { try core_wallet_tx_builder_set_current_height(handle, height).check() From d207874e37755ea241c5d6afbfd0157671317d86 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:30:35 +0300 Subject: [PATCH 2/8] feat(platform-wallet): let a Core build fund from only the inputs it was given The wallet-aware finalizers offer every unreserved UTXO of the funding account alongside anything `core_wallet_tx_builder_add_inputs_from_outpoints` seeded, so seeding a subset does not restrict what gets selected. A caller draining an account in batches that each stay under the standard-transaction input limit therefore achieves nothing: every batch sees the whole account and fails with a too-many-inputs error, and an account above the cap cannot be drained at all. That is the iOS CoinJoin sweep. Reproduced on a testnet wallet holding 700 mixed UTXOs: "Too many inputs for a standard transaction: 700 (max 500)" on every attempt; the reporting mainnet wallet has 589 and ~101 DASH it cannot move. key-wallet takes the choice per funding call (dashpay/rust-dashcore#994), and the finalizers make that call internally, so the intent is carried on the FFI builder and read when they run. `finalize_transaction` keeps its signature and delegates to `finalize_transaction_with_options`, so no existing caller changes. --- Cargo.lock | 24 +++++++-------- Cargo.toml | 16 +++++----- .../src/core_wallet/transaction_builder.rs | 30 +++++++++++-------- .../src/wallet/core/transaction.rs | 27 ++++++++++++++++- 4 files changed, 64 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e3c84e0e6fc..942d3f6e3a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" +source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" dependencies = [ "bincode", "bincode_derive", @@ -1673,7 +1673,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" +source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" dependencies = [ "dash-network", ] @@ -1768,7 +1768,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" +source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" dependencies = [ "async-trait", "chrono", @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" +source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" dependencies = [ "anyhow", "base64-compat", @@ -1823,12 +1823,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" +source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" +source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" dependencies = [ "dashcore-rpc-json", "hex", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" +source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" dependencies = [ "bincode", "dashcore", @@ -1856,7 +1856,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" +source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" dependencies = [ "bincode", "dashcore-private", @@ -2925,7 +2925,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" +source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" [[package]] name = "glob" @@ -4137,7 +4137,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" +source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" dependencies = [ "aes", "async-trait", @@ -4166,7 +4166,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" +source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4182,7 +4182,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" +source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index d2ad9658fa3..26c7834fed3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,14 +53,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs index fcbd182bcac..79d3c00e96d 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs @@ -31,6 +31,10 @@ use std::str::FromStr; pub struct FFITransactionBuilder { inner: *mut c_void, network: FFINetwork, + /// Set by `core_wallet_tx_builder_use_only_added_inputs`. key-wallet takes + /// this per funding call, which the finalizers make internally, so the + /// intent has to be carried here and read when they run. + reservation_only: bool, } /// Owned signed-transaction bytes handed across the C ABI as the `out_tx` @@ -142,11 +146,13 @@ pub unsafe extern "C" fn core_wallet_tx_builder_finalize( let signer = MnemonicResolverCoreSigner::new(core_signer_handle, wallet.wallet_id(), wallet.network()); - let finalized = runtime().block_on(wallet.core().finalize_transaction( + let reservation_only = (*builder).reservation_only; + let finalized = runtime().block_on(wallet.core().finalize_transaction_with_options( inner, account_type.funding_sources(), account_index, &signer, + reservation_only, )); let finalized = unwrap_result_or_return!(finalized); @@ -451,7 +457,11 @@ pub unsafe extern "C" fn core_wallet_tx_builder_new( network: FFINetwork, ) -> *mut FFITransactionBuilder { let inner = Box::into_raw(Box::new(TransactionBuilder::new())) as *mut c_void; - Box::into_raw(Box::new(FFITransactionBuilder { inner, network })) + Box::into_raw(Box::new(FFITransactionBuilder { + inner, + network, + reservation_only: false, + })) } /// # Safety @@ -619,11 +629,11 @@ pub unsafe extern "C" fn core_wallet_tx_builder_set_fee_rate( /// Fund the build from the inputs `core_wallet_tx_builder_add_inputs_from_outpoints` /// supplied, and nothing else. /// -/// Without this, the wallet-aware finalizers add every unreserved UTXO of the -/// funding account to the candidate pool, so seeding a subset does not restrict -/// what gets selected. A caller draining an account in batches that each stay -/// under the standard-transaction input limit needs this, or every batch sees -/// the whole account and fails with a too-many-inputs error. +/// Without this, the wallet-aware finalizers offer every unreserved UTXO of the +/// funding account alongside the seeded ones, so seeding a subset does not +/// restrict what gets selected. A caller draining an account in batches that +/// each stay under the standard-transaction input limit needs this, or every +/// batch sees the whole account and fails with a too-many-inputs error. /// /// # Safety /// `builder` must be a valid, non-destroyed pointer. @@ -632,11 +642,7 @@ pub unsafe extern "C" fn core_wallet_tx_builder_use_only_added_inputs( builder: *mut FFITransactionBuilder, ) -> PlatformWalletFFIResult { check_ptr!(builder); - - let b = (*builder).take_builder(); - let b = b.use_only_added_inputs(); - (*builder).store_builder(b); - + (*builder).reservation_only = true; PlatformWalletFFIResult::ok() } diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 44f9f797767..183dcb7fbbb 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -300,6 +300,26 @@ impl CoreWallet { /// Consume a configured builder, atomically fund and reserve its selected /// inputs, then sign without holding the wallet-manager lock. pub async fn finalize_transaction( + &self, + builder: TransactionBuilder, + sources: &[AccountTypePreference], + source_index: u32, + signer: &S, + ) -> Result { + self.finalize_transaction_with_options(builder, sources, source_index, signer, false) + .await + } + + /// `reservation_only` funds through + /// [`TransactionBuilder::add_funding_reservation_only`]: the sources take on + /// their reservation bookkeeping but offer no candidates, so the build spends + /// only the inputs already seeded on the builder. + /// + /// A caller draining an account in batches under the standard-transaction + /// input limit needs it — ordinary funding offers the whole account on top of + /// the batch, so every batch trips the cap and an account above it can never + /// be drained. + pub async fn finalize_transaction_with_options( &self, builder: TransactionBuilder, // The funding sources to POOL, in order — the first supplies the @@ -311,6 +331,7 @@ impl CoreWallet { sources: &[AccountTypePreference], source_index: u32, signer: &S, + reservation_only: bool, ) -> Result { let primary = *sources.first().ok_or_else(|| { PlatformWalletError::TransactionBuild("no funding sources named".into()) @@ -375,7 +396,11 @@ impl CoreWallet { paths.insert(utxo.address.clone(), path); } } - builder = builder.add_funding(managed, account); + builder = if reservation_only { + builder.add_funding_reservation_only(managed, account) + } else { + builder.add_funding(managed, account) + }; offered_accounts.push(at); } // A strict single-source SET selector (a DashPay preference From 28044ee26d8e4d3e3a0257c98a85e1a137d7c4e7 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:02:05 +0300 Subject: [PATCH 3/8] feat(platform-wallet): report the balance a pooled build can actually spend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `core_wallet_get_balance` sums every funding account the wallet has — CoinJoin included — and never consults a reservation set. A host gating its amount entry on it therefore offers money the build then refuses, and the shortfall surfaces as CorePooledInsufficientFunds only after the user has committed to an amount. Support ticket 32081 is the shape of it: a wallet reading 94 DASH, of which 0.0054 was actually spendable, everything else on the CoinJoin account the send pool excludes by design. The same mismatch produces the asset-lock shortfall on the Transparent to Shielded path. `pooled_spendable_balance` answers with the accounts `finalize_transaction` funds from, resolved through the same `resolve_source_accounts` and the same source list, counting only UTXOs coin selection accepts. Hosts read it instead of mirroring the pooling rule themselves — the mirror is what drifted here. Reservations are not subtracted: key-wallet keeps each account's ReservationSet private, so reading it needs an accessor there and a pin bump. Documented at every layer. That part is transient — a reservation is released when its spend is processed, on a definitive rejection, at the TTL, or on restart — while the account-set difference is permanent and was the whole of the reported shortfall. --- .../src/core_wallet/transaction_builder.rs | 33 +++++++++++ .../src/wallet/core/transaction.rs | 55 +++++++++++++++++++ .../CoreWallet/ManagedCoreWallet.swift | 21 +++++++ 3 files changed, 109 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs index 79d3c00e96d..a4ba6ffa4db 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs @@ -635,6 +635,39 @@ pub unsafe extern "C" fn core_wallet_tx_builder_set_fee_rate( /// each stay under the standard-transaction input limit needs this, or every /// batch sees the whole account and fails with a too-many-inputs error. /// +/// The balance a build funded by `account_type` could actually select from — the +/// same accounts `core_wallet_tx_builder_finalize` would fund from, counting +/// only UTXOs coin selection accepts. +/// +/// Gate amount entry on this rather than on `core_wallet_get_balance`, which +/// sums every funding account the wallet has — CoinJoin included — and so +/// reports money a build then refuses. +/// +/// Reservations are not subtracted; see `CoreWallet::pooled_spendable_balance`. +/// +/// # Safety +/// `out_balance` must be a valid, writable pointer. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_pooled_spendable_balance( + wallet: Handle, + account_type: CoreAccountTypeFFI, + account_index: u32, + out_balance: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_balance); + *out_balance = 0; + + let wallet = unwrap_option_or_return!(PLATFORM_WALLET_STORAGE.with_item(wallet, |w| w.clone())); + let balance = unwrap_result_or_return!(runtime().block_on( + wallet + .core() + .pooled_spendable_balance(account_type.funding_sources(), account_index) + )); + + *out_balance = balance; + PlatformWalletFFIResult::ok() +} + /// # Safety /// `builder` must be a valid, non-destroyed pointer. #[no_mangle] diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 183dcb7fbbb..943da849334 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -299,6 +299,61 @@ pub(crate) fn resolve_source_accounts( impl CoreWallet { /// Consume a configured builder, atomically fund and reserve its selected /// inputs, then sign without holding the wallet-manager lock. + /// The balance a pooled build could actually select from — the same + /// accounts [`Self::finalize_transaction`] funds from, counting only UTXOs + /// coin selection would accept. + /// + /// Hosts gate their amount entry on this. The wallet-level balance is a + /// strict superset: it sums every funding account, CoinJoin included, and + /// never consults a reservation set, so gating on it offers money the build + /// then refuses — the shortfall surfacing as + /// [`CorePooledInsufficientFunds`](PlatformWalletError::CorePooledInsufficientFunds) + /// after the user has already committed to an amount. + /// + /// Missing sources are skipped, as in a pooled build: a wallet without a + /// BIP32 account or without DashPay contacts still has a spendable balance. + /// + /// Reservations are NOT subtracted: key-wallet keeps each account's + /// `ReservationSet` private, so reading it needs an accessor there and a pin + /// bump. The figure is therefore optimistic by whatever another in-flight + /// build currently holds — transient by construction, since a reservation is + /// released when its spend is processed, on a definitive broadcast + /// rejection, at the TTL, or on restart. The account-set mismatch this fixes + /// is permanent, and was the whole of the shortfall in the report that + /// prompted it (support ticket 32081: 0.0054 DASH offered as spendable + /// against a 94 DASH balance, all of it CoinJoin). + pub async fn pooled_spendable_balance( + &self, + sources: &[AccountTypePreference], + source_index: u32, + ) -> Result { + let mut manager = self.wallet_manager.write().await; + let (_wallet, info) = manager + .get_wallet_and_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound("wallet not found".into()))?; + let height = info.core_wallet.last_processed_height(); + + let mut seen: HashSet = HashSet::new(); + let mut total: u64 = 0; + for &preference in sources { + for at in resolve_source_accounts(&info.core_wallet.accounts, preference, source_index) + { + if !seen.insert(at) { + continue; + } + let Some(managed) = info.core_wallet.accounts.funds_account_mut(&at) else { + continue; + }; + total += managed + .spendable_utxos(height) + .iter() + .map(|utxo| utxo.value()) + .sum::(); + } + } + Ok(total) + } + pub async fn finalize_transaction( &self, builder: TransactionBuilder, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift index 8ce56359353..d01496a8fb1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift @@ -89,6 +89,27 @@ public class ManagedCoreWallet { ) } + /// The balance a build funded by `accountType` could actually select from — + /// the same accounts `finalizeAtomic` funds from, counting only UTXOs coin + /// selection accepts. + /// + /// Gate amount entry on this, not on ``balance()``: that sums every funding + /// account the wallet has, CoinJoin included, so a wallet holding mixed + /// coins is offered money the build then refuses. + /// + /// Reservations are not subtracted — an in-flight build's inputs still + /// count here. That is transient; the account-set difference is not. + public func pooledSpendableBalance( + accountType: CoreTransactionBuilder.AccountType = .allSpendable, + accountIndex: UInt32 = 0 + ) throws -> UInt64 { + var balance: UInt64 = 0 + try core_wallet_pooled_spendable_balance( + handle, accountType.ffi, accountIndex, &balance + ).check() + return balance + } + /// Get the network this wallet operates on. public func network() throws -> Network { var ffiNetwork = FFINetwork(0) From 96c8be13373204adb02eefafc28bebfd83bf1995 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:17:12 +0300 Subject: [PATCH 4/8] refactor(platform-wallet): drive both funding paths from one account resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the new balance call had already drifted from the funding loop it was meant to be the truth for — the point of the PR, reproduced inside it. `resolved_funding_accounts` now names the accounts, and both `finalize_transaction_with_options` and `pooled_spendable_balance` are driven from it. Three divergences go with it: - the balance counted an account that resolved only on the managed side, while funding requires both halves and skips otherwise, so it over-reported exactly the shape this PR removes; - single-source selectors returned Ok(0) where funding errors WalletNotFound, giving two answers to the same selector — the strict rule, including the empty SET selector case, now lives in the resolver; - the dedup set existed twice. The balance also takes the read lock rather than the write lock: nothing here mutates, and gating amount entry means a call per keystroke against concurrent finalizers and sync writers. The fee is documented rather than subtracted. Doing it here means re-declaring key-wallet's input and output sizes in this crate, which is the duplication the call exists to remove; the estimate belongs beside FeeRate and MAX_STANDARD_TX_INPUTS. --- .../src/wallet/core/transaction.rs | 136 ++++++++++++------ 1 file changed, 96 insertions(+), 40 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 943da849334..03f068c19ff 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use dashcore::{Address, OutPoint, Transaction}; use key_wallet::account::AccountType; +use key_wallet::managed_account::managed_account_collection::ManagedAccountCollection; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionError; use key_wallet::wallet::managed_wallet_info::transaction_builder::{ @@ -17,6 +18,7 @@ use key_wallet::wallet::managed_wallet_info::transaction_builder::{ }; use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::Wallet; use key_wallet::{DerivationPath, ReservationToken, Utxo}; use super::{CoreWallet, WalletGeneration}; @@ -267,6 +269,58 @@ pub const ASSET_LOCK_FUNDING_SOURCES: [AccountTypePreference; 3] = SEND_FUNDING_ /// DashPay source. A set selector matching nothing resolves to an empty list, /// not an error — a wallet with no contacts still sends from its standard /// accounts. +/// The accounts a pooled build will actually fund from, in funding order and +/// deduplicated: those `resolve_source_accounts` names AND that resolve on both +/// halves — the keys side (`wallet.accounts`) and the managed side +/// (`info.core_wallet.accounts`). An account present in only one is skipped, +/// because funding needs both. +/// +/// `strict` reproduces the single-source contract: naming ONE account is an +/// explicit request for it, so a miss is an error rather than a silent skip. A +/// pooled call skips instead — a wallet without a BIP32 account or without +/// DashPay contacts still funds from what it has. +/// +/// Shared by `finalize_transaction_with_options` and +/// `pooled_spendable_balance` so the set one reports can never drift from the +/// set the other funds. +pub(crate) fn resolved_funding_accounts( + accounts: &ManagedAccountCollection, + wallet: &Wallet, + sources: &[AccountTypePreference], + source_index: u32, + strict: bool, +) -> Result, PlatformWalletError> { + let mut seen: HashSet = HashSet::new(); + let mut resolved: Vec = Vec::new(); + for &preference in sources { + for at in resolve_source_accounts(accounts, preference, source_index) { + if !seen.insert(at) { + continue; + } + if wallet.accounts.account_of_type(at).is_none() + || accounts.funds_account(&at).is_none() + { + if strict { + return Err(PlatformWalletError::WalletNotFound(format!( + "wallet account {preference:?} #{source_index} not found" + ))); + } + continue; + } + resolved.push(at); + } + } + // A strict SET selector (a DashPay preference naming zero accounts) is a + // miss too: the caller asked for exactly those funds. + if strict && resolved.is_empty() { + return Err(PlatformWalletError::WalletNotFound(format!( + "wallet account {:?} #{source_index} not found", + sources.first() + ))); + } + Ok(resolved) +} + pub(crate) fn resolve_source_accounts( accounts: &key_wallet::account::ManagedAccountCollection, preference: AccountTypePreference, @@ -313,6 +367,18 @@ impl CoreWallet { /// Missing sources are skipped, as in a pooled build: a wallet without a /// BIP32 account or without DashPay contacts still has a spendable balance. /// + /// **Gross, not net of fee.** This is the sum a build may draw on; a build + /// needs `amount + fee`, so a host offering this verbatim as a max amount + /// moves the shortfall from the CoinJoin edge to the max-amount edge rather + /// than removing it. Hosts must keep reserving fee headroom, as they did + /// against the wallet-wide figure this replaces — the fee is unchanged by + /// this call, only the account set is. + /// + /// Subtracting it here would mean re-declaring key-wallet's per-input and + /// per-output sizes in this crate, which is the same duplication the call + /// exists to remove. The estimate belongs beside `MAX_STANDARD_TX_INPUTS` + /// and `FeeRate`, in key-wallet. + /// /// Reservations are NOT subtracted: key-wallet keeps each account's /// `ReservationSet` private, so reading it needs an accessor there and a pin /// bump. The figure is therefore optimistic by whatever another in-flight @@ -327,29 +393,31 @@ impl CoreWallet { sources: &[AccountTypePreference], source_index: u32, ) -> Result { - let mut manager = self.wallet_manager.write().await; - let (_wallet, info) = manager - .get_wallet_and_info_mut(&self.wallet_id) + let manager = self.wallet_manager.read().await; + let (wallet, info) = manager + .get_wallet_and_info(&self.wallet_id) .ok_or_else(|| PlatformWalletError::WalletNotFound("wallet not found".into()))?; let height = info.core_wallet.last_processed_height(); - let mut seen: HashSet = HashSet::new(); + // Same resolver, same strictness rule, as the funding path. + let resolved = resolved_funding_accounts( + &info.core_wallet.accounts, + wallet, + sources, + source_index, + sources.len() == 1, + )?; + let mut total: u64 = 0; - for &preference in sources { - for at in resolve_source_accounts(&info.core_wallet.accounts, preference, source_index) - { - if !seen.insert(at) { - continue; - } - let Some(managed) = info.core_wallet.accounts.funds_account_mut(&at) else { - continue; - }; - total += managed - .spendable_utxos(height) - .iter() - .map(|utxo| utxo.value()) - .sum::(); - } + for at in resolved { + let Some(managed) = info.core_wallet.accounts.funds_account(&at) else { + continue; + }; + total += managed + .spendable_utxos(height) + .iter() + .map(|utxo| utxo.value()) + .sum::(); } Ok(total) } @@ -426,24 +494,20 @@ impl CoreWallet { // build-time cleanup only, and the contributor list stored on the // transaction is derived from the selected inputs below. let mut offered_accounts: Vec = Vec::new(); - let mut offered_seen: HashSet = HashSet::new(); let mut paths: HashMap = HashMap::new(); - for &preference in sources { - for at in - resolve_source_accounts(&info.core_wallet.accounts, preference, source_index) - { - if !offered_seen.insert(at) { - continue; - } + let resolved = resolved_funding_accounts( + &info.core_wallet.accounts, + wallet, + sources, + source_index, + strict, + )?; + { + for at in resolved { let (Some(account), Some(managed)) = ( wallet.accounts.account_of_type(at), info.core_wallet.accounts.funds_account_mut(&at), ) else { - if strict { - return Err(PlatformWalletError::WalletNotFound(format!( - "wallet account {preference:?} #{source_index} not found" - ))); - } continue; }; for utxo in managed.utxos.values() { @@ -458,14 +522,6 @@ impl CoreWallet { }; offered_accounts.push(at); } - // A strict single-source SET selector (a DashPay preference - // naming zero accounts) also errors — the caller asked for - // exactly those funds. - if strict && offered_accounts.is_empty() { - return Err(PlatformWalletError::WalletNotFound(format!( - "wallet account {preference:?} #{source_index} not found" - ))); - } } if offered_accounts.is_empty() { return Err(PlatformWalletError::WalletNotFound(format!( From bb3ea98420b2049169549a99095893c92a7051f6 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:50:43 +0300 Subject: [PATCH 5/8] test(platform-wallet): pin pooled_spendable_balance to the funding set Review asked for this and for the doc capture; both were still open. The getter had no test at all, which is odd for a function whose entire justification is that it must not drift from finalize_transaction's account set - the drift it replaces is a host-side hand-copy of the same rule. Three couplings are now pinned: the pooled selector sums both standard families (1_400_000 from the dual fixture), a DashPay contact account contributes through AllDashpayReceivingFunds, and a single-source miss errors with WalletNotFound exactly as single_source_missing_account_still_errors requires of finalize, rather than answering Ok(0) that a host renders as insufficient funds. A fourth pins the ticket itself: a wallet holding only CoinJoin reports 0 spendable, which is the 94-DASH-against-0.0054 gap from 32081 in miniature. Verified the strictness leg kills its mutant (strict -> false gives Ok(0)). Also return the doc line the insertion captured: finalize_transaction's summary had become the first line of pooled_spendable_balance's doc, leaving finalize undocumented and describing a read-only getter as reserving and signing. The FFI-side twin of this was resolved in the v4.2-dev merge. --- .../src/wallet/core/transaction.rs | 101 +++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index a2b17b83d6c..01c1d75f226 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -351,8 +351,6 @@ pub(crate) fn resolve_source_accounts( } impl CoreWallet { - /// Consume a configured builder, atomically fund and reserve its selected - /// inputs, then sign without holding the wallet-manager lock. /// The balance a pooled build could actually select from — the same /// accounts [`Self::finalize_transaction`] funds from, counting only UTXOs /// coin selection would accept. @@ -422,6 +420,8 @@ impl CoreWallet { Ok(total) } + /// Consume a configured builder, atomically fund and reserve its selected + /// inputs, then sign without holding the wallet-manager lock. pub async fn finalize_transaction( &self, builder: TransactionBuilder, @@ -1022,6 +1022,103 @@ mod tests { ); } + /// The coupling this getter exists to enforce: the total must come from the + /// same accounts `finalize_transaction` funds from, and the strictness rule + /// must match too. Nothing else pins that — the two are separate code paths + /// over the same rule, which is exactly how the host's hand-copy drifted in + /// the first place (support ticket 32081). + #[tokio::test] + async fn pooled_spendable_balance_reports_the_funding_set_and_stays_strict() { + let (manager, wallet_id, generation, _signer) = + funded_wallet_manager_dual_standard(&[700_000], &[700_000]).await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let core = CoreWallet::new( + sdk, + manager, + wallet_id, + Arc::new(AlwaysOkBroadcaster), + generation, + ); + + // The pooled selector sums BOTH standard families — the same set + // `pooled_send_spans_families_and_abandon_releases_all` proves a build + // draws on. An account counted here but skipped by the build (or the + // reverse) breaks this number. + assert_eq!( + core.pooled_spendable_balance(&crate::SEND_FUNDING_SOURCES, 0) + .await + .expect("pooled balance"), + 1_400_000, + "the pooled figure must be both families, and nothing else" + ); + + // A single-family selector sees only its own family. + assert_eq!( + core.pooled_spendable_balance(&[AccountTypePreference::BIP44], 0) + .await + .expect("bip44 balance"), + 700_000 + ); + + // Strictness matches `single_source_missing_account_still_errors`: a + // single-source miss is an error here too, never a silent `Ok(0)` that + // the host would render as "insufficient funds" while the matching + // `finalize` call says "no such account". + let missed = core + .pooled_spendable_balance(&[AccountTypePreference::BIP44], 7) + .await; + assert!( + matches!(missed, Err(PlatformWalletError::WalletNotFound(_))), + "a single-source miss must error as it does in finalize, got {missed:?}" + ); + } + + /// The DashPay leg, and the exclusion the ticket turned on: contact funds + /// count toward the pooled figure, CoinJoin does not. + #[tokio::test] + async fn pooled_spendable_balance_counts_contacts_and_excludes_coinjoin() { + let (manager, wallet_id, generation, _signer, _contact) = + funded_wallet_manager_with_contact(&[700_000], &[700_000]).await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let core = CoreWallet::new( + sdk, + manager, + wallet_id, + Arc::new(AlwaysOkBroadcaster), + generation, + ); + assert_eq!( + core.pooled_spendable_balance(&crate::SEND_FUNDING_SOURCES, 0) + .await + .expect("pooled balance"), + 1_400_000, + "AllDashpayReceivingFunds must contribute the contact account's funds" + ); + + // A wallet whose money is ALL in CoinJoin: the wallet-level balance the + // host used to gate on reports the full 10_000_000, the pooled figure + // reports nothing, and the build agrees with the pooled figure. That + // gap, on a 94 DASH wallet, is the whole of ticket 32081. + let (manager, wallet_id, generation, _signer) = + crate::test_support::funded_coinjoin_wallet_manager().await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let coinjoin_only = CoreWallet::new( + sdk, + manager, + wallet_id, + Arc::new(AlwaysOkBroadcaster), + generation, + ); + assert_eq!( + coinjoin_only + .pooled_spendable_balance(&crate::SEND_FUNDING_SOURCES, 0) + .await + .expect("pooled balance"), + 0, + "CoinJoin is excluded from the send pool, so it must not be offered as spendable" + ); + } + fn payment_builder(tag: u8) -> TransactionBuilder { TransactionBuilder::new().add_output( &DashAddress::dummy(Network::Testnet, usize::from(tag)), From dd92e1b30723451ff9024b181a59342cab7e017b Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:00:58 +0300 Subject: [PATCH 6/8] feat(platform-wallet): price the fee into a pooled send-max figure Review's remaining point: the gross balance is not an amount a build accepts, so a host wiring a max control to it relocates the shortfall this API removes from the CoinJoin edge to the max-amount edge. Answered with the number rather than a caveat in the doc. pooled_max_sendable prices the fee off the inputs that spending everything would take - one output, no change - using key-wallet's own estimate_tx_size and FeeRate. The earlier objection that this would re-declare key-wallet's per-input and per-output sizes here was wrong: both are public, and the per-input cost is taken by difference from the estimator rather than as a constant, so it cannot drift from what the build charges. A UTXO whose value does not cover the fee its own input adds is excluded, since including it lowers the answer - the maximum is a selection problem, not a subtraction. Kept additive: pooled_spendable_balance still reports the gross sum, which is what an available-balance line should show, and its doc now points at the net figure instead of asking hosts to guess headroom. New FFI entry point takes a fee rate (0 = the builder default) and the Swift wrapper mirrors it. Two tests, both mutation-checked. The first settles the question by building rather than by arithmetic: the gross figure fails finalize_transaction, the net one builds and takes both accounts' inputs with it - asserting only max < gross would pass an estimate that is merely close. The second pins the dust rule. 942 lib tests pass. --- .../src/core_wallet/transaction_builder.rs | 34 +++ .../src/wallet/core/transaction.rs | 210 +++++++++++++++++- .../CoreWallet/ManagedCoreWallet.swift | 29 +++ 3 files changed, 262 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs index f6449ce3d02..6a57867bab6 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs @@ -693,6 +693,40 @@ pub unsafe extern "C" fn core_wallet_pooled_spendable_balance( PlatformWalletFFIResult::ok() } +/// The largest amount a build funded by `account_type` could actually pay out, +/// net of the fee spending it costs — what a "send max" control must use. +/// +/// `core_wallet_pooled_spendable_balance` is the gross figure: entering it +/// verbatim as an amount fails, because a build needs `amount + fee`. This +/// prices the fee off the inputs that spending everything would take, at +/// `fee_rate_sat_per_kb` — pass 0 for the same default `TransactionBuilder` +/// starts from, or the rate the host sets on its builders. +/// +/// # Safety +/// `out_max_sendable` must be a valid, writable pointer. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_pooled_max_sendable( + wallet: Handle, + account_type: CoreAccountTypeFFI, + account_index: u32, + fee_rate_sat_per_kb: u64, + out_max_sendable: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_max_sendable); + *out_max_sendable = 0; + + let fee_rate = (fee_rate_sat_per_kb != 0).then(|| FeeRate::new(fee_rate_sat_per_kb)); + let wallet = unwrap_option_or_return!(PLATFORM_WALLET_STORAGE.with_item(wallet, |w| w.clone())); + let max_sendable = unwrap_result_or_return!(runtime().block_on(wallet.core().pooled_max_sendable( + account_type.funding_sources(), + account_index, + fee_rate, + ))); + + *out_max_sendable = max_sendable; + PlatformWalletFFIResult::ok() +} + /// # Safety /// `builder` must be a valid, non-destroyed pointer. #[no_mangle] diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 01c1d75f226..7bb09a056b8 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -13,6 +13,7 @@ use key_wallet::account::AccountType; use key_wallet::managed_account::managed_account_collection::ManagedAccountCollection; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionError; +use key_wallet::wallet::managed_wallet_info::fee::{estimate_tx_size, FeeRate}; use key_wallet::wallet::managed_wallet_info::transaction_builder::{ BuilderError, TransactionBuilder, TransactionSigner, }; @@ -365,17 +366,11 @@ impl CoreWallet { /// Missing sources are skipped, as in a pooled build: a wallet without a /// BIP32 account or without DashPay contacts still has a spendable balance. /// - /// **Gross, not net of fee.** This is the sum a build may draw on; a build - /// needs `amount + fee`, so a host offering this verbatim as a max amount - /// moves the shortfall from the CoinJoin edge to the max-amount edge rather - /// than removing it. Hosts must keep reserving fee headroom, as they did - /// against the wallet-wide figure this replaces — the fee is unchanged by - /// this call, only the account set is. - /// - /// Subtracting it here would mean re-declaring key-wallet's per-input and - /// per-output sizes in this crate, which is the same duplication the call - /// exists to remove. The estimate belongs beside `MAX_STANDARD_TX_INPUTS` - /// and `FeeRate`, in key-wallet. + /// **Gross, not net of fee** — the sum a build may draw on, which is what an + /// "available balance" line should show. A build needs `amount + fee`, so + /// this is NOT the number to put behind a max/"send all" control: use + /// [`Self::pooled_max_sendable`], which prices the fee off the inputs that + /// spending it all would take. /// /// Reservations are NOT subtracted: key-wallet keeps each account's /// `ReservationSet` private, so reading it needs an accessor there and a pin @@ -420,6 +415,84 @@ impl CoreWallet { Ok(total) } + /// The largest amount a pooled build could actually pay out, net of the fee + /// that spending it costs — the figure a "send max" control must use. + /// + /// [`Self::pooled_spendable_balance`] is the gross sum a build may draw on. + /// Entering that verbatim as an amount fails: coin selection clears its + /// `total_available >= amount` check and then cannot cover `amount + fee`, + /// so the shortfall this API exists to remove simply moves from the CoinJoin + /// edge to the max-amount edge. The fee is not a rounding concern here — + /// with `use_only_added_inputs` draining accounts hundreds of UTXOs at a + /// time, the input count, and so the fee, runs far above dust. + /// + /// Computed the way "spend everything" actually builds: one output, no + /// change, sizes from key-wallet's own [`estimate_tx_size`] and + /// [`FeeRate`] rather than sizes re-declared here. A UTXO whose value does + /// not cover the fee its own input adds is left out — including it would + /// lower the answer — so this is a true maximum, not a subtraction from the + /// gross figure. + /// + /// `fee_rate` defaults to [`FeeRate::normal()`], which is what + /// `TransactionBuilder::new` starts from; pass the host's rate if it sets + /// one, or the answer will not match the build. + /// + /// Two limits are deliberately NOT modelled, both of which can only make the + /// real ceiling lower: reservations held by another in-flight build (see + /// [`Self::pooled_spendable_balance`]), and the standard-transaction input + /// cap, which key-wallet keeps private and which a pool above it must reach + /// through batched sends rather than a larger amount. + pub async fn pooled_max_sendable( + &self, + sources: &[AccountTypePreference], + source_index: u32, + fee_rate: Option, + ) -> Result { + let fee_rate = fee_rate.unwrap_or_else(FeeRate::normal); + // Per-input and per-output costs derived from key-wallet's estimator by + // difference, so this crate never re-declares a size that could drift + // from the one the build charges. + let empty = estimate_tx_size(0, 1, false); + let per_input = estimate_tx_size(1, 1, false).saturating_sub(empty); + + let manager = self.wallet_manager.read().await; + let (wallet, info) = manager + .get_wallet_and_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound("wallet not found".into()))?; + let height = info.core_wallet.last_processed_height(); + + let resolved = resolved_funding_accounts( + &info.core_wallet.accounts, + wallet, + sources, + source_index, + sources.len() == 1, + )?; + + // A UTXO earns its place only if it brings in more than its own input + // costs at this rate; the rest are dead weight and are dropped. + let input_cost = fee_rate.calculate_fee(per_input); + let mut selected: u64 = 0; + let mut count: usize = 0; + for at in resolved { + let Some(managed) = info.core_wallet.accounts.funds_account(&at) else { + continue; + }; + for utxo in managed.spendable_utxos(height) { + if utxo.value() > input_cost { + selected += utxo.value(); + count += 1; + } + } + } + if count == 0 { + return Ok(0); + } + + let fee = fee_rate.calculate_fee(estimate_tx_size(count, 1, false)); + Ok(selected.saturating_sub(fee)) + } + /// Consume a configured builder, atomically fund and reserve its selected /// inputs, then sign without holding the wallet-manager lock. pub async fn finalize_transaction( @@ -1073,6 +1146,121 @@ mod tests { ); } + /// The fee question, settled by building rather than by arithmetic: the + /// gross figure is NOT an amount a build accepts, and the net one is. + /// + /// This is the failure review described — a host wiring "send max" to the + /// gross balance and passing it through verbatim relocates the shortfall + /// from the CoinJoin edge to the max-amount edge. Asserting only that + /// `max < gross` would not catch an estimate that is merely close; running + /// both numbers through `finalize_transaction` does. + #[tokio::test] + async fn pooled_max_sendable_is_an_amount_a_build_accepts() { + let (manager, wallet_id, generation, signer) = + funded_wallet_manager_dual_standard(&[700_000], &[700_000]).await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let core = CoreWallet::new( + sdk, + manager, + wallet_id, + Arc::new(AlwaysOkBroadcaster), + generation, + ); + + let gross = core + .pooled_spendable_balance(&crate::SEND_FUNDING_SOURCES, 0) + .await + .expect("gross balance"); + let max = core + .pooled_max_sendable(&crate::SEND_FUNDING_SOURCES, 0, None) + .await + .expect("max sendable"); + assert!( + max < gross, + "the fee has to come off somewhere: gross {gross}, max {max}" + ); + + let spend = |amount: u64, tag: u8| { + TransactionBuilder::new().add_output( + &DashAddress::dummy(Network::Testnet, usize::from(tag)), + amount, + ) + }; + + // The gross figure is unbuildable — the whole of review's point. + let over = core + .finalize_transaction(spend(gross, 70), &crate::SEND_FUNDING_SOURCES, 0, &signer) + .await; + assert!( + over.is_err(), + "the gross balance must not be offerable as an amount, got {over:?}" + ); + + // The net figure builds, and takes every UTXO with it. + let finalized = core + .finalize_transaction(spend(max, 71), &crate::SEND_FUNDING_SOURCES, 0, &signer) + .await + .expect("max sendable must be an amount a build accepts"); + assert_eq!( + finalized.transaction().input.len(), + 2, + "spending the maximum must draw on both funded accounts" + ); + core.abandon_transaction(&finalized).await; + } + + /// A UTXO that does not cover the fee its own input adds must be left out: + /// including it would LOWER the answer, so the maximum is a selection + /// problem, not a subtraction from the gross figure. Without this the doc's + /// "true maximum" claim is untested. + #[tokio::test] + async fn pooled_max_sendable_drops_utxos_that_cost_more_than_they_bring() { + let sdk = || Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + + let (manager, wallet_id, generation, _signer) = + funded_wallet_manager_dual_standard(&[700_000], &[700_000]).await; + let plain = CoreWallet::new( + sdk(), + manager, + wallet_id, + Arc::new(AlwaysOkBroadcaster), + generation, + ); + let baseline = plain + .pooled_max_sendable(&crate::SEND_FUNDING_SOURCES, 0, None) + .await + .expect("max sendable"); + + // The same wallet plus one UTXO worth far less than the ~296 duffs its + // input costs at the default rate. + let (manager, wallet_id, generation, _signer) = + funded_wallet_manager_dual_standard(&[700_000, 100], &[700_000]).await; + let with_dust = CoreWallet::new( + sdk(), + manager, + wallet_id, + Arc::new(AlwaysOkBroadcaster), + generation, + ); + + assert_eq!( + with_dust + .pooled_spendable_balance(&crate::SEND_FUNDING_SOURCES, 0) + .await + .expect("gross balance"), + 1_400_100, + "the gross figure counts every spendable UTXO, dust included" + ); + assert_eq!( + with_dust + .pooled_max_sendable(&crate::SEND_FUNDING_SOURCES, 0, None) + .await + .expect("max sendable"), + baseline, + "a UTXO that cannot pay for its own input must not move the maximum" + ); + } + /// The DashPay leg, and the exclusion the ticket turned on: contact funds /// count toward the pooled figure, CoinJoin does not. #[tokio::test] diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift index d01496a8fb1..e69418fa23f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift @@ -99,6 +99,10 @@ public class ManagedCoreWallet { /// /// Reservations are not subtracted — an in-flight build's inputs still /// count here. That is transient; the account-set difference is not. + /// + /// This is the GROSS sum, which is what an "available" line should show. A + /// build needs `amount + fee`, so do not put this behind a max/"send all" + /// control — use ``pooledMaxSendable(accountType:accountIndex:feeRateSatPerKb:)``. public func pooledSpendableBalance( accountType: CoreTransactionBuilder.AccountType = .allSpendable, accountIndex: UInt32 = 0 @@ -110,6 +114,31 @@ public class ManagedCoreWallet { return balance } + /// The largest amount a build funded by `accountType` could actually pay + /// out, net of the fee spending it costs — the figure a max/"send all" + /// control must use. + /// + /// Entering ``pooledSpendableBalance(accountType:accountIndex:)`` verbatim + /// as an amount fails: coin selection clears its available-versus-amount + /// check and then cannot cover `amount + fee`. This prices the fee off the + /// inputs that spending everything would take, so it needs no host-side + /// headroom guess on top. + /// + /// `feeRateSatPerKb` defaults to 0, meaning the same rate a builder starts + /// from; pass the host's rate if it sets one on its builders, or the answer + /// will not match what the build charges. + public func pooledMaxSendable( + accountType: CoreTransactionBuilder.AccountType = .allSpendable, + accountIndex: UInt32 = 0, + feeRateSatPerKb: UInt64 = 0 + ) throws -> UInt64 { + var maxSendable: UInt64 = 0 + try core_wallet_pooled_max_sendable( + handle, accountType.ffi, accountIndex, feeRateSatPerKb, &maxSendable + ).check() + return maxSendable + } + /// Get the network this wallet operates on. public func network() throws -> Network { var ffiNetwork = FFINetwork(0) From 6444e67896952b3eb30304584546a059e2d4b5f3 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:06:14 +0300 Subject: [PATCH 7/8] style: rustfmt the new pooled-max-sendable FFI entry point cargo fmt --check was the only red job; nothing else in the workspace differs. --- .../src/core_wallet/transaction_builder.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs index 6a57867bab6..16866546414 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs @@ -717,11 +717,11 @@ pub unsafe extern "C" fn core_wallet_pooled_max_sendable( let fee_rate = (fee_rate_sat_per_kb != 0).then(|| FeeRate::new(fee_rate_sat_per_kb)); let wallet = unwrap_option_or_return!(PLATFORM_WALLET_STORAGE.with_item(wallet, |w| w.clone())); - let max_sendable = unwrap_result_or_return!(runtime().block_on(wallet.core().pooled_max_sendable( - account_type.funding_sources(), - account_index, - fee_rate, - ))); + let max_sendable = unwrap_result_or_return!(runtime().block_on( + wallet + .core() + .pooled_max_sendable(account_type.funding_sources(), account_index, fee_rate) + )); *out_max_sendable = max_sendable; PlatformWalletFFIResult::ok() From f95e4015c5b4771198bcab11da1cfbf22b721779 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:18:45 +0300 Subject: [PATCH 8/8] fix(platform-wallet): cap pooled_max_sendable at the standard input limit A pool holding more eligible UTXOs than one transaction can carry was summed whole, so the reported maximum named an amount no build could reach - the same class of over-report this API exists to remove, just at a different edge. The figure is now taken from the largest MAX_STANDARD_TX_INPUTS UTXOs, and the doc says the money beyond the cap is unreachable in a single send rather than gone. key-wallet enforces the limit but keeps the constant private, so it is mirrored here. Rather than trust the mirror, the regression test builds against a wallet holding one UTXO more than the cap: the uncapped amount is refused, the capped one builds and fills the transaction exactly to the cap. Raising the mirror above key-wallet's real limit therefore reds the test - verified with 600. Lowering it does not, and cannot: every assertion is written in terms of the mirror and moves with it. Both the constant's doc and the test say so plainly rather than claiming a guarantee in both directions; under-reporting is conservative, over-reporting is the failure. Making the key-wallet constant public - it sits one line from MAX_STANDARD_OP_RETURN_BYTES, which was made public for this exact reason - would remove the mirror, at the cost of a pin bump on this branch. 943 lib tests pass. --- .../src/wallet/core/transaction.rs | 145 ++++++++++++++++-- 1 file changed, 130 insertions(+), 15 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 7bb09a056b8..5e41ace2315 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -351,6 +351,20 @@ pub(crate) fn resolve_source_accounts( .collect() } +/// The most inputs one standard transaction may carry. key-wallet enforces this +/// (`BuilderError::TooManyInputs`) but keeps its constant private, so the value +/// is mirrored here rather than imported. +/// +/// A mirrored constant is exactly the hand-copy that drifts, so the direction +/// that matters is pinned by behaviour rather than by trust: +/// `pooled_max_sendable_respects_the_input_cap` builds a transaction filled to +/// this number, so a value ABOVE key-wallet's real limit turns red — that is +/// the direction that breaks the promise, since it names an amount no build can +/// reach. A value below it cannot be caught the same way (a test written in +/// terms of the mirror moves with it) and is merely conservative: the maximum +/// is under-reported and the last UTXOs stay unreachable in one send. +const MAX_STANDARD_TX_INPUTS: usize = 500; + impl CoreWallet { /// The balance a pooled build could actually select from — the same /// accounts [`Self::finalize_transaction`] funds from, counting only UTXOs @@ -437,11 +451,14 @@ impl CoreWallet { /// `TransactionBuilder::new` starts from; pass the host's rate if it sets /// one, or the answer will not match the build. /// - /// Two limits are deliberately NOT modelled, both of which can only make the - /// real ceiling lower: reservations held by another in-flight build (see - /// [`Self::pooled_spendable_balance`]), and the standard-transaction input - /// cap, which key-wallet keeps private and which a pool above it must reach - /// through batched sends rather than a larger amount. + /// A pool holding more than [`MAX_STANDARD_TX_INPUTS`] eligible UTXOs is + /// capped at its largest that many: one transaction cannot carry the rest, + /// so offering their value would name an amount no build could reach. Money + /// beyond the cap is not lost, only unreachable in a single send. + /// + /// One limit is still NOT modelled, and it can only make the real ceiling + /// lower, never higher: reservations held by another in-flight build (see + /// [`Self::pooled_spendable_balance`]). pub async fn pooled_max_sendable( &self, sources: &[AccountTypePreference], @@ -472,24 +489,33 @@ impl CoreWallet { // A UTXO earns its place only if it brings in more than its own input // costs at this rate; the rest are dead weight and are dropped. let input_cost = fee_rate.calculate_fee(per_input); - let mut selected: u64 = 0; - let mut count: usize = 0; + let mut values: Vec = Vec::new(); for at in resolved { let Some(managed) = info.core_wallet.accounts.funds_account(&at) else { continue; }; - for utxo in managed.spendable_utxos(height) { - if utxo.value() > input_cost { - selected += utxo.value(); - count += 1; - } - } + values.extend( + managed + .spendable_utxos(height) + .iter() + .map(|utxo| utxo.value()) + .filter(|value| *value > input_cost), + ); } - if count == 0 { + if values.is_empty() { return Ok(0); } - let fee = fee_rate.calculate_fee(estimate_tx_size(count, 1, false)); + // One transaction cannot carry more inputs than the relay cap, so a pool + // above it can only offer its largest `MAX_STANDARD_TX_INPUTS` — asking + // for more would need a build key-wallet refuses outright. + if values.len() > MAX_STANDARD_TX_INPUTS { + values.sort_unstable_by(|a, b| b.cmp(a)); + values.truncate(MAX_STANDARD_TX_INPUTS); + } + + let selected: u64 = values.iter().sum(); + let fee = fee_rate.calculate_fee(estimate_tx_size(values.len(), 1, false)); Ok(selected.saturating_sub(fee)) } @@ -923,6 +949,7 @@ mod tests { funded_wallet_manager_with_contact, AlwaysMaybeSentBroadcaster, AlwaysOkBroadcaster, AlwaysRejectedBroadcaster, WalletSigner, }; + use crate::wallet::core::transaction::MAX_STANDARD_TX_INPUTS; use crate::wallet::core::CoreWallet; use crate::PlatformWalletError; @@ -1261,6 +1288,94 @@ mod tests { ); } + /// The input cap, and the value of `MAX_STANDARD_TX_INPUTS` itself. + /// + /// A wallet holding one UTXO more than a transaction can carry cannot spend + /// everything in one send, so a maximum computed from every eligible UTXO + /// names an amount no build could reach. Both halves are asserted by + /// building: the uncapped figure fails, the capped one succeeds with exactly + /// the cap's worth of inputs. + /// + /// This is also what pins the mirrored constant, in the direction that can + /// break the API's promise: the build filled to the cap only succeeds if + /// key-wallet's private limit is at least the mirrored value, so raising the + /// mirror above key-wallet's reds this test (verified with 600). Lowering it + /// does not, and cannot — every assertion here is written in terms of the + /// mirror and moves with it — but under-reporting is safe, only stingy. + #[tokio::test] + async fn pooled_max_sendable_respects_the_input_cap() { + // One more than a standard transaction can carry, each well above the + // ~296-duff cost of its own input so none is dropped as unprofitable. + let outputs = vec![10_000u64; MAX_STANDARD_TX_INPUTS + 1]; + let (manager, wallet_id, generation, signer) = + crate::test_support::funded_wallet_manager_with_outputs( + StandardAccountType::BIP44Account, + &outputs, + ) + .await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let core = CoreWallet::new( + sdk, + manager, + wallet_id, + Arc::new(AlwaysOkBroadcaster), + generation, + ); + + let sources = &[AccountTypePreference::BIP44][..]; + let gross = core + .pooled_spendable_balance(sources, 0) + .await + .expect("gross balance"); + assert_eq!( + gross, + 10_000 * (MAX_STANDARD_TX_INPUTS as u64 + 1), + "the gross figure counts every UTXO, cap or no cap" + ); + + let max = core + .pooled_max_sendable(sources, 0, None) + .await + .expect("max sendable"); + + // The capped total, minus the fee for a transaction that full. + let capped_value = 10_000 * MAX_STANDARD_TX_INPUTS as u64; + assert!( + max < capped_value, + "the fee for {MAX_STANDARD_TX_INPUTS} inputs has to come off: max {max}" + ); + + let spend = |amount: u64, tag: u8| { + TransactionBuilder::new().add_output( + &DashAddress::dummy(Network::Testnet, usize::from(tag)), + amount, + ) + }; + + // Anything needing the UTXO beyond the cap is unbuildable — this is the + // amount an uncapped maximum would have offered. + let over = core + .finalize_transaction(spend(capped_value + 1, 80), sources, 0, &signer) + .await; + assert!( + over.is_err(), + "an amount requiring more than the cap must not build, got {over:?}" + ); + + // The reported maximum builds, and fills the transaction exactly to the + // cap — which is only true if the mirrored constant matches key-wallet's. + let finalized = core + .finalize_transaction(spend(max, 81), sources, 0, &signer) + .await + .expect("the capped maximum must be an amount a build accepts"); + assert_eq!( + finalized.transaction().input.len(), + MAX_STANDARD_TX_INPUTS, + "spending the capped maximum must fill the transaction to the cap" + ); + core.abandon_transaction(&finalized).await; + } + /// The DashPay leg, and the exclusion the ticket turned on: contact funds /// count toward the pooled figure, CoinJoin does not. #[tokio::test]