Skip to content

Commit 6387af0

Browse files
Release descriptor policy review v0.3
1 parent fe251c2 commit 6387af0

14 files changed

Lines changed: 345 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "btc-risk-lab"
3-
version = "0.2.0"
3+
version = "0.3.0"
44
edition = "2021"
55
authors = ["Jose Robles"]
66
description = "A Rust CLI for explainable Bitcoin transaction, PSBT, and script risk reports."

src/analyzer/descriptor.rs

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
use std::str::FromStr;
2+
3+
use anyhow::{bail, Context, Result};
4+
use bitcoin::PublicKey;
5+
use miniscript::{descriptor::DescriptorType, Descriptor};
6+
7+
use super::{
8+
common_limitations, complexity, summary, warning, ArtifactType, DescriptorAnalysis, RiskLevel,
9+
RiskReport, ScriptSignals,
10+
};
11+
use crate::analyzer::script::{analyze_script_bytes, classify_script};
12+
13+
pub fn analyze_descriptor_input(input: &str) -> Result<RiskReport> {
14+
let descriptor = input.trim();
15+
if descriptor.is_empty() {
16+
bail!("descriptor cannot be empty");
17+
}
18+
19+
let descriptor = Descriptor::<PublicKey>::from_str(descriptor)
20+
.context("descriptor input is not a valid public-key output descriptor")?;
21+
22+
Ok(analyze_descriptor(&descriptor))
23+
}
24+
25+
fn analyze_descriptor(descriptor: &Descriptor<PublicKey>) -> RiskReport {
26+
let descriptor_type = descriptor_type_name(descriptor.desc_type()).to_owned();
27+
let script_pubkey = descriptor.script_pubkey();
28+
let script_type = classify_script(&script_pubkey);
29+
let sanity_check = descriptor.sanity_check().is_ok();
30+
let max_satisfaction_weight_wu = descriptor
31+
.max_weight_to_satisfy()
32+
.ok()
33+
.map(|weight| weight.to_wu());
34+
let signals = descriptor_signals(descriptor);
35+
36+
let mut warnings = Vec::new();
37+
let missing_data = Vec::new();
38+
39+
if !sanity_check {
40+
warnings.push(warning(
41+
"descriptor-sanity-check",
42+
RiskLevel::Medium,
43+
"Descriptor sanity check failed",
44+
"Miniscript parsed the descriptor, but its safety checks did not pass. Review spend paths, malleability, and standardness assumptions before operational use.",
45+
));
46+
}
47+
48+
if signals.multisig || signals.threshold {
49+
warnings.push(warning(
50+
"threshold-policy",
51+
RiskLevel::Low,
52+
"Threshold or multisig policy detected",
53+
"The descriptor includes threshold-like signing policy. Review signer count, quorum, backup paths, and key origin documentation.",
54+
));
55+
}
56+
57+
if signals.timelock || signals.relative_timelock {
58+
warnings.push(warning(
59+
"timelock-signal",
60+
RiskLevel::Medium,
61+
"Timelock signal detected",
62+
"The descriptor contains absolute or relative timelock policy. Confirm block height, median time, and sequence assumptions before relying on it.",
63+
));
64+
}
65+
66+
let complexity = descriptor_complexity(&descriptor_type, &signals);
67+
let mut summary_items = vec![
68+
summary("descriptor_type", &descriptor_type),
69+
summary("script_type", &script_type),
70+
summary("sanity_check", sanity_check),
71+
];
72+
if let Some(weight) = max_satisfaction_weight_wu {
73+
summary_items.push(summary("max_satisfaction_weight_wu", weight));
74+
}
75+
76+
let risk = RiskLevel::from_warnings(&warnings, &missing_data);
77+
RiskReport {
78+
schema_version: super::REPORT_SCHEMA_VERSION.to_owned(),
79+
artifact_type: ArtifactType::Descriptor,
80+
risk,
81+
summary: summary_items,
82+
warnings,
83+
missing_data,
84+
limitations: common_limitations(),
85+
transaction: None,
86+
psbt: None,
87+
script: None,
88+
descriptor: Some(DescriptorAnalysis {
89+
descriptor_type,
90+
script_type,
91+
sanity_check,
92+
max_satisfaction_weight_wu,
93+
signals,
94+
complexity,
95+
}),
96+
}
97+
}
98+
99+
fn descriptor_signals(descriptor: &Descriptor<PublicKey>) -> ScriptSignals {
100+
let normalized = descriptor.to_string();
101+
let mut signals = descriptor
102+
.explicit_script()
103+
.map(|script| analyze_script_bytes(script.as_bytes()).signals)
104+
.unwrap_or_default();
105+
106+
signals.multisig |= has_any(&normalized, &["multi(", "sortedmulti(", "multi_a("]);
107+
signals.threshold |= has_any(
108+
&normalized,
109+
&["thresh(", "multi(", "sortedmulti(", "multi_a("],
110+
);
111+
signals.timelock |= normalized.contains("after(");
112+
signals.relative_timelock |= normalized.contains("older(");
113+
signals
114+
}
115+
116+
fn has_any(input: &str, needles: &[&str]) -> bool {
117+
needles.iter().any(|needle| input.contains(needle))
118+
}
119+
120+
fn descriptor_complexity(descriptor_type: &str, signals: &ScriptSignals) -> super::Complexity {
121+
let mut score = 0;
122+
let mut factors = vec![format!("descriptor type {descriptor_type}")];
123+
124+
if descriptor_type.contains("wsh") || descriptor_type.contains("sh_") {
125+
score += 1;
126+
factors.push("wrapped or witness script descriptor".to_owned());
127+
}
128+
129+
if signals.multisig || signals.threshold {
130+
score += 2;
131+
factors.push("threshold policy detected".to_owned());
132+
}
133+
134+
if signals.timelock || signals.relative_timelock {
135+
score += 2;
136+
factors.push("timelock policy detected".to_owned());
137+
}
138+
139+
complexity(score, factors)
140+
}
141+
142+
fn descriptor_type_name(descriptor_type: DescriptorType) -> &'static str {
143+
match descriptor_type {
144+
DescriptorType::Bare => "bare",
145+
DescriptorType::Sh => "sh",
146+
DescriptorType::Pkh => "pkh",
147+
DescriptorType::Wpkh => "wpkh",
148+
DescriptorType::Wsh => "wsh",
149+
DescriptorType::ShWsh => "sh_wsh",
150+
DescriptorType::ShWpkh => "sh_wpkh",
151+
DescriptorType::ShSortedMulti => "sh_sortedmulti",
152+
DescriptorType::WshSortedMulti => "wsh_sortedmulti",
153+
DescriptorType::ShWshSortedMulti => "sh_wsh_sortedmulti",
154+
DescriptorType::Tr => "tr",
155+
}
156+
}
157+
158+
#[cfg(test)]
159+
mod tests {
160+
use super::*;
161+
162+
#[test]
163+
fn analyzes_simple_descriptor_fixture() {
164+
let report =
165+
analyze_descriptor_input(include_str!("../../tests/fixtures/descriptors/simple.txt"))
166+
.unwrap();
167+
let analysis = report.descriptor.unwrap();
168+
169+
assert_eq!(report.schema_version, "0.3");
170+
assert_eq!(analysis.descriptor_type, "wpkh");
171+
assert_eq!(analysis.script_type, "p2wpkh");
172+
assert!(analysis.sanity_check);
173+
assert!(analysis.max_satisfaction_weight_wu.is_some());
174+
assert!(!analysis.signals.multisig);
175+
assert_eq!(report.risk, RiskLevel::Low);
176+
}
177+
178+
#[test]
179+
fn analyzes_sortedmulti_descriptor_fixture() {
180+
let report = analyze_descriptor_input(include_str!(
181+
"../../tests/fixtures/descriptors/sortedmulti.txt"
182+
))
183+
.unwrap();
184+
let analysis = report.descriptor.unwrap();
185+
186+
assert_eq!(analysis.descriptor_type, "wsh_sortedmulti");
187+
assert_eq!(analysis.script_type, "p2wsh");
188+
assert!(analysis.signals.multisig);
189+
assert!(analysis.signals.threshold);
190+
assert!(report
191+
.warnings
192+
.iter()
193+
.any(|warning| warning.code == "threshold-policy"));
194+
}
195+
196+
#[test]
197+
fn analyzes_timelock_descriptor_fixture() {
198+
let report = analyze_descriptor_input(include_str!(
199+
"../../tests/fixtures/descriptors/timelock.txt"
200+
))
201+
.unwrap();
202+
let analysis = report.descriptor.unwrap();
203+
204+
assert_eq!(analysis.descriptor_type, "wsh");
205+
assert!(analysis.signals.relative_timelock);
206+
assert_eq!(report.risk, RiskLevel::Medium);
207+
assert!(report
208+
.warnings
209+
.iter()
210+
.any(|warning| warning.code == "timelock-signal"));
211+
}
212+
213+
#[test]
214+
fn rejects_invalid_descriptor_fixture() {
215+
let err =
216+
analyze_descriptor_input(include_str!("../../tests/fixtures/descriptors/invalid.txt"))
217+
.unwrap_err();
218+
219+
assert!(err
220+
.to_string()
221+
.contains("descriptor input is not a valid public-key output descriptor"));
222+
}
223+
}

