From 9c89c68635dc22b4aaf52833611c725f131acc9b Mon Sep 17 00:00:00 2001 From: Borja Castellano Date: Mon, 3 Aug 2026 01:20:24 -0700 Subject: [PATCH] fix(dash-spv): preserve already defined change addr tx builder --- .../transaction_builder.rs | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs b/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs index 747f23e8a..82d16b855 100644 --- a/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs @@ -110,6 +110,11 @@ impl TransactionBuilder { /// must therefore not be held across an `await` between `set_funding` and /// `build_signed` or `assemble_unsigned`, since suspending there reopens the /// read-then-reserve window for a concurrent build. + /// + /// A change address already set by [`set_change_address`](Self::set_change_address) + /// wins: the funding account is only asked for one when the builder has none. + /// Deriving unconditionally would both discard the caller's choice and burn a + /// pool address, since `next_change_address` advances the pool state. pub fn set_funding(mut self, funds_acc: &mut ManagedCoreFundsAccount, acc: &Account) -> Self { let reserved = funds_acc.reservations().reserved(self.current_height); self.inputs = funds_acc @@ -119,7 +124,9 @@ impl TransactionBuilder { .cloned() .collect(); self.reservations = Some(funds_acc.reservations().clone()); - self.change_addr = funds_acc.next_change_address(Some(&acc.account_xpub), true).ok(); + if self.change_addr.is_none() { + self.change_addr = funds_acc.next_change_address(Some(&acc.account_xpub), true).ok(); + } self } @@ -1206,6 +1213,33 @@ mod tests { assert!(candidates.contains(&free.outpoint)); } + #[test] + fn set_funding_keeps_an_explicit_change_address() { + let ctx = TestWalletContext::new_random(); + let account = + ctx.wallet.accounts.standard_bip44_accounts.get(&0).expect("BIP44 account").clone(); + + let mut funds = ManagedCoreFundsAccount::dummy_bip44(); + let utxo = Utxo::dummy(0x01, 1_000_000, 100, false, true); + funds.utxos.insert(utxo.outpoint, utxo); + + let explicit = Address::dummy(Network::Testnet, 1); + let builder = TransactionBuilder::new() + .set_current_height(200) + .set_change_address(explicit.clone()) + .set_funding(&mut funds, &account); + + assert_eq!(builder.change_addr.as_ref(), Some(&explicit)); + + // Nor was a pool address burned to produce one that would be thrown away: + // `funds` still hands out the same address a pristine account would. + let mut control = ManagedCoreFundsAccount::dummy_bip44(); + assert_eq!( + funds.next_change_address(Some(&account.account_xpub), true).expect("change address"), + control.next_change_address(Some(&account.account_xpub), true).expect("change address"), + ); + } + #[tokio::test] async fn build_signed_releases_reservation_on_signing_failure() { let ctx = TestWalletContext::new_random();