diff --git a/Cargo.lock b/Cargo.lock index 8887833b..b25b4b15 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + [[package]] name = "base64-compat" version = "1.0.0" @@ -234,6 +240,7 @@ name = "minisafe" version = "0.0.1" dependencies = [ "backtrace", + "base64", "dirs", "fern", "jsonrpc", diff --git a/Cargo.toml b/Cargo.toml index 850384a0..22ab5863 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,3 +53,6 @@ jsonrpc = "0.12" # Used for daemonization libc = "0.2" + +# Used for PSBTs +base64 = "0.13" diff --git a/doc/API.md b/doc/API.md index 4d4f6997..f126fffb 100644 --- a/doc/API.md +++ b/doc/API.md @@ -84,3 +84,49 @@ This command does not take any parameter for now. | `amount` | int | Value of the UTxO in satoshis | | `outpoint` | string | Transaction id and output index of this coin | | `block_height` | int or null | Blockheight the transaction was confirmed at, or `null` | + + +### `createspend` + +Create a transaction spending one or more of our coins. All coins must exist and not be spent. + +Will error if the given coins are not sufficient to cover the transaction cost at 90% (or more) of +the given feerate. If on the contrary the transaction is more than sufficiently funded, it will +create a change output when economically rationale to do so. + +This command will refuse to create any output worth less than 5k sats. + +#### Request + +| Field | Type | Description | +| -------------- | ----------------- | ----------------------------------------------------------------- | +| `outpoints` | list of string | List of the coins to be spent, as `txid:vout`. | +| `destinations` | object | Map from Bitcoin address to value | +| `feerate` | integer | Target feerate for the transaction, in satoshis per virtual byte. | + +#### Response + +| Field | Type | Description | +| -------------- | --------- | ---------------------------------------------------- | +| `psbt` | string | PSBT of the spending transaction, encoded as base64. | + + +### `updatespend` + +Store the PSBT of a Spend transaction in database, updating it if it already exists. + +Will merge the partial signatures for all inputs if a PSBT for a transaction with the same txid +exists in DB. + +#### Request + +| Field | Type | Description | +| --------- | ------ | ------------------------------------------- | +| `psbt` | string | Base64-encoded PSBT of a Spend transaction. | + +#### Response + +This command does not return anything for now. + +| Field | Type | Description | +| -------------- | --------- | ---------------------------------------------------- | diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 0c6c0e69..4a364f19 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -9,11 +9,161 @@ use crate::{ database::{Coin, DatabaseInterface}, descriptors, DaemonControl, VERSION, }; -use utils::{deser_amount_from_sats, ser_amount}; +use utils::{deser_amount_from_sats, deser_psbt_base64, ser_amount, ser_base64}; -use miniscript::bitcoin; +use std::{ + collections::{BTreeMap, HashMap}, + convert::TryInto, + fmt, +}; + +use miniscript::bitcoin::{ + self, + util::bip32, + util::psbt::{self, Input as PsbtIn, Output as PsbtOut, PartiallySignedTransaction as Psbt}, +}; use serde::{Deserialize, Serialize}; +const WITNESS_FACTOR: usize = 4; + +// We would never create a transaction with an output worth less than this. +// That's 1$ at 20_000$ per BTC. +const DUST_OUTPUT_SATS: u64 = 5_000; + +// Assume that paying more than 1BTC in fee is a bug. +const MAX_FEE: u64 = bitcoin::blockdata::constants::COIN_VALUE; + +// Assume that paying more than 1000sat/vb in feerate is a bug. +const MAX_FEERATE: u64 = bitcoin::blockdata::constants::COIN_VALUE; + +#[derive(Debug, Clone, PartialEq)] +pub enum CommandError { + NoOutpoint, + NoDestination, + InvalidFeerate(/* sats/vb */ u64), + UnknownOutpoint(bitcoin::OutPoint), + AlreadySpent(bitcoin::OutPoint), + InvalidOutputValue(bitcoin::Amount), + InsufficientFunds( + /* in value */ bitcoin::Amount, + /* out value */ bitcoin::Amount, + /* target feerate */ u64, + ), + SanityCheckFailure(Psbt), +} + +impl fmt::Display for CommandError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::NoOutpoint => write!(f, "No provided outpoint. Need at least one."), + Self::NoDestination => write!(f, "No provided destination. Need at least one."), + Self::InvalidFeerate(sats_vb) => write!(f, "Invalid feerate: {} sats/vb.", sats_vb), + Self::AlreadySpent(op) => write!(f, "Coin at '{}' is already spent.", op), + Self::UnknownOutpoint(op) => write!(f, "Unknown outpoint '{}'.", op), + Self::InvalidOutputValue(amount) => write!(f, "Invalid output value '{}'.", amount), + Self::InsufficientFunds(in_val, out_val, feerate) => write!( + f, + "Cannot create a {} sat/vb transaction with input value {} and output value {}", + feerate, in_val, out_val + ), + Self::SanityCheckFailure(psbt) => write!( + f, + "BUG! Please report this. Failed sanity checks for PSBT '{:?}'.", + psbt + ), + } + } +} + +impl std::error::Error for CommandError {} + +// Sanity check the value of a transaction output. +fn check_output_value(value: bitcoin::Amount) -> Result<(), CommandError> { + // NOTE: the network parameter isn't used upstream + if value.as_sat() > bitcoin::blockdata::constants::max_money(bitcoin::Network::Bitcoin) + || value.as_sat() < DUST_OUTPUT_SATS + { + Err(CommandError::InvalidOutputValue(value)) + } else { + Ok(()) + } +} + +// Apply some sanity checks on a created transaction's PSBT. +// TODO: add more sanity checks from revault_tx +fn sanity_check_psbt(psbt: &Psbt) -> Result<(), CommandError> { + let tx = &psbt.global.unsigned_tx; + + // Must have as many in/out in the PSBT and Bitcoin tx. + if psbt.inputs.len() != tx.input.len() || psbt.outputs.len() != tx.output.len() { + return Err(CommandError::SanityCheckFailure(psbt.clone())); + } + + // Compute the transaction input value, checking all PSBT inputs have the derivation + // index set for signing devices to recognize them as ours. + let mut value_in = 0; + for psbtin in psbt.inputs.iter() { + if psbtin.bip32_derivation.is_empty() { + return Err(CommandError::SanityCheckFailure(psbt.clone())); + } + value_in += psbtin + .witness_utxo + .as_ref() + .ok_or(CommandError::SanityCheckFailure(psbt.clone()))? + .value; + } + + // Compute the output value and check the absolute fee isn't insane. + let value_out: u64 = tx.output.iter().map(|o| o.value).sum(); + let abs_fee = value_in + .checked_sub(value_out) + .ok_or(CommandError::SanityCheckFailure(psbt.clone()))?; + if abs_fee > MAX_FEE { + return Err(CommandError::SanityCheckFailure(psbt.clone())); + } + + // Check the feerate isn't insane. + let tx_vb: u64 = tx_vbytes(&tx); + let feerate_sats_vb = abs_fee + .checked_div(tx_vb) + .ok_or(CommandError::SanityCheckFailure(psbt.clone()))?; + if feerate_sats_vb > MAX_FEERATE || feerate_sats_vb < 1 { + return Err(CommandError::SanityCheckFailure(psbt.clone())); + } + + Ok(()) +} + +// Get the maximum satisfaction size in vbytes for this descriptor +fn desc_sat_vb(desc: &descriptors::DerivedInheritanceDescriptor) -> u64 { + desc.max_sat_weight() + .checked_div(WITNESS_FACTOR) + .unwrap() + .try_into() + .unwrap() +} + +// Get the virtual size of this transaction +fn tx_vbytes(tx: &bitcoin::Transaction) -> u64 { + tx.get_weight() + .checked_div(WITNESS_FACTOR) + .unwrap() + .try_into() + .unwrap() +} + +// Get the size of a type that can be serialized (txos, transactions, ..) +fn serializable_size(t: &T) -> u64 { + bitcoin::consensus::serialize(t).len().try_into().unwrap() +} + +impl DaemonControl { + // Get the descriptor at this derivation index + fn derived_desc(&self, index: bip32::ChildNumber) -> descriptors::DerivedInheritanceDescriptor { + self.config.main_descriptor.derive(index, &self.secp) + } +} + impl DaemonControl { /// Get information about the current state of the daemon pub fn get_info(&self) -> GetInfoResult { @@ -66,6 +216,208 @@ impl DaemonControl { .collect(); ListCoinsResult { coins } } + + pub fn create_spend( + &self, + coins_outpoints: &[bitcoin::OutPoint], + destinations: &HashMap, + feerate_vb: u64, + ) -> Result { + if coins_outpoints.is_empty() { + return Err(CommandError::NoOutpoint); + } + if destinations.is_empty() { + return Err(CommandError::NoDestination); + } + if feerate_vb < 1 { + return Err(CommandError::InvalidFeerate(feerate_vb)); + } + let mut db_conn = self.db.connection(); + + // Iterate through given outpoints to fetch the coins (hence checking there existence + // at the same time). We checked there is at least one, therefore after this loop the + // list of coins is not empty. + // While doing so, we record the total input value of the transaction to later compute + // fees, and add necessary information to the PSBT inputs. + let mut in_value = bitcoin::Amount::from_sat(0); + let mut sat_vb = 0; + let mut txins = Vec::with_capacity(destinations.len()); + let mut psbt_ins = Vec::with_capacity(destinations.len()); + let coins = db_conn.coins_by_outpoints(coins_outpoints); + for op in coins_outpoints { + let coin = coins.get(op).ok_or(CommandError::UnknownOutpoint(*op))?; + if coin.is_spent() { + return Err(CommandError::AlreadySpent(*op)); + } + in_value += coin.amount; + txins.push(bitcoin::TxIn { + previous_output: *op, + // TODO: once we move to Taproot, anti-fee-sniping using nSequence + ..bitcoin::TxIn::default() + }); + + let coin_desc = self.derived_desc(coin.derivation_index); + sat_vb += desc_sat_vb(&coin_desc); + let witness_script = Some(coin_desc.witness_script()); + let witness_utxo = Some(bitcoin::TxOut { + value: coin.amount.as_sat(), + script_pubkey: coin_desc.script_pubkey(), + }); + let bip32_derivation = coin_desc.bip32_derivations(); + psbt_ins.push(PsbtIn { + witness_script, + witness_utxo, + bip32_derivation, + ..PsbtIn::default() + }); + } + + // Add the destinations outputs to the transaction and PSBT. At the same time record the + // total output value to later compute fees, and sanity check each output's value. + let mut out_value = bitcoin::Amount::from_sat(0); + let mut txouts = Vec::with_capacity(destinations.len()); + let mut psbt_outs = Vec::with_capacity(destinations.len()); + for (address, value_sat) in destinations { + let amount = bitcoin::Amount::from_sat(*value_sat); + check_output_value(amount)?; + out_value = out_value.checked_add(amount).unwrap(); + + txouts.push(bitcoin::TxOut { + value: amount.as_sat(), + script_pubkey: address.script_pubkey(), + }); + // TODO: if it's an address of ours, signal it as change to signing devices by adding + // the BIP32 derivation path to the PSBT input. + psbt_outs.push(PsbtOut::default()); + } + + // Now create the transaction, compute its fees and already sanity check if its feerate + // isn't much less than what was asked (and obviously that fees aren't negative). + let mut tx = bitcoin::Transaction { + version: 2, + lock_time: 0, // TODO: randomized anti fee sniping + input: txins, + output: txouts, + }; + let nochange_vb = tx_vbytes(&tx) + sat_vb; + let absolute_fee = + in_value + .checked_sub(out_value) + .ok_or(CommandError::InsufficientFunds( + in_value, out_value, feerate_vb, + ))?; + let nochange_feerate_vb = absolute_fee.as_sat().checked_div(nochange_vb).unwrap(); + if nochange_feerate_vb.checked_mul(10).unwrap() < feerate_vb.checked_mul(9).unwrap() { + return Err(CommandError::InsufficientFunds( + in_value, out_value, feerate_vb, + )); + } + + // If necessary, add a change output. The computation here is a bit convoluted: we infer + // the needed change value from the target feerate and the size of the transaction *with + // an added output* (for the change). + if nochange_feerate_vb > feerate_vb { + // Get the change address to create a dummy change txo. + // TODO: decent change management + let first_coin = coins + .get(&coins_outpoints.get(0).expect("We checked it wasn't empty")) + .expect("We checked they were all present"); + let coin_desc = self.derived_desc(first_coin.derivation_index); + let mut change_txo = bitcoin::TxOut { + value: std::u64::MAX, + script_pubkey: coin_desc.script_pubkey(), + }; + // Serialized size is equal to the virtual size for an output. + let change_vb: u64 = serializable_size(&change_txo); + // We assume the added output does not increase the size of the varint for + // the output count. + let with_change_vb = nochange_vb.checked_add(change_vb).unwrap(); + let with_change_feerate_vb = absolute_fee.as_sat().checked_div(with_change_vb).unwrap(); + + if with_change_feerate_vb > feerate_vb { + let target_fee = with_change_vb.checked_mul(feerate_vb).unwrap(); + let change_amount = absolute_fee + .checked_sub(bitcoin::Amount::from_sat(target_fee)) + .unwrap(); + if change_amount.as_sat() >= DUST_OUTPUT_SATS { + check_output_value(change_amount)?; + + // TODO: shuffle once we have Taproot + change_txo.value = change_amount.as_sat(); + tx.output.push(change_txo); + psbt_outs.push(PsbtOut::default()); + } + } + } + + let psbt = Psbt { + global: psbt::Global { + unsigned_tx: tx, + version: 0, + xpub: BTreeMap::new(), + proprietary: BTreeMap::new(), + unknown: BTreeMap::new(), + }, + inputs: psbt_ins, + outputs: psbt_outs, + }; + sanity_check_psbt(&psbt)?; + // TODO: maybe check for common standardness rules (max size, ..)? + + Ok(CreateSpendResult { psbt }) + } + + pub fn update_spend(&self, mut psbt: Psbt) -> Result<(), CommandError> { + let mut db_conn = self.db.connection(); + let tx = &psbt.global.unsigned_tx; + + // If the transaction already exists in DB, merge the signatures for each input on a best + // effort basis. + // We work on the newly provided PSBT, in case its content was updated. + let txid = tx.txid(); + if let Some(db_psbt) = db_conn.spend_tx(&txid) { + let db_tx = db_psbt.global.unsigned_tx; + for i in 0..db_tx.input.len() { + if tx + .input + .get(i) + .map(|tx_in| tx_in.previous_output == db_tx.input[i].previous_output) + != Some(true) + { + continue; + } + let psbtin = match psbt.inputs.get_mut(i) { + Some(psbtin) => psbtin, + None => continue, + }; + let db_psbtin = match db_psbt.inputs.get(i) { + Some(db_psbtin) => db_psbtin, + None => continue, + }; + psbtin + .partial_sigs + .extend(db_psbtin.partial_sigs.clone().into_iter()); + } + } else { + // If the transaction doesn't exist in DB already, sanity check its inputs. + // FIXME: should we allow for external inputs? + let outpoints: Vec = + tx.input.iter().map(|txin| txin.previous_output).collect(); + let coins = db_conn.coins_by_outpoints(&outpoints); + if coins.len() != outpoints.len() { + for op in outpoints { + if coins.get(&op).is_none() { + return Err(CommandError::UnknownOutpoint(op)); + } + } + } + } + + // Finally, insert (or update) the PSBT in database. + db_conn.store_spend(&psbt); + + Ok(()) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -104,10 +456,17 @@ pub struct ListCoinsResult { pub coins: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct CreateSpendResult { + #[serde(serialize_with = "ser_base64", deserialize_with = "deser_psbt_base64")] + pub psbt: Psbt, +} + #[cfg(test)] mod tests { use super::*; use crate::testutils::*; + use bitcoin::hashes::hex::FromHex; use std::str::FromStr; #[test] @@ -138,4 +497,220 @@ mod tests { ms.shutdown(); } + + #[test] + fn create_spend() { + let ms = DummyMinisafe::new(); + let control = &ms.handle.control; + + // Arguments sanity checking + let dummy_op = bitcoin::OutPoint::from_str( + "3753a1d74c0af8dd0a0f3b763c14faf3bd9ed03cbdf33337a074fb0e9f6c7810:0", + ) + .unwrap(); + let dummy_addr = + bitcoin::Address::from_str("bc1qnsexk3gnuyayu92fc3tczvc7k62u22a22ua2kv").unwrap(); + let dummy_value = 10_000; + let mut destinations: HashMap = [(dummy_addr.clone(), dummy_value)] + .iter() + .cloned() + .collect(); + assert_eq!( + control.create_spend(&[], &destinations, 1), + Err(CommandError::NoOutpoint) + ); + assert_eq!( + control.create_spend(&[dummy_op], &HashMap::new(), 1), + Err(CommandError::NoDestination) + ); + assert_eq!( + control.create_spend(&[dummy_op], &destinations, 0), + Err(CommandError::InvalidFeerate(0)) + ); + + // The coin doesn't exist. If we create a new unspent one at this outpoint with a much + // higher value, we'll get a Spend transaction with a change output. + assert_eq!( + control.create_spend(&[dummy_op], &destinations, 1), + Err(CommandError::UnknownOutpoint(dummy_op)) + ); + let mut db_conn = control.db().lock().unwrap().connection(); + db_conn.new_unspent_coins(&[Coin { + outpoint: dummy_op, + block_height: None, + amount: bitcoin::Amount::from_sat(100_000), + derivation_index: bip32::ChildNumber::from(13), + spend_txid: None, + }]); + let res = control.create_spend(&[dummy_op], &destinations, 1).unwrap(); + let tx = res.psbt.global.unsigned_tx; + assert_eq!(tx.input.len(), 1); + assert_eq!(tx.input[0].previous_output, dummy_op); + assert_eq!(tx.output.len(), 2); + assert_eq!(tx.output[0].script_pubkey, dummy_addr.script_pubkey()); + assert_eq!(tx.output[0].value, dummy_value); + + // Transaction is 1 in (P2WSH satisfaction), 2 outs. At 1sat/vb, it's 170 sats fees. + // At 2sats/vb, it's twice that. + assert_eq!(tx.output[1].value, 89_830); + let res = control.create_spend(&[dummy_op], &destinations, 2).unwrap(); + let tx = res.psbt.global.unsigned_tx; + assert_eq!(tx.output[1].value, 89_660); + + // If we ask for a too high feerate, or a too large/too small output, it'll fail. + assert_eq!( + control.create_spend(&[dummy_op], &destinations, 10_000), + Err(CommandError::InsufficientFunds( + bitcoin::Amount::from_sat(100_000), + bitcoin::Amount::from_sat(10_000), + 10_000 + )) + ); + *destinations.get_mut(&dummy_addr).unwrap() = 100_001; + assert_eq!( + control.create_spend(&[dummy_op], &destinations, 1), + Err(CommandError::InsufficientFunds( + bitcoin::Amount::from_sat(100_000), + bitcoin::Amount::from_sat(100_001), + 1 + )) + ); + *destinations.get_mut(&dummy_addr).unwrap() = 4_500; + assert_eq!( + control.create_spend(&[dummy_op], &destinations, 1), + Err(CommandError::InvalidOutputValue(bitcoin::Amount::from_sat( + 4_500 + ))) + ); + + // If we ask for a large, but valid, output we won't get a change output. 95_000 because we + // won't create an output lower than 5k sats. + *destinations.get_mut(&dummy_addr).unwrap() = 95_000; + let res = control.create_spend(&[dummy_op], &destinations, 1).unwrap(); + let tx = res.psbt.global.unsigned_tx; + assert_eq!(tx.input.len(), 1); + assert_eq!(tx.input[0].previous_output, dummy_op); + assert_eq!(tx.output.len(), 1); + assert_eq!(tx.output[0].script_pubkey, dummy_addr.script_pubkey()); + assert_eq!(tx.output[0].value, 95_000); + + // Now if we mark the coin as spent, we won't create another Spend transaction containing + // it. + db_conn.spend_coins(&[( + dummy_op, + bitcoin::Txid::from_str( + "ef78f79ba747813887747cf8582897a48f1a09f1ca04d2cd3d6fcfdcbb5e0797", + ) + .unwrap(), + )]); + assert_eq!( + control.create_spend(&[dummy_op], &destinations, 1), + Err(CommandError::AlreadySpent(dummy_op)) + ); + + ms.shutdown(); + } + + #[test] + fn update_spend() { + let ms = DummyMinisafe::new(); + let control = &ms.handle.control; + let mut db_conn = control.db().lock().unwrap().connection(); + + // Add two (unconfirmed) coins in DB + let dummy_op_a = bitcoin::OutPoint::from_str( + "3753a1d74c0af8dd0a0f3b763c14faf3bd9ed03cbdf33337a074fb0e9f6c7810:0", + ) + .unwrap(); + let dummy_op_b = bitcoin::OutPoint::from_str( + "4753a1d74c0af8dd0a0f3b763c14faf3bd9ed03cbdf33337a074fb0e9f6c7810:1", + ) + .unwrap(); + db_conn.new_unspent_coins(&[ + Coin { + outpoint: dummy_op_a, + block_height: None, + amount: bitcoin::Amount::from_sat(100_000), + derivation_index: bip32::ChildNumber::from(13), + spend_txid: None, + }, + Coin { + outpoint: dummy_op_b, + block_height: None, + amount: bitcoin::Amount::from_sat(115_680), + derivation_index: bip32::ChildNumber::from(34), + spend_txid: None, + }, + ]); + + // Now create three transactions spending those coins differently + let dummy_addr_a = + bitcoin::Address::from_str("bc1qnsexk3gnuyayu92fc3tczvc7k62u22a22ua2kv").unwrap(); + let dummy_addr_b = + bitcoin::Address::from_str("bc1q39srgatmkp6k2ne3l52yhkjprdvunvspqydmkx").unwrap(); + let dummy_value_a = 50_000; + let dummy_value_b = 60_000; + let destinations_a: HashMap = + [(dummy_addr_a.clone(), dummy_value_a)] + .iter() + .cloned() + .collect(); + let destinations_b: HashMap = + [(dummy_addr_b.clone(), dummy_value_b)] + .iter() + .cloned() + .collect(); + let destinations_c: HashMap = [ + (dummy_addr_a.clone(), dummy_value_a), + (dummy_addr_b.clone(), dummy_value_b), + ] + .iter() + .cloned() + .collect(); + let mut psbt_a = control + .create_spend(&[dummy_op_a], &destinations_a, 1) + .unwrap() + .psbt; + let txid_a = psbt_a.global.unsigned_tx.txid(); + let psbt_b = control + .create_spend(&[dummy_op_b], &destinations_b, 10) + .unwrap() + .psbt; + let txid_b = psbt_b.global.unsigned_tx.txid(); + let psbt_c = control + .create_spend(&[dummy_op_a, dummy_op_b], &destinations_c, 100) + .unwrap() + .psbt; + let txid_c = psbt_c.global.unsigned_tx.txid(); + + // We can store and query them all + control.update_spend(psbt_a.clone()).unwrap(); + assert_eq!(db_conn.spend_tx(&txid_a).unwrap(), psbt_a); + control.update_spend(psbt_b.clone()).unwrap(); + assert_eq!(db_conn.spend_tx(&txid_b).unwrap(), psbt_b); + control.update_spend(psbt_c.clone()).unwrap(); + assert_eq!(db_conn.spend_tx(&txid_c).unwrap(), psbt_c); + + // As well as update them, with or without new signatures + psbt_a.inputs[0].partial_sigs.insert(bitcoin::PublicKey::from_str("023a664c5617412f0b292665b1fd9d766456a7a3b1614c7e7c5f411200ff1958ef").unwrap(), Vec::::from_hex("304402204004fcdbb9c0d0cbf585f58cee34dccb012efbd8fc2b0d5e97760045ae35803802201a0bd7ec2383e0b93748abc9946c8e17a8312e314dab85982aeba650e738cbf401").unwrap()); + control.update_spend(psbt_a.clone()).unwrap(); + assert_eq!(db_conn.spend_tx(&txid_a).unwrap(), psbt_a); + control.update_spend(psbt_b.clone()).unwrap(); + assert_eq!(db_conn.spend_tx(&txid_b).unwrap(), psbt_b); + control.update_spend(psbt_c.clone()).unwrap(); + assert_eq!(db_conn.spend_tx(&txid_c).unwrap(), psbt_c); + + // We can't store a PSBT spending an external coin + let external_op = bitcoin::OutPoint::from_str( + "8753a1d74c0af8dd0a0f3b763c14faf3bd9ed03cbdf33337a074fb0e9f6c7810:2", + ) + .unwrap(); + psbt_a.global.unsigned_tx.input[0].previous_output = external_op; + assert_eq!( + control.update_spend(psbt_a), + Err(CommandError::UnknownOutpoint(external_op)) + ); + + ms.shutdown(); + } } diff --git a/src/commands/utils.rs b/src/commands/utils.rs index 807aef6d..a34975b3 100644 --- a/src/commands/utils.rs +++ b/src/commands/utils.rs @@ -1,5 +1,5 @@ -use miniscript::bitcoin; -use serde::{Deserialize, Deserializer, Serializer}; +use miniscript::bitcoin::{self, consensus, util::psbt::PartiallySignedTransaction as Psbt}; +use serde::{de, Deserialize, Deserializer, Serializer}; /// Serialize an amount as sats pub fn ser_amount(amount: &bitcoin::Amount, s: S) -> Result { @@ -14,3 +14,21 @@ where let a = u64::deserialize(deserializer)?; Ok(bitcoin::Amount::from_sat(a)) } + +pub fn ser_base64(t: T, s: S) -> Result +where + S: Serializer, + T: consensus::Encodable, +{ + s.serialize_str(&base64::encode(consensus::serialize(&t))) +} + +pub fn deser_psbt_base64<'de, D>(d: D) -> Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(d)?; + let s = base64::decode(&s).map_err(de::Error::custom)?; + let psbt = consensus::deserialize(&s).map_err(de::Error::custom)?; + Ok(psbt) +} diff --git a/src/database/mod.rs b/src/database/mod.rs index 68f5d105..2bf5214b 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -13,7 +13,10 @@ use crate::{ use std::{collections::HashMap, sync}; -use miniscript::bitcoin::{self, secp256k1, util::bip32}; +use miniscript::bitcoin::{ + self, secp256k1, + util::{bip32, psbt::PartiallySignedTransaction as Psbt}, +}; pub trait DatabaseInterface: Send { fn connection(&self) -> Box; @@ -62,6 +65,44 @@ pub trait DatabaseConnection { /// Mark a set of coins as being spent by a specified txid. fn spend_coins<'a>(&mut self, outpoints: &[(bitcoin::OutPoint, bitcoin::Txid)]); + + /// Get specific coins from the database. + fn coins_by_outpoints( + &mut self, + outpoints: &[bitcoin::OutPoint], + ) -> HashMap; + + fn spend_tx(&mut self, txid: &bitcoin::Txid) -> Option; + + /// Insert a new Spend transaction or replace an existing one. + fn store_spend(&mut self, psbt: &Psbt); +} + +// FIXME: if possible, avoid reallocating. +fn db_coins_into_coins(db_coins: Vec) -> HashMap { + db_coins + .into_iter() + .map(|db_coin| { + let DbCoin { + outpoint, + block_height, + amount, + derivation_index, + spend_txid, + .. + } = db_coin; + ( + outpoint, + Coin { + outpoint, + block_height, + amount, + derivation_index, + spend_txid, + }, + ) + }) + .collect() } impl DatabaseConnection for SqliteConn { @@ -93,30 +134,7 @@ impl DatabaseConnection for SqliteConn { } fn unspent_coins(&mut self) -> HashMap { - // FIXME: if possible, avoid reallocating. - self.unspent_coins() - .into_iter() - .map(|db_coin| { - let DbCoin { - outpoint, - block_height, - amount, - derivation_index, - spend_txid, - .. - } = db_coin; - ( - outpoint, - Coin { - outpoint, - block_height, - amount, - derivation_index, - spend_txid, - }, - ) - }) - .collect() + db_coins_into_coins(self.unspent_coins()) } fn new_unspent_coins<'a>(&mut self, coins: &[Coin]) { @@ -138,6 +156,21 @@ impl DatabaseConnection for SqliteConn { self.db_address(address) .map(|db_addr| db_addr.derivation_index) } + + fn coins_by_outpoints( + &mut self, + outpoints: &[bitcoin::OutPoint], + ) -> HashMap { + db_coins_into_coins(self.db_coins(outpoints)) + } + + fn spend_tx(&mut self, txid: &bitcoin::Txid) -> Option { + self.db_spend(txid).map(|db_spend| db_spend.psbt) + } + + fn store_spend(&mut self, psbt: &Psbt) { + self.store_spend(psbt) + } } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src/database/sqlite/mod.rs b/src/database/sqlite/mod.rs index 8406ff37..55b76171 100644 --- a/src/database/sqlite/mod.rs +++ b/src/database/sqlite/mod.rs @@ -13,7 +13,7 @@ use crate::{ bitcoin::BlockChainTip, database::{ sqlite::{ - schema::{DbAddress, DbCoin, DbTip, DbWallet}, + schema::{DbAddress, DbCoin, DbSpendTransaction, DbTip, DbWallet}, utils::{create_fresh_db, db_exec, db_query, db_tx_query, LOOK_AHEAD_LIMIT}, }, Coin, @@ -23,7 +23,10 @@ use crate::{ use std::{convert::TryInto, fmt, io, path}; -use miniscript::bitcoin::{self, secp256k1}; +use miniscript::bitcoin::{ + self, consensus::encode, hashes::hex::ToHex, secp256k1, + util::psbt::PartiallySignedTransaction as Psbt, +}; const DB_VERSION: i64 = 0; @@ -330,6 +333,55 @@ impl SqliteConn { .expect("Db must not fail") .pop() } + + pub fn db_coins(&mut self, outpoints: &[bitcoin::OutPoint]) -> Vec { + // SELECT * FROM coins WHERE (txid, vout) IN ((txidA, voutA), (txidB, voutB)); + let mut query = "SELECT * FROM coins WHERE (txid, vout) IN (VALUES ".to_string(); + for (i, outpoint) in outpoints.iter().enumerate() { + // NOTE: the txid is not stored as little-endian. Convert it to vec first. + query += &format!( + "(x'{}', {})", + &outpoint.txid.to_vec().to_hex(), + outpoint.vout + ); + if i != outpoints.len() - 1 { + query += ", "; + } + } + query += ")"; + + db_query(&mut self.conn, &query, rusqlite::params![], |row| { + row.try_into() + }) + .expect("Db must not fail") + } + + pub fn db_spend(&mut self, txid: &bitcoin::Txid) -> Option { + db_query( + &mut self.conn, + "SELECT * FROM spend_transactions WHERE txid = ?1", + rusqlite::params![txid.to_vec()], + |row| row.try_into(), + ) + .expect("Db must not fail") + .pop() + } + + /// Insert a new Spend transaction or replace an existing one. + pub fn store_spend(&mut self, psbt: &Psbt) { + let txid = psbt.global.unsigned_tx.txid().to_vec(); + let psbt = encode::serialize(psbt); + + db_exec(&mut self.conn, |db_tx| { + db_tx.execute( + "INSERT into spend_transactions (psbt, txid) VALUES (?1, ?2) \ + ON CONFLICT DO UPDATE SET psbt=excluded.psbt", + rusqlite::params![psbt, txid], + )?; + Ok(()) + }) + .expect("Db must not fail"); + } } #[cfg(test)] @@ -462,6 +514,11 @@ mod tests { conn.new_unspent_coins(&[coin_a.clone()]); // On 1.48, arrays aren't IntoIterator assert_eq!(conn.unspent_coins()[0].outpoint, coin_a.outpoint); + // We can query it by its outpoint + let coins = conn.db_coins(&[coin_a.outpoint]); + assert_eq!(coins.len(), 1); + assert_eq!(coins[0].outpoint, coin_a.outpoint); + // Add a second one, we'll get both. let coin_b = Coin { outpoint: bitcoin::OutPoint::from_str( @@ -482,6 +539,24 @@ mod tests { assert!(outpoints.contains(&coin_a.outpoint)); assert!(outpoints.contains(&coin_b.outpoint)); + // We can query both by their outpoints + let coins = conn.db_coins(&[coin_a.outpoint]); + assert_eq!(coins.len(), 1); + assert_eq!(coins[0].outpoint, coin_a.outpoint); + let coins = conn.db_coins(&[coin_b.outpoint]); + assert_eq!(coins.len(), 1); + assert_eq!(coins[0].outpoint, coin_b.outpoint); + let coins = conn.db_coins(&[coin_a.outpoint, coin_b.outpoint]); + assert_eq!(coins.len(), 2); + assert!(coins + .iter() + .find(|c| c.outpoint == coin_a.outpoint) + .is_some()); + assert!(coins + .iter() + .find(|c| c.outpoint == coin_b.outpoint) + .is_some()); + // Now if we confirm one, it'll be marked as such. let height = 174500; conn.confirm_coins(&[(coin_a.outpoint, height)]); @@ -501,6 +576,10 @@ mod tests { .collect(); assert!(!outpoints.contains(&coin_a.outpoint)); assert!(outpoints.contains(&coin_b.outpoint)); + + // Both are still in DB + let coins = conn.db_coins(&[coin_a.outpoint, coin_b.outpoint]); + assert_eq!(coins.len(), 2); } fs::remove_dir_all(&tmp_dir).unwrap(); diff --git a/src/database/sqlite/schema.rs b/src/database/sqlite/schema.rs index 355728fe..fe6b662f 100644 --- a/src/database/sqlite/schema.rs +++ b/src/database/sqlite/schema.rs @@ -2,7 +2,11 @@ use crate::descriptors::InheritanceDescriptor; use std::{convert::TryFrom, str::FromStr}; -use miniscript::bitcoin::{self, consensus::encode, util::bip32}; +use miniscript::bitcoin::{ + self, + consensus::encode, + util::{bip32, psbt::PartiallySignedTransaction as Psbt}, +}; pub const SCHEMA: &str = "\ CREATE TABLE version ( @@ -49,6 +53,13 @@ CREATE TABLE addresses ( address TEXT NOT NULL UNIQUE, derivation_index INTEGER NOT NULL UNIQUE ); + +/* Transactions we created that spend some of our coins. */ +CREATE TABLE spend_transactions ( + id INTEGER PRIMARY KEY NOT NULL, + psbt BLOB UNIQUE NOT NULL, + txid BLOB UNIQUE NOT NULL +); "; /// A row in the "tip" table. @@ -186,3 +197,28 @@ impl TryFrom<&rusqlite::Row<'_>> for DbAddress { }) } } + +/// A row in the "spend_transactions" table +#[derive(Debug, Clone, PartialEq)] +pub struct DbSpendTransaction { + pub id: i64, + pub psbt: Psbt, + pub txid: bitcoin::Txid, +} + +impl TryFrom<&rusqlite::Row<'_>> for DbSpendTransaction { + type Error = rusqlite::Error; + + fn try_from(row: &rusqlite::Row) -> Result { + let id: i64 = row.get(0)?; + + let psbt: Vec = row.get(1)?; + let psbt: Psbt = encode::deserialize(&psbt).expect("We only store valid PSBTs"); + + let txid: Vec = row.get(2)?; + let txid: bitcoin::Txid = encode::deserialize(&txid).expect("We only store valid txids"); + assert_eq!(txid, psbt.global.unsigned_tx.txid()); + + Ok(DbSpendTransaction { id, psbt, txid }) + } +} diff --git a/src/descriptors.rs b/src/descriptors.rs index d58e5dbe..eb258ff1 100644 --- a/src/descriptors.rs +++ b/src/descriptors.rs @@ -11,7 +11,7 @@ use miniscript::{ MiniscriptKey, ScriptContext, ToPublicKey, TranslatePk2, }; -use std::{error, fmt, io::Write, str, sync}; +use std::{collections::BTreeMap, error, fmt, io::Write, str, sync}; use serde::{Deserialize, Serialize}; @@ -374,12 +374,56 @@ impl InheritanceDescriptor { } } +/// Map of a raw public key to the xpub used to derive it and its derivation path +pub type Bip32Deriv = BTreeMap; + impl DerivedInheritanceDescriptor { pub fn address(&self, network: bitcoin::Network) -> bitcoin::Address { self.0 .address(network) .expect("A P2WSH always has an address") } + + pub fn script_pubkey(&self) -> bitcoin::Script { + self.0.script_pubkey() + } + + pub fn witness_script(&self) -> bitcoin::Script { + self.0.explicit_script() + } + + pub fn bip32_derivations(&self) -> Bip32Deriv { + let ms = match self.0 { + descriptor::Descriptor::Wsh(ref wsh) => match wsh.as_inner() { + descriptor::WshInner::Ms(ms) => ms, + descriptor::WshInner::SortedMulti(_) => { + unreachable!("None of our descriptors is a sorted multi") + } + }, + _ => unreachable!("All our descriptors are always P2WSH"), + }; + + // For DerivedPublicKey, Pk::Hash == Self. + ms.iter_pk_pkh() + .map(|pkpkh| match pkpkh { + PkPkh::PlainPubkey(pk) => pk, + PkPkh::HashedPubkey(pkh) => pkh, + }) + .map(|k| { + ( + k.key, + (k.origin.0, bip32::DerivationPath::from(&[k.origin.1][..])), + ) + }) + .collect() + } + + /// Get the maximum size in WU of a satisfaction for this descriptor. + pub fn max_sat_weight(&self) -> usize { + self.0 + .max_satisfaction_weight() + .expect("Cannot fail for P2WSH") + } } #[cfg(test)] @@ -426,6 +470,12 @@ mod tests { "bc1qvjzcg25nsxmfccct0txjvljxjwn68htkrw57jqmjhfzvhyd2z4msc74w65", der_desc.address(bitcoin::Network::Bitcoin).to_string() ); + + // Sanity check we can call the methods on the derived desc + der_desc.script_pubkey(); + der_desc.witness_script(); + assert!(!der_desc.bip32_derivations().is_empty()); + assert!(!der_desc.max_sat_weight() > 0); } #[test] diff --git a/src/jsonrpc/api.rs b/src/jsonrpc/api.rs index b748063a..90f224c6 100644 --- a/src/jsonrpc/api.rs +++ b/src/jsonrpc/api.rs @@ -1,15 +1,84 @@ use crate::{ - jsonrpc::{Error, Request, Response}, + jsonrpc::{Error, Params, Request, Response}, DaemonControl, }; +use std::{collections::HashMap, convert::TryInto, str::FromStr}; + +use miniscript::bitcoin::{self, consensus, util::psbt::PartiallySignedTransaction as Psbt}; + +fn create_spend(control: &DaemonControl, params: Params) -> Result { + let outpoints = params + .get(0, "outpoints") + .ok_or(Error::invalid_params("Missing 'outpoints' parameter."))? + .as_array() + .and_then(|arr| { + arr.into_iter() + .map(|entry| { + entry + .as_str() + .and_then(|e| bitcoin::OutPoint::from_str(&e).ok()) + }) + .collect::>>() + }) + .ok_or(Error::invalid_params("Invalid 'outpoints' parameter."))?; + let destinations = params + .get(1, "destinations") + .ok_or(Error::invalid_params("Missing 'destinations' parameter."))? + .as_object() + .and_then(|obj| { + obj.into_iter() + .map(|(k, v)| { + let addr = bitcoin::Address::from_str(&k).ok()?; + let amount: u64 = v.as_i64()?.try_into().ok()?; + Some((addr, amount)) + }) + .collect::>>() + }) + .ok_or(Error::invalid_params("Invalid 'destinations' parameter."))?; + let feerate: u64 = params + .get(2, "feerate") + .ok_or(Error::invalid_params("Missing 'feerate' parameter."))? + .as_i64() + .and_then(|i| i.try_into().ok()) + .ok_or(Error::invalid_params("Invalid 'feerate' parameter."))?; + + let res = control.create_spend(&outpoints, &destinations, feerate)?; + Ok(serde_json::json!(&res)) +} + +fn update_spend(control: &DaemonControl, params: Params) -> Result { + let psbt: Psbt = params + .get(0, "psbt") + .ok_or(Error::invalid_params("Missing 'psbt' parameter."))? + .as_str() + .and_then(|s| base64::decode(&s).ok()) + .and_then(|bytes| consensus::deserialize(&bytes).ok()) + .ok_or(Error::invalid_params("Invalid 'feerate' parameter."))?; + control.update_spend(psbt)?; + + Ok(serde_json::json!({})) +} + /// Handle an incoming JSONRPC2 request. pub fn handle_request(control: &DaemonControl, req: Request) -> Result { let result = match req.method.as_str() { + "createspend" => { + let params = req.params.ok_or(Error::invalid_params( + "Missing 'outpoints', 'destinations' and 'feerate' parameters.", + ))?; + create_spend(control, params)? + } "getinfo" => serde_json::json!(&control.get_info()), "getnewaddress" => serde_json::json!(&control.get_new_address()), "listcoins" => serde_json::json!(&control.list_coins()), "stop" => serde_json::json!({}), + "updatespend" => { + let params = req + .params + .ok_or(Error::invalid_params("Missing 'psbt' parameter."))?; + update_spend(control, params)? + } _ => { return Err(Error::method_not_found()); } diff --git a/src/jsonrpc/mod.rs b/src/jsonrpc/mod.rs index a771e666..91683a75 100644 --- a/src/jsonrpc/mod.rs +++ b/src/jsonrpc/mod.rs @@ -1,6 +1,8 @@ mod api; pub mod server; +use crate::commands; + use std::{error, fmt}; use serde::{self, Deserialize, Deserializer, Serialize, Serializer}; @@ -13,6 +15,20 @@ pub enum Params { Map(serde_json::Map), } +impl Params { + /// Get the parameter supposed to be at a given index / of a given name. + pub fn get(&self, index: usize, name: &Q) -> Option<&serde_json::Value> + where + String: std::borrow::Borrow, + Q: ?Sized + Ord + Eq + std::hash::Hash, + { + match self { + Params::Array(vec) => vec.get(index), + Params::Map(map) => map.get(name), + } + } +} + #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[serde(deny_unknown_fields)] #[serde(untagged)] @@ -42,6 +58,8 @@ pub enum ErrorCode { MethodNotFound, /// Invalid method parameter(s). InvalidParams, + /// Internal error while handling the command. + InternalError, /// Reserved for implementation-defined server-errors. ServerError(i64), } @@ -51,6 +69,7 @@ impl Into for &ErrorCode { match self { ErrorCode::MethodNotFound => -32601, ErrorCode::InvalidParams => -32602, + ErrorCode::InternalError => -32603, ErrorCode::ServerError(code) => *code, } } @@ -108,7 +127,7 @@ impl Error { Error::new(ErrorCode::MethodNotFound, "Method not found") } - pub fn invalid_params(message: impl Into) -> Error { + pub fn invalid_params(message: impl Into) -> Error { Error::new( ErrorCode::InvalidParams, format!("Invalid params: {}", message.into()), @@ -125,6 +144,25 @@ impl fmt::Display for Error { impl error::Error for Error {} +impl From for Error { + fn from(e: commands::CommandError) -> Error { + match e { + commands::CommandError::NoOutpoint + | commands::CommandError::NoDestination + | commands::CommandError::UnknownOutpoint(..) + | commands::CommandError::InvalidFeerate(..) + | commands::CommandError::AlreadySpent(..) + | commands::CommandError::InvalidOutputValue(..) + | commands::CommandError::InsufficientFunds(..) => { + Error::new(ErrorCode::InvalidParams, e.to_string()) + } + commands::CommandError::SanityCheckFailure(_) => { + Error::new(ErrorCode::InternalError, e.to_string()) + } + } + } +} + /// JSONRPC2 response. See https://www.jsonrpc.org/specification#response_object. #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(deny_unknown_fields)] diff --git a/src/lib.rs b/src/lib.rs index ba4fb6c5..6c282cb1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -233,6 +233,12 @@ impl DaemonControl { secp, } } + + // Useful for unit test to directly mess up with the DB + #[cfg(test)] + pub fn db(&self) -> sync::Arc> { + self.db.clone() + } } pub struct DaemonHandle { diff --git a/src/testutils.rs b/src/testutils.rs index d97b1fec..5cefaf93 100644 --- a/src/testutils.rs +++ b/src/testutils.rs @@ -8,7 +8,10 @@ use crate::{ use std::{collections::HashMap, env, fs, io, path, process, str::FromStr, sync, thread, time}; use miniscript::{ - bitcoin::{self, secp256k1, util::bip32}, + bitcoin::{ + self, secp256k1, + util::{bip32, psbt::PartiallySignedTransaction as Psbt}, + }, descriptor, }; @@ -58,6 +61,7 @@ pub struct DummyDb { curr_index: bip32::ChildNumber, curr_tip: Option, coins: HashMap, + spend_txs: HashMap, } impl DummyDb { @@ -66,6 +70,7 @@ impl DummyDb { curr_index: 0.into(), curr_tip: None, coins: HashMap::new(), + spend_txs: HashMap::new(), } } } @@ -137,6 +142,34 @@ impl DatabaseConnection for DummyDbConn { fn derivation_index_by_address(&mut self, _: &bitcoin::Address) -> Option { None } + + fn coins_by_outpoints( + &mut self, + outpoints: &[bitcoin::OutPoint], + ) -> HashMap { + // Very inefficient but hey + self.db + .read() + .unwrap() + .coins + .clone() + .into_iter() + .filter(|(op, _)| outpoints.contains(&op)) + .collect() + } + + fn store_spend(&mut self, psbt: &Psbt) { + let txid = psbt.global.unsigned_tx.txid(); + self.db + .write() + .unwrap() + .spend_txs + .insert(txid, psbt.clone()); + } + + fn spend_tx(&mut self, txid: &bitcoin::Txid) -> Option { + self.db.read().unwrap().spend_txs.get(txid).cloned() + } } pub struct DummyMinisafe { diff --git a/tests/fixtures.py b/tests/fixtures.py index dad19e38..c94707ea 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,3 +1,5 @@ +from bip32 import BIP32 +from bip380.descriptors import Descriptor from concurrent import futures from ephemeral_port_reserve import reserve from test_framework.bitcoind import Bitcoind @@ -117,10 +119,13 @@ def minisafed(bitcoind, directory): os.makedirs(datadir, exist_ok=True) bitcoind_cookie = os.path.join(bitcoind.bitcoin_dir, "regtest", ".cookie") - main_desc = "wsh(or_d(pk(tpubD9vQiBdDxYzU1V5D5UUmMTXF9FZC13PuQDs4aiv6rF7UCKQFvtVKZguYakX12C2bt8736ksioxu9Y9Nmp18gj4jDeNJEEqrBPEZXAxe5YcQ/*),and_v(v:pkh(tpubD9vQiBdDxYzU4cVFtApWj4devZrvcfWaPXX1zHdDc7GPfUsDKqGnbhraccfm7BAXgRgUbVQUV2v2o4NitjGEk7hpbuP85kvBrD4ahFDtNBJ/*),older(65000))))" + owner_hd = BIP32.from_seed(os.urandom(32), network="test") + owner_xpub = owner_hd.get_xpub() + main_desc = Descriptor.from_str(f"wsh(or_d(pk({owner_xpub}/*),and_v(v:pkh(tpubD9vQiBdDxYzU4cVFtApWj4devZrvcfWaPXX1zHdDc7GPfUsDKqGnbhraccfm7BAXgRgUbVQUV2v2o4NitjGEk7hpbuP85kvBrD4ahFDtNBJ/*),older(65000))))") minisafed = Minisafed( datadir, + owner_hd, main_desc, bitcoind.rpcport, bitcoind_cookie, diff --git a/tests/requirements.txt b/tests/requirements.txt index 5afa825c..34e9dc94 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -2,3 +2,6 @@ pytest==6.2 pytest-xdist==1.31.0 pytest-timeout==1.3.4 ephemeral_port_reserve==1.1.1 + +bip32~=3.0 +bip380==0.0.3 diff --git a/tests/test_framework/minisafed.py b/tests/test_framework/minisafed.py index 05e36658..7568c118 100644 --- a/tests/test_framework/minisafed.py +++ b/tests/test_framework/minisafed.py @@ -1,6 +1,9 @@ import logging import os +from bip32.utils import coincurve +from bip380.descriptors import Descriptor +from bip380.miniscript import SatisfactionMaterial from test_framework.utils import ( UnixDomainSocketRpc, TailableProc, @@ -8,12 +11,19 @@ from test_framework.utils import ( LOG_LEVEL, MINISAFED_PATH, ) +from test_framework.serializations import ( + PSBT, + sighash_all_witness, + CTxInWitness, + CScriptWitness, +) class Minisafed(TailableProc): def __init__( self, datadir, + owner_hd, main_desc, bitcoind_rpc_port, bitcoind_cookie_path, @@ -22,6 +32,9 @@ class Minisafed(TailableProc): self.prefix = os.path.split(datadir)[-1] + self.owner_hd = owner_hd + self.main_desc = main_desc + self.conf_file = os.path.join(datadir, "config.toml") self.cmd_line = [MINISAFED_PATH, "--conf", f"{self.conf_file}"] socket_path = os.path.join(os.path.join(datadir, "regtest"), "minisafed_rpc") @@ -42,6 +55,53 @@ class Minisafed(TailableProc): f.write(f"cookie_path = '{bitcoind_cookie_path}'\n") f.write(f"addr = '127.0.0.1:{bitcoind_rpc_port}'\n") + def sign_psbt(self, psbt): + """Sign a transaction using the owner's key. + This creates a valid witness for all inputs in the transaction using the + information contained in the PSBT. + + :param psbt: PSBT of the transaction to be signed. + :returns: the serialized valid transaction, as hex. + """ + assert isinstance(psbt, PSBT) + + # Create a witness for each input of the transaction. + for i, psbt_in in enumerate(psbt.inputs): + # First, gather the needed information from the PSBT input. + # 'hd_keypaths' is of the form {pubkey: (fingerprint, derivation index)} + der_index = next(iter(psbt_in.hd_keypaths.values()))[1] + script_code = psbt_in.witness_script + + # Now sign the transaction with the key of the "owner" (the participant that + # can sign immediately without a timelock) + sighash = sighash_all_witness(script_code, psbt, i) + privkey = coincurve.PrivateKey( + self.owner_hd.get_privkey_from_path([der_index]) + ) + pubkey = privkey.public_key.format() + assert pubkey in psbt_in.hd_keypaths.keys() + sig = privkey.sign(sighash, hasher=None) + b"\x01" + logging.debug(f"Adding signature {sig.hex()} for pubkey {pubkey.hex()}") + + # Create a copy of the descriptor to derive it at the index used in this input. + # Then create a satisfaction for it using the signature we just created. + desc = Descriptor.from_str(str(self.main_desc)) + desc.derive(der_index) + sat_material = SatisfactionMaterial( + signatures={pubkey: sig}, + ) + stack = desc.satisfy(sat_material) + logging.debug(f"Satisfaction for {desc} is {[e.hex() for e in stack]}") + + # Update the transaction inside the PSBT directly. + assert stack is not None + psbt_in.final_script_witness = CTxInWitness(CScriptWitness(stack)) + psbt.tx.wit.vtxinwit.append(psbt_in.final_script_witness) + + tx = psbt.tx.serialize_with_witness().hex() + logging.debug(f"Final transaction: {tx}") + return tx + def start(self): TailableProc.start(self) self.wait_for_logs( diff --git a/tests/test_framework/serializations.py b/tests/test_framework/serializations.py new file mode 100644 index 00000000..c0cdc4f5 --- /dev/null +++ b/tests/test_framework/serializations.py @@ -0,0 +1,1045 @@ +#!/usr/bin/env python3 +# Stolen from https://github.com/achow101/psbt-simple-signer/blob/5def3622a09f5bcb76ae79707f0790d050291474/serializations.py +# PSBT serialization was authored by Andrew Chow (achow101) +# Copyright (c) 2010 ArtForz -- public domain half-a-node +# Copyright (c) 2012 Jeff Garzik +# Copyright (c) 2010-2016 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Bitcoin Object Python Serializations + +Modified from the test/test_framework/mininode.py file from the +Bitcoin repository + +CTransaction,CTxIn, CTxOut, etc....: + data structures that should map to corresponding structures in + bitcoin/primitives for transactions only +ser_*, deser_*: functions that handle serialization/deserialization +""" + +from io import BytesIO, BufferedReader +from codecs import encode +import struct +import binascii +import hashlib +import copy +import base64 + + +def sha256(s): + return hashlib.new("sha256", s).digest() + + +def ripemd160(s): + return hashlib.new("ripemd160", s).digest() + + +def hash256(s): + return sha256(sha256(s)) + + +def hash160(s): + return ripemd160(sha256(s)) + + +# Serialization/deserialization tools +def ser_compact_size(l): + r = b"" + if l < 253: + r = struct.pack("B", l) + elif l < 0x10000: + r = struct.pack(">= 32 + return rs + + +def uint256_from_str(s): + r = 0 + t = struct.unpack("> 24) & 0xFF + v = (c & 0xFFFFFF) << (8 * (nbytes - 3)) + return v + + +def deser_vector(f, c): + nit = deser_compact_size(f) + r = [] + for i in range(nit): + t = c() + t.deserialize(f) + r.append(t) + return r + + +# ser_function_name: Allow for an alternate serialization function on the +# entries in the vector (we use this for serializing the vector of transactions +# for a witness block). +def ser_vector(l, ser_function_name=None): + r = ser_compact_size(len(l)) + for i in l: + if ser_function_name: + r += getattr(i, ser_function_name)() + else: + r += i.serialize() + return r + + +def deser_uint256_vector(f): + nit = deser_compact_size(f) + r = [] + for i in range(nit): + t = deser_uint256(f) + r.append(t) + return r + + +def ser_uint256_vector(l): + r = ser_compact_size(len(l)) + for i in l: + r += ser_uint256(i) + return r + + +def deser_string_vector(f): + nit = deser_compact_size(f) + r = [] + for i in range(nit): + t = deser_string(f) + r.append(t) + return r + + +def ser_string_vector(l): + r = ser_compact_size(len(l)) + for sv in l: + r += ser_string(sv) + return r + + +def deser_int_vector(f): + nit = deser_compact_size(f) + r = [] + for i in range(nit): + t = struct.unpack(" 42: + return (False, None, None) + + if script[0] != 0 and (script[0] < 81 or script[0] > 96): + return (False, None, None) + + if script[1] + 2 == len(script): + return (True, script[0] - 0x50 if script[0] else 0, script[2:]) + + return (False, None, None) + + +# Objects that map to bitcoind objects, which can be serialized/deserialized + +MSG_WITNESS_FLAG = 1 << 30 + + +class COutPoint(object): + def __init__(self, hash=0, n=0xFFFFFFFF): + self.hash = hash + self.n = n + + def deserialize(self, f): + self.hash = deser_uint256(f) + self.n = struct.unpack(" 21000000 * COIN: + return False + return True + + def is_null(self): + return len(self.vin) == 0 and len(self.vout) == 0 + + def __repr__(self): + return "CTransaction(nVersion=%i vin=%s vout=%s wit=%s nLockTime=%i)" % ( + self.nVersion, + repr(self.vin), + repr(self.vout), + repr(self.wit), + self.nLockTime, + ) + + +def DeserializeHDKeypath(f, key, hd_keypaths): + if len(key) != 34 and len(key) != 66: + raise IOError( + "Size of key was not the expected size for the type partial signature pubkey" + ) + pubkey = key[1:] + if pubkey in hd_keypaths: + raise IOError( + "Duplicate key, input partial signature for pubkey already provided" + ) + + value = deser_string(f) + hd_keypaths[pubkey] = struct.unpack("<" + "I" * (len(value) // 4), value) + + +def SerializeHDKeypath(hd_keypaths, type): + r = b"" + for pubkey, path in hd_keypaths.items(): + r += ser_string(type + pubkey) + packed = struct.pack("<" + "I" * len(path), *path) + r += ser_string(packed) + return r + + +class PartiallySignedInput: + def __init__(self): + self.non_witness_utxo = None + self.witness_utxo = None + self.partial_sigs = {} + self.sighash = 0 + self.redeem_script = b"" + self.witness_script = b"" + self.hd_keypaths = {} + self.final_script_sig = b"" + self.final_script_witness = CTxInWitness() + self.unknown = {} + + def set_null(self): + self.non_witness_utxo = None + self.witness_utxo = None + self.partial_sigs.clear() + self.sighash = 0 + self.redeem_script = b"" + self.witness_script = b"" + self.hd_keypaths.clear() + self.final_script_sig = b"" + self.final_script_witness = CTxInWitness() + self.unknown.clear() + + def deserialize(self, f): + while True: + # read the key + try: + key = deser_string(f) + except Exception: + break + + # Check for separator + if len(key) == 0: + break + + # First byte of key is the type + key_type = struct.unpack("b", bytearray([key[0]]))[0] + + if key_type == 0: + if self.non_witness_utxo: + raise IOError( + "Duplicate Key, input non witness utxo already provided" + ) + elif len(key) != 1: + raise IOError("non witness utxo key is more than one byte type") + self.non_witness_utxo = CTransaction() + value = BufferedReader(BytesIO(deser_string(f))) + self.non_witness_utxo.deserialize(value) + self.non_witness_utxo.rehash() + + elif key_type == 1: + if self.witness_utxo: + raise IOError("Duplicate Key, input witness utxo already provided") + elif len(key) != 1: + raise IOError("witness utxo key is more than one byte type") + self.witness_utxo = CTxOut() + value = BufferedReader(BytesIO(deser_string(f))) + self.witness_utxo.deserialize(value) + + elif key_type == 2: + if len(key) != 34 and len(key) != 66: + raise IOError( + "Size of key was not the expected size for the type partial signature pubkey" + ) + pubkey = key[1:] + if pubkey in self.partial_sigs: + raise IOError( + "Duplicate key, input partial signature for pubkey already provided" + ) + + sig = deser_string(f) + self.partial_sigs[pubkey] = sig + + elif key_type == 3: + if self.sighash > 0: + raise IOError("Duplicate key, input sighash type already provided") + elif len(key) != 1: + raise IOError("sighash key is more than one byte type") + value = deser_string(f) + self.sighash = struct.unpack(" 0: + r += ser_string(b"\x03") + r += ser_string(struct.pack(" 1: + raise IOError("Global unsigned tx key is more than one byte type") + + # read in value + value = BufferedReader(BytesIO(deser_string(f))) + self.tx.deserialize(value) + + # Make sure that all scriptSigs and scriptWitnesses are empty + for txin in self.tx.vin: + if len(txin.scriptSig) != 0 or not self.tx.wit.is_null(): + raise IOError( + "Unsigned tx does not have empty scriptSigs and scriptWitnesses" + ) + + else: + if key in self.unknown: + raise IOError( + "Duplicate key, key for unknown value already provided" + ) + value = deser_string(f) + self.unknown[key] = value + + # make sure that we got an unsigned tx + if self.tx.is_null(): + raise IOError("No unsigned trasaction was provided") + + # Read input data + for txin in self.tx.vin: + input = PartiallySignedInput() + input.deserialize(f) + self.inputs.append(input) + + if ( + input.non_witness_utxo + and input.non_witness_utxo.rehash() + and input.non_witness_utxo.sha256 != txin.prevout.sha256 + ): + raise IOError("Non-witness UTXO does not match outpoint hash") + + if len(self.inputs) != len(self.tx.vin): + raise IOError( + "Inputs provided does not match the number of inputs in transaction" + ) + + # Read output data + for txout in self.tx.vout: + output = PartiallySignedOutput() + output.deserialize(f) + self.outputs.append(output) + + if len(self.outputs) != len(self.tx.vout): + raise IOError( + "Outputs provided does not match the number of outputs in transaction" + ) + + if not self.is_sane(): + raise IOError("PSBT is not sane") + + def serialize(self): + r = b"" + + # magic bytes + r += b"psbt\xff" + + # unsigned tx flag + r += b"\x01\x00" + + # write serialized tx + tx = self.tx.serialize_with_witness() + r += ser_compact_size(len(tx)) + r += tx + + # separator + r += b"\x00" + + # unknowns + for key, value in self.unknown: + r += ser_string(key) + r += ser_string(value) + + # inputs + for input in self.inputs: + r += input.serialize() + + # outputs + for output in self.outputs: + r += output.serialize() + + # return hex string + return HexToBase64(binascii.hexlify(r)).decode() + + def is_sane(self): + for input in self.inputs: + if not input.is_sane(): + return False + return True + + +# Sighash serializations +def sighash_all_witness(script_code, psbt, i, acp=False): + """ + Compute the ALL signature hash of the {psbt} 's input {i}. + + :param acp: if True, use ALL | ANYONECANPAY behaviour. + """ + # Calculate hashPrevouts and hashSequence + if not acp: + prevouts_preimage = b"" + sequence_preimage = b"" + for inputs in psbt.tx.vin: + prevouts_preimage += inputs.prevout.serialize() + sequence_preimage += struct.pack(" 0) + outpoints = [c["outpoint"] for c in minisafed.rpc.listcoins()["coins"]] + destinations = { + bitcoind.rpc.getnewaddress(): 200_000, + } + res = minisafed.rpc.createspend(outpoints, destinations, 6) + assert "psbt" in res + + # Now update it + minisafed.rpc.updatespend(res["psbt"]) + + # TODO: check it's stored once we implement 'listspendtxs' + # TODO: check with added signatures once we implement 'listspendtxs'