]> Untitled Git - bdk-cli/commitdiff
feat(compile): add tr option for compile cmd
authorVadim Anufriev <mailbox@vaan.io>
Mon, 8 Sep 2025 08:19:54 +0000 (12:19 +0400)
committerVadim Anufriev <mailbox@vaan.io>
Mon, 8 Sep 2025 08:19:54 +0000 (12:19 +0400)
src/commands.rs
src/handlers.rs
src/utils.rs
tests/integration.rs

index d202737b8f75bc96bd6f9a358c9b940fa2d30b99..8130b00411c264aafe93a8e060ecd33758b03c88 100644 (file)
@@ -94,7 +94,7 @@ pub enum CliSubCommand {
         #[arg(env = "POLICY", required = true, index = 1)]
         policy: String,
         /// Sets the script type used to embed the compiled policy.
-        #[arg(env = "TYPE", short = 't', long = "type", default_value = "wsh", value_parser = ["sh","wsh", "sh-wsh"]
+        #[arg(env = "TYPE", short = 't', long = "type", default_value = "wsh", value_parser = ["sh","wsh", "sh-wsh", "tr"]
         )]
         script_type: String,
     },
index 9a86b77fa49d246bbd1e210224db344adb1fa484..362c45689e76515df4c6a6b571b9f2e54993b9b6 100644 (file)
@@ -30,21 +30,20 @@ use bdk_wallet::bitcoin::{
 };
 use bdk_wallet::chain::ChainPosition;
 use bdk_wallet::descriptor::Segwitv0;
+use bdk_wallet::keys::{
+    DerivableKey, DescriptorKey, DescriptorKey::Secret, ExtendedKey, GeneratableKey, GeneratedKey,
+    bip39::WordCount,
+};
+use bdk_wallet::miniscript::miniscript;
 #[cfg(feature = "sqlite")]
 use bdk_wallet::rusqlite::Connection;
 use bdk_wallet::{KeychainKind, SignOptions, Wallet};
 #[cfg(feature = "compiler")]
 use bdk_wallet::{
     descriptor::{Descriptor, Legacy, Miniscript},
-    miniscript::policy::Concrete,
+    miniscript::{Tap, descriptor::TapTree, policy::Concrete},
 };
 use cli_table::{Cell, CellStruct, Style, Table, format::Justify};
-
-use bdk_wallet::keys::{
-    DerivableKey, DescriptorKey, DescriptorKey::Secret, ExtendedKey, GeneratableKey, GeneratedKey,
-    bip39::WordCount,
-};
-use bdk_wallet::miniscript::miniscript;
 use serde_json::json;
 use std::collections::BTreeMap;
 #[cfg(any(feature = "electrum", feature = "esplora"))]
@@ -53,14 +52,16 @@ use std::convert::TryFrom;
 #[cfg(any(feature = "repl", feature = "electrum", feature = "esplora"))]
 use std::io::Write;
 use std::str::FromStr;
+#[cfg(any(feature = "redb", feature = "compiler"))]
+use std::sync::Arc;
 
 #[cfg(feature = "electrum")]
 use crate::utils::BlockchainClient::Electrum;
 #[cfg(feature = "cbf")]
 use bdk_kyoto::{Info, LightClient};
+#[cfg(feature = "compiler")]
+use bdk_wallet::bitcoin::XOnlyPublicKey;
 use bdk_wallet::bitcoin::base64::prelude::*;
-#[cfg(feature = "redb")]
-use std::sync::Arc;
 #[cfg(feature = "cbf")]
 use tokio::select;
 #[cfg(any(
@@ -82,6 +83,10 @@ use {
     bdk_wallet::chain::{BlockId, CanonicalizationParams, CheckPoint},
 };
 
+#[cfg(feature = "compiler")]
+const NUMS_UNSPENDABLE_KEY_HEX: &str =
+    "50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0";
+
 /// Execute an offline wallet sub-command
 ///
 /// Offline wallet sub-commands are described in [`OfflineWalletSubCommand`].
@@ -606,9 +611,9 @@ pub(crate) async fn handle_online_wallet_subcommand(
                 let mut once = HashSet::<KeychainKind>::new();
                 move |k, spk_i, _| {
                     if once.insert(k) {
-                        print!("\nScanning keychain [{:?}]", k);
+                        print!("\nScanning keychain [{k:?}]");
                     }
-                    print!(" {:<3}", spk_i);
+                    print!(" {spk_i:<3}");
                     stdout.flush().expect("must flush");
                 }
             });
