Repository: wizardsardine/liana
Pull request #985 (2024-02-27 12:46:51)

Taproot support
This introduces Taproot support in the Liana daemon / core library.

We start by introducing support for `tr()` descriptors alongside `wsh()` descriptors in the Taproot modules. For Taproot-Liana descriptors whose primary spending path isn't a single key, we deterministically derive an unspendable internal key as per https://delvingbitcoin.org/t/unspendable-keys-in-descriptors/304/21. This is to allow signing devices to not display the internal key as a spendable key when verifying a descriptor. Currently signing device vendors signaled willingness to implement this scheme.

We then adapt the PSBT management logic to use Taproot fields when necessary and upgrade the hot signer to provide Schnorr signatures depending on the PSBT information.

Finally, the functional tests are adapted to be able to run the whole test suite under either P2WSH or Taproot. This is done in a somewhat hacky way as i bailed out of re-implementing a Taproot PSBT signer and finalizer in Python after implementing [TapMiniscript support in upstream python-bip380](https://github.com/darosior/python-bip380/pull/23). Instead we use a small Rust program to sign PSBTs for now and we skip a test which explicitly requires an external finalizer.

darosior: I still need to:
- Cleanup the bugfixes to https://github.com/darosior/python-bip380/pull/23 i've got locally and release a new version to update it in our functional tests;
- Merge some preparatory PRs.

I should open a few issues for needed followups before i forget about it.
darosior: Alright, i updated `python-bip380` and the CI to run the Taproot tests. Looks like one of them is a bit flaky, i'll have a look later. In the meantime it's ready for a first round of review!
darosior: Rebased on master. I adapted the tests for the selection of unconfirmed coins from #873 in a separate commit, as it was less mechanic / obvious than the other tests.

What i still need to investigate:
- `test_coin_selection` is very slightly flaky. The assertion of the vsize of the ancestor might sometimes fail. This is unrelated to this PR, but can potentially make CI fail here;
- it seems the performance of `createspend` has been greatly reduced. We might see some timeout on functional tests making heavy use of this call. I need to profile it i suppose. But my hunch is rust-miniscript's way of populating a PSBT input is inefficient.

Patch:
diff --git a/.cirrus.yml b/.cirrus.yml
index 4c420593..8bfbfa55 100644
--- a/.cirrus.yml
+++ b/.cirrus.yml
@@ -16,12 +16,19 @@ task:
         matrix:
           - USE_MIN_BITCOIN_VERSION: 'TRUE'
           - USE_MIN_BITCOIN_VERSION: 'FALSE'
+          - USE_TAPROOT: 1
     - name: 'RPC functional tests'
       env:
         TEST_GROUP: tests/test_rpc.py
+        matrix:
+          - USE_TAPROOT: 0
+          - USE_TAPROOT: 1
     - name: 'Chain functional tests'
       env:
         TEST_GROUP: tests/test_chain.py
+        matrix:
+          - USE_TAPROOT: 0
+          - USE_TAPROOT: 1
 
   cargo_registry_cache:
     folders: $CARGO_HOME/registry
@@ -34,7 +41,12 @@ task:
     fingerprint_script:
       - rustc --version
       - cat Cargo.lock
-  lianad_build_script: cargo build --release
+  tests_tools_cache:
+    folder: tests/tools/taproot_signer/target
+    fingerprint_script:
+      - rustc --version
+      - cat tests/tools/taproot_signer/Cargo.lock
+  lianad_build_script: cargo build --release && cd tests/tools/taproot_signer && cargo build --release
 
   deps_script: apt update && apt install -y python3 python3-pip
 
diff --git a/fuzz/fuzz_targets/descriptor_parse.rs b/fuzz/fuzz_targets/descriptor_parse.rs
index 9d7563a5..8a3b095d 100644
--- a/fuzz/fuzz_targets/descriptor_parse.rs
+++ b/fuzz/fuzz_targets/descriptor_parse.rs
@@ -67,10 +67,12 @@ fuzz_target!(|data: &[u8]| {
         desc.receive_descriptor().derive(der_index, &SECP256K1),
         desc.change_descriptor().derive(der_index, &SECP256K1),
     ];
+    let mut psbt_in = Default::default();
+    let mut psbt_out = Default::default();
     for desc in der_descs {
         desc.address(Network::Bitcoin);
         desc.script_pubkey();
-        desc.witness_script();
-        desc.bip32_derivations();
+        desc.update_psbt_in(&mut psbt_in);
+        desc.update_change_psbt_out(&mut psbt_out);
     }
 });
diff --git a/fuzz/fuzz_targets/descriptors.rs b/fuzz/fuzz_targets/descriptors.rs
index 61eaeca2..b358d2d7 100644
--- a/fuzz/fuzz_targets/descriptors.rs
+++ b/fuzz/fuzz_targets/descriptors.rs
@@ -101,6 +101,7 @@ struct Config {
     pub recovery_paths: Vec<RecPathConfig>,
     pub der_index: u16,
     pub dummy_psbt: DummyPsbt,
+    pub use_taproot: bool,
 }
 
 fuzz_target!(|config: Config| {
@@ -126,7 +127,12 @@ fuzz_target!(|config: Config| {
     } else {
         return;
     };
-    let policy = if let Ok(policy) = LianaPolicy::new(prim_path_info, rec_paths_info) {
+    let policy = if config.use_taproot {
+        LianaPolicy::new(prim_path_info, rec_paths_info)
+    } else {
+        LianaPolicy::new_legacy(prim_path_info, rec_paths_info)
+    };
+    let policy = if let Ok(policy) = policy {
         policy
     } else {
         return;
@@ -154,11 +160,13 @@ fuzz_target!(|config: Config| {
         desc.receive_descriptor().derive(der_index, &SECP256K1),
         desc.change_descriptor().derive(der_index, &SECP256K1),
     ];
+    let mut psbt_in = Default::default();
+    let mut psbt_out = Default::default();
     for desc in &der_descs {
         desc.address(Network::Bitcoin);
         desc.script_pubkey();
-        desc.witness_script();
-        desc.bip32_derivations();
+        desc.update_psbt_in(&mut psbt_in);
+        desc.update_change_psbt_out(&mut psbt_out);
     }
 
     // Exercise the methods gathering information from a PSBT. TODO: get more useful PSBTs.
@@ -172,12 +180,11 @@ fuzz_target!(|config: Config| {
         // for outputs.
         let rec_desc = &der_descs[0];
         for psbt_in in psbt.inputs.iter_mut() {
-            psbt_in.witness_script = Some(rec_desc.witness_script());
-            psbt_in.bip32_derivation = rec_desc.bip32_derivations();
+            rec_desc.update_psbt_in(psbt_in);
         }
         let change_desc = &der_descs[1];
         for psbt_out in psbt.outputs.iter_mut() {
-            psbt_out.bip32_derivation = change_desc.bip32_derivations();
+            change_desc.update_change_psbt_out(psbt_out);
         }
 
         // Now get the spend info again with these info.
diff --git a/gui/src/app/error.rs b/gui/src/app/error.rs
index b13f8ae7..0ddbf896 100644
--- a/gui/src/app/error.rs
+++ b/gui/src/app/error.rs
@@ -51,7 +51,7 @@ impl std::fmt::Display for Error {
                 DaemonError::CoinSelectionError => write!(f, "{}", e),
             },
             Self::Unexpected(e) => write!(f, "Unexpected error: {}", e),
-            Self::HardwareWallet(e) => write!(f, "error: {}\nPlease check if the device is still connected and unlocked with the correct firmware open for the current network and no other application is accessing the device.", e),
+            Self::HardwareWallet(e) => write!(f, "{}", e),
             Self::Desc(e) => write!(f, "Liana descriptor error: {}", e),
         }
     }
diff --git a/gui/src/app/view/psbts.rs b/gui/src/app/view/psbts.rs
index 74f9dd74..66252f9c 100644
--- a/gui/src/app/view/psbts.rs
+++ b/gui/src/app/view/psbts.rs
@@ -133,17 +133,17 @@ fn spend_tx_list_view(i: usize, tx: &SpendTx) -> Element<'_, Message> {
                         .align_items(Alignment::Center)
                         .width(Length::Fill),
                 )
+                .push_maybe(match tx.status {
+                    SpendStatus::Deprecated => Some(badge::deprecated()),
+                    SpendStatus::Broadcast => Some(badge::unconfirmed()),
+                    SpendStatus::Spent => Some(badge::spent()),
+                    _ => None,
+                })
                 .push_maybe(if tx.is_batch() {
                     Some(badge::batch())
                 } else {
                     None
                 })
-                .push_maybe(match tx.status {
-                    SpendStatus::Deprecated => Some(badge::deprecated().width(120.0)),
-                    SpendStatus::Broadcast => Some(badge::unconfirmed().width(120.0)),
-                    SpendStatus::Spent => Some(badge::spent().width(120.0)),
-                    _ => None,
-                })
                 .push(
                     Column::new()
                         .align_items(Alignment::End)
@@ -153,7 +153,7 @@ fn spend_tx_list_view(i: usize, tx: &SpendTx) -> Element<'_, Message> {
                             Container::new(p1_regular("Self-transfer"))
                         })
                         .push_maybe(tx.fee_amount.map(|fee| amount_with_size(&fee, P2_SIZE)))
-                        .width(Length::Fixed(140.0)),
+                        .width(Length::Shrink),
                 )
                 .align_items(Alignment::Center)
                 .spacing(20),
diff --git a/gui/ui/src/component/badge.rs b/gui/ui/src/component/badge.rs
index 502489a7..e64f70f1 100644
--- a/gui/ui/src/component/badge.rs
+++ b/gui/ui/src/component/badge.rs
@@ -75,45 +75,66 @@ pub fn coin<T>() -> Container<'static, T> {
 }
 
 pub fn recovery<'a, T: 'a>() -> Container<'a, T> {
-    badge_pill("  Recovery  ", "This transaction is using a recovery path")
+    Container::new(
+        tooltip::Tooltip::new(
+            Container::new(text::p2_regular("  Recovery  "))
+                .padding(10)
+                .style(theme::Container::Pill(theme::Pill::Simple)),
+            "This transaction is using a recovery path",
+            tooltip::Position::Top,
+        )
+        .style(theme::Container::Card(theme::Card::Simple)),
+    )
 }
 
 pub fn unconfirmed<'a, T: 'a>() -> Container<'a, T> {
-    badge_pill(
-        "  Unconfirmed  ",
-        "Do not treat this as a payment until it is confirmed",
+    Container::new(
+        tooltip::Tooltip::new(
+            Container::new(text::p2_regular("  Unconfirmed  "))
+                .padding(10)
+                .style(theme::Container::Pill(theme::Pill::Simple)),
+            "Do not treat this as a payment until it is confirmed",
+            tooltip::Position::Top,
+        )
+        .style(theme::Container::Card(theme::Card::Simple)),
     )
 }
 
 pub fn batch<'a, T: 'a>() -> Container<'a, T> {
-    badge_pill("  Batch  ", "This transaction contains multiple payments")
+    Container::new(
+        tooltip::Tooltip::new(
+            Container::new(text::p2_regular("  Batch  "))
+                .padding(10)
+                .style(theme::Container::Pill(theme::Pill::Simple)),
+            "This transaction contains multiple payments",
+            tooltip::Position::Top,
+        )
+        .style(theme::Container::Card(theme::Card::Simple)),
+    )
 }
 
 pub fn deprecated<'a, T: 'a>() -> Container<'a, T> {
-    badge_pill(
-        "  Deprecated  ",
-        "This transaction cannot be included in the blockchain anymore.",
+    Container::new(
+        tooltip::Tooltip::new(
+            Container::new(text::p2_regular("  Deprecated  "))
+                .padding(10)
+                .style(theme::Container::Pill(theme::Pill::Simple)),
+            "This transaction cannot be included in the blockchain anymore.",
+            tooltip::Position::Top,
+        )
+        .style(theme::Container::Card(theme::Card::Simple)),
     )
 }
 
 pub fn spent<'a, T: 'a>() -> Container<'a, T> {
-    badge_pill(
-        "  Spent  ",
-        "The transaction was included in the blockchain.",
-    )
-}
-
-pub fn badge_pill<'a, T: 'a>(label: &'a str, tooltip: &'a str) -> Container<'a, T> {
-    Container::new({
+    Container::new(
         tooltip::Tooltip::new(
-            Container::new(text::p2_regular(label))
+            Container::new(text::p2_regular("  Spent  "))
                 .padding(10)
-                .width(Length::Fill)
-                .center_x()
                 .style(theme::Container::Pill(theme::Pill::Simple)),
-            tooltip,
+            "The transaction was included in the blockchain.",
             tooltip::Position::Top,
         )
-        .style(theme::Container::Card(theme::Card::Simple))
-    })
+        .style(theme::Container::Card(theme::Card::Simple)),
+    )
 }
diff --git a/src/bitcoin/d/mod.rs b/src/bitcoin/d/mod.rs
index faaaf371..41e25216 100644
--- a/src/bitcoin/d/mod.rs
+++ b/src/bitcoin/d/mod.rs
@@ -45,6 +45,9 @@ const BITCOIND_RETRY_LIMIT: usize = 60;
 // The minimum bitcoind version that can be used with lianad.
 const MIN_BITCOIND_VERSION: u64 = 240000;
 