src/analyzer/mod.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
1+
mod descriptor;
12
mod psbt;
23
mod script;
34
mod tx;
45

56
use serde::{Deserialize, Serialize};
67

8+
pub use descriptor::analyze_descriptor_input;
79
pub use psbt::analyze_psbt_file;
810
pub use script::analyze_script_input;
911
pub use tx::{
1012
analyze_transaction_file, analyze_transaction_hex, analyze_transaction_hex_with_prevouts,
1113
PrevoutInput,
1214
};
1315

16+
const REPORT_SCHEMA_VERSION: &str = "0.3";
17+
1418
#[derive(Clone, Debug, Deserialize, Serialize)]
1519
pub struct RiskReport {
20+
pub schema_version: String,
1621
pub artifact_type: ArtifactType,
1722
pub risk: RiskLevel,
1823
pub summary: Vec<SummaryItem>,
@@ -25,6 +30,8 @@ pub struct RiskReport {
2530
pub psbt: Option<PsbtAnalysis>,
2631
#[serde(skip_serializing_if = "Option::is_none")]
2732
pub script: Option<ScriptAnalysis>,
33+
#[serde(skip_serializing_if = "Option::is_none")]
34+
pub descriptor: Option<DescriptorAnalysis>,
2835
}
2936

3037
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -33,6 +40,7 @@ pub enum ArtifactType {
3340
Transaction,
3441
Psbt,
3542
Script,
43+
Descriptor,
3644
}
3745

3846
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, Serialize)]
@@ -114,6 +122,16 @@ pub struct ScriptAnalysis {
114122
pub complexity: Complexity,
115123
}
116124

