Skip to main content

bsv/script/
inscriptions.rs

1//! Inscription and OP_RETURN data embedding helpers.
2//!
3//! Provides structured inscription creation (content type + data)
4//! and simple OP_RETURN data scripts.
5//! Translates the Go SDK inscriptions.go.
6
7use crate::script::error::ScriptError;
8use crate::script::locking_script::LockingScript;
9use crate::script::op::Op;
10use crate::script::script::Script;
11use crate::script::script_chunk::ScriptChunk;
12
13/// An inscription with a content type and data payload.
14///
15/// Encoded as an OP_FALSE OP_RETURN script with two data pushes:
16/// the content type string and the raw data.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Inscription {
19    pub content_type: String,
20    pub data: Vec<u8>,
21}
22
23impl Inscription {
24    /// Create a new inscription with the given content type and data.
25    pub fn new(content_type: &str, data: Vec<u8>) -> Self {
26        Inscription {
27            content_type: content_type.to_string(),
28            data,
29        }
30    }
31
32    /// Convert this inscription to a locking script.
33    ///
34    /// Format: OP_FALSE OP_RETURN <content_type_bytes> <data_bytes>
35    pub fn to_script(&self) -> LockingScript {
36        let ct_bytes = self.content_type.as_bytes().to_vec();
37        let chunks = vec![
38            ScriptChunk::new_opcode(Op::Op0), // OP_FALSE = OP_0
39            ScriptChunk::new_opcode(Op::OpReturn),
40            Self::make_data_chunk(&ct_bytes),
41            Self::make_data_chunk(&self.data),
42        ];
43        LockingScript::from_script(Script::from_chunks(chunks))
44    }
45
46    /// Parse an inscription from a script.
47    ///
48    /// Expected format: OP_FALSE/OP_0 OP_RETURN <content_type_data> <payload_data>
49    pub fn from_script(script: &Script) -> Result<Self, ScriptError> {
50        let chunks = script.chunks();
51
52        if chunks.len() < 4 {
53            return Err(ScriptError::InvalidScript(
54                "inscription script must have at least 4 chunks".to_string(),
55            ));
56        }
57
58        // First chunk: OP_FALSE (OP_0)
59        if chunks[0].op != Op::Op0 {
60            return Err(ScriptError::InvalidScript(
61                "inscription must start with OP_FALSE/OP_0".to_string(),
62            ));
63        }
64
65        // Second chunk: OP_RETURN
66        if chunks[1].op != Op::OpReturn {
67            return Err(ScriptError::InvalidScript(
68                "inscription second opcode must be OP_RETURN".to_string(),
69            ));
70        }
71
72        // Third chunk: content type data
73        let ct_data = chunks[2].data.as_ref().ok_or_else(|| {
74            ScriptError::InvalidScript("inscription content type chunk has no data".to_string())
75        })?;
76        let content_type = String::from_utf8(ct_data.clone()).map_err(|e| {
77            ScriptError::InvalidScript(format!("inscription content type is not valid UTF-8: {e}"))
78        })?;
79
80        // Fourth chunk: payload data
81        let data = chunks[3].data.as_ref().ok_or_else(|| {
82            ScriptError::InvalidScript("inscription data chunk has no data".to_string())
83        })?;
84
85        Ok(Inscription {
86            content_type,
87            data: data.clone(),
88        })
89    }
90
91    /// Create an appropriate data push chunk for the given bytes.
92    fn make_data_chunk(data: &[u8]) -> ScriptChunk {
93        let len = data.len();
94        let op_byte = if len < 0x4c {
95            len as u8
96        } else if len < 256 {
97            Op::OpPushData1.to_byte()
98        } else if len < 65536 {
99            Op::OpPushData2.to_byte()
100        } else {
101            Op::OpPushData4.to_byte()
102        };
103        ScriptChunk::new_raw(op_byte, Some(data.to_vec()))
104    }
105}
106
107/// Create a simple OP_FALSE OP_RETURN data script (no content type).
108///
109/// Format: `OP_FALSE OP_RETURN <data>`
110pub fn op_return_data(data: &[u8]) -> LockingScript {
111    let chunks = vec![
112        ScriptChunk::new_opcode(Op::Op0), // OP_FALSE
113        ScriptChunk::new_opcode(Op::OpReturn),
114        Inscription::make_data_chunk(data),
115    ];
116    LockingScript::from_script(Script::from_chunks(chunks))
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn test_inscription_to_script_format() {
125        let insc = Inscription::new("text/plain", b"hello world".to_vec());
126        let script = insc.to_script();
127        let binary = script.to_binary();
128
129        // First byte: OP_FALSE (0x00)
130        assert_eq!(binary[0], 0x00, "should start with OP_FALSE");
131        // Second byte: OP_RETURN (0x6a)
132        assert_eq!(binary[1], 0x6a, "second byte should be OP_RETURN");
133    }
134
135    #[test]
136    fn test_inscription_roundtrip() {
137        let insc = Inscription::new("text/plain", b"hello world".to_vec());
138        let script = insc.to_script();
139
140        // The script uses from_chunks, which does NOT trigger OP_RETURN
141        // conditional-block semantics from parse_chunks. We need to parse
142        // the chunks directly.
143        let decoded = Inscription::from_script(&script).unwrap();
144        assert_eq!(decoded.content_type, "text/plain");
145        assert_eq!(decoded.data, b"hello world");
146    }
147
148    #[test]
149    fn test_inscription_from_script_invalid() {
150        // Too few chunks
151        let script = Script::from_chunks(vec![ScriptChunk::new_opcode(Op::Op0)]);
152        assert!(Inscription::from_script(&script).is_err());
153
154        // Wrong first opcode
155        let script = Script::from_chunks(vec![
156            ScriptChunk::new_opcode(Op::Op1),
157            ScriptChunk::new_opcode(Op::OpReturn),
158            ScriptChunk::new_raw(4, Some(b"test".to_vec())),
159            ScriptChunk::new_raw(4, Some(b"data".to_vec())),
160        ]);
161        assert!(Inscription::from_script(&script).is_err());
162    }
163
164    #[test]
165    fn test_op_return_data_format() {
166        let script = op_return_data(b"test data");
167        let binary = script.to_binary();
168
169        assert_eq!(binary[0], 0x00, "OP_FALSE");
170        assert_eq!(binary[1], 0x6a, "OP_RETURN");
171        // Third byte is push length (9 bytes of "test data")
172        assert_eq!(binary[2], 9);
173        assert_eq!(&binary[3..12], b"test data");
174    }
175
176    #[test]
177    fn test_op_return_data_empty() {
178        let script = op_return_data(&[]);
179        let binary = script.to_binary();
180
181        assert_eq!(binary[0], 0x00, "OP_FALSE");
182        assert_eq!(binary[1], 0x6a, "OP_RETURN");
183        assert_eq!(binary[2], 0x00, "push 0 bytes");
184    }
185
186    #[test]
187    fn test_inscription_various_content_types() {
188        let test_cases = vec![
189            ("application/json", b"{\"key\":\"value\"}".to_vec()),
190            ("image/png", vec![0x89, 0x50, 0x4e, 0x47]),
191            ("text/html", b"<html></html>".to_vec()),
192        ];
193
194        for (ct, data) in test_cases {
195            let insc = Inscription::new(ct, data.clone());
196            let script = insc.to_script();
197            let decoded = Inscription::from_script(&script).unwrap();
198            assert_eq!(decoded.content_type, ct);
199            assert_eq!(decoded.data, data);
200        }
201    }
202}