+// The minimum bitcoind version that can be used with lianad and a Taproot descriptor.
+const MIN_TAPROOT_BITCOIND_VERSION: u64 = 260000;
+
 /// An error in the bitcoind interface.
 #[derive(Debug)]
 pub enum BitcoindError {
@@ -129,8 +132,8 @@ impl std::fmt::Display for BitcoindError {
             BitcoindError::InvalidVersion(v) => {
                 write!(
                     f,
-                    "Invalid bitcoind version '{}', minimum supported is '{}'.",
-                    v, MIN_BITCOIND_VERSION
+                    "Invalid bitcoind version '{}', minimum supported is '{}' and minimum supported if using Taproot is '{}'.",
+                    v, MIN_BITCOIND_VERSION, MIN_TAPROOT_BITCOIND_VERSION
                 )
             }
             BitcoindError::NetworkMismatch(conf_net, bitcoind_net) => {
@@ -699,12 +702,16 @@ impl BitcoinD {
     pub fn node_sanity_checks(
         &self,
         config_network: bitcoin::Network,
+        is_taproot: bool,
     ) -> Result<(), BitcoindError> {
         // Check the minimum supported bitcoind version
         let version = self.get_bitcoind_version();
         if version < MIN_BITCOIND_VERSION {
             return Err(BitcoindError::InvalidVersion(version));
         }
+        if is_taproot && version < MIN_TAPROOT_BITCOIND_VERSION {
+            return Err(BitcoindError::InvalidVersion(version));
+        }
 
         // Check bitcoind is running on the right network
         let bitcoind_net = self.get_network_bip70();
@@ -747,13 +754,27 @@ impl BitcoinD {
         // Check our main descriptor is imported in this wallet.
         let receive_desc = main_descriptor.receive_descriptor();
         let change_desc = main_descriptor.change_descriptor();
-        let desc_list: Vec<String> = self
+        let desc_list: Vec<_> = self
             .list_descriptors()
             .into_iter()
-            .map(|entry| entry.desc)
+            .filter_map(|entry| {
+                match descriptor::Descriptor::<descriptor::DescriptorPublicKey>::from_str(
+                    &entry.desc,
+                ) {
+                    Ok(desc) => Some(desc),
+                    Err(e) => {
+                        log::error!(
+                            "Error deserializing descriptor: {}. Descriptor: {}.",
+                            e,
+                            entry.desc
+                        );
+                        None
+                    }
+                }
+            })
             .collect();
-        if !desc_list.contains(&receive_desc.to_string())
-            || !desc_list.contains(&change_desc.to_string())
+        if !desc_list.iter().any(|desc| *receive_desc == *desc)
+            || !desc_list.iter().any(|desc| *change_desc == *desc)
         {
             return Err(BitcoindError::Wallet(
                 self.watchonly_wallet_path.clone(),
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
index 9a4bf835..6cb372d7 100644
--- a/src/commands/mod.rs
+++ b/src/commands/mod.rs
@@ -591,6 +591,12 @@ impl DaemonControl {
                 psbtin
                     .partial_sigs
                     .extend(db_psbtin.partial_sigs.clone().into_iter());
+                psbtin
+                    .tap_script_sigs
+                    .extend(db_psbtin.tap_script_sigs.clone().into_iter());
+                if psbtin.tap_key_sig.is_none() {
+                    psbtin.tap_key_sig = db_psbtin.tap_key_sig;
+                }
             }
         } else {
             // If the transaction doesn't exist in DB already, sanity check its inputs.
diff --git a/src/config.rs b/src/config.rs
index f73cda88..08a98923 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -14,7 +14,7 @@ where
 {
     let string = String::deserialize(deserializer)?;
     T::from_str(&string)
-        .map_err(|e| de::Error::custom(format!("Error parsing descriptor '{}': '{}'", string, e)))
+        .map_err(|e| de::Error::custom(format!("Error parsing '{}': {}", string, e)))
 }
 
 pub fn serialize_to_string<T: std::fmt::Display, S: Serializer>(
@@ -319,6 +319,26 @@ mod tests {
         #[cfg(unix)] // On non-UNIX there is no 'daemon' member.
         assert_eq!(toml_str, serialized);
 
+        // A valid, round-tripping, config for a Taproot descriptor.
+        let toml_str = r#"
+            data_dir = '/home/wizardsardine/custom/folder/'
+            daemon = false
+            log_level = 'TRACE'
+            main_descriptor = 'tr([abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,and_v(v:pk([abcdef01]xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*),older(52560)))#0mt7e93c'
+
+            [bitcoin_config]
+            network = 'bitcoin'
+            poll_interval_secs = 18
+
+            [bitcoind_config]
+            cookie_path = '/home/user/.bitcoin/.cookie'
+            addr = '127.0.0.1:8332'
+            "#.trim_start().replace("            ", "");
+        let parsed = toml::from_str::<Config>(&toml_str).expect("Deserializing toml_str");
+        let serialized = toml::to_string_pretty(&parsed).expect("Serializing to toml");
+        #[cfg(unix)] // On non-UNIX there is no 'daemon' member.
+        assert_eq!(toml_str, serialized);
+
         // A valid, round-tripping, config with `auth` instead of `cookie_path`
         let toml_str = r#"
             data_dir = '/home/wizardsardine/custom/folder/'
diff --git a/src/descriptors/analysis.rs b/src/descriptors/analysis.rs
index dbedc421..2420672f 100644
--- a/src/descriptors/analysis.rs
+++ b/src/descriptors/analysis.rs
@@ -1,7 +1,11 @@
 use miniscript::{
-    bitcoin::{bip32, Sequence},
+    bitcoin::{
+        self, bip32,
+        hashes::{sha256, Hash},
+        secp256k1, Sequence,
+    },
     descriptor,
-    policy::{compiler, Concrete as ConcretePolicy, Liftable, Semantic as SemanticPolicy},
+    policy::{Concrete as ConcretePolicy, Liftable, Semantic as SemanticPolicy},
     ScriptContext,
 };
 
@@ -9,6 +13,7 @@ use std::{
     collections::{BTreeMap, HashMap, HashSet},
     convert::TryFrom,
     error, fmt,
+    str::FromStr,
 };
 
 #[derive(Debug)]
@@ -22,10 +27,10 @@ pub enum LianaPolicyError {
     InvalidMultiThresh(usize),
     InvalidMultiKeys(usize),
     IncompatibleDesc,
+    PolicyAnalysis(miniscript::Error),
     /// The spending policy is not a valid Miniscript policy: it may for instance be malleable, or
     /// overflow some limit.
-    InvalidPolicy(compiler::CompilerError),
-    PolicyAnalysis(miniscript::Error),
+    InvalidPolicy(miniscript::Error),
 }
 
 impl std::fmt::Display for LianaPolicyError {
@@ -357,6 +362,91 @@ impl PathInfo {
     }
 }
 
+// See
+// https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs:
+// > One example of such a point is H =
+// > lift_x(0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0) which is constructed
+// > by taking the hash of the standard uncompressed encoding of the secp256k1 base point G as X
+// > coordinate.
+fn bip341_nums() -> secp256k1::PublicKey {
+    secp256k1::PublicKey::from_str(
+        "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0",
+    )
+    .expect("Valid pubkey: NUMS from BIP341")
+}
+
+// Given a descpubkey, extract its xpub assuming it is a multixpub. Returns None otherwise.
+fn get_multi_xkey(desc_key: &descriptor::DescriptorPublicKey) -> Option<&bip32::Xpub> {
+    if let descriptor::DescriptorPublicKey::MultiXPub(descriptor::DescriptorMultiXKey {
+        xkey,
+        ..
+    }) = desc_key
+    {
+        Some(xkey)
+    } else {
+        None
+    }
+}
+
+// Construct an unspendable xpub to be used as internal key in a Taproot descriptor, in a way which
+// could eventually be standardized into wallet policies for a signer to display to the user
+// "UNSPENDABLE" upon registration (instead of a meaningless key).
+// See https://delvingbitcoin.org/t/unspendable-keys-in-descriptors/304/21.
+//
+// Returns `None` if:
+// - The given descriptor does not contain a Taptree with at least a key in each leaf.
+// - The keys contained in the descriptor aren't all MultiXPub's.
+fn unspendable_internal_xpub(
+    desc: &descriptor::Tr<descriptor::DescriptorPublicKey>,
+) -> Option<bip32::Xpub> {
+    let tap_tree = desc.tap_tree().as_ref()?;
+
+    // Fetch the network to use for the unspendable key from the first key in the descriptor.
+    let first_key = tap_tree.iter().flat_map(|(_, ms)| ms.iter_pk()).next()?;
+    let network = get_multi_xkey(&first_key)?.network;
+
+    // Compute the chaincode to use for the xpub. This is the sha256() of the concatenation of all
+    // the xpubs' pubkey part in the Taptree.
+    let concat =
+        tap_tree
+            .iter()
+            .flat_map(|(_, ms)| ms.iter_pk())
+            .try_fold(Vec::new(), |mut acc, pk| {
+                let xkey = get_multi_xkey(&pk)?;
+                acc.extend_from_slice(&xkey.public_key.serialize());
+                Some(acc)
+            })?;
+    let chain_code = bip32::ChainCode::from(sha256::Hash::hash(&concat).as_ref());
+
+    // Construct the unspendable key. The pubkey part is always BIP341's NUMS.
+    let public_key = bip341_nums();
+    Some(bip32::Xpub {
+        public_key,
+        chain_code,
+        depth: 0,
+        parent_fingerprint: [0; 4].into(),
+        child_number: 0.into(),
+        network,
+    })
+}
+
+fn unspendable_internal_key(
+    desc: &descriptor::Tr<descriptor::DescriptorPublicKey>,
+) -> Option<descriptor::DescriptorPublicKey> {
+    Some(descriptor::DescriptorPublicKey::MultiXPub(
+        descriptor::DescriptorMultiXKey {
+            origin: None,
+            xkey: unspendable_internal_xpub(desc)?,
+            derivation_paths: descriptor::DerivPaths::new(vec![
+                [0.into()][..].into(),
+                [1.into()][..].into(),
+            ])
+            .expect("Non empty vec"),
+            wildcard: descriptor::Wildcard::Unhardened,
+        },
+    ))
+}
+
 /// A Liana spending policy is one composed of at least two spending paths:
 ///     - A directly available path with any number of keys checks; or
 ///     - One or more recovery paths with any number of keys checks, behind increasing relative
@@ -370,13 +460,15 @@ impl PathInfo {
 pub struct LianaPolicy {
     pub(super) primary_path: PathInfo,
     pub(super) recovery_paths: BTreeMap<u16, PathInfo>,
+    is_taproot: bool,
 }
 
 impl LianaPolicy {
     /// Create a new Liana policy from a given configuration.
-    pub fn new(
+    fn _new(
         primary_path: PathInfo,
         recovery_paths: BTreeMap<u16, PathInfo>,
+        is_taproot: bool,
     ) -> Result<LianaPolicy, LianaPolicyError> {
         if recovery_paths.is_empty() {
             return Err(LianaPolicyError::MissingRecoveryPath);
@@ -430,32 +522,73 @@ impl LianaPolicy {
         let policy = LianaPolicy {
             primary_path,
             recovery_paths,
+            is_taproot,
         };
-        policy.clone().into_miniscript()?;
+        policy.clone().into_multipath_descriptor_fallible()?;
         Ok(policy)
     }
 
+    /// Create a new Liana policy for use under a Taproot context.
+    pub fn new(
+        primary_path: PathInfo,
+        recovery_paths: BTreeMap<u16, PathInfo>,
+    ) -> Result<LianaPolicy, LianaPolicyError> {
+        Self::_new(primary_path, recovery_paths, /* is_taproot = */ true)
+    }
+
+    /// Create a new Liana policy for use under a P2WSH context.
+    pub fn new_legacy(
+        primary_path: PathInfo,
+        recovery_paths: BTreeMap<u16, PathInfo>,
+    ) -> Result<LianaPolicy, LianaPolicyError> {
+        Self::_new(primary_path, recovery_paths, /* is_taproot = */ false)
+    }
+
     /// Create a Liana policy from a descriptor. This will check the descriptor is correctly formed
     /// (P2WSH, multipath, ..) and has a valid Liana semantic.
     pub fn from_multipath_descriptor(
         desc: &descriptor::Descriptor<descriptor::DescriptorPublicKey>,
     ) -> Result<LianaPolicy, LianaPolicyError> {
-        // For now we only allow P2WSH descriptors.
-        let wsh_desc = match &desc {
-            descriptor::Descriptor::Wsh(desc) => desc,
-            _ => return Err(LianaPolicyError::IncompatibleDesc),
-        };
-
         // Lift a semantic policy out of this Miniscript and normalize it to make sure we compare
         // apples to apples below.
-        let ms = match wsh_desc.as_inner() {
-            descriptor::WshInner::Ms(ms) => ms,
+        let policy = match desc {
+            descriptor::Descriptor::Wsh(wsh_desc) => {
+                let ms = match wsh_desc.as_inner() {
+                    descriptor::WshInner::Ms(ms) => ms,
+                    _ => return Err(LianaPolicyError::IncompatibleDesc),
+                };
+                ms.lift().map_err(LianaPolicyError::PolicyAnalysis)?
+            }
+            descriptor::Descriptor::Tr(desc) => {
+                // For Taproot, make sure to not take the internal key into account in the semantic
+                // policy if it's unspendable.
+                if let Some(tree) = desc.tap_tree() {
+                    let tree_policy = tree.lift().map_err(LianaPolicyError::PolicyAnalysis)?;
+                    let unspend_int_xpub = unspendable_internal_xpub(desc)
+                        .ok_or(LianaPolicyError::IncompatibleDesc)?;
+                    let desc_int_xpub = get_multi_xkey(desc.internal_key())
+                        .ok_or(LianaPolicyError::IncompatibleDesc)?;
+                    if *desc_int_xpub == unspend_int_xpub {
+                        tree_policy
+                    } else {
+                        SemanticPolicy::Threshold(
+                            1,
+                            vec![
+                                SemanticPolicy::Key(desc.internal_key().clone()),
+                                tree_policy,
+                            ],
+                        )
+                    }
+                } else {
+                    // A Liana descriptor must contain a timelocked path.
+                    return Err(LianaPolicyError::IncompatibleDesc);
+                }
+            }
+            // We only allow P2WSH and Taproot descriptors.
             _ => return Err(LianaPolicyError::IncompatibleDesc),
-        };
-        let policy = ms
-            .lift()
-            .map_err(LianaPolicyError::PolicyAnalysis)?
-            .normalized();
+        }
+        .normalized();
+        let is_taproot = matches!(desc, descriptor::Descriptor::Tr(..));
 
         // The policy must always be "1 of N spending paths" with at least an always-available
         // primary path with at least one key, and at least one timelocked recovery path with at
@@ -499,7 +632,7 @@ impl LianaPolicy {
         // Use the constructor for sanity checking the keys and the Miniscript policy. Note this
         // makes sure the recovery paths mapping isn't empty, too.
         let prim_path = primary_path.ok_or(LianaPolicyError::IncompatibleDesc)?;
-        LianaPolicy::new(prim_path, recovery_paths)
+        LianaPolicy::_new(prim_path, recovery_paths, is_taproot)
     }
 
     pub fn primary_path(&self) -> &PathInfo {
@@ -513,15 +646,11 @@ impl LianaPolicy {
         &self.recovery_paths
     }
 
-    fn into_miniscript(
-        self,
-    ) -> Result<
-        miniscript::Miniscript<descriptor::DescriptorPublicKey, miniscript::Segwitv0>,
-        LianaPolicyError,
-    > {
+    fn into_policy(self) -> miniscript::policy::Concrete<descriptor::DescriptorPublicKey> {
         let LianaPolicy {
             primary_path,
             recovery_paths,
+            ..
         } = self;
 
         // Start with the primary spending path. We'll then or() all the recovery paths to it.
@@ -529,20 +658,76 @@ impl LianaPolicy {
 
         // Incrementally create the top-level policy using all recovery paths.
         assert!(!recovery_paths.is_empty());
-        let tl_policy =
-            recovery_paths
-                .into_iter()
-                .fold(primary_keys, |tl_policy, (timelock, path_info)| {
-                    let timelock = ConcretePolicy::Older(Sequence::from_height(timelock));
-                    let keys = path_info.into_ms_policy();
-                    let recovery_branch = ConcretePolicy::And(vec![keys.into(), timelock.into()]);
-                    // We assume the larger the timelock the less likely a branch would be used.
-                    ConcretePolicy::Or(vec![(99, tl_policy.into()), (1, recovery_branch.into())])
-                });
+        recovery_paths
+            .into_iter()
+            .fold(primary_keys, |tl_policy, (timelock, path_info)| {
+                let timelock = ConcretePolicy::Older(Sequence::from_height(timelock));
+                let keys = path_info.into_ms_policy();
+                let recovery_branch = ConcretePolicy::And(vec![keys.into(), timelock.into()]);
+                // We assume the larger the timelock the less likely a branch would be used.
+                ConcretePolicy::Or(vec![(99, tl_policy.into()), (1, recovery_branch.into())])
+            })
+    }
 
-        tl_policy
-            .compile::<miniscript::Segwitv0>()
-            .map_err(LianaPolicyError::InvalidPolicy)
+    fn into_multipath_descriptor_fallible(
+        self,
+    ) -> Result<descriptor::Descriptor<descriptor::DescriptorPublicKey>, LianaPolicyError> {
+        if self.is_taproot {
+            // If compiling to a Taproot descriptor and we can't have an internal key, we want to
+            // compute a deterministic unspendable key to use as internal key. We compute it from
+            // the xpubs in the Taptree as per
+            // https://delvingbitcoin.org/t/unspendable-keys-in-descriptors/304/21. However, there
+            // is clearly an inter-dependency here: we need an internal key to get the Taptree, and
+            // vice-versa. So we use a dummy internal key. If it ends up as the internal key in the
+            // compiled descriptor, we replace it with a deterministically computed unspendable
+            // internal key.
+            let dummy_internal_key =
+                descriptor::DescriptorPublicKey::XPub(descriptor::DescriptorXKey::<bip32::Xpub> {
+                    origin: None,
+                    xkey: bip32::Xpub {
+                        public_key: bip341_nums(),
+                        chain_code: [0; 32].into(),
+                        depth: 0,
+                        parent_fingerprint: [0; 4].into(),
+                        child_number: 0.into(),
+                        network: bitcoin::Network::Regtest,
+                    },
+                    derivation_path: vec![].into(),
+                    wildcard: descriptor::Wildcard::None,
+                });
+            let policy = self.into_policy();
+            let desc = policy
+                .clone()
+                .compile_tr(Some(dummy_internal_key.clone()))
+                .map_err(LianaPolicyError::InvalidPolicy)?;
+            let inner_desc = if let descriptor::Descriptor::Tr(ref d) = desc {
+                d
+            } else {
+                unreachable!("compile_tr() always gives a tr() descriptor.");
+            };
+            if inner_desc.internal_key() == &dummy_internal_key {
+                // Unfortunately to replace the dummy internal key with the correct one we need to
+                // perform the computation again.
+                let actual_internal_key = unspendable_internal_key(inner_desc)
+                    .expect("Desc has a Taptree and only multixpubs.");
+                policy
+                    .compile_tr(Some(actual_internal_key))
+                    .map_err(LianaPolicyError::InvalidPolicy)
+            } else {
+                // A key from the policy could be used as internal key. No need for a deterministic
+                // internal key.
+                Ok(desc)
+            }
+        } else {
+            let ms = self
+                .into_policy()
+                .compile::<miniscript::Segwitv0>()
+                .map_err(|e| LianaPolicyError::InvalidPolicy(e.into()))?;
+            miniscript::Segwitv0::check_local_validity(&ms).expect("Miniscript must be sane");
+            Ok(descriptor::Descriptor::Wsh(
+                descriptor::Wsh::new(ms).expect("Must pass sanity checks"),
+            ))
+        }
     }
 
     /// Create a descriptor from this spending policy with multipath key expressions. Note this
@@ -552,11 +737,8 @@ impl LianaPolicy {
     pub fn into_multipath_descriptor(
         self,
     ) -> descriptor::Descriptor<descriptor::DescriptorPublicKey> {
-        let ms = self
-            .into_miniscript()
-            .expect("This is always checked when creating a LianaPolicy.");
-        miniscript::Segwitv0::check_local_validity(&ms).expect("Miniscript must be sane");
-        descriptor::Descriptor::Wsh(descriptor::Wsh::new(ms).expect("Must pass sanity checks"))
+        self.into_multipath_descriptor_fallible()
+            .expect("This is always checked when creating a LianaPolicy.")
     }
 }
 
diff --git a/src/descriptors/mod.rs b/src/descriptors/mod.rs
index 2d249b54..022faeef 100644
--- a/src/descriptors/mod.rs
+++ b/src/descriptors/mod.rs
@@ -2,13 +2,20 @@ use miniscript::{
     bitcoin::{
         self, bip32,
         constants::WITNESS_SCALE_FACTOR,
-        psbt::{Input as PsbtIn, Psbt},
+        psbt::{Input as PsbtIn, Output as PsbtOut, Psbt},
         secp256k1,
     },
-    descriptor, translate_hash_clone, ForEachKey, TranslatePk, Translator,
+    descriptor,
+    psbt::{PsbtInputExt, PsbtOutputExt},
+    translate_hash_clone, ForEachKey, TranslatePk, Translator,
 };
 
-use std::{collections::BTreeMap, convert::TryInto, error, fmt, str};
+use std::{
+    collections::{BTreeMap, HashMap, HashSet},
+    convert::TryInto,
+    error, fmt,
+    str::{self, FromStr},
+};
 
 use serde::{Deserialize, Serialize};
 
@@ -49,6 +56,29 @@ impl From<LianaPolicyError> for LianaDescError {
     }
 }
 
+// Whether the key identified by its fingerprint+derivation path was derived from one of the xpubs
+// for this spending path.
+fn key_is_for_path(
+    path_origins: &HashMap<bip32::Fingerprint, HashSet<bip32::DerivationPath>>,
+    fg: &bip32::Fingerprint,
+    der_path: &bip32::DerivationPath,
+) -> bool {
+    // Does it come from a signer used in this spending path?
+    if let Some(der_paths) = path_origins.get(fg) {
+        // Get the derivation path from the master fingerprint to the parent used to
+        // derive this key, in order to check whether it's part of the derivation paths
+        // used in this spending path (only checking the fingerprint isn't sufficient
+        // as a single signer may be used in more than one spending path).
+        // NOTE: this assumes there is only one derivation step after the key used in
+        // the policy. This is fine, because the keys in the policy are normalized (so
+        // the derivation path up to the wildcard is part of the origin).
+        if let Some((_, der_path_no_wildcard)) = der_path[..].split_last() {
+            return der_paths.contains(&der_path_no_wildcard.into());
+        }
+    }
+    false
+}
+
 /// An [SinglePathLianaDesc] that contains multipath keys for (and only for) the receive keychain
 /// and the change keychain.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -198,6 +228,8 @@ impl LianaDescriptor {
             .0
     }
 
+    // TODO: on Taproot we should use this for recovery but keyspend size if there is a spendable
+    // internal key.
     /// Get the maximum size difference of a transaction input spending a Script derived from this
     /// descriptor before and after satisfaction. The returned value is in weight units.
     /// Callers are expected to account for the Segwit marker (2 WU). This takes into account the
@@ -235,6 +267,11 @@ impl LianaDescriptor {
         32 + 4 + 4 + 1 + self.max_sat_vbytes()
     }
 
+    /// Whether this is a Taproot descriptor.
+    pub fn is_taproot(&self) -> bool {
+        matches!(self.multi_desc, descriptor::Descriptor::Tr(..))
+    }
+
     /// Get some information about a PSBT input spending Liana coins.
     /// This analysis assumes that:
     /// - The PSBT input actually spend a Liana coin for this descriptor. Otherwise the analysis will be off.
@@ -244,11 +281,38 @@ impl LianaDescriptor {
         psbt_in: &PsbtIn,
         txin: &bitcoin::TxIn,
     ) -> PartialSpendInfo {
-        // Get the identifier of all the keys that signed this transaction.
-        let pubkeys_signed = psbt_in
-            .partial_sigs
-            .iter()
-            .filter_map(|(pk, _)| psbt_in.bip32_derivation.get(&pk.inner));
+        let is_taproot = self.is_taproot();
+        // Get the origin ECDSA or Schnorr signatures, depending on the descriptor type.
+        let pubkeys_signed = (!is_taproot)
+            .then(|| {
+                // ECDSA sigs.
+                psbt_in
+                    .partial_sigs
+                    .iter()
+                    .filter_map(|(pk, _)| psbt_in.bip32_derivation.get(&pk.inner))
+            })
+            .into_iter()
+            .flatten()
+            .chain(
+                is_taproot
+                    .then(|| {
+                        // Tapscript Schnorr sigs.
+                        psbt_in
+                            .tap_script_sigs
+                            .iter()
+                            .filter_map(|((pk, _), _)| {
+                                psbt_in.tap_key_origins.get(pk).map(|or| &or.1)
+                            })
+                            // Tapkey Schnorr sig.
+                            .chain(psbt_in.tap_key_sig.and_then(|_| {
+                                psbt_in
+                                    .tap_internal_key
+                                    .and_then(|pk| psbt_in.tap_key_origins.get(&pk).map(|or| &or.1))
+                            }))
+                    })
+                    .into_iter()
+                    .flatten(),
+            );
 
         // Determine the structure of the descriptor. Then compute the spend info for the primary
         // and recovery paths. Only provide the spend info for the recovery path if it is available
@@ -378,24 +442,14 @@ impl LianaDescriptor {
         // Go through all the PSBT inputs and drop the BIP32 derivations for keys that are not from
         // this spending path.
         for psbt_in in psbt.inputs.iter_mut() {
-            psbt_in.bip32_derivation.retain(|_, (fg, der_path)| {
-                // Does it come from a signer used in this spending path?
-                if let Some(der_paths) = path_origins.get(fg) {
-                    // Get the derivation path from the master fingerprint to the parent used to
-                    // derive this key, in order to check whether it's part of the derivation paths
-                    // used in this spending path (only checking the fingerprint isn't sufficient
-                    // as a single signer may be used in more than one spending path).
-                    // NOTE: this assumes there is only one derivation step after the key used in
-                    // the policy. This is fine, because the keys in the policy are normalized (so
-                    // the derivation path up to the wildcard is part of the origin).
-                    if let Some((_, der_path_no_wildcard)) = der_path[..].split_last() {
-                        if der_paths.contains(&der_path_no_wildcard.into()) {
-                            return true;
-                        }
-                    }
-                }
-                false
-            });
+            // Perform it for both legacy and Taproot origins, as if one is set the other should be
+            // empty so it's a noop.
+            psbt_in
+                .bip32_derivation
+                .retain(|_, (fg, der_path)| key_is_for_path(&path_origins, fg, der_path));
+            psbt_in
+                .tap_key_origins
+                .retain(|_, (_, (fg, der_path))| key_is_for_path(&path_origins, fg, der_path));
         }
 
         psbt
@@ -505,6 +559,11 @@ impl SinglePathLianaDesc {
     }
 }
 
+pub enum DescKeysOrigins {
+    Wsh(BTreeMap<secp256k1::PublicKey, (bip32::Fingerprint, bip32::DerivationPath)>),
+    Tr(BTreeMap<secp256k1::PublicKey, (bip32::Fingerprint, bip32::DerivationPath)>),
+}
+
 /// Map of a raw public key to the xpub used to derive it and its derivation path
 pub type Bip32Deriv = BTreeMap<secp256k1::PublicKey, (bip32::Fingerprint, bip32::DerivationPath)>;
 
@@ -519,11 +578,13 @@ impl DerivedSinglePathLianaDesc {
         self.0.script_pubkey()
     }
 
-    pub fn witness_script(&self) -> bitcoin::ScriptBuf {
+    // NB: panics if called for a Taproot descriptor.
+    fn witness_script(&self) -> bitcoin::ScriptBuf {
         self.0.explicit_script().expect("Not a Taproot descriptor")
     }
 
-    pub fn bip32_derivations(&self) -> Bip32Deriv {
+    // NB: panics if called for a Taproot descriptor.
+    fn bip32_derivations(&self) -> Bip32Deriv {
         let ms = match self.0 {
             descriptor::Descriptor::Wsh(ref wsh) => match wsh.as_inner() {
                 descriptor::WshInner::Ms(ms) => ms,
@@ -531,7 +592,7 @@ impl DerivedSinglePathLianaDesc {
                     unreachable!("None of our descriptors is a sorted multi")
                 }
             },
-            _ => unreachable!("All our descriptors are always P2WSH"),
+            _ => unreachable!("Must never be called for a Taproot descriptor."),
         };
 
         // For DerivedPublicKey, Pk::Hash == Self.
@@ -539,15 +600,54 @@ impl DerivedSinglePathLianaDesc {
             .map(|k| (k.key.inner, (k.origin.0, k.origin.1)))
             .collect()
     }
+
+    // FIXME: update_with_descriptor() needs a Descriptor<DefiniteKey>. This is a temporary hack to
+    // avoid having to duplicate the cumbersome logic here. Could use translate_pk() instead in the
+    // future.
+    fn definite_desc(&self) -> descriptor::Descriptor<descriptor::DefiniteDescriptorKey> {
+        descriptor::Descriptor::<_>::from_str(&self.0.to_string()).expect("Must roundtrip")
+    }
+
+    /// Update the PSBT input information with data from this derived descriptor.
+    pub fn update_psbt_in(&self, psbtin: &mut PsbtIn) {
+        match self.0 {
+            descriptor::Descriptor::Wsh(_) => {
+                psbtin.bip32_derivation = self.bip32_derivations();
+                psbtin.witness_script = Some(self.witness_script());
+            }
+            descriptor::Descriptor::Tr(_) => {
+                let desc = self.definite_desc();
+                if let Err(e) = psbtin.update_with_descriptor_unchecked(&desc) {
+                    log::error!("BUG! Please report this! Error when adding key origins for desc: {}. Descriptor: {}.", e, desc);
+                }
+            }
+            _ => unreachable!("Only ever a wsh() or a tr() descriptor."),
+        }
+    }
+
+    /// Update the info of a PSBT output for a change output with data from this derived
+    /// descriptor.
+    pub fn update_change_psbt_out(&self, psbtout: &mut PsbtOut) {
+        match self.0 {
+            descriptor::Descriptor::Wsh(_) => {
+                psbtout.bip32_derivation = self.bip32_derivations();
+            }
+            descriptor::Descriptor::Tr(_) => {
+                let desc = self.definite_desc();
+                if let Err(e) = psbtout.update_with_descriptor_unchecked(&desc) {
+                    log::error!("BUG! Please report this! Error when adding key origins for desc: {}. Descriptor: {}.", e, desc);
+                }
+            }
+            _ => unreachable!("Only ever a wsh() or a tr() descriptor."),
+        }
+    }
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
 
-    use bitcoin::Sequence;
-
-    use std::str::FromStr;
+    use bitcoin::{hashes::Hash, Sequence};
 
     use crate::signer::HotSigner;
 
@@ -572,16 +672,25 @@ mod tests {
 
     #[test]
     fn descriptor_creation() {
+        // Simple 1 primary key, 1 recovery key.
         let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
         let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*").unwrap());
         let timelock = 52560;
-        let policy = LianaPolicy::new(
+        let policy = LianaPolicy::new_legacy(
             owner_key.clone(),
             [(timelock, heir_key.clone())].iter().cloned().collect(),
         )
         .unwrap();
         assert_eq!(LianaDescriptor::new(policy).to_string(), "wsh(or_d(pk([abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*),and_v(v:pkh([abcdef01]xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*),older(52560))))#g7vk9r5l");
 
+        // Same under Taproot.
+        let policy = LianaPolicy::new(
+            owner_key.clone(),
+            [(timelock, heir_key.clone())].iter().cloned().collect(),
+        )
+        .unwrap();
+        assert_eq!(LianaDescriptor::new(policy).to_string(), "tr([abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,and_v(v:pk([abcdef01]xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*),older(52560)))#0mt7e93c");
+
         // A 3-of-3 multisig decaying into a 2-of-3 multisig after 6 months. Trying to mimic a
         // real situation, we use keys from 3 different origins (in practice, 3 different devices
         // held by 3 different persons).
@@ -603,12 +712,20 @@ mod tests {
                 descriptor::DescriptorPublicKey::from_str("[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/1/<0;1>/*").unwrap(),
             ],
         );
+        let policy = LianaPolicy::new_legacy(
+            primary_keys.clone(),
+            [(26352, recovery_keys.clone())].iter().cloned().collect(),
+        )
+        .unwrap();
+        assert_eq!(LianaDescriptor::new(policy).to_string(), "wsh(or_d(multi(3,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/0/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/0/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/0/<0;1>/*),and_v(v:thresh(2,pkh([aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/1/<0;1>/*),a:pkh([aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/1/<0;1>/*),a:pkh([aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/1/<0;1>/*)),older(26352))))#prj7nktq");
+
+        // Same under Taproot.
         let policy = LianaPolicy::new(
             primary_keys,
             [(26352, recovery_keys)].iter().cloned().collect(),
         )
         .unwrap();
-        assert_eq!(LianaDescriptor::new(policy).to_string(), "wsh(or_d(multi(3,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/0/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/0/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/0/<0;1>/*),and_v(v:thresh(2,pkh([aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/1/<0;1>/*),a:pkh([aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/1/<0;1>/*),a:pkh([aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/1/<0;1>/*)),older(26352))))#prj7nktq");
+        assert_eq!(LianaDescriptor::new(policy.clone()).to_string(), "tr(xpub661MyMwAqRbcFERisZuMzFcfg3Ur3dKB17kb8iEG89ZJYMHTWqKQGRdLjTXC6Byr8kjKo6JabFfRCm3ETM4woq7DxUXuUxxRFHfog4Peh41/<0;1>/*,{and_v(v:multi_a(2,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/1/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/1/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/1/<0;1>/*),older(26352)),multi_a(3,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/0/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/0/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/0/<0;1>/*)})#tugn7xtx");
 
         // Another derivation step before the wildcard is taken into account.
         // desc_b is the very same descriptor as desc_a, except the very first xpub's derivation
@@ -641,26 +758,34 @@ mod tests {
                 descriptor::DescriptorPublicKey::from_str("[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<2;3>/*").unwrap(),
             ],
         );
+        let policy = LianaPolicy::new_legacy(
+            primary_keys.clone(),
+            [(26352, recovery_keys.clone())].iter().cloned().collect(),
+        )
+        .unwrap();
+        assert_eq!(LianaDescriptor::new(policy).to_string(), "wsh(or_d(multi(3,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*),and_v(v:thresh(2,pkh([aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<2;3>/*),a:pkh([aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<2;3>/*),a:pkh([aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<2;3>/*)),older(26352))))#d2h994td");
+
+        // Same under Taproot.
         let policy = LianaPolicy::new(
             primary_keys,
             [(26352, recovery_keys)].iter().cloned().collect(),
         )
         .unwrap();
-        assert_eq!(LianaDescriptor::new(policy).to_string(), "wsh(or_d(multi(3,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*),and_v(v:thresh(2,pkh([aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<2;3>/*),a:pkh([aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<2;3>/*),a:pkh([aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<2;3>/*)),older(26352))))#d2h994td");
+        assert_eq!(LianaDescriptor::new(policy.clone()).to_string(), "tr(xpub661MyMwAqRbcFERisZuMzFcfg3Ur3dKB17kb8iEG89ZJYMHTWqKQGRdLjTXC6Byr8kjKo6JabFfRCm3ETM4woq7DxUXuUxxRFHfog4Peh41/<0;1>/*,{and_v(v:multi_a(2,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<2;3>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<2;3>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<2;3>/*),older(26352)),multi_a(3,[aabb0011/48'/0'/0'/2']xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0012/48'/0'/0'/2']xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,[aabb0013/48'/0'/0'/2']xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*)})#ayju5dfr");
 
         // We prevent footguns with timelocks by requiring a u16. Note how the following wouldn't
         // compile:
-        //LianaPolicy::new(owner_key.clone(), heir_key.clone(), 0x00_01_0f_00).unwrap_err();
-        //LianaPolicy::new(owner_key.clone(), heir_key.clone(), (1 << 31) + 1).unwrap_err();
-        //LianaPolicy::new(owner_key, heir_key, (1 << 22) + 1).unwrap_err();
+        //LianaPolicy::new_legacy(owner_key.clone(), heir_key.clone(), 0x00_01_0f_00).unwrap_err();
+        //LianaPolicy::new_legacy(owner_key.clone(), heir_key.clone(), (1 << 31) + 1).unwrap_err();
+        //LianaPolicy::new_legacy(owner_key, heir_key, (1 << 22) + 1).unwrap_err();
 
         // You can't use a null timelock in Miniscript.
-        LianaPolicy::new(owner_key, [(0, heir_key)].iter().cloned().collect()).unwrap_err();
+        LianaPolicy::new_legacy(owner_key, [(0, heir_key)].iter().cloned().collect()).unwrap_err();
 
         let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[aabb0011/10/4893]xpub661MyMwAqRbcFG59fiikD8UV762quhruT8K8bdjqy6N2o3LG7yohoCdLg1m2HAY1W6rfBrtauHkBhbfA4AQ3iazaJj5wVPhwgaRCHBW2DBg/<0;1>/*").unwrap());
         let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/24/32/<0;1>/*").unwrap());
         let timelock = 57600;
-        let policy = LianaPolicy::new(
+        let policy = LianaPolicy::new_legacy(
             owner_key.clone(),
             [(timelock, heir_key)].iter().cloned().collect(),
         )
@@ -671,13 +796,13 @@ mod tests {
         // without both the change and receive derivation paths, or with more than 2 different
         // derivation paths.
         let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/<0;1>/354").unwrap());
-        LianaPolicy::new(
+        LianaPolicy::new_legacy(
             owner_key.clone(),
             [(timelock, heir_key)].iter().cloned().collect(),
         )
         .unwrap_err();
         let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/<0;1>/*'").unwrap());
-        LianaPolicy::new(
+        LianaPolicy::new_legacy(
             owner_key.clone(),
             [(timelock, heir_key)].iter().cloned().collect(),
         )
@@ -688,19 +813,20 @@ mod tests {
             )
             .unwrap(),
         );
-        LianaPolicy::new(
+        LianaPolicy::new_legacy(
             owner_key.clone(),
             [(timelock, heir_key)].iter().cloned().collect(),
         )
         .unwrap_err();
         let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/0/*'").unwrap());
-        LianaPolicy::new(
+        LianaPolicy::new_legacy(
             owner_key.clone(),
             [(timelock, heir_key)].iter().cloned().collect(),
         )
         .unwrap_err();
         let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub661MyMwAqRbcFfxf71L4Dx4w5TmyNXrBicTEAM7vLzumxangwATWWgdJPb6xH1JHcJH9S3jNZx3fCnkkB1WyqrqGgavj1rehHcbythmruvZ/<0;1;2>/*'").unwrap());
-        LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err();
+        LianaPolicy::new_legacy(owner_key, [(timelock, heir_key)].iter().cloned().collect())
+            .unwrap_err();
 
         // And it's checked even in a multisig. For instance:
         let primary_keys = PathInfo::Multi(
@@ -717,6 +843,13 @@ mod tests {
                 descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6AA2N8RALRYgLD6jT1iXYCEDkndTeZndMtWPbtNX6sY5dPiLtf2T88ahdxrGXMUPoNadgR86sFhBXWQVgifPzDYbY9ZtwK4gqzx4y5Da1DW/<0;1>/*").unwrap(),
             ],
         );
+        LianaPolicy::new_legacy(
+            primary_keys.clone(),
+            [(26352, recovery_keys.clone())].iter().cloned().collect(),
+        )
+        .unwrap_err();
+
+        // It's also checked under Taproot context.
         LianaPolicy::new(
             primary_keys,
             [(26352, recovery_keys)].iter().cloned().collect(),
@@ -726,13 +859,16 @@ mod tests {
         // You can't pass duplicate keys, even if they are encoded differently.
         let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
         let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
-        LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err();
+        LianaPolicy::new_legacy(owner_key, [(timelock, heir_key)].iter().cloned().collect())
+            .unwrap_err();
         let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[00aabb44]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
         let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
-        LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err();
+        LianaPolicy::new_legacy(owner_key, [(timelock, heir_key)].iter().cloned().collect())
+            .unwrap_err();
         let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[00aabb44]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
         let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[11223344/2/98]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
-        LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err();
+        LianaPolicy::new_legacy(owner_key, [(timelock, heir_key)].iter().cloned().collect())
+            .unwrap_err();
 
         // You can't pass duplicate keys, even across multisigs.
         let primary_keys = PathInfo::Multi(
@@ -751,6 +887,14 @@ mod tests {
                 descriptor::DescriptorPublicKey::from_str("[abcdef02]xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*").unwrap(),
             ],
         );
+        let err = LianaPolicy::new_legacy(
+            primary_keys.clone(),
+            [(26352, recovery_keys.clone())].iter().cloned().collect(),
+        )
+        .unwrap_err();
+        assert!(matches!(err, LianaPolicyError::DuplicateKey(_)));
+
+        // It's also checked under Taproot.
         let err = LianaPolicy::new(
             primary_keys,
             [(26352, recovery_keys)].iter().cloned().collect(),
@@ -767,6 +911,14 @@ mod tests {
             ]
         );
         let recovery_keys = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef02]xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*").unwrap());
+        let err = LianaPolicy::new_legacy(
+            primary_keys.clone(),
+            [(26352, recovery_keys.clone())].iter().cloned().collect(),
+        )
+        .unwrap_err();
+        assert!(matches!(err, LianaPolicyError::DuplicateOriginSamePath(_)));
+
+        // It's also checked under Taproot.
         let err = LianaPolicy::new(
             primary_keys,
             [(26352, recovery_keys)].iter().cloned().collect(),
@@ -783,6 +935,14 @@ mod tests {
             ]
         );
         let primary_keys = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef02]xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*").unwrap());
+        let err = LianaPolicy::new_legacy(
+            primary_keys.clone(),
+            [(26352, recovery_keys.clone())].iter().cloned().collect(),
+        )
+        .unwrap_err();
+        assert!(matches!(err, LianaPolicyError::DuplicateOriginSamePath(_)));
+
+        // It's also checked under Taproot.
         let err = LianaPolicy::new(
             primary_keys,
             [(26352, recovery_keys)].iter().cloned().collect(),
@@ -799,6 +959,13 @@ mod tests {
             ]
         );
         let recovery_keys = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*").unwrap());
+        LianaPolicy::new_legacy(
+            primary_keys.clone(),
+            [(26352, recovery_keys.clone())].iter().cloned().collect(),
+        )
+        .unwrap();
+
+        // It's also possible under Taproot.
         LianaPolicy::new(
             primary_keys,
             [(26352, recovery_keys)].iter().cloned().collect(),
@@ -809,18 +976,39 @@ mod tests {
         let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
         let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*").unwrap());
         let timelock = 52560;
+        LianaPolicy::new_legacy(
+            owner_key.clone(),
+            [(timelock, heir_key.clone())].iter().cloned().collect(),
+        )
+        .unwrap_err();
         LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err();
 
         // One of the xpub isn't normalized.
         let owner_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[abcdef01]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*").unwrap());
         let heir_key = PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[aabbccdd]xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/42'/<0;1>/*").unwrap());
         let timelock = 52560;
+        LianaPolicy::new_legacy(
+            owner_key.clone(),
+            [(timelock, heir_key.clone())].iter().cloned().collect(),
+        )
+        .unwrap_err();
         LianaPolicy::new(owner_key, [(timelock, heir_key)].iter().cloned().collect()).unwrap_err();
 
         // A 1-of-N multisig as primary path.
         LianaDescriptor::from_str("wsh(or_d(multi(1,[573fb35b/48'/1'/0'/2']tpubDFKp9T7WAYDcENSjoifkrpq1gMDF47KGJcJrpxzX23Qor8wuGbrEVs9utNq1MDS8E2WXJSBk1qoPQLpwyokW7DiUNPwFuxQkL7owNkLAb9W/<0;1>/*,[573fb35c/48'/1'/1'/2']tpubDFGezyzuHJPhdP3jHGW7v7Hwes4Hihqv5W2yyCmRY9VZJCRchETvxrMC8uECeJZdxQ14V4iD4DecoArkUSDwj8ogYE9WEv4MNZr12thNHCs/<0;1>/*),and_v(v:multi(2,[573fb35b/48'/1'/2'/2']tpubDDwxQauiaU964vPzt5Vd7jnDHEUtp2Vc34PaWpEXg5TQ3bRccxnc1MKKh88Hi7xiMeZo9Tm6fBcq4UGXqnDtGUniJLjqAD8SjQ8Eci3aSR7/<0;1>/*,[573fb35c/48'/1'/3'/2']tpubDE37XAVB5CQ1x85md3BQ5uHCoMwT5fgT8X13zzCUQ3x5o2jskYxKjj7Qcxt1Jpj4QB8tqspn2dooPCekRuQDYrDHov7J1ueUNu2wcvgRDxr/<0;1>/*),older(1000))))#fccaqlhh").unwrap();
     }
 
+    #[test]
+    fn descriptor_unspendable_internal_key() {
+        // We correctly detect a deterministically derived unspendable internal key.
+        LianaDescriptor::from_str("tr(tpubD6NzVbkrYhZ4YdBUPkUhDYj6Sd1QK8vgiCf5RwHnAnSNK5ozemAZzPTYZbgQq4diod7oxFJJYGa8FNRHzRo7URkixzQTuudh38xRRdSc4Hu/<0;1>/*,{and_v(v:multi_a(1,[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<2;3>/*,[da2ee873/48'/1'/0'/2']tpubDEbXY6RbN9mxAvQW797WxReGGkrdyRfdYcehVVaQQcQ3kyfhxSMcnU9qGpUVRHXXALvBtc99jcuxx5tkzcLaJbAukSNpP9h2ti4XFRosv1g/<2;3>/*),older(2)),multi_a(2,[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<0;1>/*,[da2ee873/48'/1'/0'/2']tpubDEbXY6RbN9mxAvQW797WxReGGkrdyRfdYcehVVaQQcQ3kyfhxSMcnU9qGpUVRHXXALvBtc99jcuxx5tkzcLaJbAukSNpP9h2ti4XFRosv1g/<0;1>/*)})").unwrap();
+        // Even if it has an origin.
+        LianaDescriptor::from_str("tr([00000000/1/2/3]tpubD6NzVbkrYhZ4YdBUPkUhDYj6Sd1QK8vgiCf5RwHnAnSNK5ozemAZzPTYZbgQq4diod7oxFJJYGa8FNRHzRo7URkixzQTuudh38xRRdSc4Hu/<0;1>/*,{and_v(v:multi_a(1,[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<2;3>/*,[da2ee873/48'/1'/0'/2']tpubDEbXY6RbN9mxAvQW797WxReGGkrdyRfdYcehVVaQQcQ3kyfhxSMcnU9qGpUVRHXXALvBtc99jcuxx5tkzcLaJbAukSNpP9h2ti4XFRosv1g/<2;3>/*),older(2)),multi_a(2,[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<0;1>/*,[da2ee873/48'/1'/0'/2']tpubDEbXY6RbN9mxAvQW797WxReGGkrdyRfdYcehVVaQQcQ3kyfhxSMcnU9qGpUVRHXXALvBtc99jcuxx5tkzcLaJbAukSNpP9h2ti4XFRosv1g/<0;1>/*)})").unwrap();
+        // We'll correctly detect a non-deterministically derived unspendable internal key and
+        // refuse to parse the descriptor (because it makes it have 2 primary spending paths).
+        LianaDescriptor::from_str("tr(tpubDCaEmvN8YCgyfjNfX6j7r71h1Gx5pqVDAjT145hd46R4DhN8cuHUC39bqRXd43xnroUNKTUqFi9RGCLtxAxxwB6ysVhAh5k26q7AkNUxF7b/<0;1>/*,{and_v(v:multi_a(1,[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<2;3>/*,[da2ee873/48'/1'/0'/2']tpubDEbXY6RbN9mxAvQW797WxReGGkrdyRfdYcehVVaQQcQ3kyfhxSMcnU9qGpUVRHXXALvBtc99jcuxx5tkzcLaJbAukSNpP9h2ti4XFRosv1g/<2;3>/*),older(2)),multi_a(2,[ffd63c8d/48'/1'/0'/2']tpubDExA3EC3iAsPxPhFn4j6gMiVup6V2eH3qKyk69RcTc9TTNRfFYVPad8bJD5FCHVQxyBT4izKsvr7Btd2R4xmQ1hZkvsqGBaeE82J71uTK4N/<0;1>/*,[da2ee873/48'/1'/0'/2']tpubDEbXY6RbN9mxAvQW797WxReGGkrdyRfdYcehVVaQQcQ3kyfhxSMcnU9qGpUVRHXXALvBtc99jcuxx5tkzcLaJbAukSNpP9h2ti4XFRosv1g/<0;1>/*)})").unwrap_err();
+    }
+
     #[test]
     fn inheritance_descriptor_derivation() {
         let secp = secp256k1::Secp256k1::verification_only();
@@ -833,8 +1021,10 @@ mod tests {
 
         // Sanity check we can call the methods on the derived desc
         der_desc.script_pubkey();
-        der_desc.witness_script();
-        assert!(!der_desc.bip32_derivations().is_empty());
+        let mut psbt_in = PsbtIn::default();
+        der_desc.update_psbt_in(&mut psbt_in);
+        assert!(psbt_in.witness_script.is_some());
+        assert!(!psbt_in.bip32_derivation.is_empty());
     }
 
     #[test]
@@ -887,75 +1077,92 @@ mod tests {
         let mut twenty_nine_keys = twenty_eight_keys.clone();
         twenty_nine_keys.push(random_desc_key(&secp));
 
+        // Test various scenarii which should pass or fail on both Taproot and P2WSH.
+        macro_rules! test_liana_desc_keys {
+            ($constructor:expr) => {
+                $constructor(
+                    prim_path.clone(),
+                    [(1, PathInfo::Multi(2, vec![random_desc_key(&secp)]))]
+                        .iter()
+                        .cloned()
+                        .collect(),
+                )
+                .unwrap_err();
+                $constructor(
+                    prim_path.clone(),
+                    [(
+                        1,
+                        PathInfo::Multi(1, vec![random_desc_key(&secp), random_desc_key(&secp)]),
+                    )]
+                    .iter()
+                    .cloned()
+                    .collect(),
+                )
+                .unwrap();
+                $constructor(
+                    prim_path.clone(),
+                    [(
+                        1,
+                        PathInfo::Multi(0, vec![random_desc_key(&secp), random_desc_key(&secp)]),
+                    )]
+                    .iter()
+                    .cloned()
+                    .collect(),
+                )
+                .unwrap_err();
+                $constructor(
+                    prim_path.clone(),
+                    [(
+                        1,
+                        PathInfo::Multi(2, vec![random_desc_key(&secp), random_desc_key(&secp)]),
+                    )]
+                    .iter()
+                    .cloned()
+                    .collect(),
+                )
+                .unwrap();
+                $constructor(
+                    prim_path.clone(),
+                    [(
+                        1,
+                        PathInfo::Multi(3, vec![random_desc_key(&secp), random_desc_key(&secp)]),
+                    )]
+                    .iter()
+                    .cloned()
+                    .collect(),
+                )
+                .unwrap_err();
+                $constructor(
+                    prim_path.clone(),
+                    [(1, PathInfo::Multi(3, twenty_eight_keys.clone()))]
+                        .iter()
+                        .cloned()
+                        .collect(),
+                )
+                .unwrap();
+                $constructor(
+                    prim_path.clone(),
+                    [(1, PathInfo::Multi(20, twenty_eight_keys.clone()))]
+                        .iter()
+                        .cloned()
+                        .collect(),
+                )
+                .unwrap();
+            };
+        }
+        test_liana_desc_keys!(LianaPolicy::new_legacy);
+        test_liana_desc_keys!(LianaPolicy::new);
+
+        // A 20-of-28 should pass on Taproot but fail on P2WSH.
         LianaPolicy::new(
             prim_path.clone(),
-            [(1, PathInfo::Multi(2, vec![random_desc_key(&secp)]))]
-                .iter()
-                .cloned()
-                .collect(),
-        )
-        .unwrap_err();
-        LianaPolicy::new(
-            prim_path.clone(),
-            [(
-                1,
-                PathInfo::Multi(1, vec![random_desc_key(&secp), random_desc_key(&secp)]),
-            )]
-            .iter()
-            .cloned()
-            .collect(),
-        )
-        .unwrap();
-        LianaPolicy::new(
-            prim_path.clone(),
-            [(
-                1,
-                PathInfo::Multi(0, vec![random_desc_key(&secp), random_desc_key(&secp)]),
-            )]
-            .iter()
-            .cloned()
-            .collect(),
-        )
-        .unwrap_err();
-        LianaPolicy::new(
-            prim_path.clone(),
-            [(
-                1,
-                PathInfo::Multi(2, vec![random_desc_key(&secp), random_desc_key(&secp)]),
-            )]
-            .iter()
-            .cloned()
-            .collect(),
-        )
-        .unwrap();
-        LianaPolicy::new(
-            prim_path.clone(),
-            [(
-                1,
-                PathInfo::Multi(3, vec![random_desc_key(&secp), random_desc_key(&secp)]),
-            )]
-            .iter()
-            .cloned()
-            .collect(),
-        )
-        .unwrap_err();
-        LianaPolicy::new(
-            prim_path.clone(),
-            [(1, PathInfo::Multi(3, twenty_eight_keys.clone()))]
-                .iter()
-                .cloned()
-                .collect(),
-        )
-        .unwrap();
-        LianaPolicy::new(
-            prim_path.clone(),
-            [(1, PathInfo::Multi(20, twenty_eight_keys))]
+            [(1, PathInfo::Multi(20, twenty_nine_keys.clone()))]
                 .iter()
                 .cloned()
                 .collect(),
         )
         .unwrap();
-        LianaPolicy::new(
+        LianaPolicy::new_legacy(
             prim_path,
             [(1, PathInfo::Multi(20, twenty_nine_keys))]
                 .iter()
@@ -970,16 +1177,26 @@ mod tests {
         assert_eq!(desc.to_string(), desc_str);
     }
 
+    // Make sure the string representation of our descriptors roundtrip. The Taproot ones were
+    // generated manually with our code because of the potential need to compute the internal key
+    // deterministically.
     #[test]
     fn roundtrip_descriptor() {
         // A descriptor with single keys in both primary and recovery paths
         roundtrip("wsh(or_d(pk([aabbccdd]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*),and_v(v:pkh([aabbccdd]xpub688Hn4wScQAAiYJLPg9yH27hUpfZAUnmJejRQBCiwfP5PEDzjWMNW1wChcninxr5gyavFqbbDjdV1aK5USJz8NDVjUy7FRQaaqqXHh5SbXe/<0;1>/*),older(52560))))#7437yjrs");
+        roundtrip("tr([8344c025]xpub661MyMwAqRbcG2SYC6YSRsUGvcSxXEZm1kjiQRTEaAqart1PQk1N1hVTTEsGfaBx6xQ5gDYXXtbourodE6ZE5qZTnaMgmehNs8GGEEY9YK6/<0;1>/*,and_v(v:pk([158fd0ef]xpub661MyMwAqRbcF2KsCnvJ4mqWXXrwd3799wCyQrLk2iNDC6CfK8UcfnABdeTpXyoJnBhRTybmtBLDAuTuHye1eQMq43BSLtR2miA6t9KqmWU/<0;1>/*),older(4242)))#zy3kddhj");
         // One with a multisig in both paths
         roundtrip("wsh(or_d(multi(3,[aabbccdd]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0011/10/4893]xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,[aabb0022]xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*),and_v(v:multi(2,[aabbccdd]xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*,[aabb0011]xpub6AA2N8RALRYgLD6jT1iXYCEDkndTeZndMtWPbtNX6sY5dPiLtf2T88ahdxrGXMUPoNadgR86sFhBXWQVgifPzDYbY9ZtwK4gqzx4y5Da1DW/<0;1>/*,[aabb0022/10/4893]xpub6AyxexvxizZJffF153evmfqHcE9MV88fCNCAtP3jQjXJHwrAKri71Tq9jWUkPxj9pja4u6AkCPHY7atgxzSEa2HtDwJfrRWKK4fsfQg4o77/<0;1>/*),older(26352))))#csjdk94l");
+        roundtrip("tr(xpub661MyMwAqRbcGg7oXkMMptXXJAGxQtVM7LZeqXNNdxPiWyEmuJdoyFD3NhRpL1YTo313XRWZmiUXTkcEK9EFQHrbie6NBNAvL2CZXp941Li/<0;1>/*,{and_v(v:multi_a(2,[6b882e01]xpub661MyMwAqRbcGRU9psMcDAPd2L2ShzwoenySSSjWpkd7u8Wv7PCPtH5fi6WYYbQAqSG4U3NbuYASCRMkWVYm7yb97iTY4MUKKZ3N8XwCERJ/<0;1>/*,[66b98303]xpub661MyMwAqRbcGicAwMZ5pHCWrB4DEMBGUtvkV2KMMLypR8dbr7g2uV9vzE9w3oDKRqtTV6HYTqHHvusxNwJUXAvRH6BFhKUPTgGMiLPnSmK/<0;1>/*,[9c04a03b]xpub661MyMwAqRbcFCBt8Gjs71UqoMe8V4PSHECuCowg1TR7EkGWLLbu2WanQtcWutzwahrTcicsuL25Q7r6EyfbEKF2jSoekmnw9soZfoiLZXu/<0;1>/*),older(42421)),multi_a(3,[30188cc2]xpub661MyMwAqRbcGeoYQgqUapNKDLBiNE7fcGs6ibKi39GjuiRmV1JgXcfAwHjt7PLLofmz4PPL66NTwAxaTwGtL8YB67RhRspAzbKgneqpenb/<0;1>/*,[aea08adc]xpub661MyMwAqRbcGVL3W5qKT8pjZ3BXcDEJghDj67rKLQYwmTaJLud8RWyYwZQ9LdzkcNtCSCHVypZdUUxd4z2k5hCfb6qprGgwAKqpmaKJTnS/<0;1>/*,[85e33ca4]xpub661MyMwAqRbcFHP9bmnRofzha8c4DHADC7ToPz3kYdov5DDDtgdBEQ3kVcwdjjqAGC8eJZ65CLF2cA9XHhUsJJqKxbE9asj8RUNmGjCJErX/<0;1>/*)})#zm4kj6yd");
         // A single key as primary path, a multisig as recovery
         roundtrip("wsh(or_d(pk([aabbccdd]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*),and_v(v:multi(2,[aabbccdd]xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*,[aabb0011]xpub6AA2N8RALRYgLD6jT1iXYCEDkndTeZndMtWPbtNX6sY5dPiLtf2T88ahdxrGXMUPoNadgR86sFhBXWQVgifPzDYbY9ZtwK4gqzx4y5Da1DW/<0;1>/*,[aabb0022/10/4893]xpub6AyxexvxizZJffF153evmfqHcE9MV88fCNCAtP3jQjXJHwrAKri71Tq9jWUkPxj9pja4u6AkCPHY7atgxzSEa2HtDwJfrRWKK4fsfQg4o77/<0;1>/*),older(26352))))#sc9gw0z0");
+        roundtrip("tr([46f4cf22]xpub661MyMwAqRbcG22nAyuZc7MUXR559qKKkHVdi7TKy3Q8m91FKN9heKhP7jWj6SJdyAA9zfQgjQUNWkqjPGJdcH6uFD8mEWJnTps5emjoi9L/<0;1>/*,and_v(v:multi_a(2,[f9eb379c]xpub661MyMwAqRbcF2FpaYbnrN7K6uPhiwg5u1LiqmsMSTnphuhQzpPv9RGdERxDd7pnnrEC8hxttAPi4wbSVsKeJYiHYymfpuxSD7TALTXqjq6/<0;1>/*,[1fc462f2]xpub661MyMwAqRbcEtYavp2XsS9QfH93wyVQnkWenWxWuWdaxDtjBqfzFfWPY83z3da5oYv2XmwgTT97GhGwX9HUGDEP4FERzzgmwaGNAz1emZr/<0;1>/*,[ebcef2a0]xpub661MyMwAqRbcH3JihNDbpEqpmT9xjY5YWd9VwVWQqoWWrggurHs7wsvTXM7ggK5X3wwATxiijwJPe73y9beirtorQebMuL4hR7dbU7akrk7/<0;1>/*),older(124)))#rgrm8l4v");
         // The other way around
         roundtrip("wsh(or_d(multi(3,[aabbccdd]xpub6Eze7yAT3Y1wGrnzedCNVYDXUqa9NmHVWck5emBaTbXtURbe1NWZbK9bsz1TiVE7Cz341PMTfYgFw1KdLWdzcM1UMFTcdQfCYhhXZ2HJvTW/<0;1>/*,[aabb0011/10/4893]xpub6Bw79HbNSeS2xXw1sngPE3ehnk1U3iSPCgLYzC9LpN8m9nDuaKLZvkg8QXxL5pDmEmQtYscmUD8B9MkAAZbh6vxPzNXMaLfGQ9Sb3z85qhR/<0;1>/*,[aabb0022]xpub67zuTXF9Ln4731avKTBSawoVVNRuMfmRvkL7kLUaLBRqma9ZqdHBJg9qx8cPUm3oNQMiXT4TmGovXNoQPuwg17RFcVJ8YrnbcooN7pxVJqC/<0;1>/*),and_v(v:pk([aabbccdd]xpub69cP4Y7S9TWcbSNxmk6CEDBsoaqr3ZEdjHuZcHxEFFKGh569RsJNr2V27XGhsbH9FXgWUEmKXRN7c5wQfq2VPjt31xP9VsYnVUyU8HcVevm/<0;1>/*),older(26352))))#kjajav3j");
+        roundtrip("tr(xpub661MyMwAqRbcFWPZkATtyZ3cZboVifGEpVoDNLRotSvymYNbb652s75MJs7x6Dsh1K4WidtHAvWiyWu6ufbvP3RG9ozXYhZA83rAniyTAcQ/<0;1>/*,{and_v(v:pk([5c1f5207]xpub661MyMwAqRbcGjhDHfE45ivpxoBGywTdSJa5vWgB5L5BjUjfdvfwQr618o6hjLSCdLruGwu8WFLbgQa7179EC3HEiEUAceLSHArRgsUPRFe/<0;1>/*),older(42)),multi_a(2,[f25498f5]xpub661MyMwAqRbcEZKeQW5gkVY61KyFdmR2ntRmHdnoyWPE5PrgcpAFhotgSftuQVkw1DXoeE7wGQpXkaijczBFNVSFYs4UN2ZsN8tiBR2cffw/<0;1>/*,[0adab7f3]xpub661MyMwAqRbcGrUYLnLEAqJEjmy9NejNXvjneoochu285TgBszNaS5usEnosxKXQxPS5ppWp923EXmi4JgYJWZa4cwEzTX6n6aacG1bN99j/<0;1>/*,[78428935]xpub661MyMwAqRbcGqCdcSemPFvTbhm5swDzRYxP9azdQGPwgcAYumXhFuQUdNFLxaf61uRB7UdpxsYoP3hMW3sqQF7ErWQm5RznBbbVMm6z6wc/<0;1>/*)})#s235c44f");
+        // More than 2 spending paths.
+        roundtrip("wsh(or_i(and_v(v:thresh(1,pkh([19e064b0]xpub661MyMwAqRbcGRgGoZDVccAfLzLuvxkXevrGCq66XGV9mmRLfJ1aiAZNtVUTfxFMoSmPNJLmEywZn8yQXBzVHVENMRbj3VpVvdzCteCkgq6/<0;1>/*),a:pkh([00454cc2]xpub661MyMwAqRbcExaDFtcC1pVGounzi9bmVb4nBxVr3reFEsFpCTn5VVwuDiUFeJkJtppEC7Gzk3cW8htEB9Q3DcXV28SAHioi2oJZv6oTobF/<0;1>/*)),older(1678)),or_i(and_v(v:thresh(2,pkh([0143d6e6]xpub661MyMwAqRbcFuLKDCSKk2r3KKN7FWXZnx9s7V1xScv7N1qs7xXPxjrarBaPkuzV9ji7Hquwf4G6G12pXFmaeqwXWhKzwSp1j8JgLHTKUDn/<0;1>/*),a:pkh([cd1f0cf2]xpub661MyMwAqRbcGcFwqwdNkHojx6ffeQEXPfopamNscuf4CXaLrKVMkCTpffiFNJ3okep7bgNVx13N1rryW3nQNiPAsrkr5zL9T3vo2ww4fC8/<0;1>/*),a:pkh([d76af68c]xpub661MyMwAqRbcGVFCMA5yqLiF9xj6G9QoqFDQdqmnyCZDTFTfpfUgzevrohjSrMTjoBYyB5YvKjtEqqX9U6yjgDCYRT8e7DeYqLnu6DbFAgj/<0;1>/*)),older(43)),or_d(multi(2,[5b016400]xpub661MyMwAqRbcGZFpjHB8mvxGnDGDdEBetsFu25nC69SrGAJKJVctsFwNNY5VwPMVx7aXL6m1LKAeA5qAE3Wheh5cAKBdxqSFrRBd3Vf7eTX/<0;1>/*,[6b0a6b3f]xpub661MyMwAqRbcGzZBMBU1evaZmfwmEVkzU8oRhu4y7DSaaHHoQeHDbM47JNqeEbJRGGhMNd1Hp9oP2NdYbRHxxcd7YfABiNfULyVW9vDg5cx/<0;1>/*,[1c4eb5d3]xpub661MyMwAqRbcGFr2mWaBr1rX3xfnv75FbzP1hPW7LEzYMzDZV5wPVgcrYEZWxwu8ALUTRJ5ioutE3mz5dTQBWKEkvxCytV3QeNdm4cDHr6p/<0;1>/*),and_v(v:pkh([5c055660]xpub661MyMwAqRbcEqgeH5cqyxRwY4UG21ey1MBJkNBX2xSTmGS9dCRmGQezqHE9mXUXzs9HqFzNEN2KkNw5o8xpqAXw2XxsVhGVm1LbRaEnxyT/<0;1>/*),older(42))))))#hd246u4a");
+        roundtrip("tr(xpub661MyMwAqRbcGqmqNapgQ9kqrLcDeZLHPktzsBcZXTtNx7aEay8NKQPizKcpu2fUejNbZzhZQaZLeDWL3nt8zg9QbFLBUTRQu4qqcSzeEmF/<0;1>/*,{{and_v(v:multi_a(1,[b4e32970]xpub661MyMwAqRbcEbs6ohRoUqTckEfLeT3vB2EsuWuckrEuDSKqdFXV6so8xJb4kvA4ZxT6hCydyFKsKwJrDm2LgSfTCphVqZgQbLzF49KwaXc/<0;1>/*,[c318e87f]xpub661MyMwAqRbcG2qnrFJ2MhKFSHehbVkK38gFfG7zXwasN51dKrL4kffj1HRd2zFhAZeQsjYKS8YaiN4sC4gVPHR28qXdQf7pf7nbYoefg6T/<0;1>/*),older(1678)),{and_v(v:pk([6c0d38a3]xpub661MyMwAqRbcF87hAvenL8GHW7qxhtn8Y9zHVkQbuTsd6RVtWkhBY5gh6m4Rua9ENmYDx7jTb8kbiyVB9iaLAbyRudxPFVTFoGPp6rTqoZn/<0;1>/*),older(42)),and_v(v:multi_a(2,[2e1370a6]xpub661MyMwAqRbcGRzCgSNLW7VFUFdwvC1dFXmKgWbZwQERj2QfNQuy5diCQSHNXuQYSS9FwXykLeWKtnZ5yRJ4ZHZzYqWf13FUY4PbDpBhipr/<0;1>/*,[fae2633e]xpub661MyMwAqRbcG9qKwZ7F363Mx3Ai3H2aMXAWTjvYCZrH4wqDEDLnsVghWFrwTKwpDGGzsSDCL7vPTiaiY7DhhdV2bY6RdPNGd7bF9om1MFz/<0;1>/*,[2ae87e33]xpub661MyMwAqRbcGw8ZvGfdLEjhCk4YC9hZrrUceKipiH32ANDMQccYFqq91kH8RpcwGiPnCbUWFo1S6ZGY2GxbVdJFsMYXqzpL1byJ1D3G2Mh/<0;1>/*),older(43))}},multi_a(2,[40f48611]xpub661MyMwAqRbcGUkDb45NBcMYwaaSE3fhsMNwvdf2psYhrqhFmRJY9n8irJuEB3juhK5LQPBiiqdr2gixMmC7Nmtg3Mwu4C5wbeagaAzbb9W/<0;1>/*,[a2bdfbe5]xpub661MyMwAqRbcH228eUBaJvc7Va1y7cGyEH9DZ5vPneKgZDX8eMsSd8PHS3uRYCFySyHPy3VfGfS8vKb5FzcS2MbNorNVv2c3Hn7AvVJJZ73/<0;1>/*,[028ece7a]xpub661MyMwAqRbcG9W1pZzs7rvWVtHeW1anzABj8iQRBbnz8yLf7vgUmYkVsydLf1hLffibgfzUjTBcrNCDKaBNnuqLtsp1xyiLSZJyLDtEjkF/<0;1>/*)})#xgzxdvrv");
     }
 
     fn psbt_from_str(psbt_str: &str) -> Psbt {
@@ -987,7 +1204,7 @@ mod tests {
     }
 
     #[test]
-    fn partial_spend_info() {
+    fn partial_spend_info_p2wsh() {
         let secp = secp256k1::Secp256k1::signing_only();
 
         // A simple descriptor with 1 keys as primary path and 1 recovery key.
@@ -1178,7 +1395,7 @@ mod tests {
         let first_reco_path = PathInfo::Multi(3, (0..5).map(|_| random_desc_key(&secp)).collect());
         let sec_reco_path = PathInfo::Multi(2, (0..5).map(|_| random_desc_key(&secp)).collect());
         let third_reco_path = PathInfo::Multi(1, (0..5).map(|_| random_desc_key(&secp)).collect());
-        let liana_policy = LianaPolicy::new(
+        let liana_policy = LianaPolicy::new_legacy(
             prim_path.clone(),
             [
                 (26784, first_reco_path.clone()),
@@ -1318,8 +1535,320 @@ mod tests {
         }
     }
 
+    // The same as above but adapted for Taproot.
+    #[test]
+    fn partial_spend_info_taproot() {
+        let secp = secp256k1::Secp256k1::signing_only();
+        let dummy_xonly_pubkeys = [
+            bitcoin::XOnlyPublicKey::from_str(
+                "85899827df71b16f2f0eed47cb920e3963f5204e171f5ef7ea4eeec3d5ecc607",
+            )
+            .unwrap(),
+            bitcoin::XOnlyPublicKey::from_str(
+                "0b71b3b1b1f2deebedebd54723428efb1d8d00b3c38a138977ef28f4fea848c9",
+            )
+            .unwrap(),
+        ];
+        let dummy_sig = bitcoin::taproot::Signature::from_slice(&[0; 64]).unwrap();
+        let dummy_leafhash = bitcoin::TapLeafHash::from_slice(&[0; 32]).unwrap();
+        let dummy_psbt = psbt_from_str("cHNidP8BAHECAAAAAUSHuliRtuCX1S6JxRuDRqDCKkWfKmWL5sV9ukZ/wzvfAAAAAAD9////AogTAAAAAAAAFgAUIxe7UY6LJ6y5mFBoWTOoVispDmdwFwAAAAAAABYAFKqO83TK+t/KdpAt21z2HGC7/Z2FAAAAAAABASsQJwAAAAAAACIAIIIySQjGCTeyx/rKUQx8qobjhJeNCiVCliBJPdyRX6XKAQVBIQI2cqWpc9UAW2gZt2WkKjvi8KoMCui00pRlL6wG32uKDKxzZHapFNYASzIYkEdH9bJz6nnqUG3uBB8kiK1asmgAAAA=");
+
+        // A simple descriptor with 1 keys as primary path and 1 recovery key.
+        let desc = LianaDescriptor::from_str("tr([f5acc2fd]tpubD6NzVbkrYhZ4YgUx2ZLNt2rLYAMTdYysCRzKoLu2BeSHKvzqPaBDvf17GeBPnExUVPkuBpx4kniP964e2MxyzzazcXLptxLXModSVCVEV1T/<0;1>/*,and_v(v:pkh([8a64f2a9]tpubD6NzVbkrYhZ4WmzFjvQrp7sDa4ECUxTi9oby8K4FZkd3XCBtEdKwUiQyYJaxiJo5y42gyDWEczrFpozEjeLxMPxjf2WtkfcbpUdfvNnozWF/<0;1>/*),older(10)))").unwrap();
+        let desc_info = desc.policy();
+        let prim_key_fg = bip32::Fingerprint::from_str("f5acc2fd").unwrap();
+        let prim_key_origin = (prim_key_fg, [0.into(), 0.into()][..].into());
+        let recov_key_origin: (_, bip32::DerivationPath) = (
+            bip32::Fingerprint::from_str("8a64f2a9").unwrap(),
+            [0.into(), 4242.into()][..].into(),
+        );
+
+        // A PSBT with a single input and output, no signature. nSequence is not set to use the
+        // recovery path.
+        let mut unsigned_single_psbt: Psbt = dummy_psbt.clone();
+        let info = desc.partial_spend_info(&unsigned_single_psbt).unwrap();
+        assert_eq!(info.primary_path.threshold, 1);
+        assert_eq!(info.primary_path.sigs_count, 0);
+        assert!(info.primary_path.signed_pubkeys.is_empty());
+        assert!(info.recovery_paths.is_empty());
+
+        // If we set the sequence too low we still won't have the recovery path info.
+        unsigned_single_psbt.unsigned_tx.input[0].sequence =
+            Sequence::from_height(desc_info.recovery_paths.keys().next().unwrap() - 1);
+        let info = desc.partial_spend_info(&unsigned_single_psbt).unwrap();
+        assert!(info.recovery_paths.is_empty());
+
+        // Now if we set the sequence at the right value we'll have it.
+        let timelock = *desc_info.recovery_paths.keys().next().unwrap();
+        unsigned_single_psbt.unsigned_tx.input[0].sequence = Sequence::from_height(timelock);
+        let info = desc.partial_spend_info(&unsigned_single_psbt).unwrap();
+        assert!(info.recovery_paths.contains_key(&timelock));
+
+        // Even if it's a bit too high (as long as it's still a block height and activated)
+        unsigned_single_psbt.unsigned_tx.input[0].sequence = Sequence::from_height(timelock + 42);
+        let info = desc.partial_spend_info(&unsigned_single_psbt).unwrap();
+        let recov_info = info.recovery_paths.get(&timelock).unwrap();
+        assert_eq!(recov_info.threshold, 1);
+        assert_eq!(recov_info.sigs_count, 0);
+        assert!(recov_info.signed_pubkeys.is_empty());
+
+        // The same PSBT but with an (invalid) signature for the primary key.
+        let mut signed_single_psbt = dummy_psbt.clone();
+        signed_single_psbt.inputs[0].tap_internal_key = Some(dummy_xonly_pubkeys[0]);
+        signed_single_psbt.inputs[0].tap_key_sig = Some(dummy_sig);
+        signed_single_psbt.inputs[0].tap_key_origins = [(
+            dummy_xonly_pubkeys[0],
+            (vec![dummy_leafhash], prim_key_origin.clone()),
+        )]
+        .iter()
+        .cloned()
+        .collect();
+        let info = desc.partial_spend_info(&signed_single_psbt).unwrap();
+        assert_eq!(info.primary_path.threshold, 1);
+        assert_eq!(info.primary_path.sigs_count, 1);
+        assert!(
+            info.primary_path.signed_pubkeys.len() == 1
+                && info.primary_path.signed_pubkeys.contains_key(&prim_key_fg)
+        );
+        assert!(info.recovery_paths.is_empty());
+
+        // Now enable the recovery path and add a signature for the recovery key.
+        let mut signed_recov_psbt = dummy_psbt.clone();
+        signed_recov_psbt.unsigned_tx.input[0].sequence = Sequence::from_height(timelock);
+        let recov_key = dummy_xonly_pubkeys[1];
+        signed_recov_psbt.inputs[0]
+            .tap_script_sigs
+            .insert((recov_key, dummy_leafhash), dummy_sig);
+        signed_recov_psbt.inputs[0].tap_key_origins =
+            [(recov_key, (vec![dummy_leafhash], recov_key_origin.clone()))]
+                .iter()
+                .cloned()
+                .collect();
+        let info = desc.partial_spend_info(&signed_recov_psbt).unwrap();
+        assert_eq!(info.primary_path.threshold, 1);
+        assert_eq!(info.primary_path.sigs_count, 0);
+        assert!(info.primary_path.signed_pubkeys.is_empty());
+        let recov_info = info.recovery_paths.get(&timelock).unwrap();
+        assert_eq!(recov_info.threshold, 1);
+        assert_eq!(recov_info.sigs_count, 1);
+        assert!(
+            recov_info.signed_pubkeys.len() == 1
+                && recov_info.signed_pubkeys.contains_key(&recov_key_origin.0)
+        );
+
+        // A PSBT with multiple inputs, all signed for the primary path but with an ECDSA
+        // signature. We must not account for those signatures since this is a Taproot descriptor.
+        let psbt: Psbt = psbt_from_str("cHNidP8BAP0fAQIAAAAGAGo6V8K5MtKcQ8vRFedf5oJiOREiH4JJcEniyRv2800BAAAAAP3///9e3dVLjWKPAGwDeuUOmKFzOYEP5Ipu4LWdOPA+lITrRgAAAAAA/f///7cl9oeu9ssBXKnkWMCUnlgZPXhb+qQO2+OPeLEsbdGkAQAAAAD9////idkxRErbs34vsHUZ7QCYaiVaAFDV9gxNvvtwQLozwHsAAAAAAP3///9EakyJhd2PjwYh1I7zT2cmcTFI5g1nBd3srLeL7wKEewIAAAAA/f///7BcaP77nMaA2NjT/hyI6zueB/2jU/jK4oxmSqMaFkAzAQAAAAD9////AUAfAAAAAAAAFgAUqo7zdMr638p2kC3bXPYcYLv9nYUAAAAAAAEA/X4BAgAAAAABApEoe5xCmSi8hNTtIFwsy46aj3hlcLrtFrug39v5wy+EAQAAAGpHMEQCIDeI8JTWCTyX6opCCJBhWc4FytH8g6fxDaH+Wa/QqUoMAiAgbITpz8TBhwxhv/W4xEXzehZpOjOTjKnPw36GIy6SHAEhA6QnYCHUbU045FVh6ZwRwYTVineqRrB9tbqagxjaaBKh/v///+v1seDE9gGsZiWwewQs3TKuh0KSBIHiEtG8ABbz2DpAAQAAAAD+////Aqhaex4AAAAAFgAUkcVOEjVMct0jyCzhZN6zBT+lvTQvIAAAAAAAACIAIKKDUd/GWjAnwU99llS9TAK2dK80/nSRNLjmrhj0odUEAAJHMEQCICSn+boh4ItAa3/b4gRUpdfblKdcWtMLKZrgSEFFrC+zAiBtXCx/Dq0NutLSu1qmzFF1lpwSCB3w3MAxp5W90z7b/QEhA51S2ERUi0bg+l+bnJMJeAfDknaetMTagfQR9+AOrVKlxdMkAAEBKy8gAAAAAAAAIgAgooNR38ZaMCfBT32WVL1MArZ0rzT+dJE0uOauGPSh1QQiAgN+zbSfdr8oJBtlKomnQTHynF2b/UhovAwf0eS8awRSqUgwRQIhAJhm6xQvxt2LY+eNZqjhsgMOAxD0OPYty6nf9WaQZtgkAiBf/AXkeyq6ALknO9TZwY6ZRa0evY+DQ3j3XaqiBiAMfgEBBUEhA37NtJ92vygkG2UqiadBMfKcXZv9SGi8DB/R5LxrBFKprHNkdqkUxttmGj2sqzzaxSaacJTnJPDCbY6IrVqyaCIGAv9qeBDEB+5kvM/sZ8jQ7QApfZcDrqtq5OAe2gQ1V+pmDIpk8qkAAAAA0AAAACIGA37NtJ92vygkG2UqiadBMfKcXZv9SGi8DB/R5LxrBFKpDPWswv0AAAAA0AAAAAABAOoCAAAAAAEB0OPoVJs9ihvnAwjO16k/wGJuEus1IEE1Yo2KBjC2NSEAAAAAAP7///8C6AMAAAAAAAAiACBfeUS9jQv6O1a96Aw/mPV6gHxHl3mfj+f0frfAs2sMpP1QGgAAAAAAFgAUDS4UAIpdm1RlFYmg0OoCxW0yBT4CRzBEAiAPvbNlnhiUxLNshxN83AuK/lGWwlpXOvmcqoxsMLzIKwIgWwATJuYPf9buLe9z5SnXVnPVL0q6UZaWE5mjCvEl1RUBIQI54LFZmq9Lw0pxKpEGeqI74NnIfQmLMDcv5ySplUS1/wDMJAABASvoAwAAAAAAACIAIF95RL2NC/o7Vr3oDD+Y9XqAfEeXeZ+P5/R+t8CzawykIgICYn4eZbb6KGoxB1PEv/XPiujZFDhfoi/rJPtfHPVML2lHMEQCIDOHEqKdBozXIPLVgtBj3eWC1MeIxcKYDADe4zw0DbcMAiAq4+dbkTNCAjyCxJi0TKz5DWrPulxrqOdjMRHWngXHsQEBBUEhAmJ+HmW2+ihqMQdTxL/1z4ro2RQ4X6Iv6yT7Xxz1TC9prHNkdqkUzc/gCLoe6rQw63CGXhIR3YRz1qCIrVqyaCIGAmJ+HmW2+ihqMQdTxL/1z4ro2RQ4X6Iv6yT7Xxz1TC9pDPWswv0AAAAAqgAAACIGA8JCTIzdSoTJhiKN1pn+NnlkyuKOndiTgH2NIX+yNsYqDIpk8qkAAAAAqgAAAAABAOoCAAAAAAEBRGpMiYXdj48GIdSO809nJnExSOYNZwXd7Ky3i+8ChHsAAAAAAP7///8COMMQAAAAAAAWABQ5rnyuG5T8iuhqfaGAmpzlybo3t+gDAAAAAAAAIgAg7Kz3CX1RBjIvbK9LBYztmi7F1XIxQpX6mtCUkflvvl8CRzBEAiBaYx4sOHckEZwDnSrbb1ivc6seX4Puasm1PBGnBWgSTQIgCeUiXvd90ajI3F4/BHifLUI4fVIgVQFCqLTbbeXQD5oBIQOmGm+gTRx1slzF+wn8NhZoR1xfSYgoKX6bpRSVRjLcEXrOJAABASvoAwAAAAAAACIAIOys9wl9UQYyL2yvSwWM7ZouxdVyMUKV+prQlJH5b75fIgID0X2UJhC5+2jgJqUrihxZxDZHK7jgPFlrUYzoSHQTmP9HMEQCIEM4K8lVACvE2oSMZHDJiOeD81qsYgAvgpRgcSYgKc3AAiAQjdDr2COBea69W+2iVbnODuH3QwacgShW3dS4yeggJAEBBUEhA9F9lCYQufto4CalK4ocWcQ2Ryu44DxZa1GM6Eh0E5j/rHNkdqkU0DTexcgOQQ+BFjgS031OTxcWiH2IrVqyaCIGA9F9lCYQufto4CalK4ocWcQ2Ryu44DxZa1GM6Eh0E5j/DPWswv0AAAAAvwAAACIGA/xg4Uvem3JHVPpyTLP5JWiUH/yk3Y/uUI6JkZasCmHhDIpk8qkAAAAAvwAAAAABAOoCAAAAAAEBmG+mPq0O6QSWEMctsMjvv5LzWHGoT8wsA9Oa05kxIxsBAAAAAP7///8C6AMAAAAAAAAiACDUvIILFr0OxybADV3fB7ms7+ufnFZgicHR0nbI+LFCw1UoGwAAAAAAFgAUC+1ZjCC1lmMcvJ/4JkevqoZF4igCRzBEAiA3d8o96CNgNWHUkaINWHTvAUinjUINvXq0KBeWcsSWuwIgKfzRNWFR2LDbnB/fMBsBY/ylVXcSYwLs8YC+kmko1zIBIQOpEfsLv0htuertA1sgzCwGvHB0vE4zFO69wWEoHClKmAfMJAABASvoAwAAAAAAACIAINS8ggsWvQ7HJsANXd8Huazv65+cVmCJwdHSdsj4sULDIgID96jZc0sCi0IIXf2CpfE7tY+9LRmMsOdSTTHelFxfCwJHMEQCIHlaiMMznx8Cag8Y3X2gXi9Qtg0ZuyHEC6DsOzipSGOKAiAV2eC+S3Mbq6ig5QtRvTBsq5M3hCBdEJQlOrLVhWWt6AEBBUEhA/eo2XNLAotCCF39gqXxO7WPvS0ZjLDnUk0x3pRcXwsCrHNkdqkUyJ+Cbx7vYVY665yjJnMNODyYrAuIrVqyaCIGAt8UyDXk+mW3Y6IZNIBuDJHkdOaZi/UEShkN5L3GiHR5DIpk8qkAAAAAuAAAACIGA/eo2XNLAotCCF39gqXxO7WPvS0ZjLDnUk0x3pRcXwsCDPWswv0AAAAAuAAAAAABAP0JAQIAAAAAAQG7Zoy4I3J9x+OybAlIhxVKcYRuPFrkDFJfxMiC3kIqIAEAAAAA/v///wO5xxAAAAAAABYAFHgBzs9wJNVk6YwR81IMKmckTmC56AMAAAAAAAAWABTQ/LmJix5JoHBOr8LcgEChXHdLROgDAAAAAAAAIgAg7Kz3CX1RBjIvbK9LBYztmi7F1XIxQpX6mtCUkflvvl8CRzBEAiA+sIKnWVE3SmngjUgJdu1K2teW6eqeolfGe0d11b+irAIgL20zSabXaFRNM8dqVlcFsfNJ0exukzvxEOKl/OcF8VsBIQJrUspHq45AMSwbm24//2a9JM8XHFWbOKpyV+gNCtW71nrOJAABASvoAwAAAAAAACIAIOys9wl9UQYyL2yvSwWM7ZouxdVyMUKV+prQlJH5b75fIgID0X2UJhC5+2jgJqUrihxZxDZHK7jgPFlrUYzoSHQTmP9IMEUCIQCmDhJ9fyhlQwPruoOUemDuldtRu3ZkiTM3DA0OhkguSQIgYerNaYdP43DcqI5tnnL3n4jEeMHFCs+TBkOd6hDnqAkBAQVBIQPRfZQmELn7aOAmpSuKHFnENkcruOA8WWtRjOhIdBOY/6xzZHapFNA03sXIDkEPgRY4EtN9Tk8XFoh9iK1asmgiBgPRfZQmELn7aOAmpSuKHFnENkcruOA8WWtRjOhIdBOY/wz1rML9AAAAAL8AAAAiBgP8YOFL3ptyR1T6ckyz+SVolB/8pN2P7lCOiZGWrAph4QyKZPKpAAAAAL8AAAAAAQDqAgAAAAABAT6/vc6qBRzhQyjVtkC25NS2BvGyl2XjjEsw3e8vAesjAAAAAAD+////AgPBAO4HAAAAFgAUEwiWd/qI1ergMUw0F1+qLys5G/foAwAAAAAAACIAIOOPEiwmp2ZXR7ciyrveITXw0tn6zbQUA1Eikd9QlHRhAkcwRAIgJMZdO5A5u2UIMrAOgrR4NcxfNgZI6OfY7GKlZP0O8yUCIDFujbBRnamLEbf0887qidnXo6UgQA9IwTx6Zomd4RvJASEDoNmR2/XcqSyCWrE1tjGJ1oLWlKt4zsFekK9oyB4Hl0HF0yQAAQEr6AMAAAAAAAAiACDjjxIsJqdmV0e3Isq73iE18NLZ+s20FANRIpHfUJR0YSICAo3uyJxKHR9Z8fwvU7cywQCnZyPvtMl3nv54wPW1GSGqSDBFAiEAlLY98zqEL/xTUvm9ZKy5kBa4UWfr4Ryu6BmSZjseXPQCIGy7efKbZLQSDq8RhgNNjl1384gWFTN7nPwWV//SGriyAQEFQSECje7InEodH1nx/C9TtzLBAKdnI++0yXee/njA9bUZIaqsc2R2qRQhPRlaLsh/M/K/9fvbjxF/M20cNoitWrJoIgYCF7Rj5jFhe5L6VDzP5m2BeaG0mA9e7+6fMeWkWxLwpbAMimTyqQAAAADNAAAAIgYCje7InEodH1nx/C9TtzLBAKdnI++0yXee/njA9bUZIaoM9azC/QAAAADNAAAAAAA=");
+        let info = desc.partial_spend_info(&psbt).unwrap();
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.partial_sigs.len() == 1));
+        assert_eq!(info.primary_path.threshold, 1);
+        assert_eq!(info.primary_path.sigs_count, 0);
+        assert!(info.primary_path.signed_pubkeys.is_empty());
+        assert!(info.recovery_paths.is_empty());
+
+        // If we analyze a descriptor with a multisig we'll get the right threshold.
+        let desc = LianaDescriptor::new(
+            LianaPolicy::new(
+                PathInfo::Multi(
+                    2,
+                    vec![
+                        descriptor::DescriptorPublicKey::from_str("[f5acc2fd]tpubD6NzVbkrYhZ4YgUx2ZLNt2rLYAMTdYysCRzKoLu2BeSHKvzqPaBDvf17GeBPnExUVPkuBpx4kniP964e2MxyzzazcXLptxLXModSVCVEV1T/<0;1>/*").unwrap(),
+                        random_desc_key(&secp),
+                        random_desc_key(&secp),
+                    ],
+                ),
+                [(
+                    42,
+                    PathInfo::Multi(
+                        1,
+                        (0..3).map(|_| random_desc_key(&secp)).collect()
+                    ),
+                )]
+                .iter()
+                .cloned()
+                .collect(),
+            )
+            .unwrap(),
+        );
+        let prim_key = dummy_xonly_pubkeys[0];
+        let mut psbt = dummy_psbt.clone();
+        psbt.inputs[0]
+            .tap_script_sigs
+            .insert((prim_key, dummy_leafhash), dummy_sig);
+        psbt.inputs[0].tap_key_origins =
+            [(prim_key, (vec![dummy_leafhash], prim_key_origin.clone()))]
+                .iter()
+                .cloned()
+                .collect();
+        let info = desc.partial_spend_info(&psbt).unwrap();
+        assert_eq!(info.primary_path.threshold, 2);
+        assert_eq!(info.primary_path.sigs_count, 1);
+        assert!(
+            info.primary_path.signed_pubkeys.len() == 1
+                && info.primary_path.signed_pubkeys.contains_key(&prim_key_fg)
+        );
+        assert!(info.recovery_paths.is_empty());
+
+        // A not very well thought-out decaying multisig.
+        let prim_path = PathInfo::Multi(3, vec![
+                        descriptor::DescriptorPublicKey::from_str("[f5acc2fd]tpubD6NzVbkrYhZ4YgUx2ZLNt2rLYAMTdYysCRzKoLu2BeSHKvzqPaBDvf17GeBPnExUVPkuBpx4kniP964e2MxyzzazcXLptxLXModSVCVEV1T/<0;1>/*").unwrap(),
+                        random_desc_key(&secp),
+                        random_desc_key(&secp),
+                    ]);
+        let first_reco_path = PathInfo::Multi(3, (0..5).map(|_| random_desc_key(&secp)).collect());
+        let sec_reco_path = PathInfo::Multi(2, (0..5).map(|_| random_desc_key(&secp)).collect());
+        let third_reco_path = PathInfo::Multi(1, (0..5).map(|_| random_desc_key(&secp)).collect());
+        let liana_policy = LianaPolicy::new(
+            prim_path.clone(),
+            [
+                (26784, first_reco_path.clone()),
+                (53568, sec_reco_path.clone()),
+                (62496, third_reco_path.clone()),
+            ]
+            .iter()
+            .cloned()
+            .collect(),
+        )
+        .unwrap();
+        let desc = LianaDescriptor::new(liana_policy.clone());
+        let policy = desc.policy();
+        assert_eq!(policy, liana_policy);
+        let mut psbt = dummy_psbt.clone();
+        let empty_partial_info = desc.partial_spend_info(&psbt).unwrap();
+        assert_eq!(empty_partial_info.primary_path.threshold, 3);
+        assert_eq!(empty_partial_info.primary_path.sigs_count, 0);
+        assert_eq!(
+            empty_partial_info.primary_path.sigs_count,
+            empty_partial_info.primary_path.signed_pubkeys.len()
+        );
+        assert!(empty_partial_info.recovery_paths.is_empty());
+
+        // Now set a signature for the primary path. All recovery paths still empty, a signature is
+        // present for the primary path.
+        let prim_key = dummy_xonly_pubkeys[0];
+        psbt.inputs[0]
+            .tap_script_sigs
+            .insert((prim_key, dummy_leafhash), dummy_sig);
+        psbt.inputs[0].tap_key_origins =
+            [(prim_key, (vec![dummy_leafhash], prim_key_origin.clone()))]
+                .iter()
+                .cloned()
+                .collect();
+        let partial_info = desc.partial_spend_info(&psbt).unwrap();
+        assert_eq!(partial_info.primary_path.threshold, 3);
+        assert_eq!(partial_info.primary_path.sigs_count, 1);
+        assert_eq!(
+            partial_info.primary_path.sigs_count,
+            partial_info.primary_path.signed_pubkeys.len()
+        );
+        assert!(partial_info.recovery_paths.is_empty());
+
+        // Now enable the first recovery path and make the signature be for this path.
+        let fingerprint = first_reco_path
+            .thresh_origins()
+            .1
+            .into_iter()
+            .next()
+            .unwrap()
+            .0;
+        psbt.inputs[0]
+            .tap_key_origins
+            .get_mut(&prim_key)
+            .unwrap()
+            .1
+             .0 = fingerprint;
+        let partial_info = desc.partial_spend_info(&psbt).unwrap();
+        assert_eq!(partial_info.primary_path.threshold, 3);
+        assert_eq!(partial_info.primary_path.sigs_count, 0);
+        assert_eq!(
+            partial_info.primary_path.sigs_count,
+            partial_info.primary_path.signed_pubkeys.len()
+        );
+        assert!(partial_info.recovery_paths.is_empty());
+        psbt.unsigned_tx.input[0].sequence = bitcoin::Sequence::from_height(26784);
+        let partial_info = desc.partial_spend_info(&psbt).unwrap();
+        assert_eq!(partial_info.recovery_paths.len(), 1);
+        assert_eq!(partial_info.recovery_paths[&26784].threshold, 3);
+        assert_eq!(partial_info.recovery_paths[&26784].sigs_count, 1);
+        assert_eq!(
+            partial_info.recovery_paths[&26784].signed_pubkeys.len(),
+            partial_info.recovery_paths[&26784].sigs_count
+        );
+
+        // Now enable the second recovery path and make the signature be for this path.
+        let fingerprint = sec_reco_path
+            .thresh_origins()
+            .1
+            .into_iter()
+            .next()
+            .unwrap()
+            .0;
+        psbt.inputs[0]
+            .tap_key_origins
+            .get_mut(&prim_key)
+            .unwrap()
+            .1
+             .0 = fingerprint;
+        psbt.unsigned_tx.input[0].sequence = bitcoin::Sequence::from_height(53568);
+        let partial_info = desc.partial_spend_info(&psbt).unwrap();
+        assert_eq!(partial_info.primary_path.threshold, 3);
+        assert_eq!(partial_info.primary_path.sigs_count, 0);
+        assert_eq!(
+            partial_info.primary_path.sigs_count,
+            partial_info.primary_path.signed_pubkeys.len()
+        );
+        assert_eq!(partial_info.recovery_paths.len(), 2);
+        assert_eq!(partial_info.recovery_paths[&26784].threshold, 3);
+        assert_eq!(partial_info.recovery_paths[&26784].sigs_count, 0);
+        assert_eq!(partial_info.recovery_paths[&53568].threshold, 2);
+        assert_eq!(partial_info.recovery_paths[&53568].sigs_count, 1);
+        for rec_path in partial_info.recovery_paths.values() {
+            assert_eq!(rec_path.sigs_count, rec_path.signed_pubkeys.len());
+        }
+
+        // Finally do the same for the third recovery path.
+        let fingerprint = third_reco_path
+            .thresh_origins()
+            .1
+            .into_iter()
+            .next()
+            .unwrap()
+            .0;
+        psbt.inputs[0]
+            .tap_key_origins
+            .get_mut(&prim_key)
+            .unwrap()
+            .1
+             .0 = fingerprint;
+        psbt.unsigned_tx.input[0].sequence = bitcoin::Sequence::from_height(53568);
+        psbt.unsigned_tx.input[0].sequence = bitcoin::Sequence::from_height(62496);
+        let partial_info = desc.partial_spend_info(&psbt).unwrap();
+        assert_eq!(partial_info.primary_path.threshold, 3);
+        assert_eq!(partial_info.primary_path.sigs_count, 0);
+        assert_eq!(
+            partial_info.primary_path.sigs_count,
+            partial_info.primary_path.signed_pubkeys.len()
+        );
+        assert_eq!(partial_info.recovery_paths.len(), 3);
+        assert_eq!(partial_info.recovery_paths[&26784].threshold, 3);
+        assert_eq!(partial_info.recovery_paths[&26784].sigs_count, 0);
+        assert_eq!(partial_info.recovery_paths[&53568].threshold, 2);
+        assert_eq!(partial_info.recovery_paths[&53568].sigs_count, 0);
+        assert_eq!(partial_info.recovery_paths[&62496].threshold, 1);
+        assert_eq!(partial_info.recovery_paths[&62496].sigs_count, 1);
+        for rec_path in partial_info.recovery_paths.values() {
+            assert_eq!(rec_path.sigs_count, rec_path.signed_pubkeys.len());
+        }
+    }
+
     #[test]
     fn bip32_derivs_pruning() {
+        let secp = secp256k1::Secp256k1::signing_only();
+        let dummy_leafhash = bitcoin::TapLeafHash::from_slice(&[0; 32]).unwrap();
+
         // A signet descriptor created using Liana v2.
         let desc = LianaDescriptor::from_str("wsh(or_i(and_v(v:thresh(3,pkh([636adf3f/48'/1'/0'/2']tpubDEE9FvWbG4kg4gxDNrALgrWLiHwNMXNs8hk6nXNPw4VHKot16xd2251vwi2M6nsyQTkak5FJNHVHkCcuzmvpSbWHdumX3DxpDm89iTfSBaL/<4;5>/*),a:pkh([172ba1bc/48'/1'/0'/2']tpubDEgTZEAraUrKmnbyKJuXYGFPzNCm82bjMqd2GRy2HKviJ1moLtEZrHoUeG2o6uyWLEGx4yBWpctAmxcBx1b5nrrrBo5LjskRxRMDmwkuKxq/<4;5>/*),a:pkh([903115ef/48'/1'/0'/2']tpubDF2Hqd3HXUn5bDMVa2gssqmdTjQsLm9Vc8CSSJFk4YwQg8PChCZiWopAeQ6ZCEWt21n1W8ApEGxEvtB8uPnWW6EG3fwPAFnFM8US4QmgKvp/<4;5>/*)),older(6)),or_d(multi(3,[636adf3f/48'/1'/0'/2']tpubDEE9FvWbG4kg4gxDNrALgrWLiHwNMXNs8hk6nXNPw4VHKot16xd2251vwi2M6nsyQTkak5FJNHVHkCcuzmvpSbWHdumX3DxpDm89iTfSBaL/<0;1>/*,[172ba1bc/48'/1'/0'/2']tpubDEgTZEAraUrKmnbyKJuXYGFPzNCm82bjMqd2GRy2HKviJ1moLtEZrHoUeG2o6uyWLEGx4yBWpctAmxcBx1b5nrrrBo5LjskRxRMDmwkuKxq/<0;1>/*,[903115ef/48'/1'/0'/2']tpubDF2Hqd3HXUn5bDMVa2gssqmdTjQsLm9Vc8CSSJFk4YwQg8PChCZiWopAeQ6ZCEWt21n1W8ApEGxEvtB8uPnWW6EG3fwPAFnFM8US4QmgKvp/<0;1>/*),and_v(v:thresh(2,pkh([636adf3f/48'/1'/0'/2']tpubDEE9FvWbG4kg4gxDNrALgrWLiHwNMXNs8hk6nXNPw4VHKot16xd2251vwi2M6nsyQTkak5FJNHVHkCcuzmvpSbWHdumX3DxpDm89iTfSBaL/<2;3>/*),a:pkh([172ba1bc/48'/1'/0'/2']tpubDEgTZEAraUrKmnbyKJuXYGFPzNCm82bjMqd2GRy2HKviJ1moLtEZrHoUeG2o6uyWLEGx4yBWpctAmxcBx1b5nrrrBo5LjskRxRMDmwkuKxq/<2;3>/*),a:pkh([903115ef/48'/1'/0'/2']tpubDF2Hqd3HXUn5bDMVa2gssqmdTjQsLm9Vc8CSSJFk4YwQg8PChCZiWopAeQ6ZCEWt21n1W8ApEGxEvtB8uPnWW6EG3fwPAFnFM8US4QmgKvp/<2;3>/*)),older(3)))))#jxya9h7u").unwrap();
         // A spend PSBT created using Liana v2.
@@ -1347,6 +1876,70 @@ mod tests {
         let psbt = desc.prune_bip32_derivs_last_avail(psbt).unwrap();
         assert_eq!(psbt.inputs[0].bip32_derivation.len(), 3);
         assert_eq!(psbt, pruned_psbt);
+
+        // Now do the same but with a Taproot descriptor.
+        let (prim_key, rec_key) = (random_desc_key(&secp), random_desc_key(&secp));
+        let prim_origin =
+            if let descriptor::DescriptorPublicKey::MultiXPub(descriptor::DescriptorMultiXKey {
+                ref origin,
+                ..
+            }) = prim_key
+            {
+                (origin.as_ref().unwrap().0, [0.into(), 1.into()][..].into())
+            } else {
+                unreachable!();
+            };
+        let rec_origin =
+            if let descriptor::DescriptorPublicKey::MultiXPub(descriptor::DescriptorMultiXKey {
+                xkey,
+                ..
+            }) = rec_key
+            {
+                (xkey.fingerprint(), [0.into(), 1.into()][..].into())
+            } else {
+                unreachable!();
+            };
+        let tap_desc = LianaDescriptor::new(
+            LianaPolicy::new(
+                PathInfo::Single(prim_key),
+                [(14, PathInfo::Single(rec_key))].iter().cloned().collect(),
+            )
+            .unwrap(),
+        );
+        let prim_path_info = tap_desc.policy().primary_path;
+        let rec_path_info = &tap_desc.policy().recovery_paths[&14];
+        let mut tap_psbt = psbt;
+        let dummy_xonly_pubkey = bitcoin::XOnlyPublicKey::from_str(
+            "85899827df71b16f2f0eed47cb920e3963f5204e171f5ef7ea4eeec3d5ecc607",
+        )
+        .unwrap();
+
+        // Set the origins for the primary key. Pruning all but the primary origins should conserve
+        // it. Pruning all but the recovery should drop it.
+        tap_psbt.inputs[0].bip32_derivation.clear();
+        tap_psbt.inputs[0].tap_key_origins =
+            [(dummy_xonly_pubkey, (vec![dummy_leafhash], prim_origin))]
+                .iter()
+                .cloned()
+                .collect();
+        assert_eq!(tap_psbt.inputs[0].tap_key_origins.len(), 1);
+        let tap_psbt = tap_desc.prune_bip32_derivs(tap_psbt, &prim_path_info);
+        assert_eq!(tap_psbt.inputs[0].tap_key_origins.len(), 1);
+        let mut tap_psbt = tap_desc.prune_bip32_derivs(tap_psbt, rec_path_info);
+        assert!(tap_psbt.inputs[0].tap_key_origins.is_empty());
+
+        // Do the opposite.
+        tap_psbt.inputs[0].bip32_derivation.clear();
+        tap_psbt.inputs[0].tap_key_origins =
+            [(dummy_xonly_pubkey, (vec![dummy_leafhash], rec_origin))]
+                .iter()
+                .cloned()
+                .collect();
+        assert_eq!(tap_psbt.inputs[0].tap_key_origins.len(), 1);
+        let tap_psbt = tap_desc.prune_bip32_derivs(tap_psbt, rec_path_info);
+        assert_eq!(tap_psbt.inputs[0].tap_key_origins.len(), 1);
+        let tap_psbt = tap_desc.prune_bip32_derivs(tap_psbt, &prim_path_info);
+        assert!(tap_psbt.inputs[0].tap_key_origins.is_empty());
     }
 
     #[test]
diff --git a/src/lib.rs b/src/lib.rs
index c3a3b52e..b608b24e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -229,7 +229,10 @@ fn setup_bitcoind(
         .as_ref()
         .ok_or(StartupError::MissingBitcoindConfig)?;
     let bitcoind = BitcoinD::new(bitcoind_config, wo_path_str)?;
-    bitcoind.node_sanity_checks(config.bitcoin_config.network)?;
+    bitcoind.node_sanity_checks(
+        config.bitcoin_config.network,
+        config.main_descriptor.is_taproot(),
+    )?;
     if fresh_data_dir {
         log::info!("Creating a new watchonly wallet on bitcoind.");
         bitcoind.create_watchonly_wallet(&config.main_descriptor)?;
diff --git a/src/signer.rs b/src/signer.rs
index e074e233..5b53343f 100644
--- a/src/signer.rs
+++ b/src/signer.rs
@@ -18,7 +18,8 @@ use miniscript::bitcoin::{
     bip32::{self, Error as Bip32Error},
     ecdsa,
     hashes::Hash,
-    psbt::Psbt,
+    key::TapTweak,
+    psbt::{Input as PsbtIn, Psbt},
     secp256k1, sighash,
 };
 
@@ -228,56 +229,167 @@ impl HotSigner {
         bip32::Xpub::from_priv(secp, &xpriv)
     }
 
+    // Provide an ECDSA signature for this transaction input from the PSBT input information.
+    fn sign_p2wsh(
+        &self,
+        secp: &secp256k1::Secp256k1<impl secp256k1::Signing>,
+        sighash_cache: &mut sighash::SighashCache<&bitcoin::Transaction>,
+        master_fingerprint: bip32::Fingerprint,
+        psbt_in: &mut PsbtIn,
+        input_index: usize,
+    ) -> Result<(), SignerError> {
+        // First of all compute the sighash for this input. We assume P2WSH spend: the sighash
+        // script code is always the witness script.
+        let witscript = psbt_in
+            .witness_script
+            .as_ref()
+            .ok_or(SignerError::IncompletePsbt)?;
+        let value = psbt_in
+            .witness_utxo
+            .as_ref()
+            .ok_or(SignerError::IncompletePsbt)?
+            .value;
+        let sig_type = sighash::EcdsaSighashType::All;
+        let sighash = sighash_cache
+            .p2wsh_signature_hash(input_index, witscript, value, sig_type)
+            .map_err(|_| SignerError::InsanePsbt)?;
+        let sighash = secp256k1::Message::from_digest_slice(sighash.as_byte_array())
+            .expect("Sighash is always 32 bytes.");
+
+        // Then provide a signature for all the keys they asked for.
+        for (curr_pubkey, (fingerprint, der_path)) in psbt_in.bip32_derivation.iter() {
+            if *fingerprint != master_fingerprint {
+                continue;
+            }
+            let privkey = self.xpriv_at(der_path, secp).to_priv();
+            let pubkey = privkey.public_key(secp);
+            if pubkey.inner != *curr_pubkey {
+                return Err(SignerError::InsanePsbt);
+            }
+            let sig = secp.sign_ecdsa_low_r(&sighash, &privkey.inner);
+            psbt_in.partial_sigs.insert(
+                pubkey,
+                ecdsa::Signature {
+                    sig,
+                    hash_ty: sig_type,
+                },
+            );
+        }
+
+        Ok(())
+    }
+
+    // Provide a BIP340 signature for this transaction input from the PSBT input information.
+    fn sign_taproot(
+        &self,
+        secp: &secp256k1::Secp256k1<secp256k1::All>,
+        sighash_cache: &mut sighash::SighashCache<&bitcoin::Transaction>,
+        master_fingerprint: bip32::Fingerprint,
+        prevouts: &[bitcoin::TxOut],
+        psbt_in: &mut PsbtIn,
+        input_index: usize,
+    ) -> Result<(), SignerError> {
+        let sig_type = sighash::TapSighashType::Default;
+        let prevouts = sighash::Prevouts::All(prevouts);
+
+        // If the details of the internal key are filled, provide a keypath signature.
+        if let Some(ref int_key) = psbt_in.tap_internal_key {
+            // NB: we don't check for empty leaf hashes on purpose, in case the internal key also
+            // appears in a leaf.
+            if let Some((_, (fg, der_path))) = psbt_in.tap_key_origins.get(int_key) {
+                if *fg == master_fingerprint {
+                    let privkey = self.xpriv_at(der_path, secp).to_priv();
+                    let keypair = secp256k1::Keypair::from_secret_key(secp, &privkey.inner);
+                    if keypair.x_only_public_key().0 != *int_key {
+                        return Err(SignerError::InsanePsbt);
+                    }
+                    let keypair = keypair.tap_tweak(secp, psbt_in.tap_merkle_root).to_inner();
+                    let sighash = sighash_cache
+                        .taproot_key_spend_signature_hash(input_index, &prevouts, sig_type)
+                        .map_err(|_| SignerError::InsanePsbt)?;
+                    let sighash = secp256k1::Message::from_digest_slice(sighash.as_byte_array())
+                        .expect("Sighash is always 32 bytes.");
+                    let sig = secp.sign_schnorr_no_aux_rand(&sighash, &keypair);
+                    let sig = bitcoin::taproot::Signature {
+                        sig,
+                        hash_ty: sig_type,
+                    };
+                    psbt_in.tap_key_sig = Some(sig);
+                }
+            }
+        }
+
+        // Now sign for all the public keys derived from our master secret, in all the leaves where
+        // they are present.
+        for (pubkey, (leaf_hashes, (fg, der_path))) in &psbt_in.tap_key_origins {
+            if *fg != master_fingerprint {
+                continue;
+            }
+
+            for leaf_hash in leaf_hashes {
+                let privkey = self.xpriv_at(der_path, secp).to_priv();
+                let keypair = secp256k1::Keypair::from_secret_key(secp, &privkey.inner);
+                let sighash = sighash_cache
+                    .taproot_script_spend_signature_hash(
+                        input_index,
+                        &prevouts,
+                        *leaf_hash,
+                        sig_type,
+                    )
+                    .map_err(|_| SignerError::InsanePsbt)?;
+                let sighash = secp256k1::Message::from_digest_slice(sighash.as_byte_array())
+                    .expect("Sighash is always 32 bytes.");
+                let sig = secp.sign_schnorr_no_aux_rand(&sighash, &keypair);
+                let sig = bitcoin::taproot::Signature {
+                    sig,
+                    hash_ty: sig_type,
+                };
+                psbt_in.tap_script_sigs.insert((*pubkey, *leaf_hash), sig);
+            }
+        }
+
+        Ok(())
+    }
+
     /// Sign all inputs of the given PSBT.
     ///
     /// **This does not perform any check. It will blindly sign anything that's passed.**
     pub fn sign_psbt(
         &self,
         mut psbt: Psbt,
-        secp: &secp256k1::Secp256k1<impl secp256k1::Signing>,
+        secp: &secp256k1::Secp256k1<secp256k1::All>,
     ) -> Result<Psbt, SignerError> {
         let master_fingerprint = self.fingerprint(secp);
         let mut sighash_cache = sighash::SighashCache::new(&psbt.unsigned_tx);
 
+        let prevouts: Vec<_> = psbt
+            .inputs
+            .iter()
+            .filter_map(|psbt_in| psbt_in.witness_utxo.clone())
+            .collect();
+        if prevouts.len() != psbt.inputs.len() {
+            return Err(SignerError::IncompletePsbt);
+        }
+
         // Sign each input in the PSBT.
         for i in 0..psbt.inputs.len() {
-            // First of all compute the sighash for this input. We assume P2WSH spend: the sighash
-            // script code is always the witness script.
-            let witscript = psbt.inputs[i]
-                .witness_script
-                .as_ref()
-                .ok_or(SignerError::IncompletePsbt)?;
-            let value = psbt.inputs[i]
-                .witness_utxo
-                .as_ref()
-                .ok_or(SignerError::IncompletePsbt)?
-                .value;
-            let sig_type = sighash::EcdsaSighashType::All;
-            let sighash = sighash_cache
-                .p2wsh_signature_hash(i, witscript, value, sig_type)
-                .map_err(|_| SignerError::InsanePsbt)?;
-            let sighash = secp256k1::Message::from_digest_slice(sighash.as_byte_array())
-                .expect("Sighash is always 32 bytes.");
-
-            // Then provide a signature for all the keys they asked for.
-            let input = &mut psbt.inputs[i]; // for borrowck reasons
-            for (curr_pubkey, (fingerprint, der_path)) in input.bip32_derivation.iter() {
-                if *fingerprint != master_fingerprint {
-                    continue;
-                }
-                let privkey = self.xpriv_at(der_path, secp).to_priv();
-                let pubkey = privkey.public_key(secp);
-                if pubkey.inner != *curr_pubkey {
-                    return Err(SignerError::InsanePsbt);
-                }
-                let sig = secp.sign_ecdsa_low_r(&sighash, &privkey.inner);
-                input.partial_sigs.insert(
-                    pubkey,
-                    ecdsa::Signature {
-                        sig,
-                        hash_ty: sig_type,
-                    },
-                );
+            if psbt.inputs[i].witness_script.is_some() {
+                self.sign_p2wsh(
+                    secp,
+                    &mut sighash_cache,
+                    master_fingerprint,
+                    &mut psbt.inputs[i],
+                    i,
+                )?;
+            } else {
+                self.sign_taproot(
+                    secp,
+                    &mut sighash_cache,
+                    master_fingerprint,
+                    &prevouts,
+                    &mut psbt.inputs[i],
+                    i,
+                )?;
             }
         }
 
@@ -357,7 +469,7 @@ mod tests {
     }
 
     #[test]
-    fn hot_signer_sign() {
+    fn hot_signer_sign_p2wsh() {
         let secp = secp256k1::Secp256k1::new();
         let network = bitcoin::Network::Bitcoin;
 
@@ -419,14 +531,22 @@ mod tests {
             wildcard: Wildcard::Unhardened,
         });
         let recov_keys = descriptors::PathInfo::Single(recov_key);
-        let policy =
-            descriptors::LianaPolicy::new(prim_keys, [(46, recov_keys)].iter().cloned().collect())
-                .unwrap();
+        let policy = descriptors::LianaPolicy::new_legacy(
+            prim_keys,
+            [(46, recov_keys)].iter().cloned().collect(),
+        )
+        .unwrap();
         let desc = descriptors::LianaDescriptor::new(policy);
 
         // Create a dummy PSBT spending a coin from this descriptor with a single input and single
         // (external) output. We'll be modifying it as we go.
         let spent_coin_desc = desc.receive_descriptor().derive(42.into(), &secp);
+        let mut psbt_in = PsbtIn::default();
+        spent_coin_desc.update_psbt_in(&mut psbt_in);
+        psbt_in.witness_utxo = Some(bitcoin::TxOut {
+            value: Amount::from_sat(19_000),
+            script_pubkey: spent_coin_desc.script_pubkey(),
+        });
         let mut dummy_psbt = Psbt {
             unsigned_tx: bitcoin::Transaction {
                 version: bitcoin::transaction::Version::TWO,
@@ -453,15 +573,7 @@ mod tests {
             xpub: BTreeMap::new(),
             proprietary: BTreeMap::new(),
             unknown: BTreeMap::new(),
-            inputs: vec![PsbtIn {
-                witness_script: Some(spent_coin_desc.witness_script()),
-                bip32_derivation: spent_coin_desc.bip32_derivations(),
-                witness_utxo: Some(bitcoin::TxOut {
-                    value: Amount::from_sat(19_000),
-                    script_pubkey: spent_coin_desc.script_pubkey(),
-                }),
-                ..PsbtIn::default()
-            }],
+            inputs: vec![psbt_in],
             outputs: Vec::new(),
         };
 
@@ -469,12 +581,20 @@ mod tests {
         // that it manages.
         let psbt = dummy_psbt.clone();
         assert!(psbt.inputs[0].partial_sigs.is_empty());
+        assert!(psbt.inputs[0].tap_key_sig.is_none());
+        assert!(psbt.inputs[0].tap_script_sigs.is_empty());
         let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap();
         assert_eq!(psbt.inputs[0].partial_sigs.len(), 1);
+        assert!(psbt.inputs[0].tap_key_sig.is_none());
+        assert!(psbt.inputs[0].tap_script_sigs.is_empty());
         let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap();
         assert_eq!(psbt.inputs[0].partial_sigs.len(), 2);
+        assert!(psbt.inputs[0].tap_key_sig.is_none());
+        assert!(psbt.inputs[0].tap_script_sigs.is_empty());
         let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap();
         assert_eq!(psbt.inputs[0].partial_sigs.len(), 4);
+        assert!(psbt.inputs[0].tap_key_sig.is_none());
+        assert!(psbt.inputs[0].tap_script_sigs.is_empty());
 
         // We can add another external output to the transaction, we can still sign without issue.
         // The output can be insane, we don't check it. It doesn't even need an accompanying PSBT
@@ -482,25 +602,31 @@ mod tests {
         dummy_psbt.unsigned_tx.output.push(bitcoin::TxOut::NULL);
         let psbt = dummy_psbt.clone();
         assert!(psbt.inputs[0].partial_sigs.is_empty());
+        assert!(psbt.inputs[0].tap_key_sig.is_none());
+        assert!(psbt.inputs[0].tap_script_sigs.is_empty());
         let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap();
         assert_eq!(psbt.inputs[0].partial_sigs.len(), 1);
+        assert!(psbt.inputs[0].tap_key_sig.is_none());
+        assert!(psbt.inputs[0].tap_script_sigs.is_empty());
         let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap();
         assert_eq!(psbt.inputs[0].partial_sigs.len(), 2);
+        assert!(psbt.inputs[0].tap_key_sig.is_none());
+        assert!(psbt.inputs[0].tap_script_sigs.is_empty());
         let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap();
         assert_eq!(psbt.inputs[0].partial_sigs.len(), 4);
+        assert!(psbt.inputs[0].tap_key_sig.is_none());
+        assert!(psbt.inputs[0].tap_script_sigs.is_empty());
 
         // We can add another input to the PSBT. If we don't attach also another transaction input
         // it will fail.
         let other_spent_coin_desc = desc.receive_descriptor().derive(84.into(), &secp);
-        dummy_psbt.inputs.push(PsbtIn {
-            witness_script: Some(other_spent_coin_desc.witness_script()),
-            bip32_derivation: other_spent_coin_desc.bip32_derivations(),
-            witness_utxo: Some(bitcoin::TxOut {
-                value: Amount::from_sat(19_000),
-                script_pubkey: other_spent_coin_desc.script_pubkey(),
-            }),
-            ..PsbtIn::default()
+        let mut psbt_in = PsbtIn::default();
+        other_spent_coin_desc.update_psbt_in(&mut psbt_in);
+        psbt_in.witness_utxo = Some(bitcoin::TxOut {
+            value: Amount::from_sat(19_000),
+            script_pubkey: other_spent_coin_desc.script_pubkey(),
         });
+        dummy_psbt.inputs.push(psbt_in);
         let psbt = dummy_psbt.clone();
         assert!(prim_signer_a
             .sign_psbt(psbt, &secp)
@@ -524,31 +650,63 @@ mod tests {
             .inputs
             .iter()
             .all(|psbt_in| psbt_in.partial_sigs.is_empty()));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_script_sigs.is_empty()));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_key_sig.is_none()));
         let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap();
         assert!(psbt
             .inputs
             .iter()
             .all(|psbt_in| psbt_in.partial_sigs.len() == 1));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_script_sigs.is_empty()));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_key_sig.is_none()));
         let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap();
         assert!(psbt
             .inputs
             .iter()
             .all(|psbt_in| psbt_in.partial_sigs.len() == 2));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_script_sigs.is_empty()));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_key_sig.is_none()));
         let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap();
         assert!(psbt
             .inputs
             .iter()
             .all(|psbt_in| psbt_in.partial_sigs.len() == 4));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_script_sigs.is_empty()));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_key_sig.is_none()));
 
-        // If the witness script is missing for one of the inputs it'll tell us the PSBT is
-        // incomplete.
+        // If the witness script is missing for one of the inputs it'll assume it's a Taproot input
+        // and provide Taproot signatures. But since we haven't provided any Taproot details it
+        // won't fill anything.
         let mut psbt = dummy_psbt.clone();
         psbt.inputs[1].witness_script = None;
-        assert!(prim_signer_a
-            .sign_psbt(psbt, &secp)
-            .unwrap_err()
-            .to_string()
-            .contains("The PSBT is missing some information necessary for signing."));
+        let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap();
+        assert!(psbt.inputs[1].partial_sigs.is_empty());
+        assert!(psbt.inputs[1].tap_key_sig.is_none());
+        assert!(psbt.inputs[1].tap_script_sigs.is_empty());
 
         // If the witness utxo is missing for one of the inputs it'll tell us the PSBT is
         // incomplete.
@@ -571,6 +729,322 @@ mod tests {
         assert_eq!(psbt.inputs[1].partial_sigs.len(), 1);
     }
 
+    #[test]
+    fn hot_signer_sign_taproot() {
+        let secp = secp256k1::Secp256k1::new();
+        let network = bitcoin::Network::Bitcoin;
+
+        // Create a Liana descriptor with as primary path a 2-of-3 with three hot signers and a
+        // single hot signer as recovery path. (The recovery path signer is also used in the
+        // primary path.) Use various random derivation paths.
+        let (prim_signer_a, prim_signer_b, recov_signer) = (
+            HotSigner::generate(network).unwrap(),
+            HotSigner::generate(network).unwrap(),
+            HotSigner::generate(network).unwrap(),
+        );
+        let origin_der = bip32::DerivationPath::from_str("m/0'/12'/42").unwrap();
+        let xkey = prim_signer_a.xpub_at(&origin_der, &secp);
+        let prim_key_a = DescriptorPublicKey::MultiXPub(DescriptorMultiXKey {
+            origin: Some((prim_signer_a.fingerprint(&secp), origin_der)),
+            xkey,
+            derivation_paths: DerivPaths::new(vec![
+                bip32::DerivationPath::from_str("m/420/56/0").unwrap(),
+                bip32::DerivationPath::from_str("m/420/56/1").unwrap(),
+            ])
+            .unwrap(),
+            wildcard: Wildcard::Unhardened,
+        });
+        let origin_der = bip32::DerivationPath::from_str("m/18'/24'").unwrap();
+        let xkey = prim_signer_b.xpub_at(&origin_der, &secp);
+        let prim_key_b = DescriptorPublicKey::MultiXPub(DescriptorMultiXKey {
+            origin: Some((prim_signer_b.fingerprint(&secp), origin_der)),
+            xkey,
+            derivation_paths: DerivPaths::new(vec![
+                bip32::DerivationPath::from_str("m/31/0").unwrap(),
+                bip32::DerivationPath::from_str("m/31/1").unwrap(),
+            ])
+            .unwrap(),
+            wildcard: Wildcard::Unhardened,
+        });
+        let origin_der = bip32::DerivationPath::from_str("m/18'/25'").unwrap();
+        let xkey = recov_signer.xpub_at(&origin_der, &secp);
+        let prim_key_c = DescriptorPublicKey::MultiXPub(DescriptorMultiXKey {
+            origin: Some((recov_signer.fingerprint(&secp), origin_der)),
+            xkey,
+            derivation_paths: DerivPaths::new(vec![
+                bip32::DerivationPath::from_str("m/0").unwrap(),
+                bip32::DerivationPath::from_str("m/1").unwrap(),
+            ])
+            .unwrap(),
+            wildcard: Wildcard::Unhardened,
+        });
+        let prim_keys =
+            descriptors::PathInfo::Multi(2, vec![prim_key_a.clone(), prim_key_b, prim_key_c]);
+        let origin_der = bip32::DerivationPath::from_str("m/1/2'/3/4'").unwrap();
+        let xkey = recov_signer.xpub_at(&origin_der, &secp);
+        let recov_key = DescriptorPublicKey::MultiXPub(DescriptorMultiXKey {
+            origin: Some((recov_signer.fingerprint(&secp), origin_der)),
+            xkey,
+            derivation_paths: DerivPaths::new(vec![
+                bip32::DerivationPath::from_str("m/5/6/0").unwrap(),
+                bip32::DerivationPath::from_str("m/5/6/1").unwrap(),
+            ])
+            .unwrap(),
+            wildcard: Wildcard::Unhardened,
+        });
+        let recov_keys = descriptors::PathInfo::Single(recov_key.clone());
+        let policy =
+            descriptors::LianaPolicy::new(prim_keys, [(46, recov_keys)].iter().cloned().collect())
+                .unwrap();
+        let desc = descriptors::LianaDescriptor::new(policy);
+
+        // Create a dummy PSBT spending a coin from this descriptor with a single input and single
+        // (external) output. We'll be modifying it as we go.
+        let spent_coin_desc = desc.receive_descriptor().derive(42.into(), &secp);
+        let mut psbt_in = PsbtIn::default();
+        spent_coin_desc.update_psbt_in(&mut psbt_in);
+        psbt_in.witness_utxo = Some(bitcoin::TxOut {
+            value: Amount::from_sat(19_000),
+            script_pubkey: spent_coin_desc.script_pubkey(),
+        });
+        let mut dummy_psbt = Psbt {
+            unsigned_tx: bitcoin::Transaction {
+                version: bitcoin::transaction::Version::TWO,
+                lock_time: absolute::LockTime::Blocks(absolute::Height::ZERO),
+                input: vec![bitcoin::TxIn {
+                    sequence: bitcoin::Sequence::ENABLE_RBF_NO_LOCKTIME,
+                    previous_output: bitcoin::OutPoint::from_str(
+                        "4613e078e4cdbb0fce1bc6e44b028f0e11621a134a1605efdc456c32d155c922:19",
+                    )
+                    .unwrap(),
+                    ..bitcoin::TxIn::default()
+                }],
+                output: vec![bitcoin::TxOut {
+                    value: Amount::from_sat(18_420),
+                    script_pubkey: bitcoin::Address::from_str(
+                        "bc1qvklensptw5lk7d470ds60pcpsr0psdpgyvwepv",
+                    )
+                    .unwrap()
+                    .payload()
+                    .script_pubkey(),
+                }],
+            },
+            version: 0,
+            xpub: BTreeMap::new(),
+            proprietary: BTreeMap::new(),
+            unknown: BTreeMap::new(),
+            inputs: vec![psbt_in],
+            outputs: Vec::new(),
+        };
+
+        // Sign the PSBT with the two primary signers. The recovery signer will sign for the two keys
+        // that it manages.
+        let psbt = dummy_psbt.clone();
+        assert!(psbt.inputs[0].partial_sigs.is_empty());
+        assert!(psbt.inputs[0].tap_key_sig.is_none());
+        assert!(psbt.inputs[0].tap_script_sigs.is_empty());
+        let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap();
+        assert_eq!(psbt.inputs[0].tap_script_sigs.len(), 1);
+        assert!(psbt.inputs[0].tap_key_sig.is_none());
+        assert!(psbt.inputs[0].partial_sigs.is_empty());
+        let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap();
+        assert_eq!(psbt.inputs[0].tap_script_sigs.len(), 2);
+        assert!(psbt.inputs[0].tap_key_sig.is_none());
+        assert!(psbt.inputs[0].partial_sigs.is_empty());
+        let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap();
+        assert_eq!(psbt.inputs[0].tap_script_sigs.len(), 4);
+        assert!(psbt.inputs[0].tap_key_sig.is_none());
+        assert!(psbt.inputs[0].partial_sigs.is_empty());
+
+        // We can add another external output to the transaction, we can still sign without issue.
+        // The output can be insane, we don't check it. It doesn't even need an accompanying PSBT
+        // output.
+        dummy_psbt.unsigned_tx.output.push(bitcoin::TxOut::NULL);
+        let psbt = dummy_psbt.clone();
+        assert!(psbt.inputs[0].tap_script_sigs.is_empty());
+        assert!(psbt.inputs[0].tap_key_sig.is_none());
+        assert!(psbt.inputs[0].tap_script_sigs.is_empty());
+        let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap();
+        assert_eq!(psbt.inputs[0].tap_script_sigs.len(), 1);
+        assert!(psbt.inputs[0].tap_key_sig.is_none());
+        assert!(psbt.inputs[0].partial_sigs.is_empty());
+        let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap();
+        assert_eq!(psbt.inputs[0].tap_script_sigs.len(), 2);
+        assert!(psbt.inputs[0].tap_key_sig.is_none());
+        assert!(psbt.inputs[0].partial_sigs.is_empty());
+        let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap();
+        assert_eq!(psbt.inputs[0].tap_script_sigs.len(), 4);
+        assert!(psbt.inputs[0].tap_key_sig.is_none());
+        assert!(psbt.inputs[0].partial_sigs.is_empty());
+
+        // We can add another input to the PSBT. If we don't attach also another transaction input
+        // it will fail.
+        let other_spent_coin_desc = desc.receive_descriptor().derive(84.into(), &secp);
+        let mut psbt_in = PsbtIn::default();
+        other_spent_coin_desc.update_psbt_in(&mut psbt_in);
+        psbt_in.witness_utxo = Some(bitcoin::TxOut {
+            value: Amount::from_sat(19_000),
+            script_pubkey: other_spent_coin_desc.script_pubkey(),
+        });
+        dummy_psbt.inputs.push(psbt_in);
+        let psbt = dummy_psbt.clone();
+        assert!(prim_signer_a
+            .sign_psbt(psbt, &secp)
+            .unwrap_err()
+            .to_string()
+            .contains("Information contained in the PSBT is wrong"));
+
+        // But now if we add the inputs also to the transaction itself, it will have signed both
+        // inputs.
+        dummy_psbt.unsigned_tx.input.push(bitcoin::TxIn {
+            // Note the sequence can be different. We don't care.
+            sequence: bitcoin::Sequence::ENABLE_LOCKTIME_NO_RBF,
+            previous_output: bitcoin::OutPoint::from_str(
+                "5613e078e4cdbb0fce1bc6e44b028f0e11621a134a1605efdc456c32d155c922:0",
+            )
+            .unwrap(),
+            ..bitcoin::TxIn::default()
+        });
+        let psbt = dummy_psbt.clone();
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_script_sigs.is_empty()));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_script_sigs.is_empty()));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_key_sig.is_none()));
+        let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap();
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_script_sigs.len() == 1));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.partial_sigs.is_empty()));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_key_sig.is_none()));
+        let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap();
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_script_sigs.len() == 2));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.partial_sigs.is_empty()));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_key_sig.is_none()));
+        let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap();
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_script_sigs.len() == 4));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.partial_sigs.is_empty()));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_key_sig.is_none()));
+
+        // If the witness script is set it'll assume it's a P2WSH input and provide ECDSA sigs.
+        // But since we haven't provided any P2WSH details it won't fill anything.
+        let mut psbt = dummy_psbt.clone();
+        psbt.inputs[1].witness_script = Some(Default::default());
+        let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap();
+        assert!(psbt.inputs[1].partial_sigs.is_empty());
+        assert!(psbt.inputs[1].tap_key_sig.is_none());
+        assert!(psbt.inputs[1].tap_script_sigs.is_empty());
+
+        // If the witness utxo is missing for one of the inputs it'll tell us the PSBT is
+        // incomplete.
+        let mut psbt = dummy_psbt.clone();
+        psbt.inputs[1].witness_utxo = None;
+        assert!(prim_signer_a
+            .sign_psbt(psbt, &secp)
+            .unwrap_err()
+            .to_string()
+            .contains("The PSBT is missing some information necessary for signing."));
+
+        // If we remove the BIP32 derivations for the first input it will only provide signatures
+        // for the second one.
+        let mut psbt = dummy_psbt.clone();
+        assert!(psbt.inputs[0].tap_script_sigs.is_empty());
+        assert!(psbt.inputs[1].tap_script_sigs.is_empty());
+        psbt.inputs[0].tap_key_origins.clear();
+        let psbt = prim_signer_b.sign_psbt(psbt, &secp).unwrap();
+        assert!(psbt.inputs[0].tap_script_sigs.is_empty());
+        assert_eq!(psbt.inputs[1].tap_script_sigs.len(), 1);
+
+        // Now use a Taproot descriptor such as there is a single primary key as the internal key.
+        let prim_keys = descriptors::PathInfo::Single(prim_key_a);
+        let recov_keys = descriptors::PathInfo::Single(recov_key);
+        let policy =
+            descriptors::LianaPolicy::new(prim_keys, [(42, recov_keys)].iter().cloned().collect())
+                .unwrap();
+        let desc = descriptors::LianaDescriptor::new(policy);
+        let spent_coin_desc = desc.receive_descriptor().derive(412.into(), &secp);
+
+        // Update the two inputs with the details for this descriptor.
+        dummy_psbt.inputs[0].tap_key_origins.clear();
+        spent_coin_desc.update_psbt_in(&mut dummy_psbt.inputs[0]);
+        dummy_psbt.inputs[1].tap_key_origins.clear();
+        spent_coin_desc.update_psbt_in(&mut dummy_psbt.inputs[1]);
+
+        // Sign the PSBT with the primary and recovery signers. The prim signer will add a sig for
+        // the key path and the recov signer for the script path.
+        let psbt = dummy_psbt.clone();
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_script_sigs.is_empty()));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_script_sigs.is_empty()));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_key_sig.is_none()));
+        let psbt = prim_signer_a.sign_psbt(psbt, &secp).unwrap();
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_key_sig.is_some()));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_script_sigs.is_empty()));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.partial_sigs.is_empty()));
+        let psbt = recov_signer.sign_psbt(psbt, &secp).unwrap();
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_key_sig.is_some()));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.tap_script_sigs.len() == 1));
+        assert!(psbt
+            .inputs
+            .iter()
+            .all(|psbt_in| psbt_in.partial_sigs.is_empty()));
+    }
+
     #[test]
     fn signer_set_net() {
         let secp = secp256k1::Secp256k1::signing_only();
diff --git a/src/spend.rs b/src/spend.rs
index d545375e..0118ef0b 100644
--- a/src/spend.rs
+++ b/src/spend.rs
@@ -114,7 +114,7 @@ fn sanity_check_psbt(
     // index set for signing devices to recognize them as ours.
     let mut value_in = 0;
     for psbtin in psbt.inputs.iter() {
-        if psbtin.bip32_derivation.is_empty() {
+        if psbtin.bip32_derivation.is_empty() && psbtin.tap_key_origins.is_empty() {
             return Err(SpendCreationError::SanityCheckFailure(psbt.clone()));
         }
         value_in += psbtin
@@ -649,20 +649,17 @@ pub fn create_spend(
         });
         // If it's an address of ours, signal it as change to signing devices by adding the
         // BIP32 derivation path to the PSBT output.
-        let bip32_derivation = if let Some(AddrInfo { index, is_change }) = address.info {
+        let mut psbt_out = PsbtOut::default();
+        if let Some(AddrInfo { index, is_change }) = address.info {
             let desc = if is_change {
                 main_descriptor.change_descriptor()
             } else {
                 main_descriptor.receive_descriptor()
             };
-            desc.derive(index, secp).bip32_derivations()
-        } else {
-            Default::default()
-        };
-        psbt_outs.push(PsbtOut {
-            bip32_derivation,
-            ..PsbtOut::default()
-        });
+            desc.derive(index, secp)
+                .update_change_psbt_out(&mut psbt_out)
+        }
+        psbt_outs.push(psbt_out);
     }
     assert_eq!(tx.output.is_empty(), is_self_send);
 
@@ -718,24 +715,21 @@ pub fn create_spend(
 
         // If the change address is ours, tell the signers by setting the BIP32 derivations in the
         // PSBT output.
-        let bip32_derivation = if let Some(AddrInfo { index, is_change }) = change_addr.info {
+        let mut psbt_out = PsbtOut::default();
+        if let Some(AddrInfo { index, is_change }) = change_addr.info {
             let desc = if is_change {
                 main_descriptor.change_descriptor()
             } else {
                 main_descriptor.receive_descriptor()
             };
-            desc.derive(index, secp).bip32_derivations()
-        } else {
-            Default::default()
-        };
+            desc.derive(index, secp)
+                .update_change_psbt_out(&mut psbt_out);
+        }
 
         // TODO: shuffle once we have Taproot
         change_txo.value = change_amount;
         tx.output.push(change_txo);
-        psbt_outs.push(PsbtOut {
-            bip32_derivation,
-            ..PsbtOut::default()
-        });
+        psbt_outs.push(psbt_out);
     } else if max_change_amount.to_sat() > 0 {
         warnings.push(CreateSpendWarning::ChangeAddedToFee(
             max_change_amount.to_sat(),
@@ -762,21 +756,17 @@ pub fn create_spend(
         });
 
         // Populate the PSBT input with the information needed by signers.
+        let mut psbt_in = PsbtIn::default();
         let coin_desc = derived_desc(secp, main_descriptor, cand);
-        let witness_script = Some(coin_desc.witness_script());
-        let witness_utxo = Some(bitcoin::TxOut {
+        coin_desc.update_psbt_in(&mut psbt_in);
+        psbt_in.witness_utxo = Some(bitcoin::TxOut {
             value: cand.amount,
             script_pubkey: coin_desc.script_pubkey(),
         });
-        let non_witness_utxo = tx_getter.get_tx(&cand.outpoint.txid);
-        let bip32_derivation = coin_desc.bip32_derivations();
-        psbt_ins.push(PsbtIn {
-            witness_script,
-            witness_utxo,
-            bip32_derivation,
-            non_witness_utxo,
-            ..PsbtIn::default()
-        });
+        if !main_descriptor.is_taproot() {
+            psbt_in.non_witness_utxo = tx_getter.get_tx(&cand.outpoint.txid);
+        }
+        psbt_ins.push(psbt_in);
     }
 
     // Finally, create the PSBT with all inputs and outputs, sanity check it and return it.


Test patch:
diff --git a/src/testutils.rs b/src/testutils.rs
index 2bee5012..9e2adfc0 100644
--- a/src/testutils.rs
+++ b/src/testutils.rs
@@ -481,7 +481,7 @@ impl DummyLiana {
 
         let owner_key = descriptors::PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[aabbccdd]xpub68JJTXc1MWK8KLW4HGLXZBJknja7kDUJuFHnM424LbziEXsfkh1WQCiEjjHw4zLqSUm4rvhgyGkkuRowE9tCJSgt3TQB5J3SKAbZ2SdcKST/<0;1>/*").unwrap());
         let heir_key = descriptors::PathInfo::Single(descriptor::DescriptorPublicKey::from_str("[aabbccdd]xpub68JJTXc1MWK8PEQozKsRatrUHXKFNkD1Cb1BuQU9Xr5moCv87anqGyXLyUd4KpnDyZgo3gz4aN1r3NiaoweFW8UutBsBbgKHzaD5HkTkifK/<0;1>/*").unwrap());
-        let policy = descriptors::LianaPolicy::new(
+        let policy = descriptors::LianaPolicy::new_legacy(
             owner_key,
             [(10_000, heir_key)].iter().cloned().collect(),
         )
diff --git a/tests/README.md b/tests/README.md
index a6d920b4..eb726db3 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -44,6 +44,16 @@ From the root of the repository:
 pytest tests/
 ```
 
+For running the tests under Taproot a `bitcoind` version 26.0 or superior must be used. It can be
+pointed to using the `BITCOIND_PATH` variable. For now, one must also compile the `taproot_signer`
+Rust program:
+```
+(cd tests/tools/taproot_signer && cargo build --release)
+```
+
+Then the test suite can be run by using Taproot descriptors instead of P2WSH descriptors by setting
+the `USE_TAPROOT` environment variable to `1`.
+
 ### Tips and tricks
 #### Logging
 
diff --git a/tests/fixtures.py b/tests/fixtures.py
index ee8d5b7c..68b6e9ee 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -5,10 +5,9 @@ from concurrent import futures
 from test_framework.bitcoind import Bitcoind
 from test_framework.lianad import Lianad
 from test_framework.signer import SingleSigner, MultiSigner
-from test_framework.utils import (
-    EXECUTOR_WORKERS,
-)
+from test_framework.utils import EXECUTOR_WORKERS, USE_TAPROOT
 
+import hashlib
 import os
 import pytest
 import shutil
@@ -120,22 +119,36 @@ def xpub_fingerprint(hd):
     return _pubkey_to_fingerprint(hd.pubkey).hex()
 
 
+def single_key_desc(prim_fg, prim_xpub, reco_fg, reco_xpub, csv_value, is_taproot):
+    if is_taproot:
+        return f"tr([{prim_fg}]{prim_xpub}/<0;1>/*,and_v(v:pk([{reco_fg}]{reco_xpub}/<0;1>/*),older({csv_value})))"
+    else:
+        return f"wsh(or_d(pk([{prim_fg}]{prim_xpub}/<0;1>/*),and_v(v:pkh([{reco_fg}]{reco_xpub}/<0;1>/*),older({csv_value}))))"
+
+
 @pytest.fixture
 def lianad(bitcoind, directory):
     datadir = os.path.join(directory, "lianad")
     os.makedirs(datadir, exist_ok=True)
     bitcoind_cookie = os.path.join(bitcoind.bitcoin_dir, "regtest", ".cookie")
 
-    signer = SingleSigner()
+    signer = SingleSigner(is_taproot=USE_TAPROOT)
     (prim_fingerprint, primary_xpub), (reco_fingerprint, recovery_xpub) = (
         (xpub_fingerprint(signer.primary_hd), signer.primary_hd.get_xpub()),
         (xpub_fingerprint(signer.recovery_hd), signer.recovery_hd.get_xpub()),
     )
     csv_value = 10
-    # NOTE: origins are the actual xpub themselves which is incorrect but make it
+    # NOTE: origins are the actual xpub themselves which is incorrect but makes it
     # possible to differentiate them.
     main_desc = Descriptor.from_str(
-        f"wsh(or_d(pk([{prim_fingerprint}]{primary_xpub}/<0;1>/*),and_v(v:pkh([{reco_fingerprint}]{recovery_xpub}/<0;1>/*),older({csv_value}))))"
+        single_key_desc(
+            prim_fingerprint,
+            primary_xpub,
+            reco_fingerprint,
+            recovery_xpub,
+            csv_value,
+            is_taproot=USE_TAPROOT,
+        )
     )
 
     lianad = Lianad(
@@ -156,8 +169,19 @@ def lianad(bitcoind, directory):
     lianad.cleanup()
 
 
-def multi_expression(thresh, keys):
-    exp = f"multi({thresh},"
+def unspendable_internal_xpub(xpubs):
+    """Deterministic, unique, unspendable internal key.
+    See See https://delvingbitcoin.org/t/unspendable-keys-in-descriptors/304/21
+    """
+    chaincode = hashlib.sha256(b"".join(xpub.pubkey for xpub in xpubs)).digest()
+    bip341_nums = bytes.fromhex(
+        "0250929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0"
+    )
+    return BIP32(chaincode, pubkey=bip341_nums, network="test")
+
+
+def multi_expression(thresh, keys, is_taproot):
+    exp = f"multi_a({thresh}," if is_taproot else f"multi({thresh},"
     for i, key in enumerate(keys):
         # NOTE: origins are the actual xpub themselves which is incorrect but make it
         # possible to differentiate them.
@@ -168,6 +192,21 @@ def multi_expression(thresh, keys):
     return exp + ")"
 
 
+def multisig_desc(multi_signer, csv_value, is_taproot):
+    prim_multi, recov_multi = (
+        multi_expression(3, multi_signer.prim_hds, is_taproot),
+        multi_expression(2, multi_signer.recov_hds[csv_value], is_taproot),
+    )
+    if is_taproot:
+        all_xpubs = [
+            hd for hd in multi_signer.prim_hds + multi_signer.recov_hds[csv_value]
+        ]
+        internal_key = unspendable_internal_xpub(all_xpubs).get_xpub()
+        return f"tr([00000000]{internal_key}/<0;1>/*,{{{prim_multi},and_v(v:{recov_multi},older({csv_value}))}})"
+    else:
+        return f"wsh(or_d({prim_multi},and_v(v:{recov_multi},older({csv_value}))))"
+
+
 @pytest.fixture
 def lianad_multisig(bitcoind, directory):
     datadir = os.path.join(directory, "lianad")
@@ -176,13 +215,9 @@ def lianad_multisig(bitcoind, directory):
 
     # A 3-of-4 that degrades into a 2-of-5 after 10 blocks
     csv_value = 10
-    signer = MultiSigner(4, {csv_value: 5})
-    prim_multi, recov_multi = (
-        multi_expression(3, signer.prim_hds),
-        multi_expression(2, signer.recov_hds[csv_value]),
-    )
+    signer = MultiSigner(4, {csv_value: 5}, is_taproot=USE_TAPROOT)
     main_desc = Descriptor.from_str(
-        f"wsh(or_d({prim_multi},and_v(v:{recov_multi},older({csv_value}))))"
+        multisig_desc(signer, csv_value, is_taproot=USE_TAPROOT)
     )
 
     lianad = Lianad(
@@ -203,6 +238,28 @@ def lianad_multisig(bitcoind, directory):
     lianad.cleanup()
 
 
+def multipath_desc(multi_signer, csv_values, is_taproot):
+    prim_multi = multi_expression(3, multi_signer.prim_hds, is_taproot)
+    first_recov_multi = multi_expression(
+        3, multi_signer.recov_hds[csv_values[0]], is_taproot
+    )
+    second_recov_multi = multi_expression(
+        1, multi_signer.recov_hds[csv_values[1]], is_taproot
+    )
+    if is_taproot:
+        all_xpubs = [
+            hd
+            for hd in multi_signer.prim_hds
+            + multi_signer.recov_hds[csv_values[0]]
+            + multi_signer.recov_hds[csv_values[1]]
+        ]
+        internal_key = unspendable_internal_xpub(all_xpubs).get_xpub()
+        # On purpose we use a single leaf instead of 3 different ones. It shouldn't be an issue.
+        return f"tr([00000000]{internal_key}/<0;1>/*,or_d({prim_multi},or_i(and_v(v:{first_recov_multi},older({csv_values[0]})),and_v(v:{second_recov_multi},older({csv_values[1]})))))"
+    else:
+        return f"wsh(or_d({prim_multi},or_i(and_v(v:{first_recov_multi},older({csv_values[0]})),and_v(v:{second_recov_multi},older({csv_values[1]})))))"
+
+
 @pytest.fixture
 def lianad_multipath(bitcoind, directory):
     datadir = os.path.join(directory, "lianad")
@@ -211,12 +268,11 @@ def lianad_multipath(bitcoind, directory):
 
     # A 3-of-4 that degrades into a 3-of-5 after 10 blocks and into a 1-of-10 after 20 blocks.
     csv_values = [10, 20]
-    signer = MultiSigner(4, {csv_values[0]: 5, csv_values[1]: 10})
-    prim_multi = multi_expression(3, signer.prim_hds)
-    first_recov_multi = multi_expression(3, signer.recov_hds[csv_values[0]])
-    second_recov_multi = multi_expression(1, signer.recov_hds[csv_values[1]])
+    signer = MultiSigner(
+        4, {csv_values[0]: 5, csv_values[1]: 10}, is_taproot=USE_TAPROOT
+    )
     main_desc = Descriptor.from_str(
-        f"wsh(or_d({prim_multi},or_i(and_v(v:{first_recov_multi},older({csv_values[0]})),and_v(v:{second_recov_multi},older({csv_values[1]})))))"
+        multipath_desc(signer, csv_values, is_taproot=USE_TAPROOT)
     )
 
     lianad = Lianad(
diff --git a/tests/requirements.txt b/tests/requirements.txt
index ed93af45..bc55b0ca 100644
--- a/tests/requirements.txt
+++ b/tests/requirements.txt
@@ -4,4 +4,4 @@ pytest-timeout==1.3.4
 ephemeral_port_reserve==1.1.1
 
 bip32~=3.0
-https://github.com/darosior/python-bip380/archive/f25eb2add9a5d461e382635231a5f971652fc8e1.zip
+https://github.com/darosior/python-bip380/archive/fb61971d9128e663f110ea2734c1d023e7e0266b.zip
diff --git a/tests/test_chain.py b/tests/test_chain.py
index 1ed1c133..e4d6a99f 100644
--- a/tests/test_chain.py
+++ b/tests/test_chain.py
@@ -10,6 +10,7 @@ from test_framework.utils import (
     COIN,
     sign_and_broadcast,
     sign_and_broadcast_psbt,
+    USE_TAPROOT,
 )
 from test_framework.serializations import PSBT
 
@@ -354,6 +355,9 @@ def test_rescan_and_recovery(lianad, bitcoind):
     sign_and_broadcast(lianad, bitcoind, reco_psbt, recovery=True)
 
 
+@pytest.mark.skipif(
+    USE_TAPROOT, reason="Needs a finalizer implemented in the Python test framework."
+)
 def test_conflicting_unconfirmed_spend_txs(lianad, bitcoind):
     """Test we'll update the spending txid of a coin if a conflicting spend enters our mempool."""
     # Get an (unconfirmed, on purpose) coin to be spent by 2 different txs.
diff --git a/tests/test_framework/signer.py b/tests/test_framework/signer.py
index 48004556..b0332eb9 100644
--- a/tests/test_framework/signer.py
+++ b/tests/test_framework/signer.py
@@ -1,5 +1,6 @@
 import logging
 import os
+import subprocess
 
 from bip32 import BIP32
 from bip32.utils import coincurve
@@ -9,10 +10,16 @@ from test_framework.serializations import (
     PSBT_IN_BIP32_DERIVATION,
     PSBT_IN_WITNESS_SCRIPT,
     PSBT_IN_PARTIAL_SIG,
+    PSBT_IN_TAP_KEY_SIG,
+    PSBT_IN_TAP_SCRIPT_SIG,
+    PSBT_IN_TAP_LEAF_SCRIPT,
+    PSBT_IN_TAP_BIP32_DERIVATION,
+    PSBT_IN_TAP_INTERNAL_KEY,
+    PSBT_IN_TAP_MERKLE_ROOT,
 )
 
 
-def sign_psbt(psbt, hds):
+def sign_psbt_wsh(psbt, hds):
     """Sign a transaction.
 
     This will fill the 'partial_sigs' field of all inputs.
@@ -58,12 +65,47 @@ def sign_psbt(psbt, hds):
     return psbt
 
 
+def sign_psbt_taproot(psbt, hds):
+    """Sign a transaction.
+
+    This will fill the 'tap_script_sig' / 'tap_key_sig' field of all inputs.
+
+    :param psbt: PSBT of the transaction to be signed.
+    :param hds: the BIP32 objects to sign the transaction with.
+    :returns: PSBT with a signature in each input for the given keys.
+    """
+    assert isinstance(psbt, PSBT)
+
+    # This file is under tests/test_framework/ and we want tests/tools/taproot_signer/target/release/taproot_signer.
+    bin_path = os.path.join(
+        os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
+        "tools",
+        "taproot_signer",
+        "target",
+        "release",
+        "taproot_signer",
+    )
+    if not os.path.exists(bin_path):
+        raise Exception(
+            "Please compile the Taproot signer under tests/tools using 'cargo bin --release'."
+        )
+
+    psbt_str = psbt.to_base64()
+    for hd in hds:
+        xprv = hd.get_xpriv()
+        proc = subprocess.run([bin_path, psbt_str, xprv], capture_output=True, check=True)
+        psbt_str = proc.stdout.decode("utf-8")
+
+    return PSBT.from_base64(psbt_str)
+
+
 class SingleSigner:
     """Assumes a simple 1-primary path 1-recovery path Liana descriptor."""
 
-    def __init__(self):
+    def __init__(self, is_taproot):
         self.primary_hd = BIP32.from_seed(os.urandom(32), network="test")
         self.recovery_hd = BIP32.from_seed(os.urandom(32), network="test")
+        self.is_taproot = is_taproot
 
     def sign_psbt(self, psbt, recovery=False):
         """Sign a transaction.
@@ -75,13 +117,17 @@ class SingleSigner:
         :returns: PSBT with a signature in each input for the specified key.
         """
         assert isinstance(recovery, bool)
-        return sign_psbt(psbt, [self.recovery_hd if recovery else self.primary_hd])
+        if self.is_taproot:
+            return sign_psbt_taproot(
+                psbt, [self.recovery_hd if recovery else self.primary_hd]
+            )
+        return sign_psbt_wsh(psbt, [self.recovery_hd if recovery else self.primary_hd])
 
 
 class MultiSigner:
     """A signer that has multiple keys and may have multiple recovery path."""
 
-    def __init__(self, primary_hds_count, recovery_hds_counts):
+    def __init__(self, primary_hds_count, recovery_hds_counts, is_taproot):
         self.prim_hds = [
             BIP32.from_seed(os.urandom(32), network="test")
             for _ in range(primary_hds_count)
@@ -91,6 +137,7 @@ class MultiSigner:
             self.recov_hds[timelock] = [
                 BIP32.from_seed(os.urandom(32), network="test") for _ in range(count)
             ]
+        self.is_taproot = is_taproot
 
     def sign_psbt(self, psbt, key_indices):
         """Sign a transaction with the keys at the specified indices.
@@ -106,4 +153,6 @@ class MultiSigner:
             ]
         else:
             hds = [self.prim_hds[i] for i in key_indices]
-        return sign_psbt(psbt, hds)
+        if self.is_taproot:
+            return sign_psbt_taproot(psbt, hds)
+        return sign_psbt_wsh(psbt, hds)
diff --git a/tests/test_framework/utils.py b/tests/test_framework/utils.py
index 4aabf759..ffa50aa3 100644
--- a/tests/test_framework/utils.py
+++ b/tests/test_framework/utils.py
@@ -24,6 +24,7 @@ DEFAULT_BITCOIND_PATH = "bitcoind"
 BITCOIND_PATH = os.getenv("BITCOIND_PATH", DEFAULT_BITCOIND_PATH)
 OLD_LIANAD_PATH = os.getenv("OLD_LIANAD_PATH", None)
 IS_NOT_BITCOIND_24 = bool(int(os.getenv("IS_NOT_BITCOIND_24", True)))
+USE_TAPROOT = bool(int(os.getenv("USE_TAPROOT", False)))  # TODO: switch to True in a couple releases.
 
 
 COIN = 10**8
@@ -71,6 +72,21 @@ def get_txid(hex_tx):
     return tx.txid().hex()
 
 
+def sign_and_broadcast(lianad, bitcoind, psbt, recovery=False):
+    """Sign a PSBT, finalize it, extract the transaction and broadcast it."""
+    signed_psbt = lianad.signer.sign_psbt(psbt, recovery)
+    # Under Taproot i didn't bother implementing a finalizer in the test suite.
+    if USE_TAPROOT:
+        lianad.rpc.updatespend(signed_psbt.to_base64())
+        txid = signed_psbt.tx.txid().hex()
+        lianad.rpc.broadcastspend(txid)
+        lianad.rpc.delspendtx(txid)
+        return txid
+    finalized_psbt = lianad.finalize_psbt(signed_psbt)
+    tx = finalized_psbt.tx.serialize_with_witness().hex()
+    return bitcoind.rpc.sendrawtransaction(tx)
+
+
 def spend_coins(lianad, bitcoind, coins):
     """Spend these coins, no matter how.
     This will create a single transaction spending them all at once at the minimum
@@ -84,21 +100,8 @@ def spend_coins(lianad, bitcoind, coins):
         bitcoind.rpc.getnewaddress(): total_value - 11 - 31 - 300 * len(coins)
     }
     res = lianad.rpc.createspend(destinations, [c["outpoint"] for c in coins], 1)
-
-    signed_psbt = lianad.signer.sign_psbt(PSBT.from_base64(res["psbt"]))
-    finalized_psbt = lianad.finalize_psbt(signed_psbt)
-    tx = finalized_psbt.tx.serialize_with_witness().hex()
-    bitcoind.rpc.sendrawtransaction(tx)
-
-    return tx
-
-
-def sign_and_broadcast(lianad, bitcoind, psbt, recovery=False):
-    """Sign a PSBT, finalize it, extract the transaction and broadcast it."""
-    signed_psbt = lianad.signer.sign_psbt(psbt, recovery)
-    finalized_psbt = lianad.finalize_psbt(signed_psbt)
-    tx = finalized_psbt.tx.serialize_with_witness().hex()
-    return bitcoind.rpc.sendrawtransaction(tx)
+    txid = sign_and_broadcast(lianad, bitcoind, PSBT.from_base64(res["psbt"]))
+    return bitcoind.rpc.getrawtransaction(txid)
 
 
 def sign_and_broadcast_psbt(lianad, psbt):
diff --git a/tests/test_misc.py b/tests/test_misc.py
index c8d4ec91..f4a81c5f 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -14,6 +14,7 @@ from test_framework.utils import (
     COIN,
     TIMEOUT,
     IS_NOT_BITCOIND_24,
+    USE_TAPROOT,
 )
 
 from threading import Thread
@@ -66,9 +67,7 @@ def receive_and_send(lianad, bitcoind):
     psbt = PSBT.from_base64(res["psbt"])
     txid = psbt.tx.txid().hex()
     # If we sign only with two keys it won't be able to finalize
-    with pytest.raises(
-        RpcError, match="Miniscript Error: could not satisfy at index 0"
-    ):
+    with pytest.raises(RpcError, match="ould not satisfy.* at index 0"):
         signed_psbt = lianad.signer.sign_psbt(psbt, range(2))
         lianad.rpc.updatespend(signed_psbt.to_base64())
         lianad.rpc.broadcastspend(txid)
@@ -242,7 +241,7 @@ def test_coinbase_deposit(lianad, bitcoind):
 
 
 @pytest.mark.skipif(
-    OLD_LIANAD_PATH is None, reason="Need the old lianad binary to create the datadir."
+    OLD_LIANAD_PATH is None or USE_TAPROOT, reason="Need the old lianad binary to create the datadir."
 )
 def test_migration(lianad_multisig, bitcoind):
     """Test we can start a newer lianad on a datadir created by an older lianad."""
diff --git a/tests/test_rpc.py b/tests/test_rpc.py
index 87e74af9..e02e0c50 100644
--- a/tests/test_rpc.py
+++ b/tests/test_rpc.py
@@ -17,6 +17,7 @@ from test_framework.utils import (
     spend_coins,
     sign_and_broadcast,
     sign_and_broadcast_psbt,
+    USE_TAPROOT,
 )
 
 
@@ -405,13 +406,21 @@ def test_create_spend(lianad, bitcoind):
     assert len(spend_psbt.o) == 4
     assert len(spend_psbt.tx.vout) == 4
 
-    # The transaction must contain the spent transaction for each input.
+    # The transaction must contain the spent transaction for each input for P2WSH. But not for Taproot.
     # We don't make assumptions about the ordering of PSBT inputs.
-    assert sorted(
-        [psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in spend_psbt.i]
-    ) == sorted(
-        [bytes.fromhex(bitcoind.rpc.gettransaction(op[:64])["hex"]) for op in outpoints]
-    )
+    if USE_TAPROOT:
+        assert all(
+            PSBT_IN_NON_WITNESS_UTXO not in psbt_in.map for psbt_in in spend_psbt.i
+        )
+    else:
+        assert sorted(
+            [psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in spend_psbt.i]
+        ) == sorted(
+            [
+                bytes.fromhex(bitcoind.rpc.gettransaction(op[:64])["hex"])
+                for op in outpoints
+            ]
+        )
 
     # We can sign it and broadcast it.
     sign_and_broadcast(lianad, bitcoind, PSBT.from_base64(res["psbt"]))
@@ -1092,9 +1101,9 @@ def test_rbfpsbt_bump_fee(lianad, bitcoind):
     rbf_1_res = lianad.rpc.rbfpsbt(first_txid, False, 10)
     rbf_1_psbt = PSBT.from_base64(rbf_1_res["psbt"])
     # The inputs are the same in both (no new inputs needed in the replacement).
-    assert sorted(
-        psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in first_psbt.i
-    ) == sorted(psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_1_psbt.i)
+    assert sorted(i.prevout.serialize() for i in first_psbt.tx.vin) == sorted(
+        i.prevout.serialize() for i in rbf_1_psbt.tx.vin
+    )
     # Check non-change output is the same in both.
     assert first_psbt.tx.vout[0].nValue == rbf_1_psbt.tx.vout[0].nValue
     assert first_psbt.tx.vout[0].scriptPubKey == rbf_1_psbt.tx.vout[0].scriptPubKey
@@ -1114,8 +1123,9 @@ def test_rbfpsbt_bump_fee(lianad, bitcoind):
     # transaction:
     with pytest.raises(RpcError, match=f"Feerate too low: 10."):
         lianad.rpc.rbfpsbt(first_txid, False, 10)
-    # Using 11 for feerate works.
-    lianad.rpc.rbfpsbt(first_txid, False, 11)
+    # Using 11 for feerate works for P2WSH. For Taproot we need 12.
+    feerate = 12 if USE_TAPROOT else 11
+    lianad.rpc.rbfpsbt(first_txid, False, feerate)
     # Add a new transaction spending the change from the first RBF.
     desc_1_destinations = {
         bitcoind.rpc.getnewaddress(): 500_000,
@@ -1149,12 +1159,12 @@ def test_rbfpsbt_bump_fee(lianad, bitcoind):
         )
     )
     # Now replace the first RBF, which will also remove its descendants.
-    rbf_2_res = lianad.rpc.rbfpsbt(rbf_1_txid, False, 11)
+    rbf_2_res = lianad.rpc.rbfpsbt(rbf_1_txid, False, feerate)
     rbf_2_psbt = PSBT.from_base64(rbf_2_res["psbt"])
     # The inputs are the same in both (no new inputs needed in the replacement).
-    assert sorted(
-        psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_1_psbt.i
-    ) == sorted(psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_2_psbt.i)
+    assert sorted(i.prevout.serialize() for i in rbf_1_psbt.tx.vin) == sorted(
+        i.prevout.serialize() for i in rbf_2_psbt.tx.vin
+    )
     # Check non-change output is the same in both.
     assert rbf_1_psbt.tx.vout[0].nValue == rbf_2_psbt.tx.vout[0].nValue
     assert rbf_1_psbt.tx.vout[0].scriptPubKey == rbf_2_psbt.tx.vout[0].scriptPubKey
@@ -1204,7 +1214,8 @@ def test_rbfpsbt_insufficient_funds(lianad, bitcoind):
     spend_txid_1 = sign_and_broadcast_psbt(lianad, spend_psbt_1)
 
     # We don't have sufficient funds to bump the fee.
-    assert "missing" in lianad.rpc.rbfpsbt(spend_txid_1, False, 2)
+    feerate = 3 if USE_TAPROOT else 2
+    assert "missing" in lianad.rpc.rbfpsbt(spend_txid_1, False, feerate)
     # We can still cancel it as the coin has enough value to create a single
     # output at a higher feerate.
     assert "psbt" in lianad.rpc.rbfpsbt(spend_txid_1, True)
@@ -1273,8 +1284,8 @@ def test_rbfpsbt_cancel(lianad, bitcoind):
     # Replacement only has a single input.
     assert len(rbf_1_psbt.i) == 1
     # This input is one of the two from the previous transaction.
-    assert rbf_1_psbt.i[0].map[PSBT_IN_NON_WITNESS_UTXO] in [
-        psbt_in.map[PSBT_IN_NON_WITNESS_UTXO] for psbt_in in rbf_1_psbt.i
+    assert rbf_1_psbt.tx.vin[0].prevout.serialize() in [
+        i.prevout.serialize() for i in first_psbt.tx.vin
     ]
     # The replacement only has a change output.
     assert len(rbf_1_psbt.tx.vout) == 1
@@ -1340,13 +1351,12 @@ def test_rbfpsbt_cancel(lianad, bitcoind):
     # Now cancel the first RBF, which will also remove its descendants.
     rbf_2_res = lianad.rpc.rbfpsbt(rbf_1_txid, True)
     rbf_2_psbt = PSBT.from_base64(rbf_2_res["psbt"])
-    #
-    assert len(rbf_2_psbt.i) == 1
+    # The inputs are the same in both (no new inputs needed in the replacement).
+    assert len(rbf_2_psbt.tx.vin) == 1
     assert (
-        rbf_1_psbt.i[0].map[PSBT_IN_NON_WITNESS_UTXO]
-        == rbf_2_psbt.i[0].map[PSBT_IN_NON_WITNESS_UTXO]
+        rbf_1_psbt.tx.vin[0].prevout.serialize()
+        == rbf_2_psbt.tx.vin[0].prevout.serialize()
     )
-    # The inputs are the same in both (no new inputs needed in the replacement).
 
     # Only a single output (change) in the replacement.
     assert len(rbf_2_psbt.tx.vout) == 1
diff --git a/tests/test_spend.py b/tests/test_spend.py
index 0cf4ab12..9c496386 100644
--- a/tests/test_spend.py
+++ b/tests/test_spend.py
@@ -1,6 +1,6 @@
 from fixtures import *
 from test_framework.serializations import PSBT, uint256_from_str
-from test_framework.utils import sign_and_broadcast_psbt, wait_for, COIN, RpcError
+from test_framework.utils import sign_and_broadcast_psbt, wait_for, COIN, RpcError, USE_TAPROOT
 
 
 def test_spend_change(lianad, bitcoind):
@@ -121,11 +121,12 @@ def test_coin_marked_spent(lianad, bitcoind):
     res = lianad.rpc.createspend(destinations, [outpoint], 1)
     psbt = PSBT.from_base64(res["psbt"])
     sign_and_broadcast(psbt)
+    change_amount = 840 if USE_TAPROOT else 830
     assert len(psbt.o) == 1
     assert len(res["warnings"]) == 1
     assert (
         res["warnings"][0]
-        == "Change amount of 830 sats added to fee as it was too small to create a transaction output."
+        == f"Change amount of {change_amount} sats added to fee as it was too small to create a transaction output."
     )
 
     # Spend the third coin to an address of ours, no change
@@ -137,11 +138,12 @@ def test_coin_marked_spent(lianad, bitcoind):
     res = lianad.rpc.createspend(destinations, [outpoint_3], 1)
     psbt = PSBT.from_base64(res["psbt"])
     sign_and_broadcast(psbt)
+    change_amount = 828 if USE_TAPROOT else 818
     assert len(psbt.o) == 1
     assert len(res["warnings"]) == 1
     assert (
         res["warnings"][0]
-        == "Change amount of 818 sats added to fee as it was too small to create a transaction output."
+        == f"Change amount of {change_amount} sats added to fee as it was too small to create a transaction output."
     )
 
     # Spend the fourth coin to an address of ours, with change
@@ -223,7 +225,8 @@ def test_send_to_self(lianad, bitcoind):
     assert len(spend_psbt.o) == len(spend_psbt.tx.vout) == 1
 
     # Note they may ask for an impossible send-to-self. In this case we'll report missing amount.
-    assert "missing" in lianad.rpc.createspend({}, outpoints, 40500)
+    huge_feerate = 50_000 if USE_TAPROOT else 40_500
+    assert "missing" in lianad.rpc.createspend({}, outpoints, huge_feerate)
 
     # Sign and broadcast the send-to-self transaction created above.
     signed_psbt = lianad.signer.sign_psbt(spend_psbt)
@@ -237,7 +240,12 @@ def test_send_to_self(lianad, bitcoind):
     # FIXME: a 15% increase is huge.
     res = bitcoind.rpc.getmempoolentry(spend_txid)
     spend_feerate = int(res["fees"]["base"] * COIN / res["vsize"])
-    assert specified_feerate <= spend_feerate <= int(specified_feerate * 115 / 100)
+    if not USE_TAPROOT:
+        assert specified_feerate <= spend_feerate <= int(specified_feerate * 115 / 100)
+    else:
+        # FIXME: under Taproot we should not consider the max feerate of all leaves if there
+        # is a spendable internal key.
+        assert specified_feerate <= spend_feerate <= int(specified_feerate * 125 / 100)
 
     # We should by now only have one coin.
     bitcoind.generate_block(1, wait_for_mempool=spend_txid)
@@ -250,7 +258,7 @@ def test_send_to_self(lianad, bitcoind):
     assert len(lianad.rpc.listaddresses()["addresses"]) == 3
     # Create a new spend to the receive address with index 3.
     recv_addr = lianad.rpc.listaddresses(3, 1)["addresses"][0]["receive"]
-    res = lianad.rpc.createspend({recv_addr: 11_955_000}, [], 1)
+    res = lianad.rpc.createspend({recv_addr: 11_955_000}, [], 2)
     assert "psbt" in res
     # Max(receive_index, change_index) is now 4:
     assert len(lianad.rpc.listaddresses()["addresses"]) == 4
@@ -308,42 +316,48 @@ def test_coin_selection(lianad, bitcoind):
     dest_addr_2 = bitcoind.rpc.getnewaddress()
     # If feerate is higher than ancestor, we'll need to pay extra.
 
+    def additional_fees(anc_vsize, anc_fee, target_feerate):
+        """The additional fee which must have been computed by lianad."""
+        computed_anc_vsize = int(anc_fee / target_feerate)
+        print(f"c  ", computed_anc_vsize)
+        extra_vsize = anc_vsize - computed_anc_vsize
+        print("e  ", extra_vsize)
+        return extra_vsize * target_feerate
+
     # Try 10 sat/vb:
-    spend_res_2 = lianad.rpc.createspend({dest_addr_2: 10_000}, [], 10)
+    feerate = 10
+    spend_res_2 = lianad.rpc.createspend({dest_addr_2: 10_000}, [], feerate)
     assert "psbt" in spend_res_2
     spend_psbt_2 = PSBT.from_base64(spend_res_2["psbt"])
     # The spend is using the unconfirmed change.
     assert spend_psbt_2.tx.vin[0].prevout.hash == uint256_from_str(
         bytes.fromhex(spend_txid_1)[::-1]
     )
-    assert bitcoind.rpc.getmempoolentry(spend_txid_1)["ancestorsize"] == 161
-    assert bitcoind.rpc.getmempoolentry(spend_txid_1)["fees"]["ancestor"] * COIN == 339
-    # ancestor vsize at feerate 10 sat/vb = ancestor_fee / 10 = 339 / 10 = 33
-    # extra_weight <= (extra vsize * witness factor) = (161 - 33) * 4 = 512
-    # additional fee at 10 sat/vb (2.5 sat/wu) = 512 * 2.5 = 1280
+    anc_vsize = bitcoind.rpc.getmempoolentry(spend_txid_1)["ancestorsize"]
+    anc_fees = int(bitcoind.rpc.getmempoolentry(spend_txid_1)["fees"]["ancestor"] * COIN)
+    additional_fee = additional_fees(anc_vsize, anc_fees, feerate)
     assert len(spend_res_2["warnings"]) == 1
     assert (
         spend_res_2["warnings"][0]
-        == "An additional fee of 1280 sats has been added to pay for ancestors at the target feerate."
+        == f"An additional fee of {additional_fee} sats has been added to pay for ancestors at the target feerate."
     )
 
     # Try 3 sat/vb:
-    spend_res_2 = lianad.rpc.createspend({dest_addr_2: 10_000}, [], 3)
+    feerate = 3
+    spend_res_2 = lianad.rpc.createspend({dest_addr_2: 10_000}, [], feerate)
     assert "psbt" in spend_res_2
     spend_psbt_2 = PSBT.from_base64(spend_res_2["psbt"])
     # The spend is using the unconfirmed change.
     assert spend_psbt_2.tx.vin[0].prevout.hash == uint256_from_str(
         bytes.fromhex(spend_txid_1)[::-1]
     )
-    assert bitcoind.rpc.getmempoolentry(spend_txid_1)["ancestorsize"] == 161
-    assert bitcoind.rpc.getmempoolentry(spend_txid_1)["fees"]["ancestor"] * COIN == 339
-    # ancestor vsize at feerate 3 sat/vb = ancestor_fee / 3 = 339 / 3 = 113
-    # extra_weight <= (extra vsize * witness factor) = (161 - 113) * 4 = 192
-    # additional fee at 3 sat/vb (0.75 sat/wu) = 192 * 0.75 = 144
+    anc_vsize = bitcoind.rpc.getmempoolentry(spend_txid_1)["ancestorsize"]
+    anc_fees = int(bitcoind.rpc.getmempoolentry(spend_txid_1)["fees"]["ancestor"] * COIN)
+    additional_fee = additional_fees(anc_vsize, anc_fees, feerate)
     assert len(spend_res_2["warnings"]) == 1
     assert (
         spend_res_2["warnings"][0]
-        == "An additional fee of 144 sats has been added to pay for ancestors at the target feerate."
+        == f"An additional fee of {additional_fee} sats has been added to pay for ancestors at the target feerate."
     )
 
     # 2 sat/vb is same feerate as ancestor and we have no warnings:
@@ -378,20 +392,18 @@ def test_coin_selection(lianad, bitcoind):
     # We'll need to pay extra for each unconfirmed coin's ancestors.
     outpoints = [c["outpoint"] for c in lianad.rpc.listcoins(["unconfirmed"])["coins"]]
 
-    spend_res_3 = lianad.rpc.createspend({dest_addr_3: 20_000}, outpoints, 10)
+    feerate = 10
+    spend_res_3 = lianad.rpc.createspend({dest_addr_3: 20_000}, outpoints, feerate)
     assert "psbt" in spend_res_3
-    assert bitcoind.rpc.getmempoolentry(deposit_2)["ancestorsize"] == 165
-    assert bitcoind.rpc.getmempoolentry(deposit_2)["fees"]["ancestor"] * COIN == 165
-    # From above, extra fee for unconfirmed change at 10 sat/vb = 1280.
-    # For unconfirmed non-change:
-    # ancestor vsize at feerate 10 sat/vb = ancestor_fee / 10 = 165 / 10 = 16
-    # extra_weight <= (extra vsize * witness factor) = (165 - 16) * 4 = 596
-    # additional fee at 10 sat/vb (2.5 sat/wu) = 596 * 2.5 = 1490
-    # Sum of extra ancestor fees = 1280 + 1490 = 2770.
+    anc_vsize = bitcoind.rpc.getmempoolentry(deposit_2)["ancestorsize"]
+    anc_fees = int(bitcoind.rpc.getmempoolentry(deposit_2)["fees"]["ancestor"] * COIN)
+    prev_anc_vsize = bitcoind.rpc.getmempoolentry(spend_txid_1)["ancestorsize"]
+    prev_anc_fees = int(bitcoind.rpc.getmempoolentry(spend_txid_1)["fees"]["ancestor"] * COIN)
+    additional_fee = additional_fees(anc_vsize, anc_fees, feerate) + additional_fees(prev_anc_vsize, prev_anc_fees, feerate)
     assert len(spend_res_3["warnings"]) == 1
     assert (
         spend_res_3["warnings"][0]
-        == "An additional fee of 2770 sats has been added to pay for ancestors at the target feerate."
+        == f"An additional fee of {additional_fee} sats has been added to pay for ancestors at the target feerate."
     )
     spend_psbt_3 = PSBT.from_base64(spend_res_3["psbt"])
     spend_txid_3 = sign_and_broadcast_psbt(lianad, spend_psbt_3)
diff --git a/tests/tools/taproot_signer/Cargo.lock b/tests/tools/taproot_signer/Cargo.lock
new file mode 100644
index 00000000..cab0bff6
--- /dev/null
+++ b/tests/tools/taproot_signer/Cargo.lock
@@ -0,0 +1,90 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "bech32"
+version = "0.10.0-beta"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98f7eed2b2781a6f0b5c903471d48e15f56fb4e1165df8a9a2337fd1a59d45ea"
+
+[[package]]
+name = "bitcoin"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd00f3c09b5f21fb357abe32d29946eb8bb7a0862bae62c0b5e4a692acbbe73c"
+dependencies = [
+ "base64",
+ "bech32",
+ "bitcoin-internals",
+ "bitcoin_hashes",
+ "hex-conservative",
+ "hex_lit",
+ "secp256k1",
+]
+
+[[package]]
+name = "bitcoin-internals"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb"
+
+[[package]]
+name = "bitcoin_hashes"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b"
+dependencies = [
+ "bitcoin-internals",
+ "hex-conservative",
+]
+
+[[package]]
+name = "cc"
+version = "1.0.86"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f9fa1897e4325be0d68d48df6aa1a71ac2ed4d27723887e7754192705350730"
+
+[[package]]
+name = "hex-conservative"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30ed443af458ccb6d81c1e7e661545f94d3176752fb1df2f543b902a1e0f51e2"
+
+[[package]]
+name = "hex_lit"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd"
+
+[[package]]
+name = "secp256k1"
+version = "0.28.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d24b59d129cdadea20aea4fb2352fa053712e5d713eee47d700cd4b2bc002f10"
+dependencies = [
+ "bitcoin_hashes",
+ "secp256k1-sys",
+]
+
+[[package]]
+name = "secp256k1-sys"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5d1746aae42c19d583c3c1a8c646bfad910498e2051c551a7f2e3c0c9fbb7eb"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "taproot_signer"
+version = "0.1.0"
+dependencies = [
+ "bitcoin",
+]
diff --git a/tests/tools/taproot_signer/Cargo.toml b/tests/tools/taproot_signer/Cargo.toml
new file mode 100644
index 00000000..c7554761
--- /dev/null
+++ b/tests/tools/taproot_signer/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "taproot_signer"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+bitcoin = { version = "0.31", features = ["base64"] }
+
+# Avoid it being built under Liana's target dir.
+[workspace]
diff --git a/tests/tools/taproot_signer/README.md b/tests/tools/taproot_signer/README.md
new file mode 100644
index 00000000..00638e28
--- /dev/null
+++ b/tests/tools/taproot_signer/README.md
@@ -0,0 +1,13 @@
+A quick and dirty program to sign Taproot PSBTs with a given xpriv. This reuses the hot signer Rust
+code so i don't have to reimplement everything in Python.
+
+Usage:
+```
+cargo build --release
+./target/release/taproot_signer "cHNidP8BAFICAAAAASLJVdEybEXc7wUWShMaYhEOjwJL5MYbzg+7zeR44BNGEwAAAAD9////AfRHAAAAAAAAFgAUZb+ZwCt1P282vnthp4cBgN4YNCgAAAAAAAEBKzhKAAAAAAAAIlEggmdBglq+Jt7mG8eFAZJPZ9TNEoqsfUXX+e0tRR4on0pCFcHpCSnxiCBSU6Ddvmwxv9nF+529tCGtEuCM1/zRYlksP2wAEs3HghglLpv2X3mSwNNzlaHY8BKNNas/13WibyuQaSCNnIknSSMfLln9wxqrj+HdoimEWq8EF48QCVphtE8of6wgQC+hw/bp6Z3LosvoFKmVGbi5BhTFxhJ88YlUkHwkyx26IFRPnR3hiTo2F1MW+PzS6hKLjdiQA+aD3DlMZkolBdKAulKcwEIVwekJKfGIIFJToN2+bDG/2cX7nb20Ia0S4IzX/NFiWSw/2HFGAyQNqMHzx0CejOgXB8dPeu9cm47MEKqCRr08ntsmIEkh6Q/Tvcb69Lv4z1KGhY5rH2SVgNb+0L6jjg9jTCA7rQEussAhFkAvocP26emdy6LL6BSplRm4uQYUxcYSfPGJVJB8JMsdOQHYcUYDJA2owfPHQJ6M6BcHx09671ybjswQqoJGvTye29gS2ksSAACAGAAAgB8AAAAAAAAAKgAAACEWSSHpD9O9xvr0u/jPUoaFjmsfZJWA1v7QvqOOD2NMIDtFAWwAEs3HghglLpv2X3mSwNNzlaHY8BKNNas/13WibyuQsLhRtwEAAAACAACAAwAAAAQAAIAFAAAABgAAAAAAAAAqAAAAIRZUT50d4Yk6NhdTFvj80uoSi43YkAPmg9w5TGZKJQXSgDUB2HFGAyQNqMHzx0CejOgXB8dPeu9cm47MEKqCRr08ntuwuFG3EgAAgBkAAIAAAAAAKgAAACEWjZyJJ0kjHy5Z/cMaq4/h3aIphFqvBBePEAlaYbRPKH9BAdhxRgMkDajB88dAnozoFwfHT3rvXJuOzBCqgka9PJ7bJxAoeAAAAIAMAACAKgAAAKQBAAA4AAAAAAAAACoAAAAhFukJKfGIIFJToN2+bDG/2cX7nb20Ia0S4IzX/NFiWSw/DQB8Rh5dAAAAACoAAAABFyDpCSnxiCBSU6Ddvmwxv9nF+529tCGtEuCM1/zRYlksPwEYIKKzzcTwLrwB4DXcLXJYeZVMlpkb1zTIpFzsTWdZJU7nAAA=" xprv9s21ZrQH143K3fRVbj9JuCc3Eaph4F7AKXUtLGs9AWxWmhyMGq5ur5NqH3fDuiQZ6GamXbW7L3msaCwRbstxVqc3eP2FX3oa9wABxVzHr1k
+```
+
+Output:
+```
+cHNidP8BAFICAAAAASLJVdEybEXc7wUWShMaYhEOjwJL5MYbzg+7zeR44BNGEwAAAAD9////AfRHAAAAAAAAFgAUZb+ZwCt1P282vnthp4cBgN4YNCgAAAAAAAEBKzhKAAAAAAAAIlEggmdBglq+Jt7mG8eFAZJPZ9TNEoqsfUXX+e0tRR4on0pBFI2ciSdJIx8uWf3DGquP4d2iKYRarwQXjxAJWmG0Tyh/2HFGAyQNqMHzx0CejOgXB8dPeu9cm47MEKqCRr08nttAJ148PhMM/k/GnKCQ09RkpEKYCAHwVh3UMueWbq0TcIMW8s3XKNk1vyFy3LrXdnGghS8Up/i4bdNR2ikX1XdlYEIVwekJKfGIIFJToN2+bDG/2cX7nb20Ia0S4IzX/NFiWSw/bAASzceCGCUum/ZfeZLA03OVodjwEo01qz/XdaJvK5BpII2ciSdJIx8uWf3DGquP4d2iKYRarwQXjxAJWmG0Tyh/rCBAL6HD9unpncuiy+gUqZUZuLkGFMXGEnzxiVSQfCTLHbogVE+dHeGJOjYXUxb4/NLqEouN2JAD5oPcOUxmSiUF0oC6UpzAQhXB6Qkp8YggUlOg3b5sMb/ZxfudvbQhrRLgjNf80WJZLD/YcUYDJA2owfPHQJ6M6BcHx09671ybjswQqoJGvTye2yYgSSHpD9O9xvr0u/jPUoaFjmsfZJWA1v7QvqOOD2NMIDutAS6ywCEWQC+hw/bp6Z3LosvoFKmVGbi5BhTFxhJ88YlUkHwkyx05AdhxRgMkDajB88dAnozoFwfHT3rvXJuOzBCqgka9PJ7b2BLaSxIAAIAYAACAHwAAAAAAAAAqAAAAIRZJIekP073G+vS7+M9ShoWOax9klYDW/tC+o44PY0wgO0UBbAASzceCGCUum/ZfeZLA03OVodjwEo01qz/XdaJvK5CwuFG3AQAAAAIAAIADAAAABAAAgAUAAAAGAAAAAAAAACoAAAAhFlRPnR3hiTo2F1MW+PzS6hKLjdiQA+aD3DlMZkolBdKANQHYcUYDJA2owfPHQJ6M6BcHx09671ybjswQqoJGvTye27C4UbcSAACAGQAAgAAAAAAqAAAAIRaNnIknSSMfLln9wxqrj+HdoimEWq8EF48QCVphtE8of0EB2HFGAyQNqMHzx0CejOgXB8dPeu9cm47MEKqCRr08ntsnECh4AAAAgAwAAIAqAAAApAEAADgAAAAAAAAAKgAAACEW6Qkp8YggUlOg3b5sMb/ZxfudvbQhrRLgjNf80WJZLD8NAHxGHl0AAAAAKgAAAAEXIOkJKfGIIFJToN2+bDG/2cX7nb20Ia0S4IzX/NFiWSw/ARggorPNxPAuvAHgNdwtclh5lUyWmRvXNMikXOxNZ1klTucAAA==
+```
diff --git a/tests/tools/taproot_signer/src/main.rs b/tests/tools/taproot_signer/src/main.rs
new file mode 100644
index 00000000..bddd4b28
--- /dev/null
+++ b/tests/tools/taproot_signer/src/main.rs
@@ -0,0 +1,119 @@
+//! A quick and dirty program which reads a PSBT and an xpriv from stdin and outputs the signed
+//! PSBT to stdout. Uses function copied from Liana's hot signer and adapted.
+
+use std::{
+    env,
+    io::{self, Write},
+    str::FromStr,
+};
+
+use bitcoin::{
+    self,
+    bip32::{self, Xpriv},
+    hashes::Hash,
+    key::TapTweak,
+    psbt::{Input as PsbtIn, Psbt},
+    secp256k1, sighash,
+};
+
+fn sign_taproot(
+    secp: &secp256k1::Secp256k1<secp256k1::All>,
+    sighash_cache: &mut sighash::SighashCache<&bitcoin::Transaction>,
+    master_xpriv: Xpriv,
+    master_fingerprint: bip32::Fingerprint,
+    prevouts: &[bitcoin::TxOut],
+    psbt_in: &mut PsbtIn,
+    input_index: usize,
+) {
+    let sig_type = sighash::TapSighashType::Default;
+    let prevouts = sighash::Prevouts::All(prevouts);
+
+    // If the details of the internal key are filled, provide a keypath signature.
+    if let Some(ref int_key) = psbt_in.tap_internal_key {
+        // NB: we don't check for empty leaf hashes on purpose, in case the internal key also
+        // appears in a leaf.
+        if let Some((_, (fg, der_path))) = psbt_in.tap_key_origins.get(int_key) {
+            if *fg == master_fingerprint {
+                let privkey = master_xpriv.derive_priv(secp, der_path).unwrap().to_priv();
+                let keypair = secp256k1::Keypair::from_secret_key(secp, &privkey.inner);
+                assert_eq!(keypair.x_only_public_key().0, *int_key);
+                let keypair = keypair.tap_tweak(secp, psbt_in.tap_merkle_root).to_inner();
+                let sighash = sighash_cache
+                    .taproot_key_spend_signature_hash(input_index, &prevouts, sig_type)
+                    .unwrap();
+                let sighash = secp256k1::Message::from_digest_slice(sighash.as_byte_array())
+                    .expect("Sighash is always 32 bytes.");
+                let sig = secp.sign_schnorr_no_aux_rand(&sighash, &keypair);
+                let sig = bitcoin::taproot::Signature {
+                    sig,
+                    hash_ty: sig_type,
+                };
+                psbt_in.tap_key_sig = Some(sig);
+            }
+        }
+    }
+
+    // Now sign for all the public keys derived from our master secret, in all the leaves where
+    // they are present.
+    for (pubkey, (leaf_hashes, (fg, der_path))) in &psbt_in.tap_key_origins {
+        if *fg != master_fingerprint {
+            continue;
+        }
+
+        for leaf_hash in leaf_hashes {
+            let privkey = master_xpriv.derive_priv(secp, der_path).unwrap().to_priv();
+            let keypair = secp256k1::Keypair::from_secret_key(secp, &privkey.inner);
+            let sighash = sighash_cache
+                .taproot_script_spend_signature_hash(input_index, &prevouts, *leaf_hash, sig_type)
+                .unwrap();
+            let sighash = secp256k1::Message::from_digest_slice(sighash.as_byte_array())
+                .expect("Sighash is always 32 bytes.");
+            let sig = secp.sign_schnorr_no_aux_rand(&sighash, &keypair);
+            let sig = bitcoin::taproot::Signature {
+                sig,
+                hash_ty: sig_type,
+            };
+            psbt_in.tap_script_sigs.insert((*pubkey, *leaf_hash), sig);
+        }
+    }
+}
+
+fn sign_psbt(psbt: &mut Psbt, master_xpriv: Xpriv, secp: &secp256k1::Secp256k1<secp256k1::All>) {
+    let mut sighash_cache = sighash::SighashCache::new(&psbt.unsigned_tx);
+    let master_fingerprint = master_xpriv.fingerprint(secp);
+
+    let prevouts: Vec<_> = psbt
+        .inputs
+        .iter()
+        .filter_map(|psbt_in| psbt_in.witness_utxo.clone())
+        .collect();
+    assert_eq!(prevouts.len(), psbt.inputs.len());
+
+    // Sign each input in the PSBT.
+    for i in 0..psbt.inputs.len() {
+        sign_taproot(
+            secp,
+            &mut sighash_cache,
+            master_xpriv,
+            master_fingerprint,
+            &prevouts,
+            &mut psbt.inputs[i],
+            i,
+        );
+    }
+}
+
+fn main() {
+    let args: Vec<String> = env::args().collect();
+    assert_eq!(args.len(), 3);
+
+    let mut psbt = Psbt::from_str(&args[1]).unwrap();
+    let xprv = Xpriv::from_str(&args[2]).unwrap();
+
+    let secp = secp256k1::Secp256k1::new();
+    sign_psbt(&mut psbt, xprv, &secp);
+
+    let psbt_str = psbt.to_string();
+    print!("{}", psbt_str);
+    io::stdout().flush().unwrap();
+}