diff --git a/Cargo.toml b/Cargo.toml index fbd87e2..ebf8d4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,10 +12,18 @@ repository = "https://github.com/bitcoindevkit/bdk-reserves" [dependencies] bdk = { version = "0.28", default-features = false } bitcoinconsensus = "0.19.0-3" +bitcoincore-rpc = { version = "0.16", optional = true } +electrum-client = { version = "0.12", optional = true } +esplora-client = { version = "0.4", default-features = false, optional = true } log = "^0.4" +[features] +electrum = ["electrum-client", "bdk/electrum"] +use-esplora-blocking = ["esplora-client/blocking", "bdk/use-esplora-blocking"] +rpc = ["bitcoincore-rpc", "bdk/rpc"] + [dev-dependencies] rstest = "^0.11" bdk-testutils = "^0.4" bdk = { version = "0.28", default-features = true } -electrsd = { version = "0.23", features = ["bitcoind_22_0", "electrs_0_9_1"] } +electrsd = { version = "0.23.1", features = ["bitcoind_23_0", "esplora_a33e97e1", "legacy"] } diff --git a/src/lib.rs b/src/lib.rs index 04703ff..9af3d86 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,3 +12,4 @@ pub extern crate bdk; pub mod reserves; +pub mod txout_set; diff --git a/src/reserves.rs b/src/reserves.rs index 18a0d9d..117c94f 100644 --- a/src/reserves.rs +++ b/src/reserves.rs @@ -23,14 +23,20 @@ use bdk::bitcoin::blockdata::script::{Builder, Script}; use bdk::bitcoin::blockdata::transaction::{EcdsaSighashType, OutPoint, TxIn, TxOut}; use bdk::bitcoin::consensus::encode::serialize; use bdk::bitcoin::hash_types::{PubkeyHash, Txid}; -use bdk::bitcoin::hashes::{hash160, sha256d, Hash}; -use bdk::bitcoin::util::psbt::{Input, PartiallySignedTransaction as PSBT}; -use bdk::bitcoin::{Network, Sequence}; +use bdk::bitcoin::hashes::{hash160, sha256, Hash}; +use bdk::bitcoin::util::psbt::{raw::Key, Input, PartiallySignedTransaction as PSBT}; +use bdk::bitcoin::{Sequence, Transaction}; use bdk::database::BatchDatabase; use bdk::wallet::tx_builder::TxOrdering; use bdk::wallet::Wallet; use bdk::Error; +use std::collections::BTreeMap; + +pub use crate::txout_set::{HistoricalTxOutQuery, MaxHeightTxOutQuery, TipTxOutQuery, TxOutSet}; + +pub const PSBT_IN_POR_COMMITMENT: u8 = 0x09; + /// The API for proof of reserves pub trait ProofOfReserves { /// Create a proof for all spendable UTXOs in a wallet @@ -61,14 +67,10 @@ pub enum ProofError { WrongNumberOfOutputs, /// Challenge input does not match ChallengeInputMismatch, - /// Found an input other than the challenge which is not spendable. Holds the position of the input. - NonSpendableInput(usize), /// Found an input that has no signature at position NotSignedInput(usize), /// Found an input with an unsupported SIGHASH type at position UnsupportedSighashType(usize), - /// Found an input that is neither witness nor legacy at position - NeitherWitnessNorLegacy(usize), /// Signature validation failed SignatureValidation(usize, String), /// The output is not valid @@ -77,8 +79,8 @@ pub enum ProofError { InAndOutValueNotEqual, /// No matching outpoint found OutpointNotFound(usize), - /// Failed to retrieve the block height of a Tx or UTXO - MissingConfirmationInfo, + /// Error looking up outpoint other than if outpoint doesn't exist, or outpoint is already spent + OutpointLookupError, /// A wrapped BDK Error BdkError(Error), } @@ -108,12 +110,22 @@ where return Err(ProofError::ChallengeInputMismatch); } let challenge_txin = challenge_txin(message); + + let challenge_key = Key { + type_value: PSBT_IN_POR_COMMITMENT, + key: Vec::new(), + }; + + let mut unknown_psbt_keys: BTreeMap> = BTreeMap::new(); + unknown_psbt_keys.insert(challenge_key, message.as_bytes().into()); + let challenge_psbt_inp = Input { witness_utxo: Some(TxOut { value: 0, script_pubkey: Builder::new().push_opcode(opcodes::OP_TRUE).into_script(), }), final_script_sig: Some(Script::default()), /* "finalize" the input with an empty scriptSig */ + unknown: unknown_psbt_keys, ..Default::default() }; @@ -140,170 +152,191 @@ where message: &str, max_block_height: Option, ) -> Result { - // verify the proof UTXOs are still spendable - let unspents = match self.list_unspent() { - Ok(utxos) => utxos, - Err(err) => return Err(ProofError::BdkError(err)), - }; - let unspents = unspents - .iter() - .map(|utxo| { - if max_block_height.is_none() { - Ok((utxo, None)) - } else { - let tx_details = self.get_tx(&utxo.outpoint.txid, false)?; - if let Some(tx_details) = tx_details { - if let Some(conf_time) = tx_details.confirmation_time { - Ok((utxo, Some(conf_time.height))) - } else { - Ok((utxo, None)) - } - } else { - Err(ProofError::MissingConfirmationInfo) - } - } - }) - .collect::, ProofError>>()?; - let outpoints = unspents - .iter() - .filter(|(_utxo, block_height)| { - block_height.unwrap_or(u32::MAX) <= max_block_height.unwrap_or(u32::MAX) - }) - .map(|(utxo, _)| (utxo.outpoint, utxo.txout.clone())) - .collect(); + if let Some(max_block_height) = max_block_height { + let txouts = self.txout_set_confirmed_by_height(max_block_height); - verify_proof(psbt, message, outpoints, self.network()) + psbt.verify_reserve_proof(message, txouts) + } else { + psbt.verify_reserve_proof(message, self) + } } } -/// Make sure this is a proof, and not a spendable transaction. -/// Make sure the proof is valid. -/// Currently proofs can only be validated against the tip of the chain. -/// If some of the UTXOs in the proof were spent in the meantime, the proof will fail. -/// We can currently not validate whether it was valid at a certain block height. -/// Since the caller provides the outpoints, he is also responsible to make sure they have enough confirmations. -/// Returns the spendable amount of the proof. -pub fn verify_proof( - psbt: &PSBT, - message: &str, - outpoints: Vec<(OutPoint, TxOut)>, - _network: Network, -) -> Result { - let tx = psbt.clone().extract_tx(); - - if tx.output.len() != 1 { - return Err(ProofError::WrongNumberOfOutputs); - } - if tx.input.len() <= 1 { - return Err(ProofError::WrongNumberOfInputs); - } - - // verify the challenge txin - let challenge_txin = challenge_txin(message); - if tx.input[0].previous_output != challenge_txin.previous_output { - return Err(ProofError::ChallengeInputMismatch); - } - - // verify the proof UTXOs are still spendable - if let Some((i, _inp)) = tx - .input - .iter() - .enumerate() - .skip(1) - .find(|(_i, inp)| !outpoints.iter().any(|op| op.0 == inp.previous_output)) - { - return Err(ProofError::NonSpendableInput(i)); - } - - // verify that the inputs are signed, except the challenge - if let Some((i, _inp)) = psbt - .inputs - .iter() - .enumerate() - .skip(1) - .find(|(_i, inp)| inp.final_script_sig.is_none() && inp.final_script_witness.is_none()) - { - return Err(ProofError::NotSignedInput(i)); - } - - // Verify the SIGHASH - if let Some((i, _psbt_in)) = psbt.inputs.iter().enumerate().find(|(_i, psbt_in)| { - psbt_in.sighash_type.is_some() && psbt_in.sighash_type != Some(EcdsaSighashType::All.into()) - }) { - return Err(ProofError::UnsupportedSighashType(i)); - } - - // calculate the spendable amount of the proof - let sum = tx - .input - .iter() - .map(|tx_in| { - if let Some(op) = outpoints.iter().find(|op| op.0 == tx_in.previous_output) { - op.1.value - } else { - 0 +/// Trait for Transaction-centric proofs +pub trait ReserveProof { + /// Verify a proof transaction. + /// Look up utxos with get_prevout() + fn verify_reserve_proof( + &self, + message: &str, + txouts: T, + ) -> Result; + + /// Verify that this transaction correctly includes the challenge + fn verify_challenge(&self, message: &str) -> Result<(), ProofError>; +} + +impl ReserveProof for Transaction { + fn verify_reserve_proof( + &self, + message: &str, + txouts: T, + ) -> Result { + if self.output.len() != 1 { + return Err(ProofError::WrongNumberOfOutputs); + } + if self.input.len() <= 1 { + return Err(ProofError::WrongNumberOfInputs); + } + + // verify the unspendable output + let pkh = PubkeyHash::from_hash(hash160::Hash::hash(&[0])); + let out_script_unspendable = Script::new_p2pkh(&pkh); + + if self.output[0].script_pubkey != out_script_unspendable { + return Err(ProofError::InvalidOutput); + } + + self.verify_challenge(message)?; + + let outpoint_iter = self.input.iter().map(|txin| &txin.previous_output); + + // Try to look up outpoints + let prevouts: Vec> = txouts + .get_prevouts(outpoint_iter) + .map_err(|_| ProofError::OutpointLookupError)?; + + // Convert missing outpoints into errors + let prevouts: Vec<(usize, TxOut)> = prevouts + .into_iter() + .enumerate() + .skip(1) + .map(|(i, txout)| match txout { + Some(txout) => Ok((i, txout)), + None => Err(ProofError::OutpointNotFound(i)), + }) + .collect::>()?; + + let sum: u64 = prevouts.iter().map(|(_i, prevout)| prevout.value).sum(); + + // inflow and outflow being equal means no miner fee + if self.output[0].value != sum { + return Err(ProofError::InAndOutValueNotEqual); + } + + let serialized_tx = serialize(&self); + + // Check that all inputs besides the challenge input are valid + prevouts.iter().try_for_each(|(i, prevout)| { + bitcoinconsensus::verify( + prevout.script_pubkey.to_bytes().as_slice(), + prevout.value, + &serialized_tx, + *i, + ) + .map_err(|e| ProofError::SignatureValidation(*i, format!("{:?}", e))) + })?; + + // Check that all inputs besides the challenge input actually + // commit to the challenge input by modifying the challenge + // input and verifying that validation *fails*. + // + // If validation succeeds here, that input did not correctly + // commit to the challenge input. + let serialized_malleated_tx = { + let mut malleated_tx = self.clone(); + + let mut malleated_message = "MALLEATED: ".to_string(); + malleated_message.push_str(message); + + malleated_tx.input[0] = challenge_txin(&malleated_message); + + serialize(&malleated_tx) + }; + + prevouts.iter().try_for_each(|(i, prevout)| { + match bitcoinconsensus::verify( + prevout.script_pubkey.to_bytes().as_slice(), + prevout.value, + &serialized_malleated_tx, + *i, + ) { + Ok(_) => Err(ProofError::SignatureValidation( + *i, + "Does not commit to challenge input".to_string(), + )), + Err(_) => Ok(()), } - }) - .sum(); + })?; - // inflow and outflow being equal means no miner fee - if tx.output[0].value != sum { - return Err(ProofError::InAndOutValueNotEqual); + Ok(sum) } - // verify the unspendable output - let pkh = PubkeyHash::from_hash(hash160::Hash::hash(&[0])); - let out_script_unspendable = Script::new_p2pkh(&pkh); + fn verify_challenge(&self, message: &str) -> Result<(), ProofError> { + let challenge_txin = challenge_txin(message); + + if self.input[0].previous_output != challenge_txin.previous_output { + return Err(ProofError::ChallengeInputMismatch); + } - if tx.output[0].script_pubkey != out_script_unspendable { - return Err(ProofError::InvalidOutput); + Ok(()) } +} - let serialized_tx = serialize(&tx); +impl ReserveProof for PSBT { + /// Make sure this is a proof, and not a spendable transaction. + /// Make sure the proof is valid. + /// Currently proofs can only be validated against the tip of the chain. + /// If some of the UTXOs in the proof were spent in the meantime, the proof will fail. + /// We can currently not validate whether it was valid at a certain block height. + /// Since the caller provides the outpoints, he is also responsible to make sure they have enough confirmations. + /// Returns the spendable amount of the proof. + fn verify_reserve_proof( + &self, + message: &str, + txouts: T, + ) -> Result { + let tx = self.clone().extract_tx(); - // Verify other inputs against prevouts. - if let Some((i, res)) = tx - .input - .iter() - .enumerate() - .skip(1) - .map(|(i, tx_in)| { - if let Some(op) = outpoints.iter().find(|op| op.0 == tx_in.previous_output) { - (i, Ok(op.1.clone())) - } else { - (i, Err(ProofError::OutpointNotFound(i))) - } - }) - .map(|(i, res)| match res { - Ok(txout) => ( - i, - bitcoinconsensus::verify( - txout.script_pubkey.to_bytes().as_slice(), - txout.value, - &serialized_tx, - i, - ) - .map_err(|e| ProofError::SignatureValidation(i, format!("{:?}", e))), - ), - Err(err) => (i, Err(err)), - }) - .find(|(_i, res)| res.is_err()) - { - return Err(ProofError::SignatureValidation( - i, - format!("{:?}", res.err().unwrap()), - )); - } - - Ok(sum) + // Redundant check to tx.verify_reserve_proof() to ensure error priority is not changed + if tx.output.len() != 1 { + return Err(ProofError::WrongNumberOfOutputs); + } + + // verify that the inputs are signed, except the challenge + if let Some((i, _inp)) = + self.inputs.iter().enumerate().skip(1).find(|(_i, inp)| { + inp.final_script_sig.is_none() && inp.final_script_witness.is_none() + }) + { + return Err(ProofError::NotSignedInput(i)); + } + + // Verify the SIGHASH + if let Some((i, _psbt_in)) = self.inputs.iter().enumerate().find(|(_i, psbt_in)| { + psbt_in.sighash_type.is_some() + && psbt_in.sighash_type != Some(EcdsaSighashType::All.into()) + }) { + return Err(ProofError::UnsupportedSighashType(i)); + } + + tx.verify_reserve_proof(message, txouts) + } + + fn verify_challenge(&self, message: &str) -> Result<(), ProofError> { + let tx = self.clone().extract_tx(); + + tx.verify_challenge(message) + } } /// Construct a challenge input with the message fn challenge_txin(message: &str) -> TxIn { let message = "Proof-of-Reserves: ".to_string() + message; - let message = sha256d::Hash::hash(message.as_bytes()); + let message = sha256::Hash::hash(message.as_bytes()); + let txid = Txid::from_inner(message.into_inner()); TxIn { - previous_output: OutPoint::new(Txid::from_hash(message), 0), + previous_output: OutPoint::new(txid, 0), sequence: Sequence(0xFFFFFFFF), ..Default::default() } @@ -312,8 +345,9 @@ fn challenge_txin(message: &str) -> TxIn { #[cfg(test)] mod test { use super::*; + use bdk::bitcoin::consensus::encode::deserialize; use bdk::bitcoin::secp256k1::ecdsa::{SerializedSignature, Signature}; - use bdk::bitcoin::{EcdsaSighashType, Network, Witness}; + use bdk::bitcoin::{EcdsaSighashType, Transaction, Witness}; use bdk::wallet::get_funded_wallet; use std::str::FromStr; @@ -327,7 +361,7 @@ mod test { let psbt_b64 = psbt.to_string(); - let expected = r#"cHNidP8BAH4BAAAAAmw1RvG4UzfnSafpx62EPTyha6VslP0Er7n3TxjEpeBeAAAAAAD/////2johM0znoXIXT1lg+ySrvGrtq1IGXPJzpfi/emkV9iIAAAAAAP////8BUMMAAAAAAAAZdqkUn3/QltN+0sDj9/DPySS+70/862iIrAAAAAAAAQEKAAAAAAAAAAABUQEHAAABAR9QwwAAAAAAABYAFOzlJlcQU9qGRUyeBmd56vnRUC5qIgYDKwVYB4vsOGlKhJM9ZZMD4lddrn6RaFkRRUEVv9ZEh+ME7OUmVwAA"#; + let expected = r#"cHNidP8BAH4BAAAAAnazTpCbEI8dIHmilAK8aXK6Zj3nPcEy5vZzHMw/SzoyAAAAAAD/////2johM0znoXIXT1lg+ySrvGrtq1IGXPJzpfi/emkV9iIAAAAAAP////8BUMMAAAAAAAAZdqkUn3/QltN+0sDj9/DPySS+70/862iIrAAAAAAAAQEKAAAAAAAAAAABUQEHAAEJE1RoaXMgYmVsb25ncyB0byBtZS4AAQEfUMMAAAAAAAAWABTs5SZXEFPahkVMngZneer50VAuaiIGAysFWAeL7DhpSoSTPWWTA+JXXa5+kWhZEUVBFb/WRIfjBOzlJlcAAA=="#; assert_eq!(psbt_b64, expected); } @@ -355,10 +389,15 @@ mod test { } fn get_signed_proof() -> PSBT { - let psbt = "cHNidP8BAH4BAAAAAmw1RvG4UzfnSafpx62EPTyha6VslP0Er7n3TxjEpeBeAAAAAAD/////2johM0znoXIXT1lg+ySrvGrtq1IGXPJzpfi/emkV9iIAAAAAAP////8BUMMAAAAAAAAZdqkUn3/QltN+0sDj9/DPySS+70/862iIrAAAAAAAAQEKAAAAAAAAAAABUQEHAAABAR9QwwAAAAAAABYAFOzlJlcQU9qGRUyeBmd56vnRUC5qAQcAAQhrAkcwRAIgDSE4PQ57JDiZ7otGkTqz35bi/e1pexYaYKWaveuvRd4CIFzVB4sAmgtdEVz2vHzs1iXc9iRKJ+KQOQb+C2DtPyvzASEDKwVYB4vsOGlKhJM9ZZMD4lddrn6RaFkRRUEVv9ZEh+MAAA=="; + let psbt = "cHNidP8BAH4BAAAAAnazTpCbEI8dIHmilAK8aXK6Zj3nPcEy5vZzHMw/SzoyAAAAAAD/////2johM0znoXIXT1lg+ySrvGrtq1IGXPJzpfi/emkV9iIAAAAAAP////8BUMMAAAAAAAAZdqkUn3/QltN+0sDj9/DPySS+70/862iIrAAAAAAAAQEKAAAAAAAAAAABUQEHAAEJE1RoaXMgYmVsb25ncyB0byBtZS4AAQEfUMMAAAAAAAAWABTs5SZXEFPahkVMngZneer50VAuaiIGAysFWAeL7DhpSoSTPWWTA+JXXa5+kWhZEUVBFb/WRIfjBOzlJlcBBwABCGsCRzBEAiBfpF8pM16CA1zJLkvl2gZ5ziGHadpZt1/yWyiQ2dB8nwIgSdBcayBSRhQvvjZEEyGyaDSBWJOiPU+ww6KHAPKeB/wBIQMrBVgHi+w4aUqEkz1lkwPiV12ufpFoWRFFQRW/1kSH4wAA"; PSBT::from_str(psbt).unwrap() } + fn get_signed_proof_tx() -> Transaction { + let psbt = get_signed_proof(); + psbt.extract_tx() + } + #[test] fn verify_internal() { let descriptor = "wpkh(cVpPVruEDdmutPzisEsYvtST1usBR3ntr8pXSyt6D2YYqXRyPcFW)"; @@ -368,10 +407,15 @@ mod test { let psbt = get_signed_proof(); let spendable = wallet.verify_proof(&psbt, message, None).unwrap(); assert_eq!(spendable, 50_000); + + let tx = psbt.extract_tx(); + + let spendable = tx.verify_reserve_proof(message, &wallet).unwrap(); + assert_eq!(spendable, 50_000); } #[test] - #[should_panic(expected = "NonSpendableInput")] + #[should_panic(expected = "OutpointNotFound")] fn verify_internal_90() { let descriptor = "wpkh(cVpPVruEDdmutPzisEsYvtST1usBR3ntr8pXSyt6D2YYqXRyPcFW)"; let (wallet, _, _) = get_funded_wallet(descriptor); @@ -382,6 +426,22 @@ mod test { assert_eq!(spendable, 50_000); } + #[test] + #[should_panic(expected = "OutpointNotFound")] + fn verify_internal_90_tx() { + let descriptor = "wpkh(cVpPVruEDdmutPzisEsYvtST1usBR3ntr8pXSyt6D2YYqXRyPcFW)"; + let (wallet, _, _) = get_funded_wallet(descriptor); + + let message = "This belongs to me."; + let tx = get_signed_proof_tx(); + + let spendable = tx + .verify_reserve_proof(message, wallet.txout_set_confirmed_by_height(90)) + .unwrap(); + + assert_eq!(spendable, 50_000); + } + #[test] fn verify_internal_100() { let descriptor = "wpkh(cVpPVruEDdmutPzisEsYvtST1usBR3ntr8pXSyt6D2YYqXRyPcFW)"; @@ -391,6 +451,13 @@ mod test { let psbt = get_signed_proof(); let spendable = wallet.verify_proof(&psbt, message, Some(100)).unwrap(); assert_eq!(spendable, 50_000); + + let tx = psbt.extract_tx(); + let spendable = tx + .verify_reserve_proof(message, wallet.txout_set_confirmed_by_height(100)) + .unwrap(); + + assert_eq!(spendable, 50_000); } #[test] @@ -401,11 +468,17 @@ mod test { let message = "This belongs to me."; let psbt = get_signed_proof(); let unspents = wallet.list_unspent().unwrap(); - let outpoints = unspents + let outpoints: BTreeMap = unspents .iter() .map(|utxo| (utxo.outpoint, utxo.txout.clone())) .collect(); - let spendable = verify_proof(&psbt, message, outpoints, Network::Testnet).unwrap(); + + let spendable = psbt.verify_reserve_proof(message, &outpoints).unwrap(); + assert_eq!(spendable, 50_000); + + let tx = psbt.extract_tx(); + + let spendable = tx.verify_reserve_proof(message, &outpoints).unwrap(); assert_eq!(spendable, 50_000); } @@ -421,6 +494,17 @@ mod test { wallet.verify_proof(&psbt, message, None).unwrap(); } + #[test] + #[should_panic(expected = "ChallengeInputMismatch")] + fn wrong_message_tx() { + let descriptor = "wpkh(cVpPVruEDdmutPzisEsYvtST1usBR3ntr8pXSyt6D2YYqXRyPcFW)"; + let (wallet, _, _) = get_funded_wallet(descriptor); + + let message = "Wrong message!"; + let tx = get_signed_proof_tx(); + tx.verify_reserve_proof(message, &wallet).unwrap(); + } + #[test] #[should_panic(expected = "WrongNumberOfInputs")] fn too_few_inputs() { @@ -435,6 +519,22 @@ mod test { wallet.verify_proof(&psbt, message, None).unwrap(); } + #[test] + #[should_panic(expected = "WrongNumberOfInputs")] + fn too_few_inputs_tx() { + let descriptor = "wpkh(cVpPVruEDdmutPzisEsYvtST1usBR3ntr8pXSyt6D2YYqXRyPcFW)"; + let (wallet, _, _) = get_funded_wallet(descriptor); + + let message = "This belongs to me."; + let mut psbt = get_signed_proof(); + psbt.unsigned_tx.input.truncate(1); + psbt.inputs.truncate(1); + + let tx = psbt.extract_tx(); + + tx.verify_reserve_proof(message, &wallet).unwrap(); + } + #[test] #[should_panic(expected = "WrongNumberOfOutputs")] fn no_output() { @@ -449,6 +549,22 @@ mod test { wallet.verify_proof(&psbt, message, None).unwrap(); } + #[test] + #[should_panic(expected = "WrongNumberOfOutputs")] + fn no_output_tx() { + let descriptor = "wpkh(cVpPVruEDdmutPzisEsYvtST1usBR3ntr8pXSyt6D2YYqXRyPcFW)"; + let (wallet, _, _) = get_funded_wallet(descriptor); + + let message = "This belongs to me."; + let mut psbt = get_signed_proof(); + psbt.unsigned_tx.output.clear(); + psbt.inputs.clear(); + + let tx = psbt.extract_tx(); + + tx.verify_reserve_proof(message, &wallet).unwrap(); + } + #[test] #[should_panic(expected = "NotSignedInput")] fn missing_signature() { @@ -463,6 +579,22 @@ mod test { wallet.verify_proof(&psbt, message, None).unwrap(); } + #[test] + #[should_panic(expected = "SignatureValidation")] + fn missing_signature_tx() { + let descriptor = "wpkh(cVpPVruEDdmutPzisEsYvtST1usBR3ntr8pXSyt6D2YYqXRyPcFW)"; + let (wallet, _, _) = get_funded_wallet(descriptor); + + let message = "This belongs to me."; + let mut psbt = get_signed_proof(); + psbt.inputs[1].final_script_sig = None; + psbt.inputs[1].final_script_witness = None; + + let tx = psbt.extract_tx(); + + tx.verify_reserve_proof(message, &wallet).unwrap(); + } + #[test] #[should_panic(expected = "SignatureValidation")] fn invalid_signature() { @@ -484,6 +616,29 @@ mod test { wallet.verify_proof(&psbt, message, None).unwrap(); } + #[test] + #[should_panic(expected = "SignatureValidation")] + fn invalid_signature_tx() { + let descriptor = "wpkh(cVpPVruEDdmutPzisEsYvtST1usBR3ntr8pXSyt6D2YYqXRyPcFW)"; + let (wallet, _, _) = get_funded_wallet(descriptor); + + let message = "This belongs to me."; + let mut psbt = get_signed_proof(); + psbt.inputs[1].final_script_sig = None; + + let invalid_signature = Signature::from_str("3045022100f3b7b0b1400287766edfe8ba66bc0412984cdb97da6bb4092d5dc63a84e1da6f02204da10796361dbeaeead8f68a23157dffa23b356ec14ec2c0c384ad68d582bb14").unwrap(); + let invalid_signature = SerializedSignature::from_signature(&invalid_signature); + + let mut invalid_witness = Witness::new(); + invalid_witness.push_bitcoin_signature(&invalid_signature, EcdsaSighashType::All); + + psbt.inputs[1].final_script_witness = Some(invalid_witness); + + let tx = psbt.extract_tx(); + + tx.verify_reserve_proof(message, &wallet).unwrap(); + } + #[test] #[should_panic(expected = "UnsupportedSighashType(1)")] fn wrong_sighash_type() { @@ -513,6 +668,24 @@ mod test { wallet.verify_proof(&psbt, message, None).unwrap(); } + #[test] + #[should_panic(expected = "InvalidOutput")] + fn invalid_output_tx() { + let descriptor = "wpkh(cVpPVruEDdmutPzisEsYvtST1usBR3ntr8pXSyt6D2YYqXRyPcFW)"; + let (wallet, _, _) = get_funded_wallet(descriptor); + + let message = "This belongs to me."; + let mut psbt = get_signed_proof(); + + let pkh = PubkeyHash::from_hash(hash160::Hash::hash(&[0, 1, 2, 3])); + let out_script_unspendable = Script::new_p2pkh(&pkh); + psbt.unsigned_tx.output[0].script_pubkey = out_script_unspendable; + + let tx = psbt.extract_tx(); + + tx.verify_reserve_proof(message, &wallet).unwrap(); + } + #[test] #[should_panic(expected = "InAndOutValueNotEqual")] fn sum_mismatch() { @@ -525,4 +698,38 @@ mod test { wallet.verify_proof(&psbt, message, None).unwrap(); } + + #[test] + #[should_panic(expected = "InAndOutValueNotEqual")] + fn sum_mismatch_tx() { + let descriptor = "wpkh(cVpPVruEDdmutPzisEsYvtST1usBR3ntr8pXSyt6D2YYqXRyPcFW)"; + let (wallet, _, _) = get_funded_wallet(descriptor); + + let message = "This belongs to me."; + let mut psbt = get_signed_proof(); + psbt.unsigned_tx.output[0].value = 123; + + let tx = psbt.extract_tx(); + + tx.verify_reserve_proof(message, &wallet).unwrap(); + } + + fn tx_from_hex(s: &str) -> Transaction { + use bdk::bitcoin::hashes::hex::FromHex; + let tx = as FromHex>::from_hex(s).unwrap(); + + deserialize(tx.as_slice()).unwrap() + } + + #[test] + fn test_signed_tx() { + let tx = tx_from_hex("0100000000010276b34e909b108f1d2079a29402bc6972ba663de73dc132e6f6731ccc3f4b3a320000000000ffffffffda3a21334ce7a172174f5960fb24abbc6aedab52065cf273a5f8bf7a6915f6220000000000ffffffff0150c30000000000001976a9149f7fd096d37ed2c0e3f7f0cfc924beef4ffceb6888ac000247304402205fa45f29335e82035cc92e4be5da0679ce218769da59b75ff25b2890d9d07c9f022049d05c6b205246142fbe36441321b26834815893a23d4fb0c3a28700f29e07fc0121032b0558078bec38694a84933d659303e2575dae7e91685911454115bfd64487e300000000"); + + let descriptor = "wpkh(cVpPVruEDdmutPzisEsYvtST1usBR3ntr8pXSyt6D2YYqXRyPcFW)"; + let (wallet, _, _) = get_funded_wallet(descriptor); + + let message = "This belongs to me."; + + tx.verify_reserve_proof(message, &wallet).unwrap(); + } } diff --git a/src/txout_set.rs b/src/txout_set.rs new file mode 100644 index 0000000..a9ac302 --- /dev/null +++ b/src/txout_set.rs @@ -0,0 +1,517 @@ +use bdk::bitcoin::{OutPoint, TxOut}; +#[cfg(any(feature = "rpc"))] +use bdk::bitcoin::Script; +#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +use bdk::bitcoin::{Transaction, Txid}; +use bdk::database::BatchDatabase; +use bdk::wallet::Wallet; + +#[cfg(feature = "rpc")] +use bitcoincore_rpc::{Client as RpcClient, RpcApi}; + +#[cfg(feature = "electrum")] +use electrum_client::{Client as ElectrumClient, ElectrumApi}; + +#[cfg(feature = "use-esplora-blocking")] +use esplora_client::BlockingClient as EsploraClient; + +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::iter::FromIterator; + +/// Trait to look up `TxOut`s by `OutPoint` +pub trait TxOutSet { + /// Lookup error return type + type Error; + + /// Atomically look up txouts + fn get_prevouts<'a, I, T>(&self, outpoints: I) -> Result + where + I: IntoIterator, + T: FromIterator>; +} + +/// Trait to get the current UTXO set at the tip of the blockchain +pub trait TipTxOutQuery<'a> { + type Target; + + /// Get a TxOutSet representing the TxOutSet at the tip of the blockchain + fn txout_set_at_tip(&'a self) -> Self::Target; +} + +/// Trait to get a TxOutSet with a consistent view of the +/// blockchain at a given height +pub trait HistoricalTxOutQuery<'a> { + type Target; + + /// Get a TxOutSet representing the actual TxOutSet at that block height. + /// This permits an accurate historical snapshot of a point in time. + fn txout_set_at_height(&'a self, height: u32) -> Self::Target; +} + +/// Trait to get the current UTXO set, excluding UTXOs confirmed after a given +/// height, and also excluding UTXOs known to be spent since that height. +pub trait MaxHeightTxOutQuery<'a> { + type Target; + + fn txout_set_confirmed_by_height(&'a self, height: u32) -> Self::Target; +} + +impl TxOutSet for &Wallet +where + D: BatchDatabase, +{ + type Error = bdk::Error; + + fn get_prevouts<'a, I, T>(&self, outpoints: I) -> Result + where + I: IntoIterator, + T: FromIterator>, + { + let wallet_at_height = self.txout_set_confirmed_by_height(u32::MAX); + + wallet_at_height.get_prevouts(outpoints) + } +} + +impl<'a, T: TxOutSet + 'a> TipTxOutQuery<'a> for T { + type Target = &'a Self; + + fn txout_set_at_tip(&'a self) -> Self::Target { + self + } +} + +impl TxOutSet for &S { + type Error = ::Error; + + fn get_prevouts<'a, I, T>(&self, outpoints: I) -> Result + where + I: IntoIterator, + T: FromIterator>, + { + (*self).get_prevouts(outpoints) + } +} + +impl TxOutSet for &BTreeMap { + type Error = (); + + fn get_prevouts<'b, I, T>(&self, outpoints: I) -> Result + where + I: IntoIterator, + T: FromIterator>, + { + let iter = outpoints + .into_iter() + .map(|outpoint| self.get(outpoint).map(|txout| txout.to_owned())); + + Ok(T::from_iter(iter)) + } +} + +/// Adapter for a wallet to a TxOutSet at a particular block height +pub struct WalletConfirmedByHeight<'a, D> +where + D: BatchDatabase, +{ + wallet: &'a Wallet, + max_block_height: u32, +} + +impl<'a, D> MaxHeightTxOutQuery<'a> for Wallet +where + D: BatchDatabase + 'a, +{ + type Target = WalletConfirmedByHeight<'a, D>; + + fn txout_set_confirmed_by_height(&'a self, height: u32) -> Self::Target { + WalletConfirmedByHeight { + wallet: self, + max_block_height: height, + } + } +} + +impl<'a, D> TxOutSet for WalletConfirmedByHeight<'a, D> +where + D: BatchDatabase, +{ + type Error = bdk::Error; + + fn get_prevouts<'b, I, T>(&self, outpoints: I) -> Result + where + I: IntoIterator, + T: FromIterator>, + { + let outpoints: Vec<_> = outpoints.into_iter().collect(); + + let outpoint_set: BTreeSet<&OutPoint> = outpoints.iter().copied().collect(); + + let tx_heights: BTreeMap<_, _> = if self.max_block_height < u32::MAX { + outpoint_set + .iter() + .map(|outpoint| { + let tx_details = match self.wallet.get_tx(&outpoint.txid, false)? { + Some(tx_details) => tx_details, + None => { + return Ok((outpoint.txid, None)); + } + }; + + Ok(( + outpoint.txid, + tx_details + .confirmation_time + .map(|tx_details| tx_details.height), + )) + }) + .filter_map(|result| match result { + Ok((txid, Some(height))) => Some(Ok((txid, height))), + Ok((_, None)) => None, + Err(e) => Some(Err(e)), + }) + .collect::>()? + } else { + // If max_block_height is u32::MAX, skip the potentially expensive tx detail lookup + BTreeMap::new() + }; + + let unspent: BTreeMap<_, _> = self + .wallet + .list_unspent()? + .into_iter() + .filter_map(|output| { + if outpoint_set.contains(&output.outpoint) { + let confirmation_height = + tx_heights.get(&output.outpoint.txid).unwrap_or(&u32::MAX); + + if *confirmation_height <= self.max_block_height { + Some((output.outpoint, output.txout)) + } else { + None + } + } else { + None + } + }) + .collect(); + + let iter = outpoints + .into_iter() + .map(|outpoint| unspent.get(outpoint).map(|outpoint| outpoint.to_owned())); + + Ok(T::from_iter(iter)) + } +} + +#[cfg(feature = "rpc")] +pub struct RpcAtHeight<'a> { + client: &'a RpcClient, + maximum_txout_height: Option, +} + +#[cfg(feature = "rpc")] +impl TxOutSet for RpcClient { + type Error = bitcoincore_rpc::Error; + + fn get_prevouts<'a, I, T>(&self, outpoints: I) -> Result + where + I: IntoIterator, + T: FromIterator>, + { + let rpc_at_height = RpcAtHeight { client: self, maximum_txout_height: None }; + + rpc_at_height.get_prevouts(outpoints) + } +} + +#[cfg(feature = "rpc")] +impl<'a> MaxHeightTxOutQuery<'a> for RpcClient { + type Target = RpcAtHeight<'a>; + + fn txout_set_confirmed_by_height(&'a self, height: u32) -> Self::Target { + RpcAtHeight { client: self, maximum_txout_height: Some(height) } + } +} + +#[cfg(feature = "rpc")] +impl<'a> TxOutSet for RpcAtHeight<'a> { + type Error = bitcoincore_rpc::Error; + + fn get_prevouts<'b, I, T>(&self, outpoints: I) -> Result + where + I: IntoIterator, + T: FromIterator>, + { + let outpoints: Vec<_> = outpoints.into_iter().collect(); + + let iter = outpoints.iter().map(|outpoint| { + let prevout = match self.client.get_tx_out(&outpoint.txid, outpoint.vout, Some(false))? { + Some(prevout) => prevout, + None => { + return Ok(None); + } + }; + + if let Some(maximum_txout_height) = self.maximum_txout_height { + let blockchain_tip_info = self.client.get_block_header_info(&prevout.bestblock)?; + + let block_height = blockchain_tip_info.height - (prevout.confirmations as usize) + 1; + + if block_height > (maximum_txout_height as usize) { + return Ok(None); + } + }; + + let output_script_pubkey: Script = prevout.script_pub_key.hex.into(); + + let txout = TxOut { + script_pubkey: output_script_pubkey.clone(), + value: prevout.value.to_sat(), + }; + + Ok(Some(txout)) + }); + + Result::::from_iter(iter) + } +} + +#[cfg(feature = "electrum")] +pub struct ElectrumAtHeight<'a> { + client: &'a ElectrumClient, + maximum_txout_height: Option, +} + +#[cfg(feature = "electrum")] +impl TxOutSet for ElectrumClient { + type Error = electrum_client::Error; + + fn get_prevouts<'a, I, T>(&self, outpoints: I) -> Result + where + I: IntoIterator, + T: FromIterator>, + { + let electrum_at_height = ElectrumAtHeight { + client: self, + maximum_txout_height: None, + }; + + electrum_at_height.get_prevouts(outpoints) + } +} + +#[cfg(feature = "electrum")] +impl<'a> MaxHeightTxOutQuery<'a> for ElectrumClient { + type Target = ElectrumAtHeight<'a>; + + fn txout_set_confirmed_by_height(&'a self, height: u32) -> Self::Target { + ElectrumAtHeight { + client: self, + maximum_txout_height: Some(height), + } + } +} + +#[cfg(feature = "electrum")] +impl<'a> TxOutSet for ElectrumAtHeight<'a> { + type Error = electrum_client::Error; + + fn get_prevouts<'b, I, T>(&self, outpoints: I) -> Result + where + I: IntoIterator, + T: FromIterator>, + { + let outpoints: Vec<_> = outpoints.into_iter().collect(); + + let input_txids: BTreeSet = outpoints.iter().map(|outpoint| outpoint.txid).collect(); + + // avoiding the obvious batch_transaction_get optimization because + // I'm not sure how it handles cases where some transactions are present but not others + // FIXME: Probably should retain some types of errors here + // and report them later + let transactions: BTreeMap<&Txid, Transaction> = input_txids + .iter() + .filter_map(|txid| { + self.client + .transaction_get(txid) + .map(|tx| Some((txid, tx))) + .unwrap_or(None) + }) + .collect(); + + let iter = outpoints.iter().map(|outpoint| { + let previous_tx = match transactions.get(&outpoint.txid) { + Some(previous_tx) => previous_tx, + None => { + return Ok(None); + } + }; + + let output = match previous_tx.output.get(outpoint.vout as usize) { + Some(output) => output, + None => { + return Ok(None); + } + }; + + let unspent = self.client.script_list_unspent(&output.script_pubkey)?; + + let output_in_unspent_list = unspent.iter().find(|unspent_info| { + unspent_info.tx_hash == outpoint.txid + && unspent_info.tx_pos == outpoint.vout as usize + && unspent_info.height + <= (self.maximum_txout_height.unwrap_or(u32::MAX) as usize) + }); + + match output_in_unspent_list { + Some(_) => Ok(Some(output.to_owned())), + None => Ok(None), + } + }); + + Result::::from_iter(iter) + } +} + +#[cfg(feature = "use-esplora-blocking")] +pub struct EsploraAtHeight<'a> { + client: &'a EsploraClient, + height: Option, +} + +#[cfg(feature = "use-esplora-blocking")] +impl<'a> EsploraAtHeight<'a> { + pub fn new(client: &'a EsploraClient, height: Option) -> Self { + Self { client, height } + } +} + +#[cfg(feature = "use-esplora-blocking")] +impl<'a> TxOutSet for EsploraAtHeight<'a> { + type Error = esplora_client::Error; + + fn get_prevouts<'b, I, T>(&self, outpoints: I) -> Result + where + I: IntoIterator, + T: FromIterator>, + { + let outpoints: Vec<_> = outpoints.into_iter().collect(); + + // Remove duplicate txids + let input_txids: BTreeSet = outpoints.iter().map(|outpoint| outpoint.txid).collect(); + + let transactions: BTreeMap<&Txid, Transaction> = input_txids + .iter() + .filter_map(|txid| { + let transaction = self.client.get_tx(txid).unwrap_or(None); + + // Get the block height of the input transaction if + // this TxOutSet is restricted to a specific height. + let height = if self.height.is_some() { + self.client + .get_tx_status(txid) + .map(|tx_status| { + tx_status + .map(|tx_status| tx_status.block_height) + .unwrap_or(None) + }) + .unwrap_or(None) + } else { + None + }; + + match (self.height, height) { + (None, Some(_height)) => None, //Should be unreachable really + (Some(_maximum_height), None) => None, + (Some(maximum_height), Some(height)) if height > maximum_height => None, + (Some(_maximum_height), Some(_height)) => { + transaction.map(|transaction| (txid, transaction)) + } + (None, None) => transaction.map(|transaction| (txid, transaction)), + } + }) + .collect(); + + let prevouts = outpoints + .iter() + .map(|outpoint| -> Result, Self::Error> { + let txout = transactions + .get(&outpoint.txid) + .and_then(|transaction| transaction.output.get(outpoint.vout as usize)); + + let txout = if let Some(txout) = txout { + txout + } else { + return Ok(None); + }; + + let txout_status = self + .client + .get_output_status(&outpoint.txid, outpoint.vout as u64)?; + + if let Some(txout_status) = txout_status { + let spending_tx_height = txout_status + .status + .map(|status| status.block_height) + .unwrap_or(None); + + match (self.height, spending_tx_height) { + // Ignore spends at later/highre blo + (Some(height), Some(spending_tx_height)) if height < spending_tx_height => { + } + (_, Some(_spending_tx_height)) => { + return Ok(None); + } + (_, _) if txout_status.spent => { + return Ok(None); + } + _ => {} + }; + } else { + // Esplora will return a non-None result for all known transaction outputs. + // Since we've already retrieved the transaction, and confirmed the relevant + // output exists, this should be unreachable unless the esplora instance is + // broken or malicious. Returning a None is the best way I can see to handle + // this, as a panic would enable a malicious esplora instance to cause much + // greater trouble. + return Ok(None); + } + + Ok(Some(txout.clone())) + }); + + Result::::from_iter(prevouts) + } +} + +#[cfg(feature = "use-esplora-blocking")] +impl<'a> HistoricalTxOutQuery<'a> for EsploraClient { + type Target = EsploraAtHeight<'a>; + + fn txout_set_at_height(&'a self, height: u32) -> Self::Target { + EsploraAtHeight { + client: self, + height: Some(height), + } + } +} + +#[cfg(feature = "use-esplora-blocking")] +impl<'a> TxOutSet for EsploraClient { + type Error = esplora_client::Error; + + fn get_prevouts<'b, I, T>(&self, outpoints: I) -> Result + where + I: IntoIterator, + T: FromIterator>, + { + let esplora_at_height = EsploraAtHeight { + client: self, + height: None, + }; + + esplora_at_height.get_prevouts(outpoints) + } +} diff --git a/tests/mempool.rs b/tests/mempool.rs index a832975..68119bd 100644 --- a/tests/mempool.rs +++ b/tests/mempool.rs @@ -69,11 +69,19 @@ fn unconfirmed() -> Result<(), ProofError> { new_balance.untrusted_pending + new_balance.confirmed ); + let tx = psbt.extract_tx(); + + let spendable = tx.verify_reserve_proof(message, &wallet)?; + assert_eq!( + spendable, + new_balance.untrusted_pending + new_balance.confirmed + ); + Ok(()) } #[test] -#[should_panic(expected = "NonSpendableInput")] +#[should_panic(expected = "OutpointNotFound")] fn confirmed() { let wallet = construct_wallet( "wpkh(cTTgG6x13nQjAeECaCaDrjrUdcjReZBGspcmNavsnSRyXq7zXT7r)", @@ -128,5 +136,70 @@ fn confirmed() { let spendable = wallet .verify_proof(&psbt, message, Some(max_confirmation_height)) .unwrap(); + + assert_eq!(spendable, new_balance.confirmed); +} + +#[test] +#[should_panic(expected = "OutpointNotFound")] +fn confirmed_tx() { + let wallet = construct_wallet( + "wpkh(cTTgG6x13nQjAeECaCaDrjrUdcjReZBGspcmNavsnSRyXq7zXT7r)", + Network::Regtest, + ) + .unwrap(); + + let regtestenv = RegTestEnv::new(); + regtestenv.generate(&[&wallet]); + let client = Client::new(regtestenv.electrum_url()).unwrap(); + let blockchain = ElectrumBlockchain::from(client); + wallet.sync(&blockchain, SyncOptions::default()).unwrap(); + + let balance = wallet.get_balance().unwrap(); + assert!( + balance.confirmed > 10_000, + "insufficient balance: {}", + balance + ); + let addr = wallet.get_address(AddressIndex::New).unwrap(); + assert_eq!( + addr.to_string(), + "bcrt1qexxes4qzr3m6a6mcqrp0d4xexagw08fgy97gss" + ); + + let mut builder = wallet.build_tx(); + builder + .add_recipient(addr.script_pubkey(), 1_000) + .fee_rate(bdk::FeeRate::from_sat_per_vb(2.0)); + let (mut psbt, _) = builder.finish().unwrap(); + let signopts = SignOptions { + trust_witness_utxo: true, + ..Default::default() + }; + let finalized = wallet.sign(&mut psbt, signopts.clone()).unwrap(); + assert!(finalized); + blockchain.broadcast(&psbt.extract_tx()).unwrap(); + wallet.sync(&blockchain, SyncOptions::default()).unwrap(); + + let new_balance = wallet.get_balance().unwrap(); + assert_ne!(balance, new_balance); + + let message = "This belongs to me."; + let mut psbt = wallet.create_proof(message).unwrap(); + let finalized = wallet.sign(&mut psbt, signopts).unwrap(); + assert!(finalized); + + const CONFIRMATIONS: u32 = 2; + let current_height = blockchain.get_height().unwrap(); + let max_confirmation_height = current_height - CONFIRMATIONS; + + let tx = psbt.extract_tx(); + + let spendable = tx + .verify_reserve_proof( + message, + wallet.txout_set_confirmed_by_height(max_confirmation_height), + ) + .unwrap(); assert_eq!(spendable, new_balance.confirmed); } diff --git a/tests/multi_sig.rs b/tests/multi_sig.rs index fa70570..0d3c5f5 100644 --- a/tests/multi_sig.rs +++ b/tests/multi_sig.rs @@ -160,5 +160,9 @@ fn test_proof_multisig( let balance = wallets[0].get_balance()?; assert_eq!(spendable, balance.confirmed); + let tx = psbt.extract_tx(); + let spendable = tx.verify_reserve_proof(message, &wallets[0])?; + assert_eq!(spendable, balance.confirmed); + Ok(()) } diff --git a/tests/regtestenv.rs b/tests/regtestenv.rs index 5bcb387..4e68b7b 100644 --- a/tests/regtestenv.rs +++ b/tests/regtestenv.rs @@ -1,4 +1,7 @@ +use bdk::bitcoin::Network; use bdk::blockchain::{electrum::ElectrumBlockchain, Blockchain}; +#[cfg(feature = "rpc")] +use bdk::blockchain::{rpc::Auth as RpcAuth, RpcConfig}; use bdk::database::memory::MemoryDatabase; use bdk::electrum_client::Client; use bdk::wallet::{AddressIndex, SyncOptions, Wallet}; @@ -30,6 +33,7 @@ impl RegTestEnv { let mut elect_conf = electrsd::Conf::default(); elect_conf.view_stderr = false; // setting this to true will lead to very verbose logging + elect_conf.http_enabled = true; let elect_exe = electrsd::downloaded_exe_path().expect("We should always have downloaded path"); let electrsd = ElectrsD::with_conf(elect_exe, &bitcoind, &elect_conf).unwrap(); @@ -37,12 +41,31 @@ impl RegTestEnv { RegTestEnv { bitcoind, electrsd } } - /// returns the URL where a client can connect to the embedded electrum server + #[allow(dead_code)] + #[cfg(feature = "rpc")] + pub fn bitcoind_conf(&self, wallet: String) -> RpcConfig { + RpcConfig { + url: self.bitcoind.rpc_url(), + auth: RpcAuth::Cookie { file: self.bitcoind.params.cookie_file.clone() }, + network: Network::Regtest, + wallet_name: wallet, + sync_params: None, + } + } + + /// returns the URL where an electrum client can connect to the embedded electrum server pub fn electrum_url(&self) -> &str { &self.electrsd.electrum_url } + #[allow(dead_code)] + /// returns the URL where an esplora client can connect to the embedded esplora server + pub fn esplora_url(&self) -> &Option { + &self.electrsd.esplora_url + } + /// generates some blocks to have some coins to test with + /// @wallets: either a single wallet, or all the signer wallets that belong to the same multisig. pub fn generate(&self, wallets: &[&Wallet]) { let addr2 = wallets[0].get_address(AddressIndex::Peek(1)).unwrap(); let addr1 = wallets[0].get_address(AddressIndex::Peek(0)).unwrap(); @@ -89,7 +112,7 @@ impl RegTestEnv { .for_each(|wallet| wallet.sync(&blockchain, SyncOptions::default()).unwrap()); } - fn generate_to_address(&self, blocks: usize, address: &Address) { + pub fn generate_to_address(&self, blocks: usize, address: &Address) { let old_height = self .electrsd .client diff --git a/tests/single_sig.rs b/tests/single_sig.rs index 7b3f44d..16fd309 100644 --- a/tests/single_sig.rs +++ b/tests/single_sig.rs @@ -38,5 +38,10 @@ fn test_proof(#[case] descriptor: &'static str) -> Result<(), ProofError> { let spendable = wallet.verify_proof(&psbt, message, None)?; assert_eq!(spendable, balance.confirmed); + let tx = psbt.extract_tx(); + + let spendable = tx.verify_reserve_proof(message, &wallet).unwrap(); + assert_eq!(spendable, balance.confirmed); + Ok(()) } diff --git a/tests/tampering.rs b/tests/tampering.rs index 0c4a53b..844f0c0 100644 --- a/tests/tampering.rs +++ b/tests/tampering.rs @@ -24,6 +24,12 @@ fn tampered_proof_message() { .unwrap(); assert_eq!(spendable, balance.confirmed); + let tx_alice = psbt_alice.clone().extract_tx(); + let spendable = tx_alice + .verify_reserve_proof(message_alice, &wallet) + .unwrap(); + assert_eq!(spendable, balance.confirmed); + // change the message let message_bob = "This belongs to Bob."; let psbt_bob = wallet.create_proof(message_bob).unwrap(); @@ -34,8 +40,15 @@ fn tampered_proof_message() { let res_bob = wallet.verify_proof(&psbt_alice, message_bob, None); assert!(res_alice.is_err()); assert!(res_bob.is_err()); + + let tx_alice = psbt_alice.extract_tx(); + let res_alice = tx_alice.verify_reserve_proof(message_alice, &wallet); + let tx_bob = psbt_bob.extract_tx(); + let res_bob = tx_bob.verify_reserve_proof(message_bob, &wallet); + assert!(res_alice.is_err()); + assert!(res_bob.is_err()); + res_alice.unwrap(); - res_bob.unwrap(); } #[test] @@ -83,3 +96,28 @@ fn tampered_proof_miner_fee() { let _spendable = wallet.verify_proof(&psbt, message, None).unwrap(); } + +#[test] +#[should_panic(expected = "InAndOutValueNotEqual")] +fn tampered_proof_miner_fee_tx() { + let descriptor = "wpkh(cVpPVruEDdmutPzisEsYvtST1usBR3ntr8pXSyt6D2YYqXRyPcFW)"; + let (wallet, _, _) = get_funded_wallet(descriptor); + + let message = "This belongs to Alice."; + let mut psbt = wallet.create_proof(message).unwrap(); + + let signopt = SignOptions { + trust_witness_utxo: true, + allow_all_sighashes: true, + ..Default::default() + }; + + // reduce the output value to grant a miner fee + psbt.unsigned_tx.output[0].value -= 100; + + let _finalized = wallet.sign(&mut psbt, signopt).unwrap(); + + let tx = psbt.extract_tx(); + + let _spendable = tx.verify_reserve_proof(message, &wallet).unwrap(); +} diff --git a/tests/txout_set.rs b/tests/txout_set.rs new file mode 100644 index 0000000..2320614 --- /dev/null +++ b/tests/txout_set.rs @@ -0,0 +1,233 @@ +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] +mod regtestenv; + +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] +use bdk::bitcoin::Network; +#[cfg(feature = "electrum")] +use bdk::blockchain::electrum::ElectrumBlockchain; +#[cfg(feature = "use-esplora-blocking")] +use bdk::blockchain::esplora::EsploraBlockchain; +#[cfg(feature = "rpc")] +use bdk::blockchain::rpc::RpcBlockchain; +#[cfg(feature = "rpc")] +use bdk::blockchain::ConfigurableBlockchain; +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] +use bdk::blockchain::{Blockchain, GetHeight}; +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] +use bdk::database::memory::MemoryDatabase; +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] +use bdk::wallet::{SyncOptions, Wallet}; +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] +use bdk::Error; +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] +use bdk::SignOptions; +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] +use bdk_reserves::reserves::*; +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] +use electrsd::bitcoind::bitcoincore_rpc::bitcoin::Address; + +#[cfg(feature = "rpc")] +use bitcoincore_rpc::Client as RpcClient; + +#[cfg(feature = "electrum")] +use electrum_client::Client as ElectrumClient; + +#[cfg(feature = "use-esplora-blocking")] +use esplora_client::{BlockingClient as EsploraClient, Builder}; +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] +use regtestenv::RegTestEnv; +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] +use std::str::FromStr; + +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] +fn construct_wallet(desc: &str, network: Network) -> Result, Error> { + let wallet = Wallet::new(desc, None, network, MemoryDatabase::default())?; + + Ok(wallet) +} + +#[cfg(feature = "use-esplora-blocking")] +fn point_in_time(regtestenv: RegTestEnv, blockchain: B) +where + B: Blockchain + GetHeight + std::ops::Deref, + C: for<'b> HistoricalTxOutQuery<'b> + for<'b> TipTxOutQuery<'b>, + for<'b> >::Target: TxOutSet + 'b, + for<'b> >::Target: TxOutSet + 'b, +{ + let wallet = construct_wallet( + "wpkh(cTTgG6x13nQjAeECaCaDrjrUdcjReZBGspcmNavsnSRyXq7zXT7r)", + Network::Regtest, + ) + .unwrap(); + + regtestenv.generate(&[&wallet]); + wallet.sync(&blockchain, SyncOptions::default()).unwrap(); + let old_height = blockchain.get_height().unwrap(); + let old_balance = wallet.get_balance().unwrap(); + + let message = "This belonged to me."; + let mut psbt = wallet.create_proof(message).unwrap(); + let signopts = SignOptions { + trust_witness_utxo: true, + ..Default::default() + }; + let finalized = wallet.sign(&mut psbt, signopts.clone()).unwrap(); + let proof = psbt; + assert!(finalized); + + let txouts_point_in_time = blockchain.txout_set_at_height(old_height); + + let spendable = proof + .verify_reserve_proof(message, txouts_point_in_time) + .unwrap(); + assert_eq!(spendable, dbg!(old_balance.confirmed)); + + proof + .verify_reserve_proof(message, blockchain.txout_set_at_tip()) + .unwrap(); + + const MY_FOREIGN_ADDR: &str = "mpSFfNURcFTz2yJxBzRY9NhnozxeJ2AUC8"; + let foreign_addr = Address::from_str(MY_FOREIGN_ADDR).unwrap(); + let mut builder = wallet.build_tx(); + builder + .add_recipient(foreign_addr.script_pubkey(), 1_000) + .fee_rate(bdk::FeeRate::from_sat_per_vb(2.0)); + + let (mut psbt, _) = builder.finish().unwrap(); + let finalized = wallet.sign(&mut psbt, signopts).unwrap(); + assert!(finalized); + blockchain.broadcast(&psbt.extract_tx()).unwrap(); + regtestenv.generate_to_address(6, &foreign_addr); + wallet.sync(&blockchain, SyncOptions::default()).unwrap(); + + let new_balance = wallet.get_balance().unwrap(); + assert_ne!(old_balance, new_balance); + + // creating a new object is not necessary, but illustrates that no state is being saved across + // calls to verify_reserve_proof + let new_txouts_point_in_time = blockchain.txout_set_at_height(old_height); + let spendable = proof + .verify_reserve_proof(message, new_txouts_point_in_time) + .unwrap(); + assert_eq!(spendable, old_balance.confirmed); + + let new_height = blockchain.get_height().unwrap(); + let new_txouts_point_in_time = blockchain.txout_set_at_height(new_height); + + proof + .verify_reserve_proof(message, new_txouts_point_in_time) + .expect_err("expect proof utxos to be spent"); + + proof + .verify_reserve_proof(message, blockchain.txout_set_at_tip()) + .expect_err("expect proof utxos to be spent at tip"); +} + +#[cfg(any(feature = "electrum", feature = "rpc"))] +fn confirmed_by(regtestenv: RegTestEnv, blockchain: B) +where + B: Blockchain + GetHeight + std::ops::Deref, + C: for<'b> MaxHeightTxOutQuery<'b> + for<'b> TipTxOutQuery<'b>, + for<'b> >::Target: TxOutSet + 'b, + for<'b> >::Target: TxOutSet + 'b, +{ + let wallet = construct_wallet( + "wpkh(cTTgG6x13nQjAeECaCaDrjrUdcjReZBGspcmNavsnSRyXq7zXT7r)", + Network::Regtest, + ) + .unwrap(); + + regtestenv.generate(&[&wallet]); + wallet.sync(&blockchain, SyncOptions::default()).unwrap(); + let old_height = blockchain.get_height().unwrap(); + let old_balance = wallet.get_balance().unwrap(); + + let message = "This belonged to me."; + let mut psbt = wallet.create_proof(message).unwrap(); + let signopts = SignOptions { + trust_witness_utxo: true, + ..Default::default() + }; + let finalized = wallet.sign(&mut psbt, signopts.clone()).unwrap(); + let proof = psbt; + assert!(finalized); + + let spendable = proof + .verify_reserve_proof(message, blockchain.txout_set_at_tip()) + .unwrap(); + assert_eq!(spendable, dbg!(old_balance.confirmed)); + + let txouts = blockchain.txout_set_confirmed_by_height(old_height); + + let spendable = proof.verify_reserve_proof(message, txouts).unwrap(); + assert_eq!(spendable, old_balance.confirmed); + + const MY_FOREIGN_ADDR: &str = "mpSFfNURcFTz2yJxBzRY9NhnozxeJ2AUC8"; + let foreign_addr = Address::from_str(MY_FOREIGN_ADDR).unwrap(); + let mut builder = wallet.build_tx(); + builder + .add_recipient(foreign_addr.script_pubkey(), 1_000) + .fee_rate(bdk::FeeRate::from_sat_per_vb(2.0)); + + let (mut psbt, _) = builder.finish().unwrap(); + let finalized = wallet.sign(&mut psbt, signopts).unwrap(); + assert!(finalized); + blockchain.broadcast(&psbt.extract_tx()).unwrap(); + + let txouts = blockchain.txout_set_confirmed_by_height(old_height); + + proof + .verify_reserve_proof(message, txouts) + .expect_err("expect coins to be spent"); + + regtestenv.generate_to_address(6, &foreign_addr); + wallet.sync(&blockchain, SyncOptions::default()).unwrap(); + + let new_balance = wallet.get_balance().unwrap(); + assert_ne!(old_balance, new_balance); + + let new_height = blockchain.get_height().unwrap(); + let new_txouts_point_in_time = blockchain.txout_set_confirmed_by_height(new_height); + + proof + .verify_reserve_proof(message, new_txouts_point_in_time) + .expect_err("expect proof utxos to be spent"); + + proof + .verify_reserve_proof(message, blockchain.txout_set_at_tip()) + .expect_err("expect proof utxos to be spent at tip"); +} + +#[test] +#[cfg(feature = "electrum")] +fn test_electrum_confirmed_by() { + let regtestenv = RegTestEnv::new(); + let electrum_url = regtestenv.electrum_url(); + let client = ElectrumClient::new(electrum_url).unwrap(); + let blockchain = ElectrumBlockchain::from(client); + + confirmed_by::(regtestenv, blockchain); +} + +#[test] +#[cfg(feature = "rpc")] +fn test_bitcoincore_rpc_confirmed_by() { + let regtestenv = RegTestEnv::new(); + + let config = regtestenv.bitcoind_conf("".to_string()); + + let blockchain = RpcBlockchain::from_config(&config).unwrap(); + + confirmed_by::(regtestenv, blockchain); +} + +#[test] +#[cfg(feature = "use-esplora-blocking")] +fn test_esplora_point_in_time() { + let regtestenv = RegTestEnv::new(); + let esplora_url = format!("http://{}", regtestenv.esplora_url().as_ref().unwrap()); + let client = Builder::new(&esplora_url).build_blocking().unwrap(); + let blockchain = EsploraBlockchain::from_client(client, 20); + + point_in_time::(regtestenv, blockchain); +}