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 076581ed6..7a86d7f17 100644 --- a/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs +++ b/key-wallet/src/wallet/managed_wallet_info/transaction_builder.rs @@ -11,12 +11,13 @@ use crate::wallet::managed_wallet_info::coin_selection::{ use crate::wallet::managed_wallet_info::fee::FeeRate; use crate::{Account, DerivationPath, Signer, Utxo, Wallet}; use core::fmt; +use dashcore::blockdata::opcodes; use dashcore::blockdata::script::{Builder, PushBytes, ScriptBuf}; use dashcore::blockdata::transaction::special_transaction::TransactionPayload; use dashcore::blockdata::transaction::{OutPoint, Transaction}; use dashcore::sighash::{EcdsaSighashType, LegacySighash, SighashCache}; use dashcore::Address; -use dashcore::{TxIn, TxOut}; +use dashcore::{Network, TxIn, TxOut}; use dashcore_hashes::Hash; use secp256k1::ecdsa::Signature; use secp256k1::{Message, PublicKey, Secp256k1}; @@ -25,6 +26,10 @@ 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; +/// Relay-policy limit for OP_RETURN payloads, matching Dash Core's `-datacarriersize` default. +/// Public so callers can reject an over-long payload before handing over a builder +/// `add_op_return` would consume. +pub const MAX_STANDARD_OP_RETURN_BYTES: usize = 80; /// Calculate varint size for a given number fn varint_size(n: usize) -> usize { @@ -36,6 +41,27 @@ fn varint_size(n: usize) -> usize { } } +fn serialized_output_size(output: &TxOut) -> usize { + serialized_script_output_size(&output.script_pubkey) +} + +fn serialized_script_output_size(script_pubkey: &ScriptBuf) -> usize { + let script_len = script_pubkey.as_bytes().len(); + 8 + varint_size(script_len) + script_len +} + +fn addresses_share_network(left: &Address, right: &Address) -> bool { + // Checked addresses retain their encoded network prefix, not a unique Network value: + // legacy testnet/devnet/regtest addresses intentionally share one prefix. Treat two + // addresses as compatible when at least one supported network accepts both encodings. + [Network::Mainnet, Network::Testnet, Network::Devnet, Network::Regtest].into_iter().any( + |network| { + left.as_unchecked().is_valid_for_network(network) + && right.as_unchecked().is_valid_for_network(network) + }, + ) +} + /// Transaction builder for creating Dash transactions /// /// This builder implements BIP-69 (Lexicographical Indexing of Transaction Inputs and Outputs) @@ -49,6 +75,8 @@ pub struct TransactionBuilder { current_height: u32, selection_strategy: SelectionStrategy, require_final_inputs: bool, + preserve_output_order: bool, + change_to_first_input: bool, /// Special transaction payload for Dash-specific transactions special_payload: Option, /// Reservation set of the funding account, captured by `set_funding`. The @@ -74,6 +102,8 @@ impl TransactionBuilder { current_height: 0, selection_strategy: SelectionStrategy::BranchAndBound, require_final_inputs: false, + preserve_output_order: false, + change_to_first_input: false, special_payload: None, reservations: None, } @@ -135,9 +165,11 @@ impl TransactionBuilder { /// Add an output to a specific address /// - /// Note: Outputs will be sorted according to BIP-69 when the transaction is built: + /// Note: by default outputs are sorted according to BIP-69 when the transaction is built: /// - First by amount (ascending) /// - Then by scriptPubKey (lexicographically) + /// + /// Call [`Self::preserve_output_order`] to keep insertion order instead. pub fn add_output(mut self, address: &Address, amount: u64) -> Self { let script_pubkey = address.script_pubkey(); self.outputs.push(TxOut { @@ -147,6 +179,44 @@ impl TransactionBuilder { self } + /// Add an OP_RETURN output carrying `data` (value 0). + /// + /// Errors if `data` exceeds [`MAX_STANDARD_OP_RETURN_BYTES`]. + pub fn add_op_return(mut self, data: &[u8]) -> Result { + if data.len() > MAX_STANDARD_OP_RETURN_BYTES { + return Err(BuilderError::OpReturnDataTooLarge { + len: data.len(), + max: MAX_STANDARD_OP_RETURN_BYTES, + }); + } + + let push_bytes = + <&PushBytes>::try_from(data).map_err(|_| BuilderError::OpReturnDataTooLarge { + len: data.len(), + max: MAX_STANDARD_OP_RETURN_BYTES, + })?; + self.outputs.push(TxOut { + value: 0, + script_pubkey: Builder::new() + .push_opcode(opcodes::all::OP_RETURN) + .push_slice(push_bytes) + .into_script(), + }); + Ok(self) + } + + /// Preserve outputs in insertion order instead of applying BIP-69 sorting. + pub fn preserve_output_order(mut self) -> Self { + self.preserve_output_order = true; + self + } + + /// Route change to the address of the first selected input (post-sort). + pub fn change_to_first_input(mut self) -> Self { + self.change_to_first_input = true; + self + } + pub fn set_fee_rate(mut self, fee_rate: FeeRate) -> Self { self.fee_rate = fee_rate; self @@ -167,6 +237,53 @@ impl TransactionBuilder { } } + fn should_estimate_change_output(&self) -> bool { + self.change_addr.is_some() || self.change_to_first_input + } + + /// Dust threshold for an output of `output_size` serialized bytes, mirroring Dash Core's + /// `GetDustThreshold`: the output is dust when spending it would cost more than a third of + /// its value at the dust relay fee, i.e. `3 * (output_size + 148)` duffs for the 148-byte + /// P2PKH input that would spend it. + /// + /// The flat 546 this replaces is exactly this formula for a 34-byte P2PKH output, so nothing + /// moves for the ordinary path. It matters for `change_to_first_input`, where change is + /// routed to whatever script VIN0 uses: a 43-byte output has a 573-duff threshold, so a + /// 550-duff change output would have passed a flat 546 check and then been rejected as dust + /// by the network. + fn dust_threshold(output_size: usize) -> u64 { + 3 * (output_size as u64 + 148) + } + + fn estimated_change_output_size(&self) -> usize { + if self.change_to_first_input { + // Coin selection may choose any spendable input, then BIP-69 determines VIN0. + // Reserve the largest eligible routing script so every possible winner is covered. + self.inputs + .iter() + .filter(|utxo| utxo.is_spendable(self.current_height)) + .map(|utxo| serialized_script_output_size(&utxo.address.script_pubkey())) + .max() + .unwrap_or(CHANGE_OUTPUT_SIZE) + } else { + self.change_addr + .as_ref() + .map(|address| serialized_script_output_size(&address.script_pubkey())) + .unwrap_or(0) + } + } + + fn effective_outputs_size(&self) -> usize { + match &self.special_payload { + // The asset-lock burn output is sized at the flat TX_OUTPUT_SIZE rather than its + // real ~11 serialized bytes. The real size is smaller, so this over-estimates and + // over-pays slightly — deliberately kept identical to the pre-OP_RETURN behaviour + // so identity funding fees do not move as a side effect of Maya support. + Some(TransactionPayload::AssetLockPayloadType(_)) => TX_OUTPUT_SIZE, + _ => self.outputs.iter().map(serialized_output_size).sum(), + } + } + /// Calculate the base transaction size excluding inputs /// Based on dashsync/DashSync/shared/Models/Transactions/Base/DSTransaction.m fn calculate_base_size(&self) -> usize { @@ -181,19 +298,21 @@ impl TransactionBuilder { // Add varint for output count size += varint_size( outputs_count - + if self.change_addr.is_some() { + + if self.should_estimate_change_output() { 1 } else { 0 }, ); - // Add outputs size (P2PKH output) - size += outputs_count * TX_OUTPUT_SIZE; + // Add serialized output sizes. A P2PKH output still measures TX_OUTPUT_SIZE; + // non-standard outputs such as OP_RETURN are sized from their real script. + size += self.effective_outputs_size(); - // Add change output if we have a change address - if self.change_addr.is_some() { - size += CHANGE_OUTPUT_SIZE; + // Add change using the actual configured script size, or the largest eligible input + // script when change will be routed to VIN0. + if self.should_estimate_change_output() { + size += self.estimated_change_output_size(); } // Add special payload size if present @@ -306,6 +425,7 @@ impl TransactionBuilder { // estimate doesn't include a phantom (~34-byte) change output. if self.selection_strategy == SelectionStrategy::All { self.change_addr = None; + self.change_to_first_input = false; } // For AssetLock the on-chain spend equals the OP_RETURN burn, which @@ -322,12 +442,8 @@ impl TransactionBuilder { self.inputs.retain(|utxo| utxo.is_confirmed || utxo.is_instantlocked); } - // Matches `calculate_base_size`: a change output is only budgeted with a change address. - let change_output_size = if self.change_addr.is_some() { - CHANGE_OUTPUT_SIZE - } else { - 0 - }; + // Must match `calculate_base_size`, including the conservative VIN0 routing-script size. + let change_output_size = self.estimated_change_output_size(); let selection = CoinSelector::new(self.selection_strategy) .select_coins_with_size( @@ -358,8 +474,12 @@ impl TransactionBuilder { }); } + selected_inputs.sort_by(bip69_input_sorter); + let change_amount = total_input.saturating_sub(total_output).saturating_sub(selection.estimated_fee); + // Computed before `self.outputs` is moved out below. + let change_dust_threshold = Self::dust_threshold(self.estimated_change_output_size()); let mut tx_outputs = match &self.special_payload { Some(TransactionPayload::AssetLockPayloadType(_)) => vec![TxOut { value: total_output, @@ -399,22 +519,39 @@ impl TransactionBuilder { }; credit.value = drained; } - } else if change_amount > 546 { + } else if change_amount > change_dust_threshold { // Add change output if above dust threshold - let Some(change_addr) = self.change_addr else { - return Err(BuilderError::NoChangeAddress); + let change_script_pubkey = if self.change_to_first_input { + let Some(first_input) = selected_inputs.first() else { + return Err(BuilderError::NoInputs); + }; + if self.change_addr.as_ref().is_some_and(|change_addr| { + !addresses_share_network(&first_input.address, change_addr) + }) { + return Err(BuilderError::InvalidData( + "Input-derived change address network does not match configured change address" + .into(), + )); + } + first_input.address.script_pubkey() + } else { + let Some(change_addr) = self.change_addr else { + return Err(BuilderError::NoChangeAddress); + }; + change_addr.script_pubkey() }; + tx_outputs.push(TxOut { value: change_amount, - script_pubkey: change_addr.script_pubkey(), + script_pubkey: change_script_pubkey, }); } - if !matches!(self.special_payload, Some(TransactionPayload::AssetLockPayloadType(_))) { + if !self.preserve_output_order + && !matches!(self.special_payload, Some(TransactionPayload::AssetLockPayloadType(_))) + { tx_outputs.sort_by(bip69_output_sorter); } - - selected_inputs.sort_by(bip69_input_sorter); let tx_inputs: Vec = selected_inputs .iter() .map(|utxo| TxIn { @@ -701,6 +838,11 @@ pub enum BuilderError { count: usize, max: usize, }, + /// OP_RETURN payload exceeds the standard relay-policy size. + OpReturnDataTooLarge { + len: usize, + max: usize, + }, } impl fmt::Display for BuilderError { @@ -727,6 +869,12 @@ impl fmt::Display for BuilderError { } => { write!(f, "Too many inputs for a standard transaction: {count} (max {max})") } + Self::OpReturnDataTooLarge { + len, + max, + } => { + write!(f, "OP_RETURN payload too large: {len} bytes (max {max})") + } } } } @@ -739,10 +887,119 @@ mod tests { use crate::test_utils::TestWalletContext; use crate::Network; use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; + use dashcore::consensus::serialize; use dashcore::{OutPoint, Txid}; use dashcore_hashes::{sha256d, Hash}; use hex; + fn op_return_script(data: &[u8]) -> ScriptBuf { + let push_bytes = <&PushBytes>::try_from(data).expect("test OP_RETURN bytes"); + Builder::new().push_opcode(opcodes::all::OP_RETURN).push_slice(push_bytes).into_script() + } + + fn build_unsigned_legacy(mut builder: TransactionBuilder) -> (Transaction, u64) { + if let Some(TransactionPayload::AssetLockPayloadType(p)) = &builder.special_payload { + assert!(!p.credit_outputs.is_empty(), "legacy helper expects outputs"); + } else if builder.outputs.is_empty() && builder.special_payload.is_none() { + panic!("legacy helper expects outputs"); + } + + if builder.selection_strategy == SelectionStrategy::All { + builder.change_addr = None; + } + + let total_output: u64 = match &builder.special_payload { + Some(TransactionPayload::AssetLockPayloadType(p)) => { + p.credit_outputs.iter().map(|o| o.value).sum() + } + _ => builder.outputs.iter().map(|o| o.value).sum(), + }; + + if builder.require_final_inputs { + builder.inputs.retain(|utxo| utxo.is_confirmed || utxo.is_instantlocked); + } + + let selection = CoinSelector::new(builder.selection_strategy) + .select_coins_with_size( + builder.inputs.iter(), + total_output, + builder.fee_rate, + builder.current_height, + builder.calculate_base_size(), + // The selector's last argument is the change-output size. The legacy path + // budgeted one only when a change address was set — reproduce exactly that, + // so the comparison isolates the output-sizing change. + if builder.change_addr.is_some() { + CHANGE_OUTPUT_SIZE + } else { + 0 + }, + ) + .expect("legacy selection"); + + let mut selected_inputs = selection.selected; + let total_input: u64 = selected_inputs.iter().map(|u| u.value()).sum(); + let change_amount = + total_input.saturating_sub(total_output).saturating_sub(selection.estimated_fee); + + let mut tx_outputs = match &builder.special_payload { + Some(TransactionPayload::AssetLockPayloadType(_)) => vec![TxOut { + value: total_output, + script_pubkey: ScriptBuf::new_op_return(&[]), + }], + _ => builder.outputs, + }; + + if builder.selection_strategy == SelectionStrategy::All { + let [out] = tx_outputs.as_mut_slice() else { + panic!("legacy helper expects a single drain output"); + }; + out.value = total_input.saturating_sub(selection.estimated_fee); + } else if change_amount > 546 { + let change_addr = builder.change_addr.expect("legacy change address"); + tx_outputs.push(TxOut { + value: change_amount, + script_pubkey: change_addr.script_pubkey(), + }); + } + + if !matches!(builder.special_payload, Some(TransactionPayload::AssetLockPayloadType(_))) { + tx_outputs.sort_by(|a, b| match a.value.cmp(&b.value) { + Ordering::Equal => a.script_pubkey.as_bytes().cmp(b.script_pubkey.as_bytes()), + other => other, + }); + } + + selected_inputs.sort_by(|a, b| { + let tx_hash_a = a.outpoint.txid.to_byte_array(); + let tx_hash_b = b.outpoint.txid.to_byte_array(); + + match tx_hash_a.cmp(&tx_hash_b) { + Ordering::Equal => a.outpoint.vout.cmp(&b.outpoint.vout), + other => other, + } + }); + + let tx = Transaction { + version: 3, + lock_time: 0, + input: selected_inputs + .iter() + .map(|utxo| TxIn { + previous_output: utxo.outpoint, + script_sig: ScriptBuf::new(), + sequence: 0xffffffff, + witness: dashcore::blockdata::witness::Witness::new(), + }) + .collect(), + output: tx_outputs, + special_transaction_payload: builder.special_payload, + }; + + let total_output: u64 = tx.output.iter().map(|out| out.value).sum(); + (tx, total_input.saturating_sub(total_output)) + } + #[test] fn test_transaction_builder_basic() { let utxo = Utxo::dummy(0, 100000, 100, false, true); @@ -1099,6 +1356,241 @@ mod tests { assert_eq!(tx.output[0].value, 100000); } + #[test] + fn test_maya_deposit_shape_preserves_output_order_and_routes_change_to_first_input() { + let vault = Address::dummy(Network::Testnet, 42); + let memo = vec![0x4d; MAX_STANDARD_OP_RETURN_BYTES]; + let utxos = vec![ + Utxo::dummy(0x02, 80_000, 100, false, true), + Utxo::dummy(0x01, 80_000, 100, false, true), + ]; + + let (tx, fee, _reservation) = TransactionBuilder::new() + .set_current_height(200) + .set_selection_strategy(SelectionStrategy::SmallestFirst) + .set_fee_rate(FeeRate::normal()) + .add_inputs(utxos) + .add_output(&vault, 120_000) + .add_op_return(&memo) + .expect("valid OP_RETURN") + .preserve_output_order() + .change_to_first_input() + .build_unsigned_reserved() + .expect("maya-shaped transaction"); + + assert_eq!(tx.output.len(), 3, "vault + memo + change"); + assert_eq!(tx.output[0].script_pubkey, vault.script_pubkey()); + assert_eq!(tx.output[1].value, 0); + assert!(tx.output[1].script_pubkey.is_op_return()); + assert_eq!(tx.output[1].script_pubkey, op_return_script(&memo)); + assert_eq!( + &tx.output[1].script_pubkey.as_bytes() + [tx.output[1].script_pubkey.as_bytes().len() - memo.len()..], + memo.as_slice() + ); + assert_eq!(tx.output[2].script_pubkey, Address::dummy(Network::Testnet, 1).script_pubkey()); + + // `build_unsigned` leaves every script_sig empty, so the serialized bytes are ~107 + // short per input (41 unsigned vs the 148 a signed P2PKH input costs). Comparing the + // fee against that would pass no matter how badly the estimate under-counted — the + // very regression the precise output sizing exists to prevent. Measure against the + // signed size the builder itself assumes. + const SIGNED_INPUT_SIZE: usize = 148; + const UNSIGNED_INPUT_SIZE: usize = 41; + let signed_size = (serialize(&tx).len() + + tx.input.len() * (SIGNED_INPUT_SIZE - UNSIGNED_INPUT_SIZE)) + as u64; + assert!( + fee >= signed_size, + "fee {fee} must cover the signed size {signed_size} at the 1 duff/byte relay minimum" + ); + } + + #[test] + fn test_change_to_first_input_sizes_larger_routing_script() { + let routed_address = + Address::p2wsh(&Builder::new().push_int(1).into_script(), Network::Testnet); + let mut routed_input = Utxo::dummy(0x01, 100_000, 100, false, true); + routed_input.address = routed_address.clone(); + routed_input.txout.script_pubkey = routed_address.script_pubkey(); + + let builder = TransactionBuilder::new() + .set_current_height(200) + .set_selection_strategy(SelectionStrategy::SmallestFirst) + .set_fee_rate(FeeRate::normal()) + .set_change_address(Address::dummy(Network::Testnet, 13)) + .add_inputs([routed_input]) + .add_output(&Address::dummy(Network::Testnet, 42), 50_000) + .change_to_first_input(); + + let routed_output_size = serialized_script_output_size(&routed_address.script_pubkey()); + assert!(routed_output_size > CHANGE_OUTPUT_SIZE); + assert_eq!(builder.estimated_change_output_size(), routed_output_size); + + let (tx, fee, _reservation) = + builder.build_unsigned_reserved().expect("witness-routed change transaction"); + assert_eq!(tx.output.len(), 2); + assert!(tx + .output + .iter() + .any(|output| output.script_pubkey == routed_address.script_pubkey())); + + const SIGNED_INPUT_SIZE: usize = 148; + const UNSIGNED_INPUT_SIZE: usize = 41; + let signed_size = (serialize(&tx).len() + + tx.input.len() * (SIGNED_INPUT_SIZE - UNSIGNED_INPUT_SIZE)) + as u64; + assert!( + fee >= signed_size, + "fee {fee} must cover the larger routed output at signed size {signed_size}" + ); + } + + #[test] + fn test_change_to_first_input_rejects_configured_network_mismatch() { + let mainnet_address = Address::dummy(Network::Mainnet, 1); + let mut input = Utxo::dummy(0x01, 100_000, 100, false, true); + input.address = mainnet_address.clone(); + input.txout.script_pubkey = mainnet_address.script_pubkey(); + + let result = TransactionBuilder::new() + .set_current_height(200) + .set_selection_strategy(SelectionStrategy::SmallestFirst) + .set_change_address(Address::dummy(Network::Testnet, 13)) + .add_inputs([input]) + .add_output(&Address::dummy(Network::Testnet, 42), 50_000) + .change_to_first_input() + .build_unsigned_reserved(); + + assert!(matches!( + result, + Err(BuilderError::InvalidData(message)) + if message.contains("network does not match") + )); + } + + /// The precise per-output sizing added for OP_RETURN support must not move the fee + /// estimate for any transaction shape that existed before it: P2PKH outputs really are + /// 34 serialized bytes, and the asset-lock burn is deliberately still charged at 34. + #[test] + fn test_base_size_unchanged_for_pre_op_return_shapes() { + fn legacy_base_size(outputs_count: usize, has_change: bool, payload_size: usize) -> usize { + let mut size = 8 + 1; + size += varint_size(outputs_count + usize::from(has_change)); + size += outputs_count * 34; + if has_change { + size += 34; + } + if payload_size > 0 { + size += varint_size(payload_size) + payload_size; + } + size + } + + let plain = TransactionBuilder::new() + .set_change_address(Address::dummy(Network::Testnet, 13)) + .add_output(&Address::dummy(Network::Testnet, 11), 80_000) + .add_output(&Address::dummy(Network::Testnet, 12), 60_000); + assert_eq!(plain.calculate_base_size(), legacy_base_size(2, true, 0)); + + let no_change = + TransactionBuilder::new().add_output(&Address::dummy(Network::Testnet, 11), 80_000); + assert_eq!(no_change.calculate_base_size(), legacy_base_size(1, false, 0)); + + let asset_lock = TransactionBuilder::new() + .set_change_address(Address::dummy(Network::Testnet, 13)) + .set_special_payload(TransactionPayload::AssetLockPayloadType(AssetLockPayload { + version: 1, + credit_outputs: vec![TxOut { + value: 50_000, + script_pubkey: Address::dummy(Network::Testnet, 14).script_pubkey(), + }], + })); + let payload_size = 1 + varint_size(1) + 34; + assert_eq!( + asset_lock.calculate_base_size(), + legacy_base_size(1, true, payload_size), + "asset-lock sizing must stay byte-identical to the pre-OP_RETURN estimate" + ); + } + + /// The dust threshold has to follow the change script, not assume P2PKH. Pins both ends: + /// the P2PKH case must still be exactly the 546 the flat literal used to hard-code, and a + /// larger routed script must demand proportionally more before change is worth creating. + #[test] + fn test_dust_threshold_follows_the_change_output_size() { + assert_eq!( + TransactionBuilder::dust_threshold(CHANGE_OUTPUT_SIZE), + 546, + "P2PKH change must keep the historical 546-duff threshold" + ); + // A 43-byte output (e.g. P2WSH) costs the same 148-byte input to spend, so its threshold + // is 3 * (43 + 148). A 550-duff change output clears 546 but is dust at this size. + assert_eq!(TransactionBuilder::dust_threshold(43), 573); + assert!(550 > TransactionBuilder::dust_threshold(CHANGE_OUTPUT_SIZE)); + assert!(550 < TransactionBuilder::dust_threshold(43)); + } + + /// Pins the boundary itself, not just an under-sized fixture: exactly + /// [`MAX_STANDARD_OP_RETURN_BYTES`] must be accepted and one byte more refused, with the + /// error reporting both sides so a caller can render something useful. + #[test] + fn test_add_op_return_enforces_the_standard_ceiling() { + TransactionBuilder::new() + .add_op_return(&[0x4d; MAX_STANDARD_OP_RETURN_BYTES]) + .expect("a payload at exactly the ceiling is standard and must be accepted"); + + match TransactionBuilder::new().add_op_return(&[0u8; MAX_STANDARD_OP_RETURN_BYTES + 1]) { + Err(BuilderError::OpReturnDataTooLarge { + len, + max, + }) => { + assert_eq!(len, MAX_STANDARD_OP_RETURN_BYTES + 1); + assert_eq!(max, MAX_STANDARD_OP_RETURN_BYTES); + } + _ => panic!("expected oversized OP_RETURN error"), + } + } + + #[test] + fn test_default_ordinary_send_matches_legacy_bytes() { + let inputs = vec![ + Utxo::dummy(0x03, 120_000, 100, false, true), + Utxo::dummy(0x01, 130_000, 100, false, true), + ]; + let destination_a = Address::dummy(Network::Testnet, 11); + let destination_b = Address::dummy(Network::Testnet, 12); + let change = Address::dummy(Network::Testnet, 13); + + let builder = TransactionBuilder::new() + .set_current_height(200) + .set_selection_strategy(SelectionStrategy::SmallestFirst) + .set_fee_rate(FeeRate::normal()) + .set_change_address(change) + .add_inputs(inputs) + .add_output(&destination_a, 80_000) + .add_output(&destination_b, 60_000); + + let (legacy_tx, legacy_fee) = build_unsigned_legacy(builder); + + let (tx, fee, _reservation) = TransactionBuilder::new() + .set_current_height(200) + .set_selection_strategy(SelectionStrategy::SmallestFirst) + .set_fee_rate(FeeRate::normal()) + .set_change_address(Address::dummy(Network::Testnet, 13)) + .add_inputs([ + Utxo::dummy(0x03, 120_000, 100, false, true), + Utxo::dummy(0x01, 130_000, 100, false, true), + ]) + .add_output(&destination_a, 80_000) + .add_output(&destination_b, 60_000) + .build_unsigned_reserved() + .expect("ordinary send"); + + assert_eq!(serialize(&tx), serialize(&legacy_tx)); + assert_eq!(fee, legacy_fee); + } + #[test] fn test_bip69_input_ordering() { // Test that inputs are sorted according to BIP-69