From 3bcec61d8721b1f5a863eb6f3e1be146bc0bf54d Mon Sep 17 00:00:00 2001 From: Vadim Anufriev Date: Mon, 8 Sep 2025 12:19:54 +0400 Subject: [PATCH] feat(compile): add tr option for compile cmd --- src/commands.rs | 2 +- src/handlers.rs | 165 +++++++++++++++++++++++++++++++++++-------- src/utils.rs | 4 +- tests/integration.rs | 6 +- 4 files changed, 143 insertions(+), 34 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index d202737b..8130b004 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -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, }, diff --git a/src/handlers.rs b/src/handlers.rs index 9a86b77f..362c4568 100644 --- a/src/handlers.rs +++ b/src/handlers.rs @@ -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::::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 = policy .compile() .map_err(|e| Error::Generic(e.to_string()))?; + let taproot_policy: Miniscript = 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 { 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()); + } } diff --git a/src/utils.rs b/src/utils.rs index c82b7d8b..fc1cec79 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -338,7 +338,7 @@ pub async fn sync_kyoto_client(wallet: &mut Wallet, client: Box) -> 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) -> 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: {}", diff --git a/tests/integration.rs b/tests/integration.rs index adfb17c2..a45cf8aa 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -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);