use crate::error::BitcoinError;
use bitcoin::psbt::Psbt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PsbtAnalysis {
pub is_complete: bool,
pub total_input_value: u64,
pub total_output_value: u64,
pub fee: u64,
pub fee_rate: f64,
pub vsize: usize,
pub warnings: Vec<ValidationWarning>,
pub security_issues: Vec<SecurityIssue>,
pub input_analysis: Vec<InputAnalysis>,
pub output_analysis: Vec<OutputAnalysis>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ValidationWarning {
HighFee {
fee: u64,
percentage: f64,
},
HighFeeRate {
rate: f64,
},
MissingWitnessData {
input_index: usize,
},
MissingUtxoInfo {
input_index: usize,
},
DustOutput {
output_index: usize,
amount: u64,
},
UnidentifiedChange,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SecurityIssue {
FeeOverpayment {
expected_fee: u64,
actual_fee: u64,
},
UnverifiedInputAmounts,
AddressReuse {
output_index: usize,
},
SuspiciousScript {
input_index: usize,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InputAnalysis {
pub index: usize,
pub value: Option<u64>,
pub script_type: String,
pub has_witness: bool,
pub has_non_witness_utxo: bool,
pub has_witness_utxo: bool,
pub signature_count: usize,
pub required_signatures: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputAnalysis {
pub index: usize,
pub value: u64,
pub script_type: String,
pub is_likely_change: bool,
pub is_dust: bool,
}
#[allow(dead_code)]
pub struct PsbtAnalyzer {
min_relay_fee_rate: f64,
high_fee_threshold: f64,
high_fee_rate_threshold: f64,
dust_threshold: u64,
}
impl PsbtAnalyzer {
pub fn new() -> Self {
Self {
min_relay_fee_rate: 1.0,
high_fee_threshold: 0.05, high_fee_rate_threshold: 100.0, dust_threshold: 546,
}
}
pub fn with_config(
min_relay_fee_rate: f64,
high_fee_threshold: f64,
high_fee_rate_threshold: f64,
dust_threshold: u64,
) -> Self {
Self {
min_relay_fee_rate,
high_fee_threshold,
high_fee_rate_threshold,
dust_threshold,
}
}
pub fn analyze(&self, psbt: &Psbt) -> Result<PsbtAnalysis, BitcoinError> {
let total_input_value = self.calculate_input_value(psbt)?;
let total_output_value: u64 = psbt
.unsigned_tx
.output
.iter()
.map(|o| o.value.to_sat())
.sum();
let fee = total_input_value.saturating_sub(total_output_value);
let vsize = self.estimate_vsize(psbt);
let fee_rate = if vsize > 0 {
fee as f64 / vsize as f64
} else {
0.0
};
let is_complete = self.check_completeness(psbt);
let input_analysis = self.analyze_inputs(psbt);
let output_analysis = self.analyze_outputs(psbt);
let mut warnings = Vec::new();
self.check_fee_warnings(&mut warnings, fee, total_output_value, fee_rate);
self.check_input_warnings(&mut warnings, &input_analysis);
self.check_output_warnings(&mut warnings, &output_analysis);
let mut security_issues = Vec::new();
self.check_security_issues(&mut security_issues, psbt, fee, &input_analysis);
Ok(PsbtAnalysis {
is_complete,
total_input_value,
total_output_value,
fee,
fee_rate,
vsize,
warnings,
security_issues,
input_analysis,
output_analysis,
})
}
fn calculate_input_value(&self, psbt: &Psbt) -> Result<u64, BitcoinError> {
let mut total = 0u64;
for input in &psbt.inputs {
if let Some(witness_utxo) = &input.witness_utxo {
total += witness_utxo.value.to_sat();
} else if let Some(non_witness_utxo) = &input.non_witness_utxo {
if let Some(vout) = psbt
.unsigned_tx
.input
.first()
.map(|i| i.previous_output.vout)
{
if let Some(output) = non_witness_utxo.output.get(vout as usize) {
total += output.value.to_sat();
}
}
} else {
return Err(BitcoinError::InvalidTransaction(
"Missing UTXO information for input".to_string(),
));
}
}
Ok(total)
}
fn estimate_vsize(&self, psbt: &Psbt) -> usize {
let input_count = psbt.unsigned_tx.input.len();
let output_count = psbt.unsigned_tx.output.len();
10 + (input_count * 68) + (output_count * 31)
}
fn check_completeness(&self, psbt: &Psbt) -> bool {
for input in &psbt.inputs {
if input.final_script_sig.is_none() && input.final_script_witness.is_none() {
return false;
}
}
true
}
fn analyze_inputs(&self, psbt: &Psbt) -> Vec<InputAnalysis> {
psbt.inputs
.iter()
.enumerate()
.map(|(index, input)| {
let value = input.witness_utxo.as_ref().map(|u| u.value.to_sat());
let script_type = if input.witness_utxo.is_some() {
"SegWit".to_string()
} else if input.non_witness_utxo.is_some() {
"Legacy".to_string()
} else {
"Unknown".to_string()
};
let has_witness = input.final_script_witness.is_some();
let signature_count = input.partial_sigs.len();
InputAnalysis {
index,
value,
script_type,
has_witness,
has_non_witness_utxo: input.non_witness_utxo.is_some(),
has_witness_utxo: input.witness_utxo.is_some(),
signature_count,
required_signatures: None, }
})
.collect()
}
fn analyze_outputs(&self, psbt: &Psbt) -> Vec<OutputAnalysis> {
psbt.unsigned_tx
.output
.iter()
.enumerate()
.map(|(index, output)| {
let value = output.value.to_sat();
let is_dust = value < self.dust_threshold;
let script_type = if output.script_pubkey.is_p2pkh() {
"P2PKH".to_string()
} else if output.script_pubkey.is_p2sh() {
"P2SH".to_string()
} else if output.script_pubkey.is_p2wpkh() {
"P2WPKH".to_string()
} else if output.script_pubkey.is_p2wsh() {
"P2WSH".to_string()
} else if output.script_pubkey.is_p2tr() {
"P2TR".to_string()
} else {
"Unknown".to_string()
};
OutputAnalysis {
index,
value,
script_type,
is_likely_change: false, is_dust,
}
})
.collect()
}
fn check_fee_warnings(
&self,
warnings: &mut Vec<ValidationWarning>,
fee: u64,
total_output: u64,
fee_rate: f64,
) {
let fee_percentage = fee as f64 / (total_output + fee) as f64;
if fee_percentage > self.high_fee_threshold {
warnings.push(ValidationWarning::HighFee {
fee,
percentage: fee_percentage * 100.0,
});
}
if fee_rate > self.high_fee_rate_threshold {
warnings.push(ValidationWarning::HighFeeRate { rate: fee_rate });
}
}
fn check_input_warnings(
&self,
warnings: &mut Vec<ValidationWarning>,
input_analysis: &[InputAnalysis],
) {
for input in input_analysis {
if !input.has_witness && !input.has_non_witness_utxo && !input.has_witness_utxo {
warnings.push(ValidationWarning::MissingUtxoInfo {
input_index: input.index,
});
}
if input.script_type == "SegWit" && !input.has_witness {
warnings.push(ValidationWarning::MissingWitnessData {
input_index: input.index,
});
}
}
}
fn check_output_warnings(
&self,
warnings: &mut Vec<ValidationWarning>,
output_analysis: &[OutputAnalysis],
) {
for output in output_analysis {
if output.is_dust {
warnings.push(ValidationWarning::DustOutput {
output_index: output.index,
amount: output.value,
});
}
}
}
#[allow(dead_code)]
fn check_security_issues(
&self,
security_issues: &mut Vec<SecurityIssue>,
psbt: &Psbt,
fee: u64,
input_analysis: &[InputAnalysis],
) {
let has_unverified = input_analysis.iter().any(|i| i.value.is_none());
if has_unverified {
security_issues.push(SecurityIssue::UnverifiedInputAmounts);
}
let vsize = self.estimate_vsize(psbt);
let expected_fee = (vsize as f64 * 10.0) as u64; if fee > expected_fee * 10 {
security_issues.push(SecurityIssue::FeeOverpayment {
expected_fee,
actual_fee: fee,
});
}
}
}
impl Default for PsbtAnalyzer {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeeVerification {
pub is_reasonable: bool,
pub fee: u64,
pub fee_rate: f64,
pub expected_fee_range: (u64, u64),
pub issues: Vec<String>,
}
pub fn verify_psbt_fees(
psbt: &Psbt,
expected_fee_rate: f64,
tolerance: f64,
) -> Result<FeeVerification, BitcoinError> {
let analyzer = PsbtAnalyzer::new();
let analysis = analyzer.analyze(psbt)?;
let min_expected_fee = (analysis.vsize as f64 * expected_fee_rate * (1.0 - tolerance)) as u64;
let max_expected_fee = (analysis.vsize as f64 * expected_fee_rate * (1.0 + tolerance)) as u64;
let is_reasonable = analysis.fee >= min_expected_fee && analysis.fee <= max_expected_fee;
let mut issues = Vec::new();
if analysis.fee < min_expected_fee {
issues.push(format!(
"Fee too low: {} sat (expected at least {} sat)",
analysis.fee, min_expected_fee
));
}
if analysis.fee > max_expected_fee {
issues.push(format!(
"Fee too high: {} sat (expected at most {} sat)",
analysis.fee, max_expected_fee
));
}
Ok(FeeVerification {
is_reasonable,
fee: analysis.fee,
fee_rate: analysis.fee_rate,
expected_fee_range: (min_expected_fee, max_expected_fee),
issues,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_psbt_analyzer_new() {
let analyzer = PsbtAnalyzer::new();
assert_eq!(analyzer.min_relay_fee_rate, 1.0);
assert_eq!(analyzer.high_fee_threshold, 0.05);
assert_eq!(analyzer.dust_threshold, 546);
}
#[test]
fn test_psbt_analyzer_custom() {
let analyzer = PsbtAnalyzer::with_config(2.0, 0.10, 200.0, 1000);
assert_eq!(analyzer.min_relay_fee_rate, 2.0);
assert_eq!(analyzer.high_fee_threshold, 0.10);
assert_eq!(analyzer.high_fee_rate_threshold, 200.0);
assert_eq!(analyzer.dust_threshold, 1000);
}
#[test]
fn test_validation_warning_types() {
let warning = ValidationWarning::HighFee {
fee: 10000,
percentage: 10.0,
};
assert!(matches!(warning, ValidationWarning::HighFee { .. }));
}
#[test]
fn test_security_issue_types() {
let issue = SecurityIssue::FeeOverpayment {
expected_fee: 1000,
actual_fee: 10000,
};
assert!(matches!(issue, SecurityIssue::FeeOverpayment { .. }));
}
}