]> Untitled Git - bdk/commitdiff
[examples_redesign] Introduce `example_cli` package
author志宇 <hello@evanlinjin.me>
Fri, 12 May 2023 08:17:17 +0000 (16:17 +0800)
committer志宇 <hello@evanlinjin.me>
Sat, 3 Jun 2023 19:32:17 +0000 (03:32 +0800)
This is the equivalent of `keychain_tracker_example_cli` that works with
the redesigned structures.

Cargo.toml
crates/chain/src/chain_data.rs
crates/chain/src/chain_oracle.rs
crates/chain/src/indexed_tx_graph.rs
crates/chain/src/local_chain.rs
crates/electrum/src/lib.rs
example-crates/example_cli/Cargo.toml [new file with mode: 0644]
example-crates/example_cli/src/lib.rs [new file with mode: 0644]

index 2104196be0a4713a8d6bc9d0f50b32158da573dd..4d0f4f4d7dd67f8ee8a325fc3f71800a433f920c 100644 (file)
@@ -4,6 +4,7 @@ members = [
     "crates/chain",
     "crates/file_store",
     "crates/electrum",
+    "example-crates/example_cli",
     "example-crates/keychain_tracker_electrum",
     "example-crates/keychain_tracker_esplora",
     "example-crates/keychain_tracker_example_cli",
index 022e12993f64b5b7f9d0b23c3c9d31298705472d..d1234298eeb7e2630e0dc79d47886be608a1c3c6 100644 (file)
@@ -16,6 +16,13 @@ pub enum ObservedAs<A> {
     Unconfirmed(u64),
 }
 
+impl<A> ObservedAs<A> {
+    /// Returns whether [`ObservedAs`] is confirmed or not.
+    pub fn is_confirmed(&self) -> bool {
+        matches!(self, Self::Confirmed(_))
+    }
+}
+
 impl<A: Clone> ObservedAs<&A> {
     pub fn cloned(self) -> ObservedAs<A> {
         match self {
index 58fbf6c18d1566882dcaadb6bc350de8c7e642d0..e736be0354d68ca7ca82b4dd18a5e258f3b2e5ba 100644 (file)
@@ -19,4 +19,7 @@ pub trait ChainOracle {
         block: BlockId,
         chain_tip: BlockId,
     ) -> Result<Option<bool>, Self::Error>;
+
+    /// Get the best chain's chain tip.
+    fn get_chain_tip(&self) -> Result<Option<BlockId>, Self::Error>;
 }
index f69b227a21f2929cc28af0a0e2df2971bf3c865b..24a1884cb98a18962a9a69a331c222f49379202d 100644 (file)
@@ -203,6 +203,15 @@ impl<A: Anchor, IA: Append> Append for IndexedAdditions<A, IA> {
     }
 }
 
+impl<A, IA: Default> From<Additions<A>> for IndexedAdditions<A, IA> {
+    fn from(graph_additions: Additions<A>) -> Self {
+        Self {
+            graph_additions,
+            ..Default::default()
+        }
+    }
+}
+
 /// Represents a structure that can index transaction data.
 pub trait Indexer {
     /// The resultant "additions" when new transaction data is indexed.
index a32a615c250f8b3af0de31eef7880fbdec482090..7623b29439d7dd8fe2d76045ffbf06f6319bc535 100644 (file)
@@ -34,6 +34,10 @@ impl ChainOracle for LocalChain {
             },
         )
     }
+
+    fn get_chain_tip(&self) -> Result<Option<BlockId>, Self::Error> {
+        Ok(self.tip())
+    }
 }
 
 impl AsRef<BTreeMap<u32, BlockHash>> for LocalChain {
index 051b6375d0a2a55be74bdb078f39376b79534a41..df5e1d741ebc226bbf0cf7af7ac39bfe20912b13 100644 (file)
@@ -130,7 +130,7 @@ impl ElectrumExt for Client {
                     let mut scanned_spk_iter = scanned_spks
                         .iter()
                         .map(|(i, (spk, _))| (i.clone(), spk.clone()));
-                    match populate_with_spks::<K, _, _>(
+                    match populate_with_spks::<_, _>(
                         self,
                         &mut update,
                         &mut scanned_spk_iter,
@@ -143,7 +143,7 @@ impl ElectrumExt for Client {
                     };
                 }
                 for (keychain, keychain_spks) in &mut request_spks {
-                    match populate_with_spks::<K, u32, _>(
+                    match populate_with_spks::<u32, _>(
                         self,
                         &mut update,
                         keychain_spks,
@@ -529,7 +529,7 @@ fn populate_with_txids(
 
 /// Populate an update [`SparseChain`] with transactions (and associated block positions) from
 /// the transaction history of the provided `spk`s.
-fn populate_with_spks<K, I, S>(
+fn populate_with_spks<I, S>(
     client: &Client,
     update: &mut SparseChain,
     spks: &mut S,
@@ -537,7 +537,6 @@ fn populate_with_spks<K, I, S>(
     batch_size: usize,
 ) -> Result<BTreeMap<I, (Script, bool)>, InternalError>
 where
-    K: Ord + Clone,
     I: Ord + Clone,
     S: Iterator<Item = (I, Script)>,
 {
diff --git a/example-crates/example_cli/Cargo.toml b/example-crates/example_cli/Cargo.toml
new file mode 100644 (file)
index 0000000..ffad2f9
--- /dev/null
@@ -0,0 +1,17 @@
+[package]
+name = "example_cli"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+bdk_chain = { path = "../../crates/chain", features = ["serde", "miniscript"]}
+bdk_file_store = { path = "../../crates/file_store" }
+bdk_tmp_plan = { path = "../../nursery/tmp_plan" }
+bdk_coin_select = { path = "../../nursery/coin_select" }
+
+clap = { version = "3.2.23", features = ["derive", "env"] }
+anyhow = "1"
+serde = { version = "1", features = ["derive"] }
+serde_json = { version = "^1.0" }
diff --git a/example-crates/example_cli/src/lib.rs b/example-crates/example_cli/src/lib.rs
new file mode 100644 (file)
index 0000000..30be503
--- /dev/null
@@ -0,0 +1,775 @@
+pub use anyhow;
+use anyhow::Context;
+use bdk_coin_select::{coin_select_bnb, CoinSelector, CoinSelectorOpt, WeightedValue};
+use bdk_file_store::Store;
+use serde::{de::DeserializeOwned, Serialize};
+use std::{cmp::Reverse, collections::HashMap, path::PathBuf, sync::Mutex, time::Duration};
+
+use bdk_chain::{
+    bitcoin::{
+        psbt::Prevouts,
+        secp256k1::{self, Secp256k1},
+        util::sighash::SighashCache,
+        Address, LockTime, Network, Script, Sequence, Transaction, TxIn, TxOut,
+    },
+    indexed_tx_graph::{IndexedAdditions, IndexedTxGraph},
+    keychain::{DerivationAdditions, KeychainTxOutIndex},
+    miniscript::{
+        descriptor::{DescriptorSecretKey, KeyMap},
+        Descriptor, DescriptorPublicKey,
+    },
+    Anchor, Append, ChainOracle, DescriptorExt, FullTxOut, ObservedAs, Persist, PersistBackend,
+};
+pub use bdk_file_store;
+pub use clap;
+
+use clap::{Parser, Subcommand};
+
+pub type KeychainTxGraph<A> = IndexedTxGraph<A, KeychainTxOutIndex<Keychain>>;
+pub type Database<'m, A, X> = Persist<Store<'m, ChangeSet<A, X>>, ChangeSet<A, X>>;
+
+#[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
+#[serde(bound(
+    deserialize = "A: Ord + serde::Deserialize<'de>, X: serde::Deserialize<'de>",
+    serialize = "A: Ord + serde::Serialize, X: serde::Serialize",
+))]
+pub struct ChangeSet<A, X> {
+    pub indexed_additions: IndexedAdditions<A, DerivationAdditions<Keychain>>,
+    pub extension: X,
+}
+
+impl<A, X: Default> Default for ChangeSet<A, X> {
+    fn default() -> Self {
+        Self {
+            indexed_additions: Default::default(),
+            extension: Default::default(),
+        }
+    }
+}
+
+impl<A: Anchor, X: Append> Append for ChangeSet<A, X> {
+    fn append(&mut self, other: Self) {
+        Append::append(&mut self.indexed_additions, other.indexed_additions);
+        Append::append(&mut self.extension, other.extension)
+    }
+
+    fn is_empty(&self) -> bool {
+        todo!()
+    }
+}
+
+#[derive(Parser)]
+#[clap(author, version, about, long_about = None)]
+#[clap(propagate_version = true)]
+pub struct Args<C: clap::Subcommand> {
+    #[clap(env = "DESCRIPTOR")]
+    pub descriptor: String,
+    #[clap(env = "CHANGE_DESCRIPTOR")]
+    pub change_descriptor: Option<String>,
+
+    #[clap(env = "BITCOIN_NETWORK", long, default_value = "signet")]
+    pub network: Network,
+
+    #[clap(env = "BDK_DB_PATH", long, default_value = ".bdk_example_db")]
+    pub db_path: PathBuf,
+
+    #[clap(env = "BDK_CP_LIMIT", long, default_value = "20")]
+    pub cp_limit: usize,
+
+    #[clap(subcommand)]
+    pub command: Commands<C>,
+}
+
+#[allow(clippy::almost_swapped)]
+#[derive(Subcommand, Debug, Clone)]
+pub enum Commands<C: clap::Subcommand> {
+    #[clap(flatten)]
+    ChainSpecific(C),
+    /// Address generation and inspection.
+    Address {
+        #[clap(subcommand)]
+        addr_cmd: AddressCmd,
+    },
+    /// Get the wallet balance.
+    Balance,
+    /// TxOut related commands.
+    #[clap(name = "txout")]
+    TxOut {
+        #[clap(subcommand)]
+        txout_cmd: TxOutCmd,
+    },
+    /// Send coins to an address.
+    Send {
+        value: u64,
+        address: Address,
+        #[clap(short, default_value = "largest-first")]
+        coin_select: CoinSelectionAlgo,
+    },
+}
+
+#[derive(Clone, Debug)]
+pub enum CoinSelectionAlgo {
+    LargestFirst,
+    SmallestFirst,
+    OldestFirst,
+    NewestFirst,
+    BranchAndBound,
+}
+
+impl Default for CoinSelectionAlgo {
+    fn default() -> Self {
+        Self::LargestFirst
+    }
+}
+
+impl core::str::FromStr for CoinSelectionAlgo {
+    type Err = anyhow::Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        use CoinSelectionAlgo::*;
+        Ok(match s {
+            "largest-first" => LargestFirst,
+            "smallest-first" => SmallestFirst,
+            "oldest-first" => OldestFirst,
+            "newest-first" => NewestFirst,
+            "bnb" => BranchAndBound,
+            unknown => {
+                return Err(anyhow::anyhow!(
+                    "unknown coin selection algorithm '{}'",
+                    unknown
+                ))
+            }
+        })
+    }
+}
+
+impl core::fmt::Display for CoinSelectionAlgo {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        use CoinSelectionAlgo::*;
+        write!(
+            f,
+            "{}",
+            match self {
+                LargestFirst => "largest-first",
+                SmallestFirst => "smallest-first",
+                OldestFirst => "oldest-first",
+                NewestFirst => "newest-first",
+                BranchAndBound => "bnb",
+            }
+        )
+    }
+}
+
+#[allow(clippy::almost_swapped)]
+#[derive(Subcommand, Debug, Clone)]
+pub enum AddressCmd {
+    /// Get the next unused address.
+    Next,
+    /// Get a new address regardless of the existing unused addresses.
+    New,
+    /// List all addresses
+    List {
+        #[clap(long)]
+        change: bool,
+    },
+    Index,
+}
+
+#[derive(Subcommand, Debug, Clone)]
+pub enum TxOutCmd {
+    List {
+        /// Return only spent outputs.
+        #[clap(short, long)]
+        spent: bool,
+        /// Return only unspent outputs.
+        #[clap(short, long)]
+        unspent: bool,
+        /// Return only confirmed outputs.
+        #[clap(long)]
+        confirmed: bool,
+        /// Return only unconfirmed outputs.
+        #[clap(long)]
+        unconfirmed: bool,
+    },
+}
+
+#[derive(
+    Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, serde::Deserialize, serde::Serialize,
+)]
+pub enum Keychain {
+    External,
+    Internal,
+}
+
+impl core::fmt::Display for Keychain {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Keychain::External => write!(f, "external"),
+            Keychain::Internal => write!(f, "internal"),
+        }
+    }
+}
+
+pub fn run_address_cmd<A, X>(
+    graph: &mut KeychainTxGraph<A>,
+    db: &Mutex<Database<'_, A, X>>,
+    network: Network,
+    cmd: AddressCmd,
+) -> anyhow::Result<()>
+where
+    ChangeSet<A, X>: Default + Append + DeserializeOwned + Serialize,
+{
+    let process_spk = |spk_i: u32, spk: &Script, index_additions: DerivationAdditions<Keychain>| {
+        if !index_additions.is_empty() {
+            let db = &mut *db.lock().unwrap();
+            db.stage(ChangeSet {
+                indexed_additions: IndexedAdditions {
+                    index_additions,
+                    ..Default::default()
+                },
+                ..Default::default()
+            });
+            db.commit()?;
+        }
+        let addr = Address::from_script(spk, network).context("failed to derive address")?;
+        println!("[address @ {}] {}", spk_i, addr);
+        Ok(())
+    };
+
+    let index = &mut graph.index;
+
+    match cmd {
+        AddressCmd::Next => {
+            let ((spk_i, spk), index_additions) = index.next_unused_spk(&Keychain::External);
+            process_spk(spk_i, spk, index_additions)
+        }
+        AddressCmd::New => {
+            let ((spk_i, spk), index_additions) = index.reveal_next_spk(&Keychain::External);
+            process_spk(spk_i, spk, index_additions)
+        }
+        AddressCmd::Index => {
+            for (keychain, derivation_index) in index.last_revealed_indices() {
+                println!("{:?}: {}", keychain, derivation_index);
+            }
+            Ok(())
+        }
+        AddressCmd::List { change } => {
+            let target_keychain = match change {
+                true => Keychain::Internal,
+                false => Keychain::External,
+            };
+            for (spk_i, spk) in index.revealed_spks_of_keychain(&target_keychain) {
+                let address = Address::from_script(spk, network)
+                    .expect("should always be able to derive address");
+                println!(
+                    "{:?} {} used:{}",
+                    spk_i,
+                    address,
+                    index.is_used(&(target_keychain, spk_i))
+                );
+            }
+            Ok(())
+        }
+    }
+}
+
+pub fn run_balance_cmd<A: Anchor, O: ChainOracle>(
+    graph: &KeychainTxGraph<A>,
+    chain: &O,
+) -> Result<(), O::Error> {
+    let balance = graph.graph().try_balance(
+        chain,
+        chain.get_chain_tip()?.unwrap_or_default(),
+        graph.index.outpoints().iter().cloned(),
+        |(k, _), _| k == &Keychain::Internal,
+    )?;
+
+    let confirmed_total = balance.confirmed + balance.immature;
+    let unconfirmed_total = balance.untrusted_pending + balance.trusted_pending;
+
+    println!("[confirmed]");
+    println!("  total     = {}sats", confirmed_total);
+    println!("  spendable = {}sats", balance.confirmed);
+    println!("  immature  = {}sats", balance.immature);
+
+    println!("[unconfirmed]");
+    println!("  total     = {}sats", unconfirmed_total,);
+    println!("  trusted   = {}sats", balance.trusted_pending);
+    println!("  untrusted = {}sats", balance.untrusted_pending);
+
+    Ok(())
+}
+
+pub fn run_txo_cmd<A: Anchor, O: ChainOracle>(
+    graph: &KeychainTxGraph<A>,
+    chain: &O,
+    network: Network,
+    cmd: TxOutCmd,
+) -> anyhow::Result<()>
+where
+    O::Error: std::error::Error + Send + Sync + 'static,
+{
+    let chain_tip = chain.get_chain_tip()?.unwrap_or_default();
+    let outpoints = graph.index.outpoints().iter().cloned();
+
+    match cmd {
+        TxOutCmd::List {
+            spent,
+            unspent,
+            confirmed,
+            unconfirmed,
+        } => {
+            let txouts = graph
+                .graph()
+                .try_filter_chain_txouts(chain, chain_tip, outpoints)
+                .filter(|r| match r {
+                    Ok((_, full_txo)) => match (spent, unspent) {
+                        (true, false) => full_txo.spent_by.is_some(),
+                        (false, true) => full_txo.spent_by.is_none(),
+                        _ => true,
+                    },
+                    // always keep errored items
+                    Err(_) => true,
+                })
+                .filter(|r| match r {
+                    Ok((_, full_txo)) => match (confirmed, unconfirmed) {
+                        (true, false) => full_txo.chain_position.is_confirmed(),
+                        (false, true) => !full_txo.chain_position.is_confirmed(),
+                        _ => true,
+                    },
+                    // always keep errored items
+                    Err(_) => true,
+                })
+                .collect::<Result<Vec<_>, _>>()?;
+
+            for (spk_i, full_txo) in txouts {
+                let addr = Address::from_script(&full_txo.txout.script_pubkey, network)?;
+                println!(
+                    "{:?} {} {} {} spent:{:?}",
+                    spk_i, full_txo.txout.value, full_txo.outpoint, addr, full_txo.spent_by
+                )
+            }
+            Ok(())
+        }
+    }
+}
+
+#[allow(clippy::too_many_arguments)]
+pub fn run_send_cmd<A: Anchor, O: ChainOracle, X>(
+    graph: &Mutex<KeychainTxGraph<A>>,
+    db: &Mutex<Database<'_, A, X>>,
+    chain: &O,
+    keymap: &HashMap<DescriptorPublicKey, DescriptorSecretKey>,
+    cs_algorithm: CoinSelectionAlgo,
+    address: Address,
+    value: u64,
+    broadcast: impl FnOnce(&Transaction) -> anyhow::Result<()>,
+) -> anyhow::Result<()>
+where
+    O::Error: std::error::Error + Send + Sync + 'static,
+    ChangeSet<A, X>: Default + Append + DeserializeOwned + Serialize,
+{
+    let (transaction, change_index) = {
+        let graph = &mut *graph.lock().unwrap();
+        // take mutable ref to construct tx -- it is only open for a short time while building it.
+        let (tx, change_info) = create_tx(graph, chain, keymap, cs_algorithm, address, value)?;
+
+        if let Some((index_additions, (change_keychain, index))) = change_info {
+            // We must first persist to disk the fact that we've got a new address from the
+            // change keychain so future scans will find the tx we're about to broadcast.
+            // If we're unable to persist this, then we don't want to broadcast.
+            db.lock().unwrap().stage(ChangeSet {
+                indexed_additions: IndexedAdditions {
+                    index_additions,
+                    ..Default::default()
+                },
+                ..Default::default()
+            });
+
+            // We don't want other callers/threads to use this address while we're using it
+            // but we also don't want to scan the tx we just created because it's not
+            // technically in the blockchain yet.
+            graph.index.mark_used(&change_keychain, index);
+            (tx, Some((change_keychain, index)))
+        } else {
+            (tx, None)
+        }
+    };
+
+    match (broadcast)(&transaction) {
+        Ok(_) => {
+            println!("Broadcasted Tx : {}", transaction.txid());
+
+            let indexed_additions = graph.lock().unwrap().insert_tx(&transaction, None, None);
+
+            // We know the tx is at least unconfirmed now. Note if persisting here fails,
+            // it's not a big deal since we can always find it again form
+            // blockchain.
+            db.lock().unwrap().stage(ChangeSet {
+                indexed_additions,
+                ..Default::default()
+            });
+            Ok(())
+        }
+        Err(e) => {
+            if let Some((keychain, index)) = change_index {
+                // We failed to broadcast, so allow our change address to be used in the future
+                graph.lock().unwrap().index.unmark_used(&keychain, index);
+            }
+            Err(e)
+        }
+    }
+}
+
+#[allow(clippy::type_complexity)]
+pub fn create_tx<A: Anchor, O: ChainOracle>(
+    graph: &mut KeychainTxGraph<A>,
+    chain: &O,
+    keymap: &HashMap<DescriptorPublicKey, DescriptorSecretKey>,
+    cs_algorithm: CoinSelectionAlgo,
+    address: Address,
+    value: u64,
+) -> anyhow::Result<(
+    Transaction,
+    Option<(DerivationAdditions<Keychain>, (Keychain, u32))>,
+)>
+where
+    O::Error: std::error::Error + Send + Sync + 'static,
+{
+    let mut additions = DerivationAdditions::default();
+
+    let assets = bdk_tmp_plan::Assets {
+        keys: keymap.iter().map(|(pk, _)| pk.clone()).collect(),
+        ..Default::default()
+    };
+
+    // TODO use planning module
+    let mut candidates = planned_utxos(graph, chain, &assets)?;
+
+    // apply coin selection algorithm
+    match cs_algorithm {
+        CoinSelectionAlgo::LargestFirst => {
+            candidates.sort_by_key(|(_, utxo)| Reverse(utxo.txout.value))
+        }
+        CoinSelectionAlgo::SmallestFirst => candidates.sort_by_key(|(_, utxo)| utxo.txout.value),
+        CoinSelectionAlgo::OldestFirst => {
+            candidates.sort_by_key(|(_, utxo)| utxo.chain_position.clone())
+        }
+        CoinSelectionAlgo::NewestFirst => {
+            candidates.sort_by_key(|(_, utxo)| Reverse(utxo.chain_position.clone()))
+        }
+        CoinSelectionAlgo::BranchAndBound => {}
+    }
+
+    // turn the txos we chose into weight and value
+    let wv_candidates = candidates
+        .iter()
+        .map(|(plan, utxo)| {
+            WeightedValue::new(
+                utxo.txout.value,
+                plan.expected_weight() as _,
+                plan.witness_version().is_some(),
+            )
+        })
+        .collect();
+
+    let mut outputs = vec![TxOut {
+        value,
+        script_pubkey: address.script_pubkey(),
+    }];
+
+    let internal_keychain = if graph.index.keychains().get(&Keychain::Internal).is_some() {
+        Keychain::Internal
+    } else {
+        Keychain::External
+    };
+
+    let ((change_index, change_script), change_additions) =
+        graph.index.next_unused_spk(&internal_keychain);
+    additions.append(change_additions);
+
+    // Clone to drop the immutable reference.
+    let change_script = change_script.clone();
+
+    let change_plan = bdk_tmp_plan::plan_satisfaction(
+        &graph
+            .index
+            .keychains()
+            .get(&internal_keychain)
+            .expect("must exist")
+            .at_derivation_index(change_index),
+        &assets,
+    )
+    .expect("failed to obtain change plan");
+
+    let mut change_output = TxOut {
+        value: 0,
+        script_pubkey: change_script,
+    };
+
+    let cs_opts = CoinSelectorOpt {
+        target_feerate: 0.5,
+        min_drain_value: graph
+            .index
+            .keychains()
+            .get(&internal_keychain)
+            .expect("must exist")
+            .dust_value(),
+        ..CoinSelectorOpt::fund_outputs(
+            &outputs,
+            &change_output,
+            change_plan.expected_weight() as u32,
+        )
+    };
+
+    // TODO: How can we make it easy to shuffle in order of inputs and outputs here?
+    // apply coin selection by saying we need to fund these outputs
+    let mut coin_selector = CoinSelector::new(&wv_candidates, &cs_opts);
+
+    // just select coins in the order provided until we have enough
+    // only use the first result (least waste)
+    let selection = match cs_algorithm {
+        CoinSelectionAlgo::BranchAndBound => {
+            coin_select_bnb(Duration::from_secs(10), coin_selector.clone())
+                .map_or_else(|| coin_selector.select_until_finished(), |cs| cs.finish())?
+        }
+        _ => coin_selector.select_until_finished()?,
+    };
+    let (_, selection_meta) = selection.best_strategy();
+
+    // get the selected utxos
+    let selected_txos = selection.apply_selection(&candidates).collect::<Vec<_>>();
+
+    if let Some(drain_value) = selection_meta.drain_value {
+        change_output.value = drain_value;
+        // if the selection tells us to use change and the change value is sufficient, we add it as an output
+        outputs.push(change_output)
+    }
+
+    let mut transaction = Transaction {
+        version: 0x02,
+        lock_time: chain
+            .get_chain_tip()?
+            .and_then(|block_id| LockTime::from_height(block_id.height).ok())
+            .unwrap_or(LockTime::ZERO)
+            .into(),
+        input: selected_txos
+            .iter()
+            .map(|(_, utxo)| TxIn {
+                previous_output: utxo.outpoint,
+                sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
+                ..Default::default()
+            })
+            .collect(),
+        output: outputs,
+    };
+
+    let prevouts = selected_txos
+        .iter()
+        .map(|(_, utxo)| utxo.txout.clone())
+        .collect::<Vec<_>>();
+    let sighash_prevouts = Prevouts::All(&prevouts);
+
+    // first, set tx values for the plan so that we don't change them while signing
+    for (i, (plan, _)) in selected_txos.iter().enumerate() {
+        if let Some(sequence) = plan.required_sequence() {
+            transaction.input[i].sequence = sequence
+        }
+    }
+
+    // create a short lived transaction
+    let _sighash_tx = transaction.clone();
+    let mut sighash_cache = SighashCache::new(&_sighash_tx);
+
+    for (i, (plan, _)) in selected_txos.iter().enumerate() {
+        let requirements = plan.requirements();
+        let mut auth_data = bdk_tmp_plan::SatisfactionMaterial::default();
+        assert!(
+            !requirements.requires_hash_preimages(),
+            "can't have hash pre-images since we didn't provide any."
+        );
+        assert!(
+            requirements.signatures.sign_with_keymap(
+                i,
+                keymap,
+                &sighash_prevouts,
+                None,
+                None,
+                &mut sighash_cache,
+                &mut auth_data,
+                &Secp256k1::default(),
+            )?,
+            "we should have signed with this input."
+        );
+
+        match plan.try_complete(&auth_data) {
+            bdk_tmp_plan::PlanState::Complete {
+                final_script_sig,
+                final_script_witness,
+            } => {
+                if let Some(witness) = final_script_witness {
+                    transaction.input[i].witness = witness;
+                }
+
+                if let Some(script_sig) = final_script_sig {
+                    transaction.input[i].script_sig = script_sig;
+                }
+            }
+            bdk_tmp_plan::PlanState::Incomplete(_) => {
+                return Err(anyhow::anyhow!(
+                    "we weren't able to complete the plan with our keys."
+                ));
+            }
+        }
+    }
+
+    let change_info = if selection_meta.drain_value.is_some() {
+        Some((additions, (internal_keychain, change_index)))
+    } else {
+        None
+    };
+
+    Ok((transaction, change_info))
+}
+
+#[allow(clippy::type_complexity)]
+pub fn planned_utxos<A: Anchor, O: ChainOracle, K: Clone + bdk_tmp_plan::CanDerive>(
+    graph: &KeychainTxGraph<A>,
+    chain: &O,
+    assets: &bdk_tmp_plan::Assets<K>,
+) -> Result<Vec<(bdk_tmp_plan::Plan<K>, FullTxOut<ObservedAs<A>>)>, O::Error> {
+    let chain_tip = chain.get_chain_tip()?.unwrap_or_default();
+    let outpoints = graph.index.outpoints().iter().cloned();
+    graph
+        .graph()
+        .try_filter_chain_unspents(chain, chain_tip, outpoints)
+        .filter_map(
+            #[allow(clippy::type_complexity)]
+            |r| -> Option<Result<(bdk_tmp_plan::Plan<K>, FullTxOut<ObservedAs<A>>), _>> {
+                let (k, i, full_txo) = match r {
+                    Err(err) => return Some(Err(err)),
+                    Ok(((k, i), full_txo)) => (k, i, full_txo),
+                };
+                let desc = graph
+                    .index
+                    .keychains()
+                    .get(&k)
+                    .expect("keychain must exist")
+                    .at_derivation_index(i);
+                let plan = bdk_tmp_plan::plan_satisfaction(&desc, assets)?;
+                Some(Ok((plan, full_txo)))
+            },
+        )
+        .collect()
+}
+
+pub fn handle_commands<C: clap::Subcommand, A: Anchor, O: ChainOracle, X>(
+    graph: &Mutex<KeychainTxGraph<A>>,
+    db: &Mutex<Database<A, X>>,
+    chain: &O,
+    keymap: &HashMap<DescriptorPublicKey, DescriptorSecretKey>,
+    network: Network,
+    broadcast: impl FnOnce(&Transaction) -> anyhow::Result<()>,
+    cmd: Commands<C>,
+) -> anyhow::Result<()>
+where
+    O::Error: std::error::Error + Send + Sync + 'static,
+    ChangeSet<A, X>: Default + Append + DeserializeOwned + Serialize,
+{
+    match cmd {
+        Commands::ChainSpecific(_) => unreachable!("example code should handle this!"),
+        Commands::Address { addr_cmd } => {
+            let graph = &mut *graph.lock().unwrap();
+            run_address_cmd(graph, db, network, addr_cmd)
+        }
+        Commands::Balance => {
+            let graph = &*graph.lock().unwrap();
+            run_balance_cmd(graph, chain).map_err(anyhow::Error::from)
+        }
+        Commands::TxOut { txout_cmd } => {
+            let graph = &*graph.lock().unwrap();
+            run_txo_cmd(graph, chain, network, txout_cmd)
+        }
+        Commands::Send {
+            value,
+            address,
+            coin_select,
+        } => run_send_cmd(
+            graph,
+            db,
+            chain,
+            keymap,
+            coin_select,
+            address,
+            value,
+            broadcast,
+        ),
+    }
+}
+
+pub fn prepare_index<C: clap::Subcommand, SC: secp256k1::Signing>(
+    args: &Args<C>,
+    secp: &Secp256k1<SC>,
+) -> anyhow::Result<(KeychainTxOutIndex<Keychain>, KeyMap)> {
+    let mut index = KeychainTxOutIndex::<Keychain>::default();
+
+    let (descriptor, mut keymap) =
+        Descriptor::<DescriptorPublicKey>::parse_descriptor(secp, &args.descriptor)?;
+    index.add_keychain(Keychain::External, descriptor);
+
+    if let Some((internal_descriptor, internal_keymap)) = args
+        .change_descriptor
+        .as_ref()
+        .map(|desc_str| Descriptor::<DescriptorPublicKey>::parse_descriptor(secp, desc_str))
+        .transpose()?
+    {
+        keymap.extend(internal_keymap);
+        index.add_keychain(Keychain::Internal, internal_descriptor);
+    }
+
+    Ok((index, keymap))
+}
+
+#[allow(clippy::type_complexity)]
+pub fn init<'m, S: clap::Subcommand, A: Anchor, X>(
+    db_magic: &'m [u8],
+    db_default_path: &str,
+) -> anyhow::Result<(
+    Args<S>,
+    KeyMap,
+    Mutex<KeychainTxGraph<A>>,
+    Mutex<Database<'m, A, X>>,
+    X,
+)>
+where
+    ChangeSet<A, X>: Default + Append + Serialize + DeserializeOwned,
+{
+    if std::env::var("BDK_DB_PATH").is_err() {
+        std::env::set_var("BDK_DB_PATH", db_default_path);
+    }
+    let args = Args::<S>::parse();
+    let secp = Secp256k1::default();
+    let (index, keymap) = prepare_index(&args, &secp)?;
+
+    let mut indexed_graph = IndexedTxGraph::<A, KeychainTxOutIndex<Keychain>>::new(index);
+
+    let mut db_backend =
+        match Store::<'m, ChangeSet<A, X>>::new_from_path(db_magic, args.db_path.as_path()) {
+            Ok(db_backend) => db_backend,
+            Err(err) => return Err(anyhow::anyhow!("failed to init db backend: {:?}", err)),
+        };
+
+    let ChangeSet {
+        indexed_additions,
+        extension,
+    } = db_backend.load_from_persistence()?;
+    indexed_graph.apply_additions(indexed_additions);
+
+    Ok((
+        args,
+        keymap,
+        Mutex::new(indexed_graph),
+        Mutex::new(Database::new(db_backend)),
+        extension,
+    ))
+}