From a655f7c58fb4e3207aa9cbea4d85a9882ad698f9 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Mon, 11 Dec 2023 09:48:55 -0600 Subject: [PATCH 01/14] Add bip-0127 PSBT_IN_POR_COMMITMENT commitment to PSBT --- src/reserves.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/reserves.rs b/src/reserves.rs index 18a0d9d..f015cf1 100644 --- a/src/reserves.rs +++ b/src/reserves.rs @@ -24,13 +24,17 @@ use bdk::bitcoin::blockdata::transaction::{EcdsaSighashType, OutPoint, TxIn, TxO 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::util::psbt::{raw::Key, Input, PartiallySignedTransaction as PSBT}; use bdk::bitcoin::{Network, Sequence}; use bdk::database::BatchDatabase; use bdk::wallet::tx_builder::TxOrdering; use bdk::wallet::Wallet; use bdk::Error; +use std::collections::BTreeMap; + +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 @@ -108,12 +112,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() }; @@ -327,7 +341,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#"cHNidP8BAH4BAAAAAmw1RvG4UzfnSafpx62EPTyha6VslP0Er7n3TxjEpeBeAAAAAAD/////2johM0znoXIXT1lg+ySrvGrtq1IGXPJzpfi/emkV9iIAAAAAAP////8BUMMAAAAAAAAZdqkUn3/QltN+0sDj9/DPySS+70/862iIrAAAAAAAAQEKAAAAAAAAAAABUQEHAAEJE1RoaXMgYmVsb25ncyB0byBtZS4AAQEfUMMAAAAAAAAWABTs5SZXEFPahkVMngZneer50VAuaiIGAysFWAeL7DhpSoSTPWWTA+JXXa5+kWhZEUVBFb/WRIfjBOzlJlcAAA=="#; assert_eq!(psbt_b64, expected); } From e25164bec4aab32ffed5046195fe95d33912dedb Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Mon, 11 Dec 2023 09:51:01 -0600 Subject: [PATCH 02/14] Enable verification of reserve proofs in raw transactions per bip-0127 --- src/reserves.rs | 221 ++++++++++++++++++++++++++++----------------- tests/tampering.rs | 1 - 2 files changed, 137 insertions(+), 85 deletions(-) diff --git a/src/reserves.rs b/src/reserves.rs index f015cf1..84687a3 100644 --- a/src/reserves.rs +++ b/src/reserves.rs @@ -25,7 +25,7 @@ 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::{raw::Key, Input, PartiallySignedTransaction as PSBT}; -use bdk::bitcoin::{Network, Sequence}; +use bdk::bitcoin::{Network, Sequence, Transaction}; use bdk::database::BatchDatabase; use bdk::wallet::tx_builder::TxOrdering; use bdk::wallet::Wallet; @@ -190,6 +190,130 @@ where } } +/// 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, get_prevout: F) -> Result + where + F: FnMut(&OutPoint) -> Option; + + /// 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, mut get_prevout: F) -> Result + where + F: FnMut(&OutPoint) -> Option + { + 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)?; + + // gather the proof UTXOs + let prevouts: Vec<(usize, TxOut)> = self + .input + .iter() + .enumerate() + .skip(1) + .map(|(i, input)| + get_prevout(&input.previous_output) + .map(|txout| (i, txout)) + .ok_or(ProofError::NonSpendableInput(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() + .map(|(i, prevout)| + bitcoinconsensus::verify( + prevout.script_pubkey.to_bytes().as_slice(), + prevout.value, + &serialized_tx, + *i, + ) + .map_err(|e| + ProofError::SignatureValidation(*i, format!("{:?}", e)) + ), + ) + .collect::>()?; + + // 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() + .map(|(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(()) + } + } + ) + .collect::>()?; + + Ok(sum) + } + + 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); + } + + Ok(()) + } +} + /// 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. @@ -205,29 +329,10 @@ pub fn verify_proof( ) -> Result { let tx = psbt.clone().extract_tx(); + // Redundant check to tx.verify_reserve_proof() to ensure error priority is not changed 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 @@ -247,69 +352,17 @@ pub fn verify_proof( 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 - } - }) - .sum(); - - // inflow and outflow being equal means no miner fee - if tx.output[0].value != sum { - return Err(ProofError::InAndOutValueNotEqual); - } - - // verify the unspendable output - let pkh = PubkeyHash::from_hash(hash160::Hash::hash(&[0])); - let out_script_unspendable = Script::new_p2pkh(&pkh); - - if tx.output[0].script_pubkey != out_script_unspendable { - return Err(ProofError::InvalidOutput); - } - - let serialized_tx = serialize(&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) + tx.verify_reserve_proof(message, |search_outpoint| + outpoints + .iter() + .find_map(|(outpoint, txout)| + if search_outpoint == outpoint { + Some(txout.clone()) + } else { + None + } + ) + ) } /// Construct a challenge input with the message diff --git a/tests/tampering.rs b/tests/tampering.rs index 0c4a53b..7d930e9 100644 --- a/tests/tampering.rs +++ b/tests/tampering.rs @@ -35,7 +35,6 @@ fn tampered_proof_message() { assert!(res_alice.is_err()); assert!(res_bob.is_err()); res_alice.unwrap(); - res_bob.unwrap(); } #[test] From 8aa9a83575b2f37df4537ac7b18a13a3274d02c6 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Fri, 15 Dec 2023 23:48:42 -0600 Subject: [PATCH 03/14] Use single sha256 for challenge txid per bip-0127 --- src/reserves.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/reserves.rs b/src/reserves.rs index 84687a3..80384b8 100644 --- a/src/reserves.rs +++ b/src/reserves.rs @@ -23,7 +23,7 @@ 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::hashes::{hash160, sha256, Hash}; use bdk::bitcoin::util::psbt::{raw::Key, Input, PartiallySignedTransaction as PSBT}; use bdk::bitcoin::{Network, Sequence, Transaction}; use bdk::database::BatchDatabase; @@ -368,9 +368,10 @@ pub fn verify_proof( /// 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() } @@ -394,7 +395,7 @@ mod test { let psbt_b64 = psbt.to_string(); - let expected = r#"cHNidP8BAH4BAAAAAmw1RvG4UzfnSafpx62EPTyha6VslP0Er7n3TxjEpeBeAAAAAAD/////2johM0znoXIXT1lg+ySrvGrtq1IGXPJzpfi/emkV9iIAAAAAAP////8BUMMAAAAAAAAZdqkUn3/QltN+0sDj9/DPySS+70/862iIrAAAAAAAAQEKAAAAAAAAAAABUQEHAAEJE1RoaXMgYmVsb25ncyB0byBtZS4AAQEfUMMAAAAAAAAWABTs5SZXEFPahkVMngZneer50VAuaiIGAysFWAeL7DhpSoSTPWWTA+JXXa5+kWhZEUVBFb/WRIfjBOzlJlcAAA=="#; + let expected = r#"cHNidP8BAH4BAAAAAnazTpCbEI8dIHmilAK8aXK6Zj3nPcEy5vZzHMw/SzoyAAAAAAD/////2johM0znoXIXT1lg+ySrvGrtq1IGXPJzpfi/emkV9iIAAAAAAP////8BUMMAAAAAAAAZdqkUn3/QltN+0sDj9/DPySS+70/862iIrAAAAAAAAQEKAAAAAAAAAAABUQEHAAEJE1RoaXMgYmVsb25ncyB0byBtZS4AAQEfUMMAAAAAAAAWABTs5SZXEFPahkVMngZneer50VAuaiIGAysFWAeL7DhpSoSTPWWTA+JXXa5+kWhZEUVBFb/WRIfjBOzlJlcAAA=="#; assert_eq!(psbt_b64, expected); } @@ -422,7 +423,7 @@ 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() } From e1350f553b53865a9efe4ac29b4c163297065aa1 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Wed, 27 Dec 2023 01:21:17 -0600 Subject: [PATCH 04/14] wip: Add tests for bip-127 style verification Significant refactor to support these tests. * Add a `TxOutSet` trait for retrieving live Utxos * Implement `TxOutSet` for `&bdk::Wallet` * Implement `TxOutSet` for `&BTreeMap` * Add `WalletAtHeight` struct to support `max_block_height` use case previously supported * Implement `TxOutSet` for `WalletAtHeight` * Eliminate `ProofError::NonSpendableInput` and use `ProofError::OutpointNotFound` instead since `NonSpendableInput` interprets the actual fact that the outpoint is not found * Eliminate dead `ProofError::NeitherWitnessNorLegacy` variant * Add `ProofError::OutpointLookupError` variant for cases where the underlying `TxOutSet` generated an error. (Such as an RPC error) * Refactor other code to use these new primitives * Remove free `verify_proof` function --- src/reserves.rs | 474 +++++++++++++++++++++++++++++++++++--------- tests/mempool.rs | 72 ++++++- tests/multi_sig.rs | 4 + tests/single_sig.rs | 5 + tests/tampering.rs | 39 ++++ 5 files changed, 495 insertions(+), 99 deletions(-) diff --git a/src/reserves.rs b/src/reserves.rs index 80384b8..d136cb5 100644 --- a/src/reserves.rs +++ b/src/reserves.rs @@ -25,13 +25,15 @@ use bdk::bitcoin::consensus::encode::serialize; use bdk::bitcoin::hash_types::{PubkeyHash, Txid}; use bdk::bitcoin::hashes::{hash160, sha256, Hash}; use bdk::bitcoin::util::psbt::{raw::Key, Input, PartiallySignedTransaction as PSBT}; -use bdk::bitcoin::{Network, Sequence, Transaction}; +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; +use std::collections::BTreeSet; +use std::iter::FromIterator; pub const PSBT_IN_POR_COMMITMENT: u8 = 0x09; @@ -65,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 @@ -81,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), } @@ -154,39 +152,146 @@ 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 + if let Some(max_block_height) = max_block_height { + let txouts = WalletAtHeight::new(self, max_block_height); + + psbt.verify_reserve_proof(message, txouts) + } else { + psbt.verify_reserve_proof(message, self) + } + } +} + +/// 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: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result; +} + +impl TxOutSet for &Wallet +where + D: BatchDatabase, +{ + type Error = bdk::Error; + + fn get_prevouts<'a, I: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result { + let wallet_at_height = WalletAtHeight::new(self, u32::MAX); + + wallet_at_height.get_prevouts(outpoints) + } +} + +impl TxOutSet for &BTreeMap { + type Error = (); + + fn get_prevouts<'a, I: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result { + 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 WalletAtHeight<'a, D> +where + D: BatchDatabase +{ + wallet: &'a Wallet, + max_block_height: u32, +} + +impl <'a, D> WalletAtHeight<'a, D> +where + D: BatchDatabase +{ + pub fn new(wallet: &'a Wallet, max_block_height: u32) -> Self { + WalletAtHeight { + wallet, + max_block_height, + } + } +} + +impl<'a, D> TxOutSet for WalletAtHeight<'a, D> +where + D: BatchDatabase +{ + type Error = bdk::Error; + + fn get_prevouts<'b, I: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result { + let outpoints: Vec<_> = outpoints + .into_iter() + .collect(); + + let outpoint_set: BTreeSet<&OutPoint> = outpoints .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)) - } + .map(|outpoint| *outpoint) + .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 { - Err(ProofError::MissingConfirmationInfo) + None } + } else { + None } }) - .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(); - verify_proof(psbt, message, outpoints, self.network()) + let iter = outpoints + .into_iter() + .map(|outpoint| + unspent + .get(outpoint) + .map(|outpoint| outpoint.to_owned()) + ); + + Ok(T::from_iter(iter)) } } @@ -194,18 +299,14 @@ where pub trait ReserveProof { /// Verify a proof transaction. /// Look up utxos with get_prevout() - fn verify_reserve_proof(&self, message: &str, get_prevout: F) -> Result - where - F: FnMut(&OutPoint) -> Option; + 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, mut get_prevout: F) -> Result - where - F: FnMut(&OutPoint) -> Option + fn verify_reserve_proof(&self, message: &str, txouts: T) -> Result { if self.output.len() != 1 { return Err(ProofError::WrongNumberOfOutputs); @@ -224,18 +325,29 @@ impl ReserveProof for Transaction { self.verify_challenge(message)?; - // gather the proof UTXOs - let prevouts: Vec<(usize, TxOut)> = self - .input + 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, input)| - get_prevout(&input.previous_output) - .map(|txout| (i, txout)) - .ok_or(ProofError::NonSpendableInput(i)) - ) - .collect::>()?; + .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) @@ -314,55 +426,48 @@ impl ReserveProof for Transaction { } } -/// 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(); - - // 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)) = 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)); - } +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 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)); - } + // Redundant check to tx.verify_reserve_proof() to ensure error priority is not changed + if tx.output.len() != 1 { + return Err(ProofError::WrongNumberOfOutputs); + } - tx.verify_reserve_proof(message, |search_outpoint| - outpoints + // verify that the inputs are signed, except the challenge + if let Some((i, _inp)) = self + .inputs .iter() - .find_map(|(outpoint, txout)| - if search_outpoint == outpoint { - Some(txout.clone()) - } else { - None - } - ) - ) + .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 @@ -380,8 +485,12 @@ fn challenge_txin(message: &str) -> TxIn { #[cfg(test)] mod test { use super::*; + use bdk::SignOptions; + use bdk::bitcoin::consensus::encode::serialize_hex; + use bdk::bitcoin::consensus::encode::deserialize; + use bdk::bitcoin::secp256k1::Secp256k1; 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; @@ -427,6 +536,11 @@ mod test { 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)"; @@ -436,10 +550,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); @@ -450,6 +569,20 @@ 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, WalletAtHeight::new(&wallet, 90)).unwrap(); + + assert_eq!(spendable, 50_000); + } + #[test] fn verify_internal_100() { let descriptor = "wpkh(cVpPVruEDdmutPzisEsYvtST1usBR3ntr8pXSyt6D2YYqXRyPcFW)"; @@ -459,6 +592,11 @@ 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, WalletAtHeight::new(&wallet, 100)).unwrap(); + + assert_eq!(spendable, 50_000); } #[test] @@ -469,11 +607,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); } @@ -489,6 +633,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() { @@ -503,6 +658,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() { @@ -517,6 +688,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() { @@ -531,6 +718,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() { @@ -552,6 +755,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() { @@ -581,6 +807,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() { @@ -593,4 +837,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(&mut 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/tests/mempool.rs b/tests/mempool.rs index a832975..5797332 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,67 @@ 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, WalletAtHeight::new(&wallet, 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/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 7d930e9..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,6 +40,14 @@ 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(); } @@ -82,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(); +} From 097c7a975b7580cd8003294bfd4225fd438ab3e8 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Sat, 6 Jan 2024 02:28:28 -0600 Subject: [PATCH 05/14] Add Esplora point in time txout set and basic tests --- Cargo.toml | 4 + src/lib.rs | 1 + src/reserves.rs | 137 +--------------- src/txout_set.rs | 406 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 413 insertions(+), 135 deletions(-) create mode 100644 src/txout_set.rs diff --git a/Cargo.toml b/Cargo.toml index fbd87e2..1d34408 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,12 @@ repository = "https://github.com/bitcoindevkit/bdk-reserves" [dependencies] bdk = { version = "0.28", default-features = false } bitcoinconsensus = "0.19.0-3" +esplora-client = { version = "0.4", default-features = false, optional = true } log = "^0.4" +[features] +use-esplora-blocking = ["esplora-client/blocking"] + [dev-dependencies] rstest = "^0.11" bdk-testutils = "^0.4" 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 d136cb5..76335e4 100644 --- a/src/reserves.rs +++ b/src/reserves.rs @@ -32,8 +32,8 @@ use bdk::wallet::Wallet; use bdk::Error; use std::collections::BTreeMap; -use std::collections::BTreeSet; -use std::iter::FromIterator; + +pub use crate::txout_set::{TxOutSet, WalletAtHeight}; pub const PSBT_IN_POR_COMMITMENT: u8 = 0x09; @@ -162,139 +162,6 @@ where } } -/// 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: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result; -} - -impl TxOutSet for &Wallet -where - D: BatchDatabase, -{ - type Error = bdk::Error; - - fn get_prevouts<'a, I: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result { - let wallet_at_height = WalletAtHeight::new(self, u32::MAX); - - wallet_at_height.get_prevouts(outpoints) - } -} - -impl TxOutSet for &BTreeMap { - type Error = (); - - fn get_prevouts<'a, I: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result { - 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 WalletAtHeight<'a, D> -where - D: BatchDatabase -{ - wallet: &'a Wallet, - max_block_height: u32, -} - -impl <'a, D> WalletAtHeight<'a, D> -where - D: BatchDatabase -{ - pub fn new(wallet: &'a Wallet, max_block_height: u32) -> Self { - WalletAtHeight { - wallet, - max_block_height, - } - } -} - -impl<'a, D> TxOutSet for WalletAtHeight<'a, D> -where - D: BatchDatabase -{ - type Error = bdk::Error; - - fn get_prevouts<'b, I: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result { - let outpoints: Vec<_> = outpoints - .into_iter() - .collect(); - - let outpoint_set: BTreeSet<&OutPoint> = outpoints - .iter() - .map(|outpoint| *outpoint) - .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)) - } -} - /// Trait for Transaction-centric proofs pub trait ReserveProof { /// Verify a proof transaction. diff --git a/src/txout_set.rs b/src/txout_set.rs new file mode 100644 index 0000000..4bb2cbf --- /dev/null +++ b/src/txout_set.rs @@ -0,0 +1,406 @@ +use bdk::bitcoin::{OutPoint, Transaction, Txid, TxOut}; +use bdk::database::BatchDatabase; +use bdk::wallet::Wallet; + +#[cfg(feature = "use-esplora-blocking" )] +use esplora_client::BlockingClient; + +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: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result; +} + +pub trait PointInTimeTxOutSet<'a> { + type Target; + + fn txout_set_at_height(&'a self, height: u32) -> Self::Target; +} + +impl TxOutSet for &Wallet +where + D: BatchDatabase, +{ + type Error = bdk::Error; + + fn get_prevouts<'a, I: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result { + let wallet_at_height = WalletAtHeight::new(self, u32::MAX); + + wallet_at_height.get_prevouts(outpoints) + } +} + +impl TxOutSet for &BTreeMap { + type Error = (); + + fn get_prevouts<'a, I: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result { + 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 WalletAtHeight<'a, D> +where + D: BatchDatabase +{ + wallet: &'a Wallet, + max_block_height: u32, +} + +impl <'a, D> WalletAtHeight<'a, D> +where + D: BatchDatabase +{ + pub fn new(wallet: &'a Wallet, max_block_height: u32) -> Self { + WalletAtHeight { + wallet, + max_block_height, + } + } +} + +impl<'a, D> TxOutSet for WalletAtHeight<'a, D> +where + D: BatchDatabase +{ + type Error = bdk::Error; + + fn get_prevouts<'b, I: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result { + let outpoints: Vec<_> = outpoints + .into_iter() + .collect(); + + let outpoint_set: BTreeSet<&OutPoint> = outpoints + .iter() + .map(|outpoint| *outpoint) + .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 = "use-esplora-blocking" )] +pub struct EsploraAtHeight<'a> { + client: &'a BlockingClient, + height: Option, +} + +#[cfg(feature = "use-esplora-blocking" )] +impl<'a> EsploraAtHeight<'a> { + pub fn new(client: &'a BlockingClient, 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: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result { + let outpoints: Vec<_> = outpoints + .into_iter() + .collect(); + + 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); + + 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) { + (Some(_maximum_height), None) => { + None + }, + (Some(maximum_height), Some(height)) if height > maximum_height => { + None + }, + (None, Some(_height)) => { + 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) { + (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 { + // FIXME: should we treat this as an error? or does this just mean it's not spent? + } + + Ok(Some(txout.clone())) + }); + + Result::::from_iter(prevouts) + } +} + +#[cfg(feature = "use-esplora-blocking" )] +impl<'a> PointInTimeTxOutSet<'a> for BlockingClient { + 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 BlockingClient { + type Error = esplora_client::Error; + + fn get_prevouts<'b, I: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result { + let esplora_at_height = EsploraAtHeight { client: self, height: None }; + + esplora_at_height.get_prevouts(outpoints) + } +} + +#[cfg(test)] +#[cfg(feature = "use-esplora-blocking" )] +mod test_esplora { + use bdk::bitcoin::{OutPoint, Txid}; + use esplora_client::{BlockingClient, Builder}; + + use std::iter::once; + use std::str::FromStr; + + use crate::txout_set::PointInTimeTxOutSet; + + use super::TxOutSet; + + const ESPLORA_URL: &str = "https://mempool.space/signet/api"; + const TEST_TX_BLOCK_HEIGHT: u32 = 175435; + + fn get_client() -> BlockingClient { + Builder::new(ESPLORA_URL) + .build_blocking() + .expect("build esplora client") + } + + fn txid(s: &str) -> Txid { + Txid::from_str(s) + .expect("parse txid") + } + + fn test_txid() -> Txid { + Txid::from_str("52e318567fc09d7ab56e9861ea8cdd970964e64a83521da94d91adf51ded5da4") + .expect("parse txid") + } + + fn test_parent_txid() -> Txid { + Txid::from_str("36e9be6467e4afe396de7a4fcbeca45e0bfaa0ea7d6344f769e5df7c80d088cb") + .expect("parse txid") + } + + #[test] + pub fn test_esplora() { + let client = get_client(); + + client.get_height() + .expect(&format!("problem with esplora \"{ESPLORA_URL}\"")); + } + + #[test] + pub fn test_confirmed_unspent_at_tip() { + let client = get_client(); + + let outpoints = [ + OutPoint { + txid: test_txid(), + vout: 1, + }, + // tx only has 3 outputs + OutPoint { + txid: test_txid(), + vout: 4, + }, + ]; + + let prevouts: Vec<_> = client.get_prevouts(outpoints.iter()) + .expect("get prevouts"); + + let valid_prevout = prevouts[0].as_ref().unwrap(); + assert!(valid_prevout.value == 699828); + assert!(prevouts[1].is_none()); + } + + #[test] + pub fn test_confirmed_at_height() { + let client = get_client(); + + let txouts = client.txout_set_at_height(TEST_TX_BLOCK_HEIGHT); + + let outpoints = [ + OutPoint { + txid: test_txid(), + vout: 1, + }, + ]; + + let prevouts: Vec<_> = txouts.get_prevouts(outpoints.iter()) + .expect("get prevouts"); + + assert!(prevouts[0].is_some()); + } + + #[test] + pub fn test_not_confirmed_at_height() { + let client = get_client(); + + let txouts = client.txout_set_at_height(TEST_TX_BLOCK_HEIGHT - 1); + + let outpoints = [ + OutPoint { + txid: test_txid(), + vout: 1, + }, + ]; + + let prevouts: Vec<_> = txouts.get_prevouts(outpoints.iter()) + .expect("get prevouts"); + + assert!(prevouts[0].is_none()); + } + + #[test] + pub fn test_spent_at_later_height() { + let client = get_client(); + + let txouts = client.txout_set_at_height(TEST_TX_BLOCK_HEIGHT - 1); + + let outpoints = [ + OutPoint { + txid: test_parent_txid(), + vout: 0, + }, + ]; + + let prevouts: Vec<_> = txouts.get_prevouts(outpoints.iter()) + .expect("get prevouts"); + + assert!(prevouts[0].is_some()); + } +} From a11cd3c664c8a66aa4f30327a5e9bf44c54bdd4e Mon Sep 17 00:00:00 2001 From: Richard Ulrich Date: Mon, 8 Jan 2024 16:18:11 +0100 Subject: [PATCH 06/14] adding a point in time test with electrsd/esplora --- Cargo.toml | 4 +- tests/point_in_time.rs | 87 ++++++++++++++++++++++++++++++++++++++++++ tests/regtestenv.rs | 11 +++++- 3 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 tests/point_in_time.rs diff --git a/Cargo.toml b/Cargo.toml index 1d34408..32ac6f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,10 +16,10 @@ esplora-client = { version = "0.4", default-features = false, optional = true } log = "^0.4" [features] -use-esplora-blocking = ["esplora-client/blocking"] +use-esplora-blocking = ["esplora-client/blocking", "bdk/use-esplora-blocking"] [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/tests/point_in_time.rs b/tests/point_in_time.rs new file mode 100644 index 0000000..fe92b22 --- /dev/null +++ b/tests/point_in_time.rs @@ -0,0 +1,87 @@ +#[cfg(feature = "use-esplora-blocking")] +mod regtestenv; +#[cfg(feature = "use-esplora-blocking")] +use bdk::bitcoin::Network; +#[cfg(feature = "use-esplora-blocking")] +use bdk::blockchain::esplora::EsploraBlockchain; +#[cfg(feature = "use-esplora-blocking")] +use bdk::blockchain::{Blockchain, GetHeight}; +#[cfg(feature = "use-esplora-blocking")] +use bdk::database::memory::MemoryDatabase; +#[cfg(feature = "use-esplora-blocking")] +use bdk::wallet::{SyncOptions, Wallet}; +#[cfg(feature = "use-esplora-blocking")] +use bdk::Error; +#[cfg(feature = "use-esplora-blocking")] +use bdk::SignOptions; +#[cfg(feature = "use-esplora-blocking")] +use bdk_reserves::reserves::*; +#[cfg(feature = "use-esplora-blocking")] +use electrsd::bitcoind::bitcoincore_rpc::bitcoin::Address; +#[cfg(feature = "use-esplora-blocking")] +use esplora_client::Builder; +#[cfg(feature = "use-esplora-blocking")] +use regtestenv::RegTestEnv; +#[cfg(feature = "use-esplora-blocking")] +use std::str::FromStr; + +fn construct_wallet(desc: &str, network: Network) -> Result, Error> { + let wallet = Wallet::new(desc, None, network, MemoryDatabase::default())?; + + Ok(wallet) +} + +#[test] +#[cfg(feature = "use-esplora-blocking")] +fn point_in_time() { + let wallet = construct_wallet( + "wpkh(cTTgG6x13nQjAeECaCaDrjrUdcjReZBGspcmNavsnSRyXq7zXT7r)", + Network::Regtest, + ) + .unwrap(); + + let regtestenv = RegTestEnv::new(); + regtestenv.generate(&[&wallet]); + 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); + 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, WalletAtHeight::new(&wallet, old_height)) + .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(); + 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 spendable = proof + .verify_reserve_proof(message, WalletAtHeight::new(&wallet, old_height)) + .unwrap(); + assert_eq!(spendable, old_balance.confirmed); +} diff --git a/tests/regtestenv.rs b/tests/regtestenv.rs index 5bcb387..6c14e31 100644 --- a/tests/regtestenv.rs +++ b/tests/regtestenv.rs @@ -30,6 +30,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 +38,18 @@ impl RegTestEnv { RegTestEnv { bitcoind, electrsd } } - /// returns the URL where a client can connect to the embedded electrum server + /// returns the URL where an electrum client can connect to the embedded electrum server pub fn electrum_url(&self) -> &str { &self.electrsd.electrum_url } + /// 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 +96,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 From 66243e99abda2432d4c525aa53bb29f64e895d0a Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Mon, 8 Jan 2024 23:32:20 -0600 Subject: [PATCH 07/14] Fix point in time test --- src/reserves.rs | 2 +- tests/point_in_time.rs | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/reserves.rs b/src/reserves.rs index 76335e4..ca36f87 100644 --- a/src/reserves.rs +++ b/src/reserves.rs @@ -33,7 +33,7 @@ use bdk::Error; use std::collections::BTreeMap; -pub use crate::txout_set::{TxOutSet, WalletAtHeight}; +pub use crate::txout_set::{TxOutSet, PointInTimeTxOutSet, WalletAtHeight}; pub const PSBT_IN_POR_COMMITMENT: u8 = 0x09; diff --git a/tests/point_in_time.rs b/tests/point_in_time.rs index fe92b22..a283ba9 100644 --- a/tests/point_in_time.rs +++ b/tests/point_in_time.rs @@ -59,8 +59,10 @@ fn point_in_time() { let proof = psbt; assert!(finalized); + let txouts_point_in_time = blockchain.txout_set_at_height(old_height); + let spendable = proof - .verify_reserve_proof(message, WalletAtHeight::new(&wallet, old_height)) + .verify_reserve_proof(message, txouts_point_in_time) .unwrap(); assert_eq!(spendable, old_balance.confirmed); @@ -80,8 +82,9 @@ fn point_in_time() { let new_balance = wallet.get_balance().unwrap(); assert_ne!(old_balance, new_balance); + let new_txouts_point_in_time = blockchain.txout_set_at_height(old_height); let spendable = proof - .verify_reserve_proof(message, WalletAtHeight::new(&wallet, old_height)) + .verify_reserve_proof(message, new_txouts_point_in_time) .unwrap(); assert_eq!(spendable, old_balance.confirmed); } From 68044111051a421a77968dcd0ebbd93ce5a0d89d Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Tue, 9 Jan 2024 01:53:19 -0600 Subject: [PATCH 08/14] Add test ensuring proof becomes invalid when evaluating at a higher height --- tests/point_in_time.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/point_in_time.rs b/tests/point_in_time.rs index a283ba9..ffeb593 100644 --- a/tests/point_in_time.rs +++ b/tests/point_in_time.rs @@ -82,9 +82,17 @@ fn point_in_time() { 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("expected proof utxos to be spent"); } From 469b22a683f7bd343c3af700a739182049d97d20 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Mon, 8 Jan 2024 09:39:04 -0600 Subject: [PATCH 09/14] Refactor TxOutSet system --- src/reserves.rs | 86 +++++----- src/txout_set.rs | 376 +++++++++++++++++------------------------ tests/mempool.rs | 5 +- tests/point_in_time.rs | 14 +- 4 files changed, 212 insertions(+), 269 deletions(-) diff --git a/src/reserves.rs b/src/reserves.rs index ca36f87..e0461c3 100644 --- a/src/reserves.rs +++ b/src/reserves.rs @@ -33,7 +33,7 @@ use bdk::Error; use std::collections::BTreeMap; -pub use crate::txout_set::{TxOutSet, PointInTimeTxOutSet, WalletAtHeight}; +pub use crate::txout_set::{HistoricalTxOutQuery, MaxHeightTxOutQuery, TipTxOutQuery, TxOutSet}; pub const PSBT_IN_POR_COMMITMENT: u8 = 0x09; @@ -153,7 +153,7 @@ where max_block_height: Option, ) -> Result { if let Some(max_block_height) = max_block_height { - let txouts = WalletAtHeight::new(self, max_block_height); + let txouts = self.txout_set_confirmed_by_height(max_block_height); psbt.verify_reserve_proof(message, txouts) } else { @@ -166,15 +166,22 @@ where pub trait ReserveProof { /// Verify a proof transaction. /// Look up utxos with get_prevout() - fn verify_reserve_proof(&self, message: &str, txouts: T) -> Result; + 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 - { + fn verify_reserve_proof( + &self, + message: &str, + txouts: T, + ) -> Result { if self.output.len() != 1 { return Err(ProofError::WrongNumberOfOutputs); } @@ -192,9 +199,7 @@ impl ReserveProof for Transaction { self.verify_challenge(message)?; - let outpoint_iter = self.input - .iter() - .map(|txin| &txin.previous_output); + let outpoint_iter = self.input.iter().map(|txin| &txin.previous_output); // Try to look up outpoints let prevouts: Vec> = txouts @@ -207,18 +212,12 @@ impl ReserveProof for Transaction { .enumerate() .skip(1) .map(|(i, txout)| match txout { - Some(txout) => { - Ok((i, txout)) - }, - None => { - Err(ProofError::OutpointNotFound(i)) - }, + Some(txout) => Ok((i, txout)), + None => Err(ProofError::OutpointNotFound(i)), }) .collect::>()?; - let sum: u64 = prevouts.iter() - .map(|(_i, prevout)| prevout.value) - .sum(); + 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 { @@ -230,17 +229,15 @@ impl ReserveProof for Transaction { // Check that all inputs besides the challenge input are valid prevouts .iter() - .map(|(i, prevout)| + .map(|(i, prevout)| { bitcoinconsensus::verify( prevout.script_pubkey.to_bytes().as_slice(), prevout.value, &serialized_tx, *i, ) - .map_err(|e| - ProofError::SignatureValidation(*i, format!("{:?}", e)) - ), - ) + .map_err(|e| ProofError::SignatureValidation(*i, format!("{:?}", e))) + }) .collect::>()?; // Check that all inputs besides the challenge input actually @@ -262,21 +259,20 @@ impl ReserveProof for Transaction { prevouts .iter() - .map(|(i, prevout)| + .map(|(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(()) - } + Ok(_) => Err(ProofError::SignatureValidation( + *i, + "Does not commit to challenge input".to_string(), + )), + Err(_) => Ok(()), } - ) + }) .collect::>()?; Ok(sum) @@ -301,7 +297,11 @@ impl ReserveProof for PSBT { /// 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 { + fn verify_reserve_proof( + &self, + message: &str, + txouts: T, + ) -> Result { let tx = self.clone().extract_tx(); // Redundant check to tx.verify_reserve_proof() to ensure error priority is not changed @@ -310,19 +310,18 @@ impl ReserveProof for PSBT { } // 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()) + 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()) + psbt_in.sighash_type.is_some() + && psbt_in.sighash_type != Some(EcdsaSighashType::All.into()) }) { return Err(ProofError::UnsupportedSighashType(i)); } @@ -352,10 +351,7 @@ fn challenge_txin(message: &str) -> TxIn { #[cfg(test)] mod test { use super::*; - use bdk::SignOptions; - use bdk::bitcoin::consensus::encode::serialize_hex; use bdk::bitcoin::consensus::encode::deserialize; - use bdk::bitcoin::secp256k1::Secp256k1; use bdk::bitcoin::secp256k1::ecdsa::{SerializedSignature, Signature}; use bdk::bitcoin::{EcdsaSighashType, Transaction, Witness}; use bdk::wallet::get_funded_wallet; @@ -445,7 +441,9 @@ mod test { let message = "This belongs to me."; let tx = get_signed_proof_tx(); - let spendable = tx.verify_reserve_proof(message, WalletAtHeight::new(&wallet, 90)).unwrap(); + let spendable = tx + .verify_reserve_proof(message, wallet.txout_set_confirmed_by_height(90)) + .unwrap(); assert_eq!(spendable, 50_000); } @@ -461,7 +459,9 @@ mod test { assert_eq!(spendable, 50_000); let tx = psbt.extract_tx(); - let spendable = tx.verify_reserve_proof(message, WalletAtHeight::new(&wallet, 100)).unwrap(); + let spendable = tx + .verify_reserve_proof(message, wallet.txout_set_confirmed_by_height(100)) + .unwrap(); assert_eq!(spendable, 50_000); } diff --git a/src/txout_set.rs b/src/txout_set.rs index 4bb2cbf..c7bd8e5 100644 --- a/src/txout_set.rs +++ b/src/txout_set.rs @@ -1,8 +1,8 @@ -use bdk::bitcoin::{OutPoint, Transaction, Txid, TxOut}; +use bdk::bitcoin::{OutPoint, Transaction, TxOut, Txid}; use bdk::database::BatchDatabase; use bdk::wallet::Wallet; -#[cfg(feature = "use-esplora-blocking" )] +#[cfg(feature = "use-esplora-blocking")] use esplora_client::BlockingClient; use std::collections::BTreeMap; @@ -15,100 +15,152 @@ pub trait TxOutSet { type Error; /// Atomically look up txouts - fn get_prevouts<'a, I: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result; + fn get_prevouts<'a, I, T>(&self, outpoints: I) -> Result + where + I: IntoIterator, + T: FromIterator>; } -pub trait PointInTimeTxOutSet<'a> { +/// 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: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result { - let wallet_at_height = WalletAtHeight::new(self, u32::MAX); + 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<'a, I: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result { + 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()) - ); + .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 WalletAtHeight<'a, D> +pub struct WalletConfirmedByHeight<'a, D> where - D: BatchDatabase + D: BatchDatabase, { wallet: &'a Wallet, max_block_height: u32, } -impl <'a, D> WalletAtHeight<'a, D> +impl<'a, D> MaxHeightTxOutQuery<'a> for Wallet where - D: BatchDatabase + D: BatchDatabase + 'a, { - pub fn new(wallet: &'a Wallet, max_block_height: u32) -> Self { - WalletAtHeight { - wallet, - max_block_height, + 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 WalletAtHeight<'a, D> +impl<'a, D> TxOutSet for WalletConfirmedByHeight<'a, D> where - D: BatchDatabase + D: BatchDatabase, { type Error = bdk::Error; - fn get_prevouts<'b, I: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result { - let outpoints: Vec<_> = outpoints - .into_iter() - .collect(); + 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() - .map(|outpoint| *outpoint) - .collect(); + let outpoint_set: BTreeSet<&OutPoint> = + outpoints.iter().map(|outpoint| *outpoint).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)); }, + Some(tx_details) => tx_details, + None => { + return Ok((outpoint.txid, None)); + } }; Ok(( outpoint.txid, - tx_details.confirmation_time - .map(|tx_details| tx_details.height) + 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)) }, + Ok((txid, Some(height))) => Some(Ok((txid, height))), + Ok((_, None)) => None, + Err(e) => Some(Err(e)), }) .collect::>()? } else { @@ -116,14 +168,14 @@ where BTreeMap::new() }; - let unspent: BTreeMap<_, _> = self.wallet + 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); + 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)) @@ -138,77 +190,65 @@ where let iter = outpoints .into_iter() - .map(|outpoint| - unspent - .get(outpoint) - .map(|outpoint| outpoint.to_owned()) - ); + .map(|outpoint| unspent.get(outpoint).map(|outpoint| outpoint.to_owned())); Ok(T::from_iter(iter)) } } -#[cfg(feature = "use-esplora-blocking" )] +#[cfg(feature = "use-esplora-blocking")] pub struct EsploraAtHeight<'a> { client: &'a BlockingClient, height: Option, } -#[cfg(feature = "use-esplora-blocking" )] +#[cfg(feature = "use-esplora-blocking")] impl<'a> EsploraAtHeight<'a> { pub fn new(client: &'a BlockingClient, height: Option) -> Self { - Self { - client, - height, - } + Self { client, height } } } -#[cfg(feature = "use-esplora-blocking" )] +#[cfg(feature = "use-esplora-blocking")] impl<'a> TxOutSet for EsploraAtHeight<'a> { type Error = esplora_client::Error; - fn get_prevouts<'b, I: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result { - let outpoints: Vec<_> = outpoints - .into_iter() - .collect(); + 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(); + // Remove duplicate txids since the + 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); + 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 + 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) { - (Some(_maximum_height), None) => { - None - }, - (Some(maximum_height), Some(height)) if height > maximum_height => { - None - }, - (None, Some(_height)) => { - None - }, - _ => { - transaction.map(|transaction| (txid, transaction)) - }, + (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(); @@ -218,10 +258,7 @@ impl<'a> TxOutSet for EsploraAtHeight<'a> { .map(|outpoint| -> Result, Self::Error> { let txout = transactions .get(&outpoint.txid) - .and_then(|transaction| - transaction.output - .get(outpoint.vout as usize) - ); + .and_then(|transaction| transaction.output.get(outpoint.vout as usize)); let txout = if let Some(txout) = txout { txout @@ -229,26 +266,36 @@ impl<'a> TxOutSet for EsploraAtHeight<'a> { return Ok(None); }; - let txout_status = self.client + 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 + let spending_tx_height = txout_status + .status .map(|status| status.block_height) .unwrap_or(None); match (self.height, spending_tx_height) { - (Some(height), Some(spending_tx_height)) if 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 { - // FIXME: should we treat this as an error? or does this just mean it's not spent? + // 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())) @@ -258,149 +305,32 @@ impl<'a> TxOutSet for EsploraAtHeight<'a> { } } -#[cfg(feature = "use-esplora-blocking" )] -impl<'a> PointInTimeTxOutSet<'a> for BlockingClient { +#[cfg(feature = "use-esplora-blocking")] +impl<'a> HistoricalTxOutQuery<'a> for BlockingClient { type Target = EsploraAtHeight<'a>; fn txout_set_at_height(&'a self, height: u32) -> Self::Target { - EsploraAtHeight { client: self, height: Some(height) } + EsploraAtHeight { + client: self, + height: Some(height), + } } } -#[cfg(feature = "use-esplora-blocking" )] +#[cfg(feature = "use-esplora-blocking")] impl<'a> TxOutSet for BlockingClient { type Error = esplora_client::Error; - fn get_prevouts<'b, I: IntoIterator, T: FromIterator>>(&self, outpoints: I) -> Result { - let esplora_at_height = EsploraAtHeight { client: self, height: None }; + 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) } } - -#[cfg(test)] -#[cfg(feature = "use-esplora-blocking" )] -mod test_esplora { - use bdk::bitcoin::{OutPoint, Txid}; - use esplora_client::{BlockingClient, Builder}; - - use std::iter::once; - use std::str::FromStr; - - use crate::txout_set::PointInTimeTxOutSet; - - use super::TxOutSet; - - const ESPLORA_URL: &str = "https://mempool.space/signet/api"; - const TEST_TX_BLOCK_HEIGHT: u32 = 175435; - - fn get_client() -> BlockingClient { - Builder::new(ESPLORA_URL) - .build_blocking() - .expect("build esplora client") - } - - fn txid(s: &str) -> Txid { - Txid::from_str(s) - .expect("parse txid") - } - - fn test_txid() -> Txid { - Txid::from_str("52e318567fc09d7ab56e9861ea8cdd970964e64a83521da94d91adf51ded5da4") - .expect("parse txid") - } - - fn test_parent_txid() -> Txid { - Txid::from_str("36e9be6467e4afe396de7a4fcbeca45e0bfaa0ea7d6344f769e5df7c80d088cb") - .expect("parse txid") - } - - #[test] - pub fn test_esplora() { - let client = get_client(); - - client.get_height() - .expect(&format!("problem with esplora \"{ESPLORA_URL}\"")); - } - - #[test] - pub fn test_confirmed_unspent_at_tip() { - let client = get_client(); - - let outpoints = [ - OutPoint { - txid: test_txid(), - vout: 1, - }, - // tx only has 3 outputs - OutPoint { - txid: test_txid(), - vout: 4, - }, - ]; - - let prevouts: Vec<_> = client.get_prevouts(outpoints.iter()) - .expect("get prevouts"); - - let valid_prevout = prevouts[0].as_ref().unwrap(); - assert!(valid_prevout.value == 699828); - assert!(prevouts[1].is_none()); - } - - #[test] - pub fn test_confirmed_at_height() { - let client = get_client(); - - let txouts = client.txout_set_at_height(TEST_TX_BLOCK_HEIGHT); - - let outpoints = [ - OutPoint { - txid: test_txid(), - vout: 1, - }, - ]; - - let prevouts: Vec<_> = txouts.get_prevouts(outpoints.iter()) - .expect("get prevouts"); - - assert!(prevouts[0].is_some()); - } - - #[test] - pub fn test_not_confirmed_at_height() { - let client = get_client(); - - let txouts = client.txout_set_at_height(TEST_TX_BLOCK_HEIGHT - 1); - - let outpoints = [ - OutPoint { - txid: test_txid(), - vout: 1, - }, - ]; - - let prevouts: Vec<_> = txouts.get_prevouts(outpoints.iter()) - .expect("get prevouts"); - - assert!(prevouts[0].is_none()); - } - - #[test] - pub fn test_spent_at_later_height() { - let client = get_client(); - - let txouts = client.txout_set_at_height(TEST_TX_BLOCK_HEIGHT - 1); - - let outpoints = [ - OutPoint { - txid: test_parent_txid(), - vout: 0, - }, - ]; - - let prevouts: Vec<_> = txouts.get_prevouts(outpoints.iter()) - .expect("get prevouts"); - - assert!(prevouts[0].is_some()); - } -} diff --git a/tests/mempool.rs b/tests/mempool.rs index 5797332..68119bd 100644 --- a/tests/mempool.rs +++ b/tests/mempool.rs @@ -196,7 +196,10 @@ fn confirmed_tx() { let tx = psbt.extract_tx(); let spendable = tx - .verify_reserve_proof(message, WalletAtHeight::new(&wallet, max_confirmation_height)) + .verify_reserve_proof( + message, + wallet.txout_set_confirmed_by_height(max_confirmation_height), + ) .unwrap(); assert_eq!(spendable, new_balance.confirmed); } diff --git a/tests/point_in_time.rs b/tests/point_in_time.rs index ffeb593..a0a23d6 100644 --- a/tests/point_in_time.rs +++ b/tests/point_in_time.rs @@ -66,12 +66,17 @@ fn point_in_time() { .unwrap(); assert_eq!(spendable, 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); @@ -93,6 +98,11 @@ fn point_in_time() { 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("expected proof utxos to be spent"); + 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"); } From b56afeae37587a3d839513e88f3b5381f8a5b5c1 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Sat, 13 Jan 2024 14:34:18 -0600 Subject: [PATCH 10/14] Add support for electrum TxOutSet queries --- Cargo.toml | 2 + src/txout_set.rs | 111 +++++++++++++++++++-- tests/point_in_time.rs | 108 --------------------- tests/txout_set.rs | 214 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 321 insertions(+), 114 deletions(-) delete mode 100644 tests/point_in_time.rs create mode 100644 tests/txout_set.rs diff --git a/Cargo.toml b/Cargo.toml index 32ac6f8..9e32038 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,10 +12,12 @@ repository = "https://github.com/bitcoindevkit/bdk-reserves" [dependencies] bdk = { version = "0.28", default-features = false } bitcoinconsensus = "0.19.0-3" +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"] [dev-dependencies] diff --git a/src/txout_set.rs b/src/txout_set.rs index c7bd8e5..3e9b6c9 100644 --- a/src/txout_set.rs +++ b/src/txout_set.rs @@ -2,8 +2,11 @@ use bdk::bitcoin::{OutPoint, Transaction, TxOut, Txid}; use bdk::database::BatchDatabase; use bdk::wallet::Wallet; +#[cfg(feature = "electrum")] +use electrum_client::{Client as ElectrumClient, ElectrumApi}; + #[cfg(feature = "use-esplora-blocking")] -use esplora_client::BlockingClient; +use esplora_client::BlockingClient as EsploraClient; use std::collections::BTreeMap; use std::collections::BTreeSet; @@ -196,15 +199,111 @@ where } } +#[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 BlockingClient, + client: &'a EsploraClient, height: Option, } #[cfg(feature = "use-esplora-blocking")] impl<'a> EsploraAtHeight<'a> { - pub fn new(client: &'a BlockingClient, height: Option) -> Self { + pub fn new(client: &'a EsploraClient, height: Option) -> Self { Self { client, height } } } @@ -220,7 +319,7 @@ impl<'a> TxOutSet for EsploraAtHeight<'a> { { let outpoints: Vec<_> = outpoints.into_iter().collect(); - // Remove duplicate txids since the + // Remove duplicate txids let input_txids: BTreeSet = outpoints.iter().map(|outpoint| outpoint.txid).collect(); let transactions: BTreeMap<&Txid, Transaction> = input_txids @@ -306,7 +405,7 @@ impl<'a> TxOutSet for EsploraAtHeight<'a> { } #[cfg(feature = "use-esplora-blocking")] -impl<'a> HistoricalTxOutQuery<'a> for BlockingClient { +impl<'a> HistoricalTxOutQuery<'a> for EsploraClient { type Target = EsploraAtHeight<'a>; fn txout_set_at_height(&'a self, height: u32) -> Self::Target { @@ -318,7 +417,7 @@ impl<'a> HistoricalTxOutQuery<'a> for BlockingClient { } #[cfg(feature = "use-esplora-blocking")] -impl<'a> TxOutSet for BlockingClient { +impl<'a> TxOutSet for EsploraClient { type Error = esplora_client::Error; fn get_prevouts<'b, I, T>(&self, outpoints: I) -> Result diff --git a/tests/point_in_time.rs b/tests/point_in_time.rs deleted file mode 100644 index a0a23d6..0000000 --- a/tests/point_in_time.rs +++ /dev/null @@ -1,108 +0,0 @@ -#[cfg(feature = "use-esplora-blocking")] -mod regtestenv; -#[cfg(feature = "use-esplora-blocking")] -use bdk::bitcoin::Network; -#[cfg(feature = "use-esplora-blocking")] -use bdk::blockchain::esplora::EsploraBlockchain; -#[cfg(feature = "use-esplora-blocking")] -use bdk::blockchain::{Blockchain, GetHeight}; -#[cfg(feature = "use-esplora-blocking")] -use bdk::database::memory::MemoryDatabase; -#[cfg(feature = "use-esplora-blocking")] -use bdk::wallet::{SyncOptions, Wallet}; -#[cfg(feature = "use-esplora-blocking")] -use bdk::Error; -#[cfg(feature = "use-esplora-blocking")] -use bdk::SignOptions; -#[cfg(feature = "use-esplora-blocking")] -use bdk_reserves::reserves::*; -#[cfg(feature = "use-esplora-blocking")] -use electrsd::bitcoind::bitcoincore_rpc::bitcoin::Address; -#[cfg(feature = "use-esplora-blocking")] -use esplora_client::Builder; -#[cfg(feature = "use-esplora-blocking")] -use regtestenv::RegTestEnv; -#[cfg(feature = "use-esplora-blocking")] -use std::str::FromStr; - -fn construct_wallet(desc: &str, network: Network) -> Result, Error> { - let wallet = Wallet::new(desc, None, network, MemoryDatabase::default())?; - - Ok(wallet) -} - -#[test] -#[cfg(feature = "use-esplora-blocking")] -fn point_in_time() { - let wallet = construct_wallet( - "wpkh(cTTgG6x13nQjAeECaCaDrjrUdcjReZBGspcmNavsnSRyXq7zXT7r)", - Network::Regtest, - ) - .unwrap(); - - let regtestenv = RegTestEnv::new(); - regtestenv.generate(&[&wallet]); - 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); - 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, 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"); -} diff --git a/tests/txout_set.rs b/tests/txout_set.rs new file mode 100644 index 0000000..7aedb4b --- /dev/null +++ b/tests/txout_set.rs @@ -0,0 +1,214 @@ +#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +mod regtestenv; + +#[cfg(any(feature = "electrum", 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(any(feature = "electrum", feature = "use-esplora-blocking"))] +use bdk::blockchain::{Blockchain, GetHeight}; +#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +use bdk::database::memory::MemoryDatabase; +#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +use bdk::wallet::{SyncOptions, Wallet}; +#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +use bdk::Error; +#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +use bdk::SignOptions; +#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +use bdk_reserves::reserves::*; +#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +use electrsd::bitcoind::bitcoincore_rpc::bitcoin::Address; + +#[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 = "use-esplora-blocking"))] +use regtestenv::RegTestEnv; +#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +use std::str::FromStr; + +#[cfg(any(feature = "electrum", 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, 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(feature = "electrum")] +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 txouts = blockchain.txout_set_confirmed_by_height(old_height); + + let spendable = proof.verify_reserve_proof(message, txouts).unwrap(); + assert_eq!(spendable, old_balance.confirmed); + + let spendable = proof + .verify_reserve_proof(message, blockchain.txout_set_at_tip()) + .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 = "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); +} From b8871c795852a6c97c12aa2631758c2a1d85242b Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Sat, 13 Jan 2024 15:28:05 -0600 Subject: [PATCH 11/14] Satisfy rust fmt --- src/txout_set.rs | 67 +++++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/src/txout_set.rs b/src/txout_set.rs index 3e9b6c9..6a0d0cd 100644 --- a/src/txout_set.rs +++ b/src/txout_set.rs @@ -212,7 +212,8 @@ impl TxOutSet for ElectrumClient { fn get_prevouts<'a, I, T>(&self, outpoints: I) -> Result where I: IntoIterator, - T: FromIterator> { + T: FromIterator>, + { let electrum_at_height = ElectrumAtHeight { client: self, maximum_txout_height: None, @@ -241,7 +242,8 @@ impl<'a> TxOutSet for ElectrumAtHeight<'a> { fn get_prevouts<'b, I, T>(&self, outpoints: I) -> Result where I: IntoIterator, - T: FromIterator> { + T: FromIterator>, + { let outpoints: Vec<_> = outpoints.into_iter().collect(); let input_txids: BTreeSet = outpoints.iter().map(|outpoint| outpoint.txid).collect(); @@ -253,44 +255,43 @@ impl<'a> TxOutSet for ElectrumAtHeight<'a> { let transactions: BTreeMap<&Txid, Transaction> = input_txids .iter() .filter_map(|txid| { - self.client.transaction_get(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 iter = outpoints.iter().map(|outpoint| { + let previous_tx = match transactions.get(&outpoint.txid) { + Some(previous_tx) => previous_tx, + None => { + return Ok(None); + } + }; - let unspent = self.client.script_list_unspent(&output.script_pubkey)?; + let output = match previous_tx.output.get(outpoint.vout as usize) { + Some(output) => output, + None => { + return Ok(None); + } + }; - 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) - ); + let unspent = self.client.script_list_unspent(&output.script_pubkey)?; - match output_in_unspent_list { - Some(_) => Ok(Some(output.to_owned())), - None => Ok(None), - } + 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) } } @@ -327,7 +328,7 @@ impl<'a> TxOutSet for EsploraAtHeight<'a> { .filter_map(|txid| { let transaction = self.client.get_tx(txid).unwrap_or(None); - // Get the block height of the input transaction if + // 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 @@ -346,7 +347,9 @@ impl<'a> TxOutSet for EsploraAtHeight<'a> { (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)), + (Some(_maximum_height), Some(_height)) => { + transaction.map(|transaction| (txid, transaction)) + } (None, None) => transaction.map(|transaction| (txid, transaction)), } }) From 7b0f91df96d4288a2b5e7e932f8504467c7fac8e Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Sun, 14 Jan 2024 00:08:36 -0600 Subject: [PATCH 12/14] Fix build error when building with neither esplora nor electrum support --- src/txout_set.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/txout_set.rs b/src/txout_set.rs index 6a0d0cd..d6e05d9 100644 --- a/src/txout_set.rs +++ b/src/txout_set.rs @@ -1,4 +1,6 @@ -use bdk::bitcoin::{OutPoint, Transaction, TxOut, Txid}; +use bdk::bitcoin::{OutPoint, TxOut}; +#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +use bdk::bitcoin::{Transaction, Txid}; use bdk::database::BatchDatabase; use bdk::wallet::Wallet; From 290e22bdf368cf2b6d062310ce53893cfa9dfdc4 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Sun, 14 Jan 2024 00:09:12 -0600 Subject: [PATCH 13/14] Fix clippy errors --- src/reserves.rs | 54 ++++++++++++++++++++------------------------- src/txout_set.rs | 3 +-- tests/regtestenv.rs | 1 + 3 files changed, 26 insertions(+), 32 deletions(-) diff --git a/src/reserves.rs b/src/reserves.rs index e0461c3..117c94f 100644 --- a/src/reserves.rs +++ b/src/reserves.rs @@ -227,18 +227,15 @@ impl ReserveProof for Transaction { let serialized_tx = serialize(&self); // Check that all inputs besides the challenge input are valid - prevouts - .iter() - .map(|(i, prevout)| { - bitcoinconsensus::verify( - prevout.script_pubkey.to_bytes().as_slice(), - prevout.value, - &serialized_tx, - *i, - ) - .map_err(|e| ProofError::SignatureValidation(*i, format!("{:?}", e))) - }) - .collect::>()?; + 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 @@ -257,23 +254,20 @@ impl ReserveProof for Transaction { serialize(&malleated_tx) }; - prevouts - .iter() - .map(|(i, prevout)| { - match bitcoinconsensus::verify( - prevout.script_pubkey.to_bytes().as_slice(), - prevout.value, - &serialized_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, - ) { - Ok(_) => Err(ProofError::SignatureValidation( - *i, - "Does not commit to challenge input".to_string(), - )), - Err(_) => Ok(()), - } - }) - .collect::>()?; + "Does not commit to challenge input".to_string(), + )), + Err(_) => Ok(()), + } + })?; Ok(sum) } @@ -724,7 +718,7 @@ mod test { use bdk::bitcoin::hashes::hex::FromHex; let tx = as FromHex>::from_hex(s).unwrap(); - deserialize(&mut tx.as_slice()).unwrap() + deserialize(tx.as_slice()).unwrap() } #[test] @@ -736,6 +730,6 @@ mod test { let message = "This belongs to me."; - tx.verify_reserve_proof(&message, &wallet).unwrap(); + tx.verify_reserve_proof(message, &wallet).unwrap(); } } diff --git a/src/txout_set.rs b/src/txout_set.rs index d6e05d9..7671ef5 100644 --- a/src/txout_set.rs +++ b/src/txout_set.rs @@ -141,8 +141,7 @@ where { let outpoints: Vec<_> = outpoints.into_iter().collect(); - let outpoint_set: BTreeSet<&OutPoint> = - outpoints.iter().map(|outpoint| *outpoint).collect(); + let outpoint_set: BTreeSet<&OutPoint> = outpoints.iter().copied().collect(); let tx_heights: BTreeMap<_, _> = if self.max_block_height < u32::MAX { outpoint_set diff --git a/tests/regtestenv.rs b/tests/regtestenv.rs index 6c14e31..746cc84 100644 --- a/tests/regtestenv.rs +++ b/tests/regtestenv.rs @@ -43,6 +43,7 @@ impl RegTestEnv { &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 From a7438d938d7a24e450169f0e0ae84544661547b8 Mon Sep 17 00:00:00 2001 From: Daniel Roberts Date: Sun, 14 Jan 2024 16:57:36 -0600 Subject: [PATCH 14/14] wip: Add bitcoincore_rpc support --- Cargo.toml | 2 ++ src/txout_set.rs | 78 +++++++++++++++++++++++++++++++++++++++++++++ tests/regtestenv.rs | 15 +++++++++ tests/txout_set.rs | 57 ++++++++++++++++++++++----------- 4 files changed, 133 insertions(+), 19 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9e32038..ebf8d4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ 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" @@ -19,6 +20,7 @@ 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" diff --git a/src/txout_set.rs b/src/txout_set.rs index 7671ef5..a9ac302 100644 --- a/src/txout_set.rs +++ b/src/txout_set.rs @@ -1,9 +1,14 @@ 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}; @@ -200,6 +205,79 @@ where } } +#[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, diff --git a/tests/regtestenv.rs b/tests/regtestenv.rs index 746cc84..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}; @@ -38,6 +41,18 @@ impl RegTestEnv { RegTestEnv { bitcoind, electrsd } } + #[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 diff --git a/tests/txout_set.rs b/tests/txout_set.rs index 7aedb4b..2320614 100644 --- a/tests/txout_set.rs +++ b/tests/txout_set.rs @@ -1,38 +1,45 @@ -#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] mod regtestenv; -#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +#[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(any(feature = "electrum", feature = "use-esplora-blocking"))] +#[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 = "use-esplora-blocking"))] +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] use bdk::database::memory::MemoryDatabase; -#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] use bdk::wallet::{SyncOptions, Wallet}; -#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] use bdk::Error; -#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] use bdk::SignOptions; -#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] use bdk_reserves::reserves::*; -#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +#[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 = "use-esplora-blocking"))] +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] use regtestenv::RegTestEnv; -#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +#[cfg(any(feature = "electrum", feature = "rpc", feature = "use-esplora-blocking"))] use std::str::FromStr; -#[cfg(any(feature = "electrum", feature = "use-esplora-blocking"))] +#[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())?; @@ -73,7 +80,7 @@ where let spendable = proof .verify_reserve_proof(message, txouts_point_in_time) .unwrap(); - assert_eq!(spendable, old_balance.confirmed); + assert_eq!(spendable, dbg!(old_balance.confirmed)); proof .verify_reserve_proof(message, blockchain.txout_set_at_tip()) @@ -116,7 +123,7 @@ where .expect_err("expect proof utxos to be spent at tip"); } -#[cfg(feature = "electrum")] +#[cfg(any(feature = "electrum", feature = "rpc"))] fn confirmed_by(regtestenv: RegTestEnv, blockchain: B) where B: Blockchain + GetHeight + std::ops::Deref, @@ -145,14 +152,14 @@ where let proof = psbt; assert!(finalized); - 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); - 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"; @@ -202,6 +209,18 @@ fn test_electrum_confirmed_by() { 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() {