diff --git a/crates/wallet/src/wallet/mod.rs b/crates/wallet/src/wallet/mod.rs index a0282b6e..a4fa541b 100644 --- a/crates/wallet/src/wallet/mod.rs +++ b/crates/wallet/src/wallet/mod.rs @@ -1691,8 +1691,9 @@ impl Wallet { return Err(CreateTxError::NoUtxosSelected); } - let mut outgoings: HashMap = HashMap::new(); - // let mut outgoing = Amount::ZERO; + let mut outgoings: BTreeMap = BTreeMap::new(); + outgoings.insert(ColorIdentifier::default(), Amount::from_tap(0)); + let mut received = Amount::ZERO; let recipients = params.recipients.iter().map(|(r, v, c)| (r, *v, *c)); @@ -1760,20 +1761,23 @@ impl Wallet { let mut selected_coins: Vec = Vec::new(); - // for Colored Coin - for (script_pubkey, value, color_id) in recipients { - if color_id.is_default() { - continue; - } - let outgoing = *outgoings.get(&color_id).unwrap_or(&Amount::ZERO); + // Reverse the outgoings so that it ends with the default color (TPC) + for (color_id, outgoing) in outgoings.iter().rev() { + let target_amount = if color_id.is_default() { + outgoing.to_tap() + fee_amount + } else { + outgoing.to_tap() + }; + let coin_selection = coin_selection.coin_select( required_utxos.clone(), optional_utxos.clone(), fee_rate, - outgoing.to_tap(), + target_amount, &drain_script, - &color_id, + color_id, )?; + fee_amount += coin_selection.fee_amount; let excess = &coin_selection.excess; @@ -1790,15 +1794,80 @@ impl Wallet { .collect::>(), ); selected_coins.extend(coin_selection.selected); + + let output_exists = + tx.output + .iter() + .any(|output| match output.script_pubkey.color_id() { + None => color_id.is_default(), + Some(this_color_id) => this_color_id == *color_id, + }); + + if !output_exists { + // Uh oh, our transaction has no outputs. + // We allow this when: + // - We have a drain_to address and the utxos we must spend (this happens, + // for example, when we RBF) + // - We have a drain_to address and drain_wallet set + // - We have an output for colored coin sending but we don't have TPC output.(This + // case we should have TPC inputs and an output for bear fees.) + // Otherwise, we don't know who we should send the funds to, and how much + // we should send! + + let utxo_exists = params.utxos.iter().any(|utxo| { + match utxo.utxo.txout().script_pubkey.color_id() { + None => color_id.is_default(), + Some(this_color_id) => this_color_id == *color_id, + } + }); + + if (color_id.is_default() && outgoings.len() > 1) + || (params.drain_to.is_some() && (params.drain_wallet || utxo_exists)) + { + if let NoChange { + dust_threshold, + remaining_amount, + change_fee, + } = excess + { + let available = if color_id.is_default() { + remaining_amount.saturating_sub(*change_fee) + } else { + *remaining_amount + }; + return Err(CreateTxError::CoinSelection(Error::InsufficientFunds { + needed: *dust_threshold, + available, + })); + } + } else { + return Err(CreateTxError::NoRecipients); + } + } + match excess { - NoChange { .. } => {} + NoChange { + remaining_amount, .. + } => { + if color_id.is_default() { + fee_amount += remaining_amount; + } + } Change { amount, fee } => { + if color_id.is_default() && self.is_mine(&drain_script) { + received += Amount::from_tap(*amount); + } fee_amount += fee; // create drain output + let script = if color_id.is_default() { + drain_script.clone() + } else { + drain_script.add_color(*color_id).unwrap() + }; let drain_output = TxOut { value: Amount::from_tap(*amount), - script_pubkey: drain_script.add_color(color_id).unwrap(), + script_pubkey: script, }; // TODO: We should pay attention when adding a new output: this might increase @@ -1809,79 +1878,6 @@ impl Wallet { }; } - // for TPC - let outgoing = outgoings - .get(&ColorIdentifier::default()) - .unwrap_or(&Amount::ZERO); - let coin_selection = coin_selection.coin_select( - required_utxos, - optional_utxos, - fee_rate, - outgoing.to_tap() + fee_amount, - &drain_script, - &ColorIdentifier::default(), - )?; - - fee_amount += coin_selection.fee_amount; - let excess = &coin_selection.excess; - - tx.input - .extend(coin_selection.selected.iter().map(|u| tapyrus::TxIn { - previous_output: u.outpoint(), - script_sig: ScriptBuf::default(), - sequence: u.sequence().unwrap_or(n_sequence), - witness: Witness::new(), - })); - selected_coins.extend(coin_selection.selected); - - if tx.output.is_empty() { - // Uh oh, our transaction has no outputs. - // We allow this when: - // - We have a drain_to address and the utxos we must spend (this happens, - // for example, when we RBF) - // - We have a drain_to address and drain_wallet set - // Otherwise, we don't know who we should send the funds to, and how much - // we should send! - if params.drain_to.is_some() && (params.drain_wallet || !params.utxos.is_empty()) { - if let NoChange { - dust_threshold, - remaining_amount, - change_fee, - } = excess - { - return Err(CreateTxError::CoinSelection(Error::InsufficientFunds { - needed: *dust_threshold, - available: remaining_amount.saturating_sub(*change_fee), - })); - } - } else { - return Err(CreateTxError::NoRecipients); - } - } - - match excess { - NoChange { - remaining_amount, .. - } => fee_amount += remaining_amount, - Change { amount, fee } => { - if self.is_mine(&drain_script) { - received += Amount::from_tap(*amount); - } - fee_amount += fee; - - // create drain output - let drain_output = TxOut { - value: Amount::from_tap(*amount), - script_pubkey: drain_script, - }; - - // TODO: We should pay attention when adding a new output: this might increase - // the length of the "number of vouts" parameter by 2 bytes, potentially making - // our feerate too low - tx.output.push(drain_output); - } - }; - // sort input/outputs according to the chosen algorithm params.ordering.sort_tx(&mut tx); diff --git a/crates/wallet/tests/common.rs b/crates/wallet/tests/common.rs index 8dc198e7..8b68c490 100644 --- a/crates/wallet/tests/common.rs +++ b/crates/wallet/tests/common.rs @@ -206,7 +206,7 @@ pub fn get_funded_wallet_with_nft_and_change( pub fn get_funded_wallet_with_reissuable_and_change( descriptor: &str, change: &str, -) -> (Wallet, tapyrus::Txid, ColorIdentifier) { +) -> (Wallet, MalFixTxid, ColorIdentifier) { let mut wallet = Wallet::new_no_persist(descriptor, change, Network::Dev).unwrap(); let receive_address = wallet.peek_address(KeychainKind::External, 0).address; let sendto_address = Address::from_str("msvWktzSViRZ5kiepVr6W8VrgE8a6mbiVu") @@ -294,7 +294,99 @@ pub fn get_funded_wallet_with_reissuable_and_change( ) .unwrap(); - (wallet, tx1.txid(), color_id) + (wallet, tx1.malfix_txid(), color_id) +} + +pub fn get_funded_wallet_with_two_colored_coin_and_change( + descriptor: &str, + change: &str, +) -> (Wallet, MalFixTxid, ColorIdentifier, ColorIdentifier) { + let (mut wallet, txid, color_id1) = + get_funded_wallet_with_reissuable_and_change(descriptor, change); + + let receive_address = wallet.peek_address(KeychainKind::External, 1).address; + let color_id2 = ColorIdentifier::reissuable(receive_address.script_pubkey().as_script()); + + let tx0 = Transaction { + version: transaction::Version::ONE, + lock_time: tapyrus::absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: txid, + vout: 1, + }, + script_sig: Default::default(), + sequence: Default::default(), + witness: Default::default(), + }], + output: vec![TxOut { + value: Amount::from_tap(45_000), + script_pubkey: receive_address.script_pubkey(), + }], + }; + + let out_point = OutPoint { + txid: tx0.malfix_txid(), + vout: 0, + }; + let color_id = ColorIdentifier::reissuable(receive_address.script_pubkey().as_script()); + + let tx1 = Transaction { + version: transaction::Version::ONE, + lock_time: tapyrus::absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: out_point, + script_sig: Default::default(), + sequence: Default::default(), + witness: Default::default(), + }], + output: vec![ + TxOut { + value: Amount::from_tap(150), + script_pubkey: receive_address + .script_pubkey() + .add_color(color_id2) + .unwrap(), + }, + TxOut { + value: Amount::from_tap(40_000), + script_pubkey: receive_address.script_pubkey(), + }, + ], + }; + + wallet + .insert_checkpoint(BlockId { + height: 3_000, + hash: BlockHash::all_zeros(), + }) + .unwrap(); + wallet + .insert_checkpoint(BlockId { + height: 4_000, + hash: BlockHash::all_zeros(), + }) + .unwrap(); + wallet + .insert_tx( + tx0, + ConfirmationTime::Confirmed { + height: 3_000, + time: 100, + }, + ) + .unwrap(); + wallet + .insert_tx( + tx1.clone(), + ConfirmationTime::Confirmed { + height: 4_000, + time: 200, + }, + ) + .unwrap(); + + (wallet, tx1.malfix_txid(), color_id1, color_id2) } /// Return a fake wallet that appears to be funded for testing. /// diff --git a/crates/wallet/tests/wallet.rs b/crates/wallet/tests/wallet.rs index b2b9ac7b..357e716d 100644 --- a/crates/wallet/tests/wallet.rs +++ b/crates/wallet/tests/wallet.rs @@ -6,6 +6,7 @@ use std::time::Duration; use assert_matches::assert_matches; use rand::random; use tapyrus::hashes::Hash; +use tapyrus::hex::DisplayHex; use tapyrus::key::Secp256k1; use tapyrus::script::PushBytesBuf; use tapyrus::secp256k1::Scalar; @@ -1345,6 +1346,77 @@ fn test_create_tx_with_reissuable() { assert_eq!(sent_received, (Amount::from_tap(100), Amount::from_tap(2))); } +#[test] +fn test_create_tx_multi_colored_coin_recipients() { + let change_desc = "pkh(cVbZ8ovhye9AoAHFsqobCf7LxbXDAECy9Kb8TZdfsDYMZGBUyCnm)"; + let (mut wallet, _, color_id) = + get_funded_wallet_with_reissuable_and_change(get_test_pkh(), change_desc); + let addr1 = Address::from_str("2N1Ffz3WaNzbeLFBb51xyFMHYSEUXcbiSoX") + .unwrap() + .assume_checked(); + let addr2 = Address::from_str("16wow9KqkSfdLL4bjP83N5wa5cUU48HwQM") + .unwrap() + .assume_checked(); + let mut builder = wallet.build_tx(); + builder + .add_recipient_with_color(addr1.script_pubkey(), Amount::from_tap(10), color_id) + .add_recipient_with_color(addr2.script_pubkey(), Amount::from_tap(25), color_id) + .add_recipient_with_color(addr2.script_pubkey(), Amount::from_tap(15), color_id); + let psbt = builder.finish().unwrap(); + + let fee = check_fee!(wallet, psbt); + let feerate = FeeRate::from_tap_per_kwu(250); // 1 sat/vb + assert_fee_rate!(psbt, fee.unwrap_or(Amount::ZERO), feerate, @add_signature); + + assert_eq!(psbt.unsigned_tx.output.len(), 5); + + let sent_received = wallet.sent_and_received( + &psbt.clone().extract_tx().expect("failed to extract tx"), + &color_id, + ); + assert_eq!(sent_received, (Amount::from_tap(100), Amount::from_tap(50))); +} + +#[test] +fn test_create_tx_multi_color_id() { + let (desc, change) = get_test_pkh_single_sig_xprv_with_change_desc(); + let (mut wallet, _, color_id1, color_id2) = + get_funded_wallet_with_two_colored_coin_and_change(desc, change); + + let addr = Address::from_str("2N1Ffz3WaNzbeLFBb51xyFMHYSEUXcbiSoX") + .unwrap() + .assume_checked(); + let mut builder = wallet.build_tx(); + builder + .add_recipient_with_color(addr.script_pubkey(), Amount::from_tap(10), color_id1) + .add_recipient_with_color(addr.script_pubkey(), Amount::from_tap(20), color_id2); + let psbt = builder.finish().unwrap(); + + let fee = check_fee!(wallet, psbt); + let feerate = FeeRate::from_tap_per_kwu(250); // 1 sat/vb + assert_fee_rate!(psbt, fee.unwrap_or(Amount::ZERO), feerate, @add_signature); + + assert_eq!(psbt.unsigned_tx.output.len(), 5); + + let sent_received1 = wallet.sent_and_received( + &psbt.clone().extract_tx().expect("failed to extract tx"), + &color_id1, + ); + assert_eq!( + sent_received1, + (Amount::from_tap(100), Amount::from_tap(90)) + ); + + let sent_received2 = wallet.sent_and_received( + &psbt.clone().extract_tx().expect("failed to extract tx"), + &color_id2, + ); + assert_eq!( + sent_received2, + (Amount::from_tap(150), Amount::from_tap(130)) + ); +} + #[test] fn test_create_tx_global_xpubs_with_origin() { use tapyrus::bip32;