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
107 changes: 107 additions & 0 deletions dash-spv/tests/dashd_sync/tests_transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@ use super::setup::{create_and_start_client, TestContext};
use dash_spv::test_utils::{create_test_wallet, TestChain};
use dashcore::address::NetworkUnchecked;
use key_wallet::account::ManagedAccountTrait;
use key_wallet::wallet::initialization::WalletAccountCreationOptions;
use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy;
use key_wallet::wallet::managed_wallet_info::fee::FeeRate;
use key_wallet::wallet::managed_wallet_info::transaction_builder::{
BuilderError, TransactionBuilder,
};
use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference;
use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface;
use key_wallet::wallet::ManagedWalletInfo;
use key_wallet::ManagedAccountType;
use key_wallet_manager::{WalletId, WalletManager};
use std::collections::BTreeSet;

/// Verify incremental sync works by generating blocks after initial sync.
///
Expand Down Expand Up @@ -385,3 +390,105 @@ async fn test_spend_incoming_balance() {

client_handle.stop().await;
}

/// Drain every UTXO of one account into another account of the same wallet.
///
/// Funds a BIP32 account with 3 UTXOs, then drains it into a BIP44 receive address with
/// `SelectionStrategy::All` (spend every UTXO; single output = total - fee, no change)
#[tokio::test]
async fn test_drain_account_into_another() {
let Some(ctx) = TestContext::new(TestChain::Minimal).await else {
return;
};
if !ctx.dashd.supports_mining {
eprintln!("Skipping test (dashd RPC miner not available)");
return;
}

const FUNDING: u64 = 50_000_000;
const FEE: u64 = 488;

// Fresh wallet with a BIP32 account 0 (drain source) and a BIP44 account 0 (destination).
// The default test options only create the BIP44 account.
let (wallet, wallet_id) = {
let mut manager = WalletManager::<ManagedWalletInfo>::new(Network::Regtest);
let id = manager
.create_wallet_from_mnemonic(
EMPTY_MNEMONIC,
0,
WalletAccountCreationOptions::SpecificAccounts(
BTreeSet::from([0]), // BIP44 account 0
BTreeSet::from([0]), // BIP32 account 0
BTreeSet::new(),
BTreeSet::new(),
BTreeSet::new(),
None,
),
)
.expect("create wallet with bip32 + bip44 accounts");
(Arc::new(RwLock::new(manager)), id)
};

let mut client_handle = create_and_start_client(&ctx.client_config, Arc::clone(&wallet)).await;
wait_for_sync(&mut client_handle.progress_receiver, ctx.dashd.initial_height).await;

let (fund_addresses, dest) = {
let mut lock = wallet.write().await;
let (w, info) = lock.get_wallet_and_info_mut(&wallet_id).expect("wallet");
let fund: Vec<Address> = (0..3)
.map(|_| info.next_receive_address(w, 0, AccountTypePreference::BIP32, true).unwrap())
.collect();
let dest = info.next_receive_address(w, 0, AccountTypePreference::BIP44, true).unwrap();
(fund, dest)
};

let miner = ctx.dashd.node.get_new_address_from_wallet("default");
for address in &fund_addresses {
ctx.dashd.node.send_to_address(address, Amount::from_sat(FUNDING));
}
ctx.dashd.node.generate_blocks(1, &miner);

let funded_height = ctx.dashd.initial_height + 1;
wait_for_sync(&mut client_handle.progress_receiver, funded_height).await;
wait_for_wallet_synced(&wallet, &wallet_id, funded_height).await;

// Drain the BIP32 account into the BIP44 destination (amount ignored under `All`).
let (tx, _) = {
let mut lock = wallet.write().await;

let (w, info) = lock.get_wallet_and_info_mut(&wallet_id).expect("wallet");
info.build_and_sign_transaction(
w,
AccountTypePreference::BIP32,
0,
vec![(dest.as_unchecked().clone(), 0)],
FeeRate::normal(),
SelectionStrategy::All,
)
.await
.expect("drain")
};

client_handle.client.broadcast_transaction(&tx).await.expect("broadcast");
wait_for_mempool_tx(&mut client_handle.wallet_event_receiver, MEMPOOL_TIMEOUT)
.await
.expect("mempool");
ctx.dashd.node.generate_blocks(1, &miner);

let drained_height = funded_height + 1;
wait_for_sync(&mut client_handle.progress_receiver, drained_height).await;
wait_for_wallet_synced(&wallet, &wallet_id, drained_height).await;

// Final state: BIP32 emptied, BIP44 holds the single drained UTXO.
let reader = wallet.read().await;
let info = reader.get_wallet_info(&wallet_id).expect("wallet");
let balance = |pref| info.account_balance(pref, 0).unwrap().total();
let utxos = |pref| info.funds_account(pref, 0).unwrap().utxos.len();

assert_eq!(balance(AccountTypePreference::BIP32), 0);
assert_eq!(utxos(AccountTypePreference::BIP32), 0);
assert_eq!(balance(AccountTypePreference::BIP44), FUNDING * 3 - FEE);
assert_eq!(utxos(AccountTypePreference::BIP44), 1);

client_handle.stop().await;
}
6 changes: 5 additions & 1 deletion key-wallet-ffi/src/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ use dashcore::{
use key_wallet::wallet::managed_wallet_info::asset_lock_builder::{
AssetLockFundingType, CreditOutputFunding,
};
use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy::BranchAndBound;
use key_wallet::wallet::managed_wallet_info::fee::FeeRate;
use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference;
use secp256k1::{Message, Secp256k1, SecretKey};
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
Expand Down Expand Up @@ -135,9 +137,11 @@ pub unsafe extern "C" fn wallet_build_and_sign_transaction(
manager
.build_and_sign_transaction(
&wallet_id,
AccountTypePreference::BIP44,
account_index,
outputs,
FeeRate::new(fee_per_kb)
FeeRate::new(fee_per_kb),
BranchAndBound
)
.await,
error
Expand Down
7 changes: 5 additions & 2 deletions key-wallet-manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ use dashcore::prelude::CoreBlockHeight;
use key_wallet::account::AccountCollection;
use key_wallet::managed_account::transaction_record::TransactionRecord;
use key_wallet::transaction_checking::{DerivedAddressInfo, TransactionContext};
use key_wallet::wallet::managed_wallet_info::coin_selection::SelectionStrategy;
use key_wallet::wallet::managed_wallet_info::transaction_building::AccountTypePreference;
use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface;
use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
Expand Down Expand Up @@ -578,17 +579,19 @@ impl WalletManager<ManagedWalletInfo> {
pub async fn build_and_sign_transaction(
&mut self,
wallet_id: &WalletId,
account_index: u32,
source: AccountTypePreference,
source_index: u32,
outputs: Vec<(Address<NetworkUnchecked>, u64)>,
fee_rate: FeeRate,
strategy: SelectionStrategy,
) -> Result<(Transaction, u64), WalletError> {
// Get the managed account for UTXOs and signing data
let (wallet, managed_wallet) = self
.get_wallet_and_info_mut(wallet_id)
.ok_or(WalletError::WalletNotFound(*wallet_id))?;

managed_wallet
.build_and_sign_transaction(wallet, account_index, outputs, fee_rate)
.build_and_sign_transaction(wallet, source, source_index, outputs, fee_rate, strategy)
.await
.map_err(|e| WalletError::TransactionBuild(e.to_string()))
}
Expand Down
61 changes: 61 additions & 0 deletions key-wallet/src/wallet/managed_wallet_info/coin_selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ pub enum SelectionStrategy {
OptimalConsolidation,
/// Random selection for privacy
Random,
/// Select EVERY spendable UTXO (drain / sweep an account empty). There is no change: the
/// single deliverable output is worth the total minus the fee to spend them all
All,
}

/// Result of UTXO selection
Expand Down Expand Up @@ -249,6 +252,38 @@ impl CoinSelector {
input_size,
)
}
SelectionStrategy::All => {
let selected: Vec<Utxo> =
utxos.into_iter().filter(|u| u.is_spendable(current_height)).cloned().collect();

if selected.is_empty() {
return Err(SelectionError::NoUtxosAvailable);
}

let total_value: u64 = selected.iter().map(|u| u.value()).sum();
let estimated_size = base_size + selected.len() * input_size;
let estimated_fee = fee_rate.calculate_fee(estimated_size);

// The caller's `target_amount` is ignored: a drain spends everything, there is no
// target to satisfy
let deliverable = total_value
.checked_sub(estimated_fee)
.filter(|d| *d > self.dust_threshold)
.ok_or(SelectionError::InsufficientFunds {
available: total_value,
required: estimated_fee + self.dust_threshold + 1,
})?;

Ok(SelectionResult {
selected,
total_value,
target_amount: deliverable,
change_amount: 0,
estimated_size,
estimated_fee,
exact_match: true,
})
}
}
}

