Summary
rust-miniscript's Taproot policy compiler currently converts at least two recoverable Taproot-construction failures into panics via internal expect(...) calls instead of surfacing normal errors.
Verified locally on commit 6131095c8ee99ba9a91ee004b1d387dd8418766a.
Confirmed variants locally:
1. Uncompressed chosen/internal key
Affected entrypoints:
Policy::<bitcoin::PublicKey>::compile_tr(...)
Policy::<bitcoin::PublicKey>::compile_tr_native(...)
Policy::<bitcoin::PublicKey>::compile_to_descriptor(DescriptorCtx::Tr(..))
These panic with:
compiler produces sane output: ContextError(UncompressedKeysNotAllowed)
By contrast:
Policy::<bitcoin::PublicKey>::compile_tr_private_experimental(...)
returns:
Err(ContextError(UncompressedKeysNotAllowed))
2. Overdeep Huffman TapTree from a skewed root-level disjunction
Affected entrypoints:
Policy::compile_tr(...)
Policy::compile_tr_native(...)
Policy::compile_tr_private_experimental(...)
Policy::compile_to_descriptor::<Tap>(DescriptorCtx::Tr(..))
These panic with:
huffman tree cannot produce depth > 128 given sane weights: TapTreeDepthError
Representative repro 1: uncompressed chosen internal key
use std::panic::AssertUnwindSafe;
use std::str::FromStr;
use miniscript::bitcoin::PublicKey;
use miniscript::policy::concrete::Policy;
fn main() {
let uncompressed = PublicKey::from_str(
"0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
)
.unwrap();
let policy = Policy::<PublicKey>::Trivial;
let res = std::panic::catch_unwind(AssertUnwindSafe(|| {
policy.compile_tr(Some(uncompressed))
}));
println!("{res:?}");
}
Observed locally:
Policy::<PublicKey>::Trivial.compile_tr(Some(uncompressed)) panics with:
compiler produces sane output: ContextError(UncompressedKeysNotAllowed)
compile_tr_native(Some(uncompressed), ...) panics with the same error.
compile_to_descriptor(DescriptorCtx::Tr(Some(uncompressed))) panics with the same error.
- The same payload passed to
compile_tr_private_experimental(...) returns:
Err(ContextError(UncompressedKeysNotAllowed))
I also rechecked a useful control locally: a two-branch policy containing one uncompressed key and one compressed key compiled successfully through compile_tr(...) and compile_tr_native(...).
That narrows this crash to cases where the chosen internal key itself is uncompressed, rather than any appearance of an uncompressed key anywhere in the policy.
Representative repro 2: overdeep Huffman TapTree
This public manual policy shape is enough:
- Build a right-comb of
Policy::Or
- Use weights
(1, 1) at every Or
- Use unique
pk(Ki) leaves
use std::sync::Arc;
use miniscript::policy::concrete::Policy;
fn main() {
let mut leaves = Vec::new();
for i in 0..131 {
leaves.push(Policy::Key(format!("K{i}")));
}
let policy = build_weighted_or(&leaves);
let _ = policy.compile_tr(None::<String>);
}
fn build_weighted_or(leaves: &[Policy<String>]) -> Policy<String> {
if leaves.len() == 2 {
return Policy::Or(vec![
(1, Arc::new(leaves[0].clone())),
(1, Arc::new(leaves[1].clone())),
]);
}
Policy::Or(vec![
(1, Arc::new(leaves[0].clone())),
(1, Arc::new(build_weighted_or(&leaves[1..]))),
])
}
Observed locally:
- 130 leaves: succeeds
- 131 leaves: panics with:
huffman tree cannot produce depth > 128 given sane weights: TapTreeDepthError
I also rechecked this locally with tiny standalone repro binaries on both entry paths:
- Manual
Policy::Or construction
- Parsed textual concrete policy
Both showed the same threshold, so this is reachable from parsed textual concrete policy as well as manual enum construction.
Why this seems to happen
There are two separate expect(...) sites turning recoverable Taproot-construction failures into panics.
1. Descriptor::new_tr(...) error is unwrapped
Public compile_tr(...) / compile_tr_native(...) call Descriptor::new_tr(...) and unwrap it with:
expect("compiler produces sane output")
Descriptor::new_tr(...) itself returns Result<_, Error>.
When the chosen or caller-supplied internal key is uncompressed, the recoverable error:
ContextError(UncompressedKeysNotAllowed)
is converted into a panic there.
By contrast, compile_tr_private_experimental(...) uses:
which is why the same input returns a normal error on that entrypoint.
2. TapTree::combine(...) error is unwrapped
The shared with_huffman_tree(...) helper does:
TapTree::combine(...)
.expect("huffman tree cannot produce depth > 128 given sane weights")
But TapTree::combine(...) itself is defined as:
pub fn combine(left: Self, right: Self) -> Result<Self, TapTreeDepthError>
So once a skewed Huffman construction would exceed depth 128, that recoverable TapTreeDepthError is also converted into a panic.
One source-level asymmetry appears to be that CompilerError currently has no variant carrying either ContextError or TapTreeDepthError, which helps explain why the public compile_tr(...) / compile_tr_native(...) path currently uses expect(...) instead of error propagation.
Summary
rust-miniscript's Taproot policy compiler currently converts at least two recoverable Taproot-construction failures into panics via internalexpect(...)calls instead of surfacing normal errors.Verified locally on commit
6131095c8ee99ba9a91ee004b1d387dd8418766a.Confirmed variants locally:
1. Uncompressed chosen/internal key
Affected entrypoints:
Policy::<bitcoin::PublicKey>::compile_tr(...)Policy::<bitcoin::PublicKey>::compile_tr_native(...)Policy::<bitcoin::PublicKey>::compile_to_descriptor(DescriptorCtx::Tr(..))These panic with:
By contrast:
Policy::<bitcoin::PublicKey>::compile_tr_private_experimental(...)returns:
2. Overdeep Huffman TapTree from a skewed root-level disjunction
Affected entrypoints:
Policy::compile_tr(...)Policy::compile_tr_native(...)Policy::compile_tr_private_experimental(...)Policy::compile_to_descriptor::<Tap>(DescriptorCtx::Tr(..))These panic with:
Representative repro 1: uncompressed chosen internal key
Observed locally:
Policy::<PublicKey>::Trivial.compile_tr(Some(uncompressed))panics with:compile_tr_native(Some(uncompressed), ...)panics with the same error.compile_to_descriptor(DescriptorCtx::Tr(Some(uncompressed)))panics with the same error.compile_tr_private_experimental(...)returns:I also rechecked a useful control locally: a two-branch policy containing one uncompressed key and one compressed key compiled successfully through
compile_tr(...)andcompile_tr_native(...).That narrows this crash to cases where the chosen internal key itself is uncompressed, rather than any appearance of an uncompressed key anywhere in the policy.
Representative repro 2: overdeep Huffman TapTree
This public manual policy shape is enough:
Policy::Or(1, 1)at everyOrpk(Ki)leavesObserved locally:
I also rechecked this locally with tiny standalone repro binaries on both entry paths:
Policy::OrconstructionBoth showed the same threshold, so this is reachable from parsed textual concrete policy as well as manual enum construction.
Why this seems to happen
There are two separate
expect(...)sites turning recoverable Taproot-construction failures into panics.1.
Descriptor::new_tr(...)error is unwrappedPublic
compile_tr(...)/compile_tr_native(...)callDescriptor::new_tr(...)and unwrap it with:Descriptor::new_tr(...)itself returnsResult<_, Error>.When the chosen or caller-supplied internal key is uncompressed, the recoverable error:
is converted into a panic there.
By contrast,
compile_tr_private_experimental(...)uses:which is why the same input returns a normal error on that entrypoint.
2.
TapTree::combine(...)error is unwrappedThe shared
with_huffman_tree(...)helper does:But
TapTree::combine(...)itself is defined as:So once a skewed Huffman construction would exceed depth 128, that recoverable
TapTreeDepthErroris also converted into a panic.One source-level asymmetry appears to be that
CompilerErrorcurrently has no variant carrying eitherContextErrororTapTreeDepthError, which helps explain why the publiccompile_tr(...)/compile_tr_native(...)path currently usesexpect(...)instead of error propagation.