From 4538fc4fdf84b9b0a1f2e6b4c2b4da8eb8178074 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:47:26 +0300 Subject: [PATCH 1/4] feat(platform-wallet): let a Core build fund from only the inputs it was given MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet-aware finalizers add every unreserved UTXO of the funding account to the candidate pool, so seeding a subset through `core_wallet_tx_builder_add_inputs_from_outpoints` does not restrict what gets selected. A caller draining an account in batches that each stay under the standard-transaction input limit therefore achieves nothing: every batch sees the whole account and fails with a too-many-inputs error, and an account above the cap cannot be drained at all. That is the iOS CoinJoin sweep. A wallet with 589 mixed UTXOs reports "Too many inputs for a standard transaction: 589 (max 500)" on every attempt and every retry; its ~101 DASH cannot be moved by any route the app offers. Exposes key-wallet's opt-in through the FFI and the Swift SDK, and moves the rust-dashcore pin onto a branch carrying it. The pin continues the existing cherry-pick lineage rather than following dev: `chore/sync-fixes-filter-rescans-and-added-inputs` is the current pin (4db5c367) plus dash-spv #866 and #974 — committed-filter-range rescans for newly derived scripts, which address the launch-dependent balances seen on heavily mixed wallets — plus the four key-wallet commits. Pinning dev instead would drag in the sweep-event chain, whose platform-side handling is #4406's subject and which breaks this workspace on seven non-exhaustive matches today. --- Cargo.lock | 24 +++++++++---------- Cargo.toml | 16 ++++++------- .../src/core_wallet/transaction_builder.rs | 24 +++++++++++++++++++ .../CoreWallet/CoreTransactionBuilder.swift | 13 ++++++++++ 4 files changed, 57 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e20524bc2e..e3c84e0e6fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "bincode", "bincode_derive", @@ -1673,7 +1673,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "dash-network", ] @@ -1768,7 +1768,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "async-trait", "chrono", @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "anyhow", "base64-compat", @@ -1823,12 +1823,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "dashcore-rpc-json", "hex", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "bincode", "dashcore", @@ -1856,7 +1856,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "bincode", "dashcore-private", @@ -2925,7 +2925,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" [[package]] name = "glob" @@ -4137,7 +4137,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "aes", "async-trait", @@ -4166,7 +4166,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4182,7 +4182,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=863866df2e398fca4691188acaf0f2d374ae3c32#863866df2e398fca4691188acaf0f2d374ae3c32" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 2370ef4ea5d..d2ad9658fa3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,14 +53,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "4db5c36701b8f38c4aea704badb81e3103ed701d" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "863866df2e398fca4691188acaf0f2d374ae3c32" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs index 2cefb0888a0..fcbd182bcac 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/transaction_builder.rs @@ -616,6 +616,30 @@ pub unsafe extern "C" fn core_wallet_tx_builder_set_fee_rate( PlatformWalletFFIResult::ok() } +/// Fund the build from the inputs `core_wallet_tx_builder_add_inputs_from_outpoints` +/// supplied, and nothing else. +/// +/// Without this, the wallet-aware finalizers add every unreserved UTXO of the +/// funding account to the candidate pool, so seeding a subset does not restrict +/// what gets selected. A caller draining an account in batches that each stay +/// under the standard-transaction input limit needs this, or every batch sees +/// the whole account and fails with a too-many-inputs error. +/// +/// # Safety +/// `builder` must be a valid, non-destroyed pointer. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_tx_builder_use_only_added_inputs( + builder: *mut FFITransactionBuilder, +) -> PlatformWalletFFIResult { + check_ptr!(builder); + + let b = (*builder).take_builder(); + let b = b.use_only_added_inputs(); + (*builder).store_builder(b); + + PlatformWalletFFIResult::ok() +} + /// # Safety /// `builder` must be a valid, non-destroyed pointer. #[no_mangle] diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift index ce22efa0704..b0efd1a4093 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/CoreTransactionBuilder.swift @@ -295,6 +295,19 @@ public final class CoreTransactionBuilder { return self } + /// Fund the build from the inputs `addInputs` supplied, and nothing else. + /// + /// Without this, `finalizeAtomic` adds every unreserved UTXO of the funding + /// account to the candidate pool, so seeding a subset does not restrict what + /// gets selected. A caller draining an account in batches that each stay + /// under the standard-transaction input limit needs this, or every batch + /// sees the whole account and fails with a too-many-inputs error. + @discardableResult + public func useOnlyAddedInputs() throws -> CoreTransactionBuilder { + try core_wallet_tx_builder_use_only_added_inputs(handle).check() + return self + } + @discardableResult public func setCurrentHeight(_ height: UInt32) throws -> CoreTransactionBuilder { try core_wallet_tx_builder_set_current_height(handle, height).check() From d207874e37755ea241c5d6afbfd0157671317d86 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:30:35 +0300 Subject: [PATCH 2/4] 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 62e7e8d1d846cd5b431c92cf715d286c101f33bf Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:33:12 +0300 Subject: [PATCH 3/4] fix(platform-wallet-ffi): read the reclaimed builder, not the raw pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Box::from_raw(builder)` hands the allocation to `ffi`, so reading `(*builder).reservation_only` afterwards aliases it. Not a use-after-free — the box outlives the read — but `Box` is `noalias`, so under Stacked Borrows the raw read invalidates `ffi`'s tag and the drop at the end of the function then runs on an invalidated one. Miri flags the shape. The value is already in scope: `ffi.reservation_only`, matching how `core_wallet_signed_payment_finalize` reads everything off the reclaimed box and never touches the raw pointer again. --- .../src/core_wallet/transaction_builder.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..c348c228da2 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 @@ -146,7 +146,7 @@ pub unsafe extern "C" fn core_wallet_tx_builder_finalize( let signer = MnemonicResolverCoreSigner::new(core_signer_handle, wallet.wallet_id(), wallet.network()); - let reservation_only = (*builder).reservation_only; + let reservation_only = ffi.reservation_only; let finalized = runtime().block_on(wallet.core().finalize_transaction_with_options( inner, account_type.funding_sources(), From a196b7b56f0b05a302cc7587087d29efca278d9c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:53:49 +0300 Subject: [PATCH 4/4] fix(wallet-ffi): honour use_only_added_inputs in the deferred finalizer core_wallet_signed_payment_finalize called plain finalize_transaction, so a host that set the restriction via core_wallet_tx_builder_use_only_added_inputs and then submitted through the deferred (BIP70/BIP270) path had it accepted at the FFI boundary and silently discarded at the finalizer: the funding account's whole UTXO set was offered to selection after all, which is exactly the too-many-inputs failure this branch exists to fix. Thread reservation_only through, read off the reclaimed box rather than through the consumed raw pointer, and say in the setter's doc that both finalizers honour it. Add the workspace-level regression test the crossing lacked. key-wallet #994 covers add_funding_reservation_only on its own side, but the flag travels an FFI struct field, a finalizer bool and a key-wallet call, and nothing here failed if it were dropped anywhere along that path. The new case funds an account with two UTXOs, seeds one, and asserts both that the build spends only the seeded input and that a payment only the pair could cover FAILS - the half that actually proves the second UTXO was never a candidate. Verified against a mutant (add_funding_reservation_only -> add_funding): the test goes red. Also correct the offered_accounts comment, which described a list that under the flag holds accounts doing reservation bookkeeping and offering nothing. --- .../src/core_wallet/transaction_builder.rs | 16 ++- .../src/wallet/core/transaction.rs | 126 +++++++++++++++++- 2 files changed, 136 insertions(+), 6 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 c348c228da2..f91675e8287 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 @@ -283,11 +283,19 @@ pub unsafe extern "C" fn core_wallet_signed_payment_finalize( MnemonicResolverCoreSigner::new(core_signer_handle, wallet.wallet_id(), wallet.network()); // Atomic select + reserve + sign in one wallet-manager critical section. - let finalized = runtime().block_on(wallet.core().finalize_transaction( + // `reservation_only` is read off the reclaimed box (never through `builder`, + // whose provenance ends at `Box::from_raw`) and threaded through here for the + // same reason as in the immediate sibling: a host that called + // `core_wallet_tx_builder_use_only_added_inputs` and then finalized a + // DEFERRED payment would otherwise have the restriction silently discarded, + // and the account's UTXOs would be offered to selection after all. + let reservation_only = ffi.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); @@ -635,6 +643,12 @@ 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. /// +/// Honoured by BOTH finalizers — `core_wallet_tx_builder_finalize` and the +/// deferred `core_wallet_signed_payment_finalize` — so the restriction cannot +/// be lost by picking one submission path over the other. It only removes +/// candidates: the account still takes on the build's reservation bookkeeping +/// and still supplies its change address. +/// /// # 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..e587d85a671 100644 --- a/packages/rs-platform-wallet/src/wallet/core/transaction.rs +++ b/packages/rs-platform-wallet/src/wallet/core/transaction.rs @@ -365,11 +365,14 @@ impl CoreWallet { // this build still owns, even if a TTL sweep re-reserved them under // a new token meanwhile (`dashpay/platform#4185`). let mut builder = builder.set_current_height(height); - // Accounts whose UTXOs were OFFERED to selection, in funding order. - // Not the same as the accounts that end up contributing inputs — - // selection may take nothing from most of them — so this drives - // build-time cleanup only, and the contributor list stored on the - // transaction is derived from the selected inputs below. + // Accounts that took on this build's reservation bookkeeping, in + // funding order — i.e. the ones a failure path must release. Under + // `reservation_only` nothing is offered to selection at all; without + // it these are also the accounts whose UTXOs were offered. Either + // way this is NOT the list of accounts that end up contributing + // inputs — selection may take nothing from most of them — so it + // drives 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(); @@ -915,6 +918,119 @@ mod tests { ) } + /// `reservation_only` end to end IN THIS WORKSPACE. key-wallet #994 covers + /// `add_funding_reservation_only` on its own side, but nothing here proved + /// the flag survives the crossing: it travels an FFI struct field, a + /// finalizer bool, and a key-wallet call, and a refactor that drops it + /// anywhere along that path leaves every test in this repo green while + /// silently reintroducing the >500-input build this branch exists to fix. + /// + /// The account holds two UTXOs; the builder is seeded with exactly one. + /// Under the flag the build must spend that one and nothing else — and a + /// payment only the pair could cover must FAIL, which is the assertion that + /// actually proves the second UTXO was never offered to selection. The + /// unflagged control shows the same wallet funds it happily. + #[tokio::test] + async fn reservation_only_finalize_spends_only_the_seeded_inputs() { + let account_type = StandardAccountType::BIP44Account; + let (manager, wallet_id, generation, signer) = + crate::test_support::funded_wallet_manager_with_outputs( + account_type, + &[900_000, 900_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, + ); + + // One of the account's two spendable UTXOs, chosen by outpoint so the + // pick is deterministic across runs. + let seeded = { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let wm = core.wallet_manager.read().await; + let (_, info) = wm + .get_wallet_and_info(&core.wallet_id()) + .expect("wallet present in manager"); + let height = info.core_wallet.last_processed_height(); + let account = info + .core_wallet + .first_bip44_managed_account() + .expect("bip44 managed account"); + let mut utxos: Vec = account + .spendable_utxos(height) + .into_iter() + .cloned() + .collect(); + assert_eq!(utxos.len(), 2, "fixture must fund two separate UTXOs"); + utxos.sort_by_key(|u| u.outpoint); + utxos.remove(0) + }; + + // A payment neither UTXO covers alone. With the flag the unseeded one + // is not a candidate, so selection must come up short. + let err = core + .finalize_transaction_with_options( + payment_builder(60).add_inputs([seeded.clone()]), + &[preference(account_type)], + 0, + &signer, + true, + ) + .await + .expect_err("the account's other UTXO must not be reachable under the flag"); + assert!( + matches!(err, PlatformWalletError::CoreInsufficientFunds { .. }), + "expected a funding shortfall, got {err:?}" + ); + + // Within what the seeded input alone covers: exactly that input, and + // the account's own change address still comes from its bookkeeping. + let finalized = core + .finalize_transaction_with_options( + TransactionBuilder::new() + .add_output(&DashAddress::dummy(Network::Testnet, 61), 500_000) + .add_inputs([seeded.clone()]), + &[preference(account_type)], + 0, + &signer, + true, + ) + .await + .expect("the seeded input alone covers this payment"); + let inputs = &finalized.transaction().input; + assert_eq!(inputs.len(), 1, "only the seeded input may be spent"); + assert_eq!( + inputs[0].previous_output, seeded.outpoint, + "the spent input must be the seeded one" + ); + core.abandon_transaction(&finalized).await; + + // Control: the same wallet, the same payment, no flag — proof the + // shortfall above came from the restriction and not from the fixture. + let pooled = core + .finalize_transaction_with_options( + payment_builder(62), + &[preference(account_type)], + 0, + &signer, + false, + ) + .await + .expect("without the flag both UTXOs fund the payment"); + assert_eq!( + pooled.transaction().input.len(), + 2, + "the unflagged build must reach for both UTXOs" + ); + core.abandon_transaction(&pooled).await; + } + #[tokio::test] async fn concurrent_same_account_finalizers_cannot_reserve_the_same_input() { let account_type = StandardAccountType::BIP44Account;