Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 8 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
126 changes: 125 additions & 1 deletion packages/rs-platform-wallet-ffi/src/core_wallet/broadcast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
});
Comment thread
llbartekll marked this conversation as resolved.
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<u8> = 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 {
Expand Down
108 changes: 108 additions & 0 deletions packages/rs-platform-wallet/src/wallet/core/broadcast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,112 @@ impl<B: TransactionBroadcaster + ?Sized> CoreWallet<B> {
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<S: Signer>(
&self,
account_index: u32,
dest: DashAddress,
signer: &S,
) -> Result<Vec<Transaction>, 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<Transaction> = {
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<Transaction> = Vec::with_capacity(signed_txs.len());
let mut last_err: Option<PlatformWalletError> = 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);
}
}
}
Comment on lines +227 to +244

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: last_err actually holds the first error — rename to match the comment

The variable is named last_err but is populated via last_err.get_or_insert(e) (line 241), which only writes when the slot is None. The inline comment at lines 239–240 correctly states 'Keep the FIRST failure (usually the root cause)'. The implementation matches the comment, but the name contradicts it. The common Rust pattern with last_err is last_err = Some(e) overwriting on each iteration; a reader scanning the variable name will draw the opposite conclusion to what the code does. Rename to first_err so the name agrees with get_or_insert and the inline comment.

source: ['claude']


if broadcast.is_empty() {
return Err(last_err.unwrap_or_else(|| {
PlatformWalletError::TransactionBuild(
"CoinJoin sweep produced no broadcastable transactions".to_string(),
)
}));
}
Comment thread
llbartekll marked this conversation as resolved.
Comment thread
llbartekll marked this conversation as resolved.

Ok(broadcast)
}
Comment thread
llbartekll marked this conversation as resolved.
}
Loading
Loading