Skip to main content

bsv/script/
script.rs

1//! Script type: chunk-based Bitcoin script with binary serialization.
2//!
3//! Translates the TS SDK Script.ts. Supports parsing from binary, hex,
4//! and ASM formats with identical OP_RETURN conditional-block semantics.
5
6use crate::script::error::ScriptError;
7use crate::script::op::Op;
8use crate::script::script_chunk::ScriptChunk;
9
10/// A Bitcoin script represented as a sequence of parsed chunks.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Script {
13    chunks: Vec<ScriptChunk>,
14}
15
16impl Script {
17    /// Create an empty script.
18    pub fn new() -> Self {
19        Script { chunks: Vec::new() }
20    }
21
22    /// Parse a script from raw binary bytes.
23    pub fn from_binary(bytes: &[u8]) -> Self {
24        Script {
25            chunks: Self::parse_chunks(bytes),
26        }
27    }
28
29    /// Parse a script from a hex string.
30    pub fn from_hex(hex: &str) -> Result<Self, ScriptError> {
31        if hex.is_empty() {
32            return Ok(Script::new());
33        }
34        if !hex.len().is_multiple_of(2) {
35            return Err(ScriptError::InvalidFormat(
36                "hex string has odd length".to_string(),
37            ));
38        }
39        let bytes = hex_to_bytes(hex).map_err(ScriptError::InvalidFormat)?;
40        Ok(Script::from_binary(&bytes))
41    }
42
43    /// Parse a script from a space-separated ASM string.
44    ///
45    /// Handles opcodes like "OP_DUP", data pushes as hex strings,
46    /// "0" as OP_0, and "-1" as OP_1NEGATE.
47    /// `OP_PUSHDATA1/2/4` in ASM format: `"OP_PUSHDATA1 <len> <hex>"`
48    pub fn from_asm(asm: &str) -> Self {
49        if asm.is_empty() {
50            return Script::new();
51        }
52
53        let mut chunks = Vec::new();
54        let tokens: Vec<&str> = asm.split(' ').collect();
55        let mut i = 0;
56
57        while i < tokens.len() {
58            let token = tokens[i];
59
60            // Special cases for "0" and "-1"
61            if token == "0" {
62                chunks.push(ScriptChunk::new_opcode(Op::Op0));
63                i += 1;
64                continue;
65            }
66            if token == "-1" {
67                chunks.push(ScriptChunk::new_opcode(Op::Op1Negate));
68                i += 1;
69                continue;
70            }
71
72            // Try to parse as opcode name
73            if let Some(op) = Op::from_name(token) {
74                // Check for PUSHDATA ops that have data following
75                if op == Op::OpPushData1 || op == Op::OpPushData2 || op == Op::OpPushData4 {
76                    // Format: OP_PUSHDATA1 <len> <hex_data>
77                    if i + 2 < tokens.len() {
78                        let hex_data = tokens[i + 2];
79                        let data = hex_to_bytes(hex_data).unwrap_or_default();
80                        chunks.push(ScriptChunk::new_raw(op.to_byte(), Some(data)));
81                        i += 3;
82                    } else {
83                        chunks.push(ScriptChunk::new_opcode(op));
84                        i += 1;
85                    }
86                } else {
87                    chunks.push(ScriptChunk::new_opcode(op));
88                    i += 1;
89                }
90                continue;
91            }
92
93            // Not an opcode -- treat as hex data
94            let mut hex = token.to_string();
95            if !hex.len().is_multiple_of(2) {
96                hex = format!("0{}", hex);
97            }
98            if let Ok(data) = hex_to_bytes(&hex) {
99                let len = data.len();
100                let op_byte = if len < 0x4c {
101                    len as u8
102                } else if len < 256 {
103                    Op::OpPushData1.to_byte()
104                } else if len < 65536 {
105                    Op::OpPushData2.to_byte()
106                } else {
107                    Op::OpPushData4.to_byte()
108                };
109                chunks.push(ScriptChunk::new_raw(op_byte, Some(data)));
110            }
111            i += 1;
112        }
113
114        Script { chunks }
115    }
116
117    /// Create a script from pre-built chunks.
118    pub fn from_chunks(chunks: Vec<ScriptChunk>) -> Self {
119        Script { chunks }
120    }
121
122    /// Serialize the script to binary bytes.
123    pub fn to_binary(&self) -> Vec<u8> {
124        let mut out = Vec::new();
125        for (i, chunk) in self.chunks.iter().enumerate() {
126            let serialized = chunk.serialize();
127            out.extend_from_slice(&serialized);
128            // If this is an OP_RETURN data chunk (with data, not inside conditional),
129            // it's the last chunk the serializer should emit, since parse_chunks
130            // would have consumed the rest as data.
131            if chunk.op == Op::OpReturn && chunk.data.is_some() {
132                // Check if this is the last chunk or there are trailing chunks
133                // (There shouldn't be, but be defensive)
134                let _ = i; // just consume, all remaining chunks are already in self.chunks
135            }
136        }
137        out
138    }
139
140    /// Serialize then hex-encode.
141    pub fn to_hex(&self) -> String {
142        let bytes = self.to_binary();
143        bytes.iter().map(|b| format!("{:02x}", b)).collect()
144    }
145
146    /// ASM string representation.
147    pub fn to_asm(&self) -> String {
148        self.chunks
149            .iter()
150            .map(|c| c.to_asm())
151            .collect::<Vec<_>>()
152            .join(" ")
153    }
154
155    /// Access the parsed chunks.
156    pub fn chunks(&self) -> &[ScriptChunk] {
157        &self.chunks
158    }
159
160    /// Number of chunks.
161    pub fn len(&self) -> usize {
162        self.chunks.len()
163    }
164
165    /// Whether the script has no chunks.
166    pub fn is_empty(&self) -> bool {
167        self.chunks.is_empty()
168    }
169
170    /// Remove all occurrences of `target` from this script (borrowing).
171    ///
172    /// Matching is done by comparing the serialized bytes of each chunk
173    /// against the full serialized target, following the TS SDK's
174    /// `findAndDelete` algorithm.
175    pub fn find_and_delete(&self, target: &Script) -> Script {
176        let target_bytes = target.to_binary();
177        let target_len = target_bytes.len();
178        if target_len == 0 {
179            return self.clone();
180        }
181
182        let target_op = target_bytes[0];
183
184        // Fast early-exit: if no chunk has a matching op_byte, skip allocation
185        if !self.chunks.iter().any(|c| c.op_byte == target_op) {
186            return self.clone();
187        }
188
189        // Clone chunks once and use retain for in-place removal (avoids per-chunk clone)
190        let mut result_chunks = self.chunks.clone();
191        Self::retain_non_matching(&mut result_chunks, target_op, target_len, &target_bytes);
192        Script {
193            chunks: result_chunks,
194        }
195    }
196
197    /// Remove all occurrences of `target` from this script (consuming self to avoid clone).
198    ///
199    /// Same semantics as `find_and_delete` but takes ownership, avoiding the
200    /// internal `Vec<ScriptChunk>` clone when the caller no longer needs the original.
201    pub fn find_and_delete_owned(mut self, target: &Script) -> Script {
202        let target_bytes = target.to_binary();
203        let target_len = target_bytes.len();
204        if target_len == 0 {
205            return self;
206        }
207
208        let target_op = target_bytes[0];
209
210        // Fast early-exit: if no chunk has a matching op_byte, return self unchanged
211        if !self.chunks.iter().any(|c| c.op_byte == target_op) {
212            return self;
213        }
214
215        Self::retain_non_matching(&mut self.chunks, target_op, target_len, &target_bytes);
216        self
217    }
218
219    /// Core retain logic shared by find_and_delete and find_and_delete_owned.
220    fn retain_non_matching(
221        chunks: &mut Vec<ScriptChunk>,
222        target_op: u8,
223        target_len: usize,
224        target_bytes: &[u8],
225    ) {
226        chunks.retain(|chunk| {
227            // Cheap u8 comparison first
228            if chunk.op_byte != target_op {
229                return true;
230            }
231            let data = chunk.data.as_deref().unwrap_or(&[]);
232            let data_len = data.len();
233
234            if data_len == 0 && chunk.data.is_none() {
235                return target_len != 1;
236            }
237
238            // OP_RETURN data chunk or direct push (0x01..=0x4b)
239            if chunk.op == Op::OpReturn || chunk.op_byte < Op::OpPushData1.to_byte() {
240                if target_len != 1 + data_len {
241                    return true;
242                }
243                return target_bytes[1..] != *data;
244            }
245
246            if chunk.op == Op::OpPushData1 {
247                if target_len != 2 + data_len {
248                    return true;
249                }
250                if target_bytes[1] != (data_len & 0xff) as u8 {
251                    return true;
252                }
253                return target_bytes[2..] != *data;
254            }
255
256            if chunk.op == Op::OpPushData2 {
257                if target_len != 3 + data_len {
258                    return true;
259                }
260                if target_bytes[1] != (data_len & 0xff) as u8 {
261                    return true;
262                }
263                if target_bytes[2] != ((data_len >> 8) & 0xff) as u8 {
264                    return true;
265                }
266                return target_bytes[3..] != *data;
267            }
268
269            if chunk.op == Op::OpPushData4 {
270                if target_len != 5 + data_len {
271                    return true;
272                }
273                let size = data_len as u32;
274                if target_bytes[1] != (size & 0xff) as u8 {
275                    return true;
276                }
277                if target_bytes[2] != ((size >> 8) & 0xff) as u8 {
278                    return true;
279                }
280                if target_bytes[3] != ((size >> 16) & 0xff) as u8 {
281                    return true;
282                }
283                if target_bytes[4] != ((size >> 24) & 0xff) as u8 {
284                    return true;
285                }
286                return target_bytes[5..] != *data;
287            }
288
289            true
290        });
291    }
292
293    /// Check if the script contains only push-data operations.
294    ///
295    /// Push-only means all opcodes are <= OP_16 (0x60).
296    pub fn is_push_only(&self) -> bool {
297        for chunk in &self.chunks {
298            if chunk.op_byte > Op::Op16.to_byte() {
299                return false;
300            }
301        }
302        true
303    }
304
305    // -- Internal parsing -----------------------------------------------------
306
307    /// Parse raw bytes into script chunks, handling OP_RETURN conditional
308    /// block semantics and push-data opcodes.
309    fn parse_chunks(bytes: &[u8]) -> Vec<ScriptChunk> {
310        let mut chunks = Vec::new();
311        let length = bytes.len();
312        let mut pos = 0;
313        let mut in_conditional_block: i32 = 0;
314
315        while pos < length {
316            let op_byte = bytes[pos];
317            pos += 1;
318
319            // OP_RETURN outside conditionals: remaining bytes become single data chunk
320            if op_byte == Op::OpReturn.to_byte() && in_conditional_block == 0 {
321                let remaining = bytes[pos..].to_vec();
322                chunks.push(ScriptChunk::new_raw(
323                    op_byte,
324                    if remaining.is_empty() {
325                        None
326                    } else {
327                        Some(remaining)
328                    },
329                ));
330                break;
331            }
332
333            // Track conditional depth
334            if op_byte == Op::OpIf.to_byte()
335                || op_byte == Op::OpNotIf.to_byte()
336                || op_byte == Op::OpVerIf.to_byte()
337                || op_byte == Op::OpVerNotIf.to_byte()
338            {
339                in_conditional_block += 1;
340            } else if op_byte == Op::OpEndIf.to_byte() {
341                in_conditional_block -= 1;
342            }
343
344            // Direct push: opcode 0x01..=0x4b means push that many bytes
345            if op_byte > 0 && op_byte < Op::OpPushData1.to_byte() {
346                let push_len = op_byte as usize;
347                let end = (pos + push_len).min(length);
348                let data = bytes[pos..end].to_vec();
349                chunks.push(ScriptChunk::new_raw(op_byte, Some(data)));
350                pos = end;
351            } else if op_byte == Op::OpPushData1.to_byte() {
352                let push_len = if pos < length { bytes[pos] as usize } else { 0 };
353                pos += 1;
354                let end = (pos + push_len).min(length);
355                let data = bytes[pos..end].to_vec();
356                chunks.push(ScriptChunk::new_raw(op_byte, Some(data)));
357                pos = end;
358            } else if op_byte == Op::OpPushData2.to_byte() {
359                let b0 = if pos < length { bytes[pos] as usize } else { 0 };
360                let b1 = if pos + 1 < length {
361                    bytes[pos + 1] as usize
362                } else {
363                    0
364                };
365                let push_len = b0 | (b1 << 8);
366                pos = (pos + 2).min(length);
367                let end = (pos + push_len).min(length);
368                let data = bytes[pos..end].to_vec();
369                chunks.push(ScriptChunk::new_raw(op_byte, Some(data)));
370                pos = end;
371            } else if op_byte == Op::OpPushData4.to_byte() {
372                let b0 = if pos < length { bytes[pos] as usize } else { 0 };
373                let b1 = if pos + 1 < length {
374                    bytes[pos + 1] as usize
375                } else {
376                    0
377                };
378                let b2 = if pos + 2 < length {
379                    bytes[pos + 2] as usize
380                } else {
381                    0
382                };
383                let b3 = if pos + 3 < length {
384                    bytes[pos + 3] as usize
385                } else {
386                    0
387                };
388                let push_len = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
389                pos = (pos + 4).min(length);
390                let end = (pos + push_len).min(length);
391                let data = bytes[pos..end].to_vec();
392                chunks.push(ScriptChunk::new_raw(op_byte, Some(data)));
393                pos = end;
394            } else {
395                // Regular opcode with no data
396                chunks.push(ScriptChunk::new_raw(op_byte, None));
397            }
398        }
399
400        chunks
401    }
402}
403
404impl Default for Script {
405    fn default() -> Self {
406        Self::new()
407    }
408}
409
410// -- Hex utility --------------------------------------------------------------
411
412fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
413    if !hex.len().is_multiple_of(2) {
414        return Err("odd hex length".to_string());
415    }
416    let mut bytes = Vec::with_capacity(hex.len() / 2);
417    for i in (0..hex.len()).step_by(2) {
418        let byte = u8::from_str_radix(&hex[i..i + 2], 16)
419            .map_err(|_| format!("invalid hex at position {}", i))?;
420        bytes.push(byte);
421    }
422    Ok(bytes)
423}
424
425// =============================================================================
426// Tests
427// =============================================================================
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    fn bytes_to_hex(bytes: &[u8]) -> String {
434        bytes.iter().map(|b| format!("{:02x}", b)).collect()
435    }
436
437    #[test]
438    fn test_binary_roundtrip_empty() {
439        let script = Script::from_binary(&[]);
440        assert!(script.is_empty());
441        assert_eq!(script.to_binary(), Vec::<u8>::new());
442    }
443
444    #[test]
445    fn test_binary_roundtrip_p2pkh() {
446        // OP_DUP OP_HASH160 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG
447        let pubkey_hash = [0xab; 20];
448        let mut script_bytes = vec![0x76, 0xa9, 0x14]; // OP_DUP, OP_HASH160, push 20
449        script_bytes.extend_from_slice(&pubkey_hash);
450        script_bytes.push(0x88); // OP_EQUALVERIFY
451        script_bytes.push(0xac); // OP_CHECKSIG
452
453        let script = Script::from_binary(&script_bytes);
454        let rt = script.to_binary();
455        assert_eq!(rt, script_bytes, "binary round-trip failed for P2PKH");
456
457        // Verify chunk count: DUP, HASH160, <data>, EQUALVERIFY, CHECKSIG = 5
458        assert_eq!(script.len(), 5);
459    }
460
461    #[test]
462    fn test_binary_roundtrip_pushdata1() {
463        // OP_PUSHDATA1 with 100 bytes of data
464        let mut script_bytes = vec![0x4c, 100]; // OP_PUSHDATA1, length=100
465        script_bytes.extend_from_slice(&[0xcc; 100]);
466        let script = Script::from_binary(&script_bytes);
467        assert_eq!(script.to_binary(), script_bytes);
468    }
469
470    #[test]
471    fn test_binary_roundtrip_pushdata2() {
472        // OP_PUSHDATA2 with 300 bytes of data
473        let mut script_bytes = vec![0x4d, 0x2c, 0x01]; // OP_PUSHDATA2, length=300 LE
474        script_bytes.extend_from_slice(&[0xdd; 300]);
475        let script = Script::from_binary(&script_bytes);
476        assert_eq!(script.to_binary(), script_bytes);
477    }
478
479    #[test]
480    fn test_hex_roundtrip() {
481        let hex = "76a914abababababababababababababababababababab88ac";
482        let script = Script::from_hex(hex).unwrap();
483        assert_eq!(script.to_hex(), hex);
484    }
485
486    #[test]
487    fn test_from_hex_empty() {
488        let script = Script::from_hex("").unwrap();
489        assert!(script.is_empty());
490    }
491
492    #[test]
493    fn test_from_hex_odd_length() {
494        let result = Script::from_hex("abc");
495        assert!(result.is_err());
496    }
497
498    #[test]
499    fn test_asm_roundtrip_p2pkh() {
500        let asm =
501            "OP_DUP OP_HASH160 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa OP_EQUALVERIFY OP_CHECKSIG";
502        let script = Script::from_asm(asm);
503        let result_asm = script.to_asm();
504        assert_eq!(result_asm, asm);
505    }
506
507    #[test]
508    fn test_asm_zero_and_negative_one() {
509        let asm = "0 -1 OP_ADD";
510        let script = Script::from_asm(asm);
511        // OP_0 renders as "0", OP_1NEGATE as "-1"
512        assert_eq!(script.to_asm(), "0 -1 OP_ADD");
513    }
514
515    #[test]
516    fn test_op_return_outside_conditional() {
517        // OP_RETURN followed by arbitrary data outside a conditional block
518        // The parser should treat everything after OP_RETURN as a single data chunk
519        let mut script_bytes = vec![0x6a]; // OP_RETURN
520        script_bytes.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]);
521
522        let script = Script::from_binary(&script_bytes);
523        assert_eq!(script.len(), 1, "OP_RETURN + data should be one chunk");
524        assert_eq!(
525            script.chunks()[0].data.as_ref().unwrap(),
526            &[0x01, 0x02, 0x03, 0x04]
527        );
528
529        // Round-trip
530        assert_eq!(script.to_binary(), script_bytes);
531    }
532
533    #[test]
534    fn test_op_return_inside_conditional() {
535        // OP_IF OP_RETURN OP_ENDIF -- OP_RETURN inside conditional should NOT
536        // consume remaining bytes
537        let script_bytes = vec![
538            0x63, // OP_IF
539            0x6a, // OP_RETURN
540            0x68, // OP_ENDIF
541        ];
542
543        let script = Script::from_binary(&script_bytes);
544        assert_eq!(
545            script.len(),
546            3,
547            "OP_RETURN inside conditional should be a standalone opcode"
548        );
549        assert!(
550            script.chunks()[1].data.is_none(),
551            "OP_RETURN inside conditional should have no data"
552        );
553
554        assert_eq!(script.to_binary(), script_bytes);
555    }
556
557    #[test]
558    fn test_op_return_no_data() {
559        // Just OP_RETURN with nothing after it
560        let script_bytes = vec![0x6a];
561        let script = Script::from_binary(&script_bytes);
562        assert_eq!(script.len(), 1);
563        assert!(script.chunks()[0].data.is_none());
564        assert_eq!(script.to_binary(), script_bytes);
565    }
566
567    #[test]
568    fn test_find_and_delete_simple() {
569        // Create a script: OP_1 OP_2 OP_3 OP_2
570        let script = Script::from_binary(&[0x51, 0x52, 0x53, 0x52]);
571        // Delete OP_2
572        let target = Script::from_binary(&[0x52]);
573        let result = script.find_and_delete(&target);
574        assert_eq!(result.to_binary(), vec![0x51, 0x53]);
575    }
576
577    #[test]
578    fn test_find_and_delete_data_push() {
579        // Create a script with a data push: <03 aabbcc> OP_DUP
580        let script_bytes = vec![0x03, 0xaa, 0xbb, 0xcc, 0x76];
581        let script = Script::from_binary(&script_bytes);
582
583        // Target: the data push <03 aabbcc>
584        let target = Script::from_binary(&[0x03, 0xaa, 0xbb, 0xcc]);
585        let result = script.find_and_delete(&target);
586        assert_eq!(result.to_binary(), vec![0x76]); // only OP_DUP remains
587    }
588
589    #[test]
590    fn test_find_and_delete_empty_target() {
591        let script = Script::from_binary(&[0x76, 0x76]);
592        let target = Script::new();
593        let result = script.find_and_delete(&target);
594        assert_eq!(result.to_binary(), vec![0x76, 0x76]);
595    }
596
597    #[test]
598    fn test_is_push_only() {
599        // OP_1 (0x51), push data, OP_16 (0x60) -- all push-only
600        let push_script = Script::from_binary(&[0x51, 0x03, 0xaa, 0xbb, 0xcc, 0x60]);
601        assert!(push_script.is_push_only());
602
603        // OP_DUP (0x76) -- not push-only
604        let non_push = Script::from_binary(&[0x76]);
605        assert!(!non_push.is_push_only());
606    }
607
608    #[test]
609    fn test_from_chunks() {
610        let chunks = vec![
611            ScriptChunk::new_opcode(Op::OpDup),
612            ScriptChunk::new_opcode(Op::OpCheckSig),
613        ];
614        let script = Script::from_chunks(chunks);
615        assert_eq!(script.len(), 2);
616        assert_eq!(script.to_binary(), vec![0x76, 0xac]);
617    }
618
619    #[test]
620    fn test_complex_script_roundtrip() {
621        // A more complex script with mixed opcodes and pushes
622        let hex = "5121031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f2103acd484e2f0c7f65309ad178a9f559abde09796974c57e714c35f110dfc27ccbe52ae";
623        let script = Script::from_hex(hex).unwrap();
624        assert_eq!(script.to_hex(), hex);
625    }
626
627    #[test]
628    fn test_nested_conditional_op_return() {
629        // OP_IF OP_IF OP_RETURN OP_ENDIF OP_ENDIF -- double nested
630        // OP_RETURN at depth 2 should NOT consume remaining bytes
631        let script_bytes = vec![
632            0x63, // OP_IF
633            0x63, // OP_IF
634            0x6a, // OP_RETURN
635            0x68, // OP_ENDIF
636            0x68, // OP_ENDIF
637        ];
638        let script = Script::from_binary(&script_bytes);
639        assert_eq!(script.len(), 5);
640        assert!(script.chunks()[2].data.is_none());
641        assert_eq!(script.to_binary(), script_bytes);
642    }
643}