Skip to content
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,6 @@ verify = []
# Extra utility tools
# Compile policies
compiler = []

[dev-dependencies]
shlex = "1.3.0"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shlex is an optional dependency, so instead of re-installing it under dev dependencies, it's better to make it a non-optional dependency.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shlex needs to remain both optional and in dev-dependencies:

  • Optional: it's only used by the repl feature, so other builds don't need it
  • Dev-dependencies: CI runs cargo test --no-default-features which excludes repl, but tests still need shlex to compile

Making it non-optional would unnecessarily add the dependency to all builds.

Alternatively, we could accept the dependency overhead but simplify the code and CI in return?

7 changes: 7 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ pub enum BDKCliError {
#[error("Miniscript error: {0}")]
MiniscriptError(#[from] bdk_wallet::miniscript::Error),

#[error("Miniscript compiler error: {0}")]
MiniscriptCompilerError(#[from] bdk_wallet::miniscript::policy::compiler::CompilerError),

Comment on lines +48 to +50
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The above miniscript error wraps all the errors from miniscript including this one.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this error only for creating policies in a more concise way:
https://github.com/bitcoindevkit/bdk-cli/pull/225/files#diff-148e623ad4cdaab49b81901460099f4efe7900e8c57e4f7a0ff6778589be01f8L896-R905

Which way do you prefer more?

  1. remove error, restore using err_map for policies
  2. keep error and create policies without err_map

#[error("ParseError: {0}")]
ParseError(#[from] bdk_wallet::bitcoin::address::ParseError),

Expand Down Expand Up @@ -78,6 +81,10 @@ pub enum BDKCliError {
#[error("Signer error: {0}")]
SignerError(#[from] bdk_wallet::signer::SignerError),

#[cfg(feature = "compiler")]
#[error("Secp256k1 error: {0}")]
Secp256k1Error(#[from] bdk_wallet::bitcoin::secp256k1::Error),

#[cfg(feature = "electrum")]
#[error("Electrum error: {0}")]
Electrum(#[from] bdk_electrum::electrum_client::Error),
Expand Down
160 changes: 45 additions & 115 deletions src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,18 @@ 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::{
bitcoin::XOnlyPublicKey,
bitcoin::{
XOnlyPublicKey,
key::{Parity, rand},
secp256k1::{PublicKey, Scalar, SecretKey},
},
descriptor::{Descriptor, Legacy, Miniscript},
miniscript::{Tap, descriptor::TapTree, policy::Concrete},
};

use cli_table::{Cell, CellStruct, Style, Table, format::Justify};
use serde_json::json;
#[cfg(feature = "cbf")]
Expand Down Expand Up @@ -893,50 +899,62 @@ pub(crate) fn handle_compile_subcommand(
pretty: bool,
) -> Result<String, Error> {
let policy = Concrete::<String>::from_str(policy.as_str())?;
let legacy_policy: Miniscript<String, Legacy> = policy
.compile()
.map_err(|e| Error::Generic(e.to_string()))?;
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 legacy_policy: Miniscript<String, Legacy> = policy.compile()?;
let segwit_policy: Miniscript<String, Segwitv0> = policy.compile()?;
let taproot_policy: Miniscript<String, Tap> = policy.compile()?;

let mut r = None;

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),
"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
// For tr descriptors, we use a randomized unspendable key (H + rG).
// This improves privacy by preventing observers from determining if key path spending is disabled.
// See BIP-341: https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs

let secp = Secp256k1::new();
let r_secret = SecretKey::new(&mut rand::thread_rng());
r = Some(r_secret.display_secret().to_string());

let nums_key = XOnlyPublicKey::from_str(NUMS_UNSPENDABLE_KEY_HEX)?;
let nums_point = PublicKey::from_x_only_public_key(nums_key, Parity::Even);

let xonly_public_key = XOnlyPublicKey::from_str(NUMS_UNSPENDABLE_KEY_HEX)
.map_err(|e| Error::Generic(format!("Invalid NUMS key: {e}")))?;
let internal_key_point = nums_point.add_exp_tweak(&secp, &Scalar::from(r_secret))?;
let (xonly_internal_key, _) = internal_key_point.x_only_public_key();

let tree = TapTree::Leaf(Arc::new(taproot_policy));
Descriptor::new_tr(xonly_public_key.to_string(), Some(tree))
Descriptor::new_tr(xonly_internal_key.to_string(), Some(tree))
}
_ => {
return Err(Error::Generic(
"Invalid script type. Supported types: sh, wsh, sh-wsh, tr".to_string(),
));
}
}?;
}?
.to_string();

if pretty {
let table = vec![vec![
"Descriptor".cell().bold(true),
descriptor.to_string().cell(),
]]
.table()
.display()
.map_err(|e| Error::Generic(e.to_string()))?;
let mut rows = vec![vec!["Descriptor".cell().bold(true), descriptor.cell()]];

if let Some(r_value) = &r {
rows.push(vec!["r".cell().bold(true), r_value.cell()]);
}

let table = rows
.table()
.display()
.map_err(|e| Error::Generic(e.to_string()))?;

Ok(format!("{table}"))
} else {
Ok(serde_json::to_string_pretty(
&json!({"descriptor": descriptor.to_string()}),
)?)
let mut output = json!({"descriptor": descriptor});
if let Some(r_value) = r {
output["r"] = json!(r_value);
}
Ok(serde_json::to_string_pretty(&output)?)
}
}

Expand Down Expand Up @@ -1450,92 +1468,4 @@ 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());
}
}
70 changes: 70 additions & 0 deletions tests/compile.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (c) 2020-2025 Bitcoin Dev Kit Developers
//
// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
// You may not use this file except in accordance with one or both of these
// licenses.

//! Compile Command Tests
//!
//! Tests for compile command and subcommands

use std::process::Command;

fn run_cmd(cmd: &str) -> Result<String, String> {
let full_cmd = format!("run --features compiler -- {}", cmd);
let args = shlex::split(&full_cmd).unwrap();

let output = Command::new("cargo").args(args).output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();

if output.status.success() {
Ok(stdout)
} else {
Err(stderr)
}
}

#[test]
fn test_compile_taproot() {
let stdout = run_cmd(r#"compile "pk(ABC)" -t tr"#).unwrap();
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();

assert!(json.get("descriptor").is_some());
assert!(json.get("r").is_some());
}

#[test]
fn test_compile_sh() {
let stdout = run_cmd(r#"compile "pk(ABC)" -t sh"#).unwrap();
let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();

assert!(json.get("descriptor").is_some());
assert!(json.get("r").is_none());
}

#[test]
fn test_invalid_cases() {
// Test invalid policy syntax
let stderr = run_cmd(r#"compile "invalid_policy""#).unwrap_err();
assert!(stderr.contains("Miniscript error"));

// Test invalid script type
let stderr = run_cmd(r#"compile "pk(A)" -t invalid_type"#).unwrap_err();
assert!(stderr.contains("error: invalid value 'invalid_type' for '--type <SCRIPT_TYPE>'"));

// Test empty policy
let stderr = run_cmd("compile").unwrap_err();
assert!(stderr.contains("error: the following required arguments were not provided"));
assert!(stderr.contains("<POLICY>"));

// Test malformed policy with unmatched parentheses
let stderr = run_cmd(r#"compile "pk(A""#).unwrap_err();
assert!(stderr.contains("Miniscript error: expected )"));

// Test policy with unknown function
let stderr = run_cmd(r#"compile "unknown_func(A)""#).unwrap_err();
assert!(stderr.contains("Miniscript error: unexpected «unknown_func»"));
}
Loading