From 673d60243e514a5871edc83c73d4c5621296087c Mon Sep 17 00:00:00 2001 From: valued mammal Date: Tue, 12 Aug 2025 13:58:51 -0400 Subject: [PATCH] wip: implement `create_psbt` for Wallet --- wallet/Cargo.toml | 6 + wallet/examples/psbt.rs | 157 +++++++++++++++++++++++ wallet/src/wallet/mod.rs | 205 ++++++++++++++++++++++++++++++- wallet/src/wallet/psbt_params.rs | 88 +++++++++++++ 4 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 wallet/examples/psbt.rs create mode 100644 wallet/src/wallet/psbt_params.rs diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index 2f509f2d..a5cac5ee 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -22,6 +22,8 @@ bitcoin = { version = "0.32.6", features = [ "serde", "base64" ], default-featur serde = { version = "^1.0", features = ["derive"] } serde_json = { version = "^1.0" } bdk_chain = { version = "0.23.1", features = [ "miniscript", "serde" ], default-features = false } +bdk_tx = { version = "0.1.0" } +bdk_coin_select = { version = "0.4.0" } # Optional dependencies bip39 = { version = "2.0", optional = true } @@ -58,3 +60,7 @@ required-features = ["all-keys"] name = "miniscriptc" path = "examples/compiler.rs" required-features = ["compiler"] + +[[example]] +name = "psbt" +required-features = ["test-utils"] diff --git a/wallet/examples/psbt.rs b/wallet/examples/psbt.rs new file mode 100644 index 00000000..7f41f27f --- /dev/null +++ b/wallet/examples/psbt.rs @@ -0,0 +1,157 @@ +#![allow(unused_imports)] +#![allow(clippy::print_stdout)] + +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; + +use bdk_chain::BlockId; +use bdk_chain::ConfirmationBlockTime; +use bdk_chain::TxUpdate; +use bdk_wallet::psbt_params::{Params, SelectionStrategy::*}; +use bdk_wallet::test_utils::*; +use bdk_wallet::{KeychainKind::*, Update, Wallet}; +use bitcoin::FeeRate; +use bitcoin::{ + consensus, + secp256k1::{self, rand}, + Address, Amount, OutPoint, TxIn, TxOut, +}; +use miniscript::descriptor::Descriptor; +use miniscript::descriptor::KeyMap; +use rand::Rng; + +// This example shows how to create a PSBT using BDK Wallet. + +const NETWORK: bitcoin::Network = bitcoin::Network::Signet; +const SEND_TO: &str = "tb1pw3g5qvnkryghme7pyal228ekj6vq48zc5k983lqtlr2a96n4xw0q5ejknw"; +const AMOUNT: Amount = Amount::from_sat(42_000); +const FEERATE: f64 = 2.0; // sat/vb + +fn main() -> anyhow::Result<()> { + let (desc, change_desc) = get_test_wpkh_and_change_desc(); + let secp = secp256k1::Secp256k1::new(); + let mut rng = rand::thread_rng(); + + // Assuming these are private descriptors, parse the KeyMap now which will come + // in handy when it comes to signing the PSBT. + let keymap: KeyMap = [desc.to_string(), change_desc.to_string()] + .iter() + .flat_map(|s| Descriptor::parse_descriptor(&secp, s).unwrap().1) + .collect(); + + // Create wallet and fund it. + let mut wallet = Wallet::create(desc, change_desc) + .network(NETWORK) + .create_wallet_no_persist()?; + + fund_wallet(&mut wallet, &mut rng)?; + + let utxos = wallet + .list_unspent() + .map(|output| (output.outpoint, output)) + .collect::>(); + + // Build params. + let mut params = Params::default(); + let addr = Address::from_str(SEND_TO)?.require_network(NETWORK)?; + let feerate = feerate_unchecked(FEERATE); + params + .add_recipients([(addr, AMOUNT)]) + .feerate(feerate) + .coin_selection(SingleRandomDraw); + + // Create PSBT (which also returns the Finalizer). + let (mut psbt, finalizer) = wallet.create_psbt(params, &mut rng)?; + + // dbg!(&psbt); + + let tx = &psbt.unsigned_tx; + for txin in &tx.input { + let op = txin.previous_output; + let output = utxos.get(&op).unwrap(); + println!("TxIn: {}", output.txout.value); + } + for txout in &tx.output { + println!("TxOut: {}", txout.value); + } + + let signer = bdk_tx::Signer(keymap); + let sign_res = psbt.sign(&signer, &secp); + println!("Signed: {}", sign_res.is_ok()); + + let finalize_res = finalizer.finalize(&mut psbt); + println!("Finalized: {}", finalize_res.is_finalized()); + + let tx = psbt.extract_tx()?; + + let feerate = wallet.calculate_fee_rate(&tx)?; + println!("Feerate: {} sat/vb", bdk_wallet::floating_rate!(feerate)); + + // println!("{}", consensus::encode::serialize_hex(&tx)); + + Ok(()) +} + +fn fund_wallet(wallet: &mut Wallet, rng: &mut impl Rng) -> anyhow::Result<()> { + let anchor = ConfirmationBlockTime { + block_id: BlockId { + height: 260071, + hash: "000000099f67ae6469d1ad0525d756e24d4b02fbf27d65b3f413d5feb367ec48".parse()?, + }, + confirmation_time: 1752184658, + }; + insert_checkpoint(wallet, anchor.block_id); + + // Fund wallet with several random utxos + for i in 0..21 { + let value = 10_000 * (i + 1) + (100 * rng.gen_range(0..10)); + let addr = wallet.reveal_next_address(External).address; + receive_output_to_addr( + wallet, + addr, + Amount::from_sat(value), + ReceiveTo::Block(anchor), + ); + } + + Ok(()) +} + +// Note: this is borrowed from `test-utils`, but here the tx appears as a coinbase tx +// and inserting it does not automatically include a timestamp. +fn receive_output_to_addr( + wallet: &mut Wallet, + addr: Address, + value: Amount, + receive_to: impl Into, +) -> OutPoint { + let tx = bitcoin::Transaction { + lock_time: bitcoin::absolute::LockTime::ZERO, + version: bitcoin::transaction::Version::TWO, + input: vec![TxIn::default()], + output: vec![TxOut { + script_pubkey: addr.script_pubkey(), + value, + }], + }; + + // Insert tx + let txid = tx.compute_txid(); + let mut tx_update = TxUpdate::default(); + tx_update.txs = vec![Arc::new(tx)]; + wallet + .apply_update(Update { + tx_update, + ..Default::default() + }) + .unwrap(); + + // Insert anchor or last-seen. + match receive_to.into() { + ReceiveTo::Block(anchor) => insert_anchor(wallet, txid, anchor), + ReceiveTo::Mempool(last_seen) => insert_seen_at(wallet, txid, last_seen), + } + + OutPoint { txid, vout: 0 } +} diff --git a/wallet/src/wallet/mod.rs b/wallet/src/wallet/mod.rs index c1aab082..875ea574 100644 --- a/wallet/src/wallet/mod.rs +++ b/wallet/src/wallet/mod.rs @@ -30,7 +30,7 @@ use bdk_chain::{ SyncResponse, }, tx_graph::{CalculateFeeError, CanonicalTx, TxGraph, TxUpdate}, - BlockId, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, + Anchor, BlockId, CanonicalizationParams, ChainPosition, ConfirmationBlockTime, DescriptorExt, FullTxOut, Indexed, IndexedTxGraph, Indexer, Merge, }; use bitcoin::{ @@ -46,6 +46,7 @@ use bitcoin::{ use miniscript::{ descriptor::KeyMap, psbt::{PsbtExt, PsbtInputExt, PsbtInputSatisfier}, + ForEachKey, }; use rand_core::RngCore; @@ -55,6 +56,8 @@ pub mod error; pub mod export; mod params; mod persisted; +#[allow(unused)] +pub mod psbt_params; pub mod signer; pub mod tx_builder; pub(crate) mod utils; @@ -80,8 +83,7 @@ pub use bdk_chain::Balance; pub use changeset::ChangeSet; pub use params::*; pub use persisted::*; -pub use utils::IsDust; -pub use utils::TxDetails; +pub use utils::{IsDust, TxDetails}; /// A Bitcoin wallet /// @@ -2633,6 +2635,203 @@ impl Wallet { } } +use bdk_tx::{ + selection_algorithm_lowest_fee_bnb, ChangePolicyType, Finalizer, Input, InputCandidates, + Output, PsbtParams, Selector, SelectorParams, TxStatus, +}; +use miniscript::plan::{Assets, Plan}; +use psbt_params::SelectionStrategy; + +/// Maps a chain position to tx confirmation status, if `pos` is the confirmed +/// variant. +/// +/// # Panics +/// +/// - If the confirmation height or time is not a valid absolute [`Height`] or [`Time`]. +/// +/// [`Height`]: bitcoin::absolute::Height +/// [`Time`]: bitcoin::absolute::Time +fn status_from_position(pos: ChainPosition) -> Option { + if let ChainPosition::Confirmed { anchor, .. } = pos { + let conf_height = anchor.confirmation_height_upper_bound(); + let height = + absolute::Height::from_consensus(conf_height).expect("must be valid block height"); + let time = absolute::Time::from_consensus( + anchor + .confirmation_time + .try_into() + .expect("confirmation time should fit into u32"), + ) + .expect("must be valid block time"); + + Some(TxStatus { height, time }) + } else { + None + } +} + +impl Wallet { + /// Create PSBT with the given `params` and `rng`. + pub fn create_psbt( + &self, + params: psbt_params::Params, + rng: &mut impl RngCore, + ) -> Result<(Psbt, Finalizer), CreatePsbtError> { + // Get input candidates + let assets = self.assets(); + // TODO: We need to handle the case where we are unable to plan + // a must-spend input. + let (must_spend, mut may_spend): (Vec, Vec) = self + .list_unspent() + .flat_map(|output| self.plan_input(&output, &assets)) + .partition(|input| params.utxos.contains(&input.prev_outpoint())); + + if let SelectionStrategy::SingleRandomDraw = params.coin_selection { + utils::shuffle_slice(&mut may_spend, rng); + } + + let input_candidates = InputCandidates::new(must_spend, may_spend); + + // Parse params + let outputs: Vec = params.recipients.into_iter().map(Output::from).collect(); + let feerate = params.feerate; + let longterm_feerate = params.longterm_feerate; + let definite_change_desc = params.change_descriptor.unwrap_or_else(|| { + let change_keychain = KeychainKind::Internal; + let desc = self.public_descriptor(change_keychain); + let next_index = self.next_derivation_index(change_keychain); + desc.at_derivation_index(next_index) + .expect("should be valid derivation index") + }); + + // Select coins + let mut selector = Selector::new( + &input_candidates, + SelectorParams::new( + feerate, + outputs, + definite_change_desc, + ChangePolicyType::NoDustAndLeastWaste { longterm_feerate }, + ), + ) + .map_err(CreatePsbtError::Selector)?; + + match params.coin_selection { + SelectionStrategy::SingleRandomDraw => { + // We should have already shuffled candidates earlier, so just select + // until the target is met. + selector + .select_until_target_met() + .map_err(CreatePsbtError::NSF)?; + } + SelectionStrategy::LowestFee => { + selector + .select_with_algorithm(selection_algorithm_lowest_fee_bnb( + longterm_feerate, + 10_000, + )) + .map_err(CreatePsbtError::Bnb)?; + } + }; + let selection = selector.try_finalize().ok_or({ + let e = bdk_tx::CannotMeetTarget; + CreatePsbtError::Selector(bdk_tx::SelectorError::CannotMeetTarget(e)) + })?; + + let chain_tip = self.chain.tip().block_id(); + let fallback_locktime = absolute::LockTime::from_consensus(chain_tip.height); + + // Create psbt + let psbt = selection + .create_psbt(PsbtParams { + fallback_locktime, + fallback_sequence: Sequence::ENABLE_LOCKTIME_NO_RBF, + ..Default::default() + }) + .map_err(CreatePsbtError::Psbt)?; + + let finalizer = selection.into_finalizer(); + + Ok((psbt, finalizer)) + } + + /// Return the "keys" assets, i.e. the ones we can trivially infer by scanning + /// the pubkeys of the wallet's descriptors. + fn assets(&self) -> Assets { + let mut pks = vec![]; + for (_, desc) in self.keychains() { + desc.for_each_key(|k| { + pks.push(k.clone()); + true + }); + } + + Assets::new().add(pks) + } + + /// Attempt to create a spending plan for the UTXO of the given `outpoint` + /// with the provided `assets`. + /// + /// Return `None` if `outpoint` doesn't correspond to an indexed txout, or + /// if the assets are not sufficient to create a plan. + fn try_plan(&self, outpoint: OutPoint, assets: &Assets) -> Option { + let indexer = &self.indexed_graph.index; + let ((keychain, index), _) = indexer.txout(outpoint)?; + let desc = indexer + .get_descriptor(keychain)? + .at_derivation_index(index) + .expect("must be valid derivation index"); + desc.plan(assets).ok() + } + + /// Plan the output with the available assets and return a new [`Input`]. + fn plan_input(&self, output: &LocalOutput, assets: &Assets) -> Option { + let op = output.outpoint; + let txid = op.txid; + if let Some(plan) = self.try_plan(op, assets) { + let tx = self.indexed_graph.graph().get_tx(txid).unwrap(); + return Some( + Input::from_prev_tx( + plan, + tx, + op.vout as usize, + status_from_position(output.chain_position), + ) + .expect("invalid outpoint"), + ); + } + + None + } +} + +/// Error when creating a PSBT. +#[derive(Debug)] +pub enum CreatePsbtError { + /// No Bnb solution. + Bnb(bdk_coin_select::NoBnbSolution), + /// Non-sufficient funds + NSF(bdk_coin_select::InsufficientFunds), + /// Failed to create PSBT + Psbt(bdk_tx::CreatePsbtError), + /// Selector error + Selector(bdk_tx::SelectorError), +} + +impl fmt::Display for CreatePsbtError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Bnb(e) => write!(f, "{e}"), + Self::NSF(e) => write!(f, "{e}"), + Self::Psbt(e) => write!(f, "{e}"), + Self::Selector(e) => write!(f, "{e}"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for CreatePsbtError {} + impl AsRef> for Wallet { fn as_ref(&self) -> &bdk_chain::tx_graph::TxGraph { self.indexed_graph.graph() diff --git a/wallet/src/wallet/psbt_params.rs b/wallet/src/wallet/psbt_params.rs new file mode 100644 index 00000000..78b0bdb7 --- /dev/null +++ b/wallet/src/wallet/psbt_params.rs @@ -0,0 +1,88 @@ +//! Parameters for PSBT building. + +use alloc::vec::Vec; + +use bdk_tx::DefiniteDescriptor; +use bitcoin::{absolute, transaction::Version, Amount, FeeRate, OutPoint, ScriptBuf, Sequence}; + +/// Parameters to create a PSBT. +#[derive(Debug, Clone)] +pub struct Params { + // Inputs + pub(crate) utxos: Vec, + // TODO: miniscript plan Assets? + // pub(crate) assets: Assets, + + // Outputs + pub(crate) recipients: Vec<(ScriptBuf, Amount)>, + pub(crate) change_descriptor: Option, + + // Coin Selection + pub(crate) feerate: FeeRate, + pub(crate) longterm_feerate: FeeRate, + pub(crate) drain_wallet: bool, + pub(crate) coin_selection: SelectionStrategy, + + // PSBT + pub(crate) version: Option, + pub(crate) locktime: Option, + pub(crate) fallback_sequence: Option, +} + +impl Default for Params { + fn default() -> Self { + Self { + utxos: Default::default(), + recipients: Default::default(), + change_descriptor: Default::default(), + feerate: bitcoin::FeeRate::BROADCAST_MIN, + longterm_feerate: bitcoin::FeeRate::from_sat_per_vb_unchecked(10), + drain_wallet: Default::default(), + coin_selection: Default::default(), + version: Default::default(), + locktime: Default::default(), + fallback_sequence: Default::default(), + } + } +} + +// TODO: more setters for Params +impl Params { + /// Add recipients. + /// + /// - `recipients`: An iterator of `(S, Amount)` tuples where `S` can be a bitcoin [`Address`], + /// a scriptPubKey, or anything that be converted straight into a [`ScriptBuf`]. + pub fn add_recipients(&mut self, recipients: I) -> &mut Self + where + I: IntoIterator, + S: Into, + { + self.recipients + .extend(recipients.into_iter().map(|(s, amt)| (s.into(), amt))); + self + } + + /// Set the target fee rate. + pub fn feerate(&mut self, feerate: FeeRate) -> &mut Self { + self.feerate = feerate; + self + } + + /// Set the strategy to be used when selecting coins. + pub fn coin_selection(&mut self, strategy: SelectionStrategy) -> &mut Self { + self.coin_selection = strategy; + self + } +} + +/// Coin select strategy. +#[derive(Debug, Clone, Copy, Default)] +pub enum SelectionStrategy { + /// Single random draw. + #[default] + SingleRandomDraw, + /// Lowest fee, a variation of Branch 'n Bound that allows for change + /// while minimizing transaction fees. Refer to + /// [`LowestFee`](bdk_coin_select::metrics::LowestFee) metric for more. + LowestFee, +}