kaccy_bitcoin/
script_analyzer.rs

1//! Bitcoin Script Analysis and Utilities
2//!
3//! This module provides tools for analyzing Bitcoin scripts, including:
4//! - Script disassembly (opcodes to human-readable format)
5//! - Script execution simulation
6//! - Complexity analysis
7//! - Witness cost estimation
8//!
9//! # Examples
10//!
11//! ```
12//! use kaccy_bitcoin::script_analyzer::{ScriptAnalyzer, ScriptDisassembler};
13//! use bitcoin::ScriptBuf;
14//!
15//! // Disassemble a script
16//! let script = ScriptBuf::new();
17//! let disassembler = ScriptDisassembler::new();
18//! let asm = disassembler.disassemble(&script);
19//! println!("Script: {}", asm);
20//!
21//! // Analyze script complexity
22//! let analyzer = ScriptAnalyzer::new(&script);
23//! let complexity = analyzer.complexity_score();
24//! println!("Complexity: {}", complexity);
25//! ```
26
27use crate::error::BitcoinError;
28use bitcoin::blockdata::opcodes::all::*;
29use bitcoin::blockdata::script::Instruction;
30use bitcoin::{Script, ScriptBuf};
31use serde::{Deserialize, Serialize};
32use std::fmt;
33
34/// Script disassembler for converting scripts to human-readable format
35pub struct ScriptDisassembler {
36    /// Include addresses for push data opcodes
37    pub show_addresses: bool,
38    /// Use symbolic names for standard scripts
39    pub use_symbolic_names: bool,
40}
41
42impl ScriptDisassembler {
43    /// Create a new script disassembler with default settings
44    pub fn new() -> Self {
45        Self {
46            show_addresses: false,
47            use_symbolic_names: true,
48        }
49    }
50
51    /// Disassemble a script into human-readable assembly
52    pub fn disassemble(&self, script: &Script) -> String {
53        let mut result = Vec::new();
54
55        for instruction in script.instructions() {
56            match instruction {
57                Ok(Instruction::Op(opcode)) => {
58                    result.push(format!("{:?}", opcode));
59                }
60                Ok(Instruction::PushBytes(bytes)) => {
61                    let hex_data = hex::encode(bytes.as_bytes());
62                    if bytes.len() <= 4 {
63                        // For small pushes, show as decimal too
64                        if let Ok(num) = bytes.as_bytes().try_into().map(i32::from_le_bytes) {
65                            result.push(format!("PUSH {} (0x{})", num, hex_data));
66                        } else {
67                            result.push(format!("PUSH 0x{}", hex_data));
68                        }
69                    } else {
70                        result.push(format!("PUSH 0x{}", hex_data));
71                    }
72                }
73                Err(e) => {
74                    result.push(format!("ERROR: {:?}", e));
75                }
76            }
77        }
78
79        // Check for standard script patterns
80        if self.use_symbolic_names {
81            if script.is_p2pkh() {
82                return format!("P2PKH ({})", result.join(" "));
83            } else if script.is_p2sh() {
84                return format!("P2SH ({})", result.join(" "));
85            } else if script.is_p2wpkh() {
86                return format!("P2WPKH ({})", result.join(" "));
87            } else if script.is_p2wsh() {
88                return format!("P2WSH ({})", result.join(" "));
89            } else if script.is_p2tr() {
90                return format!("P2TR ({})", result.join(" "));
91            } else if script.is_op_return() {
92                return format!("OP_RETURN ({})", result.join(" "));
93            }
94        }
95
96        result.join(" ")
97    }
98
99    /// Get a compact representation of the script
100    pub fn disassemble_compact(&self, script: &Script) -> String {
101        if script.is_p2pkh() {
102            "P2PKH".to_string()
103        } else if script.is_p2sh() {
104            "P2SH".to_string()
105        } else if script.is_p2wpkh() {
106            "P2WPKH".to_string()
107        } else if script.is_p2wsh() {
108            "P2WSH".to_string()
109        } else if script.is_p2tr() {
110            "P2TR".to_string()
111        } else if script.is_op_return() {
112            "OP_RETURN".to_string()
113        } else {
114            format!("<{} bytes>", script.len())
115        }
116    }
117}
118
119impl Default for ScriptDisassembler {
120    fn default() -> Self {
121        Self::new()
122    }
123}
124
125/// Bitcoin script analyzer for comprehensive script analysis
126pub struct ScriptAnalyzer<'a> {
127    script: &'a Script,
128}
129
130impl<'a> ScriptAnalyzer<'a> {
131    /// Create a new script analyzer
132    pub fn new(script: &'a Script) -> Self {
133        Self { script }
134    }
135
136    /// Calculate script complexity score (higher = more complex)
137    ///
138    /// This score considers:
139    /// - Number of opcodes
140    /// - Presence of complex operations (crypto, control flow)
141    /// - Data push sizes
142    /// - Stack depth requirements
143    pub fn complexity_score(&self) -> u32 {
144        let mut score = 0u32;
145
146        for instruction in self.script.instructions().flatten() {
147            score += match instruction {
148                Instruction::Op(opcode) => self.opcode_complexity(opcode),
149                Instruction::PushBytes(bytes) => {
150                    // Larger pushes are slightly more complex
151                    1 + (bytes.len() / 32) as u32
152                }
153            };
154        }
155
156        score
157    }
158
159    /// Get the complexity score for a single opcode
160    fn opcode_complexity(&self, opcode: bitcoin::opcodes::Opcode) -> u32 {
161        use bitcoin::blockdata::opcodes::all::*;
162
163        match opcode.to_u8() {
164            // Crypto operations are most complex
165            x if x == OP_CHECKSIG.to_u8()
166                || x == OP_CHECKSIGVERIFY.to_u8()
167                || x == OP_CHECKMULTISIG.to_u8()
168                || x == OP_CHECKMULTISIGVERIFY.to_u8() =>
169            {
170                10
171            }
172            x if x == OP_HASH160.to_u8()
173                || x == OP_HASH256.to_u8()
174                || x == OP_SHA256.to_u8()
175                || x == OP_RIPEMD160.to_u8() =>
176            {
177                5
178            }
179
180            // Control flow is moderately complex
181            x if x == OP_IF.to_u8()
182                || x == OP_NOTIF.to_u8()
183                || x == OP_ELSE.to_u8()
184                || x == OP_ENDIF.to_u8() =>
185            {
186                3
187            }
188            x if x == OP_RETURN.to_u8() => 1,
189
190            // Stack operations
191            x if x == OP_DUP.to_u8()
192                || x == OP_DROP.to_u8()
193                || x == OP_SWAP.to_u8()
194                || x == OP_ROT.to_u8() =>
195            {
196                1
197            }
198
199            // Arithmetic
200            x if x == OP_ADD.to_u8() || x == OP_SUB.to_u8() => 2,
201
202            // Everything else
203            _ => 1,
204        }
205    }
206
207    /// Count the number of opcodes in the script
208    pub fn opcode_count(&self) -> usize {
209        self.script
210            .instructions()
211            .filter(|i| matches!(i, Ok(Instruction::Op(_))))
212            .count()
213    }
214
215    /// Count the number of push operations in the script
216    pub fn push_count(&self) -> usize {
217        self.script
218            .instructions()
219            .filter(|i| matches!(i, Ok(Instruction::PushBytes(_))))
220            .count()
221    }
222
223    /// Estimate the witness cost (weight units) for this script
224    ///
225    /// This is an approximation based on script size and complexity
226    pub fn estimate_witness_cost(&self) -> usize {
227        // Base cost is 4 * script size (non-witness data)
228        let base_cost = self.script.len() * 4;
229
230        // Add witness-specific costs for SegWit scripts
231        let witness_cost = if self.script.is_p2wpkh() {
232            // P2WPKH witness: signature (72) + pubkey (33)
233            72 + 33 // Witness data is counted as 1 WU per byte
234        } else if self.script.is_p2wsh() {
235            // P2WSH witness varies, estimate based on complexity
236            let complexity = self.complexity_score();
237            (complexity * 10) as usize
238        } else if self.script.is_p2tr() {
239            // Taproot key-path: signature (64)
240            64
241        } else {
242            0
243        };
244
245        base_cost + witness_cost
246    }
247
248    /// Check if the script contains any potentially problematic opcodes
249    ///
250    /// Currently checks for commonly disabled opcodes (those between OP_CAT and OP_CODESEPARATOR)
251    pub fn has_risky_opcodes(&self) -> bool {
252        for instruction in self.script.instructions() {
253            if let Ok(Instruction::Op(opcode)) = instruction {
254                let op_byte = opcode.to_u8();
255                // Check for disabled opcodes (126-129, 131-134, 141-142, 149-153)
256                // These are the historically disabled opcodes in Bitcoin
257                if (126..=129).contains(&op_byte)
258                    || (131..=134).contains(&op_byte)
259                    || (141..=142).contains(&op_byte)
260                    || (149..=153).contains(&op_byte)
261                {
262                    return true;
263                }
264            }
265        }
266        false
267    }
268
269    /// Identify the script type
270    pub fn script_type(&self) -> ScriptType {
271        if self.script.is_p2pkh() {
272            ScriptType::P2PKH
273        } else if self.script.is_p2sh() {
274            ScriptType::P2SH
275        } else if self.script.is_p2wpkh() {
276            ScriptType::P2WPKH
277        } else if self.script.is_p2wsh() {
278            ScriptType::P2WSH
279        } else if self.script.is_p2tr() {
280            ScriptType::P2TR
281        } else if self.script.is_op_return() {
282            ScriptType::OpReturn
283        } else {
284            ScriptType::NonStandard
285        }
286    }
287
288    /// Generate a full analysis report
289    pub fn analyze(&self) -> ScriptAnalysis {
290        ScriptAnalysis {
291            script_type: self.script_type(),
292            script_size: self.script.len(),
293            opcode_count: self.opcode_count(),
294            push_count: self.push_count(),
295            complexity_score: self.complexity_score(),
296            estimated_witness_cost: self.estimate_witness_cost(),
297            has_risky_opcodes: self.has_risky_opcodes(),
298            is_standard: self.is_standard_script(),
299        }
300    }
301
302    /// Check if this is a standard script type
303    fn is_standard_script(&self) -> bool {
304        self.script.is_p2pkh()
305            || self.script.is_p2sh()
306            || self.script.is_p2wpkh()
307            || self.script.is_p2wsh()
308            || self.script.is_p2tr()
309            || self.script.is_op_return()
310    }
311}
312
313/// Script type classification
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
315pub enum ScriptType {
316    /// Pay to Public Key Hash
317    P2PKH,
318    /// Pay to Script Hash
319    P2SH,
320    /// Pay to Witness Public Key Hash
321    P2WPKH,
322    /// Pay to Witness Script Hash
323    P2WSH,
324    /// Pay to Taproot
325    P2TR,
326    /// OP_RETURN data output
327    OpReturn,
328    /// Non-standard script
329    NonStandard,
330}
331
332impl fmt::Display for ScriptType {
333    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334        match self {
335            ScriptType::P2PKH => write!(f, "P2PKH"),
336            ScriptType::P2SH => write!(f, "P2SH"),
337            ScriptType::P2WPKH => write!(f, "P2WPKH"),
338            ScriptType::P2WSH => write!(f, "P2WSH"),
339            ScriptType::P2TR => write!(f, "P2TR"),
340            ScriptType::OpReturn => write!(f, "OP_RETURN"),
341            ScriptType::NonStandard => write!(f, "Non-Standard"),
342        }
343    }
344}
345
346/// Comprehensive script analysis result
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct ScriptAnalysis {
349    /// The type of script
350    pub script_type: ScriptType,
351    /// Size of the script in bytes
352    pub script_size: usize,
353    /// Number of opcodes
354    pub opcode_count: usize,
355    /// Number of push operations
356    pub push_count: usize,
357    /// Complexity score (higher = more complex)
358    pub complexity_score: u32,
359    /// Estimated witness cost in weight units
360    pub estimated_witness_cost: usize,
361    /// Whether the script contains risky/disabled opcodes
362    pub has_risky_opcodes: bool,
363    /// Whether this is a standard script type
364    pub is_standard: bool,
365}
366
367impl fmt::Display for ScriptAnalysis {
368    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
369        writeln!(f, "Script Analysis:")?;
370        writeln!(f, "  Type: {}", self.script_type)?;
371        writeln!(f, "  Size: {} bytes", self.script_size)?;
372        writeln!(f, "  Opcodes: {}", self.opcode_count)?;
373        writeln!(f, "  Push operations: {}", self.push_count)?;
374        writeln!(f, "  Complexity: {}", self.complexity_score)?;
375        writeln!(
376            f,
377            "  Estimated witness cost: {} WU",
378            self.estimated_witness_cost
379        )?;
380        writeln!(f, "  Standard script: {}", self.is_standard)?;
381        writeln!(f, "  Risky opcodes: {}", self.has_risky_opcodes)?;
382        Ok(())
383    }
384}
385
386/// Script template builder for common patterns
387pub struct ScriptTemplateBuilder;
388
389impl ScriptTemplateBuilder {
390    /// Create a P2PKH script template
391    pub fn p2pkh(pubkey_hash: &[u8; 20]) -> Result<ScriptBuf, BitcoinError> {
392        use bitcoin::hashes::Hash;
393        let hash = bitcoin::hashes::hash160::Hash::from_byte_array(*pubkey_hash);
394        Ok(ScriptBuf::new_p2pkh(&bitcoin::PubkeyHash::from_raw_hash(
395            hash,
396        )))
397    }
398
399    /// Create an OP_RETURN script with data
400    pub fn op_return(data: &[u8]) -> Result<ScriptBuf, BitcoinError> {
401        if data.len() > 80 {
402            return Err(BitcoinError::InvalidInput(
403                "OP_RETURN data too large (max 80 bytes)".to_string(),
404            ));
405        }
406
407        // Construct OP_RETURN script from raw bytes
408        let mut script_bytes = vec![OP_RETURN.to_u8()];
409
410        // Add push opcode for data length
411        if data.is_empty() {
412            // Empty OP_RETURN
413        } else if data.len() <= 75 {
414            script_bytes.push(data.len() as u8);
415            script_bytes.extend_from_slice(data);
416        } else {
417            // OP_PUSHDATA1 for 76-80 bytes
418            script_bytes.push(0x4c); // OP_PUSHDATA1
419            script_bytes.push(data.len() as u8);
420            script_bytes.extend_from_slice(data);
421        }
422
423        Ok(ScriptBuf::from_bytes(script_bytes))
424    }
425
426    /// Create a simple timelock script (CLTV)
427    pub fn timelock_cltv(locktime: u32, pubkey_hash: &[u8; 20]) -> Result<ScriptBuf, BitcoinError> {
428        // Use the array directly since [u8; 20] implements AsRef<PushBytes>
429        let script = ScriptBuf::builder()
430            .push_int(locktime as i64)
431            .push_opcode(OP_CLTV)
432            .push_opcode(OP_DROP)
433            .push_opcode(OP_DUP)
434            .push_opcode(OP_HASH160)
435            .push_slice(pubkey_hash)
436            .push_opcode(OP_EQUALVERIFY)
437            .push_opcode(OP_CHECKSIG)
438            .into_script();
439
440        Ok(script)
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use bitcoin::hashes::Hash;
448
449    #[test]
450    fn test_disassemble_empty_script() {
451        let script = ScriptBuf::new();
452        let disassembler = ScriptDisassembler::new();
453        let asm = disassembler.disassemble(&script);
454        assert_eq!(asm, "");
455    }
456
457    #[test]
458    fn test_disassemble_compact() {
459        let pubkey_hash = [0u8; 20];
460        let hash = bitcoin::hashes::hash160::Hash::from_byte_array(pubkey_hash);
461        let script = ScriptBuf::new_p2pkh(&bitcoin::PubkeyHash::from_raw_hash(hash));
462        let disassembler = ScriptDisassembler::new();
463        let compact = disassembler.disassemble_compact(&script);
464        assert_eq!(compact, "P2PKH");
465    }
466
467    #[test]
468    fn test_script_analyzer_p2pkh() {
469        let pubkey_hash = [0u8; 20];
470        let hash = bitcoin::hashes::hash160::Hash::from_byte_array(pubkey_hash);
471        let script = ScriptBuf::new_p2pkh(&bitcoin::PubkeyHash::from_raw_hash(hash));
472        let analyzer = ScriptAnalyzer::new(&script);
473        assert_eq!(analyzer.script_type(), ScriptType::P2PKH);
474        assert!(analyzer.complexity_score() > 0);
475    }
476
477    #[test]
478    fn test_script_analyzer_p2wpkh() {
479        let pubkey_hash = [0u8; 20];
480        let hash = bitcoin::hashes::hash160::Hash::from_byte_array(pubkey_hash);
481        let script = ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_raw_hash(hash));
482        let analyzer = ScriptAnalyzer::new(&script);
483        assert_eq!(analyzer.script_type(), ScriptType::P2WPKH);
484    }
485
486    #[test]
487    fn test_complexity_score() {
488        let pubkey_hash = [0u8; 20];
489        let hash = bitcoin::hashes::hash160::Hash::from_byte_array(pubkey_hash);
490        let script = ScriptBuf::new_p2pkh(&bitcoin::PubkeyHash::from_raw_hash(hash));
491        let analyzer = ScriptAnalyzer::new(&script);
492        let score = analyzer.complexity_score();
493        assert!(score > 0);
494    }
495
496    #[test]
497    fn test_opcode_count() {
498        let pubkey_hash = [0u8; 20];
499        let hash = bitcoin::hashes::hash160::Hash::from_byte_array(pubkey_hash);
500        let script = ScriptBuf::new_p2pkh(&bitcoin::PubkeyHash::from_raw_hash(hash));
501        let analyzer = ScriptAnalyzer::new(&script);
502        assert!(analyzer.opcode_count() > 0);
503    }
504
505    #[test]
506    fn test_push_count() {
507        let pubkey_hash = [0u8; 20];
508        let hash = bitcoin::hashes::hash160::Hash::from_byte_array(pubkey_hash);
509        let script = ScriptBuf::new_p2pkh(&bitcoin::PubkeyHash::from_raw_hash(hash));
510        let analyzer = ScriptAnalyzer::new(&script);
511        assert!(analyzer.push_count() > 0);
512    }
513
514    #[test]
515    fn test_witness_cost_estimation() {
516        let pubkey_hash = [0u8; 20];
517        let hash = bitcoin::hashes::hash160::Hash::from_byte_array(pubkey_hash);
518        let script = ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_raw_hash(hash));
519        let analyzer = ScriptAnalyzer::new(&script);
520        let cost = analyzer.estimate_witness_cost();
521        assert!(cost > 0);
522    }
523
524    #[test]
525    fn test_no_risky_opcodes() {
526        let pubkey_hash = [0u8; 20];
527        let hash = bitcoin::hashes::hash160::Hash::from_byte_array(pubkey_hash);
528        let script = ScriptBuf::new_p2pkh(&bitcoin::PubkeyHash::from_raw_hash(hash));
529        let analyzer = ScriptAnalyzer::new(&script);
530        assert!(!analyzer.has_risky_opcodes());
531    }
532
533    #[test]
534    fn test_script_analysis() {
535        let pubkey_hash = [0u8; 20];
536        let hash = bitcoin::hashes::hash160::Hash::from_byte_array(pubkey_hash);
537        let script = ScriptBuf::new_p2pkh(&bitcoin::PubkeyHash::from_raw_hash(hash));
538        let analyzer = ScriptAnalyzer::new(&script);
539        let analysis = analyzer.analyze();
540        assert_eq!(analysis.script_type, ScriptType::P2PKH);
541        assert!(analysis.is_standard);
542        assert!(!analysis.has_risky_opcodes);
543    }
544
545    #[test]
546    fn test_op_return_template() {
547        let data = b"Hello, Bitcoin!";
548        let script = ScriptTemplateBuilder::op_return(data).unwrap();
549        let analyzer = ScriptAnalyzer::new(&script);
550        assert_eq!(analyzer.script_type(), ScriptType::OpReturn);
551    }
552
553    #[test]
554    fn test_op_return_too_large() {
555        let data = vec![0u8; 81]; // Too large
556        let result = ScriptTemplateBuilder::op_return(&data);
557        assert!(result.is_err());
558    }
559
560    #[test]
561    fn test_timelock_cltv_template() {
562        let pubkey_hash = [0u8; 20];
563        let locktime = 600000;
564        let script = ScriptTemplateBuilder::timelock_cltv(locktime, &pubkey_hash).unwrap();
565        let analyzer = ScriptAnalyzer::new(&script);
566        assert!(analyzer.complexity_score() > 10); // Should be reasonably complex
567    }
568}