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 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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 08/11] 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] From 2abc906d1288dbf6601d1bc10aa004a214074396 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:23:36 +0300 Subject: [PATCH 09/11] fix(platform-wallet-ffi): look the pooled-balance handle up in the core-wallet table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `core_wallet_pooled_spendable_balance` and `core_wallet_pooled_max_sendable` are exposed on the core wallet (`ManagedCoreWallet` in the Swift SDK), so the handle they receive is the one `platform_wallet_get_core` issues — a `CORE_WALLET_STORAGE` entry, the same one `core_wallet_get_balance` takes. Both were written beside the `core_wallet_tx_builder_*` entry points and copied their lookup, `PLATFORM_WALLET_STORAGE.with_item(handle)`. Handles come from one global counter, so a core handle is never present in the platform table: every call failed with `ErrorInvalidHandle`. The Swift caller swallowed that error and published a permanent 0, which zeroed Max and blocked every send in the 2026-09-03 QA build (dashwallet-ios#1107) — for every wallet, mixed or not, which is what both QA and a support report hit. Reproduced on a fresh 2 tDASH testnet wallet. Resolve through `CORE_WALLET_STORAGE`, name the parameter for what it is, and pin the contract with tests: a core handle returns the same figure as the direct `CoreWallet` call, and an unknown handle is refused with the out-parameter left at 0. The positive test fails on the old lookup with `NotFound`. --- .../src/core_wallet/transaction_builder.rs | 125 +++++++++++++++--- 1 file changed, 110 insertions(+), 15 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 16866546414..3dcc97f4573 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 @@ -1,6 +1,8 @@ use crate::core_wallet_types::OutPointFFI; use crate::error::*; -use crate::handle::{Handle, CORE_SIGNED_TRANSACTION_STORAGE, PLATFORM_WALLET_STORAGE}; +use crate::handle::{ + Handle, CORE_SIGNED_TRANSACTION_STORAGE, CORE_WALLET_STORAGE, PLATFORM_WALLET_STORAGE, +}; use crate::runtime::runtime; use crate::types::{FFINetwork, Network}; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; @@ -670,11 +672,17 @@ pub unsafe extern "C" fn core_wallet_tx_builder_use_only_added_inputs( /// /// Reservations are not subtracted; see `CoreWallet::pooled_spendable_balance`. /// +/// `core_wallet` is the handle `platform_wallet_get_core` returns — the same +/// one `core_wallet_get_balance` takes — NOT the platform-wallet handle the +/// `core_wallet_tx_builder_*` entry points above take. Handles are drawn from +/// one global counter, so a core handle looked up in the platform table (or +/// vice versa) is always `NotFound`, never a wrong wallet. +/// /// # Safety /// `out_balance` must be a valid, writable pointer. #[no_mangle] pub unsafe extern "C" fn core_wallet_pooled_spendable_balance( - wallet: Handle, + core_wallet: Handle, account_type: CoreAccountTypeFFI, account_index: u32, out_balance: *mut u64, @@ -682,12 +690,9 @@ pub unsafe extern "C" fn core_wallet_pooled_spendable_balance( 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) - )); + let core = unwrap_option_or_return!(CORE_WALLET_STORAGE.with_item(core_wallet, |w| w.clone())); + let balance = unwrap_result_or_return!(runtime() + .block_on(core.pooled_spendable_balance(account_type.funding_sources(), account_index))); *out_balance = balance; PlatformWalletFFIResult::ok() @@ -702,11 +707,14 @@ pub unsafe extern "C" fn core_wallet_pooled_spendable_balance( /// `fee_rate_sat_per_kb` — pass 0 for the same default `TransactionBuilder` /// starts from, or the rate the host sets on its builders. /// +/// `core_wallet` is the core-wallet handle, as for +/// `core_wallet_pooled_spendable_balance`. +/// /// # Safety /// `out_max_sendable` must be a valid, writable pointer. #[no_mangle] pub unsafe extern "C" fn core_wallet_pooled_max_sendable( - wallet: Handle, + core_wallet: Handle, account_type: CoreAccountTypeFFI, account_index: u32, fee_rate_sat_per_kb: u64, @@ -716,12 +724,12 @@ pub unsafe extern "C" fn core_wallet_pooled_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) - )); + let core = unwrap_option_or_return!(CORE_WALLET_STORAGE.with_item(core_wallet, |w| w.clone())); + let max_sendable = unwrap_result_or_return!(runtime().block_on(core.pooled_max_sendable( + account_type.funding_sources(), + account_index, + fee_rate + ))); *out_max_sendable = max_sendable; PlatformWalletFFIResult::ok() @@ -931,3 +939,90 @@ pub unsafe extern "C" fn core_wallet_transaction_free(tx: *mut FFICoreTransactio tx.tx_bytes = std::ptr::null_mut(); tx.tx_len = 0; } + +#[cfg(test)] +mod pooled_balance_handle_tests { + //! The two pooled-balance entry points are exposed on the *core* wallet + //! (`ManagedCoreWallet` in the Swift SDK), so they must resolve the handle + //! `platform_wallet_get_core` hands out — the `CORE_WALLET_STORAGE` one — + //! not the platform-wallet handle their `core_wallet_tx_builder_*` + //! neighbours take. Looking a core handle up in the platform table failed + //! every call with `ErrorInvalidHandle`; the Swift caller swallowed it and + //! published a permanent 0, which zeroed Max and blocked every send on the + //! 2026-09-03 QA build (dashwallet-ios#1107 / platform#4582). + + use key_wallet::account::account_type::StandardAccountType; + use platform_wallet::test_support::funded_spv_core_wallet; + use platform_wallet::SEND_FUNDING_SOURCES; + + use super::*; + + #[test] + fn pooled_spendable_balance_resolves_the_core_wallet_handle() { + let (core, _signer) = + runtime().block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account)); + let expected = runtime() + .block_on(core.pooled_spendable_balance(&SEND_FUNDING_SOURCES, 0)) + .expect("direct pooled balance"); + assert!( + expected > 0, + "the helper funds BIP44, so the pool is non-empty" + ); + + let core_handle = CORE_WALLET_STORAGE.insert(core.clone()); + let mut out: u64 = 0; + let result = unsafe { + core_wallet_pooled_spendable_balance( + core_handle, + CoreAccountTypeFFI::AllSpendable, + 0, + &mut out, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out, expected); + CORE_WALLET_STORAGE.remove(core_handle); + } + + #[test] + fn pooled_max_sendable_resolves_the_core_wallet_handle() { + let (core, _signer) = + runtime().block_on(funded_spv_core_wallet(StandardAccountType::BIP44Account)); + let expected = runtime() + .block_on(core.pooled_max_sendable(&SEND_FUNDING_SOURCES, 0, None)) + .expect("direct pooled max"); + assert!(expected > 0); + + let core_handle = CORE_WALLET_STORAGE.insert(core.clone()); + let mut out: u64 = 0; + let result = unsafe { + core_wallet_pooled_max_sendable( + core_handle, + CoreAccountTypeFFI::AllSpendable, + 0, + 0, + &mut out, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out, expected); + CORE_WALLET_STORAGE.remove(core_handle); + } + + /// A handle that was never issued for a core wallet is refused, and the + /// out-parameter is left at 0 rather than at a stale value. + #[test] + fn unknown_handle_is_refused_with_zero_out() { + let mut out: u64 = 7; + let result = unsafe { + core_wallet_pooled_spendable_balance( + Handle::MAX, + CoreAccountTypeFFI::AllSpendable, + 0, + &mut out, + ) + }; + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out, 0); + } +} From e9c69ddb668f6f18b54d276263a4edd460340853 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:38:59 +0300 Subject: [PATCH 10/11] fix(platform-wallet): make the pooled send-max figure safe to act on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects in `pooled_max_sendable`, all found in review. The fee arithmetic could not hold every rate a caller may pass. key-wallet's `FeeRate::calculate_fee` multiplies `sat_per_kb * size_bytes` unchecked, and the rate arrives as a `u64` the host picks and `core_wallet_pooled_max_sendable` forwards verbatim. The iOS profile builds with `panic = "abort"`, so an overflow there ends the process instead of the call, and a profile without overflow checks wraps into a fee that makes the answer nonsense. `checked_fee` does the same arithmetic with `checked_mul` and returns a typed error instead. Covering the fee was also being mistaken for being spendable. A net output below the modeled script's dust threshold is refused by standard relay, so reporting it names a payment that cannot be made: one confirmed 600-duff UTXO advertised 408 duffs against a 546-duff threshold. Below the threshold now reports nothing sendable, the same answer an empty pool gives. The input-cap test did not test the input cap. Asking for `capped_value + 1` leaves too little for the 501-input fee, so it failed on funds before reaching the cap and would have kept passing if key-wallet dropped its cap entirely. It now asks for the gross total minus that fee — which is *below* `capped_value`, because the 501st input's fee costs more than the 10,000 duffs it brings, and which needs that input because it exceeds what a 500-input build can pay — and asserts the refusal is specifically the too-many-inputs one. Also from review, in `resolved_funding_accounts`: the doc block belonging to `resolve_source_accounts` had been captured by the function inserted above it; the strict-miss error formatted `sources.first()` and so printed `Some(BIP44)` where it used to print `BIP44`; and a bare block left over from an earlier edit wrapped only the funding loop. --- .../src/wallet/core/transaction.rs | 244 +++++++++++++++--- 1 file changed, 202 insertions(+), 42 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index 5e41ace2315..b6a19c4a49d 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -8,7 +8,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use dashcore::{Address, OutPoint, Transaction}; +use dashcore::{Address, OutPoint, PubkeyHash, ScriptBuf, Transaction}; use key_wallet::account::AccountType; use key_wallet::managed_account::managed_account_collection::ManagedAccountCollection; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; @@ -74,6 +74,39 @@ fn map_builder_error(error: BuilderError, context: FundingContext<'_>) -> Platfo PlatformWalletError::TransactionBuild(error.to_string()) } +/// key-wallet's `FeeRate::calculate_fee`, with the multiplication checked. +/// +/// Upstream computes `(sat_per_kb * size_bytes).div_ceil(1000)` unchecked, and +/// the rate reaching [`CoreWallet::pooled_max_sendable`] is a `u64` the host +/// picks and hands across the FFI. An overflow there is not a wrong number: +/// the iOS profile builds with `panic = "abort"`, so it takes the host process +/// down instead of surfacing as an error the caller can show — and a profile +/// without overflow checks is worse still, wrapping silently into a fee that +/// makes the reported maximum nonsense. Both become a typed error here. +fn checked_fee(fee_rate: FeeRate, size_bytes: usize) -> Result { + fee_rate + .as_sat_per_kb() + .checked_mul(size_bytes as u64) + .map(|total| total.div_ceil(1000)) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "fee rate {} sat/kb overflows for a {size_bytes}-byte transaction", + fee_rate.as_sat_per_kb() + )) + }) +} + +/// The output [`CoreWallet::pooled_max_sendable`] prices: one P2PKH, matching +/// the `estimate_tx_size(_, 1, false)` model it sizes the transaction with. +/// +/// Only the script *shape* matters — `dust_value` reads the encoded length, not +/// the hash — so a zero hash stands in for the real destination, which the +/// caller has not chosen yet at send-max time. +fn modeled_output_script() -> ScriptBuf { + use dashcore::hashes::Hash; + ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array([0u8; 20])) +} + /// A signed Core transaction whose selected inputs remain reserved until it is /// broadcast, explicitly abandoned, observed by sync, or reclaimed by the /// reservation TTL. @@ -263,13 +296,6 @@ pub const SEND_FUNDING_SOURCES: [AccountTypePreference; 3] = [ /// through `AssetLockBuildAmount::DrainAll`. pub const ASSET_LOCK_FUNDING_SOURCES: [AccountTypePreference; 3] = SEND_FUNDING_SOURCES; -/// The concrete accounts `preference` resolves to at `source_index` — the -/// platform mirror of key-wallet's private `account_types_for`: the single -/// account at `source_index` for the standard families, and every DashPay -/// receiving account the selector picks (which span their own indices) for a -/// 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 @@ -312,16 +338,27 @@ pub(crate) fn resolved_funding_accounts( } } // A strict SET selector (a DashPay preference naming zero accounts) is a - // miss too: the caller asked for exactly those funds. + // miss too: the caller asked for exactly those funds. Name the preference + // itself, not the `Option` wrapping it — `strict` only ever arrives with a + // one-element list, but formatting the `Option` would print `Some(BIP44)`. if strict && resolved.is_empty() { - return Err(PlatformWalletError::WalletNotFound(format!( - "wallet account {:?} #{source_index} not found", - sources.first() - ))); + return Err(PlatformWalletError::WalletNotFound(match sources.first() { + Some(preference) => { + format!("wallet account {preference:?} #{source_index} not found") + } + None => format!("no funding source named for #{source_index}"), + })); } Ok(resolved) } +/// The concrete accounts `preference` resolves to at `source_index` — the +/// platform mirror of key-wallet's private `account_types_for`: the single +/// account at `source_index` for the standard families, and every DashPay +/// receiving account the selector picks (which span their own indices) for a +/// 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. pub(crate) fn resolve_source_accounts( accounts: &key_wallet::account::ManagedAccountCollection, preference: AccountTypePreference, @@ -488,7 +525,7 @@ 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 input_cost = checked_fee(fee_rate, per_input)?; let mut values: Vec = Vec::new(); for at in resolved { let Some(managed) = info.core_wallet.accounts.funds_account(&at) else { @@ -515,8 +552,17 @@ impl CoreWallet { } let selected: u64 = values.iter().sum(); - let fee = fee_rate.calculate_fee(estimate_tx_size(values.len(), 1, false)); - Ok(selected.saturating_sub(fee)) + let fee = checked_fee(fee_rate, estimate_tx_size(values.len(), 1, false))?; + let net = selected.saturating_sub(fee); + + // Covering the fee is not enough: an output below the modeled script's + // dust threshold is rejected by standard relay policy, so offering that + // amount as a maximum names a payment that cannot be relayed. Report + // nothing sendable instead — the same answer an empty pool gives. + if net < modeled_output_script().dust_value().to_sat() { + return Ok(0); + } + Ok(net) } /// Consume a configured builder, atomically fund and reserve its selected @@ -604,26 +650,27 @@ impl CoreWallet { 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 { - continue; - }; - for utxo in managed.utxos.values() { - if let Some(path) = managed.address_derivation_path(&utxo.address) { - paths.insert(utxo.address.clone(), path); - } + for at in resolved { + // `resolved_funding_accounts` already dropped anything missing + // from either half, so this destructure is defensive only — + // the `continue` is not a reachable skip. + let (Some(account), Some(managed)) = ( + wallet.accounts.account_of_type(at), + info.core_wallet.accounts.funds_account_mut(&at), + ) else { + continue; + }; + for utxo in managed.utxos.values() { + if let Some(path) = managed.address_derivation_path(&utxo.address) { + paths.insert(utxo.address.clone(), path); } - builder = if reservation_only { - builder.add_funding_reservation_only(managed, account) - } else { - builder.add_funding(managed, account) - }; - offered_accounts.push(at); } + builder = if reservation_only { + builder.add_funding_reservation_only(managed, account) + } else { + builder.add_funding(managed, account) + }; + offered_accounts.push(at); } if offered_accounts.is_empty() { return Err(PlatformWalletError::WalletNotFound(format!( @@ -949,7 +996,9 @@ mod tests { funded_wallet_manager_with_contact, AlwaysMaybeSentBroadcaster, AlwaysOkBroadcaster, AlwaysRejectedBroadcaster, WalletSigner, }; - use crate::wallet::core::transaction::MAX_STANDARD_TX_INPUTS; + use key_wallet::wallet::managed_wallet_info::fee::{estimate_tx_size, FeeRate}; + + use crate::wallet::core::transaction::{modeled_output_script, MAX_STANDARD_TX_INPUTS}; use crate::wallet::core::CoreWallet; use crate::PlatformWalletError; @@ -1352,15 +1401,37 @@ mod tests { ) }; - // 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; + // Anything needing the UTXO beyond the cap is unbuildable. The amount + // has to be the one an UNCAPPED maximum would have offered — the gross + // total minus the fee a build spending every UTXO would pay — so that + // funds are sufficient and the cap is the only thing left to refuse. + // `capped_value + 1` looks like the same test but is not: it leaves too + // little for the 501-input fee, fails on funds first, and would keep + // passing if key-wallet dropped its cap entirely. + let uncapped_max = gross + - FeeRate::normal().calculate_fee(estimate_tx_size( + MAX_STANDARD_TX_INPUTS + 1, + 1, + false, + )); + // Note it is BELOW `capped_value`: the 501st input's fee costs more + // than the 10,000 duffs it brings. What makes it need that input is + // that it is beyond what a 500-input build can pay for — which is + // exactly the reported maximum. assert!( - over.is_err(), - "an amount requiring more than the cap must not build, got {over:?}" + uncapped_max > max, + "the uncapped amount must be past what a capped build can pay: {uncapped_max} vs {max}" ); + let over = core + .finalize_transaction(spend(uncapped_max, 80), sources, 0, &signer) + .await; + match over { + Err(PlatformWalletError::TransactionBuild(message)) => assert!( + message.contains("Too many inputs"), + "the refusal must be the input cap, not something else: {message}" + ), + other => panic!("an amount requiring more than the cap must be refused for that reason, got {other:?}"), + } // The reported maximum builds, and fills the transaction exactly to the // cap — which is only true if the mirrored constant matches key-wallet's. @@ -1376,6 +1447,95 @@ mod tests { core.abandon_transaction(&finalized).await; } + /// A fee rate the host may pass but the fee arithmetic cannot hold. + /// + /// key-wallet multiplies `sat_per_kb * size_bytes` unchecked, and the rate + /// arrives as a `u64` chosen host-side and forwarded verbatim by + /// `core_wallet_pooled_max_sendable`. The iOS profile builds with + /// `panic = "abort"`, so an overflow inside that multiplication ends the + /// process rather than the call — it has to be refused before it happens, + /// and the largest representable rate must still compute. + #[tokio::test] + async fn pooled_max_sendable_refuses_a_fee_rate_that_would_overflow() { + let (manager, wallet_id, generation, _signer) = + crate::test_support::funded_wallet_manager_with_outputs( + StandardAccountType::BIP44Account, + &[10_000_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 sources = &[AccountTypePreference::BIP44][..]; + + let refused = core + .pooled_max_sendable(sources, 0, Some(FeeRate::new(u64::MAX))) + .await; + assert!( + matches!(refused, Err(PlatformWalletError::TransactionBuild(_))), + "an unrepresentable fee rate must be an error, not a panic: {refused:?}" + ); + + // The boundary itself: the highest rate whose per-input fee still fits. + // Every UTXO is priced out at a rate this large, so the answer is zero — + // but it is an ANSWER, which is the point. + let per_input = + estimate_tx_size(1, 1, false).saturating_sub(estimate_tx_size(0, 1, false)) as u64; + let highest = u64::MAX / per_input; + assert_eq!( + core.pooled_max_sendable(sources, 0, Some(FeeRate::new(highest))) + .await + .expect("the largest representable rate must still compute"), + 0, + "no UTXO can outearn its own input cost at that rate" + ); + } + + /// Covering the fee is not the same as being spendable: an output under the + /// dust threshold is refused by standard relay, so a maximum reported below + /// it names a payment that cannot be made. Both sides of the boundary. + #[tokio::test] + async fn pooled_max_sendable_reports_nothing_when_the_net_output_would_be_dust() { + let fee = FeeRate::normal().calculate_fee(estimate_tx_size(1, 1, false)); + let dust = modeled_output_script().dust_value().to_sat(); + + let max_for = |funding: u64| async move { + let (manager, wallet_id, generation, _signer) = + crate::test_support::funded_wallet_manager_with_outputs( + StandardAccountType::BIP44Account, + &[funding], + ) + .await; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + CoreWallet::new( + sdk, + manager, + wallet_id, + Arc::new(AlwaysOkBroadcaster), + generation, + ) + .pooled_max_sendable(&[AccountTypePreference::BIP44][..], 0, None) + .await + .expect("max sendable") + }; + + assert_eq!( + max_for(dust + fee - 1).await, + 0, + "one duff short of a relayable output must report nothing sendable" + ); + assert_eq!( + max_for(dust + fee).await, + dust, + "exactly at the dust threshold is still sendable" + ); + } + /// The DashPay leg, and the exclusion the ticket turned on: contact funds /// count toward the pooled figure, CoinJoin does not. #[tokio::test] From 61daa8da072d50c1b2048b85fc1d3ce725a75d2f Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:15:59 +0300 Subject: [PATCH 11/11] fix(platform-wallet): price the pooled maximum the way coin selection prices it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pooled_max_sendable` charged one rounded fee over the whole transaction, while the default BranchAndBound selector rounds per input: it subtracts `ceil(rate × TX_INPUT_SIZE)` from every candidate's value and compares the total against `amount + ceil(rate × base)`. `ceil(a) + n·ceil(b) >= ceil(a + n·b)`, so the selector always needs at least as much as the whole-transaction arithmetic suggested — and on a rate that is not a whole number of duffs per byte it needs strictly more. It closes that gap by taking one more UTXO, which for a pool already truncated to the 500-input cap is the input there is no room for: the build fails with "too many inputs" on the exact amount this getter had just promised. Reported with a probe: 500 × 10,000-duff UTXOs plus one of 575, at `FeeRate::new(1001)`. The small one survives the profitability filter, misses the top-500 cut so the estimate never sees it, and is then pulled in as the 501st input. The fee is now summed the selector's way. At a whole number of duffs per byte the two agree, so nothing changes for the default rate. `pooled_max_sendable_is_buildable_at_a_fractional_fee_rate` pins it — it reproduces the reported pool and fails with `Too many inputs for a standard transaction: 501 (max 500)` without the change. --- .../src/wallet/core/transaction.rs | 82 ++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/transaction.rs b/packages/rs-platform-wallet/src/wallet/core/transaction.rs index fe4b285e4a4..c417525ba90 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -551,7 +551,34 @@ impl CoreWallet { } let selected: u64 = values.iter().sum(); - let fee = checked_fee(fee_rate, estimate_tx_size(values.len(), 1, false))?; + // Priced the way the default BranchAndBound selector prices it: one + // rounding per input plus one for the rest of the transaction, never a + // single rounding over the whole thing. + // + // `ceil(a) + n*ceil(b) >= ceil(a + n*b)`, so the selector always needs + // at least as much as whole-transaction arithmetic suggests. Reporting + // the cheaper figure names an amount those inputs cannot fund under the + // selector's own acceptance test, and it closes the gap by reaching for + // one more UTXO — the input a pool sitting on the cap has no room for, + // so the build fails with "too many inputs" on the very amount this + // getter promised. At a whole number of duffs per byte the two agree; + // a fractional rate is where they part. + let inputs_fee = input_cost.checked_mul(values.len() as u64).ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "fee rate {} sat/kb overflows for {} inputs", + fee_rate.as_sat_per_kb(), + values.len() + )) + })?; + let fee = checked_fee(fee_rate, empty)? + .checked_add(inputs_fee) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "fee rate {} sat/kb overflows for a {}-input transaction", + fee_rate.as_sat_per_kb(), + values.len() + )) + })?; let net = selected.saturating_sub(fee); // Covering the fee is not enough: an output below the modeled script's @@ -1335,6 +1362,59 @@ mod tests { ); } + /// The same cap promise at a rate that is not a whole number of duffs per + /// byte, where the selector's per-input rounding and a single rounding over + /// the whole transaction disagree. + /// + /// The extra 575-duff UTXO is the trap: it survives the profitability + /// filter, is too small to make the top-500 cut, and is therefore invisible + /// to the estimate — but a maximum priced with whole-transaction rounding + /// leaves the selector a few duffs short of its own acceptance test, and it + /// closes that gap by taking this input as the 501st. + #[tokio::test] + async fn pooled_max_sendable_is_buildable_at_a_fractional_fee_rate() { + let mut outputs = vec![10_000u64; MAX_STANDARD_TX_INPUTS]; + outputs.push(575); + 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 rate = FeeRate::new(1001); + let max = core + .pooled_max_sendable(sources, 0, Some(rate)) + .await + .expect("max sendable"); + assert!(max > 0, "the pool is well above dust"); + + let built = core + .finalize_transaction( + TransactionBuilder::new() + .set_fee_rate(rate) + .add_output(&DashAddress::dummy(Network::Testnet, 81), max), + sources, + 0, + &signer, + ) + .await; + assert!( + built.is_ok(), + "the reported maximum must build at the rate it was priced for: {:?}", + built.err() + ); + } + /// 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