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())) +} +