@@ -687,7 +692,7 @@ pub(crate) async fn handle_online_wallet_subcommand(
                 .start_sync_with_revealed_spks()
                 .inspect(|item, progress| {
                     let pc = (100 * progress.consumed()) as f32 / progress.total() as f32;
-                    eprintln!("[ SCANNING {:03.0}% ] {}", pc, item);
+                    eprintln!("[ SCANNING {pc:03.0}% ] {item}");
                 });
             match client {
                 #[cfg(feature = "electrum")]
@@ -813,7 +818,7 @@ pub(crate) async fn handle_online_wallet_subcommand(
 
                     let subscriber = tracing_subscriber::FmtSubscriber::new();
                     tracing::subscriber::set_global_default(subscriber)
-                        .map_err(|e| Error::Generic(format!("SetGlobalDefault error: {}", e)))?;
+                        .map_err(|e| Error::Generic(format!("SetGlobalDefault error: {e}")))?;
 
                     tokio::task::spawn(async move { node.run().await });
                     tokio::task::spawn(async move {
@@ -833,7 +838,7 @@ pub(crate) async fn handle_online_wallet_subcommand(
                     let txid = tx.compute_txid();
                     requester
                         .broadcast_random(tx.clone())
-                        .map_err(|e| Error::Generic(format!("{}", e)))?;
+                        .map_err(|e| Error::Generic(format!("{e}")))?;
                     tokio::time::timeout(tokio::time::Duration::from_secs(30), async move {
                         while let Some(info) = info_subscriber.recv().await {
                             match info {
@@ -874,8 +879,7 @@ pub(crate) fn is_final(psbt: &Psbt) -> Result<(), Error> {
     let psbt_inputs = psbt.inputs.len();
     if unsigned_tx_inputs != psbt_inputs {
         return Err(Error::Generic(format!(
-            "Malformed PSBT, {} unsigned tx inputs and {} psbt inputs.",
-            unsigned_tx_inputs, psbt_inputs
+            "Malformed PSBT, {unsigned_tx_inputs} unsigned tx inputs and {psbt_inputs} psbt inputs."
         )));
     }
     let sig_count = psbt.inputs.iter().fold(0, |count, input| {
@@ -1022,12 +1026,30 @@ pub(crate) fn handle_compile_subcommand(
     let segwit_policy: Miniscript<String, Segwitv0> = policy
         .compile()
         .map_err(|e| Error::Generic(e.to_string()))?;
+    let taproot_policy: Miniscript<String, Tap> = policy
+        .compile()
+        .map_err(|e| Error::Generic(e.to_string()))?;
 
     let descriptor = match script_type.as_str() {
         "sh" => Descriptor::new_sh(legacy_policy),
         "wsh" => Descriptor::new_wsh(segwit_policy),
         "sh-wsh" => Descriptor::new_sh_wsh(segwit_policy),
-        _ => panic!("Invalid type"),
+        "tr" => {
+            // For tr descriptors, we use a well-known unspendable key (NUMS point).
+            // This ensures the key path is effectively disabled and only script path can be used.
+            // See https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs
+
+            let xonly_public_key = XOnlyPublicKey::from_str(NUMS_UNSPENDABLE_KEY_HEX)
+                .map_err(|e| Error::Generic(format!("Invalid NUMS key: {e}")))?;
+
+            let tree = TapTree::Leaf(Arc::new(taproot_policy));
+            Descriptor::new_tr(xonly_public_key.to_string(), Some(tree))
+        }
+        _ => {
+            return Err(Error::Generic(
+                "Invalid script type. Supported types: sh, wsh, sh-wsh, tr".to_string(),
+            ));
+        }
     }?;
     if pretty {
         let table = vec![vec![
@@ -1331,21 +1353,20 @@ fn readline() -> Result<String, Error> {
     Ok(buffer)
 }
 
-#[cfg(any(
-    feature = "electrum",
-    feature = "esplora",
-    feature = "cbf",
-    feature = "rpc"
-))]
 #[cfg(test)]
 mod test {
-    use bdk_wallet::bitcoin::Psbt;
-
-    use super::is_final;
-    use std::str::FromStr;
-
+    #[cfg(any(
+        feature = "electrum",
+        feature = "esplora",
+        feature = "cbf",
+        feature = "rpc"
+    ))]
     #[test]
     fn test_psbt_is_final() {
+        use super::is_final;
+        use bdk_wallet::bitcoin::Psbt;
+        use std::str::FromStr;
+
         let unsigned_psbt = Psbt::from_str("cHNidP8BAIkBAAAAASWJHzxzyVORV/C3lAynKHVVL7+Rw7/Jj8U9fuvD24olAAAAAAD+////AiBOAAAAAAAAIgAgLzY9yE4jzTFJnHtTjkc+rFAtJ9NB7ENFQ1xLYoKsI1cfqgKVAAAAACIAIFsbWgDeLGU8EA+RGwBDIbcv4gaGG0tbEIhDvwXXa/E7LwEAAAABALUCAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BALLAAD/////AgD5ApUAAAAAIgAgWxtaAN4sZTwQD5EbAEMhty/iBoYbS1sQiEO/Bddr8TsAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQErAPkClQAAAAAiACBbG1oA3ixlPBAPkRsAQyG3L+IGhhtLWxCIQ78F12vxOwEFR1IhA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDIQLKhV/gEZYmlsQXnsL5/Uqv5Y8O31tmWW1LQqIBkiqzCVKuIgYCyoVf4BGWJpbEF57C+f1Kr+WPDt9bZlltS0KiAZIqswkEboH3lCIGA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDBDS6ZSEAACICAsqFX+ARliaWxBeewvn9Sq/ljw7fW2ZZbUtCogGSKrMJBG6B95QiAgPyVdlP9KV1voj+PUHLGIpRL5GRHeKYZgzPJ1fMAvjHgwQ0umUhAA==").unwrap();
         assert!(is_final(&unsigned_psbt).is_err());
 
@@ -1355,4 +1376,92 @@ mod test {
         let full_signed_psbt = Psbt::from_str("cHNidP8BAIkBAAAAASWJHzxzyVORV/C3lAynKHVVL7+Rw7/Jj8U9fuvD24olAAAAAAD+////AiBOAAAAAAAAIgAgLzY9yE4jzTFJnHtTjkc+rFAtJ9NB7ENFQ1xLYoKsI1cfqgKVAAAAACIAIFsbWgDeLGU8EA+RGwBDIbcv4gaGG0tbEIhDvwXXa/E7LwEAAAABALUCAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BALLAAD/////AgD5ApUAAAAAIgAgWxtaAN4sZTwQD5EbAEMhty/iBoYbS1sQiEO/Bddr8TsAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQErAPkClQAAAAAiACBbG1oA3ixlPBAPkRsAQyG3L+IGhhtLWxCIQ78F12vxOwEFR1IhA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDIQLKhV/gEZYmlsQXnsL5/Uqv5Y8O31tmWW1LQqIBkiqzCVKuIgYCyoVf4BGWJpbEF57C+f1Kr+WPDt9bZlltS0KiAZIqswkEboH3lCIGA/JV2U/0pXW+iP49QcsYilEvkZEd4phmDM8nV8wC+MeDBDS6ZSEBBwABCNsEAEgwRQIhAJzT6busDV9h12M/LNquZ17oOHFn7whg90kh9gjSpvshAiBEDu/1EYVD7BqJJzExPhq2CX/Vsap/ULLjfRRo99nEKQFHMEQCIGoFCvJ2zPB7PCpznh4+1jsY03kMie49KPoPDdr7/T9TAiB3jV7wzR9BH11FSbi+8U8gSX95PrBlnp1lOBgTUIUw3QFHUiED8lXZT/Sldb6I/j1ByxiKUS+RkR3imGYMzydXzAL4x4MhAsqFX+ARliaWxBeewvn9Sq/ljw7fW2ZZbUtCogGSKrMJUq4AACICAsqFX+ARliaWxBeewvn9Sq/ljw7fW2ZZbUtCogGSKrMJBG6B95QiAgPyVdlP9KV1voj+PUHLGIpRL5GRHeKYZgzPJ1fMAvjHgwQ0umUhAA==").unwrap();
         assert!(is_final(&full_signed_psbt).is_ok());
     }
+
+    #[cfg(feature = "compiler")]
+    #[test]
+    fn test_compile_taproot() {
+        use super::{NUMS_UNSPENDABLE_KEY_HEX, handle_compile_subcommand};
+        use bdk_wallet::bitcoin::Network;
+
+        // Expected taproot descriptors with checksums (using NUMS key from constant)
+        let expected_pk_a = format!("tr({},pk(A))#a2mlskt0", NUMS_UNSPENDABLE_KEY_HEX);
+        let expected_and_ab = format!(
+            "tr({},and_v(v:pk(A),pk(B)))#sfplm6kv",
+            NUMS_UNSPENDABLE_KEY_HEX
+        );
+
+        // Test simple pk policy compilation to taproot
+        let result = handle_compile_subcommand(
+            Network::Testnet,
+            "pk(A)".to_string(),
+            "tr".to_string(),
+            false,
+        );
+        assert!(result.is_ok());
+        let json_string = result.unwrap();
+        let json_result: serde_json::Value = serde_json::from_str(&json_string).unwrap();
+        let descriptor = json_result.get("descriptor").unwrap().as_str().unwrap();
+        assert_eq!(descriptor, expected_pk_a);
+
+        // Test more complex policy
+        let result = handle_compile_subcommand(
+            Network::Testnet,
+            "and(pk(A),pk(B))".to_string(),
+            "tr".to_string(),
+            false,
+        );
+        assert!(result.is_ok());
+        let json_string = result.unwrap();
+        let json_result: serde_json::Value = serde_json::from_str(&json_string).unwrap();
+        let descriptor = json_result.get("descriptor").unwrap().as_str().unwrap();
+        assert_eq!(descriptor, expected_and_ab);
+    }
+
+    #[cfg(feature = "compiler")]
+    #[test]
+    fn test_compile_invalid_cases() {
+        use super::handle_compile_subcommand;
+        use bdk_wallet::bitcoin::Network;
+
+        // Test invalid policy syntax
+        let result = handle_compile_subcommand(
+            Network::Testnet,
+            "invalid_policy".to_string(),
+            "tr".to_string(),
+            false,
+        );
+        assert!(result.is_err());
+
+        // Test invalid script type
+        let result = handle_compile_subcommand(
+            Network::Testnet,
+            "pk(A)".to_string(),
+            "invalid_type".to_string(),
+            false,
+        );
+        assert!(result.is_err());
+
+        // Test empty policy
+        let result =
+            handle_compile_subcommand(Network::Testnet, "".to_string(), "tr".to_string(), false);
+        assert!(result.is_err());
+
+        // Test malformed policy with unmatched parentheses
+        let result = handle_compile_subcommand(
+            Network::Testnet,
+            "pk(A".to_string(),
+            "tr".to_string(),
+            false,
+        );
+        assert!(result.is_err());
+
+        // Test policy with unknown function
+        let result = handle_compile_subcommand(
+            Network::Testnet,
+            "unknown_func(A)".to_string(),
+            "tr".to_string(),
+            false,
+        );
+        assert!(result.is_err());
+    }
 }
index c82b7d8b071a7700b7e05a89f0af3f81576f7a67..fc1cec793071c54d849f9eed5478b8c0928ff475 100644 (file)
@@ -338,7 +338,7 @@ pub async fn sync_kyoto_client(wallet: &mut Wallet, client: Box<LightClient>) ->
 
     let subscriber = tracing_subscriber::FmtSubscriber::new();
     tracing::subscriber::set_global_default(subscriber)
-        .map_err(|e| Error::Generic(format!("SetGlobalDefault error: {}", e)))?;
+        .map_err(|e| Error::Generic(format!("SetGlobalDefault error: {e}")))?;
 
     tokio::task::spawn(async move { node.run().await });
     tokio::task::spawn(async move {
@@ -355,7 +355,7 @@ pub async fn sync_kyoto_client(wallet: &mut Wallet, client: Box<LightClient>) ->
     tracing::info!("Received update: applying to wallet");
     wallet
         .apply_update(update)
-        .map_err(|e| Error::Generic(format!("Failed to apply update: {}", e)))?;
+        .map_err(|e| Error::Generic(format!("Failed to apply update: {e}")))?;
 
     tracing::info!(
         "Chain tip: {}, Transactions: {}, Balance: {}",
index adfb17c24b0c8491b1e3795edbb03c067029fe55..a45cf8aa2236e0d201af5f419fbfee2189a1d627 100644 (file)
@@ -127,19 +127,19 @@ mod test {
                 node_datadir,
             };
 
-            println!("BDK-CLI Config : {:#?}", bdk_cli);
+            println!("BDK-CLI Config : {bdk_cli:#?}");
             let bdk_master_key = bdk_cli.key_exec(&["generate"])?;
             let bdk_xprv = get_value(&bdk_master_key, "xprv")?;
 
             let bdk_recv_desc =
                 bdk_cli.key_exec(&["derive", "--path", "m/84h/1h/0h/0", "--xprv", &bdk_xprv])?;
             let bdk_recv_desc = get_value(&bdk_recv_desc, "xprv")?;
-            let bdk_recv_desc = format!("wpkh({})", bdk_recv_desc);
+            let bdk_recv_desc = format!("wpkh({bdk_recv_desc})");
 
             let bdk_chng_desc =
                 bdk_cli.key_exec(&["derive", "--path", "m/84h/1h/0h/1", "--xprv", &bdk_xprv])?;
             let bdk_chng_desc = get_value(&bdk_chng_desc, "xprv")?;
-            let bdk_chng_desc = format!("wpkh({})", bdk_chng_desc);
+            let bdk_chng_desc = format!("wpkh({bdk_chng_desc})");
 
             bdk_cli.recv_desc = Some(bdk_recv_desc);
             bdk_cli.chang_desc = Some(bdk_chng_desc);