From 9b256b1d6ca9dac3af3fb68b73c56cd2b2d79f92 Mon Sep 17 00:00:00 2001 From: chris-belcher Date: Thu, 13 Jan 2022 17:54:20 +0000 Subject: [PATCH] Implement direct-send main subroutine Can be used to spend certain coins, in the manner of coin control. Does not yet support having the wallet choose the coins to spend. So coin control is non-optional for now. Does not yet support spending timelocked utxos. --- src/lib.rs | 50 ++++++++- src/main.rs | 39 +++++-- src/wallet_direct_send.rs | 213 ++++++++++++++++++++++++++++++++++++++ src/wallet_sync.rs | 176 +++++++++++++++++++------------ 4 files changed, 404 insertions(+), 74 deletions(-) create mode 100644 src/wallet_direct_send.rs diff --git a/src/lib.rs b/src/lib.rs index a836eddb..1b9b4ba9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,9 @@ use bitcoincore_rpc::{Auth, Client, Error, RpcApi}; pub mod wallet_sync; use wallet_sync::{Wallet, WalletSyncAddressAmount}; +pub mod wallet_direct_send; +use wallet_direct_send::{CoinToSpend, Destination, SendAmount}; + pub mod contracts; use contracts::{read_locktime_from_contract, SwapCoin}; @@ -158,15 +161,15 @@ pub fn display_wallet_balance(wallet_file_name: &PathBuf, long_form: Option rpc, + Err(error) => { + log::error!(target: "main", "error connecting to bitcoin node: {:?}", error); + return; + } + }; + let mut wallet = + match Wallet::load_wallet_from_file(wallet_file_name, WalletSyncAddressAmount::Normal) { + Ok(w) => w, + Err(error) => { + log::error!(target: "main", "error loading wallet file: {:?}", error); + return; + } + }; + wallet.startup_sync(&rpc).unwrap(); + let tx = wallet + .create_direct_send(&rpc, send_amount, destination, coins_to_spend) + .unwrap(); + if dont_broadcast { + let txhex = bitcoin::consensus::encode::serialize_hex(&tx); + let accepted = rpc + .test_mempool_accept(&[txhex.clone()]) + .unwrap() + .iter() + .any(|tma| tma.allowed); + assert!(accepted); + println!("tx = \n{}", txhex); + } else { + let txid = rpc.send_raw_transaction(&tx).unwrap(); + println!("broadcasted {}", txid); + } +} + pub fn run_watchtower(kill_flag: Option>>) { let rpc = match get_bitcoin_rpc() { Ok(rpc) => rpc, diff --git a/src/main.rs b/src/main.rs index f2550309..979e6372 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use structopt::StructOpt; use teleport; use teleport::maker_protocol::MakerBehavior; +use teleport::wallet_direct_send::{CoinToSpend, Destination, SendAmount}; use teleport::wallet_sync::WalletSyncAddressAmount; use teleport::watchtower_client::ContractInfo; @@ -17,6 +18,11 @@ struct ArgsWithWalletFile { #[structopt(default_value = "wallet.teleport", parse(from_os_str), long)] wallet_file_name: PathBuf, + /// Dont broadcast transactions, only output their transaction hex string + /// Only for commands which involve sending transactions e.g. recover-from-incomplete-coinswap + #[structopt(short, long)] + dont_broadcast: bool, + /// Subcommand #[structopt(flatten)] subcommand: Subcommand, @@ -59,8 +65,17 @@ enum Subcommand { RecoverFromIncompleteCoinswap { /// Hashvalue as hex string which uniquely identifies the coinswap hashvalue: Hash160, - /// Dont broadcast transactions, only output their transaction hex string - dont_broadcast: Option, + }, + + /// Send a transaction from the wallet + DirectSend { + /// Amount to send (in sats), or "sweep" for sweep + send_amount: SendAmount, + /// Address to send coins to, or "wallet" to send back to own wallet + destination: Destination, + /// Coins to spend as inputs, either in long form ":vout" or short + /// form "txid-prefix..txid-suffix:vout" + coins_to_spend: Vec, }, /// Run watchtower @@ -111,14 +126,24 @@ fn main() -> Result<(), Box> { Subcommand::CoinswapSend => { teleport::run_taker(&args.wallet_file_name, WalletSyncAddressAmount::Normal); } - Subcommand::RecoverFromIncompleteCoinswap { - hashvalue, - dont_broadcast, - } => { + Subcommand::RecoverFromIncompleteCoinswap { hashvalue } => { teleport::recover_from_incomplete_coinswap( &args.wallet_file_name, hashvalue, - dont_broadcast.unwrap_or(false), + args.dont_broadcast, + ); + } + Subcommand::DirectSend { + send_amount, + destination, + coins_to_spend, + } => { + teleport::direct_send( + &args.wallet_file_name, + send_amount, + destination, + &coins_to_spend, + args.dont_broadcast, ); } Subcommand::RunWatchtower => { diff --git a/src/wallet_direct_send.rs b/src/wallet_direct_send.rs new file mode 100644 index 00000000..0cc5c480 --- /dev/null +++ b/src/wallet_direct_send.rs @@ -0,0 +1,213 @@ +use std::num::ParseIntError; +use std::str::FromStr; + +use bitcoin::{Address, Amount, OutPoint, Script, Transaction, TxIn, TxOut}; + +use bitcoincore_rpc::json::ListUnspentResultEntry; +use bitcoincore_rpc::Client; + +use crate::error::Error; +use crate::wallet_sync::{UTXOSpendInfo, Wallet, NETWORK, SignTransactionInputInfo}; + +#[derive(Debug)] +pub enum SendAmount { + Sweep, + Amount(Amount), +} + +impl FromStr for SendAmount { + type Err = ParseIntError; + + fn from_str(s: &str) -> Result { + Ok(if s == "sweep" { + SendAmount::Sweep + } else { + SendAmount::Amount(Amount::from_sat(String::from(s).parse::()?)) + }) + } +} + +#[derive(Debug)] +pub enum Destination { + Wallet, + Address(Address), +} + +impl FromStr for Destination { + type Err = bitcoin::util::address::Error; + + fn from_str(s: &str) -> Result { + Ok(if s == "wallet" { + Destination::Wallet + } else { + Destination::Address(Address::from_str(s)?) + }) + } +} + +#[derive(Debug)] +pub enum CoinToSpend { + LongForm(OutPoint), + ShortForm { + prefix: String, + suffix: String, + vout: u32, + }, +} + +fn parse_short_form_coin(s: &str) -> Option { + //example short form: 568a4e..83a2e8:0 + if s.len() < 15 { + return None; + } + let dots = &s[6..8]; + if dots != ".." { + return None; + } + let colon = s.chars().nth(14).unwrap(); + if colon != ':' { + return None; + } + let prefix = String::from(&s[0..6]); + let suffix = String::from(&s[8..14]); + let vout = *(&s[15..].parse::().ok()?); + Some(CoinToSpend::ShortForm { + prefix, + suffix, + vout, + }) +} + +impl FromStr for CoinToSpend { + type Err = bitcoin::blockdata::transaction::ParseOutPointError; + + fn from_str(s: &str) -> Result { + let parsed_outpoint = OutPoint::from_str(s); + if parsed_outpoint.is_ok() { + Ok(CoinToSpend::LongForm(parsed_outpoint.unwrap())) + } else { + let short_form = parse_short_form_coin(s); + if short_form.is_some() { + Ok(short_form.unwrap()) + } else { + Err(parsed_outpoint.err().unwrap()) + } + } + } +} + +impl Wallet { + pub fn create_direct_send( + &mut self, + rpc: &Client, + send_amount: SendAmount, + destination: Destination, + coins_to_spend: &[CoinToSpend], + ) -> Result { + let mut tx_inputs = Vec::::new(); + let mut unspent_inputs = Vec::<(ListUnspentResultEntry, UTXOSpendInfo)>::new(); + //TODO this search within a search could get very slow + let list_unspent_result = self.list_unspent_from_wallet(rpc)?; + for (list_unspent_entry, spend_info) in list_unspent_result { + for cts in coins_to_spend { + let previous_output = match cts { + CoinToSpend::LongForm(outpoint) => { + if list_unspent_entry.txid == outpoint.txid + && list_unspent_entry.vout == outpoint.vout + { + *outpoint + } else { + continue; + } + } + CoinToSpend::ShortForm { + prefix, + suffix, + vout, + } => { + let txid_hex = list_unspent_entry.txid.to_string(); + if txid_hex.starts_with(prefix) + && txid_hex.ends_with(suffix) + && list_unspent_entry.vout == *vout + { + OutPoint { + txid: list_unspent_entry.txid, + vout: list_unspent_entry.vout, + } + } else { + continue; + } + } + }; + tx_inputs.push(TxIn { + previous_output, + sequence: 0, + witness: Vec::new(), + script_sig: Script::new(), + }); + unspent_inputs.push((list_unspent_entry.clone(), spend_info.clone())); + } + } + if tx_inputs.len() != coins_to_spend.len() { + panic!("unable to find all given inputs, only found = {:?}", tx_inputs); + } + let dest_addr = match destination { + Destination::Wallet => self.get_next_external_address(rpc)?, + Destination::Address(a) => a, + }; + if dest_addr.network != NETWORK { + panic!("wrong address network type (e.g. testnet, regtest)"); + } + let miner_fee = 500; //TODO do this calculation properly + + let mut output = Vec::::new(); + let total_input_value = unspent_inputs + .iter() + .fold(Amount::ZERO, |acc, u| acc + u.0.amount) + .as_sat(); + output.push(TxOut { + script_pubkey: dest_addr.script_pubkey(), + value: match send_amount { + SendAmount::Sweep => total_input_value - miner_fee, + SendAmount::Amount(a) => a.as_sat(), + }, + }); + if let SendAmount::Amount(amount) = send_amount { + output.push(TxOut { + script_pubkey: self.get_next_internal_addresses(rpc, 1)?[0].script_pubkey(), + value: total_input_value - amount.as_sat() - miner_fee, + }); + } + + let mut tx = Transaction { + input: tx_inputs, + output, + lock_time: 0, + version: 2, + }; + self.sign_transaction(&mut tx, &mut unspent_inputs.iter() + .map(|(lure, usi)| + match usi { + UTXOSpendInfo::SeedUTXO { path } => { + SignTransactionInputInfo::SeedCoin { + path: path.clone(), + input_value: lure.amount.as_sat() + } + } + UTXOSpendInfo::SwapCoin { multisig_redeemscript } => { + SignTransactionInputInfo::SwapCoin { + multisig_redeemscript: multisig_redeemscript.clone() + } + } + UTXOSpendInfo::TimelockContract { swapcoin_multisig_redeemscript } => { + panic!("not implemented yet"); + } + } + ) + ); + + println!("tx = {:?}", tx); + + Ok(tx) + } +} diff --git a/src/wallet_sync.rs b/src/wallet_sync.rs index a475bb8e..94a307e4 100644 --- a/src/wallet_sync.rs +++ b/src/wallet_sync.rs @@ -95,6 +95,19 @@ pub enum CoreAddressLabelType { } const WATCH_ONLY_SWAPCOIN_LABEL: &str = "watchonly_swapcoin_label"; +//data needed to find information in addition to ListUnspentResultEntry +//about a UTXO required to spend it +#[derive(Debug, Clone)] +pub enum UTXOSpendInfo { + SeedUTXO { path: String }, + SwapCoin { + multisig_redeemscript: Script, + }, + TimelockContract { + swapcoin_multisig_redeemscript: Script, + }, +} + pub enum SignTransactionInputInfo { SeedCoin { path: String, input_value: u64 }, SwapCoin { multisig_redeemscript: Script }, @@ -720,50 +733,70 @@ impl Wallet { .collect::>() } - fn is_utxo_ours_and_spendable( + fn is_utxo_ours_and_spendable_get_pointer( &self, u: &ListUnspentResultEntry, contract_scriptpubkeys_outgoing_swapcoins: &HashMap, - ) -> bool { + ) -> Option { if u.descriptor.is_none() { let swapcoin = contract_scriptpubkeys_outgoing_swapcoins.get(&u.script_pub_key); if swapcoin.is_none() { - return false; + return None; } let swapcoin = swapcoin.unwrap(); let timelock = read_locktime_from_contract(&swapcoin.contract_redeemscript); if timelock.is_none() { - return false; + return None; } let timelock = timelock.unwrap(); - return u.confirmations >= timelock.into(); + return if u.confirmations >= timelock.into() { + Some(UTXOSpendInfo::TimelockContract { + swapcoin_multisig_redeemscript: swapcoin.get_multisig_redeemscript(), + }) + } else { + None + }; } let descriptor = u.descriptor.as_ref().unwrap(); - if let Some(ret) = self.get_hd_path_from_descriptor(&descriptor) { + if let Some(ret) = get_hd_path_from_descriptor(&descriptor) { //utxo is in a hd wallet - let (fingerprint, _, _) = ret; + let (fingerprint, addr_type, index) = ret; let secp = Secp256k1::new(); let master_private_key = self .master_key .derive_priv(&secp, &DerivationPath::from_str(DERIVATION_PATH).unwrap()) .unwrap(); - fingerprint == master_private_key.fingerprint(&secp).to_string() + if fingerprint == master_private_key.fingerprint(&secp).to_string() { + Some(UTXOSpendInfo::SeedUTXO { + path: format!("m/{}/{}", addr_type, index) + }) + } else { + None + } } else { //utxo might be one of our swapcoins - self.find_incoming_swapcoin( - u.witness_script - .as_ref() - .unwrap_or(&Script::from(Vec::from_hex("").unwrap())), - ) - .map_or(false, |sc| sc.other_privkey.is_some()) + let found = self + .find_incoming_swapcoin( + u.witness_script + .as_ref() + .unwrap_or(&Script::from(Vec::from_hex("").unwrap())), + ) + .map_or(false, |sc| sc.other_privkey.is_some()) || self .find_outgoing_swapcoin( u.witness_script .as_ref() .unwrap_or(&Script::from(Vec::from_hex("").unwrap())), ) - .map_or(false, |sc| sc.hash_preimage.is_some()) + .map_or(false, |sc| sc.hash_preimage.is_some()); + if found { + Some(UTXOSpendInfo::SwapCoin { + multisig_redeemscript: u.witness_script.as_ref().unwrap().clone(), + }) + } else { + None + } } } @@ -778,7 +811,11 @@ impl Wallet { let utxos_to_lock = &all_unspents .into_iter() .filter(|u| { - !self.is_utxo_ours_and_spendable(u, &contract_scriptpubkeys_outgoing_swapcoins) + self.is_utxo_ours_and_spendable_get_pointer( + u, + &contract_scriptpubkeys_outgoing_swapcoins, + ) + .is_none() }) .map(|u| OutPoint { txid: u.txid, @@ -792,7 +829,7 @@ impl Wallet { pub fn list_unspent_from_wallet( &self, rpc: &Client, - ) -> Result, Error> { + ) -> Result, Error> { let contract_scriptpubkeys_outgoing_swapcoins = self.create_contract_scriptpubkey_swapcoin_hashmap(); rpc.call::("lockunspent", &[Value::Bool(true)]) @@ -800,11 +837,18 @@ impl Wallet { Ok(rpc .list_unspent(Some(0), Some(9999999), None, None, None)? .iter() - .filter(|u| { - self.is_utxo_ours_and_spendable(u, &contract_scriptpubkeys_outgoing_swapcoins) + .map(|u| { + ( + u, + self.is_utxo_ours_and_spendable_get_pointer( + u, + &contract_scriptpubkeys_outgoing_swapcoins, + ), + ) }) - .cloned() - .collect::>()) + .filter(|(_u, o_info)| o_info.is_some()) + .map(|(u, o_info)| (u.clone(), o_info.unwrap())) + .collect::>()) } pub fn find_incomplete_coinswaps( @@ -941,45 +985,16 @@ impl Wallet { )) } - // returns None if not a hd descriptor (but possibly a swapcoin (multisig) descriptor instead) - fn get_hd_path_from_descriptor<'a>(&self, descriptor: &'a str) -> Option<(&'a str, u32, i32)> { - //e.g - //"desc": "wpkh([a945b5ca/1/1]029b77637989868dcd502dbc07d6304dc2150301693ae84a60b379c3b696b289ad)#aq759em9", - let open = descriptor.find('['); - let close = descriptor.find(']'); - if open.is_none() || close.is_none() { - //unexpected, so printing it to stdout - println!("unknown descriptor = {}", descriptor); - return None; - } - let path = &descriptor[open.unwrap() + 1..close.unwrap()]; - let path_chunks: Vec<&str> = path.split('/').collect(); - if path_chunks.len() != 3 { - return None; - //unexpected descriptor = wsh(multi(2,[f67b69a3]0245ddf535f08a04fd86d794b76f8e3949f27f7ae039b641bf277c6a4552b4c387,[dbcd3c6e]030f781e9d2a6d3a823cee56be2d062ed4269f5a6294b20cb8817eb540c641d9a2))#8f70vn2q - } - let addr_type = path_chunks[1].parse::(); - if addr_type.is_err() { - log::debug!(target: "wallet", "unexpected address_type = {}", path); - return None; - } - let index = path_chunks[2].parse::(); - if index.is_err() { - return None; - } - Some((path_chunks[0], addr_type.unwrap(), index.unwrap())) - } - fn find_hd_next_index(&self, rpc: &Client, address_type: u32) -> Result { let mut max_index: i32 = -1; //TODO error handling let utxos = self.list_unspent_from_wallet(rpc)?; - for utxo in utxos { + for (utxo, _) in utxos { if utxo.descriptor.is_none() { continue; } let descriptor = utxo.descriptor.unwrap(); - let ret = self.get_hd_path_from_descriptor(&descriptor); + let ret = get_hd_path_from_descriptor(&descriptor); if ret.is_none() { continue; } @@ -1003,9 +1018,22 @@ impl Wallet { Ok(receive_address) } + pub fn get_next_internal_addresses( + &self, + rpc: &Client, + count: u32, + ) -> Result, Error> { + let next_change_addr_index = self.find_hd_next_index(rpc, 1)?; + let change_branch_descriptor = &self.get_hd_wallet_descriptors(rpc)?[1]; + Ok(rpc.derive_addresses( + change_branch_descriptor, + Some([next_change_addr_index, next_change_addr_index + count]), + )?) + } + pub fn get_offer_maxsize(&self, rpc: Arc) -> Result { let utxos = self.list_unspent_from_wallet(&rpc)?; - let balance: Amount = utxos.iter().fold(Amount::ZERO, |acc, u| acc + u.amount); + let balance: Amount = utxos.iter().fold(Amount::ZERO, |acc, u| acc + u.0.amount); Ok(balance.as_sat()) } @@ -1117,17 +1145,7 @@ impl Wallet { // walletcreatefundedpsbt to create funding txes that create change //this is the solution used right now - let next_change_addr_index = self.find_hd_next_index(rpc, 1)?; - let change_branch_descriptor = &self.get_hd_wallet_descriptors(rpc)?[1]; - let change_addresses = rpc - .derive_addresses( - change_branch_descriptor, - Some([ - next_change_addr_index, - next_change_addr_index + destinations.len() as u32, - ]), - ) - .unwrap(); + let change_addresses = self.get_next_internal_addresses(rpc, destinations.len() as u32)?; self.lock_all_nonwallet_unspents(rpc)?; let mut output_values = Wallet::generate_amount_fractions( @@ -1483,3 +1501,33 @@ fn convert_json_rpc_bitcoin_to_satoshis(amount: &Value) -> u64 { .parse::() .unwrap() } + +// returns None if not a hd descriptor (but possibly a swapcoin (multisig) descriptor instead) +fn get_hd_path_from_descriptor<'a>(descriptor: &'a str) -> Option<(&'a str, u32, i32)> { + //e.g + //"desc": "wpkh([a945b5ca/1/1]029b77637989868dcd502dbc07d6304dc2150301693ae84a60b379c3b696b289ad)#aq759em9", + let open = descriptor.find('['); + let close = descriptor.find(']'); + if open.is_none() || close.is_none() { + //unexpected, so printing it to stdout + println!("unknown descriptor = {}", descriptor); + return None; + } + let path = &descriptor[open.unwrap() + 1..close.unwrap()]; + let path_chunks: Vec<&str> = path.split('/').collect(); + if path_chunks.len() != 3 { + return None; + //unexpected descriptor = wsh(multi(2,[f67b69a3]0245ddf535f08a04fd86d794b76f8e3949f27f7ae039b641bf277c6a4552b4c387,[dbcd3c6e]030f781e9d2a6d3a823cee56be2d062ed4269f5a6294b20cb8817eb540c641d9a2))#8f70vn2q + } + let addr_type = path_chunks[1].parse::(); + if addr_type.is_err() { + log::debug!(target: "wallet", "unexpected address_type = {}", path); + return None; + } + let index = path_chunks[2].parse::(); + if index.is_err() { + return None; + } + Some((path_chunks[0], addr_type.unwrap(), index.unwrap())) +} +