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!(
78                "inscription content type is not valid UTF-8: {}",
79                e
80            ))
81        })?;
82
83        // Fourth chunk: payload data
84        let data = chunks[3].data.as_ref().ok_or_else(|| {
85            ScriptError::InvalidScript("inscription data chunk has no data".to_string())
86        })?;
87
88        Ok(Inscription {
89            content_type,
90            data: data.clone(),
91        })
92    }
93
94    /// Create an appropriate data push chunk for the given bytes.
95    fn make_data_chunk(data: &[u8]) -> ScriptChunk {
96        let len = data.len();
97        let op_byte = if len < 0x4c {
98            len as u8
99        } else if len < 256 {
100            Op::OpPushData1.to_byte()
101        } else if len < 65536 {
102            Op::OpPushData2.to_byte()
103        } else {
104            Op::OpPushData4.to_byte()
105        };
106        ScriptChunk::new_raw(op_byte, Some(data.to_vec()))
107    }
108}
109
110/// Create a simple OP_FALSE OP_RETURN data script (no content type).
111///
112/// Format: `OP_FALSE OP_RETURN <data>`
113pub fn op_return_data(data: &[u8]) -> LockingScript {
114    let chunks = vec![
115        ScriptChunk::new_opcode(Op::Op0), // OP_FALSE
116        ScriptChunk::new_opcode(Op::OpReturn),
117        Inscription::make_data_chunk(data),
118    ];
119    LockingScript::from_script(Script::from_chunks(chunks))
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_inscription_to_script_format() {
128        let insc = Inscription::new("text/plain", b"hello world".to_vec());
129        let script = insc.to_script();
130        let binary = script.to_binary();
131
132        // First byte: OP_FALSE (0x00)
133        assert_eq!(binary[0], 0x00, "should start with OP_FALSE");
134        // Second byte: OP_RETURN (0x6a)
135        assert_eq!(binary[1], 0x6a, "second byte should be OP_RETURN");
136    }
137
138    #[test]
139    fn test_inscription_roundtrip() {
140        let insc = Inscription::new("text/plain", b"hello world".to_vec());
141        let script = insc.to_script();
142
143        // The script uses from_chunks, which does NOT trigger OP_RETURN
144        // conditional-block semantics from parse_chunks. We need to parse
145        // the chunks directly.
146        let decoded = Inscription::from_script(&script).unwrap();
147        assert_eq!(decoded.content_type, "text/plain");
148        assert_eq!(decoded.data, b"hello world");
149    }
150
151    #[test]
152    fn test_inscription_from_script_invalid() {
153        // Too few chunks
154        let script = Script::from_chunks(vec![ScriptChunk::new_opcode(Op::Op0)]);
155        assert!(Inscription::from_script(&script).is_err());
156
157        // Wrong first opcode
158        let script = Script::from_chunks(vec![
159            ScriptChunk::new_opcode(Op::Op1),
160            ScriptChunk::new_opcode(Op::OpReturn),
161            ScriptChunk::new_raw(4, Some(b"test".to_vec())),
162            ScriptChunk::new_raw(4, Some(b"data".to_vec())),
163        ]);
164        assert!(Inscription::from_script(&script).is_err());
165    }
166
167    #[test]
168    fn test_op_return_data_format() {
169        let script = op_return_data(b"test data");
170        let binary = script.to_binary();
171
172        assert_eq!(binary[0], 0x00, "OP_FALSE");
173        assert_eq!(binary[1], 0x6a, "OP_RETURN");
174        // Third byte is push length (9 bytes of "test data")
175        assert_eq!(binary[2], 9);
176        assert_eq!(&binary[3..12], b"test data");
177    }
178
179    #[test]
180    fn test_op_return_data_empty() {
181        let script = op_return_data(&[]);
182        let binary = script.to_binary();
183
184        assert_eq!(binary[0], 0x00, "OP_FALSE");
185        assert_eq!(binary[1], 0x6a, "OP_RETURN");
186        assert_eq!(binary[2], 0x00, "push 0 bytes");
187    }
188
189    #[test]
190    fn test_inscription_various_content_types() {
191        let test_cases = vec![
192            ("application/json", b"{\"key\":\"value\"}".to_vec()),
193            ("image/png", vec![0x89, 0x50, 0x4e, 0x47]),
194            ("text/html", b"<html></html>".to_vec()),
195        ];
196
197        for (ct, data) in test_cases {
198            let insc = Inscription::new(ct, data.clone());
199            let script = insc.to_script();
200            let decoded = Inscription::from_script(&script).unwrap();
201            assert_eq!(decoded.content_type, ct);
202            assert_eq!(decoded.data, data);
203        }
204    }
205}