125+
#[derive(Clone, Debug, Deserialize, Serialize)]
126+
pub struct DescriptorAnalysis {
127+
pub descriptor_type: String,
128+
pub script_type: String,
129+
pub sanity_check: bool,
130+
pub max_satisfaction_weight_wu: Option<u64>,
131+
pub signals: ScriptSignals,
132+
pub complexity: Complexity,
133+
}
134+
117135
#[derive(Clone, Debug, Deserialize, Serialize)]
118136
pub struct OutputAnalysis {
119137
pub index: usize,
@@ -126,6 +144,7 @@ pub struct OutputAnalysis {
126144
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
127145
pub struct ScriptSignals {
128146
pub multisig: bool,
147+
pub threshold: bool,
129148
pub timelock: bool,
130149
pub relative_timelock: bool,
131150
pub op_return: bool,

src/analyzer/psbt.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ fn analyze_psbt(psbt: &Psbt) -> RiskReport {
127127

128128
let risk = RiskLevel::from_warnings(&warnings, &missing_data);
129129
RiskReport {
130+
schema_version: super::REPORT_SCHEMA_VERSION.to_owned(),
130131
artifact_type: ArtifactType::Psbt,
131132
risk,
132133
summary: summary_items,
@@ -145,6 +146,7 @@ fn analyze_psbt(psbt: &Psbt) -> RiskReport {
145146
complexity,
146147
}),
147148
script: None,
149+
descriptor: None,
148150
}
149151
}
150152

src/analyzer/script.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ pub fn analyze_script_input(input: &str) -> Result<RiskReport> {
5555

5656
let risk = RiskLevel::from_warnings(&warnings, &missing_data);
5757
Ok(RiskReport {
58+
schema_version: super::REPORT_SCHEMA_VERSION.to_owned(),
5859
artifact_type: ArtifactType::Script,
5960
risk,
6061
summary: vec![
@@ -69,6 +70,7 @@ pub fn analyze_script_input(input: &str) -> Result<RiskReport> {
6970
transaction: None,
7071
psbt: None,
7172
script: Some(analysis),
73+
descriptor: None,
7274
})
7375
}
7476

@@ -82,6 +84,7 @@ fn analyze_script(script: &Script) -> ScriptAnalysis {
8284
let lock_signals = contains_timelock(bytes);
8385
let signals = ScriptSignals {
8486
multisig: contains_multisig(bytes),
87+
threshold: false,
8588
timelock: lock_signals.absolute,
8689
relative_timelock: lock_signals.relative,
8790
op_return: bytes.first().is_some_and(|opcode| *opcode == OP_RETURN),

src/analyzer/tx.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ fn analyze_transaction(
153153

154154
let risk = RiskLevel::from_warnings(&warnings, &missing_data);
155155
RiskReport {
156+
schema_version: super::REPORT_SCHEMA_VERSION.to_owned(),
156157
artifact_type: ArtifactType::Transaction,
157158
risk,
158159
summary: summary_items,
@@ -170,6 +171,7 @@ fn analyze_transaction(
170171
}),
171172
psbt: None,
172173
script: None,
174+
descriptor: None,
173175
}
174176
}
175177

src/main.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@ enum Commands {
6868
format: CliFormat,
6969
},
7070

71+
/// Analyze an output descriptor for policy and script risk signals.
72+
AnalyzeDescriptor {
73+
#[arg(long)]
74+
descriptor: String,
75+
76+
#[arg(long, value_enum, default_value_t = CliFormat::Markdown)]
77+
format: CliFormat,
78+
},
79+
7180
/// Generate an optional executive summary from an existing technical JSON report.
7281
Summarize {
7382
#[arg(long)]
@@ -123,6 +132,10 @@ fn main() -> Result<()> {
123132
let report = analyzer::analyze_script_input(&script)?;
124133
println!("{}", render_report(&report, format.into())?);
125134
}
135+
Commands::AnalyzeDescriptor { descriptor, format } => {
136+
let report = analyzer::analyze_descriptor_input(&descriptor)?;
137+
println!("{}", render_report(&report, format.into())?);
138+
}
126139
Commands::Summarize { input, provider } => summarize(input, provider)?,
127140
}
128141

0 commit comments

Comments
 (0)