Expand Down Expand Up @@ -660,6 +695,32 @@ impl std::error::Error for SelectionError {}
mod tests {
use super::*;

#[test]
fn test_select_all_drains_everything() {
let utxos = vec![
Utxo::dummy(0, 10000, 100, false, true),
Utxo::dummy(0, 20000, 100, false, true),
Utxo::dummy(0, 30000, 100, false, true),
];
let selector = CoinSelector::new(SelectionStrategy::All);
// Pass a non-zero target to prove it is IGNORED: a drain takes everything regardless.
let result = selector.select_coins(&utxos, 12_345, FeeRate::new(1000), 200).unwrap();

assert_eq!(result.selected.len(), 3, "All selects every spendable UTXO");
assert_eq!(result.total_value, 60000);
assert!(result.estimated_fee > 0);
assert_eq!(result.target_amount, 60000 - result.estimated_fee, "deliverable = total - fee");
assert_eq!(result.change_amount, 0, "a drain leaves no change");
assert!(result.exact_match, "no change output for a drain");
}

#[test]
fn test_select_all_empty_is_error() {
let selector = CoinSelector::new(SelectionStrategy::All);
let result = selector.select_coins(&[], 0, FeeRate::new(1000), 200);
assert!(matches!(result, Err(SelectionError::NoUtxosAvailable)));
}

#[test]
fn test_smallest_first_selection() {
let utxos = vec![
Expand Down
6 changes: 6 additions & 0 deletions key-wallet/src/wallet/managed_wallet_info/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ impl ManagedWalletInfo {

address
}
AccountTypePreference::CoinJoin => {
unimplemented!("CoinJoin accounts are spend-only in our current use cases")
}
Comment on lines +177 to +179

@coderabbitai coderabbitai Bot Jun 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Return None instead of panicking for CoinJoin address requests.

CoinJoin is a valid AccountTypePreference, so these public library methods can now crash callers instead of reporting “no address available.”

As per coding guidelines, **/*.rs: "Never panic in library code; only use panic! for unrecoverable errors in Rust."

Proposed fix
             AccountTypePreference::CoinJoin => {
-                unimplemented!("CoinJoin accounts are spend-only in our current use cases")
+                None
             }

Apply the same change in both match arms.

Also applies to: 223-225

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@key-wallet/src/wallet/managed_wallet_info/mod.rs` around lines 177 - 179, The
CoinJoin match arm in `managed_wallet_info::mod.rs` currently panics via
`unimplemented!`, but these public library methods should not crash callers;
update the `AccountTypePreference::CoinJoin` handling in both affected match
arms to return `None` just like the other “no address available” cases. Use the
same fix in the corresponding address lookup methods so `CoinJoin` remains
supported without panicking.

Source: Coding guidelines

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@xdustinface What would you do here?? Returning None doesn't explain that this functionality isn't supported and returning an error indicates an error in the library but it is not an error, so I wouldn't like to return one either

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@ZocoLini You’re right — None is a lossy fix here.

My concern is mainly the unimplemented!, not that None is the only acceptable replacement. If I were designing this API, I’d prefer:

Result<Option<Address>, AddressRequestError>

with something explicit like:

enum AddressRequestError {
    UnsupportedAccountType(AccountTypePreference),
}

That way:

  • Ok(Some(addr)) = got an address
  • Ok(None) = no address currently available
  • Err(UnsupportedAccountType(AccountTypePreference::CoinJoin)) = caller asked for an operation this account type does not support

So this would be an unsupported-operation error, not “the library broke”.

If you want to keep this PR scoped and avoid a signature change, then returning None is still safer than panicking, but I agree it’s not as expressive. In that case, I’d at least document that CoinJoin is spend-only and maybe follow up later with a typed/fallible API.

(•ᴗ•)


🧠 Learnings used
Learnt from: ZocoLini
Repo: dashpay/rust-dashcore PR: 782
File: key-wallet/src/wallet/mod.rs:129-131
Timestamp: 2026-05-22T17:37:15.824Z
Learning: In this repository, avoid adding unit tests for trivial methods such as simple getters, setters, or constructors (e.g., methods that only return a field, assign a value, or initialize state with no additional logic). During code review, do not flag or suggest test coverage solely for these trivial implementations; reserve unit tests for behavior with meaningful logic or side effects.

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.

Hm i think just returning some "not supported" error would be fine? Or just return a struct or tuple which contains some success flag or so? I guess it's kind of okay to panic but also kind of not. I dont have a strong opinion here to be honest but at the end the error feels just a less intense.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

};

address
Expand Down Expand Up @@ -217,6 +220,9 @@ impl ManagedWalletInfo {

address
}
AccountTypePreference::CoinJoin => {
unimplemented!("CoinJoin accounts are spend-only in our current use cases")
}
};

address
Expand Down
47 changes: 44 additions & 3 deletions key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ use secp256k1::ecdsa::Signature;
use secp256k1::{Message, PublicKey, Secp256k1};
use std::cmp::Ordering;

/// A transaction with more inputs would exceed the relay standard-size cap (~100 KB at ~148
/// bytes/signed input) and be rejected by the network
const MAX_STANDARD_TX_INPUTS: usize = 500;

/// Calculate varint size for a given number
fn varint_size(n: usize) -> usize {
match n {
Expand Down Expand Up @@ -240,7 +244,7 @@ impl TransactionBuilder {
size
}

fn assemble_unsigned(self) -> Result<(Transaction, Vec<Utxo>), BuilderError> {
fn assemble_unsigned(mut self) -> Result<(Transaction, Vec<Utxo>), BuilderError> {
if let Some(TransactionPayload::AssetLockPayloadType(p)) = &self.special_payload {
if p.credit_outputs.is_empty() {
return Err(BuilderError::NoOutputs);
Expand All @@ -249,6 +253,12 @@ impl TransactionBuilder {
return Err(BuilderError::NoOutputs);
}

// A drain (`All`) never emits change; drop the change address before sizing so the fee
// estimate doesn't include a phantom (~34-byte) change output.
if self.selection_strategy == SelectionStrategy::All {
self.change_addr = None;
}

// For AssetLock the on-chain spend equals the OP_RETURN burn, which
// mirrors the sum of the credit_outputs carried in the payload. For
// every other tx type, it's just the sum of user-provided outputs.
Expand All @@ -271,6 +281,14 @@ impl TransactionBuilder {
.map_err(BuilderError::CoinSelection)?;

let mut selected_inputs = selection.selected;

if selected_inputs.len() > MAX_STANDARD_TX_INPUTS {
return Err(BuilderError::TooManyInputs {
count: selected_inputs.len(),
max: MAX_STANDARD_TX_INPUTS,
});
}

let total_input: u64 = selected_inputs.iter().map(|u| u.value()).sum();

if total_input < total_output + selection.estimated_fee {
Expand All @@ -290,8 +308,17 @@ impl TransactionBuilder {
_ => self.outputs,
};

// Add change output if above dust threshold
if change_amount > 546 {
if self.selection_strategy == SelectionStrategy::All {
// Drain: the single output takes the whole balance minus fee (the caller's amount is
// ignored); no change.
let [out] = tx_outputs.as_mut_slice() else {
return Err(BuilderError::InvalidData(
"SelectionStrategy::All requires exactly one output (the destination)".into(),
));
};
out.value = total_input.saturating_sub(selection.estimated_fee);
} else if change_amount > 546 {
// Add change output if above dust threshold
let Some(change_addr) = self.change_addr else {
return Err(BuilderError::NoChangeAddress);
};
Expand Down Expand Up @@ -506,6 +533,8 @@ pub enum BuilderError {
NoOutputs,
/// No change address provided
NoChangeAddress,
/// The requested funding account does not exist
AccountNotFound(String),
/// Insufficient funds
InsufficientFunds {
available: u64,
Expand All @@ -521,6 +550,11 @@ pub enum BuilderError {
CoinSelection(crate::wallet::managed_wallet_info::coin_selection::SelectionError),
/// Signing was attempted with a watch-only wallet
WatchOnlyWallet,
/// More inputs than fit in a single standard transaction
TooManyInputs {
count: usize,
max: usize,
},
}

impl fmt::Display for BuilderError {
Expand All @@ -529,6 +563,7 @@ impl fmt::Display for BuilderError {
Self::NoInputs => write!(f, "No inputs provided"),
Self::NoOutputs => write!(f, "No outputs provided"),
Self::NoChangeAddress => write!(f, "No change address provided"),
Self::AccountNotFound(msg) => write!(f, "Account not found: {msg}"),
Self::InsufficientFunds {
available,
required,
Expand All @@ -540,6 +575,12 @@ impl fmt::Display for BuilderError {
Self::SigningFailed(msg) => write!(f, "Signing failed: {}", msg),
Self::CoinSelection(err) => write!(f, "Coin selection error: {}", err),
Self::WatchOnlyWallet => write!(f, "Cannot sign with a watch-only wallet"),
Self::TooManyInputs {
count,
max,
} => {
write!(f, "Too many inputs for a standard transaction: {count} (max {max})")
}
}
}
}
Expand Down
Loading
Loading