diff --git a/Cargo.lock b/Cargo.lock index c6dde4b784e..bb719ce42be 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=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=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +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=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +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=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +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=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +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=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +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=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +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=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +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=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +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=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +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=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +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=4db5c36701b8f38c4aea704badb81e3103ed701d#4db5c36701b8f38c4aea704badb81e3103ed701d" +source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 80e96266e49..00a6f240c92 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 = "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 2cefb0888a0..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 @@ -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 = 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); @@ -277,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); @@ -451,7 +465,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 @@ -616,6 +634,32 @@ 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 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. +/// +/// 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] +pub unsafe extern "C" fn core_wallet_tx_builder_use_only_added_inputs( + builder: *mut FFITransactionBuilder, +) -> PlatformWalletFFIResult { + check_ptr!(builder); + (*builder).reservation_only = true; + 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 44f9f797767..e587d85a671 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()) @@ -344,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(); @@ -375,7 +399,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 @@ -890,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; 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()