From 572cc8c393b73b5ead42246329dc2e14378ca9e5 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 1 Jun 2026 15:34:36 +0200 Subject: [PATCH 01/10] feat(platform-wallet): CoinJoin sweep + one-time recovery gap-limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support recovering DashSync-era CoinJoin "mixed coins" (BIP44 purpose 4') after migration to SwiftDashSDK, which the standard send path cannot reach. - sweep_coinjoin_to_address: build an all-input, single-output tx that empties the CoinJoin account into one destination (no change). Bypasses TransactionBuilder's LargestFirst selection (which can drop small UTXOs) by assembling inputs manually and signing via the Signer. - set_coinjoin_gap_limit: widen the CoinJoin pool's gap limit and pre-generate addresses (maintain_gap_limit) so SPV actually watches the wider window, then bump_monitor_revision. Setting the limit alone is not enough — only materialized addresses are watched. In-memory only (not persisted), so a later load reverts to the default gap. - FFI: core_wallet_sweep_coinjoin, core_wallet_set_coinjoin_gap_limit. - swift-sdk: ManagedCoreWallet.sweepCoinJoinAccount / setCoinJoinGapLimit. Co-Authored-By: Claude Opus 4.8 --- .../src/core_wallet/broadcast.rs | 93 ++++++++++++ .../src/wallet/core/broadcast.rs | 132 ++++++++++++++++++ .../src/wallet/core/coinjoin_recovery.rs | 108 ++++++++++++++ .../rs-platform-wallet/src/wallet/core/mod.rs | 1 + .../CoreWallet/ManagedCoreWallet.swift | 70 ++++++++++ 5 files changed, 404 insertions(+) create mode 100644 packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs index 24d54bb0776..8e7ba8bd351 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -127,6 +127,99 @@ pub unsafe extern "C" fn core_wallet_send_to_addresses( PlatformWalletFFIResult::ok() } +/// Sweep the entire spendable balance of the wallet's CoinJoin account +/// (BIP44 purpose 4') into a single output to `dest_address`, leaving no +/// change so the account is fully emptied. +/// +/// CoinJoin "mixed coins" live on a dedicated account that +/// [`core_wallet_send_to_addresses`] cannot reach (it only handles +/// standard BIP44/BIP32 accounts). Used by the DashSync → SwiftDashSDK +/// migration to recover a user's mixed coins (no longer supported) into +/// their spendable balance. Uses the same external mnemonic-resolver +/// signer model as [`core_wallet_send_to_addresses`]. +/// +/// On success, `out_tx_bytes`/`out_tx_len` receive the serialized signed +/// transaction; free it with [`core_wallet_free_tx_bytes`]. +/// +/// # Safety +/// - `dest_address` must be a valid NUL-terminated C string. +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle`. Ownership is retained by the caller. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_sweep_coinjoin( + handle: Handle, + account_index: u32, + dest_address: *const c_char, + core_signer_handle: *mut MnemonicResolverHandle, + out_tx_bytes: *mut *mut u8, + out_tx_len: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(dest_address); + check_ptr!(core_signer_handle); + check_ptr!(out_tx_bytes); + check_ptr!(out_tx_len); + + let dest_str = unwrap_result_or_return!(std::ffi::CStr::from_ptr(dest_address).to_str()); + let dest = unwrap_result_or_return!(dashcore::Address::from_str(dest_str)).assume_checked(); + + let signer_addr = core_signer_handle as usize; + + let option = CORE_WALLET_STORAGE.with_item(handle, |wallet| { + let wallet_id = wallet.wallet_id(); + let network = wallet.network(); + // SAFETY: the resolver handle is pinned alive for the duration of + // this FFI call (see fn-level safety doc). The + // `MnemonicResolverCoreSigner` lives on this stack frame and is + // dropped before the function returns. + let signer = unsafe { + MnemonicResolverCoreSigner::new( + signer_addr as *mut MnemonicResolverHandle, + wallet_id, + network, + ) + }; + runtime().block_on(wallet.sweep_coinjoin_to_address(account_index, dest, &signer)) + }); + let result = unwrap_option_or_return!(option); + let tx = unwrap_result_or_return!(result); + let serialized = dashcore::consensus::serialize(&tx); + let len = serialized.len(); + let boxed = serialized.into_boxed_slice(); + *out_tx_bytes = Box::into_raw(boxed) as *mut u8; + *out_tx_len = len; + PlatformWalletFFIResult::ok() +} + +/// Widen the wallet's CoinJoin account (BIP44 purpose 4') address gap limit +/// to `gap_limit` and generate the addresses so SPV watches the wider window. +/// +/// Used by the DashSync → SwiftDashSDK migration's one-time CoinJoin recovery +/// scan. CoinJoin mixed coins are scattered with holes wider than the default +/// gap (30), so for wallets that used CoinJoin the app widens the gap (to match +/// DashSync's 400) before starting SPV, then reverts once the coins are swept. +/// The widened limit is in-memory only and not persisted. +/// +/// On success, `out_highest_index` receives the pool's highest generated +/// address index after generation. Idempotent: re-running is a cheap no-op +/// once the window is covered. +#[no_mangle] +pub unsafe extern "C" fn core_wallet_set_coinjoin_gap_limit( + handle: Handle, + account_index: u32, + gap_limit: u32, + out_highest_index: *mut u32, +) -> PlatformWalletFFIResult { + check_ptr!(out_highest_index); + + let option = CORE_WALLET_STORAGE.with_item(handle, |wallet| { + runtime().block_on(wallet.set_coinjoin_gap_limit(account_index, gap_limit)) + }); + let result = unwrap_option_or_return!(option); + let highest = unwrap_result_or_return!(result); + *out_highest_index = highest; + PlatformWalletFFIResult::ok() +} + /// Free transaction bytes returned by `core_wallet_send_to_addresses`. #[no_mangle] pub unsafe extern "C" fn core_wallet_free_tx_bytes(bytes: *mut u8, len: usize) { diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 4609d1fb6d2..9490f4532b2 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -145,4 +145,136 @@ impl CoreWallet { self.broadcast_transaction(&tx).await?; Ok(tx) } + + /// Sweep the *entire* spendable balance of a CoinJoin account into a + /// single output to `dest`, leaving no change behind. + /// + /// CoinJoin "mixed coins" live on a dedicated CoinJoin account (BIP44 + /// purpose 4'), which [`send_to_addresses`](Self::send_to_addresses) + /// cannot reach — it only resolves standard BIP44/BIP32 accounts. This + /// is used by the DashSync → SwiftDashSDK migration to move a user's + /// mixed coins (no longer supported) into their spendable balance. + /// + /// All UTXOs are added as explicit inputs and the transaction is + /// assembled and signed directly — it deliberately does NOT route through + /// `TransactionBuilder::build_signed`, whose `LargestFirst` coin selection + /// re-selects a *covering subset* and stops early, which can drop small + /// UTXOs (e.g. a tiny fragment sitting behind larger denominations) and + /// leave the account non-empty. The single output is `total_input - fee` + /// (fee sized for N inputs + 1 output, no change), so there is no change + /// output and every UTXO is consumed. + pub async fn sweep_coinjoin_to_address( + &self, + account_index: u32, + dest: DashAddress, + signer: &S, + ) -> Result { + use dashcore::blockdata::witness::Witness; + use dashcore::{ScriptBuf, TxIn, TxOut}; + use key_wallet::wallet::managed_wallet_info::fee::FeeRate; + use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionSigner; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let tx = { + let mut wm = self.wallet_manager.write().await; + let (_wallet, info) = wm.get_wallet_and_info_mut(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet not found in wallet manager".to_string(), + ) + })?; + + let current_height = info.core_wallet.synced_height(); + + let managed_account = info + .core_wallet + .accounts + .coinjoin_accounts + .get(&account_index) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "CoinJoin managed account {} not found", + account_index + )) + })?; + + // Snapshot every spendable UTXO — the sweep consumes all of them. + let utxos: Vec<_> = managed_account + .spendable_utxos(current_height) + .into_iter() + .cloned() + .collect(); + + if utxos.is_empty() { + return Err(PlatformWalletError::TransactionBuild( + "no spendable CoinJoin UTXOs to sweep".to_string(), + )); + } + + let total_input: u64 = utxos.iter().map(|u| u.value()).sum(); + let input_count = utxos.len(); + + // Exact fee for (input_count inputs, 1 output, no change). Mirrors + // key-wallet's `calculate_base_size()` (8 + input-varint + output- + // varint + 34) and the selector's 148 B/input, so `total_input - + // fee` drives the selector to pick all inputs with zero change. + let fee_rate = FeeRate::normal(); + const BASE_SIZE_1_OUTPUT_NO_CHANGE: usize = 8 + 1 + 1 + 34; + const INPUT_SIZE: usize = 148; + let fee = fee_rate + .calculate_fee(BASE_SIZE_1_OUTPUT_NO_CHANGE + input_count * INPUT_SIZE); + + if total_input <= fee { + return Err(PlatformWalletError::TransactionBuild(format!( + "CoinJoin balance {} is below the sweep fee {}", + total_input, fee + ))); + } + let output_amount = total_input - fee; + + // Assemble the tx with ALL inputs explicitly and sign it directly. + // Do NOT use `TransactionBuilder::build_signed` — its `LargestFirst` + // coin selection re-selects a covering subset and stops once + // `output_amount + fee` is met, which can drop small UTXOs and + // leave the CoinJoin account non-empty. A sweep must consume + // everything, so we build the all-input, single-output tx by hand. + let tx_inputs: Vec = utxos + .iter() + .map(|u| TxIn { + previous_output: u.outpoint, + script_sig: ScriptBuf::new(), + sequence: 0xffff_ffff, // Dash has no RBF + witness: Witness::new(), + }) + .collect(); + let unsigned = Transaction { + version: 3, + lock_time: 0, + input: tx_inputs, + output: vec![TxOut { + value: output_amount, + script_pubkey: dest.script_pubkey(), + }], + special_transaction_payload: None, + }; + + // `sign_tx` signs `tx.input[i]` using `utxos[i]`, so input order + // and the utxo vec must line up — both derive from the same vec. + let signed = signer + .sign_tx(unsigned, utxos, |addr| { + managed_account.address_derivation_path(&addr) + }) + .await + .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; + + debug_assert_eq!( + signed.input.len(), + input_count, + "CoinJoin sweep must consume every UTXO" + ); + signed + }; + + self.broadcast_transaction(&tx).await?; + Ok(tx) + } } diff --git a/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs b/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs new file mode 100644 index 00000000000..e5acff14ab4 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs @@ -0,0 +1,108 @@ +//! One-time CoinJoin recovery: widen a CoinJoin account's address gap limit +//! and eagerly generate the addresses so SPV watches the wider window. +//! +//! CoinJoin "mixed coins" are scattered across the CoinJoin derivation path +//! (BIP44 purpose 4') with holes wider than the SDK's default gap limit (30), +//! so a fresh post-migration scan would silently miss deep coins. For wallets +//! flagged (by the app) as having used CoinJoin in DashSync, the app widens the +//! gap — matching DashSync's `SEQUENCE_GAP_LIMIT_INITIAL_COINJOIN` of 400 — +//! before starting SPV, runs the recovery scan, then reverts to the default. + +use key_wallet::gap_limit::MAX_GAP_LIMIT; +use key_wallet::managed_account::address_pool::KeySource; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + +use crate::broadcaster::TransactionBroadcaster; +use crate::{CoreWallet, PlatformWalletError}; + +impl CoreWallet { + /// Widen the CoinJoin account's single-pool gap limit to `gap_limit` and + /// generate addresses so indices up to `highest_used + gap_limit` exist + /// and are watched by SPV. Returns the pool's highest generated index. + /// + /// Setting the gap limit alone is **not** enough: SPV only watches + /// addresses materialized into the pool's script index, so a scan starting + /// at the default gap (30) never sees — and so never extends toward — a + /// used CoinJoin address sitting beyond index 30. Pre-generating the wide + /// window (via [`AddressPool::maintain_gap_limit`]) is what lets the + /// recovery scan find scattered mixed coins; the in-progress scan then + /// auto-extends from there as deep addresses are marked used (see + /// `wallet_checker`'s `maintain_gap_limit` call). + /// + /// Idempotent: generation only fills missing indices, so re-running with + /// the same (or a smaller) limit is a cheap no-op. The widened `gap_limit` + /// is in-memory only — it is not persisted, so a later wallet load + /// reconstructs the pool at the default gap. That is the intended + /// "revert after recovery" behavior: the app re-applies the widen on each + /// flagged launch and stops once the coins are swept. + /// + /// Derivation uses the account's **public** xpub only — no private key + /// crosses any boundary (CoinJoin receive addresses are non-hardened). + pub async fn set_coinjoin_gap_limit( + &self, + account_index: u32, + gap_limit: u32, + ) -> Result { + let mut wm = self.wallet_manager.write().await; + let (wallet, info) = wm.get_wallet_and_info_mut(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet not found in wallet manager".to_string(), + ) + })?; + + // Watch-only account xpub for deriving the CoinJoin pool's addresses. + // Copied out (it's `Copy`) so the immutable `wallet` borrow ends before + // we take the mutable `info` borrow below. + let account_xpub = wallet + .accounts + .coinjoin_accounts + .get(&account_index) + .map(|a| a.account_xpub) + .ok_or_else(|| { + PlatformWalletError::WalletNotFound(format!( + "CoinJoin account {} not found", + account_index + )) + })?; + let key_source = KeySource::Public(account_xpub); + + let managed_account = info + .core_wallet + .accounts + .coinjoin_accounts + .get_mut(&account_index) + .ok_or_else(|| { + PlatformWalletError::WalletNotFound(format!( + "CoinJoin managed account {} not found", + account_index + )) + })?; + + // CoinJoin uses a single address pool. Widen the gap limit, then + // materialize addresses up to it. Scope the pool borrow so it ends + // before `bump_monitor_revision` reborrows the managed account. + let new_highest = { + let pool = managed_account + .managed_account_type_mut() + .address_pools_mut() + .into_iter() + .next() + .ok_or_else(|| { + PlatformWalletError::AddressOperation( + "CoinJoin account has no address pool".to_string(), + ) + })?; + + pool.gap_limit = gap_limit.min(MAX_GAP_LIMIT); + pool.maintain_gap_limit(&key_source) + .map_err(|e| PlatformWalletError::AddressOperation(e.to_string()))?; + pool.highest_generated.unwrap_or(0) + }; + + // The watched-address set changed — bump the revision so the SPV + // compact-filter / bloom filter is rebuilt to include the new scripts. + managed_account.bump_monitor_revision(); + + Ok(new_highest) + } +} diff --git a/packages/rs-platform-wallet/src/wallet/core/mod.rs b/packages/rs-platform-wallet/src/wallet/core/mod.rs index 106a4108c22..e1b4cbe6b03 100644 --- a/packages/rs-platform-wallet/src/wallet/core/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/core/mod.rs @@ -1,6 +1,7 @@ pub mod balance; pub mod balance_handler; mod broadcast; +mod coinjoin_recovery; pub mod wallet; pub use balance::WalletBalance; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift index 4e45600e01b..c1207bdedf3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift @@ -162,6 +162,76 @@ public class ManagedCoreWallet { return Data(bytes: ptr, count: Int(txLen)) } + /// Sweep the entire spendable balance of the wallet's CoinJoin account + /// into a single output to `destination`, leaving no change so the + /// account is fully emptied. + /// + /// CoinJoin "mixed coins" live on a dedicated account (BIP44 purpose 4') + /// that ``sendToAddresses(accountType:accountIndex:recipients:)`` cannot + /// reach. Used by the DashSync → SwiftDashSDK migration to move a user's + /// mixed coins (no longer supported) into their spendable balance. + /// + /// Returns the serialized signed transaction (already broadcast by the + /// FFI), mirroring `sendToAddresses`. + public func sweepCoinJoinAccount( + accountIndex: UInt32 = 0, + destination: String + ) throws -> Data { + var txBytesPtr: UnsafeMutablePointer? = nil + var txLen: UInt = 0 + + // Single owned recipient string; the resolver-backed signer owns + // mnemonic access for the lifetime of the call (same model as + // `sendToAddresses`). + let resolver = MnemonicResolver() + + try destination.withCString { destPtr in + try withExtendedLifetime(resolver) { + try core_wallet_sweep_coinjoin( + handle, + accountIndex, + destPtr, + resolver.handle, + &txBytesPtr, + &txLen + ).check() + } + } + + guard let ptr = txBytesPtr, txLen > 0 else { + throw PlatformWalletError.unknown("FFI returned success but tx buffer was empty") + } + defer { core_wallet_free_tx_bytes(ptr, txLen) } + + return Data(bytes: ptr, count: Int(txLen)) + } + + /// Widen the wallet's CoinJoin account address gap limit to `gapLimit` and + /// generate the addresses so SPV watches the wider window. + /// + /// CoinJoin "mixed coins" (BIP44 purpose 4') are scattered with holes wider + /// than the default gap (30), so a fresh post-migration scan would miss + /// deep coins. The DashSync → SwiftDashSDK migration calls this — only for + /// wallets that used CoinJoin — before starting SPV so the one-time + /// recovery scan finds every mixed coin. The widened limit is in-memory + /// only (not persisted), so a later launch reverts to the default gap. + /// + /// Idempotent. Returns the pool's highest generated address index. + @discardableResult + public func setCoinJoinGapLimit( + accountIndex: UInt32 = 0, + gapLimit: UInt32 + ) throws -> UInt32 { + var highestIndex: UInt32 = 0 + try core_wallet_set_coinjoin_gap_limit( + handle, + accountIndex, + gapLimit, + &highestIndex + ).check() + return highestIndex + } + /// Broadcast a raw signed transaction. /// /// Returns the transaction ID as a hex string. From b3f387390dd3afb20a61fc59f7a0afd963314dff Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Fri, 5 Jun 2026 16:29:41 +0200 Subject: [PATCH 02/10] feat(coinjoin): chunk the CoinJoin sweep across multiple transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A heavy mixer can hold thousands of small mixed-coin UTXOs. The sweep packed all of them into one transaction, which past ~675 inputs exceeds the standard relay-size limit (MAX_STANDARD_TX_SIZE = 100 000 B) and is silently unrelayable — the funds never move. sweep_coinjoin_to_address now partitions the UTXO snapshot into balanced chunks of <= 500 inputs (~74 KB/tx) and returns Vec. Each chunk spends a disjoint slice of the snapshot, so the transactions have no inter-dependency and may broadcast in any order. Broadcast tolerates partial failure: the chunks that did broadcast are returned (the caller refreshes balance and may re-run to sweep the remainder); an error is returned only if none broadcast at all. - rs-platform-wallet: chunk the sweep; sweep_chunk_size helper + unit test. - rs-platform-wallet-ffi: core_wallet_sweep_coinjoin returns N wire-order txids (out_txids = count*32 bytes + out_count) instead of one tx blob — the app only needs the ids. Freed via the existing core_wallet_free_tx_bytes. - swift-sdk: sweepCoinJoinAccount returns [Data] of wire-order txids. Rebuild the xcframework (build_ios.sh) to pick up the new FFI ABI. cargo check both crates + the chunking unit test pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/core_wallet/broadcast.rs | 45 +++- .../src/wallet/core/broadcast.rs | 245 +++++++++++++----- .../CoreWallet/ManagedCoreWallet.swift | 35 ++- 3 files changed, 227 insertions(+), 98 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs index 8e7ba8bd351..0f2af1a7f70 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -128,8 +128,8 @@ pub unsafe extern "C" fn core_wallet_send_to_addresses( } /// Sweep the entire spendable balance of the wallet's CoinJoin account -/// (BIP44 purpose 4') into a single output to `dest_address`, leaving no -/// change so the account is fully emptied. +/// (BIP44 purpose 4') to `dest_address` across one or more transactions, +/// leaving no change so the account is fully emptied. /// /// CoinJoin "mixed coins" live on a dedicated account that /// [`core_wallet_send_to_addresses`] cannot reach (it only handles @@ -138,8 +138,14 @@ pub unsafe extern "C" fn core_wallet_send_to_addresses( /// their spendable balance. Uses the same external mnemonic-resolver /// signer model as [`core_wallet_send_to_addresses`]. /// -/// On success, `out_tx_bytes`/`out_tx_len` receive the serialized signed -/// transaction; free it with [`core_wallet_free_tx_bytes`]. +/// The sweep is split into one or more transactions because a heavy mixer's +/// UTXO set can exceed a single transaction's relay-size limit. On success, +/// `out_txids` receives a heap buffer of `*out_count` consecutive 32-byte +/// transaction ids in **wire order** (Dash Core's internal orientation — the +/// reverse of the hex shown in block explorers), in chunk order. Free the +/// buffer with [`core_wallet_free_tx_bytes`], passing `*out_count * 32` as the +/// length. The serialized transactions themselves are not returned — the +/// caller only needs the ids (to group the resulting withdrawals). /// /// # Safety /// - `dest_address` must be a valid NUL-terminated C string. @@ -151,13 +157,13 @@ pub unsafe extern "C" fn core_wallet_sweep_coinjoin( account_index: u32, dest_address: *const c_char, core_signer_handle: *mut MnemonicResolverHandle, - out_tx_bytes: *mut *mut u8, - out_tx_len: *mut usize, + out_txids: *mut *mut u8, + out_count: *mut usize, ) -> PlatformWalletFFIResult { check_ptr!(dest_address); check_ptr!(core_signer_handle); - check_ptr!(out_tx_bytes); - check_ptr!(out_tx_len); + check_ptr!(out_txids); + check_ptr!(out_count); let dest_str = unwrap_result_or_return!(std::ffi::CStr::from_ptr(dest_address).to_str()); let dest = unwrap_result_or_return!(dashcore::Address::from_str(dest_str)).assume_checked(); @@ -181,12 +187,23 @@ pub unsafe extern "C" fn core_wallet_sweep_coinjoin( runtime().block_on(wallet.sweep_coinjoin_to_address(account_index, dest, &signer)) }); let result = unwrap_option_or_return!(option); - let tx = unwrap_result_or_return!(result); - let serialized = dashcore::consensus::serialize(&tx); - let len = serialized.len(); - let boxed = serialized.into_boxed_slice(); - *out_tx_bytes = Box::into_raw(boxed) as *mut u8; - *out_tx_len = len; + let txs = unwrap_result_or_return!(result); + + // Emit the chunks' txids as a contiguous `count * 32` byte buffer in wire + // order (`Txid::as_byte_array`) — the orientation the app records and + // groups withdrawals by. Free with `core_wallet_free_tx_bytes(ptr, + // count * 32)`. `txs` is never empty here (the core sweep errors if no + // transaction broadcast), so `out_count >= 1` on success. + use dashcore::hashes::Hash; // brings `Txid::as_byte_array` into scope + let count = txs.len(); + let mut buf: Vec = Vec::with_capacity(count * 32); + for tx in &txs { + let txid = tx.txid(); + buf.extend_from_slice(txid.as_byte_array()); + } + let boxed = buf.into_boxed_slice(); + *out_txids = Box::into_raw(boxed) as *mut u8; + *out_count = count; PlatformWalletFFIResult::ok() } diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 9490f4532b2..9ffe3767b72 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -6,6 +6,27 @@ use key_wallet::signer::Signer; use crate::broadcaster::TransactionBroadcaster; use crate::{CoreWallet, PlatformWalletError}; +/// Max inputs per CoinJoin sweep transaction. A single Dash transaction must +/// stay under the standard relay/mempool size limit (`MAX_STANDARD_TX_SIZE` = +/// 100 000 B); at ~148 B/input (`INPUT_SIZE` below) that is ~675 inputs, so 500 +/// leaves a comfortable margin for the output + overhead. A heavy mixer's UTXOs +/// are therefore swept across `ceil(N / MAX_INPUTS_PER_SWEEP)` transactions +/// rather than one oversized, unrelayable transaction. +const MAX_INPUTS_PER_SWEEP: usize = 500; + +/// Balanced input count per sweep transaction for `total` spendable UTXOs, so +/// that `utxos.chunks(sweep_chunk_size(total))` yields `ceil(total / +/// MAX_INPUTS_PER_SWEEP)` near-equal chunks, each within `MAX_INPUTS_PER_SWEEP`. +/// +/// Using a ceil-divided chunk size keeps chunks near-equal (e.g. 501 → 251 + +/// 250, not 500 + 1), so no chunk is a lone sub-fee dust input. `total` must be +/// greater than zero (the sweep early-returns on an empty UTXO set). +fn sweep_chunk_size(total: usize) -> usize { + debug_assert!(total > 0, "sweep_chunk_size requires at least one UTXO"); + let num_chunks = total.div_ceil(MAX_INPUTS_PER_SWEEP); + total.div_ceil(num_chunks) +} + impl CoreWallet { /// Broadcast a signed transaction to the network. /// @@ -146,8 +167,8 @@ impl CoreWallet { Ok(tx) } - /// Sweep the *entire* spendable balance of a CoinJoin account into a - /// single output to `dest`, leaving no change behind. + /// Sweep the *entire* spendable balance of a CoinJoin account to `dest`, + /// leaving no change behind, across one or more transactions. /// /// CoinJoin "mixed coins" live on a dedicated CoinJoin account (BIP44 /// purpose 4'), which [`send_to_addresses`](Self::send_to_addresses) @@ -155,27 +176,40 @@ impl CoreWallet { /// is used by the DashSync → SwiftDashSDK migration to move a user's /// mixed coins (no longer supported) into their spendable balance. /// - /// All UTXOs are added as explicit inputs and the transaction is - /// assembled and signed directly — it deliberately does NOT route through - /// `TransactionBuilder::build_signed`, whose `LargestFirst` coin selection - /// re-selects a *covering subset* and stops early, which can drop small - /// UTXOs (e.g. a tiny fragment sitting behind larger denominations) and - /// leave the account non-empty. The single output is `total_input - fee` - /// (fee sized for N inputs + 1 output, no change), so there is no change - /// output and every UTXO is consumed. + /// The UTXO set is split into balanced chunks of at most + /// [`MAX_INPUTS_PER_SWEEP`] inputs so no transaction exceeds the standard + /// relay size limit (a heavy mixer can hold thousands of small mixed-coin + /// UTXOs). Each chunk spends a *disjoint* slice of the snapshot, so the + /// transactions have no inter-dependency and may broadcast in any order. + /// + /// Within each chunk all inputs are added explicitly and the transaction + /// is assembled and signed directly — it deliberately does NOT route + /// through `TransactionBuilder::build_signed`, whose `LargestFirst` coin + /// selection re-selects a *covering subset* and stops early, which can drop + /// small UTXOs and leave the account non-empty. Each chunk's single output + /// is `chunk_total - chunk_fee` (no change), so every UTXO is consumed. + /// + /// Returns the broadcast transactions in chunk order. Broadcast tolerates + /// partial failure: the successfully broadcast transactions are returned + /// (the caller refreshes balance and may re-run to sweep any remainder, + /// since a re-run sees only the still-unspent UTXOs). An error is returned + /// only if *no* transaction broadcast at all. pub async fn sweep_coinjoin_to_address( &self, account_index: u32, dest: DashAddress, signer: &S, - ) -> Result { + ) -> Result, PlatformWalletError> { use dashcore::blockdata::witness::Witness; use dashcore::{ScriptBuf, TxIn, TxOut}; use key_wallet::wallet::managed_wallet_info::fee::FeeRate; use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionSigner; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; - let tx = { + // Build + sign every chunk under the wallet write lock (signing borrows + // `managed_account` for address derivation), then broadcast after the + // lock is released. + let signed_txs: Vec = { let mut wm = self.wallet_manager.write().await; let (_wallet, info) = wm.get_wallet_and_info_mut(&self.wallet_id).ok_or_else(|| { PlatformWalletError::WalletNotFound( @@ -210,71 +244,140 @@ impl CoreWallet { )); } - let total_input: u64 = utxos.iter().map(|u| u.value()).sum(); - let input_count = utxos.len(); - - // Exact fee for (input_count inputs, 1 output, no change). Mirrors - // key-wallet's `calculate_base_size()` (8 + input-varint + output- - // varint + 34) and the selector's 148 B/input, so `total_input - - // fee` drives the selector to pick all inputs with zero change. let fee_rate = FeeRate::normal(); const BASE_SIZE_1_OUTPUT_NO_CHANGE: usize = 8 + 1 + 1 + 34; const INPUT_SIZE: usize = 148; - let fee = fee_rate - .calculate_fee(BASE_SIZE_1_OUTPUT_NO_CHANGE + input_count * INPUT_SIZE); - - if total_input <= fee { - return Err(PlatformWalletError::TransactionBuild(format!( - "CoinJoin balance {} is below the sweep fee {}", - total_input, fee - ))); - } - let output_amount = total_input - fee; - - // Assemble the tx with ALL inputs explicitly and sign it directly. - // Do NOT use `TransactionBuilder::build_signed` — its `LargestFirst` - // coin selection re-selects a covering subset and stops once - // `output_amount + fee` is met, which can drop small UTXOs and - // leave the CoinJoin account non-empty. A sweep must consume - // everything, so we build the all-input, single-output tx by hand. - let tx_inputs: Vec = utxos - .iter() - .map(|u| TxIn { - previous_output: u.outpoint, - script_sig: ScriptBuf::new(), - sequence: 0xffff_ffff, // Dash has no RBF - witness: Witness::new(), - }) - .collect(); - let unsigned = Transaction { - version: 3, - lock_time: 0, - input: tx_inputs, - output: vec![TxOut { - value: output_amount, - script_pubkey: dest.script_pubkey(), - }], - special_transaction_payload: None, - }; - // `sign_tx` signs `tx.input[i]` using `utxos[i]`, so input order - // and the utxo vec must line up — both derive from the same vec. - let signed = signer - .sign_tx(unsigned, utxos, |addr| { - managed_account.address_derivation_path(&addr) - }) - .await - .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; + // Balanced chunks of <= MAX_INPUTS_PER_SWEEP so no transaction + // exceeds the relay size limit. `chunks()` over disjoint slices + // guarantees each UTXO is consumed by exactly one transaction. + let chunk_size = sweep_chunk_size(utxos.len()); + let mut signed_txs = Vec::with_capacity(utxos.len().div_ceil(chunk_size)); - debug_assert_eq!( - signed.input.len(), - input_count, - "CoinJoin sweep must consume every UTXO" - ); - signed + for chunk in utxos.chunks(chunk_size) { + let chunk_utxos: Vec<_> = chunk.to_vec(); + let input_count = chunk_utxos.len(); + let total_input: u64 = chunk_utxos.iter().map(|u| u.value()).sum(); + + // Exact fee for (input_count inputs, 1 output, no change). + // Mirrors key-wallet's `calculate_base_size()` (8 + input- + // varint + output-varint + 34) and the selector's 148 B/input, + // so `total_input - fee` yields a single output with zero + // change for this chunk. + let fee = fee_rate + .calculate_fee(BASE_SIZE_1_OUTPUT_NO_CHANGE + input_count * INPUT_SIZE); + + if total_input <= fee { + return Err(PlatformWalletError::TransactionBuild(format!( + "CoinJoin sweep chunk balance {} is below the chunk fee {}", + total_input, fee + ))); + } + let output_amount = total_input - fee; + + // Assemble the chunk's tx with ITS inputs explicitly and sign + // it directly. Do NOT use `TransactionBuilder::build_signed` — + // its `LargestFirst` coin selection re-selects a covering subset + // and stops once `output_amount + fee` is met, which can drop + // small UTXOs and leave the CoinJoin account non-empty. A sweep + // must consume everything, so each chunk is an all-input, + // single-output tx built by hand. + let tx_inputs: Vec = chunk_utxos + .iter() + .map(|u| TxIn { + previous_output: u.outpoint, + script_sig: ScriptBuf::new(), + sequence: 0xffff_ffff, // Dash has no RBF + witness: Witness::new(), + }) + .collect(); + let unsigned = Transaction { + version: 3, + lock_time: 0, + input: tx_inputs, + output: vec![TxOut { + value: output_amount, + script_pubkey: dest.script_pubkey(), + }], + special_transaction_payload: None, + }; + + // `sign_tx` signs `tx.input[i]` using `chunk_utxos[i]`, so input + // order and the utxo vec must line up — both derive from `chunk`. + let signed = signer + .sign_tx(unsigned, chunk_utxos, |addr| { + managed_account.address_derivation_path(&addr) + }) + .await + .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; + + debug_assert_eq!( + signed.input.len(), + input_count, + "CoinJoin sweep chunk must consume every UTXO in the chunk" + ); + signed_txs.push(signed); + } + + signed_txs }; - self.broadcast_transaction(&tx).await?; - Ok(tx) + // Broadcast each chunk (disjoint inputs, no inter-tx dependency, so + // order is irrelevant). Collect successes and tolerate partial failure + // so a flaky broadcast doesn't strand the chunks that did go out — the + // caller can re-run to sweep any remainder. Error only if nothing + // broadcast at all. + let mut broadcast: Vec = Vec::with_capacity(signed_txs.len()); + let mut last_err: Option = None; + for tx in signed_txs { + match self.broadcast_transaction(&tx).await { + Ok(_) => broadcast.push(tx), + Err(e) => last_err = Some(e), + } + } + + if broadcast.is_empty() { + return Err(last_err.unwrap_or_else(|| { + PlatformWalletError::TransactionBuild( + "CoinJoin sweep produced no broadcastable transactions".to_string(), + ) + })); + } + + Ok(broadcast) + } +} + +#[cfg(test)] +mod sweep_chunking_tests { + use super::{sweep_chunk_size, MAX_INPUTS_PER_SWEEP}; + + /// The chunk plan must, for any UTXO count: produce `ceil(total / MAX)` + /// transactions, keep every chunk within `MAX` inputs, and consume every + /// UTXO exactly once (disjoint slices that sum back to `total`). + #[test] + fn partitions_every_utxo_within_the_relay_cap() { + for &total in &[ + 1usize, 30, 499, 500, 501, 675, 999, 1000, 1001, 1499, 5000, 12_345, + ] { + let chunk_size = sweep_chunk_size(total); + let sizes: Vec = (0..total) + .collect::>() + .chunks(chunk_size) + .map(|c| c.len()) + .collect(); + + let expected_chunks = total.div_ceil(MAX_INPUTS_PER_SWEEP); + assert_eq!(sizes.len(), expected_chunks, "tx count for {total} UTXOs"); + assert_eq!( + sizes.iter().sum::(), + total, + "every UTXO consumed exactly once for {total}" + ); + assert!( + sizes.iter().all(|&n| n >= 1 && n <= MAX_INPUTS_PER_SWEEP), + "every chunk within [1, {MAX_INPUTS_PER_SWEEP}] for {total}: {sizes:?}" + ); + } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift index c1207bdedf3..ed642a46a38 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift @@ -162,8 +162,8 @@ public class ManagedCoreWallet { return Data(bytes: ptr, count: Int(txLen)) } - /// Sweep the entire spendable balance of the wallet's CoinJoin account - /// into a single output to `destination`, leaving no change so the + /// Sweep the entire spendable balance of the wallet's CoinJoin account to + /// `destination` across one or more transactions, leaving no change so the /// account is fully emptied. /// /// CoinJoin "mixed coins" live on a dedicated account (BIP44 purpose 4') @@ -171,14 +171,17 @@ public class ManagedCoreWallet { /// reach. Used by the DashSync → SwiftDashSDK migration to move a user's /// mixed coins (no longer supported) into their spendable balance. /// - /// Returns the serialized signed transaction (already broadcast by the - /// FFI), mirroring `sendToAddresses`. + /// A heavy mixer's UTXO set can exceed a single transaction's relay-size + /// limit, so the sweep is split into balanced chunks. Returns the + /// **wire-order** txids of the broadcast transactions (already broadcast by + /// the FFI), in chunk order — the caller records them to group the + /// resulting withdrawals. public func sweepCoinJoinAccount( accountIndex: UInt32 = 0, destination: String - ) throws -> Data { - var txBytesPtr: UnsafeMutablePointer? = nil - var txLen: UInt = 0 + ) throws -> [Data] { + var txidsPtr: UnsafeMutablePointer? = nil + var count: UInt = 0 // Single owned recipient string; the resolver-backed signer owns // mnemonic access for the lifetime of the call (same model as @@ -192,18 +195,24 @@ public class ManagedCoreWallet { accountIndex, destPtr, resolver.handle, - &txBytesPtr, - &txLen + &txidsPtr, + &count ).check() } } - guard let ptr = txBytesPtr, txLen > 0 else { - throw PlatformWalletError.unknown("FFI returned success but tx buffer was empty") + guard let ptr = txidsPtr, count > 0 else { + throw PlatformWalletError.unknown("FFI returned success but txid buffer was empty") } - defer { core_wallet_free_tx_bytes(ptr, txLen) } + // One contiguous `count * 32` byte buffer; free it as a single block. + let byteCount = Int(count) * 32 + defer { core_wallet_free_tx_bytes(ptr, UInt(byteCount)) } - return Data(bytes: ptr, count: Int(txLen)) + // Split into individual 32-byte wire-order txids (chunk order). + let allBytes = Data(bytes: ptr, count: byteCount) + return (0.. Date: Mon, 8 Jun 2026 10:34:52 +0200 Subject: [PATCH 03/10] chore(deps): re-pin rust-dashcore to dev (#804 merged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dashpay/rust-dashcore#804 ("fix(key-wallet): correct CoinJoin discovery") is merged into dev. Switch the 8 workspace rust-dashcore crates from the prior pinned rev (eb889af) to dev's HEAD 7ff6b246 — the #804 merge commit — so the CoinJoin sweep/recovery stack builds against the merged fix instead of the pre-merge PR branch. cargo check -p platform-wallet -p platform-wallet-ffi passes; Cargo.lock updated (same 0.43.0 versions, source rev only). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 16 ++++++++-------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fef98173a96..c75bc50a41b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1550,7 +1550,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=7ff6b246df72164adb351551e819e53d10057caa#7ff6b246df72164adb351551e819e53d10057caa" dependencies = [ "bincode", "bincode_derive", @@ -1561,7 +1561,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=7ff6b246df72164adb351551e819e53d10057caa#7ff6b246df72164adb351551e819e53d10057caa" dependencies = [ "dash-network", ] @@ -1638,7 +1638,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=7ff6b246df72164adb351551e819e53d10057caa#7ff6b246df72164adb351551e819e53d10057caa" dependencies = [ "async-trait", "chrono", @@ -1667,7 +1667,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=7ff6b246df72164adb351551e819e53d10057caa#7ff6b246df72164adb351551e819e53d10057caa" dependencies = [ "anyhow", "base64-compat", @@ -1693,12 +1693,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=7ff6b246df72164adb351551e819e53d10057caa#7ff6b246df72164adb351551e819e53d10057caa" [[package]] name = "dashcore-rpc" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=7ff6b246df72164adb351551e819e53d10057caa#7ff6b246df72164adb351551e819e53d10057caa" dependencies = [ "dashcore-rpc-json", "hex", @@ -1711,7 +1711,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=7ff6b246df72164adb351551e819e53d10057caa#7ff6b246df72164adb351551e819e53d10057caa" dependencies = [ "bincode", "dashcore", @@ -1726,7 +1726,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=7ff6b246df72164adb351551e819e53d10057caa#7ff6b246df72164adb351551e819e53d10057caa" dependencies = [ "bincode", "dashcore-private", @@ -2639,7 +2639,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=7ff6b246df72164adb351551e819e53d10057caa#7ff6b246df72164adb351551e819e53d10057caa" [[package]] name = "glob" @@ -3783,7 +3783,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=7ff6b246df72164adb351551e819e53d10057caa#7ff6b246df72164adb351551e819e53d10057caa" dependencies = [ "aes", "async-trait", @@ -3811,7 +3811,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=7ff6b246df72164adb351551e819e53d10057caa#7ff6b246df72164adb351551e819e53d10057caa" dependencies = [ "cbindgen 0.29.2", "dash-network", @@ -3827,7 +3827,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=eb889af13f667ed39c35e8e8a0830eeedf523476#eb889af13f667ed39c35e8e8a0830eeedf523476" +source = "git+https://github.com/dashpay/rust-dashcore?rev=7ff6b246df72164adb351551e819e53d10057caa#7ff6b246df72164adb351551e819e53d10057caa" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index cdd99bb28e9..4581e07e91c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,14 +49,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "eb889af13f667ed39c35e8e8a0830eeedf523476" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "7ff6b246df72164adb351551e819e53d10057caa" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "7ff6b246df72164adb351551e819e53d10057caa" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "7ff6b246df72164adb351551e819e53d10057caa" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "7ff6b246df72164adb351551e819e53d10057caa" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "7ff6b246df72164adb351551e819e53d10057caa" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "7ff6b246df72164adb351551e819e53d10057caa" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "7ff6b246df72164adb351551e819e53d10057caa" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "7ff6b246df72164adb351551e819e53d10057caa" } # Optimize heavy crypto crates even in dev/test builds so that # Halo 2 proof generation and verification run at near-release speed. From 904118732b85cc7dd8b6097057b0f8fe602b1fa9 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 8 Jun 2026 13:40:12 +0200 Subject: [PATCH 04/10] fix(coinjoin): sign internal-chain ("change") mixed coins in the sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DashSync CoinJoin uses two chains — external (.../0/i, receive/denominations) and internal (.../1/i, mixing change) — but the SDK's CoinJoin account models only the external pool. A migrated wallet's internal-chain mixed coins are imported as spendable UTXOs (they count in the balance) yet have no derivation path in the account, so signing a sweep that includes them failed with "no derivation path for input address" — blocking the whole sweep (one unresolved input fails its chunk). sweep_coinjoin_to_address now resolves signing paths across BOTH chains: it derives external and internal addresses from the CoinJoin account xpub (non-hardened, public-key-only) into a combined address->path map covering every input, and signs from it. Errors clearly if any input can't be resolved on either chain within COINJOIN_SWEEP_MAX_INDEX, rather than failing mid-sign. Self-contained — no longer relies on the account's external pool being pre-widened. Verified on a real migrated testnet wallet: 12.746 DASH (incl. internal-chain coins) swept to spendable. + 2 unit tests (external + internal resolution, and the unresolvable-input error). Rebuild the xcframework to pick this up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/wallet/core/broadcast.rs | 220 +++++++++++++++++- 1 file changed, 216 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 9ffe3767b72..b73a009d01f 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -1,7 +1,9 @@ use dashcore::{Address as DashAddress, Transaction}; use key_wallet::account::account_type::StandardAccountType; +use key_wallet::managed_account::address_pool::KeySource; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::signer::Signer; +use key_wallet::{DerivationPath, Network}; use crate::broadcaster::TransactionBroadcaster; use crate::{CoreWallet, PlatformWalletError}; @@ -211,12 +213,34 @@ impl CoreWallet { // lock is released. let signed_txs: Vec = { let mut wm = self.wallet_manager.write().await; - let (_wallet, info) = wm.get_wallet_and_info_mut(&self.wallet_id).ok_or_else(|| { + let (wallet, info) = wm.get_wallet_and_info_mut(&self.wallet_id).ok_or_else(|| { PlatformWalletError::WalletNotFound( "Wallet not found in wallet manager".to_string(), ) })?; + // CoinJoin account key material for deriving signing paths across + // BOTH chains. DashSync CoinJoin puts mixing *change* on the internal + // chain (.../1/i), but the SDK's CoinJoin account derives only the + // external pool (.../0/i) — so internal-chain inputs are owned and + // spendable yet have no derivation path. `coinjoin_sweep_path_map` + // (below) re-derives both chains from the account xpub. Copied/cloned + // out so the `wallet` borrow ends before the `info` borrow + // (`account_xpub` is `Copy`). + let (account_xpub, account_type, network) = { + let acct = wallet + .accounts + .coinjoin_accounts + .get(&account_index) + .ok_or_else(|| { + PlatformWalletError::WalletNotFound(format!( + "CoinJoin account {} not found", + account_index + )) + })?; + (acct.account_xpub, acct.account_type.clone(), acct.network) + }; + let current_height = info.core_wallet.synced_height(); let managed_account = info @@ -244,6 +268,24 @@ impl CoreWallet { )); } + // Resolve an absolute derivation path for every input address across + // BOTH CoinJoin chains (external /0/ and internal /1/), so the signer + // can sign internal-chain ("change") mixed coins too. Errors if any + // input can't be resolved on either chain rather than failing mid-sign. + let account_path = account_type + .derivation_path(network) + .map_err(|e| PlatformWalletError::AddressOperation(e.to_string()))?; + let key_source = KeySource::Public(account_xpub); + let input_addresses: Vec = + utxos.iter().map(|u| u.address.clone()).collect(); + let path_map = coinjoin_sweep_path_map( + &account_path, + &key_source, + network, + &input_addresses, + COINJOIN_SWEEP_MAX_INDEX, + )?; + let fee_rate = FeeRate::normal(); const BASE_SIZE_1_OUTPUT_NO_CHANGE: usize = 8 + 1 + 1 + 34; const INPUT_SIZE: usize = 148; @@ -305,9 +347,7 @@ impl CoreWallet { // `sign_tx` signs `tx.input[i]` using `chunk_utxos[i]`, so input // order and the utxo vec must line up — both derive from `chunk`. let signed = signer - .sign_tx(unsigned, chunk_utxos, |addr| { - managed_account.address_derivation_path(&addr) - }) + .sign_tx(unsigned, chunk_utxos, |addr| path_map.get(&addr).cloned()) .await .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; @@ -348,6 +388,89 @@ impl CoreWallet { } } +/// Build an `address → absolute derivation path` map covering every address in +/// `needed_addrs` across BOTH CoinJoin chains — external (`/0/`) and internal +/// (`/1/`) — derived from the account's public xpub. `account_path` is the +/// hardened account path `m/9'/coin'/4'/account'`. +/// +/// The SDK's CoinJoin account models only the external chain, but DashSync +/// CoinJoin puts mixing *change* on the internal chain. A migrated wallet's +/// internal-chain mixed coins are imported as spendable UTXOs yet have no +/// derivation path in the account, so signing a sweep that includes them fails +/// with "no derivation path for input address". This re-derives both chains +/// (non-hardened, public-key-only) and returns each input's absolute path so the +/// signer can sign every input regardless of chain. +/// +/// Returns an error if any address can't be resolved within +/// `COINJOIN_SWEEP_MAX_INDEX` on either chain — defensive, so a sweep never +/// silently mis-signs or drops an input. +/// Per-chain index ceiling for the sweep resolver. A heavy mixer's CoinJoin +/// indices sit well under this; the cap only bounds the (never-hit-in-practice) +/// unresolved case so the search terminates. +const COINJOIN_SWEEP_MAX_INDEX: u32 = 20_000; + +fn coinjoin_sweep_path_map( + account_path: &DerivationPath, + key_source: &KeySource, + network: Network, + needed_addrs: &[DashAddress], + max_index: u32, +) -> Result, PlatformWalletError> { + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType}; + use key_wallet::ChildNumber; + use std::collections::{HashMap, HashSet}; + + const BATCH: u32 = 500; + + let mut needed: HashSet = needed_addrs.iter().cloned().collect(); + let mut path_map: HashMap = HashMap::new(); + + // chain 0 = external (receive / denominations), chain 1 = internal (change). + for (chain, pool_type) in [ + (0u32, AddressPoolType::External), + (1u32, AddressPoolType::Internal), + ] { + if needed.is_empty() { + break; + } + let mut base = account_path.clone(); + base.push( + ChildNumber::from_normal_idx(chain) + .map_err(|e| PlatformWalletError::AddressOperation(e.to_string()))?, + ); + // Empty pool (NoKeySource skips generation); we generate below with the + // real public key source so each `AddressInfo` carries its full path. + let mut pool = AddressPool::new(base, pool_type, 0, network, &KeySource::NoKeySource) + .map_err(|e| PlatformWalletError::AddressOperation(e.to_string()))?; + + let mut generated = 0u32; + while generated < max_index && !needed.is_empty() { + let batch = pool + .generate_addresses(BATCH, key_source, true) + .map_err(|e| PlatformWalletError::AddressOperation(e.to_string()))?; + for addr in &batch { + if needed.remove(addr) { + if let Some(info) = pool.address_info(addr) { + path_map.insert(addr.clone(), info.path.clone()); + } + } + } + generated += BATCH; + } + } + + if !needed.is_empty() { + return Err(PlatformWalletError::TransactionBuild(format!( + "CoinJoin sweep: {} input address(es) have no derivation path on either \ + CoinJoin chain (within {} indices)", + needed.len(), + max_index + ))); + } + + Ok(path_map) +} + #[cfg(test)] mod sweep_chunking_tests { use super::{sweep_chunk_size, MAX_INPUTS_PER_SWEEP}; @@ -381,3 +504,92 @@ mod sweep_chunking_tests { } } } + +#[cfg(test)] +mod coinjoin_sweep_path_map_tests { + use super::coinjoin_sweep_path_map; + use dashcore::secp256k1::Secp256k1; + use key_wallet::account::account_type::AccountType; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey, Network}; + + fn coinjoin_account(network: Network, seed_byte: u8) -> (ExtendedPubKey, DerivationPath) { + let secp = Secp256k1::new(); + let master = ExtendedPrivKey::new_master(network, &[seed_byte; 32]).unwrap(); + let account_path = AccountType::CoinJoin { index: 0 } + .derivation_path(network) + .unwrap(); + let account_xpriv = master.derive_priv(&secp, &account_path).unwrap(); + (ExtendedPubKey::from_priv(&secp, &account_xpriv), account_path) + } + + /// Derive `//` the way the resolver does, to get + /// a known target address to look up. + fn derive_addr( + xpub: &ExtendedPubKey, + account_path: &DerivationPath, + network: Network, + chain: u32, + index: u32, + ) -> dashcore::Address { + let pool_type = if chain == 0 { + AddressPoolType::External + } else { + AddressPoolType::Internal + }; + let mut base = account_path.clone(); + base.push(ChildNumber::from_normal_idx(chain).unwrap()); + let mut pool = + AddressPool::new(base, pool_type, 0, network, &KeySource::NoKeySource).unwrap(); + let addrs = pool + .generate_addresses(index + 1, &KeySource::Public(*xpub), true) + .unwrap(); + addrs[index as usize].clone() + } + + fn abs_path(account_path: &DerivationPath, chain: u32, index: u32) -> DerivationPath { + let mut p = account_path.clone(); + p.push(ChildNumber::from_normal_idx(chain).unwrap()); + p.push(ChildNumber::from_normal_idx(index).unwrap()); + p + } + + /// Both an external (/0/) and an internal (/1/) CoinJoin address resolve to + /// their correct absolute paths — the internal case is the bug this fixes. + #[test] + fn resolves_external_and_internal_chain_addresses() { + let network = Network::Testnet; + let (xpub, account_path) = coinjoin_account(network, 7); + let key_source = KeySource::Public(xpub); + + let external = derive_addr(&xpub, &account_path, network, 0, 7); + let internal = derive_addr(&xpub, &account_path, network, 1, 42); + + let map = coinjoin_sweep_path_map( + &account_path, + &key_source, + network, + &[external.clone(), internal.clone()], + 200, + ) + .unwrap(); + + assert_eq!(map.get(&external), Some(&abs_path(&account_path, 0, 7))); + assert_eq!(map.get(&internal), Some(&abs_path(&account_path, 1, 42))); + } + + /// An address not derivable from this account xpub on either chain yields the + /// defensive error (here: a different wallet's CoinJoin address). + #[test] + fn errors_when_an_input_address_is_unresolvable() { + let network = Network::Testnet; + let (xpub, account_path) = coinjoin_account(network, 7); + let key_source = KeySource::Public(xpub); + + let (other_xpub, other_path) = coinjoin_account(network, 9); + let foreign = derive_addr(&other_xpub, &other_path, network, 0, 3); + + let result = coinjoin_sweep_path_map(&account_path, &key_source, network, &[foreign], 200); + assert!(result.is_err(), "foreign address must not resolve"); + } +} From c65905f8a1b0e416b64b58a9e2466c364e168634 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 9 Jun 2026 08:39:36 +0200 Subject: [PATCH 05/10] =?UTF-8?q?fix(coinjoin):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20reject=20zero=20gap=20limit,=20harden=20sweep=20inv?= =?UTF-8?q?ariants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #3817 review feedback: - [blocking] set_coinjoin_gap_limit: reject gap_limit == 0 before touching the pool. key-wallet's maintain_gap_limit computes `gap_limit - 1` when no address has been used, underflowing at 0 (debug panic / release wrap to u32::MAX → ~4B address generations under the write lock). Callers only pass 400, but this is a public + FFI API, so validate at the boundary. - coinjoin_sweep_path_map: drop an address from `needed` only after its path is recorded, so "unresolved ⇒ error" holds by construction even if a future AddressPool refactor desyncs generate_addresses / address_info. - sweep chunk: promote the "signer consumed every UTXO" debug_assert_eq! to a runtime error — it guards the single-output `total_input - fee` amount, a fund-correctness invariant that must hold in release too. - broadcast loop: log each dropped chunk error via tracing::warn! so a partial sweep is observable (still tolerated; caller re-runs for the remainder). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/wallet/core/broadcast.rs | 38 +++++++++++++++---- .../src/wallet/core/coinjoin_recovery.rs | 12 +++++- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index b73a009d01f..846e60e91aa 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -351,11 +351,19 @@ impl CoreWallet { .await .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; - debug_assert_eq!( - signed.input.len(), - input_count, - "CoinJoin sweep chunk must consume every UTXO in the chunk" - ); + // Fund-safety invariant: the single output is `total_input - + // fee`, which is only correct if the signer consumed every UTXO + // in the chunk. Enforce at runtime (not debug_assert, which is + // compiled out in release) so a signer that ever drops an input + // aborts the chunk instead of broadcasting an under-consuming tx. + if signed.input.len() != input_count { + return Err(PlatformWalletError::TransactionBuild(format!( + "CoinJoin sweep chunk must consume every UTXO in the chunk: \ + signed {} inputs, expected {}", + signed.input.len(), + input_count + ))); + } signed_txs.push(signed); } @@ -372,7 +380,16 @@ impl CoreWallet { for tx in signed_txs { match self.broadcast_transaction(&tx).await { Ok(_) => broadcast.push(tx), - Err(e) => last_err = Some(e), + Err(e) => { + // Partial failure is tolerated (caller re-runs to sweep the + // remainder), but never silent: log each dropped chunk error. + tracing::warn!( + "CoinJoin sweep: a chunk failed to broadcast, continuing \ + with remaining chunks (caller can re-run): {}", + e + ); + last_err = Some(e); + } } } @@ -449,9 +466,16 @@ fn coinjoin_sweep_path_map( .generate_addresses(BATCH, key_source, true) .map_err(|e| PlatformWalletError::AddressOperation(e.to_string()))?; for addr in &batch { - if needed.remove(addr) { + // Only drop from `needed` once the path is actually recorded, so + // "removed from needed ⇒ inserted into path_map" holds by + // construction. If `address_info` ever returned None for a + // freshly generated address (an AddressPool invariant break), the + // address stays in `needed` and the check below errors loudly + // rather than silently dropping it into a mid-sign failure. + if needed.contains(addr) { if let Some(info) = pool.address_info(addr) { path_map.insert(addr.clone(), info.path.clone()); + needed.remove(addr); } } } diff --git a/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs b/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs index e5acff14ab4..36a48f6bc35 100644 --- a/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs @@ -93,7 +93,17 @@ impl CoreWallet { ) })?; - pool.gap_limit = gap_limit.min(MAX_GAP_LIMIT); + // Reject a zero gap limit before touching the pool. key-wallet's + // `maintain_gap_limit` computes `gap_limit - 1` when no address has + // been used yet, which underflows at 0 (debug panic / release wrap + // to u32::MAX → ~4B address generations under the write lock). + let gap_limit = gap_limit.min(MAX_GAP_LIMIT); + if gap_limit == 0 { + return Err(PlatformWalletError::AddressOperation( + "CoinJoin gap limit must be greater than zero".to_string(), + )); + } + pool.gap_limit = gap_limit; pool.maintain_gap_limit(&key_source) .map_err(|e| PlatformWalletError::AddressOperation(e.to_string()))?; pool.highest_generated.unwrap_or(0) From f52644b7e8189975eb1f7e66ea060f5a29a3b042 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 9 Jun 2026 09:18:51 +0200 Subject: [PATCH 06/10] fix(coinjoin): validate sweep destination network + rustfmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #3817 round-2 review: - [blocking] core_wallet_sweep_coinjoin (FFI): validate the caller-supplied destination against the wallet's network. It was parsed with assume_checked() and used to sweep every CoinJoin UTXO with no network check — a wrong-network address would drain the account to a script unspendable on this chain. Now parses NetworkUnchecked and require_network(wallet.network()) inside the storage closure, erroring before any tx is built. Mirrors the withdrawal FFI. (Not reachable from the app, which always passes its own current-network receive address, but this is a public fund-moving FFI boundary.) - cargo fmt on the three touched files to satisfy CI rustfmt. Two other round-2 suggestions were declined with on-thread reasons: the resolver batch-overshoot (20000 is an exact multiple of 500 — no overshoot) and releasing the wallet write lock across the sign .await (tokio async RwLock, broadcast is already unlocked, body does no wallet mutation — a throughput nit not worth the borrow-ordering risk on a rare one-time sweep). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/core_wallet/broadcast.rs | 12 +++++++++++- .../rs-platform-wallet/src/wallet/core/broadcast.rs | 9 ++++++--- .../src/wallet/core/coinjoin_recovery.rs | 4 +--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs index 0f2af1a7f70..1598c4d0f9d 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -166,13 +166,23 @@ pub unsafe extern "C" fn core_wallet_sweep_coinjoin( check_ptr!(out_count); let dest_str = unwrap_result_or_return!(std::ffi::CStr::from_ptr(dest_address).to_str()); - let dest = unwrap_result_or_return!(dashcore::Address::from_str(dest_str)).assume_checked(); + // Parse without committing to a network; the destination is validated + // against the wallet's own network inside the storage closure below so a + // wrong-network address fails before any tx is built. This is the all-funds + // sweep, so the destination script must be spendable on this chain. + let dest_unchecked = unwrap_result_or_return!(dashcore::Address::from_str(dest_str)); let signer_addr = core_signer_handle as usize; let option = CORE_WALLET_STORAGE.with_item(handle, |wallet| { let wallet_id = wallet.wallet_id(); let network = wallet.network(); + // Reject a wrong-network destination before building/broadcasting the + // all-funds sweep — the resulting script would be unspendable on this + // chain and the sweep is irreversible. Mirrors the withdrawal FFI. + let dest = dest_unchecked + .require_network(network) + .map_err(|e| platform_wallet::PlatformWalletError::AddressOperation(e.to_string()))?; // SAFETY: the resolver handle is pinned alive for the duration of // this FFI call (see fn-level safety doc). The // `MnemonicResolverCoreSigner` lives on this stack frame and is diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 846e60e91aa..02dc1cf2cf4 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -306,8 +306,8 @@ impl CoreWallet { // varint + output-varint + 34) and the selector's 148 B/input, // so `total_input - fee` yields a single output with zero // change for this chunk. - let fee = fee_rate - .calculate_fee(BASE_SIZE_1_OUTPUT_NO_CHANGE + input_count * INPUT_SIZE); + let fee = + fee_rate.calculate_fee(BASE_SIZE_1_OUTPUT_NO_CHANGE + input_count * INPUT_SIZE); if total_input <= fee { return Err(PlatformWalletError::TransactionBuild(format!( @@ -544,7 +544,10 @@ mod coinjoin_sweep_path_map_tests { .derivation_path(network) .unwrap(); let account_xpriv = master.derive_priv(&secp, &account_path).unwrap(); - (ExtendedPubKey::from_priv(&secp, &account_xpriv), account_path) + ( + ExtendedPubKey::from_priv(&secp, &account_xpriv), + account_path, + ) } /// Derive `//` the way the resolver does, to get diff --git a/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs b/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs index 36a48f6bc35..7da14460dbf 100644 --- a/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs @@ -45,9 +45,7 @@ impl CoreWallet { ) -> Result { let mut wm = self.wallet_manager.write().await; let (wallet, info) = wm.get_wallet_and_info_mut(&self.wallet_id).ok_or_else(|| { - PlatformWalletError::WalletNotFound( - "Wallet not found in wallet manager".to_string(), - ) + PlatformWalletError::WalletNotFound("Wallet not found in wallet manager".to_string()) })?; // Watch-only account xpub for deriving the CoinJoin pool's addresses. From a0ead209f38ff4ddd8f53f95b4c2b498fded2b99 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 9 Jun 2026 09:27:14 +0200 Subject: [PATCH 07/10] fix(coinjoin): satisfy clippy on the sweep path CI clippy: - drop a redundant .clone() on AccountType (it is Copy) in sweep_coinjoin_to_address - use RangeInclusive::contains instead of a manual `n >= 1 && n <= MAX` in the sweep_chunk_size test assertion (plus the rustfmt the change required). FFI crate is clippy-clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/rs-platform-wallet/src/wallet/core/broadcast.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 02dc1cf2cf4..14266ba59f8 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -238,7 +238,7 @@ impl CoreWallet { account_index )) })?; - (acct.account_xpub, acct.account_type.clone(), acct.network) + (acct.account_xpub, acct.account_type, acct.network) }; let current_height = info.core_wallet.synced_height(); @@ -522,7 +522,9 @@ mod sweep_chunking_tests { "every UTXO consumed exactly once for {total}" ); assert!( - sizes.iter().all(|&n| n >= 1 && n <= MAX_INPUTS_PER_SWEEP), + sizes + .iter() + .all(|&n| (1..=MAX_INPUTS_PER_SWEEP).contains(&n)), "every chunk within [1, {MAX_INPUTS_PER_SWEEP}] for {total}: {sizes:?}" ); } From 43ee9afe0ab9074413bd51a366630eedeeab554f Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Fri, 12 Jun 2026 12:19:28 +0200 Subject: [PATCH 08/10] fix(coinjoin): tighten sweep fee bound + own network check in the sweep API Round-3 review follow-ups on the CoinJoin sweep (all non-blocking review suggestions; verified with clippy + tests): - Sweep fee is now a true upper bound. FeeRate::normal() is exactly 1 duff/byte (the relay minimum, no headroom), so the size estimate must not undershoot the serialized tx or a chunk pays below the minimum and is rejected. Size the input-count CompactSize per chunk (1 B < 253 inputs, 3 B for 253..=500) and use the 149 B max compressed-P2PKH input instead of the 148 B typical. Reachable for a heavy mixer whose chunk holds 253-500 inputs; over-paying a few duffs on an all-funds sweep is harmless. - sweep_coinjoin_to_address now rejects a wrong-network destination itself (dest.as_unchecked().is_valid_for_network), not only at the FFI boundary, so the irreversible all-funds sweep is fund-safe for any Rust caller. - Partial-broadcast all-fail now returns the FIRST chunk error (last_err.get_or_insert) instead of the last - the likely root cause. - Documented coinjoin_recovery's external-only scope: internal-chain mixed coins arrive as imported UTXOs, so external-pool widening suffices for discovery while the sweep still signs both chains. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/wallet/core/broadcast.rs | 50 +++++++++++++++---- .../src/wallet/core/coinjoin_recovery.rs | 12 +++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 14266ba59f8..6300710f70f 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -241,6 +241,17 @@ impl CoreWallet { (acct.account_xpub, acct.account_type, acct.network) }; + // Fund-safety invariant owned by the sweep itself, not just the FFI + // boundary: refuse to drain every CoinJoin UTXO to a destination that + // isn't valid on this wallet's network. The sweep is irreversible, so + // any non-FFI Rust caller (tests, future wrappers) is covered here by + // construction. + if !dest.as_unchecked().is_valid_for_network(network) { + return Err(PlatformWalletError::AddressOperation(format!( + "CoinJoin sweep destination is not valid for the wallet network {network:?}" + ))); + } + let current_height = info.core_wallet.synced_height(); let managed_account = info @@ -287,8 +298,20 @@ impl CoreWallet { )?; let fee_rate = FeeRate::normal(); - const BASE_SIZE_1_OUTPUT_NO_CHANGE: usize = 8 + 1 + 1 + 34; - const INPUT_SIZE: usize = 148; + // Upper bounds for the serialized size of a (N-input, 1-output, + // no-change) tx. `FeeRate::normal()` is exactly 1 duff/byte — the + // relay minimum, with no headroom — so this size estimate must never + // undershoot the real transaction or the chunk pays below the minimum + // fee and gets rejected. The maximum compressed-P2PKH input is 149 B + // (36 outpoint + 1 script-len + 108 scriptSig at a 73-byte low-S DER + // signature + 4 sequence); the input-count CompactSize is sized per + // chunk below (it grows from 1 to 3 bytes at 253 inputs). Slightly + // over-paying on an all-funds sweep is harmless — the single output + // just absorbs the difference. + const VERSION_PLUS_LOCKTIME: usize = 8; + const OUTPUT_COUNT_VARINT: usize = 1; // exactly one output + const ONE_P2PKH_OUTPUT: usize = 34; + const MAX_P2PKH_INPUT_SIZE: usize = 149; // Balanced chunks of <= MAX_INPUTS_PER_SWEEP so no transaction // exceeds the relay size limit. `chunks()` over disjoint slices @@ -301,13 +324,18 @@ impl CoreWallet { let input_count = chunk_utxos.len(); let total_input: u64 = chunk_utxos.iter().map(|u| u.value()).sum(); - // Exact fee for (input_count inputs, 1 output, no change). - // Mirrors key-wallet's `calculate_base_size()` (8 + input- - // varint + output-varint + 34) and the selector's 148 B/input, - // so `total_input - fee` yields a single output with zero - // change for this chunk. - let fee = - fee_rate.calculate_fee(BASE_SIZE_1_OUTPUT_NO_CHANGE + input_count * INPUT_SIZE); + // Upper-bound fee for (input_count inputs, 1 output, no change) + // at the 1 duff/byte relay minimum, so `total_input - fee` is a + // single zero-change output that always clears relay. The + // input-count CompactSize is 1 byte below 253 inputs and 3 bytes + // for 253..=MAX_INPUTS_PER_SWEEP (500). + let input_count_varint = if input_count < 253 { 1 } else { 3 }; + let tx_size = VERSION_PLUS_LOCKTIME + + input_count_varint + + OUTPUT_COUNT_VARINT + + ONE_P2PKH_OUTPUT + + input_count * MAX_P2PKH_INPUT_SIZE; + let fee = fee_rate.calculate_fee(tx_size); if total_input <= fee { return Err(PlatformWalletError::TransactionBuild(format!( @@ -388,7 +416,9 @@ impl CoreWallet { with remaining chunks (caller can re-run): {}", e ); - last_err = Some(e); + // Keep the FIRST failure (usually the root cause); the later + // chunk errors are already surfaced via the warn! above. + last_err.get_or_insert(e); } } } diff --git a/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs b/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs index 7da14460dbf..5d85c8de7b5 100644 --- a/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs @@ -7,6 +7,18 @@ //! flagged (by the app) as having used CoinJoin in DashSync, the app widens the //! gap — matching DashSync's `SEQUENCE_GAP_LIMIT_INITIAL_COINJOIN` of 400 — //! before starting SPV, runs the recovery scan, then reverts to the default. +//! +//! ## Scope: external CoinJoin chain only +//! +//! This widens only the CoinJoin account's external pool (`.../0/i`). DashSync +//! CoinJoin also puts mixing *change* on the internal chain (`.../1/i`), but the +//! SDK's CoinJoin account models only the external chain, and a migrated wallet's +//! internal-chain mixed coins arrive as **imported spendable UTXOs** — not via an +//! SPV address scan — so no gap widening is needed to discover them. The sweep +//! still signs them regardless of chain: `coinjoin_sweep_path_map` (in +//! `core::broadcast`) re-derives both `/0/` and `/1/` from the account xpub. If a +//! future migration path instead relied on SPV to *re-discover* internal-chain +//! coins, this recovery would also need to materialize the internal pool. use key_wallet::gap_limit::MAX_GAP_LIMIT; use key_wallet::managed_account::address_pool::KeySource; From 41d5e8ac649a81b1f70ec708a47155b31da32a8c Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Fri, 12 Jun 2026 12:44:52 +0200 Subject: [PATCH 09/10] docs(coinjoin): note core_wallet_free_tx_bytes also frees the sweep txid buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core_wallet_free_tx_bytes is the free-contract for both core_wallet_send_to_addresses (serialized tx bytes) and core_wallet_sweep_coinjoin (the count*32 txid buffer), but its doc only mentioned the former. Pure docs — no behavioral change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rs-platform-wallet-ffi/src/core_wallet/broadcast.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs index 1598c4d0f9d..f95f8e419a5 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -247,7 +247,11 @@ pub unsafe extern "C" fn core_wallet_set_coinjoin_gap_limit( PlatformWalletFFIResult::ok() } -/// Free transaction bytes returned by `core_wallet_send_to_addresses`. +/// Free a heap buffer returned by this crate's CoreWallet FFI: +/// - the serialized transaction bytes from `core_wallet_send_to_addresses` +/// (pass the returned `out_tx_len` as `len`), or +/// - the contiguous `count * 32` byte txid buffer from +/// `core_wallet_sweep_coinjoin` (pass `out_count * 32` as `len`). #[no_mangle] pub unsafe extern "C" fn core_wallet_free_tx_bytes(bytes: *mut u8, len: usize) { if !bytes.is_null() && len > 0 { From 8760508eacd03b61995605a41be50531241a2444 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 23 Jun 2026 10:41:44 +0200 Subject: [PATCH 10/10] refactor(platform-wallet): delegate CoinJoin sweep + recovery to key-wallet Addresses review feedback (@ZocoLini): the CoinJoin sweep/recovery logic belongs in rust-dashcore, and the sweep should use TransactionBuilder instead of hand-rolling transaction assembly. The logic now lives upstream (dashpay/rust-dashcore#819); this bumps the pin to that commit and reduces the platform side to thin wrappers. - broadcast.rs: sweep_coinjoin_to_address resolves the account under the wallet lock, delegates build+sign to ManagedCoreFundsAccount::build_coinjoin_sweep_txs, then broadcasts (partial-failure tolerance unchanged). -458 lines. - coinjoin_recovery.rs: set_coinjoin_gap_limit delegates to ManagedCoreFundsAccount::set_coinjoin_gap_limit. -91 lines. - Cargo.toml/Cargo.lock: bump rust-dashcore pin 5c0113e7 -> e406d1cb (adds the upstream CoinJoin sweep/recovery + TransactionBuilder::sweep_to drain mode). Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 24 +- Cargo.toml | 16 +- .../src/wallet/core/broadcast.rs | 458 ++---------------- .../src/wallet/core/coinjoin_recovery.rs | 91 +--- 4 files changed, 72 insertions(+), 517 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e296c3aebdb..f9e04e30152 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e406d1cbb86a03503fa41a9d796f25c0bacc3e12#e406d1cbb86a03503fa41a9d796f25c0bacc3e12" dependencies = [ "bincode", "bincode_derive", @@ -1648,7 +1648,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e406d1cbb86a03503fa41a9d796f25c0bacc3e12#e406d1cbb86a03503fa41a9d796f25c0bacc3e12" dependencies = [ "dash-network", ] @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e406d1cbb86a03503fa41a9d796f25c0bacc3e12#e406d1cbb86a03503fa41a9d796f25c0bacc3e12" dependencies = [ "async-trait", "chrono", @@ -1754,7 +1754,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e406d1cbb86a03503fa41a9d796f25c0bacc3e12#e406d1cbb86a03503fa41a9d796f25c0bacc3e12" dependencies = [ "anyhow", "base64-compat", @@ -1780,12 +1780,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e406d1cbb86a03503fa41a9d796f25c0bacc3e12#e406d1cbb86a03503fa41a9d796f25c0bacc3e12" [[package]] name = "dashcore-rpc" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e406d1cbb86a03503fa41a9d796f25c0bacc3e12#e406d1cbb86a03503fa41a9d796f25c0bacc3e12" dependencies = [ "dashcore-rpc-json", "hex", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e406d1cbb86a03503fa41a9d796f25c0bacc3e12#e406d1cbb86a03503fa41a9d796f25c0bacc3e12" dependencies = [ "bincode", "dashcore", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e406d1cbb86a03503fa41a9d796f25c0bacc3e12#e406d1cbb86a03503fa41a9d796f25c0bacc3e12" dependencies = [ "bincode", "dashcore-private", @@ -2867,7 +2867,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e406d1cbb86a03503fa41a9d796f25c0bacc3e12#e406d1cbb86a03503fa41a9d796f25c0bacc3e12" [[package]] name = "glob" @@ -4034,7 +4034,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e406d1cbb86a03503fa41a9d796f25c0bacc3e12#e406d1cbb86a03503fa41a9d796f25c0bacc3e12" dependencies = [ "aes", "async-trait", @@ -4063,7 +4063,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e406d1cbb86a03503fa41a9d796f25c0bacc3e12#e406d1cbb86a03503fa41a9d796f25c0bacc3e12" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +source = "git+https://github.com/dashpay/rust-dashcore?rev=e406d1cbb86a03503fa41a9d796f25c0bacc3e12#e406d1cbb86a03503fa41a9d796f25c0bacc3e12" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 5d5f2960f1d..86e95d4797d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,14 +50,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "e406d1cbb86a03503fa41a9d796f25c0bacc3e12" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "e406d1cbb86a03503fa41a9d796f25c0bacc3e12" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "e406d1cbb86a03503fa41a9d796f25c0bacc3e12" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "e406d1cbb86a03503fa41a9d796f25c0bacc3e12" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "e406d1cbb86a03503fa41a9d796f25c0bacc3e12" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "e406d1cbb86a03503fa41a9d796f25c0bacc3e12" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "e406d1cbb86a03503fa41a9d796f25c0bacc3e12" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "e406d1cbb86a03503fa41a9d796f25c0bacc3e12" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 6300710f70f..0b68ae0ad46 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -1,34 +1,11 @@ use dashcore::{Address as DashAddress, Transaction}; use key_wallet::account::account_type::StandardAccountType; -use key_wallet::managed_account::address_pool::KeySource; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::signer::Signer; -use key_wallet::{DerivationPath, Network}; use crate::broadcaster::TransactionBroadcaster; use crate::{CoreWallet, PlatformWalletError}; -/// Max inputs per CoinJoin sweep transaction. A single Dash transaction must -/// stay under the standard relay/mempool size limit (`MAX_STANDARD_TX_SIZE` = -/// 100 000 B); at ~148 B/input (`INPUT_SIZE` below) that is ~675 inputs, so 500 -/// leaves a comfortable margin for the output + overhead. A heavy mixer's UTXOs -/// are therefore swept across `ceil(N / MAX_INPUTS_PER_SWEEP)` transactions -/// rather than one oversized, unrelayable transaction. -const MAX_INPUTS_PER_SWEEP: usize = 500; - -/// Balanced input count per sweep transaction for `total` spendable UTXOs, so -/// that `utxos.chunks(sweep_chunk_size(total))` yields `ceil(total / -/// MAX_INPUTS_PER_SWEEP)` near-equal chunks, each within `MAX_INPUTS_PER_SWEEP`. -/// -/// Using a ceil-divided chunk size keeps chunks near-equal (e.g. 501 → 251 + -/// 250, not 500 + 1), so no chunk is a lone sub-fee dust input. `total` must be -/// greater than zero (the sweep early-returns on an empty UTXO set). -fn sweep_chunk_size(total: usize) -> usize { - debug_assert!(total > 0, "sweep_chunk_size requires at least one UTXO"); - let num_chunks = total.div_ceil(MAX_INPUTS_PER_SWEEP); - total.div_ceil(num_chunks) -} - impl CoreWallet { /// Broadcast a signed transaction to the network. /// @@ -178,38 +155,26 @@ impl CoreWallet { /// is used by the DashSync → SwiftDashSDK migration to move a user's /// mixed coins (no longer supported) into their spendable balance. /// - /// The UTXO set is split into balanced chunks of at most - /// [`MAX_INPUTS_PER_SWEEP`] inputs so no transaction exceeds the standard - /// relay size limit (a heavy mixer can hold thousands of small mixed-coin - /// UTXOs). Each chunk spends a *disjoint* slice of the snapshot, so the - /// transactions have no inter-dependency and may broadcast in any order. + /// The chunking, dual-chain (`/0/` + `/1/`) signing-path resolution, and + /// all-input/no-change transaction building live upstream in key-wallet + /// ([`ManagedCoreFundsAccount::build_coinjoin_sweep_txs`](key_wallet::managed_account::ManagedCoreFundsAccount::build_coinjoin_sweep_txs)). + /// This wrapper only resolves the account under the wallet lock, delegates + /// the build+sign, then broadcasts. /// - /// Within each chunk all inputs are added explicitly and the transaction - /// is assembled and signed directly — it deliberately does NOT route - /// through `TransactionBuilder::build_signed`, whose `LargestFirst` coin - /// selection re-selects a *covering subset* and stops early, which can drop - /// small UTXOs and leave the account non-empty. Each chunk's single output - /// is `chunk_total - chunk_fee` (no change), so every UTXO is consumed. - /// - /// Returns the broadcast transactions in chunk order. Broadcast tolerates - /// partial failure: the successfully broadcast transactions are returned - /// (the caller refreshes balance and may re-run to sweep any remainder, - /// since a re-run sees only the still-unspent UTXOs). An error is returned - /// only if *no* transaction broadcast at all. + /// Broadcast tolerates partial failure: the successfully broadcast + /// transactions are returned (the caller refreshes balance and may re-run + /// to sweep any remainder, since a re-run sees only the still-unspent + /// UTXOs). An error is returned only if *no* transaction broadcast at all. pub async fn sweep_coinjoin_to_address( &self, account_index: u32, dest: DashAddress, signer: &S, ) -> Result, PlatformWalletError> { - use dashcore::blockdata::witness::Witness; - use dashcore::{ScriptBuf, TxIn, TxOut}; - use key_wallet::wallet::managed_wallet_info::fee::FeeRate; - use key_wallet::wallet::managed_wallet_info::transaction_builder::TransactionSigner; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; // Build + sign every chunk under the wallet write lock (signing borrows - // `managed_account` for address derivation), then broadcast after the + // the managed account for address derivation), then broadcast after the // lock is released. let signed_txs: Vec = { let mut wm = self.wallet_manager.write().await; @@ -219,41 +184,23 @@ impl CoreWallet { ) })?; - // CoinJoin account key material for deriving signing paths across - // BOTH chains. DashSync CoinJoin puts mixing *change* on the internal - // chain (.../1/i), but the SDK's CoinJoin account derives only the - // external pool (.../0/i) — so internal-chain inputs are owned and - // spendable yet have no derivation path. `coinjoin_sweep_path_map` - // (below) re-derives both chains from the account xpub. Copied/cloned - // out so the `wallet` borrow ends before the `info` borrow - // (`account_xpub` is `Copy`). - let (account_xpub, account_type, network) = { - let acct = wallet - .accounts - .coinjoin_accounts - .get(&account_index) - .ok_or_else(|| { - PlatformWalletError::WalletNotFound(format!( - "CoinJoin account {} not found", - account_index - )) - })?; - (acct.account_xpub, acct.account_type, acct.network) - }; - - // Fund-safety invariant owned by the sweep itself, not just the FFI - // boundary: refuse to drain every CoinJoin UTXO to a destination that - // isn't valid on this wallet's network. The sweep is irreversible, so - // any non-FFI Rust caller (tests, future wrappers) is covered here by - // construction. - if !dest.as_unchecked().is_valid_for_network(network) { - return Err(PlatformWalletError::AddressOperation(format!( - "CoinJoin sweep destination is not valid for the wallet network {network:?}" - ))); - } + // The CoinJoin account's watch-only public xpub. The managed account + // doesn't store it, so it's read from the wallet side and passed to + // the upstream builder to re-derive signing paths across both chains + // (no private key crosses any boundary). `Copy`, so the immutable + // `wallet` borrow ends here, before the `info` borrow below. + let account_xpub = wallet + .accounts + .coinjoin_accounts + .get(&account_index) + .ok_or_else(|| { + PlatformWalletError::WalletNotFound(format!( + "CoinJoin account {account_index} not found" + )) + })? + .account_xpub; let current_height = info.core_wallet.synced_height(); - let managed_account = info .core_wallet .accounts @@ -261,141 +208,14 @@ impl CoreWallet { .get(&account_index) .ok_or_else(|| { PlatformWalletError::TransactionBuild(format!( - "CoinJoin managed account {} not found", - account_index + "CoinJoin managed account {account_index} not found" )) })?; - // Snapshot every spendable UTXO — the sweep consumes all of them. - let utxos: Vec<_> = managed_account - .spendable_utxos(current_height) - .into_iter() - .cloned() - .collect(); - - if utxos.is_empty() { - return Err(PlatformWalletError::TransactionBuild( - "no spendable CoinJoin UTXOs to sweep".to_string(), - )); - } - - // Resolve an absolute derivation path for every input address across - // BOTH CoinJoin chains (external /0/ and internal /1/), so the signer - // can sign internal-chain ("change") mixed coins too. Errors if any - // input can't be resolved on either chain rather than failing mid-sign. - let account_path = account_type - .derivation_path(network) - .map_err(|e| PlatformWalletError::AddressOperation(e.to_string()))?; - let key_source = KeySource::Public(account_xpub); - let input_addresses: Vec = - utxos.iter().map(|u| u.address.clone()).collect(); - let path_map = coinjoin_sweep_path_map( - &account_path, - &key_source, - network, - &input_addresses, - COINJOIN_SWEEP_MAX_INDEX, - )?; - - let fee_rate = FeeRate::normal(); - // Upper bounds for the serialized size of a (N-input, 1-output, - // no-change) tx. `FeeRate::normal()` is exactly 1 duff/byte — the - // relay minimum, with no headroom — so this size estimate must never - // undershoot the real transaction or the chunk pays below the minimum - // fee and gets rejected. The maximum compressed-P2PKH input is 149 B - // (36 outpoint + 1 script-len + 108 scriptSig at a 73-byte low-S DER - // signature + 4 sequence); the input-count CompactSize is sized per - // chunk below (it grows from 1 to 3 bytes at 253 inputs). Slightly - // over-paying on an all-funds sweep is harmless — the single output - // just absorbs the difference. - const VERSION_PLUS_LOCKTIME: usize = 8; - const OUTPUT_COUNT_VARINT: usize = 1; // exactly one output - const ONE_P2PKH_OUTPUT: usize = 34; - const MAX_P2PKH_INPUT_SIZE: usize = 149; - - // Balanced chunks of <= MAX_INPUTS_PER_SWEEP so no transaction - // exceeds the relay size limit. `chunks()` over disjoint slices - // guarantees each UTXO is consumed by exactly one transaction. - let chunk_size = sweep_chunk_size(utxos.len()); - let mut signed_txs = Vec::with_capacity(utxos.len().div_ceil(chunk_size)); - - for chunk in utxos.chunks(chunk_size) { - let chunk_utxos: Vec<_> = chunk.to_vec(); - let input_count = chunk_utxos.len(); - let total_input: u64 = chunk_utxos.iter().map(|u| u.value()).sum(); - - // Upper-bound fee for (input_count inputs, 1 output, no change) - // at the 1 duff/byte relay minimum, so `total_input - fee` is a - // single zero-change output that always clears relay. The - // input-count CompactSize is 1 byte below 253 inputs and 3 bytes - // for 253..=MAX_INPUTS_PER_SWEEP (500). - let input_count_varint = if input_count < 253 { 1 } else { 3 }; - let tx_size = VERSION_PLUS_LOCKTIME - + input_count_varint - + OUTPUT_COUNT_VARINT - + ONE_P2PKH_OUTPUT - + input_count * MAX_P2PKH_INPUT_SIZE; - let fee = fee_rate.calculate_fee(tx_size); - - if total_input <= fee { - return Err(PlatformWalletError::TransactionBuild(format!( - "CoinJoin sweep chunk balance {} is below the chunk fee {}", - total_input, fee - ))); - } - let output_amount = total_input - fee; - - // Assemble the chunk's tx with ITS inputs explicitly and sign - // it directly. Do NOT use `TransactionBuilder::build_signed` — - // its `LargestFirst` coin selection re-selects a covering subset - // and stops once `output_amount + fee` is met, which can drop - // small UTXOs and leave the CoinJoin account non-empty. A sweep - // must consume everything, so each chunk is an all-input, - // single-output tx built by hand. - let tx_inputs: Vec = chunk_utxos - .iter() - .map(|u| TxIn { - previous_output: u.outpoint, - script_sig: ScriptBuf::new(), - sequence: 0xffff_ffff, // Dash has no RBF - witness: Witness::new(), - }) - .collect(); - let unsigned = Transaction { - version: 3, - lock_time: 0, - input: tx_inputs, - output: vec![TxOut { - value: output_amount, - script_pubkey: dest.script_pubkey(), - }], - special_transaction_payload: None, - }; - - // `sign_tx` signs `tx.input[i]` using `chunk_utxos[i]`, so input - // order and the utxo vec must line up — both derive from `chunk`. - let signed = signer - .sign_tx(unsigned, chunk_utxos, |addr| path_map.get(&addr).cloned()) - .await - .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))?; - - // Fund-safety invariant: the single output is `total_input - - // fee`, which is only correct if the signer consumed every UTXO - // in the chunk. Enforce at runtime (not debug_assert, which is - // compiled out in release) so a signer that ever drops an input - // aborts the chunk instead of broadcasting an under-consuming tx. - if signed.input.len() != input_count { - return Err(PlatformWalletError::TransactionBuild(format!( - "CoinJoin sweep chunk must consume every UTXO in the chunk: \ - signed {} inputs, expected {}", - signed.input.len(), - input_count - ))); - } - signed_txs.push(signed); - } - - signed_txs + managed_account + .build_coinjoin_sweep_txs(account_xpub, current_height, dest, signer) + .await + .map_err(|e| PlatformWalletError::TransactionBuild(e.to_string()))? }; // Broadcast each chunk (disjoint inputs, no inter-tx dependency, so @@ -434,221 +254,3 @@ impl CoreWallet { Ok(broadcast) } } - -/// Build an `address → absolute derivation path` map covering every address in -/// `needed_addrs` across BOTH CoinJoin chains — external (`/0/`) and internal -/// (`/1/`) — derived from the account's public xpub. `account_path` is the -/// hardened account path `m/9'/coin'/4'/account'`. -/// -/// The SDK's CoinJoin account models only the external chain, but DashSync -/// CoinJoin puts mixing *change* on the internal chain. A migrated wallet's -/// internal-chain mixed coins are imported as spendable UTXOs yet have no -/// derivation path in the account, so signing a sweep that includes them fails -/// with "no derivation path for input address". This re-derives both chains -/// (non-hardened, public-key-only) and returns each input's absolute path so the -/// signer can sign every input regardless of chain. -/// -/// Returns an error if any address can't be resolved within -/// `COINJOIN_SWEEP_MAX_INDEX` on either chain — defensive, so a sweep never -/// silently mis-signs or drops an input. -/// Per-chain index ceiling for the sweep resolver. A heavy mixer's CoinJoin -/// indices sit well under this; the cap only bounds the (never-hit-in-practice) -/// unresolved case so the search terminates. -const COINJOIN_SWEEP_MAX_INDEX: u32 = 20_000; - -fn coinjoin_sweep_path_map( - account_path: &DerivationPath, - key_source: &KeySource, - network: Network, - needed_addrs: &[DashAddress], - max_index: u32, -) -> Result, PlatformWalletError> { - use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType}; - use key_wallet::ChildNumber; - use std::collections::{HashMap, HashSet}; - - const BATCH: u32 = 500; - - let mut needed: HashSet = needed_addrs.iter().cloned().collect(); - let mut path_map: HashMap = HashMap::new(); - - // chain 0 = external (receive / denominations), chain 1 = internal (change). - for (chain, pool_type) in [ - (0u32, AddressPoolType::External), - (1u32, AddressPoolType::Internal), - ] { - if needed.is_empty() { - break; - } - let mut base = account_path.clone(); - base.push( - ChildNumber::from_normal_idx(chain) - .map_err(|e| PlatformWalletError::AddressOperation(e.to_string()))?, - ); - // Empty pool (NoKeySource skips generation); we generate below with the - // real public key source so each `AddressInfo` carries its full path. - let mut pool = AddressPool::new(base, pool_type, 0, network, &KeySource::NoKeySource) - .map_err(|e| PlatformWalletError::AddressOperation(e.to_string()))?; - - let mut generated = 0u32; - while generated < max_index && !needed.is_empty() { - let batch = pool - .generate_addresses(BATCH, key_source, true) - .map_err(|e| PlatformWalletError::AddressOperation(e.to_string()))?; - for addr in &batch { - // Only drop from `needed` once the path is actually recorded, so - // "removed from needed ⇒ inserted into path_map" holds by - // construction. If `address_info` ever returned None for a - // freshly generated address (an AddressPool invariant break), the - // address stays in `needed` and the check below errors loudly - // rather than silently dropping it into a mid-sign failure. - if needed.contains(addr) { - if let Some(info) = pool.address_info(addr) { - path_map.insert(addr.clone(), info.path.clone()); - needed.remove(addr); - } - } - } - generated += BATCH; - } - } - - if !needed.is_empty() { - return Err(PlatformWalletError::TransactionBuild(format!( - "CoinJoin sweep: {} input address(es) have no derivation path on either \ - CoinJoin chain (within {} indices)", - needed.len(), - max_index - ))); - } - - Ok(path_map) -} - -#[cfg(test)] -mod sweep_chunking_tests { - use super::{sweep_chunk_size, MAX_INPUTS_PER_SWEEP}; - - /// The chunk plan must, for any UTXO count: produce `ceil(total / MAX)` - /// transactions, keep every chunk within `MAX` inputs, and consume every - /// UTXO exactly once (disjoint slices that sum back to `total`). - #[test] - fn partitions_every_utxo_within_the_relay_cap() { - for &total in &[ - 1usize, 30, 499, 500, 501, 675, 999, 1000, 1001, 1499, 5000, 12_345, - ] { - let chunk_size = sweep_chunk_size(total); - let sizes: Vec = (0..total) - .collect::>() - .chunks(chunk_size) - .map(|c| c.len()) - .collect(); - - let expected_chunks = total.div_ceil(MAX_INPUTS_PER_SWEEP); - assert_eq!(sizes.len(), expected_chunks, "tx count for {total} UTXOs"); - assert_eq!( - sizes.iter().sum::(), - total, - "every UTXO consumed exactly once for {total}" - ); - assert!( - sizes - .iter() - .all(|&n| (1..=MAX_INPUTS_PER_SWEEP).contains(&n)), - "every chunk within [1, {MAX_INPUTS_PER_SWEEP}] for {total}: {sizes:?}" - ); - } - } -} - -#[cfg(test)] -mod coinjoin_sweep_path_map_tests { - use super::coinjoin_sweep_path_map; - use dashcore::secp256k1::Secp256k1; - use key_wallet::account::account_type::AccountType; - use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; - use key_wallet::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey, Network}; - - fn coinjoin_account(network: Network, seed_byte: u8) -> (ExtendedPubKey, DerivationPath) { - let secp = Secp256k1::new(); - let master = ExtendedPrivKey::new_master(network, &[seed_byte; 32]).unwrap(); - let account_path = AccountType::CoinJoin { index: 0 } - .derivation_path(network) - .unwrap(); - let account_xpriv = master.derive_priv(&secp, &account_path).unwrap(); - ( - ExtendedPubKey::from_priv(&secp, &account_xpriv), - account_path, - ) - } - - /// Derive `//` the way the resolver does, to get - /// a known target address to look up. - fn derive_addr( - xpub: &ExtendedPubKey, - account_path: &DerivationPath, - network: Network, - chain: u32, - index: u32, - ) -> dashcore::Address { - let pool_type = if chain == 0 { - AddressPoolType::External - } else { - AddressPoolType::Internal - }; - let mut base = account_path.clone(); - base.push(ChildNumber::from_normal_idx(chain).unwrap()); - let mut pool = - AddressPool::new(base, pool_type, 0, network, &KeySource::NoKeySource).unwrap(); - let addrs = pool - .generate_addresses(index + 1, &KeySource::Public(*xpub), true) - .unwrap(); - addrs[index as usize].clone() - } - - fn abs_path(account_path: &DerivationPath, chain: u32, index: u32) -> DerivationPath { - let mut p = account_path.clone(); - p.push(ChildNumber::from_normal_idx(chain).unwrap()); - p.push(ChildNumber::from_normal_idx(index).unwrap()); - p - } - - /// Both an external (/0/) and an internal (/1/) CoinJoin address resolve to - /// their correct absolute paths — the internal case is the bug this fixes. - #[test] - fn resolves_external_and_internal_chain_addresses() { - let network = Network::Testnet; - let (xpub, account_path) = coinjoin_account(network, 7); - let key_source = KeySource::Public(xpub); - - let external = derive_addr(&xpub, &account_path, network, 0, 7); - let internal = derive_addr(&xpub, &account_path, network, 1, 42); - - let map = coinjoin_sweep_path_map( - &account_path, - &key_source, - network, - &[external.clone(), internal.clone()], - 200, - ) - .unwrap(); - - assert_eq!(map.get(&external), Some(&abs_path(&account_path, 0, 7))); - assert_eq!(map.get(&internal), Some(&abs_path(&account_path, 1, 42))); - } - - /// An address not derivable from this account xpub on either chain yields the - /// defensive error (here: a different wallet's CoinJoin address). - #[test] - fn errors_when_an_input_address_is_unresolvable() { - let network = Network::Testnet; - let (xpub, account_path) = coinjoin_account(network, 7); - let key_source = KeySource::Public(xpub); - - let (other_xpub, other_path) = coinjoin_account(network, 9); - let foreign = derive_addr(&other_xpub, &other_path, network, 0, 3); - - let result = coinjoin_sweep_path_map(&account_path, &key_source, network, &[foreign], 200); - assert!(result.is_err(), "foreign address must not resolve"); - } -} diff --git a/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs b/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs index 5d85c8de7b5..70140ec5bd8 100644 --- a/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs @@ -8,6 +8,11 @@ //! gap — matching DashSync's `SEQUENCE_GAP_LIMIT_INITIAL_COINJOIN` of 400 — //! before starting SPV, runs the recovery scan, then reverts to the default. //! +//! The actual gap-limit widening + address materialization lives upstream in +//! key-wallet +//! ([`ManagedCoreFundsAccount::set_coinjoin_gap_limit`](key_wallet::managed_account::ManagedCoreFundsAccount::set_coinjoin_gap_limit)); +//! this wrapper only resolves the account under the wallet lock and delegates. +//! //! ## Scope: external CoinJoin chain only //! //! This widens only the CoinJoin account's external pool (`.../0/i`). DashSync @@ -15,41 +20,25 @@ //! SDK's CoinJoin account models only the external chain, and a migrated wallet's //! internal-chain mixed coins arrive as **imported spendable UTXOs** — not via an //! SPV address scan — so no gap widening is needed to discover them. The sweep -//! still signs them regardless of chain: `coinjoin_sweep_path_map` (in -//! `core::broadcast`) re-derives both `/0/` and `/1/` from the account xpub. If a -//! future migration path instead relied on SPV to *re-discover* internal-chain -//! coins, this recovery would also need to materialize the internal pool. - -use key_wallet::gap_limit::MAX_GAP_LIMIT; -use key_wallet::managed_account::address_pool::KeySource; -use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +//! still signs them regardless of chain: key-wallet's `coinjoin_sweep` resolver +//! re-derives both `/0/` and `/1/` from the account xpub. If a future migration +//! path instead relied on SPV to *re-discover* internal-chain coins, this +//! recovery would also need to materialize the internal pool. use crate::broadcaster::TransactionBroadcaster; use crate::{CoreWallet, PlatformWalletError}; impl CoreWallet { /// Widen the CoinJoin account's single-pool gap limit to `gap_limit` and - /// generate addresses so indices up to `highest_used + gap_limit` exist - /// and are watched by SPV. Returns the pool's highest generated index. - /// - /// Setting the gap limit alone is **not** enough: SPV only watches - /// addresses materialized into the pool's script index, so a scan starting - /// at the default gap (30) never sees — and so never extends toward — a - /// used CoinJoin address sitting beyond index 30. Pre-generating the wide - /// window (via [`AddressPool::maintain_gap_limit`]) is what lets the - /// recovery scan find scattered mixed coins; the in-progress scan then - /// auto-extends from there as deep addresses are marked used (see - /// `wallet_checker`'s `maintain_gap_limit` call). - /// - /// Idempotent: generation only fills missing indices, so re-running with - /// the same (or a smaller) limit is a cheap no-op. The widened `gap_limit` - /// is in-memory only — it is not persisted, so a later wallet load - /// reconstructs the pool at the default gap. That is the intended - /// "revert after recovery" behavior: the app re-applies the widen on each - /// flagged launch and stops once the coins are swept. + /// generate addresses so SPV watches the wider window. Returns the pool's + /// highest generated index. /// - /// Derivation uses the account's **public** xpub only — no private key - /// crosses any boundary (CoinJoin receive addresses are non-hardened). + /// Resolves the account's watch-only public xpub and managed account under + /// the wallet write lock, then delegates the widening + address + /// materialization + monitor-revision bump to + /// [`ManagedCoreFundsAccount::set_coinjoin_gap_limit`](key_wallet::managed_account::ManagedCoreFundsAccount::set_coinjoin_gap_limit), + /// which rejects a zero gap limit and clamps to `MAX_GAP_LIMIT`. Derivation + /// uses the account's public xpub only — no private key crosses any boundary. pub async fn set_coinjoin_gap_limit( &self, account_index: u32, @@ -70,11 +59,9 @@ impl CoreWallet { .map(|a| a.account_xpub) .ok_or_else(|| { PlatformWalletError::WalletNotFound(format!( - "CoinJoin account {} not found", - account_index + "CoinJoin account {account_index} not found" )) })?; - let key_source = KeySource::Public(account_xpub); let managed_account = info .core_wallet @@ -83,46 +70,12 @@ impl CoreWallet { .get_mut(&account_index) .ok_or_else(|| { PlatformWalletError::WalletNotFound(format!( - "CoinJoin managed account {} not found", - account_index + "CoinJoin managed account {account_index} not found" )) })?; - // CoinJoin uses a single address pool. Widen the gap limit, then - // materialize addresses up to it. Scope the pool borrow so it ends - // before `bump_monitor_revision` reborrows the managed account. - let new_highest = { - let pool = managed_account - .managed_account_type_mut() - .address_pools_mut() - .into_iter() - .next() - .ok_or_else(|| { - PlatformWalletError::AddressOperation( - "CoinJoin account has no address pool".to_string(), - ) - })?; - - // Reject a zero gap limit before touching the pool. key-wallet's - // `maintain_gap_limit` computes `gap_limit - 1` when no address has - // been used yet, which underflows at 0 (debug panic / release wrap - // to u32::MAX → ~4B address generations under the write lock). - let gap_limit = gap_limit.min(MAX_GAP_LIMIT); - if gap_limit == 0 { - return Err(PlatformWalletError::AddressOperation( - "CoinJoin gap limit must be greater than zero".to_string(), - )); - } - pool.gap_limit = gap_limit; - pool.maintain_gap_limit(&key_source) - .map_err(|e| PlatformWalletError::AddressOperation(e.to_string()))?; - pool.highest_generated.unwrap_or(0) - }; - - // The watched-address set changed — bump the revision so the SPV - // compact-filter / bloom filter is rebuilt to include the new scripts. - managed_account.bump_monitor_revision(); - - Ok(new_highest) + managed_account + .set_coinjoin_gap_limit(account_xpub, gap_limit) + .map_err(|e| PlatformWalletError::AddressOperation(e.to_string())) } }