From b90071230fdde34210d1358fcc3e05e67ccde911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 4 Apr 2025 18:17:49 +1100 Subject: [PATCH 1/6] feat!: Simplifications * Remove `PsbtUpdater` and `Builder`. * Introduce `PsbtParams` and `create_psbt` function. --- Cargo.toml | 1 + src/builder.rs | 1047 ---------------------------------------------- src/finalizer.rs | 107 +++++ src/input.rs | 141 +++++++ src/lib.rs | 197 ++++++++- src/output.rs | 131 ++++++ src/updater.rs | 315 -------------- tests/psbt.rs | 454 ++++++++++++++++++++ 8 files changed, 1027 insertions(+), 1366 deletions(-) delete mode 100644 src/builder.rs create mode 100644 src/finalizer.rs create mode 100644 src/input.rs create mode 100644 src/output.rs delete mode 100644 src/updater.rs create mode 100644 tests/psbt.rs diff --git a/Cargo.toml b/Cargo.toml index 7444842..1959ab6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ readme = "README.md" [dependencies] miniscript = { version = "12", default-features = false } +bdk_coin_select = "0.4.0" [dev-dependencies] anyhow = "1" diff --git a/src/builder.rs b/src/builder.rs deleted file mode 100644 index 4a316ca..0000000 --- a/src/builder.rs +++ /dev/null @@ -1,1047 +0,0 @@ -use alloc::vec::Vec; -use core::fmt; - -use bitcoin::{ - absolute, transaction, Amount, FeeRate, OutPoint, Psbt, ScriptBuf, Sequence, SignedAmount, - Transaction, TxIn, TxOut, Weight, -}; -use miniscript::{bitcoin, plan::Plan}; - -use crate::{DataProvider, Finalizer, PsbtUpdater, UpdatePsbtError}; - -/// A UTXO with spend plan -#[derive(Debug, Clone)] -pub struct PlanUtxo { - /// plan - pub plan: Plan, - /// outpoint - pub outpoint: OutPoint, - /// txout - pub txout: TxOut, -} - -/// An output in the transaction, includes a txout and whether the output should be -/// treated as change. -#[derive(Debug, Clone)] -struct Output { - txout: TxOut, - is_change: bool, -} - -impl Output { - /// Create a new output - fn new(script: ScriptBuf, amount: Amount) -> Self { - Self::from((script, amount)) - } - - /// Create a new change output - fn new_change(script: ScriptBuf, amount: Amount) -> Self { - let mut output = Self::new(script, amount); - output.is_change = true; - output - } -} - -impl Default for Output { - fn default() -> Self { - Self { - txout: TxOut { - script_pubkey: ScriptBuf::default(), - value: Amount::default(), - }, - is_change: false, - } - } -} - -impl From<(ScriptBuf, Amount)> for Output { - fn from(tup: (ScriptBuf, Amount)) -> Self { - Self { - txout: TxOut { - script_pubkey: tup.0, - value: tup.1, - }, - ..Default::default() - } - } -} - -/// Transaction builder -#[derive(Debug, Clone, Default)] -pub struct Builder { - utxos: Vec, - outputs: Vec, - version: Option, - locktime: Option, - - sequence: Option, - check_fee: CheckFee, -} - -impl Builder { - /// New - pub fn new() -> Self { - Self::default() - } - - /// Add outputs to the transaction. - /// - /// This should be used for setting outgoing scripts and amounts. If adding a change output, - /// use [`Builder::add_change_output`] instead. - pub fn add_outputs( - &mut self, - outputs: impl IntoIterator, - ) -> &mut Self { - self.outputs.extend(outputs.into_iter().map(Output::from)); - self - } - - /// Add an output with the given `script` and `amount` to the transaction. - /// - /// See also [`add_outputs`](Self::add_outputs). - pub fn add_output(&mut self, script: ScriptBuf, amount: Amount) -> &mut Self { - self.add_outputs([(script, amount)]); - self - } - - /// Get the target amounts based on the weight and value of all outputs not including change. - /// - /// This is a convenience method used for passing target values to a coin selection - /// implementation. - pub fn target_outputs(&self) -> impl Iterator + '_ { - self.outputs - .iter() - .filter(|out| !out.is_change) - .cloned() - .map(|out| (out.txout.weight(), out.txout.value)) - } - - /// Add a change output. - /// - /// This should only be used for adding a change output. See [`Builder::add_output`] for - /// adding an outgoing output. Note that only one output may be designated as change, which - /// means only the last call to this method will apply to the transaction. - /// - /// Note: if combined with [`Builder::check_fee`], the given amount may be adjusted to - /// meet the desired transaction fee. - pub fn add_change_output(&mut self, script: ScriptBuf, amount: Amount) -> &mut Self { - if self.is_change_added() { - let out = self - .outputs - .iter_mut() - .find(|out| out.is_change) - .expect("must have change output"); - out.txout = TxOut { - script_pubkey: script, - value: amount, - }; - } else { - self.outputs.push(Output::new_change(script, amount)); - } - self - } - - /// Add an input to fund the tx - pub fn add_input(&mut self, utxo: impl Into) -> &mut Self { - self.utxos.push(utxo.into()); - self - } - - /// Add inputs to be used to fund the tx - pub fn add_inputs(&mut self, utxos: I) -> &mut Self - where - I: IntoIterator, - I::Item: Into, - { - self.utxos.extend(utxos.into_iter().map(Into::into)); - self - } - - /// Whether a change output has been added to this [`Builder`] - fn is_change_added(&self) -> bool { - self.outputs.iter().any(|out| out.is_change) - } - - /// Target a given fee / feerate of the transaction. - /// - /// If change is added, this allows making an adjustment to the value of the change - /// output to meet the given fee and/or feerate. By default we target a minimum - /// feerate of 1 sat/vbyte. - /// - /// Note: this option may be ignored if meeting the specified fee or feerate would - /// consume the entire amount of the change. - pub fn check_fee(&mut self, fee: Option, feerate: Option) { - let mut check = CheckFee::default(); - if let Some(fee) = fee { - check.fee = fee; - } - if let Some(feerate) = feerate { - check.feerate = feerate; - } - self.check_fee = check; - } - - /// Use a specific [`transaction::Version`] - pub fn version(&mut self, version: transaction::Version) -> &mut Self { - self.version = Some(version); - self - } - - /// Use a specific transaction [`LockTime`](absolute::LockTime). - /// - /// Note that building a transaction may raise an error if the given locktime has a - /// different lock type than that of a planned input. The greatest locktime value - /// among all of the spend plans is what goes into the final tx, so this value - /// may be ignored if it doesn't increase the overall maximum. - pub fn locktime(&mut self, locktime: absolute::LockTime) -> &mut Self { - self.locktime = Some(locktime); - self - } - - /// Set a default [`Sequence`] for all inputs. Note that building the tx may raise an - /// error if the given `sequence` is incompatible with the relative locktime of a - /// planned input. - pub fn sequence(&mut self, sequence: Sequence) -> &mut Self { - self.sequence = Some(sequence); - self - } - - /// Add a data-carrying output using `OP_RETURN`. - /// - /// # Errors - /// - /// - If `data` exceeds 80 bytes in size. - /// - If this is not the first `OP_RETURN` output being added to this builder. - /// - /// Refer to for more - /// details about transaction standardness. - pub fn add_data(&mut self, data: T) -> Result<&mut Self, Error> - where - T: AsRef<[u8]>, - { - if self - .outputs - .iter() - .any(|out| out.txout.script_pubkey.is_op_return()) - { - return Err(Error::TooManyOpReturn); - } - if data.as_ref().len() > 80 { - return Err(Error::MaxOpReturnRelay); - } - - let mut bytes = bitcoin::script::PushBytesBuf::new(); - bytes.extend_from_slice(data.as_ref()).expect("should push"); - - self.outputs - .push(Output::new(ScriptBuf::new_op_return(bytes), Amount::ZERO)); - - Ok(self) - } - - /// Build a PSBT with the given data provider and return a [`PsbtUpdater`]. - /// - /// # Errors - /// - /// - If attempting to mix locktime units - /// - If the tx is illegally constructed or fails one of a number of sanity checks - /// defined by the library. - /// - If a requested locktime or sequence interferes with the locktime constraints - /// of a planned input. - pub fn build_psbt(self, provider: &mut D) -> Result - where - D: DataProvider, - { - use absolute::LockTime; - - let version = self.version.unwrap_or(transaction::Version::TWO); - - // accumulate the max required locktime - let mut lock_time: Option = self.utxos.iter().try_fold(None, |acc, u| match u - .plan - .absolute_timelock - { - None => Ok(acc), - Some(lock) => match acc { - None => Ok(Some(lock)), - Some(acc) => { - if !lock.is_same_unit(acc) { - Err(Error::LockTypeMismatch) - } else if acc.is_implied_by(lock) { - Ok(Some(lock)) - } else { - Ok(Some(acc)) - } - } - }, - })?; - - if let Some(param) = self.locktime { - match lock_time { - Some(lt) => { - if !lt.is_same_unit(param) { - return Err(Error::LockTypeMismatch); - } - if param.to_consensus_u32() < lt.to_consensus_u32() { - return Err(Error::LockTimeCltv { - requested: param, - required: lt, - }); - } - if lt.is_implied_by(param) { - lock_time = Some(param); - } - } - None => lock_time = Some(param), - } - } - - let lock_time = lock_time.unwrap_or(LockTime::ZERO); - - let input = self - .utxos - .iter() - .map(|PlanUtxo { plan, outpoint, .. }| { - Ok(TxIn { - previous_output: *outpoint, - sequence: match (self.sequence, plan.relative_timelock) { - (Some(requested), Some(lt)) => { - let required = lt.to_sequence(); - if !check_nsequence(requested, required) { - return Err(Error::SequenceCsv { - requested, - required, - }); - } - requested - } - (None, Some(lt)) => lt.to_sequence(), - (Some(seq), None) => seq, - (None, None) => Sequence::ENABLE_RBF_NO_LOCKTIME, - }, - ..Default::default() - }) - }) - .collect::, Error>>()?; - - let output = self - .outputs - .iter() - .cloned() - .map(|out| out.txout) - .collect::>(); - - let mut unsigned_tx = Transaction { - version, - lock_time, - input, - output, - }; - - // check, validate - self.sanity_check()?; - - if self.is_change_added() { - self.do_check_fee(&mut unsigned_tx); - } - - provider.sort_transaction(&mut unsigned_tx); - - Ok(PsbtUpdater::new(unsigned_tx, self.utxos)?) - } - - /// Convenience method to build an updated [`Psbt`] and return a [`Finalizer`]. - /// Refer to [`build_psbt`](Self::build_psbt) for more. - /// - /// # Errors - /// - /// This method returns an error if a problem occurs when either building or updating - /// the PSBT. - pub fn build_tx(self, provider: &mut D) -> Result<(Psbt, Finalizer), Error> - where - D: DataProvider, - { - let mut updater = self.build_psbt(provider)?; - updater - .update_psbt(provider, crate::UpdateOptions::default()) - .map_err(Error::Update)?; - Ok(updater.into_finalizer()) - } - - /// Sanity checks the tx for - /// - /// - Negative fee - /// - Absurd fee: The absurd fee threshold is currently 2x the sum of the outputs - // - // TODO: check total amounts, max tx weight, is standard spk - // - vin/vout not empty - fn sanity_check(&self) -> Result<(), Error> { - let total_in: Amount = self.utxos.iter().map(|p| p.txout.value).sum(); - let total_out: Amount = self.outputs.iter().map(|out| out.txout.value).sum(); - if total_out > total_in { - return Err(Error::NegativeFee(SignedAmount::from_sat( - total_in.to_sat() as i64 - total_out.to_sat() as i64, - ))); - } - let weight = self.estimate_weight(); - if total_in > total_out * 2 { - let fee = total_in - total_out; - let feerate = fee / weight; - return Err(Error::InsaneFee(feerate)); - } - - Ok(()) - } - - /// This will shift the allocation of funds from the change output to the - /// transaction fee in two cases: - /// - /// - if the computed feerate of tx is below a target feerate - /// - if the computed fee of tx is below a target fee amount - /// - /// We have to set an amount by which the change output is allowed to shrink - /// and still be positive. This will be the value of the change output minus - /// some amount of dust (546). - /// - /// If the target fee or feerate cannot be met without shrinking the change output - /// to below the dust limit, then no shrinking will occur. - /// - /// Panics if `tx` is not a sane tx - fn do_check_fee(&self, tx: &mut Transaction) { - const DUST: u64 = 546; - if !self.is_change_added() { - return; - } - let CheckFee { - fee: exp_fee, - feerate: exp_feerate, - } = self.check_fee; - - // We use these units in the below calculation: - // fee: u64 satoshi - // weight: u64 wu - // feerate: f32 satoshi per 1000 wu - let fee = self.fee_amount(tx).expect("must be sane tx").to_sat(); - let weight = self.estimate_weight().to_wu(); - let feerate = 1000.0 * fee as f32 / weight as f32; - - let txout = self - .outputs - .iter() - .find(|out| out.is_change) - .map(|out| out.txout.clone()) - .expect("must have change output"); - let (output_index, _) = tx - .output - .iter() - .enumerate() - .find(|(_, txo)| **txo == txout) - .expect("must have txout"); - - // check feerate - if feerate < exp_feerate.to_sat_per_kwu() as f32 { - let exp_feerate = exp_feerate.to_sat_per_kwu() as f32; - let exp_fee = (exp_feerate * (weight as f32 / 1000.0)) as u64; - let delta = exp_fee.saturating_sub(fee); - - let txout = &mut tx.output[output_index]; - if txout.value.to_sat() >= delta + DUST { - txout.value -= Amount::from_sat(delta); - } - } - - // check fee - let fee = self.fee_amount(tx).expect("must be sane tx"); - if fee < exp_fee { - let delta = exp_fee - fee; - let txout = &mut tx.output[output_index]; - if txout.value >= delta + Amount::from_sat(DUST) { - txout.value -= delta; - } - } - } - - /// Get an estimate of the current tx weight - pub fn estimate_weight(&self) -> Weight { - Transaction { - version: bitcoin::transaction::Version::TWO, - lock_time: bitcoin::absolute::LockTime::ZERO, - input: (0..self.utxos.len()).map(|_| TxIn::default()).collect(), - output: self.outputs.iter().cloned().map(|out| out.txout).collect(), - } - .weight() - + self - .utxos - .iter() - .map(|p| Weight::from_wu_usize(p.plan.satisfaction_weight())) - .sum() - } - - /// Returns the tx fee as the sum of the inputs minus the sum of the outputs - /// returning `None` on overflowing subtraction. - fn fee_amount(&self, tx: &Transaction) -> Option { - self.utxos - .iter() - .map(|p| p.txout.value) - .sum::() - .checked_sub(tx.output.iter().map(|txo| txo.value).sum::()) - } -} - -/// Checks that the given `sequence` is compatible with `csv`. To be compatible, both -/// must enable relative locktime, have the same lock type unit, and the requested -/// sequence must be at least the value of `csv`. -fn check_nsequence(sequence: Sequence, csv: Sequence) -> bool { - debug_assert!( - csv.is_relative_lock_time(), - "csv must enable relative locktime" - ); - if !sequence.is_relative_lock_time() { - return false; - } - if sequence.is_height_locked() != csv.is_height_locked() { - return false; - } - if sequence < csv { - return false; - } - - true -} - -/// Check fee -#[derive(Debug, Copy, Clone)] -struct CheckFee { - fee: Amount, - feerate: FeeRate, -} - -impl Default for CheckFee { - fn default() -> Self { - Self { - feerate: FeeRate::from_sat_per_vb_unchecked(1), - fee: Amount::default(), - } - } -} - -/// [`Builder`] error -#[derive(Debug)] -pub enum Error { - /// insane feerate - InsaneFee(FeeRate), - /// requested locktime is incompatible with required CLTV - LockTimeCltv { - /// requested locktime - requested: absolute::LockTime, - /// required locktime - required: absolute::LockTime, - }, - /// attempted to mix locktime types - LockTypeMismatch, - /// output exceeds data carrier limit - MaxOpReturnRelay, - /// negative fee - NegativeFee(SignedAmount), - /// bitcoin psbt error - Psbt(bitcoin::psbt::Error), - /// requested sequence is incompatible with requirement - SequenceCsv { - /// requested sequence - requested: Sequence, - /// required sequence - required: Sequence, - }, - /// too many `OP_RETURN` in a single tx - TooManyOpReturn, - /// error when updating a PSBT - Update(UpdatePsbtError), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::InsaneFee(r) => write!(f, "absurd feerate: {r:#}"), - Self::LockTimeCltv { - requested, - required, - } => write!( - f, - "requested locktime {requested} must be at least {required}" - ), - Self::LockTypeMismatch => write!(f, "cannot mix locktime units"), - Self::MaxOpReturnRelay => write!(f, "non-standard: output exceeds data carrier limit"), - Self::NegativeFee(e) => write!(f, "illegal tx: negative fee: {}", e.display_dynamic()), - Self::Psbt(e) => e.fmt(f), - Self::SequenceCsv { - requested, - required, - } => write!(f, "{requested} is incompatible with required {required}"), - Self::TooManyOpReturn => write!(f, "non-standard: only 1 OP_RETURN output permitted"), - Self::Update(e) => e.fmt(f), - } - } -} - -impl From for Error { - fn from(e: bitcoin::psbt::Error) -> Self { - Self::Psbt(e) - } -} - -#[cfg(feature = "std")] -impl std::error::Error for Error {} - -#[cfg(test)] -mod test { - use super::*; - use crate::Signer; - use alloc::string::String; - - use bitcoin::{ - secp256k1::{self, Secp256k1}, - Txid, - }; - use miniscript::{ - descriptor::{DefiniteDescriptorKey, Descriptor, DescriptorPublicKey, KeyMap}, - plan::Assets, - ForEachKey, - }; - - use bdk_chain::{ - bdk_core, keychain_txout::KeychainTxOutIndex, local_chain::LocalChain, IndexedTxGraph, - TxGraph, - }; - use bdk_core::{CheckPoint, ConfirmationBlockTime}; - - const XPRV: &str = "tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L"; - const WIF: &str = "cU6BxEezV8FnkEPBCaFtc4WNuUKmgFaAu6sJErB154GXgMUjhgWe"; - const SPK: &str = "00143f027073e6f341c481f55b7baae81dda5e6a9fba"; - - fn get_single_sig_tr_xprv() -> Vec { - (0..2) - .map(|i| format!("tr({XPRV}/86h/1h/0h/{i}/*)")) - .collect() - } - - fn get_single_sig_cltv_timestamp() -> String { - format!("wsh(and_v(v:pk({WIF}),after(1735877503)))") - } - - type KeychainTxGraph = IndexedTxGraph>; - - #[derive(Debug)] - struct TestProvider { - assets: Assets, - signer: Signer, - secp: Secp256k1, - chain: LocalChain, - graph: KeychainTxGraph, - } - - impl DataProvider for TestProvider { - fn get_tx(&self, txid: Txid) -> Option { - self.graph - .graph() - .get_tx(txid) - .map(|tx| tx.as_ref().clone()) - } - - fn get_descriptor_for_txout( - &self, - txout: &TxOut, - ) -> Option> { - let indexer = &self.graph.index; - - let (keychain, index) = indexer.index_of_spk(txout.script_pubkey.clone())?; - let desc = indexer.get_descriptor(*keychain)?; - - desc.at_derivation_index(*index).ok() - } - } - - impl TestProvider { - /// Set max absolute timelock - fn after(mut self, lt: absolute::LockTime) -> Self { - self.assets = self.assets.after(lt); - self - } - - /// Get a reference to the tx graph - fn graph(&self) -> &TxGraph { - self.graph.graph() - } - - /// Get a reference to the indexer - fn index(&self) -> &KeychainTxOutIndex { - &self.graph.index - } - - /// Get the script pubkey at the specified `index` from the first keychain - /// (by Ord). - fn spk_at_index(&self, index: u32) -> Option { - let keychain = self.graph.index.keychains().next().unwrap().0; - self.graph.index.spk_at_index(keychain, index) - } - - /// Get next unused internal script pubkey - fn next_internal_spk(&mut self) -> ScriptBuf { - let keychain = self.graph.index.keychains().last().unwrap().0; - let ((_, spk), _) = self.graph.index.next_unused_spk(keychain).unwrap(); - spk - } - - /// Get balance - fn balance(&self) -> bdk_chain::Balance { - let chain = &self.chain; - let chain_tip = chain.tip().block_id(); - - let outpoints = self.graph.index.outpoints().clone(); - let graph = self.graph.graph(); - graph.balance(chain, chain_tip, outpoints, |_, _| true) - } - - /// Get a list of planned utxos sorted largest first - fn planned_utxos(&self) -> Vec { - let chain = &self.chain; - let chain_tip = chain.tip().block_id(); - let op = self.index().outpoints().clone(); - - let mut utxos = vec![]; - - for (indexed, txo) in self.graph().filter_chain_unspents(chain, chain_tip, op) { - let (keychain, index) = indexed; - let desc = self.index().get_descriptor(keychain).unwrap(); - let def = desc.at_derivation_index(index).unwrap(); - if let Ok(plan) = def.plan(&self.assets) { - utxos.push(PlanUtxo { - plan, - outpoint: txo.outpoint, - txout: txo.txout, - }); - } - } - - utxos.sort_by_key(|p| p.txout.value); - utxos.reverse(); - - utxos - } - - /// Attempt to create all the required signatures for this psbt - fn sign(&self, psbt: &mut Psbt) { - let _ = psbt.sign(&self.signer, &self.secp); - } - } - - macro_rules! block_id { - ( $height:expr, $hash:expr ) => { - bdk_chain::BlockId { - height: $height, - hash: $hash, - } - }; - } - - fn new_tx(lt: u32) -> Transaction { - Transaction { - version: transaction::Version(2), - lock_time: absolute::LockTime::from_consensus(lt), - input: vec![TxIn::default()], - output: vec![], - } - } - - fn parse_descriptor(s: &str) -> (Descriptor, KeyMap) { - >::parse_descriptor(&Secp256k1::new(), s).unwrap() - } - - /// Initialize a [`TestProvider`] with the given `descriptors`. - /// - /// The returned object contains a local chain at height 1000 and an indexed tx graph - /// with 10 x 1Msat utxos. - fn init_graph(descriptors: &[String]) -> TestProvider { - use bitcoin::{constants, hashes::Hash, Network}; - - let mut keys = vec![]; - let mut keymap = KeyMap::new(); - - let mut index = KeychainTxOutIndex::new(10); - for (keychain, desc_str) in descriptors.iter().enumerate() { - let (desc, km) = parse_descriptor(desc_str); - desc.for_each_key(|k| { - keys.push(k.clone()); - true - }); - keymap.extend(km); - index.insert_descriptor(keychain, desc).unwrap(); - } - - let mut graph = KeychainTxGraph::new(index); - - let genesis_hash = constants::genesis_block(Network::Regtest).block_hash(); - let mut cp = CheckPoint::new(block_id!(0, genesis_hash)); - - for height in 1..11 { - let ((_, script_pubkey), _) = graph.index.reveal_next_spk(0).unwrap(); - - let tx = Transaction { - output: vec![TxOut { - value: Amount::from_btc(0.01).unwrap(), - script_pubkey, - }], - ..new_tx(height) - }; - let txid = tx.compute_txid(); - let _ = graph.insert_tx(tx); - - let block_id = block_id!(height, Hash::hash(height.to_be_bytes().as_slice())); - let anchor = ConfirmationBlockTime { - block_id, - confirmation_time: height as u64, - }; - let _ = graph.insert_anchor(txid, anchor); - - cp = cp.insert(block_id); - } - - let tip = block_id!(1000, Hash::hash(b"Z")); - cp = cp.insert(tip); - let chain = LocalChain::from_tip(cp).unwrap(); - - let assets = Assets::new().add(keys); - - TestProvider { - assets, - signer: Signer(keymap), - secp: Secp256k1::new(), - chain, - graph, - } - } - - #[test] - fn test_build_tx_finalize() { - let mut graph = init_graph(&get_single_sig_tr_xprv()); - assert_eq!(graph.balance().total().to_btc(), 0.1); - - let recip = ScriptBuf::from_hex(SPK).unwrap(); - let mut builder = Builder::new(); - builder.add_output(recip, Amount::from_sat(2_500_000)); - - let selection = graph.planned_utxos().into_iter().take(3); - builder.add_inputs(selection); - builder.add_change_output(graph.next_internal_spk(), Amount::from_sat(499_500)); - - let (mut psbt, finalizer) = builder.build_tx(&mut graph).unwrap(); - assert_eq!(psbt.unsigned_tx.input.len(), 3); - assert_eq!(psbt.unsigned_tx.output.len(), 2); - - graph.sign(&mut psbt); - assert!(finalizer.finalize(&mut psbt).is_finalized()); - } - - #[test] - fn test_build_tx_insane_fee() { - let mut graph = init_graph(&get_single_sig_tr_xprv()); - - let recip = ScriptBuf::from_hex(SPK).unwrap(); - let mut builder = Builder::new(); - builder.add_output(recip, Amount::from_btc(0.01).unwrap()); - - let selection = graph - .planned_utxos() - .into_iter() - .take(3) - .collect::>(); - assert_eq!( - selection - .iter() - .map(|p| p.txout.value) - .sum::() - .to_btc(), - 0.03 - ); - builder.add_inputs(selection); - - let err = builder.build_tx(&mut graph).unwrap_err(); - assert!(matches!(err, Error::InsaneFee(..))); - } - - #[test] - fn test_build_tx_negative_fee() { - let mut graph = init_graph(&get_single_sig_tr_xprv()); - - let recip = ScriptBuf::from_hex(SPK).unwrap(); - - let mut builder = Builder::new(); - builder.add_output(recip, Amount::from_btc(0.02).unwrap()); - builder.add_inputs(graph.planned_utxos().into_iter().take(1)); - - let err = builder.build_tx(&mut graph).unwrap_err(); - assert!(matches!(err, Error::NegativeFee(..))); - } - - #[test] - fn test_build_tx_add_data() { - let mut graph = init_graph(&get_single_sig_tr_xprv()); - - let mut builder = Builder::new(); - builder.add_inputs(graph.planned_utxos().into_iter().take(1)); - builder.add_output(graph.next_internal_spk(), Amount::from_sat(999_000)); - builder.add_data(b"satoshi nakamoto").unwrap(); - - let psbt = builder.build_tx(&mut graph).unwrap().0; - assert!(psbt - .unsigned_tx - .output - .iter() - .any(|txo| txo.script_pubkey.is_op_return())); - - // try to add more than 80 bytes of data - let data = [0x90; 81]; - builder = Builder::new(); - assert!(matches!( - builder.add_data(data), - Err(Error::MaxOpReturnRelay) - )); - - // try to add more than 1 op return - let data = [0x90; 80]; - builder = Builder::new(); - builder.add_data(data).unwrap(); - assert!(matches!( - builder.add_data(data), - Err(Error::TooManyOpReturn) - )); - } - - #[test] - fn test_build_tx_version() { - use transaction::Version; - let mut graph = init_graph(&get_single_sig_tr_xprv()); - - // test default tx version (2) - let mut builder = Builder::new(); - let recip = graph.spk_at_index(0).unwrap(); - let utxo = graph.planned_utxos().first().unwrap().clone(); - let amt = utxo.txout.value - Amount::from_sat(256); - builder.add_input(utxo.clone()); - builder.add_output(recip.clone(), amt); - - let psbt = builder.build_tx(&mut graph).unwrap().0; - assert_eq!(psbt.unsigned_tx.version, Version::TWO); - - // allow any potentially non-standard version - builder = Builder::new(); - builder.version(Version(3)); - builder.add_input(utxo); - builder.add_output(recip, amt); - - let psbt = builder.build_tx(&mut graph).unwrap().0; - assert_eq!(psbt.unsigned_tx.version, Version(3)); - } - - #[test] - fn test_timestamp_timelock() { - #[derive(Clone)] - struct InOut { - input: PlanUtxo, - output: (ScriptBuf, Amount), - } - fn check_locktime(graph: &mut TestProvider, in_out: InOut, lt: u32, exp_lt: Option) { - let InOut { - input, - output: (recip, amount), - } = in_out; - - let mut builder = Builder::new(); - builder.add_output(recip, amount); - builder.add_input(input); - builder.locktime(absolute::LockTime::from_consensus(lt)); - - let res = builder.build_tx(graph); - - match res { - Ok((mut psbt, finalizer)) => { - assert_eq!( - psbt.unsigned_tx.lock_time.to_consensus_u32(), - exp_lt.unwrap() - ); - graph.sign(&mut psbt); - assert!(finalizer.finalize(&mut psbt).is_finalized()); - } - Err(e) => { - assert!(exp_lt.is_none()); - if absolute::LockTime::from_consensus(lt).is_block_height() { - assert!(matches!(e, Error::LockTypeMismatch)); - } else if lt < 1735877503 { - assert!(matches!(e, Error::LockTimeCltv { .. })); - } - } - } - } - - // initial state - let mut graph = init_graph(&[get_single_sig_cltv_timestamp()]); - let mut t = 1735877503; - let locktime = absolute::LockTime::from_consensus(t); - - // supply the assets needed to create plans - graph = graph.after(locktime); - - let in_out = InOut { - input: graph.planned_utxos().first().unwrap().clone(), - output: (ScriptBuf::from_hex(SPK).unwrap(), Amount::from_sat(999_000)), - }; - - // Test: tx should use the planned locktime - check_locktime(&mut graph, in_out.clone(), t, Some(t)); - - // Test: requesting a lower timelock should error - check_locktime( - &mut graph, - in_out.clone(), - absolute::LOCK_TIME_THRESHOLD, - None, - ); - - // Test: tx may use a custom locktime - t += 1; - check_locktime(&mut graph, in_out.clone(), t, Some(t)); - - // Test: error if lock type mismatch - check_locktime(&mut graph, in_out, 100, None); - } - - #[test] - fn test_build_zero_fee_tx() { - let mut graph = init_graph(&get_single_sig_tr_xprv()); - - let recip = ScriptBuf::from_hex(SPK).unwrap(); - let utxos = graph.planned_utxos(); - - // case: 1-in/1-out - let mut builder = Builder::new(); - builder.add_inputs(utxos.iter().take(1).cloned()); - builder.add_output(recip.clone(), Amount::from_sat(1_000_000)); - let psbt = builder.build_tx(&mut graph).unwrap().0; - assert_eq!(psbt.unsigned_tx.output.len(), 1); - assert_eq!(psbt.unsigned_tx.output[0].value.to_btc(), 0.01); - - // case: 1-in/2-out - let mut builder = Builder::new(); - builder.add_inputs(utxos.iter().take(1).cloned()); - builder.add_output(recip, Amount::from_sat(500_000)); - builder.add_change_output(graph.next_internal_spk(), Amount::from_sat(500_000)); - builder.check_fee(Some(Amount::ZERO), Some(FeeRate::from_sat_per_kwu(0))); - - let psbt = builder.build_tx(&mut graph).unwrap().0; - assert_eq!(psbt.unsigned_tx.output.len(), 2); - assert!(psbt - .unsigned_tx - .output - .iter() - .all(|txo| txo.value.to_sat() == 500_000)); - } -} diff --git a/src/finalizer.rs b/src/finalizer.rs new file mode 100644 index 0000000..6c1ac3f --- /dev/null +++ b/src/finalizer.rs @@ -0,0 +1,107 @@ +use crate::collections::{BTreeMap, HashMap}; +use bitcoin::{OutPoint, Psbt, Witness}; +use miniscript::{bitcoin, plan::Plan, psbt::PsbtInputSatisfier}; + +/// Finalizer +#[derive(Debug)] +pub struct Finalizer { + pub(crate) plans: HashMap, +} + +impl Finalizer { + /// Finalize a PSBT input and return whether finalization was successful. + /// + /// # Errors + /// + /// If the spending plan associated with the PSBT input cannot be satisfied, + /// then a [`miniscript::Error`] is returned. + /// + /// # Panics + /// + /// - If `input_index` is outside the bounds of the PSBT input vector. + pub fn finalize_input( + &self, + psbt: &mut Psbt, + input_index: usize, + ) -> Result { + let mut finalized = false; + let outpoint = psbt + .unsigned_tx + .input + .get(input_index) + .expect("index out of range") + .previous_output; + if let Some(plan) = self.plans.get(&outpoint) { + let stfr = PsbtInputSatisfier::new(psbt, input_index); + let (stack, script) = plan.satisfy(&stfr)?; + // clearing all fields and setting back the utxo, final scriptsig and witness + let original = core::mem::take(&mut psbt.inputs[input_index]); + let psbt_input = &mut psbt.inputs[input_index]; + psbt_input.non_witness_utxo = original.non_witness_utxo; + psbt_input.witness_utxo = original.witness_utxo; + if !script.is_empty() { + psbt_input.final_script_sig = Some(script); + } + if !stack.is_empty() { + psbt_input.final_script_witness = Some(Witness::from_slice(&stack)); + } + finalized = true; + } + + Ok(finalized) + } + + /// Attempt to finalize all of the inputs. + /// + /// This method returns a [`FinalizeMap`] that contains the result of finalization + /// for each input. + pub fn finalize(&self, psbt: &mut Psbt) -> FinalizeMap { + let mut finalized = true; + let mut result = FinalizeMap(BTreeMap::new()); + + for input_index in 0..psbt.inputs.len() { + let psbt_input = &psbt.inputs[input_index]; + if psbt_input.final_script_sig.is_some() || psbt_input.final_script_witness.is_some() { + continue; + } + match self.finalize_input(psbt, input_index) { + Ok(is_final) => { + if finalized && !is_final { + finalized = false; + } + result.0.insert(input_index, Ok(is_final)); + } + Err(e) => { + result.0.insert(input_index, Err(e)); + } + } + } + + // clear psbt outputs + if finalized { + for psbt_output in &mut psbt.outputs { + psbt_output.bip32_derivation.clear(); + psbt_output.tap_key_origins.clear(); + psbt_output.tap_internal_key.take(); + } + } + + result + } +} + +/// Holds the results of finalization +#[derive(Debug)] +pub struct FinalizeMap(BTreeMap>); + +impl FinalizeMap { + /// Whether all inputs were finalized + pub fn is_finalized(&self) -> bool { + self.0.values().all(|res| matches!(res, Ok(true))) + } + + /// Get the results as a map of `input_index` to `finalize_input` result. + pub fn results(self) -> BTreeMap> { + self.0 + } +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..1c15645 --- /dev/null +++ b/src/input.rs @@ -0,0 +1,141 @@ +use std::{sync::Arc, vec::Vec}; + +use bdk_coin_select::TXIN_BASE_WEIGHT; +use bitcoin::transaction::OutputsIndexError; +use miniscript::bitcoin; +use miniscript::bitcoin::{OutPoint, Transaction, TxOut}; +use miniscript::plan::Plan; + +/// Single-input plan. +#[derive(Debug, Clone)] +pub struct Input { + outpoint: OutPoint, + txout: TxOut, + tx: Option>, + plan: Plan, +} + +impl From<(Plan, OutPoint, TxOut)> for Input { + fn from((plan, prev_outpoint, prev_txout): (Plan, OutPoint, TxOut)) -> Self { + Self::from_prev_txout(plan, prev_outpoint, prev_txout) + } +} + +impl TryFrom<(Plan, T, usize)> for Input +where + T: Into>, +{ + type Error = OutputsIndexError; + + fn try_from((plan, prev_tx, output_index): (Plan, T, usize)) -> Result { + Self::from_prev_tx(plan, prev_tx, output_index) + } +} + +impl Input { + /// Create + /// + /// Returns `None` if `prev_output_index` does not exist in `prev_tx`. + pub fn from_prev_tx( + plan: Plan, + prev_tx: T, + output_index: usize, + ) -> Result + where + T: Into>, + { + let tx: Arc = prev_tx.into(); + Ok(Self { + outpoint: OutPoint::new(tx.compute_txid(), output_index as _), + txout: tx.tx_out(output_index).cloned()?, + tx: Some(tx), + plan, + }) + } + + /// Create + pub fn from_prev_txout(plan: Plan, prev_outpoint: OutPoint, prev_txout: TxOut) -> Self { + Self { + outpoint: prev_outpoint, + txout: prev_txout, + tx: None, + plan, + } + } + + /// Plan + pub fn plan(&self) -> &Plan { + &self.plan + } + + /// Previous outpoint. + pub fn prev_outpoint(&self) -> OutPoint { + self.outpoint + } + + /// Previous txout. + pub fn prev_txout(&self) -> &TxOut { + &self.txout + } + + /// Previous tx (if any). + pub fn prev_tx(&self) -> Option<&Transaction> { + self.tx.as_ref().map(|tx| tx.as_ref()) + } + + /// To coin selection candidate. + pub fn to_candidate(&self) -> bdk_coin_select::Candidate { + bdk_coin_select::Candidate::new( + self.prev_txout().value.to_sat(), + self.plan.satisfaction_weight() as _, + self.plan.witness_version().is_some(), + ) + } +} + +/// Input group. Cannot be empty. +#[derive(Debug, Clone)] +pub struct InputGroup(Vec); + +impl InputGroup { + /// From a single input. + pub fn from_input(input: impl Into) -> Self { + Self(vec![input.into()]) + } + + /// This return `None` to avoid creating empty input groups. + pub fn from_inputs(inputs: impl IntoIterator>) -> Option { + let group = inputs.into_iter().map(Into::into).collect::>(); + if group.is_empty() { + None + } else { + Some(Self(group)) + } + } + + /// Reference to the inputs of this group. + pub fn inputs(&self) -> &Vec { + &self.0 + } + + /// To coin selection candidate. + pub fn to_candidate(&self) -> bdk_coin_select::Candidate { + bdk_coin_select::Candidate { + value: self + .inputs() + .iter() + .map(|input| input.prev_txout().value.to_sat()) + .sum(), + weight: self + .inputs() + .iter() + .map(|input| TXIN_BASE_WEIGHT + input.plan().satisfaction_weight() as u64) + .sum(), + input_count: self.inputs().len(), + is_segwit: self + .inputs() + .iter() + .any(|input| input.plan().witness_version().is_some()), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 9fc5404..6a95687 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,13 +9,24 @@ extern crate alloc; #[cfg(feature = "std")] extern crate std; -mod builder; +mod finalizer; +mod input; +mod output; mod signer; -mod updater; -pub use builder::*; +use alloc::vec::Vec; + +use bitcoin::{ + absolute, + transaction::{self, Version}, + Psbt, +}; +pub use finalizer::*; +pub use input::*; +pub use miniscript::bitcoin; +use miniscript::{psbt::PsbtExt, DefiniteDescriptorKey, Descriptor}; +pub use output::*; pub use signer::*; -pub use updater::*; pub(crate) mod collections { #![allow(unused)] @@ -27,3 +38,181 @@ pub(crate) mod collections { pub type HashMap = alloc::collections::BTreeMap; pub use alloc::collections::*; } + +/// Definite descriptor. +pub type DefiniteDescriptor = Descriptor; + +/// Parameters for creating a psbt. +#[derive(Debug, Clone)] +pub struct PsbtParams { + /// Inputs to fund the tx. + /// + /// It is up to the caller to not duplicate inputs, spend from 2 conflicting txs, spend from + /// invalid inputs, etc. + pub inputs: Vec, + /// Outputs. + pub outputs: Vec, + + /// Use a specific [`transaction::Version`]. + pub version: transaction::Version, + + /// Fallback tx locktime. + /// + /// The locktime to use if no inputs specifies a required absolute locktime. + /// + /// It is best practive to set this to the latest block height to avoid fee sniping. + pub fallback_locktime: absolute::LockTime, + + /// Recommended. + pub mandate_full_tx_for_segwit_v0: bool, +} + +impl Default for PsbtParams { + fn default() -> Self { + Self { + version: Version::TWO, + inputs: Default::default(), + outputs: Default::default(), + fallback_locktime: absolute::LockTime::ZERO, + mandate_full_tx_for_segwit_v0: true, + } + } +} + +/// Occurs when creating a psbt fails. +#[derive(Debug)] +pub enum PsbtError { + /// Attempted to mix locktime types. + LockTypeMismatch, + /// Missing tx for legacy input. + MissingFullTxForLegacyInput(Input), + /// Missing tx for segwit v0 input. + MissingFullTxForSegwitV0Input(Input), + /// Psbt error. + Psbt(bitcoin::psbt::Error), + /// Update psbt output with descriptor error. + OutputUpdate(miniscript::psbt::OutputUpdateError), +} + +impl core::fmt::Display for PsbtError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + PsbtError::LockTypeMismatch => write!(f, "cannot mix locktime units"), + PsbtError::MissingFullTxForLegacyInput(input) => write!( + f, + "legacy input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", + input.prev_outpoint() + ), + PsbtError::MissingFullTxForSegwitV0Input(input) => write!( + f, + "segwit v0 input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", + input.prev_outpoint() + ), + PsbtError::Psbt(error) => error.fmt(f), + PsbtError::OutputUpdate(output_update_error) => output_update_error.fmt(f), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for PsbtError {} + +const FALLBACK_SEQUENCE: bitcoin::Sequence = bitcoin::Sequence::MAX; + +/// Returns none if there is a mismatch of units in `locktimes`. +/// +/// As according to BIP-64... +pub fn accumulate_max_locktime( + locktimes: impl IntoIterator, + fallback: absolute::LockTime, +) -> Option { + let mut acc = Option::::None; + for locktime in locktimes { + match &mut acc { + Some(acc) => { + if !acc.is_same_unit(locktime) { + return None; + } + if acc.is_implied_by(locktime) { + *acc = locktime; + } + } + acc => *acc = Some(locktime), + }; + } + if acc.is_none() { + acc = Some(fallback); + } + acc +} + +/// Create psbt. +pub fn create_psbt(params: PsbtParams) -> Result<(bitcoin::Psbt, Finalizer), PsbtError> { + let mut psbt = Psbt::from_unsigned_tx(bitcoin::Transaction { + version: params.version, + lock_time: accumulate_max_locktime( + params + .inputs + .iter() + .filter_map(|input| input.plan().absolute_timelock), + params.fallback_locktime, + ) + .ok_or(PsbtError::LockTypeMismatch)?, + input: params + .inputs + .iter() + .map(|input| bitcoin::TxIn { + previous_output: input.prev_outpoint(), + sequence: input + .plan() + .relative_timelock + .map_or(FALLBACK_SEQUENCE, |locktime| locktime.to_sequence()), + ..Default::default() + }) + .collect(), + output: params.outputs.iter().map(|output| output.txout()).collect(), + }) + .map_err(PsbtError::Psbt)?; + + for (plan_input, psbt_input) in params.inputs.iter().zip(psbt.inputs.iter_mut()) { + let txout = plan_input.prev_txout(); + + plan_input.plan().update_psbt_input(psbt_input); + + let witness_version = plan_input.plan().witness_version(); + if witness_version.is_some() { + psbt_input.witness_utxo = Some(txout.clone()); + } + + // We are allowed to have full tx for segwit inputs. Might as well include it. + // If the caller does not wish to include the full tx in Segwit V0 inputs, they should not + // include it in `crate::Input`. + psbt_input.non_witness_utxo = plan_input.prev_tx().cloned(); + if psbt_input.non_witness_utxo.is_none() { + if witness_version.is_none() { + return Err(PsbtError::MissingFullTxForLegacyInput(plan_input.clone())); + } + if params.mandate_full_tx_for_segwit_v0 + && witness_version == Some(bitcoin::WitnessVersion::V0) + { + return Err(PsbtError::MissingFullTxForSegwitV0Input(plan_input.clone())); + } + } + } + for (output_index, output) in params.outputs.iter().enumerate() { + if let Some(desc) = output.descriptor() { + psbt.update_output_with_descriptor(output_index, desc) + .map_err(PsbtError::OutputUpdate)?; + } + } + + let finalizer = Finalizer { + plans: params + .inputs + .into_iter() + .map(|input| (input.prev_outpoint(), input.plan().clone())) + .collect(), + }; + + Ok((psbt, finalizer)) +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..c0d27ac --- /dev/null +++ b/src/output.rs @@ -0,0 +1,131 @@ +use bitcoin::{Amount, ScriptBuf, TxOut}; +use miniscript::bitcoin; + +use crate::DefiniteDescriptor; + +/// Can get script pubkey from this. +#[derive(Debug, Clone)] +pub enum ScriptSource { + /// From ScriptBuf. + Script(ScriptBuf), + /// From definite descriptor. + Descriptor(DefiniteDescriptor), +} + +impl From for ScriptSource { + fn from(script: ScriptBuf) -> Self { + Self::from_script(script) + } +} + +impl From for ScriptSource { + fn from(descriptor: DefiniteDescriptor) -> Self { + Self::from_descriptor(descriptor) + } +} + +impl ScriptSource { + /// From script + pub fn from_script(script: ScriptBuf) -> Self { + Self::Script(script) + } + + /// From descriptor + pub fn from_descriptor(descriptor: DefiniteDescriptor) -> Self { + Self::Descriptor(descriptor) + } + + /// To ScriptBuf + pub fn script(&self) -> ScriptBuf { + match self { + ScriptSource::Script(script_buf) => script_buf.clone(), + ScriptSource::Descriptor(descriptor) => descriptor.script_pubkey(), + } + } + + /// Get descriptor (if any). + pub fn descriptor(&self) -> Option<&DefiniteDescriptor> { + match self { + ScriptSource::Script(_) => None, + ScriptSource::Descriptor(descriptor) => Some(descriptor), + } + } +} + +/// Builder output +#[derive(Debug, Clone)] +pub struct Output { + /// Value + pub value: Amount, + /// Spk source + pub script_pubkey_source: ScriptSource, +} + +impl From<(ScriptBuf, Amount)> for Output { + fn from((script, value): (ScriptBuf, Amount)) -> Self { + Self::with_script(script, value) + } +} + +impl From<(DefiniteDescriptor, Amount)> for Output { + fn from((descriptor, value): (DefiniteDescriptor, Amount)) -> Self { + Self::with_descriptor(descriptor, value) + } +} + +impl Output { + /// From script + pub fn with_script(script: ScriptBuf, value: Amount) -> Self { + Self { + value, + script_pubkey_source: script.into(), + } + } + + /// From descriptor + pub fn with_descriptor(descriptor: DefiniteDescriptor, value: Amount) -> Self { + Self { + value, + script_pubkey_source: descriptor.into(), + } + } + + /// Script pubkey + pub fn script_pubkey(&self) -> ScriptBuf { + self.script_pubkey_source.script() + } + + /// Descriptor + pub fn descriptor(&self) -> Option<&DefiniteDescriptor> { + self.script_pubkey_source.descriptor() + } + + /// Create txout. + pub fn txout(&self) -> TxOut { + TxOut { + value: self.value, + script_pubkey: self.script_pubkey_source.script(), + } + } + + /// To coin select drain (change) output weights. + /// + /// Returns `None` if no descriptor is avaliable or the output is unspendable. + pub fn to_drain_weights(&self) -> Option { + let descriptor = self.descriptor()?; + Some(bdk_coin_select::DrainWeights { + output_weight: self.txout().weight().to_wu(), + spend_weight: descriptor.max_weight_to_satisfy().ok()?.to_wu(), + n_outputs: 1, + }) + } + + /// To coin select target outputs. + pub fn to_target_outputs(&self) -> bdk_coin_select::TargetOutputs { + bdk_coin_select::TargetOutputs { + value_sum: self.txout().value.to_sat(), + weight_sum: self.txout().weight().to_wu(), + n_outputs: 1, + } + } +} diff --git a/src/updater.rs b/src/updater.rs deleted file mode 100644 index 85ded19..0000000 --- a/src/updater.rs +++ /dev/null @@ -1,315 +0,0 @@ -use bitcoin::{ - bip32::{self, DerivationPath, Fingerprint}, - psbt::{self, PsbtSighashType}, - OutPoint, Psbt, Transaction, TxOut, Txid, Witness, -}; -use miniscript::{ - bitcoin, - descriptor::{DefiniteDescriptorKey, DescriptorType}, - plan::Plan, - psbt::{PsbtExt, PsbtInputSatisfier}, - Descriptor, -}; - -use crate::collections::{BTreeMap, HashMap}; -use crate::PlanUtxo; - -/// Trait describing the actions required to update a PSBT. -pub trait DataProvider { - /// Get transaction by txid - fn get_tx(&self, txid: Txid) -> Option; - - /// Get the definite descriptor that can derive the script in `txout`. - fn get_descriptor_for_txout(&self, txout: &TxOut) -> Option>; - - /// Sort transaction inputs and outputs. - /// - /// This has a default implementation that does no sorting. The implementation must not alter - /// the semantics of the transaction in any way, like changing the number of inputs and outputs, - /// changing scripts or amounts, or otherwise interfere with transaction building. - fn sort_transaction(&mut self, _tx: &mut Transaction) {} -} - -/// Updater -#[derive(Debug)] -pub struct PsbtUpdater { - psbt: Psbt, - map: HashMap, -} - -impl PsbtUpdater { - /// New from `unsigned_tx` and `utxos` - pub fn new( - unsigned_tx: Transaction, - utxos: impl IntoIterator, - ) -> Result { - let map: HashMap<_, _> = utxos.into_iter().map(|p| (p.outpoint, p)).collect(); - debug_assert!( - unsigned_tx - .input - .iter() - .all(|txin| map.contains_key(&txin.previous_output)), - "all spends must be accounted for", - ); - let psbt = Psbt::from_unsigned_tx(unsigned_tx)?; - - Ok(Self { psbt, map }) - } - - /// Get plan - fn get_plan(&self, outpoint: &OutPoint) -> Option<&Plan> { - Some(&self.map.get(outpoint)?.plan) - } - - // Get txout - fn get_txout(&self, outpoint: &OutPoint) -> Option { - self.map.get(outpoint).map(|p| p.txout.clone()) - } - - /// Update the PSBT with the given `provider` and update options. - /// - /// # Errors - /// - /// This function may error if a discrepancy is found between the outpoint, previous - /// txout and witness/non-witness utxo for a planned input. - pub fn update_psbt( - &mut self, - provider: &D, - opt: UpdateOptions, - ) -> Result<(), UpdatePsbtError> - where - D: DataProvider, - { - let tx = self.psbt.unsigned_tx.clone(); - - // update inputs - for (input_index, txin) in tx.input.iter().enumerate() { - let outpoint = txin.previous_output; - let plan = self.get_plan(&outpoint).expect("must have plan").clone(); - let prevout = self.get_txout(&outpoint).expect("must have txout"); - - // update input with plan - let psbt_input = &mut self.psbt.inputs[input_index]; - plan.update_psbt_input(psbt_input); - - // add non-/witness utxo - if let Some(desc) = provider.get_descriptor_for_txout(&prevout) { - if is_witness(desc.desc_type()) { - psbt_input.witness_utxo = Some(prevout.clone()); - } - if !is_taproot(desc.desc_type()) && !opt.only_witness_utxo { - psbt_input.non_witness_utxo = provider.get_tx(outpoint.txid); - } - } - - if opt.sighash_type.is_some() { - psbt_input.sighash_type = opt.sighash_type; - } - - // update fields not covered by `update_psbt_input` e.g. `.tap_scripts` - if opt.update_with_descriptor { - if let Some(desc) = provider.get_descriptor_for_txout(&prevout) { - self.psbt - .update_input_with_descriptor(input_index, &desc) - .map_err(UpdatePsbtError::Utxo)?; - } - } - } - - // update outputs - for (output_index, txout) in tx.output.iter().enumerate() { - if let Some(desc) = provider.get_descriptor_for_txout(txout) { - self.psbt - .update_output_with_descriptor(output_index, &desc) - .map_err(UpdatePsbtError::Output)?; - } - } - - Ok(()) - } - - /// Return a reference to the PSBT - pub fn psbt(&self) -> &Psbt { - &self.psbt - } - - /// Add a [`bip32::Xpub`] and key origin to the psbt global xpubs - pub fn add_global_xpub(&mut self, xpub: bip32::Xpub, origin: (Fingerprint, DerivationPath)) { - self.psbt.xpub.insert(xpub, origin); - } - - /// Set a `sighash_type` for the psbt input at `index` - pub fn sighash_type(&mut self, index: usize, sighash_type: Option) { - if let Some(psbt_input) = self.psbt.inputs.get_mut(index) { - psbt_input.sighash_type = sighash_type; - } - } - - /// Convert this updater into a [`Finalizer`] and return the updated [`Psbt`]. - pub fn into_finalizer(self) -> (Psbt, Finalizer) { - (self.psbt, Finalizer { map: self.map }) - } -} - -/// Options for updating a PSBT -#[derive(Debug, Default, Clone)] -pub struct UpdateOptions { - /// Only set the input `witness_utxo` if applicable, i.e. do not set `non_witness_utxo`. - /// - /// Defaults to `false` which will set the `non_witness_utxo` for non-taproot inputs - pub only_witness_utxo: bool, - - /// Use a particular sighash type for all PSBT inputs - pub sighash_type: Option, - - /// Whether to use the descriptor to update as many fields as we can. - /// - /// Defaults to `false` which will update only the fields of the PSBT - /// that are relevant to the current spend plan. - pub update_with_descriptor: bool, -} - -/// Error when updating a PSBT -#[derive(Debug)] -pub enum UpdatePsbtError { - /// output update - Output(miniscript::psbt::OutputUpdateError), - /// utxo update - Utxo(miniscript::psbt::UtxoUpdateError), -} - -impl core::fmt::Display for UpdatePsbtError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - Self::Output(e) => e.fmt(f), - Self::Utxo(e) => e.fmt(f), - } - } -} - -#[cfg(feature = "std")] -impl std::error::Error for UpdatePsbtError {} - -/// Whether the given descriptor type matches any of the post-segwit descriptor types -/// including segwit v1 (taproot) -fn is_witness(desc_ty: DescriptorType) -> bool { - use DescriptorType::*; - matches!( - desc_ty, - Wpkh | ShWpkh | Wsh | ShWsh | ShWshSortedMulti | WshSortedMulti | Tr, - ) -} - -/// Whether this descriptor type is `Tr` -fn is_taproot(desc_ty: DescriptorType) -> bool { - matches!(desc_ty, DescriptorType::Tr) -} - -/// Finalizer -#[derive(Debug)] -pub struct Finalizer { - map: HashMap, -} - -impl Finalizer { - /// Get plan - fn get_plan(&self, outpoint: &OutPoint) -> Option<&Plan> { - Some(&self.map.get(outpoint)?.plan) - } - - /// Finalize a PSBT input and return whether finalization was successful. - /// - /// # Errors - /// - /// If the spending plan associated with the PSBT input cannot be satisfied, - /// then a [`miniscript::Error`] is returned. - /// - /// # Panics - /// - /// - If `input_index` is outside the bounds of the PSBT input vector. - pub fn finalize_input( - &self, - psbt: &mut Psbt, - input_index: usize, - ) -> Result { - let mut finalized = false; - let outpoint = psbt - .unsigned_tx - .input - .get(input_index) - .expect("index out of range") - .previous_output; - if let Some(plan) = self.get_plan(&outpoint) { - let stfr = PsbtInputSatisfier::new(psbt, input_index); - let (stack, script) = plan.satisfy(&stfr)?; - // clearing all fields and setting back the utxo, final scriptsig and witness - let original = core::mem::take(&mut psbt.inputs[input_index]); - let psbt_input = &mut psbt.inputs[input_index]; - psbt_input.non_witness_utxo = original.non_witness_utxo; - psbt_input.witness_utxo = original.witness_utxo; - if !script.is_empty() { - psbt_input.final_script_sig = Some(script); - } - if !stack.is_empty() { - psbt_input.final_script_witness = Some(Witness::from_slice(&stack)); - } - finalized = true; - } - - Ok(finalized) - } - - /// Attempt to finalize all of the inputs. - /// - /// This method returns a [`FinalizeMap`] that contains the result of finalization - /// for each input. - pub fn finalize(&self, psbt: &mut Psbt) -> FinalizeMap { - let mut finalized = true; - let mut result = FinalizeMap(BTreeMap::new()); - - for input_index in 0..psbt.inputs.len() { - let psbt_input = &psbt.inputs[input_index]; - if psbt_input.final_script_sig.is_some() || psbt_input.final_script_witness.is_some() { - continue; - } - match self.finalize_input(psbt, input_index) { - Ok(is_final) => { - if finalized && !is_final { - finalized = false; - } - result.0.insert(input_index, Ok(is_final)); - } - Err(e) => { - result.0.insert(input_index, Err(e)); - } - } - } - - // clear psbt outputs - if finalized { - for psbt_output in &mut psbt.outputs { - psbt_output.bip32_derivation.clear(); - psbt_output.tap_key_origins.clear(); - psbt_output.tap_internal_key.take(); - } - } - - result - } -} - -/// Holds the results of finalization -#[derive(Debug)] -pub struct FinalizeMap(BTreeMap>); - -impl FinalizeMap { - /// Whether all inputs were finalized - pub fn is_finalized(&self) -> bool { - self.0.values().all(|res| matches!(res, Ok(true))) - } - - /// Get the results as a map of `input_index` to `finalize_input` result. - pub fn results(self) -> BTreeMap> { - self.0 - } -} diff --git a/tests/psbt.rs b/tests/psbt.rs new file mode 100644 index 0000000..78f4298 --- /dev/null +++ b/tests/psbt.rs @@ -0,0 +1,454 @@ +// #[cfg(test)] +// mod test { +// use super::*; +// use crate::Signer; +// use alloc::string::String; +// +// use bitcoin::{ +// secp256k1::{self, Secp256k1}, +// Txid, +// }; +// use miniscript::{ +// descriptor::{DefiniteDescriptorKey, Descriptor, DescriptorPublicKey, KeyMap}, +// plan::Assets, +// ForEachKey, +// }; +// +// use bdk_chain::{ +// bdk_core, keychain_txout::KeychainTxOutIndex, local_chain::LocalChain, IndexedTxGraph, +// TxGraph, +// }; +// use bdk_core::{CheckPoint, ConfirmationBlockTime}; +// +// const XPRV: &str = "tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L"; +// const WIF: &str = "cU6BxEezV8FnkEPBCaFtc4WNuUKmgFaAu6sJErB154GXgMUjhgWe"; +// const SPK: &str = "00143f027073e6f341c481f55b7baae81dda5e6a9fba"; +// +// fn get_single_sig_tr_xprv() -> Vec { +// (0..2) +// .map(|i| format!("tr({XPRV}/86h/1h/0h/{i}/*)")) +// .collect() +// } +// +// fn get_single_sig_cltv_timestamp() -> String { +// format!("wsh(and_v(v:pk({WIF}),after(1735877503)))") +// } +// +// type KeychainTxGraph = IndexedTxGraph>; +// +// #[derive(Debug)] +// struct TestProvider { +// assets: Assets, +// signer: Signer, +// secp: Secp256k1, +// chain: LocalChain, +// graph: KeychainTxGraph, +// } +// +// // impl DataProvider for TestProvider { +// // fn get_tx(&self, txid: Txid) -> Option { +// // self.graph +// // .graph() +// // .get_tx(txid) +// // .map(|tx| tx.as_ref().clone()) +// // } +// // +// // // fn get_descriptor_for_txout( +// // // &self, +// // // txout: &TxOut, +// // // ) -> Option> { +// // // let indexer = &self.graph.index; +// // // +// // // let (keychain, index) = indexer.index_of_spk(txout.script_pubkey.clone())?; +// // // let desc = indexer.get_descriptor(*keychain)?; +// // // +// // // desc.at_derivation_index(*index).ok() +// // // } +// // } +// +// impl TestProvider { +// /// Set max absolute timelock +// fn after(mut self, lt: absolute::LockTime) -> Self { +// self.assets = self.assets.after(lt); +// self +// } +// +// /// Get a reference to the tx graph +// fn graph(&self) -> &TxGraph { +// self.graph.graph() +// } +// +// /// Get a reference to the indexer +// fn index(&self) -> &KeychainTxOutIndex { +// &self.graph.index +// } +// +// /// Get the script pubkey at the specified `index` from the first keychain +// /// (by Ord). +// fn spk_at_index(&self, index: u32) -> Option { +// let keychain = self.graph.index.keychains().next().unwrap().0; +// self.graph.index.spk_at_index(keychain, index) +// } +// +// /// Get next unused internal script pubkey +// fn next_internal_spk(&mut self) -> ScriptBuf { +// let keychain = self.graph.index.keychains().last().unwrap().0; +// let ((_, spk), _) = self.graph.index.next_unused_spk(keychain).unwrap(); +// spk +// } +// +// /// Get balance +// fn balance(&self) -> bdk_chain::Balance { +// let chain = &self.chain; +// let chain_tip = chain.tip().block_id(); +// +// let outpoints = self.graph.index.outpoints().clone(); +// let graph = self.graph.graph(); +// graph.balance(chain, chain_tip, outpoints, |_, _| true) +// } +// +// /// Get a list of planned utxos sorted largest first +// fn planned_utxos(&self) -> Vec { +// let chain = &self.chain; +// let chain_tip = chain.tip().block_id(); +// let op = self.index().outpoints().clone(); +// +// let mut utxos = vec![]; +// +// for (indexed, txo) in self.graph().filter_chain_unspents(chain, chain_tip, op) { +// let (keychain, index) = indexed; +// let desc = self.index().get_descriptor(keychain).unwrap(); +// let def = desc.at_derivation_index(index).unwrap(); +// if let Ok(plan) = def.plan(&self.assets) { +// utxos.push(PlanInput { +// plan, +// outpoint: txo.outpoint, +// txout: txo.txout, +// residing_tx: None, +// }); +// } +// } +// +// utxos.sort_by_key(|p| p.txout.value); +// utxos.reverse(); +// +// utxos +// } +// +// /// Attempt to create all the required signatures for this psbt +// fn sign(&self, psbt: &mut Psbt) { +// let _ = psbt.sign(&self.signer, &self.secp); +// } +// } +// +// macro_rules! block_id { +// ( $height:expr, $hash:expr ) => { +// bdk_chain::BlockId { +// height: $height, +// hash: $hash, +// } +// }; +// } +// +// fn new_tx(lt: u32) -> Transaction { +// Transaction { +// version: transaction::Version(2), +// lock_time: absolute::LockTime::from_consensus(lt), +// input: vec![TxIn::default()], +// output: vec![], +// } +// } +// +// fn parse_descriptor(s: &str) -> (Descriptor, KeyMap) { +// >::parse_descriptor(&Secp256k1::new(), s).unwrap() +// } +// +// /// Initialize a [`TestProvider`] with the given `descriptors`. +// /// +// /// The returned object contains a local chain at height 1000 and an indexed tx graph +// /// with 10 x 1Msat utxos. +// fn init_graph(descriptors: &[String]) -> TestProvider { +// use bitcoin::{constants, hashes::Hash, Network}; +// +// let mut keys = vec![]; +// let mut keymap = KeyMap::new(); +// +// let mut index = KeychainTxOutIndex::new(10); +// for (keychain, desc_str) in descriptors.iter().enumerate() { +// let (desc, km) = parse_descriptor(desc_str); +// desc.for_each_key(|k| { +// keys.push(k.clone()); +// true +// }); +// keymap.extend(km); +// index.insert_descriptor(keychain, desc).unwrap(); +// } +// +// let mut graph = KeychainTxGraph::new(index); +// +// let genesis_hash = constants::genesis_block(Network::Regtest).block_hash(); +// let mut cp = CheckPoint::new(block_id!(0, genesis_hash)); +// +// for height in 1..11 { +// let ((_, script_pubkey), _) = graph.index.reveal_next_spk(0).unwrap(); +// +// let tx = Transaction { +// output: vec![TxOut { +// value: Amount::from_btc(0.01).unwrap(), +// script_pubkey, +// }], +// ..new_tx(height) +// }; +// let txid = tx.compute_txid(); +// let _ = graph.insert_tx(tx); +// +// let block_id = block_id!(height, Hash::hash(height.to_be_bytes().as_slice())); +// let anchor = ConfirmationBlockTime { +// block_id, +// confirmation_time: height as u64, +// }; +// let _ = graph.insert_anchor(txid, anchor); +// +// cp = cp.insert(block_id); +// } +// +// let tip = block_id!(1000, Hash::hash(b"Z")); +// cp = cp.insert(tip); +// let chain = LocalChain::from_tip(cp).unwrap(); +// +// let assets = Assets::new().add(keys); +// +// TestProvider { +// assets, +// signer: Signer(keymap), +// secp: Secp256k1::new(), +// chain, +// graph, +// } +// } +// +// #[test] +// fn test_build_tx_finalize() { +// let mut graph = init_graph(&get_single_sig_tr_xprv()); +// assert_eq!(graph.balance().total().to_btc(), 0.1); +// +// let recip = ScriptBuf::from_hex(SPK).unwrap(); +// let mut builder = Builder::new(); +// builder.add_output(recip, Amount::from_sat(2_500_000)); +// +// let selection = graph.planned_utxos().into_iter().take(3); +// builder.add_inputs(selection); +// builder.add_change_output(graph.next_internal_spk(), Amount::from_sat(499_500)); +// +// let (mut psbt, finalizer) = builder.build_tx().unwrap(); +// assert_eq!(psbt.unsigned_tx.input.len(), 3); +// assert_eq!(psbt.unsigned_tx.output.len(), 2); +// +// graph.sign(&mut psbt); +// assert!(finalizer.finalize(&mut psbt).is_finalized()); +// } +// +// #[test] +// fn test_build_tx_insane_fee() { +// let mut graph = init_graph(&get_single_sig_tr_xprv()); +// +// let recip = ScriptBuf::from_hex(SPK).unwrap(); +// let mut builder = Builder::new(); +// builder.add_output(recip, Amount::from_btc(0.01).unwrap()); +// +// let selection = graph +// .planned_utxos() +// .into_iter() +// .take(3) +// .collect::>(); +// assert_eq!( +// selection +// .iter() +// .map(|p| p.txout.value) +// .sum::() +// .to_btc(), +// 0.03 +// ); +// builder.add_inputs(selection); +// +// let err = builder.build_tx().unwrap_err(); +// assert!(matches!(err, Error::InsaneFee(..))); +// } +// +// #[test] +// fn test_build_tx_negative_fee() { +// let mut graph = init_graph(&get_single_sig_tr_xprv()); +// +// let recip = ScriptBuf::from_hex(SPK).unwrap(); +// +// let mut builder = Builder::new(); +// builder.add_output(recip, Amount::from_btc(0.02).unwrap()); +// builder.add_inputs(graph.planned_utxos().into_iter().take(1)); +// +// let err = builder.build_tx().unwrap_err(); +// assert!(matches!(err, Error::NegativeFee(..))); +// } +// +// #[test] +// fn test_build_tx_add_data() { +// let mut graph = init_graph(&get_single_sig_tr_xprv()); +// +// let mut builder = Builder::new(); +// builder.add_inputs(graph.planned_utxos().into_iter().take(1)); +// builder.add_output(graph.next_internal_spk(), Amount::from_sat(999_000)); +// builder.add_data(b"satoshi nakamoto").unwrap(); +// +// let psbt = builder.build_tx().unwrap().0; +// assert!(psbt +// .unsigned_tx +// .output +// .iter() +// .any(|txo| txo.script_pubkey.is_op_return())); +// +// // try to add more than 80 bytes of data +// let data = [0x90; 81]; +// builder = Builder::new(); +// assert!(matches!( +// builder.add_data(data), +// Err(Error::MaxOpReturnRelay) +// )); +// +// // try to add more than 1 op return +// let data = [0x90; 80]; +// builder = Builder::new(); +// builder.add_data(data).unwrap(); +// assert!(matches!( +// builder.add_data(data), +// Err(Error::TooManyOpReturn) +// )); +// } +// +// #[test] +// fn test_build_tx_version() { +// use transaction::Version; +// let mut graph = init_graph(&get_single_sig_tr_xprv()); +// +// // test default tx version (2) +// let mut builder = Builder::new(); +// let recip = graph.spk_at_index(0).unwrap(); +// let utxo = graph.planned_utxos().first().unwrap().clone(); +// let amt = utxo.txout.value - Amount::from_sat(256); +// builder.add_input(utxo.clone()); +// builder.add_output(recip.clone(), amt); +// +// let psbt = builder.build_tx().unwrap().0; +// assert_eq!(psbt.unsigned_tx.version, Version::TWO); +// +// // allow any potentially non-standard version +// builder = Builder::new(); +// builder.version(Version(3)); +// builder.add_input(utxo); +// builder.add_output(recip, amt); +// +// let psbt = builder.build_tx().unwrap().0; +// assert_eq!(psbt.unsigned_tx.version, Version(3)); +// } +// +// #[test] +// fn test_timestamp_timelock() { +// #[derive(Clone)] +// struct InOut { +// input: PlanInput, +// output: (ScriptBuf, Amount), +// } +// fn check_locktime(graph: &mut TestProvider, in_out: InOut, lt: u32, exp_lt: Option) { +// let InOut { +// input, +// output: (recip, amount), +// } = in_out; +// +// let mut builder = Builder::new(); +// builder.add_output(recip, amount); +// builder.add_input(input); +// builder.locktime(absolute::LockTime::from_consensus(lt)); +// +// let res = builder.build_tx(); +// +// match res { +// Ok((mut psbt, finalizer)) => { +// assert_eq!( +// psbt.unsigned_tx.lock_time.to_consensus_u32(), +// exp_lt.unwrap() +// ); +// graph.sign(&mut psbt); +// assert!(finalizer.finalize(&mut psbt).is_finalized()); +// } +// Err(e) => { +// assert!(exp_lt.is_none()); +// if absolute::LockTime::from_consensus(lt).is_block_height() { +// assert!(matches!(e, Error::LockTypeMismatch)); +// } else if lt < 1735877503 { +// assert!(matches!(e, Error::LockTimeCltv { .. })); +// } +// } +// } +// } +// +// // initial state +// let mut graph = init_graph(&[get_single_sig_cltv_timestamp()]); +// let mut t = 1735877503; +// let locktime = absolute::LockTime::from_consensus(t); +// +// // supply the assets needed to create plans +// graph = graph.after(locktime); +// +// let in_out = InOut { +// input: graph.planned_utxos().first().unwrap().clone(), +// output: (ScriptBuf::from_hex(SPK).unwrap(), Amount::from_sat(999_000)), +// }; +// +// // Test: tx should use the planned locktime +// check_locktime(&mut graph, in_out.clone(), t, Some(t)); +// +// // Test: requesting a lower timelock should error +// check_locktime( +// &mut graph, +// in_out.clone(), +// absolute::LOCK_TIME_THRESHOLD, +// None, +// ); +// +// // Test: tx may use a custom locktime +// t += 1; +// check_locktime(&mut graph, in_out.clone(), t, Some(t)); +// +// // Test: error if lock type mismatch +// check_locktime(&mut graph, in_out, 100, None); +// } +// +// #[test] +// fn test_build_zero_fee_tx() { +// let mut graph = init_graph(&get_single_sig_tr_xprv()); +// +// let recip = ScriptBuf::from_hex(SPK).unwrap(); +// let utxos = graph.planned_utxos(); +// +// // case: 1-in/1-out +// let mut builder = Builder::new(); +// builder.add_inputs(utxos.iter().take(1).cloned()); +// builder.add_output(recip.clone(), Amount::from_sat(1_000_000)); +// let psbt = builder.build_tx().unwrap().0; +// assert_eq!(psbt.unsigned_tx.output.len(), 1); +// assert_eq!(psbt.unsigned_tx.output[0].value.to_btc(), 0.01); +// +// // case: 1-in/2-out +// let mut builder = Builder::new(); +// builder.add_inputs(utxos.iter().take(1).cloned()); +// builder.add_output(recip, Amount::from_sat(500_000)); +// builder.add_change_output(graph.next_internal_spk(), Amount::from_sat(500_000)); +// builder.check_fee(Some(Amount::ZERO), Some(FeeRate::from_sat_per_kwu(0))); +// +// let psbt = builder.build_tx().unwrap().0; +// assert_eq!(psbt.unsigned_tx.output.len(), 2); +// assert!(psbt +// .unsigned_tx +// .output +// .iter() +// .all(|txo| txo.value.to_sat() == 500_000)); +// } +// } From 3e1290bbdbf58ab6787bb6161f9c0880caf8ea1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Fri, 4 Apr 2025 20:47:49 +1100 Subject: [PATCH 2/6] WIP --- Cargo.toml | 4 +- src/create_psbt.rs | 187 ++++++++++++++++++++++++++++ src/create_selection.rs | 261 ++++++++++++++++++++++++++++++++++++++++ src/input.rs | 6 + src/lib.rs | 189 +---------------------------- tests/synopsis.rs | 150 +++++++++++++++++++++++ 6 files changed, 613 insertions(+), 184 deletions(-) create mode 100644 src/create_psbt.rs create mode 100644 src/create_selection.rs create mode 100644 tests/synopsis.rs diff --git a/Cargo.toml b/Cargo.toml index 1959ab6..123cde8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,12 +11,14 @@ readme = "README.md" [dependencies] miniscript = { version = "12", default-features = false } bdk_coin_select = "0.4.0" +bdk_chain = { version = "0.21" } [dev-dependencies] anyhow = "1" -bdk_chain = { version = "0.21" } bdk_tx = { path = "." } bitcoin = { version = "0.32", features = ["rand-std"] } +bdk_testenv = "0.11.1" +bdk_bitcoind_rpc = "0.18.0" [features] default = ["std"] diff --git a/src/create_psbt.rs b/src/create_psbt.rs new file mode 100644 index 0000000..f82c53b --- /dev/null +++ b/src/create_psbt.rs @@ -0,0 +1,187 @@ +use std::vec::Vec; + +use bitcoin::{absolute, transaction}; +use miniscript::{bitcoin, psbt::PsbtExt}; + +use crate::{Finalizer, Input, Output}; + +/// Parameters for creating a psbt. +#[derive(Debug, Clone)] +pub struct CreatePsbtParams { + /// Inputs to fund the tx. + /// + /// It is up to the caller to not duplicate inputs, spend from 2 conflicting txs, spend from + /// invalid inputs, etc. + pub inputs: Vec, + /// Outputs. + pub outputs: Vec, + + /// Use a specific [`transaction::Version`]. + pub version: transaction::Version, + + /// Fallback tx locktime. + /// + /// The locktime to use if no inputs specifies a required absolute locktime. + /// + /// It is best practive to set this to the latest block height to avoid fee sniping. + pub fallback_locktime: absolute::LockTime, + + /// Recommended. + pub mandate_full_tx_for_segwit_v0: bool, +} + +impl Default for CreatePsbtParams { + fn default() -> Self { + Self { + version: transaction::Version::TWO, + inputs: Default::default(), + outputs: Default::default(), + fallback_locktime: absolute::LockTime::ZERO, + mandate_full_tx_for_segwit_v0: true, + } + } +} + +/// Occurs when creating a psbt fails. +#[derive(Debug)] +pub enum CreatePsbtError { + /// Attempted to mix locktime types. + LockTypeMismatch, + /// Missing tx for legacy input. + MissingFullTxForLegacyInput(Input), + /// Missing tx for segwit v0 input. + MissingFullTxForSegwitV0Input(Input), + /// Psbt error. + Psbt(bitcoin::psbt::Error), + /// Update psbt output with descriptor error. + OutputUpdate(miniscript::psbt::OutputUpdateError), +} + +impl core::fmt::Display for CreatePsbtError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + CreatePsbtError::LockTypeMismatch => write!(f, "cannot mix locktime units"), + CreatePsbtError::MissingFullTxForLegacyInput(input) => write!( + f, + "legacy input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", + input.prev_outpoint() + ), + CreatePsbtError::MissingFullTxForSegwitV0Input(input) => write!( + f, + "segwit v0 input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", + input.prev_outpoint() + ), + CreatePsbtError::Psbt(error) => error.fmt(f), + CreatePsbtError::OutputUpdate(output_update_error) => output_update_error.fmt(f), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for CreatePsbtError {} + +const FALLBACK_SEQUENCE: bitcoin::Sequence = bitcoin::Sequence::MAX; + +/// Returns none if there is a mismatch of units in `locktimes`. +/// +/// As according to BIP-64... +pub fn accumulate_max_locktime( + locktimes: impl IntoIterator, + fallback: absolute::LockTime, +) -> Option { + let mut acc = Option::::None; + for locktime in locktimes { + match &mut acc { + Some(acc) => { + if !acc.is_same_unit(locktime) { + return None; + } + if acc.is_implied_by(locktime) { + *acc = locktime; + } + } + acc => *acc = Some(locktime), + }; + } + if acc.is_none() { + acc = Some(fallback); + } + acc +} + +/// Create psbt. +pub fn create_psbt( + params: CreatePsbtParams, +) -> Result<(bitcoin::Psbt, Finalizer), CreatePsbtError> { + let mut psbt = bitcoin::Psbt::from_unsigned_tx(bitcoin::Transaction { + version: params.version, + lock_time: accumulate_max_locktime( + params + .inputs + .iter() + .filter_map(|input| input.plan().absolute_timelock), + params.fallback_locktime, + ) + .ok_or(CreatePsbtError::LockTypeMismatch)?, + input: params + .inputs + .iter() + .map(|input| bitcoin::TxIn { + previous_output: input.prev_outpoint(), + sequence: input + .plan() + .relative_timelock + .map_or(FALLBACK_SEQUENCE, |locktime| locktime.to_sequence()), + ..Default::default() + }) + .collect(), + output: params.outputs.iter().map(|output| output.txout()).collect(), + }) + .map_err(CreatePsbtError::Psbt)?; + + for (plan_input, psbt_input) in params.inputs.iter().zip(psbt.inputs.iter_mut()) { + let txout = plan_input.prev_txout(); + + plan_input.plan().update_psbt_input(psbt_input); + + let witness_version = plan_input.plan().witness_version(); + if witness_version.is_some() { + psbt_input.witness_utxo = Some(txout.clone()); + } + + // We are allowed to have full tx for segwit inputs. Might as well include it. + // If the caller does not wish to include the full tx in Segwit V0 inputs, they should not + // include it in `crate::Input`. + psbt_input.non_witness_utxo = plan_input.prev_tx().cloned(); + if psbt_input.non_witness_utxo.is_none() { + if witness_version.is_none() { + return Err(CreatePsbtError::MissingFullTxForLegacyInput( + plan_input.clone(), + )); + } + if params.mandate_full_tx_for_segwit_v0 + && witness_version == Some(bitcoin::WitnessVersion::V0) + { + return Err(CreatePsbtError::MissingFullTxForSegwitV0Input( + plan_input.clone(), + )); + } + } + } + for (output_index, output) in params.outputs.iter().enumerate() { + if let Some(desc) = output.descriptor() { + psbt.update_output_with_descriptor(output_index, desc) + .map_err(CreatePsbtError::OutputUpdate)?; + } + } + + let finalizer = Finalizer { + plans: params + .inputs + .into_iter() + .map(|input| (input.prev_outpoint(), input.plan().clone())) + .collect(), + }; + + Ok((psbt, finalizer)) +} diff --git a/src/create_selection.rs b/src/create_selection.rs new file mode 100644 index 0000000..fbdbe89 --- /dev/null +++ b/src/create_selection.rs @@ -0,0 +1,261 @@ +use core::fmt::{Debug, Display}; +use std::collections::{BTreeMap, HashSet}; +use std::vec::Vec; + +use bdk_chain::KeychainIndexed; +use bdk_chain::{local_chain::LocalChain, Anchor, TxGraph}; +use bdk_coin_select::float::Ordf32; +use bdk_coin_select::metrics::LowestFee; +use bdk_coin_select::{ + Candidate, ChangePolicy, CoinSelector, DrainWeights, FeeRate, NoBnbSolution, Target, TargetFee, + TargetOutputs, +}; +use bitcoin::{absolute, Amount, OutPoint, TxOut}; +use miniscript::bitcoin; +use miniscript::{plan::Assets, Descriptor, DescriptorPublicKey, ForEachKey}; + +use crate::{DefiniteDescriptor, Input, InputGroup, Output}; + +/// Error +#[derive(Debug)] +pub enum GetCandidateInputsError { + /// Descriptor is missing for keychain K. + MissingDescriptor(K), + /// Cannot plan descriptor. Missing assets? + CannotPlan(DefiniteDescriptor), +} + +impl Display for GetCandidateInputsError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + GetCandidateInputsError::MissingDescriptor(k) => { + write!(f, "missing descriptor for keychain {:?}", k) + } + GetCandidateInputsError::CannotPlan(descriptor) => { + write!(f, "cannot plan input with descriptor {}", descriptor) + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for GetCandidateInputsError {} + +/// Get candidate inputs. +/// +/// This does not do any UTXO filtering or grouping. +pub fn get_candidate_inputs( + tx_graph: &TxGraph, + chain: &LocalChain, + outpoints: impl IntoIterator>, + owned_descriptors: BTreeMap>, + additional_assets: Assets, +) -> Result, GetCandidateInputsError> { + let tip = chain.tip().block_id(); + + let mut pks = vec![]; + for desc in owned_descriptors.values() { + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + } + + let assets = Assets::new() + .after(absolute::LockTime::from_height(tip.height).expect("must be valid height")) + .add(pks) + .add(additional_assets); + + tx_graph + .filter_chain_unspents(chain, tip, outpoints) + .map( + move |((k, i), txo)| -> Result<_, GetCandidateInputsError> { + let descriptor = owned_descriptors + .get(&k) + .ok_or(GetCandidateInputsError::MissingDescriptor(k))? + .at_derivation_index(i) + // TODO: Is this safe? + .expect("derivation index must not overflow"); + + let plan = match descriptor.desc_type().segwit_version() { + Some(_) => descriptor.plan(&assets), + None => descriptor.plan_mall(&assets), + } + .map_err(GetCandidateInputsError::CannotPlan)?; + + // BDK cannot spend from floating txouts so we will always have the full tx. + let tx = tx_graph + .get_tx(txo.outpoint.txid) + .expect("must have full tx"); + + let input = Input::from_prev_tx(plan, tx, txo.outpoint.vout as _) + .expect("tx must have output"); + Ok(input) + }, + ) + .collect() +} + +/// Parameters for creating tx. +#[derive(Debug, Clone)] +pub struct CreateSelectionParams { + /// All candidate inputs. + pub input_candidates: Vec, + + /// Inputs that must be included in the final tx, given that they exist in `input_candidates`. + pub must_spend: HashSet, + + /// To derive change output. + /// + /// Will error if this is unsatisfiable descriptor. + /// + pub change_descriptor: DefiniteDescriptor, + + /// Feerate target! + pub target_feerate: bitcoin::FeeRate, + + /// Uses `target_feerate` as a fallback. + pub long_term_feerate: Option, + + /// Outputs that must be included. + pub target_outputs: Vec, + + /// Max rounds of branch-and-bound. + pub max_rounds: usize, +} + +/// Final selection of inputs and outputs. +#[derive(Debug, Clone)] +pub struct Selection { + /// Inputs in this selection. + pub inputs: Vec, + /// Outputs in this selection. + pub outputs: Vec, + /// Selection score. + pub score: Ordf32, + /// Whether there is a change output in this selection. + pub has_change: bool, +} + +/// When create_tx fails. +#[derive(Debug)] +pub enum CreateSelectionError { + /// No solution. + NoSolution(NoBnbSolution), + /// Cannot satisfy change descriptor. + CannotSatisfyChangeDescriptor(miniscript::Error), +} + +impl Display for CreateSelectionError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + CreateSelectionError::NoSolution(no_bnb_solution) => Display::fmt(&no_bnb_solution, f), + CreateSelectionError::CannotSatisfyChangeDescriptor(error) => Display::fmt(&error, f), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for CreateSelectionError {} + +/// TODO +pub fn create_selection(params: CreateSelectionParams) -> Result { + fn convert_feerate(feerate: bitcoin::FeeRate) -> bdk_coin_select::FeeRate { + FeeRate::from_sat_per_wu(feerate.to_sat_per_kwu() as f32 / 1000.0) + } + + let (must_spend, may_spend) = + params + .input_candidates + .into_iter() + .partition::, _>(|group: &InputGroup| { + group + .inputs() + .iter() + .any(|input| params.must_spend.contains(&input.prev_outpoint())) + }); + + let candidates = must_spend + .iter() + .chain(&may_spend) + .map(|group| group.to_candidate()) + .collect::>(); + + let target_feerate = convert_feerate(params.target_feerate); + let long_term_feerate = + convert_feerate(params.long_term_feerate.unwrap_or(params.target_feerate)); + println!("target_feerate: {} sats/vb", target_feerate.as_sat_vb()); + + let target = Target { + fee: TargetFee::from_feerate(target_feerate), + outputs: TargetOutputs::fund_outputs( + params + .target_outputs + .iter() + .map(|output| (output.txout().weight().to_wu(), output.value.to_sat())), + ), + }; + + let change_policy = ChangePolicy::min_value_and_waste( + DrainWeights { + output_weight: (TxOut { + script_pubkey: params.change_descriptor.script_pubkey(), + value: Amount::ZERO, + }) + .weight() + .to_wu(), + spend_weight: params + .change_descriptor + .max_weight_to_satisfy() + .map_err(CreateSelectionError::CannotSatisfyChangeDescriptor)? + .to_wu(), + n_outputs: 1, + }, + params + .change_descriptor + .script_pubkey() + .minimal_non_dust() + .to_sat(), + target_feerate, + long_term_feerate, + ); + + let bnb_metric = LowestFee { + target, + long_term_feerate, + change_policy, + }; + + let mut selector = CoinSelector::new(&candidates); + + // Select input candidates that must be spent. + for index in 0..must_spend.len() { + selector.select(index); + } + + // We assume that this still works if the current selection is already a solution. + let score = selector + .run_bnb(bnb_metric, params.max_rounds) + .map_err(CreateSelectionError::NoSolution)?; + + let maybe_drain = selector.drain(target, change_policy); + Ok(Selection { + inputs: selector + .apply_selection(&must_spend.into_iter().chain(may_spend).collect::>()) + .flat_map(|group| group.inputs()) + .cloned() + .collect::>(), + outputs: { + let mut outputs = params.target_outputs; + if maybe_drain.is_some() { + outputs.push(Output::with_descriptor( + params.change_descriptor, + Amount::from_sat(maybe_drain.value), + )); + } + outputs + }, + score, + has_change: maybe_drain.is_some(), + }) +} diff --git a/src/input.rs b/src/input.rs index 1c15645..55e4be2 100644 --- a/src/input.rs +++ b/src/input.rs @@ -97,6 +97,12 @@ impl Input { #[derive(Debug, Clone)] pub struct InputGroup(Vec); +impl From for InputGroup { + fn from(input: Input) -> Self { + Self::from_input(input) + } +} + impl InputGroup { /// From a single input. pub fn from_input(input: impl Into) -> Self { diff --git a/src/lib.rs b/src/lib.rs index 6a95687..c267142 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,22 +9,20 @@ extern crate alloc; #[cfg(feature = "std")] extern crate std; +mod create_psbt; +mod create_selection; mod finalizer; mod input; mod output; mod signer; -use alloc::vec::Vec; - -use bitcoin::{ - absolute, - transaction::{self, Version}, - Psbt, -}; +pub use create_psbt::*; +pub use create_selection::*; pub use finalizer::*; pub use input::*; +pub use miniscript; pub use miniscript::bitcoin; -use miniscript::{psbt::PsbtExt, DefiniteDescriptorKey, Descriptor}; +use miniscript::{DefiniteDescriptorKey, Descriptor}; pub use output::*; pub use signer::*; @@ -41,178 +39,3 @@ pub(crate) mod collections { /// Definite descriptor. pub type DefiniteDescriptor = Descriptor; - -/// Parameters for creating a psbt. -#[derive(Debug, Clone)] -pub struct PsbtParams { - /// Inputs to fund the tx. - /// - /// It is up to the caller to not duplicate inputs, spend from 2 conflicting txs, spend from - /// invalid inputs, etc. - pub inputs: Vec, - /// Outputs. - pub outputs: Vec, - - /// Use a specific [`transaction::Version`]. - pub version: transaction::Version, - - /// Fallback tx locktime. - /// - /// The locktime to use if no inputs specifies a required absolute locktime. - /// - /// It is best practive to set this to the latest block height to avoid fee sniping. - pub fallback_locktime: absolute::LockTime, - - /// Recommended. - pub mandate_full_tx_for_segwit_v0: bool, -} - -impl Default for PsbtParams { - fn default() -> Self { - Self { - version: Version::TWO, - inputs: Default::default(), - outputs: Default::default(), - fallback_locktime: absolute::LockTime::ZERO, - mandate_full_tx_for_segwit_v0: true, - } - } -} - -/// Occurs when creating a psbt fails. -#[derive(Debug)] -pub enum PsbtError { - /// Attempted to mix locktime types. - LockTypeMismatch, - /// Missing tx for legacy input. - MissingFullTxForLegacyInput(Input), - /// Missing tx for segwit v0 input. - MissingFullTxForSegwitV0Input(Input), - /// Psbt error. - Psbt(bitcoin::psbt::Error), - /// Update psbt output with descriptor error. - OutputUpdate(miniscript::psbt::OutputUpdateError), -} - -impl core::fmt::Display for PsbtError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - PsbtError::LockTypeMismatch => write!(f, "cannot mix locktime units"), - PsbtError::MissingFullTxForLegacyInput(input) => write!( - f, - "legacy input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", - input.prev_outpoint() - ), - PsbtError::MissingFullTxForSegwitV0Input(input) => write!( - f, - "segwit v0 input that spends {} requires PSBT_IN_NON_WITNESS_UTXO", - input.prev_outpoint() - ), - PsbtError::Psbt(error) => error.fmt(f), - PsbtError::OutputUpdate(output_update_error) => output_update_error.fmt(f), - } - } -} - -#[cfg(feature = "std")] -impl std::error::Error for PsbtError {} - -const FALLBACK_SEQUENCE: bitcoin::Sequence = bitcoin::Sequence::MAX; - -/// Returns none if there is a mismatch of units in `locktimes`. -/// -/// As according to BIP-64... -pub fn accumulate_max_locktime( - locktimes: impl IntoIterator, - fallback: absolute::LockTime, -) -> Option { - let mut acc = Option::::None; - for locktime in locktimes { - match &mut acc { - Some(acc) => { - if !acc.is_same_unit(locktime) { - return None; - } - if acc.is_implied_by(locktime) { - *acc = locktime; - } - } - acc => *acc = Some(locktime), - }; - } - if acc.is_none() { - acc = Some(fallback); - } - acc -} - -/// Create psbt. -pub fn create_psbt(params: PsbtParams) -> Result<(bitcoin::Psbt, Finalizer), PsbtError> { - let mut psbt = Psbt::from_unsigned_tx(bitcoin::Transaction { - version: params.version, - lock_time: accumulate_max_locktime( - params - .inputs - .iter() - .filter_map(|input| input.plan().absolute_timelock), - params.fallback_locktime, - ) - .ok_or(PsbtError::LockTypeMismatch)?, - input: params - .inputs - .iter() - .map(|input| bitcoin::TxIn { - previous_output: input.prev_outpoint(), - sequence: input - .plan() - .relative_timelock - .map_or(FALLBACK_SEQUENCE, |locktime| locktime.to_sequence()), - ..Default::default() - }) - .collect(), - output: params.outputs.iter().map(|output| output.txout()).collect(), - }) - .map_err(PsbtError::Psbt)?; - - for (plan_input, psbt_input) in params.inputs.iter().zip(psbt.inputs.iter_mut()) { - let txout = plan_input.prev_txout(); - - plan_input.plan().update_psbt_input(psbt_input); - - let witness_version = plan_input.plan().witness_version(); - if witness_version.is_some() { - psbt_input.witness_utxo = Some(txout.clone()); - } - - // We are allowed to have full tx for segwit inputs. Might as well include it. - // If the caller does not wish to include the full tx in Segwit V0 inputs, they should not - // include it in `crate::Input`. - psbt_input.non_witness_utxo = plan_input.prev_tx().cloned(); - if psbt_input.non_witness_utxo.is_none() { - if witness_version.is_none() { - return Err(PsbtError::MissingFullTxForLegacyInput(plan_input.clone())); - } - if params.mandate_full_tx_for_segwit_v0 - && witness_version == Some(bitcoin::WitnessVersion::V0) - { - return Err(PsbtError::MissingFullTxForSegwitV0Input(plan_input.clone())); - } - } - } - for (output_index, output) in params.outputs.iter().enumerate() { - if let Some(desc) = output.descriptor() { - psbt.update_output_with_descriptor(output_index, desc) - .map_err(PsbtError::OutputUpdate)?; - } - } - - let finalizer = Finalizer { - plans: params - .inputs - .into_iter() - .map(|input| (input.prev_outpoint(), input.plan().clone())) - .collect(), - }; - - Ok((psbt, finalizer)) -} diff --git a/tests/synopsis.rs b/tests/synopsis.rs new file mode 100644 index 0000000..b72f424 --- /dev/null +++ b/tests/synopsis.rs @@ -0,0 +1,150 @@ +use bdk_bitcoind_rpc::Emitter; +use bdk_chain::{bdk_core, Balance}; +use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; +use bdk_tx::{ + create_psbt, create_selection, get_candidate_inputs, CreatePsbtParams, CreateSelectionParams, + InputGroup, Output, Signer, +}; +use bitcoin::{key::Secp256k1, Address, Amount, BlockHash, FeeRate}; +use miniscript::{plan::Assets, Descriptor, DescriptorPublicKey}; + +const EXTERNAL: &str = "external"; +const INTERNAL: &str = "internal"; + +struct Wallet { + chain: bdk_chain::local_chain::LocalChain, + graph: bdk_chain::IndexedTxGraph< + bdk_core::ConfirmationBlockTime, + bdk_chain::keychain_txout::KeychainTxOutIndex<&'static str>, + >, +} + +impl Wallet { + pub fn new( + genesis_hash: BlockHash, + external: Descriptor, + internal: Descriptor, + ) -> anyhow::Result { + let mut indexer = bdk_chain::keychain_txout::KeychainTxOutIndex::default(); + indexer.insert_descriptor(EXTERNAL, external)?; + indexer.insert_descriptor(INTERNAL, internal)?; + let graph = bdk_chain::IndexedTxGraph::new(indexer); + let (chain, _) = bdk_chain::local_chain::LocalChain::from_genesis_hash(genesis_hash); + Ok(Self { chain, graph }) + } + + pub fn sync(&mut self, env: &TestEnv) -> anyhow::Result<()> { + let client = env.rpc_client(); + let last_cp = self.chain.tip(); + let mut emitter = Emitter::new(client, last_cp, 0); + while let Some(event) = emitter.next_block()? { + let _ = self + .graph + .apply_block_relevant(&event.block, event.block_height()); + let _ = self.chain.apply_update(event.checkpoint); + } + let mempool = emitter.mempool()?; + let _ = self.graph.batch_insert_relevant_unconfirmed(mempool); + Ok(()) + } + + pub fn next_address(&mut self) -> Option
{ + let ((_, spk), _) = self.graph.index.next_unused_spk(EXTERNAL)?; + Address::from_script(&spk, bitcoin::consensus::Params::REGTEST).ok() + } + + pub fn balance(&self) -> Balance { + let outpoints = self.graph.index.outpoints().clone(); + self.graph.graph().balance( + &self.chain, + self.chain.tip().block_id(), + outpoints, + |_, _| true, + ) + } + + pub fn candidates(&self) -> anyhow::Result> { + let outpoints = self.graph.index.outpoints().clone(); + let internal = self.graph.index.get_descriptor(INTERNAL).unwrap().clone(); + let external = self.graph.index.get_descriptor(EXTERNAL).unwrap().clone(); + let inputs = get_candidate_inputs( + self.graph.graph(), + &self.chain, + outpoints, + [(INTERNAL, internal), (EXTERNAL, external)].into(), + Assets::new(), + )?; + Ok(inputs.into_iter().map(Into::into).collect()) + } +} + +#[test] +fn synopsis() -> anyhow::Result<()> { + let secp = Secp256k1::new(); + let (external, external_keymap) = + Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[3])?; + let (internal, internal_keymap) = + Descriptor::parse_descriptor(&secp, bdk_testenv::utils::DESCRIPTORS[4])?; + + let external_signer = Signer(external_keymap); + let internal_signer = Signer(internal_keymap); + + let env = TestEnv::new()?; + let genesis_hash = env.genesis_hash()?; + env.mine_blocks(101, None)?; + + let mut wallet = Wallet::new(genesis_hash, external, internal.clone())?; + wallet.sync(&env)?; + + let addr = wallet.next_address().expect("must derive address"); + + env.send(&addr, Amount::ONE_BTC)?; + env.mine_blocks(1, None)?; + wallet.sync(&env)?; + println!("balance: {}", wallet.balance()); + + env.send(&addr, Amount::ONE_BTC)?; + wallet.sync(&env)?; + println!("balance: {}", wallet.balance()); + + let recipient_addr = env + .rpc_client() + .get_new_address(None, None)? + .assume_checked(); + + // okay now create tx. + let input_candidates = wallet.candidates()?; + println!("input candidates: {}", input_candidates.len()); + + let selection = create_selection(CreateSelectionParams { + input_candidates, + must_spend: Default::default(), + // TODO: get this from the wallet. + change_descriptor: internal.at_derivation_index(0)?, + target_feerate: FeeRate::from_sat_per_vb(5).unwrap(), + long_term_feerate: Some(FeeRate::from_sat_per_vb(1).unwrap()), + target_outputs: vec![Output::with_script( + recipient_addr.script_pubkey(), + Amount::from_sat(100_000), + )], + max_rounds: 100_000, + })?; + + let (mut psbt, finalizer) = create_psbt(CreatePsbtParams { + inputs: selection.inputs, + outputs: selection.outputs, + ..Default::default() + })?; + let _ = psbt.sign(&external_signer, &secp); + let _ = psbt.sign(&internal_signer, &secp); + let res = finalizer.finalize(&mut psbt); + assert!(res.is_finalized()); + let tx = psbt.extract_tx()?; + let txid = env.rpc_client().send_raw_transaction(&tx)?; + println!("tx broadcasted: {}", txid); + + wallet.sync(&env)?; + println!("balance: {}", wallet.balance()); + + Ok(()) +} From 59fdc6b80e8d58d4ccfa80a95c863b40b5cf558c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sun, 6 Apr 2025 06:55:47 +1000 Subject: [PATCH 3/6] WIP: clean up "coin control" logic and add grouping --- src/create_psbt.rs | 35 ++++---- src/create_selection.rs | 156 ++++++++++++---------------------- src/input.rs | 155 ++++++++++++++++++++++++++++++---- src/input_candidates.rs | 180 ++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + tests/synopsis.rs | 52 ++++++------ 6 files changed, 415 insertions(+), 165 deletions(-) create mode 100644 src/input_candidates.rs diff --git a/src/create_psbt.rs b/src/create_psbt.rs index f82c53b..4ed5921 100644 --- a/src/create_psbt.rs +++ b/src/create_psbt.rs @@ -1,20 +1,13 @@ -use std::vec::Vec; - use bitcoin::{absolute, transaction}; use miniscript::{bitcoin, psbt::PsbtExt}; -use crate::{Finalizer, Input, Output}; +use crate::{Finalizer, Input, Selection}; /// Parameters for creating a psbt. #[derive(Debug, Clone)] pub struct CreatePsbtParams { - /// Inputs to fund the tx. - /// - /// It is up to the caller to not duplicate inputs, spend from 2 conflicting txs, spend from - /// invalid inputs, etc. - pub inputs: Vec, - /// Outputs. - pub outputs: Vec, + /// Inputs and outputs to fund the tx. + pub selection: Selection, /// Use a specific [`transaction::Version`]. pub version: transaction::Version, @@ -30,12 +23,12 @@ pub struct CreatePsbtParams { pub mandate_full_tx_for_segwit_v0: bool, } -impl Default for CreatePsbtParams { - fn default() -> Self { +impl CreatePsbtParams { + /// With default values. + pub fn new(selection: Selection) -> Self { Self { + selection, version: transaction::Version::TWO, - inputs: Default::default(), - outputs: Default::default(), fallback_locktime: absolute::LockTime::ZERO, mandate_full_tx_for_segwit_v0: true, } @@ -117,6 +110,7 @@ pub fn create_psbt( version: params.version, lock_time: accumulate_max_locktime( params + .selection .inputs .iter() .filter_map(|input| input.plan().absolute_timelock), @@ -124,6 +118,7 @@ pub fn create_psbt( ) .ok_or(CreatePsbtError::LockTypeMismatch)?, input: params + .selection .inputs .iter() .map(|input| bitcoin::TxIn { @@ -135,11 +130,16 @@ pub fn create_psbt( ..Default::default() }) .collect(), - output: params.outputs.iter().map(|output| output.txout()).collect(), + output: params + .selection + .outputs + .iter() + .map(|output| output.txout()) + .collect(), }) .map_err(CreatePsbtError::Psbt)?; - for (plan_input, psbt_input) in params.inputs.iter().zip(psbt.inputs.iter_mut()) { + for (plan_input, psbt_input) in params.selection.inputs.iter().zip(psbt.inputs.iter_mut()) { let txout = plan_input.prev_txout(); plan_input.plan().update_psbt_input(psbt_input); @@ -168,7 +168,7 @@ pub fn create_psbt( } } } - for (output_index, output) in params.outputs.iter().enumerate() { + for (output_index, output) in params.selection.outputs.iter().enumerate() { if let Some(desc) = output.descriptor() { psbt.update_output_with_descriptor(output_index, desc) .map_err(CreatePsbtError::OutputUpdate)?; @@ -177,6 +177,7 @@ pub fn create_psbt( let finalizer = Finalizer { plans: params + .selection .inputs .into_iter() .map(|input| (input.prev_outpoint(), input.plan().clone())) diff --git a/src/create_selection.rs b/src/create_selection.rs index fbdbe89..b8440c7 100644 --- a/src/create_selection.rs +++ b/src/create_selection.rs @@ -1,101 +1,18 @@ use core::fmt::{Debug, Display}; -use std::collections::{BTreeMap, HashSet}; +use std::collections::HashSet; use std::vec::Vec; -use bdk_chain::KeychainIndexed; -use bdk_chain::{local_chain::LocalChain, Anchor, TxGraph}; use bdk_coin_select::float::Ordf32; use bdk_coin_select::metrics::LowestFee; use bdk_coin_select::{ Candidate, ChangePolicy, CoinSelector, DrainWeights, FeeRate, NoBnbSolution, Target, TargetFee, TargetOutputs, }; -use bitcoin::{absolute, Amount, OutPoint, TxOut}; +use bitcoin::{Amount, OutPoint, TxOut}; use miniscript::bitcoin; -use miniscript::{plan::Assets, Descriptor, DescriptorPublicKey, ForEachKey}; use crate::{DefiniteDescriptor, Input, InputGroup, Output}; -/// Error -#[derive(Debug)] -pub enum GetCandidateInputsError { - /// Descriptor is missing for keychain K. - MissingDescriptor(K), - /// Cannot plan descriptor. Missing assets? - CannotPlan(DefiniteDescriptor), -} - -impl Display for GetCandidateInputsError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - GetCandidateInputsError::MissingDescriptor(k) => { - write!(f, "missing descriptor for keychain {:?}", k) - } - GetCandidateInputsError::CannotPlan(descriptor) => { - write!(f, "cannot plan input with descriptor {}", descriptor) - } - } - } -} - -#[cfg(feature = "std")] -impl std::error::Error for GetCandidateInputsError {} - -/// Get candidate inputs. -/// -/// This does not do any UTXO filtering or grouping. -pub fn get_candidate_inputs( - tx_graph: &TxGraph, - chain: &LocalChain, - outpoints: impl IntoIterator>, - owned_descriptors: BTreeMap>, - additional_assets: Assets, -) -> Result, GetCandidateInputsError> { - let tip = chain.tip().block_id(); - - let mut pks = vec![]; - for desc in owned_descriptors.values() { - desc.for_each_key(|k| { - pks.extend(k.clone().into_single_keys()); - true - }); - } - - let assets = Assets::new() - .after(absolute::LockTime::from_height(tip.height).expect("must be valid height")) - .add(pks) - .add(additional_assets); - - tx_graph - .filter_chain_unspents(chain, tip, outpoints) - .map( - move |((k, i), txo)| -> Result<_, GetCandidateInputsError> { - let descriptor = owned_descriptors - .get(&k) - .ok_or(GetCandidateInputsError::MissingDescriptor(k))? - .at_derivation_index(i) - // TODO: Is this safe? - .expect("derivation index must not overflow"); - - let plan = match descriptor.desc_type().segwit_version() { - Some(_) => descriptor.plan(&assets), - None => descriptor.plan_mall(&assets), - } - .map_err(GetCandidateInputsError::CannotPlan)?; - - // BDK cannot spend from floating txouts so we will always have the full tx. - let tx = tx_graph - .get_tx(txo.outpoint.txid) - .expect("must have full tx"); - - let input = Input::from_prev_tx(plan, tx, txo.outpoint.vout as _) - .expect("tx must have output"); - Ok(input) - }, - ) - .collect() -} - /// Parameters for creating tx. #[derive(Debug, Clone)] pub struct CreateSelectionParams { @@ -108,7 +25,6 @@ pub struct CreateSelectionParams { /// To derive change output. /// /// Will error if this is unsatisfiable descriptor. - /// pub change_descriptor: DefiniteDescriptor, /// Feerate target! @@ -124,6 +40,26 @@ pub struct CreateSelectionParams { pub max_rounds: usize, } +impl CreateSelectionParams { + /// With default params. + pub fn new( + input_candidates: Vec, + change_descriptor: DefiniteDescriptor, + target_outputs: Vec, + target_feerate: bitcoin::FeeRate, + ) -> Self { + Self { + input_candidates, + must_spend: HashSet::new(), + change_descriptor, + target_feerate, + long_term_feerate: None, + target_outputs, + max_rounds: 100_000, + } + } +} + /// Final selection of inputs and outputs. #[derive(Debug, Clone)] pub struct Selection { @@ -131,6 +67,10 @@ pub struct Selection { pub inputs: Vec, /// Outputs in this selection. pub outputs: Vec, +} + +/// Selection Metrics. +pub struct SelectionMetrics { /// Selection score. pub score: Ordf32, /// Whether there is a change output in this selection. @@ -159,7 +99,9 @@ impl Display for CreateSelectionError { impl std::error::Error for CreateSelectionError {} /// TODO -pub fn create_selection(params: CreateSelectionParams) -> Result { +pub fn create_selection( + params: CreateSelectionParams, +) -> Result<(Selection, SelectionMetrics), CreateSelectionError> { fn convert_feerate(feerate: bitcoin::FeeRate) -> bdk_coin_select::FeeRate { FeeRate::from_sat_per_wu(feerate.to_sat_per_kwu() as f32 / 1000.0) } @@ -239,23 +181,27 @@ pub fn create_selection(params: CreateSelectionParams) -> Result>()) - .flat_map(|group| group.inputs()) - .cloned() - .collect::>(), - outputs: { - let mut outputs = params.target_outputs; - if maybe_drain.is_some() { - outputs.push(Output::with_descriptor( - params.change_descriptor, - Amount::from_sat(maybe_drain.value), - )); - } - outputs + Ok(( + Selection { + inputs: selector + .apply_selection(&must_spend.into_iter().chain(may_spend).collect::>()) + .flat_map(|group| group.inputs()) + .cloned() + .collect::>(), + outputs: { + let mut outputs = params.target_outputs; + if maybe_drain.is_some() { + outputs.push(Output::with_descriptor( + params.change_descriptor, + Amount::from_sat(maybe_drain.value), + )); + } + outputs + }, + }, + SelectionMetrics { + score, + has_change: maybe_drain.is_some(), }, - score, - has_change: maybe_drain.is_some(), - }) + )) } diff --git a/src/input.rs b/src/input.rs index 55e4be2..731ffb1 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,11 +1,35 @@ use std::{sync::Arc, vec::Vec}; use bdk_coin_select::TXIN_BASE_WEIGHT; +use bitcoin::constants::COINBASE_MATURITY; use bitcoin::transaction::OutputsIndexError; +use bitcoin::{absolute, relative}; use miniscript::bitcoin; use miniscript::bitcoin::{OutPoint, Transaction, TxOut}; use miniscript::plan::Plan; +/// Confirmation status of a tx. +#[derive(Debug, Clone, Copy)] +pub struct InputStatus { + /// Confirmation block height. + pub height: absolute::Height, + /// Confirmation block median time past. + /// + /// TODO: Currently BDK cannot fetch MTP time. We can pretend that the latest block time is the + /// MTP time for now. + pub time: absolute::Time, +} + +impl InputStatus { + /// New + pub fn new(height: u32, time: u64) -> Result { + Ok(Self { + height: absolute::Height::from_consensus(height)?, + time: absolute::Time::from_consensus(time as _)?, + }) + } +} + /// Single-input plan. #[derive(Debug, Clone)] pub struct Input { @@ -13,23 +37,8 @@ pub struct Input { txout: TxOut, tx: Option>, plan: Plan, -} - -impl From<(Plan, OutPoint, TxOut)> for Input { - fn from((plan, prev_outpoint, prev_txout): (Plan, OutPoint, TxOut)) -> Self { - Self::from_prev_txout(plan, prev_outpoint, prev_txout) - } -} - -impl TryFrom<(Plan, T, usize)> for Input -where - T: Into>, -{ - type Error = OutputsIndexError; - - fn try_from((plan, prev_tx, output_index): (Plan, T, usize)) -> Result { - Self::from_prev_tx(plan, prev_tx, output_index) - } + status: Option, + is_coinbase: bool, } impl Input { @@ -40,26 +49,38 @@ impl Input { plan: Plan, prev_tx: T, output_index: usize, + status: Option, ) -> Result where T: Into>, { let tx: Arc = prev_tx.into(); + let is_coinbase = tx.is_coinbase(); Ok(Self { outpoint: OutPoint::new(tx.compute_txid(), output_index as _), txout: tx.tx_out(output_index).cloned()?, tx: Some(tx), plan, + status, + is_coinbase, }) } /// Create - pub fn from_prev_txout(plan: Plan, prev_outpoint: OutPoint, prev_txout: TxOut) -> Self { + pub fn from_prev_txout( + plan: Plan, + prev_outpoint: OutPoint, + prev_txout: TxOut, + status: Option, + is_coinbase: bool, + ) -> Self { Self { outpoint: prev_outpoint, txout: prev_txout, tx: None, plan, + status, + is_coinbase, } } @@ -83,6 +104,75 @@ impl Input { self.tx.as_ref().map(|tx| tx.as_ref()) } + /// Confirmation status. + pub fn status(&self) -> Option { + self.status + } + + /// Whether prev output resides in coinbase. + pub fn is_coinbase(&self) -> bool { + self.is_coinbase + } + + /// Whether prev output is an immature coinbase output and cannot be spent in the next block. + pub fn is_immature(&self, tip_height: absolute::Height) -> bool { + if !self.is_coinbase { + return false; + } + match self.status { + Some(status) => { + let age = tip_height + .to_consensus_u32() + .saturating_sub(status.height.to_consensus_u32()); + age + 1 < COINBASE_MATURITY + } + None => { + debug_assert!(false, "coinbase should never be unconfirmed"); + true + } + } + } + + /// Whether the output is still locked by timelock constraints and cannot be spent in the + /// next block. + pub fn is_timelocked(&self, tip_height: absolute::Height, tip_time: absolute::Time) -> bool { + if let Some(locktime) = self.plan.absolute_timelock { + if !locktime.is_satisfied_by(tip_height, tip_time) { + return true; + } + } + if let Some(locktime) = self.plan.relative_timelock { + // TODO: Make sure this logic is right. + let (relative_height, relative_time) = match self.status { + Some(status) => { + let relative_height = tip_height + .to_consensus_u32() + .saturating_sub(status.height.to_consensus_u32()); + let relative_time = tip_time + .to_consensus_u32() + .saturating_sub(status.time.to_consensus_u32()); + ( + relative::Height::from_height( + relative_height.try_into().unwrap_or(u16::MAX), + ), + relative::Time::from_seconds_floor(relative_time) + .unwrap_or(relative::Time::MAX), + ) + } + None => (relative::Height::ZERO, relative::Time::ZERO), + }; + if !locktime.is_satisfied_by(relative_height, relative_time) { + return true; + } + } + false + } + + /// Whether this output can be spent now. + pub fn is_spendable_now(&self, tip_height: absolute::Height, tip_time: absolute::Time) -> bool { + !self.is_immature(tip_height) && !self.is_timelocked(tip_height, tip_time) + } + /// To coin selection candidate. pub fn to_candidate(&self) -> bdk_coin_select::Candidate { bdk_coin_select::Candidate::new( @@ -124,6 +214,35 @@ impl InputGroup { &self.0 } + /// Consume the input group and return all inputs. + pub fn into_inputs(self) -> Vec { + self.0 + } + + /// Push input in group. + pub fn push(&mut self, input: Input) { + self.0.push(input); + } + + /// Whether any contained inputs are immature. + pub fn is_immature(&self, tip_height: absolute::Height) -> bool { + self.0.iter().any(|input| input.is_immature(tip_height)) + } + + /// Whether any contained inputs are time locked. + pub fn is_timelocked(&self, tip_height: absolute::Height, tip_time: absolute::Time) -> bool { + self.0 + .iter() + .any(|input| input.is_timelocked(tip_height, tip_time)) + } + + /// Whether all contained inputs are spendable now. + pub fn is_spendable_now(&self, tip_height: absolute::Height, tip_time: absolute::Time) -> bool { + self.0 + .iter() + .all(|input| input.is_spendable_now(tip_height, tip_time)) + } + /// To coin selection candidate. pub fn to_candidate(&self) -> bdk_coin_select::Candidate { bdk_coin_select::Candidate { diff --git a/src/input_candidates.rs b/src/input_candidates.rs new file mode 100644 index 0000000..107a52f --- /dev/null +++ b/src/input_candidates.rs @@ -0,0 +1,180 @@ +use std::vec::Vec; + +use crate::{collections::BTreeMap, DefiniteDescriptor, InputGroup, InputStatus}; +use bdk_chain::{ + local_chain::LocalChain, BlockId, ConfirmationBlockTime, KeychainIndexed, TxGraph, +}; +use bitcoin::{absolute, OutPoint, ScriptBuf}; +use core::fmt::{Debug, Display}; +use miniscript::{bitcoin, plan::Assets, Descriptor, DescriptorPublicKey, ForEachKey}; + +use crate::Input; + +/// Input candidates that are not processed (filtered and/or grouped). +/// +/// Some inputs may be unspendable now (due to unsatisfied time-locks for they are immature +/// coinbase spends). +#[derive(Debug, Clone)] +pub struct InputCandidates { + inputs: Vec, +} + +/// Error +#[derive(Debug)] +pub enum InputCandidatesError { + /// Descriptor is missing for keychain K. + MissingDescriptor(K), + /// Cannot plan descriptor. Missing assets? + CannotPlan(DefiniteDescriptor), +} + +impl Display for InputCandidatesError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + InputCandidatesError::MissingDescriptor(k) => { + write!(f, "missing descriptor for keychain {:?}", k) + } + InputCandidatesError::CannotPlan(descriptor) => { + write!(f, "cannot plan input with descriptor {}", descriptor) + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for InputCandidatesError {} + +/// Default group policy. +pub fn group_by_spk(input: &Input) -> ScriptBuf { + input.prev_txout().script_pubkey.clone() +} + +/// No grouping. +pub fn no_groups(input: &Input) -> OutPoint { + input.prev_outpoint() +} + +/// Filter out inputs that cannot be spent now. +pub fn filter_unspendable_now( + tip_height: absolute::Height, + tip_time: absolute::Time, +) -> impl Fn(&InputGroup) -> bool { + move |group| group.is_spendable_now(tip_height, tip_time) +} + +impl InputCandidates { + /// Construct + /// + /// # Error + /// + /// Requires a descriptor for each corresponding K value. + pub fn new( + tx_graph: &TxGraph, + chain: &LocalChain, + chain_tip: BlockId, + outpoints: impl IntoIterator>, + descriptors: BTreeMap>, + additional_assets: Assets, + ) -> Result> + where + K: Clone + Ord + Debug, + { + let mut pks = vec![]; + for desc in descriptors.values() { + desc.for_each_key(|k| { + pks.extend(k.clone().into_single_keys()); + true + }); + } + + let assets = Assets::new() + .after(absolute::LockTime::from_height(chain_tip.height).expect("must be valid height")) + .add(pks) + .add(additional_assets); + + let inputs = tx_graph + .filter_chain_unspents(chain, chain_tip, outpoints) + .map(move |((k, i), txo)| -> Result<_, InputCandidatesError> { + let descriptor = descriptors + .get(&k) + .ok_or(InputCandidatesError::MissingDescriptor(k))? + .at_derivation_index(i) + // TODO: Is this safe? + .expect("derivation index must not overflow"); + + let plan = match descriptor.desc_type().segwit_version() { + Some(_) => descriptor.plan(&assets), + None => descriptor.plan_mall(&assets), + } + .map_err(InputCandidatesError::CannotPlan)?; + + // TODO: BDK cannot spend from floating txouts so we will always have the full tx. + let tx = tx_graph + .get_tx(txo.outpoint.txid) + .expect("must have full tx"); + + let status = match txo.chain_position { + bdk_chain::ChainPosition::Confirmed { anchor, .. } => Some( + InputStatus::new(anchor.block_id.height, anchor.confirmation_time) + .expect("height and time must not overflow"), + ), + bdk_chain::ChainPosition::Unconfirmed { .. } => None, + }; + + let input = Input::from_prev_tx( + plan, + tx, + txo.outpoint + .vout + .try_into() + .expect("u32 must fit into usize"), + status, + ) + .expect("tx must have output"); + Ok(input) + }) + .collect::, _>>()?; + + Ok(Self { inputs }) + } + + /// The unprocessed inputs. + pub fn inputs(&self) -> &Vec { + &self.inputs + } + + /// Into groups of 1-input-per-group. + pub fn into_single_groups( + self, + filter_policy: impl Fn(&InputGroup) -> bool, + ) -> Vec { + self.inputs + .into_iter() + .map::(Into::into) + .filter(filter_policy) + .collect() + } + + /// Into groups. + pub fn into_groups( + self, + group_policy: impl Fn(&Input) -> G, + filter_policy: impl Fn(&InputGroup) -> bool, + ) -> Vec + where + G: Clone + Ord + Debug, + { + let mut groups = BTreeMap::::new(); + for input in self.inputs.into_iter() { + let group_key = group_policy(&input); + use std::collections::btree_map::Entry; + match groups.entry(group_key) { + Entry::Vacant(entry) => { + entry.insert(InputGroup::from_input(input)); + } + Entry::Occupied(mut entry) => entry.get_mut().push(input), + }; + } + groups.into_values().filter(filter_policy).collect() + } +} diff --git a/src/lib.rs b/src/lib.rs index c267142..fffc6c7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ mod finalizer; mod input; mod output; mod signer; +mod input_candidates; pub use create_psbt::*; pub use create_selection::*; @@ -25,6 +26,7 @@ pub use miniscript::bitcoin; use miniscript::{DefiniteDescriptorKey, Descriptor}; pub use output::*; pub use signer::*; +pub use input_candidates::*; pub(crate) mod collections { #![allow(unused)] diff --git a/tests/synopsis.rs b/tests/synopsis.rs index b72f424..13bfe2d 100644 --- a/tests/synopsis.rs +++ b/tests/synopsis.rs @@ -2,11 +2,11 @@ use bdk_bitcoind_rpc::Emitter; use bdk_chain::{bdk_core, Balance}; use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; use bdk_tx::{ - create_psbt, create_selection, get_candidate_inputs, CreatePsbtParams, CreateSelectionParams, - InputGroup, Output, Signer, + create_psbt, create_selection, filter_unspendable_now, group_by_spk, CreatePsbtParams, + CreateSelectionParams, InputCandidates, InputGroup, Output, Signer, }; -use bitcoin::{key::Secp256k1, Address, Amount, BlockHash, FeeRate}; -use miniscript::{plan::Assets, Descriptor, DescriptorPublicKey}; +use bitcoin::{absolute, key::Secp256k1, Address, Amount, BlockHash, FeeRate}; +use miniscript::{Descriptor, DescriptorPublicKey}; const EXTERNAL: &str = "external"; const INTERNAL: &str = "internal"; @@ -63,18 +63,27 @@ impl Wallet { ) } - pub fn candidates(&self) -> anyhow::Result> { + pub fn candidates(&self, client: &impl RpcApi) -> anyhow::Result> { let outpoints = self.graph.index.outpoints().clone(); let internal = self.graph.index.get_descriptor(INTERNAL).unwrap().clone(); let external = self.graph.index.get_descriptor(EXTERNAL).unwrap().clone(); - let inputs = get_candidate_inputs( + let tip = self.chain.tip().block_id(); + let tip_info = client.get_block_header_info(&tip.hash)?; + let tip_time = + absolute::Time::from_consensus(tip_info.median_time.unwrap_or(tip_info.time) as _)?; + let inputs = InputCandidates::new( self.graph.graph(), &self.chain, + tip, outpoints, [(INTERNAL, internal), (EXTERNAL, external)].into(), - Assets::new(), - )?; - Ok(inputs.into_iter().map(Into::into).collect()) + Default::default(), + )? + .into_groups( + group_by_spk, + filter_unspendable_now(absolute::Height::from_consensus(tip.height)?, tip_time), + ); + Ok(inputs) } } @@ -113,33 +122,26 @@ fn synopsis() -> anyhow::Result<()> { .assume_checked(); // okay now create tx. - let input_candidates = wallet.candidates()?; + let input_candidates = wallet.candidates(env.rpc_client())?; println!("input candidates: {}", input_candidates.len()); - let selection = create_selection(CreateSelectionParams { + let (selection, _metrics) = create_selection(CreateSelectionParams::new( input_candidates, - must_spend: Default::default(), - // TODO: get this from the wallet. - change_descriptor: internal.at_derivation_index(0)?, - target_feerate: FeeRate::from_sat_per_vb(5).unwrap(), - long_term_feerate: Some(FeeRate::from_sat_per_vb(1).unwrap()), - target_outputs: vec![Output::with_script( + internal.at_derivation_index(0)?, + vec![Output::with_script( recipient_addr.script_pubkey(), Amount::from_sat(100_000), )], - max_rounds: 100_000, - })?; - - let (mut psbt, finalizer) = create_psbt(CreatePsbtParams { - inputs: selection.inputs, - outputs: selection.outputs, - ..Default::default() - })?; + FeeRate::from_sat_per_vb(5).unwrap(), + ))?; + + let (mut psbt, finalizer) = create_psbt(CreatePsbtParams::new(selection))?; let _ = psbt.sign(&external_signer, &secp); let _ = psbt.sign(&internal_signer, &secp); let res = finalizer.finalize(&mut psbt); assert!(res.is_finalized()); let tx = psbt.extract_tx()?; + assert_eq!(tx.input.len(), 2); let txid = env.rpc_client().send_raw_transaction(&tx)?; println!("tx broadcasted: {}", txid); From f44ad7e557baf987d4acd55a3c5be738df68a2be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Sun, 6 Apr 2025 09:36:46 +1000 Subject: [PATCH 4/6] WIP: Caller needs to opt in to create malleable input --- src/input_candidates.rs | 12 +++++++----- src/lib.rs | 4 ++-- tests/synopsis.rs | 1 + 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/input_candidates.rs b/src/input_candidates.rs index 107a52f..245d5ed 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -1,4 +1,4 @@ -use std::vec::Vec; +use std::{collections::BTreeSet, vec::Vec}; use crate::{collections::BTreeMap, DefiniteDescriptor, InputGroup, InputStatus}; use bdk_chain::{ @@ -74,6 +74,7 @@ impl InputCandidates { chain_tip: BlockId, outpoints: impl IntoIterator>, descriptors: BTreeMap>, + allow_malleable: BTreeSet, additional_assets: Assets, ) -> Result> where @@ -95,6 +96,7 @@ impl InputCandidates { let inputs = tx_graph .filter_chain_unspents(chain, chain_tip, outpoints) .map(move |((k, i), txo)| -> Result<_, InputCandidatesError> { + let allow_malleable = allow_malleable.contains(&k); let descriptor = descriptors .get(&k) .ok_or(InputCandidatesError::MissingDescriptor(k))? @@ -102,11 +104,11 @@ impl InputCandidates { // TODO: Is this safe? .expect("derivation index must not overflow"); - let plan = match descriptor.desc_type().segwit_version() { - Some(_) => descriptor.plan(&assets), - None => descriptor.plan_mall(&assets), + let mut plan_res = descriptor.plan(&assets); + if allow_malleable { + plan_res = plan_res.or_else(|descriptor| descriptor.plan_mall(&assets)); } - .map_err(InputCandidatesError::CannotPlan)?; + let plan = plan_res.map_err(InputCandidatesError::CannotPlan)?; // TODO: BDK cannot spend from floating txouts so we will always have the full tx. let tx = tx_graph diff --git a/src/lib.rs b/src/lib.rs index fffc6c7..ac3c4ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,20 +13,20 @@ mod create_psbt; mod create_selection; mod finalizer; mod input; +mod input_candidates; mod output; mod signer; -mod input_candidates; pub use create_psbt::*; pub use create_selection::*; pub use finalizer::*; pub use input::*; +pub use input_candidates::*; pub use miniscript; pub use miniscript::bitcoin; use miniscript::{DefiniteDescriptorKey, Descriptor}; pub use output::*; pub use signer::*; -pub use input_candidates::*; pub(crate) mod collections { #![allow(unused)] diff --git a/tests/synopsis.rs b/tests/synopsis.rs index 13bfe2d..70c10b1 100644 --- a/tests/synopsis.rs +++ b/tests/synopsis.rs @@ -78,6 +78,7 @@ impl Wallet { outpoints, [(INTERNAL, internal), (EXTERNAL, external)].into(), Default::default(), + Default::default(), )? .into_groups( group_by_spk, From 6e2414ed7e701c04d0af46ff477c2dbf9a9f75b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BF=97=E5=AE=87?= Date: Mon, 7 Apr 2025 09:24:22 +1000 Subject: [PATCH 5/6] WIP: Can use finalized psbt input as candidate --- src/create_psbt.rs | 58 +++++----- src/create_selection.rs | 7 +- src/finalizer.rs | 13 ++- src/input.rs | 240 +++++++++++++++++++++++++++++++++------- src/input_candidates.rs | 5 +- src/lib.rs | 1 + 6 files changed, 256 insertions(+), 68 deletions(-) diff --git a/src/create_psbt.rs b/src/create_psbt.rs index 4ed5921..3b6fe1e 100644 --- a/src/create_psbt.rs +++ b/src/create_psbt.rs @@ -113,7 +113,7 @@ pub fn create_psbt( .selection .inputs .iter() - .filter_map(|input| input.plan().absolute_timelock), + .filter_map(|input| input.absolute_timelock()), params.fallback_locktime, ) .ok_or(CreatePsbtError::LockTypeMismatch)?, @@ -123,10 +123,7 @@ pub fn create_psbt( .iter() .map(|input| bitcoin::TxIn { previous_output: input.prev_outpoint(), - sequence: input - .plan() - .relative_timelock - .map_or(FALLBACK_SEQUENCE, |locktime| locktime.to_sequence()), + sequence: input.sequence().unwrap_or(FALLBACK_SEQUENCE), ..Default::default() }) .collect(), @@ -140,33 +137,38 @@ pub fn create_psbt( .map_err(CreatePsbtError::Psbt)?; for (plan_input, psbt_input) in params.selection.inputs.iter().zip(psbt.inputs.iter_mut()) { - let txout = plan_input.prev_txout(); - - plan_input.plan().update_psbt_input(psbt_input); - - let witness_version = plan_input.plan().witness_version(); - if witness_version.is_some() { - psbt_input.witness_utxo = Some(txout.clone()); + if let Some(finalized_psbt_input) = plan_input.psbt_input() { + *psbt_input = finalized_psbt_input.clone(); + continue; } + if let Some(plan) = plan_input.plan() { + plan.update_psbt_input(psbt_input); - // We are allowed to have full tx for segwit inputs. Might as well include it. - // If the caller does not wish to include the full tx in Segwit V0 inputs, they should not - // include it in `crate::Input`. - psbt_input.non_witness_utxo = plan_input.prev_tx().cloned(); - if psbt_input.non_witness_utxo.is_none() { - if witness_version.is_none() { - return Err(CreatePsbtError::MissingFullTxForLegacyInput( - plan_input.clone(), - )); + let witness_version = plan.witness_version(); + if witness_version.is_some() { + psbt_input.witness_utxo = Some(plan_input.prev_txout().clone()); } - if params.mandate_full_tx_for_segwit_v0 - && witness_version == Some(bitcoin::WitnessVersion::V0) - { - return Err(CreatePsbtError::MissingFullTxForSegwitV0Input( - plan_input.clone(), - )); + // We are allowed to have full tx for segwit inputs. Might as well include it. + // If the caller does not wish to include the full tx in Segwit V0 inputs, they should not + // include it in `crate::Input`. + psbt_input.non_witness_utxo = plan_input.prev_tx().cloned(); + if psbt_input.non_witness_utxo.is_none() { + if witness_version.is_none() { + return Err(CreatePsbtError::MissingFullTxForLegacyInput( + plan_input.clone(), + )); + } + if params.mandate_full_tx_for_segwit_v0 + && witness_version == Some(bitcoin::WitnessVersion::V0) + { + return Err(CreatePsbtError::MissingFullTxForSegwitV0Input( + plan_input.clone(), + )); + } } + continue; } + unreachable!("input candidate must either have finalized psbt input or plan"); } for (output_index, output) in params.selection.outputs.iter().enumerate() { if let Some(desc) = output.descriptor() { @@ -180,7 +182,7 @@ pub fn create_psbt( .selection .inputs .into_iter() - .map(|input| (input.prev_outpoint(), input.plan().clone())) + .filter_map(|input| Some((input.prev_outpoint(), input.plan()?.clone()))) .collect(), }; diff --git a/src/create_selection.rs b/src/create_selection.rs index b8440c7..bc5a72b 100644 --- a/src/create_selection.rs +++ b/src/create_selection.rs @@ -120,7 +120,12 @@ pub fn create_selection( let candidates = must_spend .iter() .chain(&may_spend) - .map(|group| group.to_candidate()) + .map(|group| bdk_coin_select::Candidate { + value: group.value().to_sat(), + weight: group.weight(), + input_count: group.input_count(), + is_segwit: group.is_segwit(), + }) .collect::>(); let target_feerate = convert_feerate(params.target_feerate); diff --git a/src/finalizer.rs b/src/finalizer.rs index 6c1ac3f..9b35e33 100644 --- a/src/finalizer.rs +++ b/src/finalizer.rs @@ -9,7 +9,8 @@ pub struct Finalizer { } impl Finalizer { - /// Finalize a PSBT input and return whether finalization was successful. + /// Finalize a PSBT input and return whether finalization was successful or input was already + /// finalized. /// /// # Errors /// @@ -24,6 +25,16 @@ impl Finalizer { psbt: &mut Psbt, input_index: usize, ) -> Result { + // return true if already finalized. + { + let psbt_input = &psbt.inputs[input_index]; + if psbt_input.final_script_witness.is_some() + || psbt_input.final_script_witness.is_some() + { + return Ok(true); + } + } + let mut finalized = false; let outpoint = psbt .unsigned_tx diff --git a/src/input.rs b/src/input.rs index 731ffb1..39e828b 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,9 +1,8 @@ use std::{sync::Arc, vec::Vec}; -use bdk_coin_select::TXIN_BASE_WEIGHT; use bitcoin::constants::COINBASE_MATURITY; use bitcoin::transaction::OutputsIndexError; -use bitcoin::{absolute, relative}; +use bitcoin::{absolute, relative, Amount}; use miniscript::bitcoin; use miniscript::bitcoin::{OutPoint, Transaction, TxOut}; use miniscript::plan::Plan; @@ -21,7 +20,7 @@ pub struct InputStatus { } impl InputStatus { - /// New + /// Helper method. pub fn new(height: u32, time: u64) -> Result { Ok(Self { height: absolute::Height::from_consensus(height)?, @@ -30,13 +29,124 @@ impl InputStatus { } } +#[derive(Debug, Clone)] +enum PlanOrPsbtInput { + Plan(Plan), + PsbtInput { + psbt_input: bitcoin::psbt::Input, + sequence: bitcoin::Sequence, + absolute_timelock: absolute::LockTime, + satisfaction_weight: usize, + }, +} + +impl PlanOrPsbtInput { + /// Returns `None` if input index does not exist or input is not finalized. + /// + /// TODO: Check whether satisfaction_weight calculations are correct. + /// TODO: Return an error type: out of bounds, not finalized, etc. + /// + /// # WHy do we only support finalized psbt inputs? + /// + /// There is no mulit-party tx building protocol that requires choosing from foreign, + /// non-finalized PSBT inputs. + fn from_finalized_psbt(psbt: &bitcoin::Psbt, input_index: usize) -> Option { + let psbt_input = psbt.inputs.get(input_index).cloned()?; + let input = psbt.unsigned_tx.input.get(input_index)?; + let absolute_timelock = psbt.unsigned_tx.lock_time; + + if psbt_input.final_script_witness.is_none() && psbt_input.final_script_sig.is_none() { + return None; + } + + let mut temp_txin = input.clone(); + if let Some(s) = &psbt_input.final_script_sig { + temp_txin.script_sig = s.clone(); + } + if let Some(w) = &psbt_input.final_script_witness { + temp_txin.witness = w.clone(); + } + let satisfaction_weight = temp_txin.segwit_weight().to_wu() as usize; + + Some(Self::PsbtInput { + psbt_input, + sequence: input.sequence, + absolute_timelock, + satisfaction_weight, + }) + } + + pub fn plan(&self) -> Option<&Plan> { + match self { + PlanOrPsbtInput::Plan(plan) => Some(plan), + _ => None, + } + } + + pub fn psbt_input(&self) -> Option<&bitcoin::psbt::Input> { + match self { + PlanOrPsbtInput::PsbtInput { psbt_input, .. } => Some(psbt_input), + _ => None, + } + } + + pub fn absolute_timelock(&self) -> Option { + match self { + PlanOrPsbtInput::Plan(plan) => plan.absolute_timelock, + PlanOrPsbtInput::PsbtInput { + absolute_timelock, .. + } => Some(*absolute_timelock), + } + } + + pub fn relative_timelock(&self) -> Option { + match self { + PlanOrPsbtInput::Plan(plan) => plan.relative_timelock, + PlanOrPsbtInput::PsbtInput { sequence, .. } => sequence.to_relative_lock_time(), + } + } + + pub fn sequence(&self) -> Option { + match self { + PlanOrPsbtInput::Plan(plan) => plan.relative_timelock.map(|rtl| rtl.to_sequence()), + PlanOrPsbtInput::PsbtInput { sequence, .. } => Some(*sequence), + } + } + + pub fn satisfaction_weight(&self) -> usize { + match self { + PlanOrPsbtInput::Plan(plan) => plan.satisfaction_weight(), + PlanOrPsbtInput::PsbtInput { + satisfaction_weight, + .. + } => *satisfaction_weight, + } + } + + pub fn is_segwit(&self) -> bool { + match self { + PlanOrPsbtInput::Plan(plan) => plan.witness_version().is_some(), + PlanOrPsbtInput::PsbtInput { psbt_input, .. } => { + psbt_input.final_script_witness.is_some() + } + } + } + + pub fn tx(&self) -> Option<&Transaction> { + match self { + PlanOrPsbtInput::Plan(_) => None, + PlanOrPsbtInput::PsbtInput { psbt_input, .. } => psbt_input.non_witness_utxo.as_ref(), + } + } +} + /// Single-input plan. #[derive(Debug, Clone)] pub struct Input { outpoint: OutPoint, txout: TxOut, tx: Option>, - plan: Plan, + plan: PlanOrPsbtInput, status: Option, is_coinbase: bool, } @@ -60,7 +170,7 @@ impl Input { outpoint: OutPoint::new(tx.compute_txid(), output_index as _), txout: tx.tx_out(output_index).cloned()?, tx: Some(tx), - plan, + plan: PlanOrPsbtInput::Plan(plan), status, is_coinbase, }) @@ -78,15 +188,45 @@ impl Input { outpoint: prev_outpoint, txout: prev_txout, tx: None, - plan, + plan: PlanOrPsbtInput::Plan(plan), status, is_coinbase, } } + /// Create + /// + /// TODO: Return error type: out of bounds, not finalized, etc. + pub fn from_finalized_psbt_input( + psbt: &bitcoin::Psbt, + input_index: usize, + status: Option, + is_coinbase: bool, + ) -> Option { + let txin = psbt.unsigned_tx.input.get(input_index)?; + let psbt_input = psbt.inputs.get(input_index).cloned()?; + let plan = PlanOrPsbtInput::from_finalized_psbt(psbt, input_index)?; + Some(Self { + outpoint: txin.previous_output, + txout: psbt_input.witness_utxo.clone().or(psbt_input + .non_witness_utxo + .clone() + .and_then(|tx| tx.output.get(input_index).cloned()))?, + tx: psbt_input.non_witness_utxo.map(Arc::new), + plan, + status, + is_coinbase, + }) + } + /// Plan - pub fn plan(&self) -> &Plan { - &self.plan + pub fn plan(&self) -> Option<&Plan> { + self.plan.plan() + } + + /// Psbt input + pub fn psbt_input(&self) -> Option<&bitcoin::psbt::Input> { + self.plan.psbt_input() } /// Previous outpoint. @@ -101,7 +241,7 @@ impl Input { /// Previous tx (if any). pub fn prev_tx(&self) -> Option<&Transaction> { - self.tx.as_ref().map(|tx| tx.as_ref()) + self.tx.as_ref().map(|tx| tx.as_ref()).or(self.plan.tx()) } /// Confirmation status. @@ -136,12 +276,12 @@ impl Input { /// Whether the output is still locked by timelock constraints and cannot be spent in the /// next block. pub fn is_timelocked(&self, tip_height: absolute::Height, tip_time: absolute::Time) -> bool { - if let Some(locktime) = self.plan.absolute_timelock { + if let Some(locktime) = self.plan.absolute_timelock() { if !locktime.is_satisfied_by(tip_height, tip_time) { return true; } } - if let Some(locktime) = self.plan.relative_timelock { + if let Some(locktime) = self.plan.relative_timelock() { // TODO: Make sure this logic is right. let (relative_height, relative_time) = match self.status { Some(status) => { @@ -173,13 +313,34 @@ impl Input { !self.is_immature(tip_height) && !self.is_timelocked(tip_height, tip_time) } - /// To coin selection candidate. - pub fn to_candidate(&self) -> bdk_coin_select::Candidate { - bdk_coin_select::Candidate::new( - self.prev_txout().value.to_sat(), - self.plan.satisfaction_weight() as _, - self.plan.witness_version().is_some(), - ) + /// Absolute timelock. + pub fn absolute_timelock(&self) -> Option { + self.plan.absolute_timelock() + } + + /// Relative timelock. + pub fn relative_timelock(&self) -> Option { + self.plan.relative_timelock() + } + + /// Sequence value. + pub fn sequence(&self) -> Option { + self.plan.sequence() + } + + /// In weight units. + /// + /// TODO: Describe what fields are actually included in this calculation. + pub fn satisfaction_weight(&self) -> u64 { + self.plan + .satisfaction_weight() + .try_into() + .expect("usize must fit into u64") + } + + /// Is segwit. + pub fn is_segwit(&self) -> bool { + self.plan.is_segwit() } } @@ -243,24 +404,29 @@ impl InputGroup { .all(|input| input.is_spendable_now(tip_height, tip_time)) } - /// To coin selection candidate. - pub fn to_candidate(&self) -> bdk_coin_select::Candidate { - bdk_coin_select::Candidate { - value: self - .inputs() - .iter() - .map(|input| input.prev_txout().value.to_sat()) - .sum(), - weight: self - .inputs() - .iter() - .map(|input| TXIN_BASE_WEIGHT + input.plan().satisfaction_weight() as u64) - .sum(), - input_count: self.inputs().len(), - is_segwit: self - .inputs() - .iter() - .any(|input| input.plan().witness_version().is_some()), - } + /// Total value of all contained inputs. + pub fn value(&self) -> Amount { + self.inputs().iter().map(|input| input.txout.value).sum() + } + + /// Total weight of all contained inputs (excluding input count varint). + pub fn weight(&self) -> u64 { + /// Txin "base" fields include `outpoint` (32+4) and `nSequence` (4) and 1 byte for the scriptSig + /// length. + pub const TXIN_BASE_WEIGHT: u64 = (32 + 4 + 4 + 1) * 4; + self.inputs() + .iter() + .map(|input| TXIN_BASE_WEIGHT + input.satisfaction_weight()) + .sum() + } + + /// Input count. + pub fn input_count(&self) -> usize { + self.inputs().len() + } + + /// Whether any contained input is a segwit spend. + pub fn is_segwit(&self) -> bool { + self.inputs().iter().any(|input| input.is_segwit()) } } diff --git a/src/input_candidates.rs b/src/input_candidates.rs index 245d5ed..36de9f6 100644 --- a/src/input_candidates.rs +++ b/src/input_candidates.rs @@ -14,6 +14,9 @@ use crate::Input; /// /// Some inputs may be unspendable now (due to unsatisfied time-locks for they are immature /// coinbase spends). +/// +/// TODO: This should live in `bdk_chain` after we move `Input`, `InputGroup`, types to +/// `bdk_tx_core`. #[derive(Debug, Clone)] pub struct InputCandidates { inputs: Vec, @@ -63,7 +66,7 @@ pub fn filter_unspendable_now( } impl InputCandidates { - /// Construct + /// Construct. /// /// # Error /// diff --git a/src/lib.rs b/src/lib.rs index ac3c4ab..34327e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,3 +41,4 @@ pub(crate) mod collections { /// Definite descriptor. pub type DefiniteDescriptor = Descriptor; + From b1a505a69297c7e3f1f3d99b7250262f3bba797c Mon Sep 17 00:00:00 2001 From: thunderbiscuit Date: Sat, 5 Apr 2025 19:22:13 -0400 Subject: [PATCH 6/6] exploring bdk-tx with bdk_wallet --- Cargo.toml | 2 + tests/bdk_wallet.rs | 119 ++++++++++++++++++++++++++++++++++++++++++++ tests/synopsis.rs | 16 +++++- 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 tests/bdk_wallet.rs diff --git a/Cargo.toml b/Cargo.toml index 123cde8..68b2911 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,8 @@ bdk_tx = { path = "." } bitcoin = { version = "0.32", features = ["rand-std"] } bdk_testenv = "0.11.1" bdk_bitcoind_rpc = "0.18.0" +bdk_wallet = "1.2.0" +bdk_esplora = { version = "0.20.1", features = ["blocking"] } [features] default = ["std"] diff --git a/tests/bdk_wallet.rs b/tests/bdk_wallet.rs new file mode 100644 index 0000000..224ccf2 --- /dev/null +++ b/tests/bdk_wallet.rs @@ -0,0 +1,119 @@ +use bdk_chain::spk_client::{FullScanRequestBuilder, FullScanResponse, SyncResponse}; +use bdk_chain::KeychainIndexed; +use bdk_esplora::esplora_client; +use bdk_esplora::esplora_client::Builder; +use bdk_esplora::EsploraExt; +use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; +use bdk_tx::{ + create_psbt, create_selection, CreatePsbtParams, CreateSelectionParams, InputCandidates, + InputGroup, Output, +}; +use bdk_wallet::{AddressInfo, KeychainKind, LocalOutput, SignOptions}; +use bitcoin::address::NetworkChecked; +use bitcoin::{Address, Amount, FeeRate, Network, OutPoint}; +use miniscript::descriptor::KeyMap; +use miniscript::plan::Assets; +use miniscript::{Descriptor, DescriptorPublicKey}; +use std::collections::BTreeMap; +use std::process::exit; +use std::str::FromStr; + +#[test] +fn bdk_wallet_simple_tx() -> anyhow::Result<()> { + const STOP_GAP: usize = 20; + const PARALLEL_REQUESTS: usize = 1; + let secp = bitcoin::secp256k1::Secp256k1::new(); + + let descriptor_private: &str = "tr(tprv8ZgxMBicQKsPdNRGG6HuFapxQCFxsDDf7TDsV8tdUgZDdiiyA6dB2ssN4RSXyp52V3MRBm4KqAps3Txng59rNMUtUEtMPDphKkKDXmamd2T/86'/1'/0'/0/*)#usy7l3tt"; + let change_descriptor_private: &str = "tr(tprv8ZgxMBicQKsPdNRGG6HuFapxQCFxsDDf7TDsV8tdUgZDdiiyA6dB2ssN4RSXyp52V3MRBm4KqAps3Txng59rNMUtUEtMPDphKkKDXmamd2T/86'/1'/0'/1/*)#dyplzymn"; + + let (descriptor, _): (Descriptor, KeyMap) = Descriptor::parse_descriptor(&secp, "tr(tprv8ZgxMBicQKsPdNRGG6HuFapxQCFxsDDf7TDsV8tdUgZDdiiyA6dB2ssN4RSXyp52V3MRBm4KqAps3Txng59rNMUtUEtMPDphKkKDXmamd2T/86'/1'/0'/0/*)#usy7l3tt")?; + let (change_descriptor, _): (Descriptor, KeyMap) = Descriptor::parse_descriptor(&secp, "tr(tprv8ZgxMBicQKsPdNRGG6HuFapxQCFxsDDf7TDsV8tdUgZDdiiyA6dB2ssN4RSXyp52V3MRBm4KqAps3Txng59rNMUtUEtMPDphKkKDXmamd2T/86'/1'/0'/1/*)#dyplzymn")?; + + // Create the wallet + let mut wallet = bdk_wallet::Wallet::create(descriptor_private, change_descriptor_private) + .network(Network::Regtest) + .create_wallet_no_persist()?; + + let client: esplora_client::BlockingClient = + Builder::new("http://127.0.0.1:3002").build_blocking(); + + println!("Syncing wallet..."); + let full_scan_request: FullScanRequestBuilder = wallet.start_full_scan(); + let update: FullScanResponse = + client.full_scan(full_scan_request, STOP_GAP, PARALLEL_REQUESTS)?; + + // Apply the update from the full scan to the wallet + wallet.apply_update(update)?; + + let balance = wallet.balance(); + println!("Wallet balance: {} sat", balance.total().to_sat()); + + if balance.total().to_sat() < 300000 { + println!("Your wallet does not have sufficient balance for the following steps!"); + // Reveal a new address from your external keychain + let address: AddressInfo = wallet.reveal_next_address(KeychainKind::External); + println!( + "Send coins to {} (address generated at index {})", + address.address, address.index + ); + exit(0) + } + + let local_outputs: Vec = wallet.list_unspent().collect(); + dbg!(&local_outputs.len()); + // dbg!(&local_outputs); + let outpoints: Vec> = local_outputs + .into_iter() + .map(|o| ((o.keychain, o.derivation_index), o.outpoint.clone())) + .collect(); + + let mut descriptors_map = BTreeMap::new(); + descriptors_map.insert(KeychainKind::External, descriptor.clone()); + descriptors_map.insert(KeychainKind::Internal, change_descriptor.clone()); + + let input_candidates: Vec = InputCandidates::new( + &wallet.tx_graph(), + &wallet.local_chain(), + wallet.local_chain().tip().block_id(), + outpoints, + descriptors_map, + Assets::new(), + )? + .into_single_groups(|_| true); + + let recipient_address: Address = + Address::from_str("bcrt1qe908k9zu8m4jgzdddgg0lkj73yctfqueg7pea9")? + .require_network(Network::Regtest)?; + + let (selection, metrics) = create_selection(CreateSelectionParams::new( + input_candidates, + change_descriptor.at_derivation_index(0)?, + vec![Output::with_script( + recipient_address.script_pubkey(), + Amount::from_sat(200_000), + )], + FeeRate::from_sat_per_vb(5).unwrap(), + ))?; + + dbg!(&selection); + + let (mut psbt, _) = create_psbt(CreatePsbtParams::new(selection))?; + let signed = wallet.sign(&mut psbt, SignOptions::default())?; + assert!(signed); + let tx = psbt.extract_tx()?; + + client.broadcast(&tx)?; + dbg!("tx broadcast: {}", tx.compute_txid()); + + println!("Syncing wallet again..."); + let sync_request = wallet.start_sync_with_revealed_spks(); + let update_2: SyncResponse = client.sync(sync_request, PARALLEL_REQUESTS)?; + + wallet.apply_update(update_2)?; + + let balance_2 = wallet.balance(); + println!("Wallet balance: {} sat", balance_2.total().to_sat()); + + Ok(()) +} diff --git a/tests/synopsis.rs b/tests/synopsis.rs index 70c10b1..b9e42d7 100644 --- a/tests/synopsis.rs +++ b/tests/synopsis.rs @@ -1,12 +1,24 @@ use bdk_bitcoind_rpc::Emitter; -use bdk_chain::{bdk_core, Balance}; +use bdk_chain::spk_client::{ + FullScanRequestBuilder, FullScanResponse, SyncRequestBuilder, SyncResponse, +}; +use bdk_chain::{bdk_core, Balance, KeychainIndexed}; +use bdk_esplora::esplora_client; +use bdk_esplora::esplora_client::Builder; +use bdk_esplora::EsploraExt; use bdk_testenv::{bitcoincore_rpc::RpcApi, TestEnv}; use bdk_tx::{ create_psbt, create_selection, filter_unspendable_now, group_by_spk, CreatePsbtParams, CreateSelectionParams, InputCandidates, InputGroup, Output, Signer, }; -use bitcoin::{absolute, key::Secp256k1, Address, Amount, BlockHash, FeeRate}; +use bdk_wallet::{AddressInfo, KeychainKind, LocalOutput, SignOptions}; +use bitcoin::address::NetworkChecked; +use bitcoin::{absolute, key::Secp256k1, Address, Amount, BlockHash, FeeRate, Network, OutPoint}; +use miniscript::descriptor::KeyMap; use miniscript::{Descriptor, DescriptorPublicKey}; +use std::collections::BTreeMap; +use std::process::exit; +use std::str::FromStr; const EXTERNAL: &str = "external"; const INTERNAL: &str = "internal";