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-ffi/src/core_wallet/broadcast.rs b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs index 24d54bb0776..f95f8e419a5 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs @@ -127,7 +127,131 @@ pub unsafe extern "C" fn core_wallet_send_to_addresses( PlatformWalletFFIResult::ok() } -/// Free transaction bytes returned by `core_wallet_send_to_addresses`. +/// Sweep the entire spendable balance of the wallet's CoinJoin account +/// (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 +/// 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`]. +/// +/// 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. +/// - `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_txids: *mut *mut u8, + out_count: *mut usize, +) -> PlatformWalletFFIResult { + check_ptr!(dest_address); + check_ptr!(core_signer_handle); + check_ptr!(out_txids); + check_ptr!(out_count); + + let dest_str = unwrap_result_or_return!(std::ffi::CStr::from_ptr(dest_address).to_str()); + // 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 + // 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 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() +} + +/// 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 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 { diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index 4609d1fb6d2..0b68ae0ad46 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -145,4 +145,112 @@ impl CoreWallet { self.broadcast_transaction(&tx).await?; Ok(tx) } + + /// 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) + /// 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. + /// + /// 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. + /// + /// 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 key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + // Build + sign every chunk under the wallet write lock (signing borrows + // 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; + 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(), + ) + })?; + + // 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 + .coinjoin_accounts + .get(&account_index) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild(format!( + "CoinJoin managed account {account_index} not found" + )) + })?; + + 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 + // 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) => { + // 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 + ); + // 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); + } + } + } + + if broadcast.is_empty() { + return Err(last_err.unwrap_or_else(|| { + PlatformWalletError::TransactionBuild( + "CoinJoin sweep produced no broadcastable transactions".to_string(), + ) + })); + } + + Ok(broadcast) + } } 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..70140ec5bd8 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/core/coinjoin_recovery.rs @@ -0,0 +1,81 @@ +//! 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. +//! +//! 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 +//! 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: 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 SPV watches the wider window. Returns the pool's + /// highest generated index. + /// + /// 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, + 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 {account_index} not found" + )) + })?; + + let managed_account = info + .core_wallet + .accounts + .coinjoin_accounts + .get_mut(&account_index) + .ok_or_else(|| { + PlatformWalletError::WalletNotFound(format!( + "CoinJoin managed account {account_index} not found" + )) + })?; + + managed_account + .set_coinjoin_gap_limit(account_xpub, gap_limit) + .map_err(|e| PlatformWalletError::AddressOperation(e.to_string())) + } +} 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..ed642a46a38 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWallet/ManagedCoreWallet.swift @@ -162,6 +162,85 @@ public class ManagedCoreWallet { return Data(bytes: ptr, count: Int(txLen)) } + /// 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') + /// 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. + /// + /// 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 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 + // `sendToAddresses`). + let resolver = MnemonicResolver() + + try destination.withCString { destPtr in + try withExtendedLifetime(resolver) { + try core_wallet_sweep_coinjoin( + handle, + accountIndex, + destPtr, + resolver.handle, + &txidsPtr, + &count + ).check() + } + } + + guard let ptr = txidsPtr, count > 0 else { + throw PlatformWalletError.unknown("FFI returned success but txid buffer was empty") + } + // 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)) } + + // Split into individual 32-byte wire-order txids (chunk order). + let allBytes = Data(bytes: ptr, count: byteCount) + return (0.. 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.