Skip to main content

rain_metadata/
metaboard.rs

1use alloy::primitives::{Bytes, FixedBytes, hex};
2use alloy::sol_types::SolCall;
3use rain_metadata_bindings::IMetaBoardV1_2::emitMetaCall;
4use serde::Serialize;
5
6use crate::{Error, RainMetaDocumentV1Item};
7use crate::meta::types::dotrain::source_v1::DotrainSourceV1;
8
9/// Generate calldata for IMetaBoardV1_2.emitMeta() function using raw bytes
10fn generate_emit_data_calldata(subject: FixedBytes<32>, data: Vec<u8>) -> Vec<u8> {
11    let call = emitMetaCall {
12        subject,
13        meta: Bytes::from(data),
14    };
15    call.abi_encode()
16}
17
18/// Generate calldata for IMetaBoardV1_2.emitMeta() function from any RainMetaDocumentV1Item
19///
20/// The subject stays the BARE cbor item hash — the key `Store` caches inner
21/// items under — while the emitted bytes are the magic-prefixed cbor-seq that
22/// `IMetaBoardV1_2.emitMeta` reverts `NotRainMetaV1` without. Two digests, on
23/// purpose.
24pub fn generate_emit_meta_calldata(meta: RainMetaDocumentV1Item) -> Result<Vec<u8>, Error> {
25    let hash = meta.hash(false)?;
26    let meta_bytes = RainMetaDocumentV1Item::cbor_encode_seq(
27        &vec![meta],
28        crate::KnownMagic::RainMetaDocumentV1,
29    )?;
30    Ok(generate_emit_data_calldata(hash.into(), meta_bytes))
31}
32
33/// Deployment data for publishing to MetaBoard
34#[derive(Debug, Clone, Serialize, serde::Deserialize)]
35pub struct DotrainSourceEmitData {
36    /// Keccak256 hash of the content (hex-encoded with 0x prefix)
37    pub subject: String,
38    /// CBOR-encoded metadata bytes (hex-encoded with 0x prefix)
39    pub meta_bytes: String,
40    /// Complete calldata for IMetaBoardV1_2.emitMeta() (hex-encoded with 0x prefix)
41    pub calldata: String,
42}
43
44/// Validate dotrain content - currently only checks for non-empty content
45fn validate_dotrain_content(content: &str) -> Result<(), Error> {
46    // Reject empty or whitespace-only content
47    if content.trim().is_empty() {
48        return Err(Error::InvalidInput(
49            "Dotrain content cannot be empty".to_string(),
50        ));
51    }
52    Ok(())
53}
54
55/// Generate tx data for DotrainSourceV1 content
56/// Validates content and returns subject hash, meta bytes, and calldata
57pub fn generate_dotrain_source_emit_tx_data(content: &str) -> Result<DotrainSourceEmitData, Error> {
58    // Validate content
59    validate_dotrain_content(content)?;
60
61    // Create DotrainSourceV1
62    let dotrain_source = DotrainSourceV1(content.to_string());
63    // Convert to RainMetaDocumentV1Item
64    let document: RainMetaDocumentV1Item = dotrain_source.into();
65
66    // Hash before moving the document into the sequence to avoid cloning.
67    // Calculate subject hash
68    let subject_hash = document.hash(false)?;
69    let documents = vec![document];
70
71    // Generate CBOR bytes
72    let meta_bytes =
73        RainMetaDocumentV1Item::cbor_encode_seq(&documents, crate::KnownMagic::RainMetaDocumentV1)?;
74
75    // Prepare hex-encoded meta first to avoid cloning later
76    let meta_bytes_hex = hex::encode_prefixed(&meta_bytes);
77    // Generate calldata
78    let calldata = generate_emit_data_calldata(subject_hash.into(), meta_bytes);
79
80    Ok(DotrainSourceEmitData {
81        subject: hex::encode_prefixed(subject_hash),
82        meta_bytes: meta_bytes_hex,
83        calldata: hex::encode_prefixed(calldata),
84    })
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::meta::{ContentEncoding, ContentLanguage, ContentType, KnownMagic};
91
92    fn create_test_meta(content: &str) -> RainMetaDocumentV1Item {
93        RainMetaDocumentV1Item {
94            payload: serde_bytes::ByteBuf::from(content),
95            magic: KnownMagic::DotrainSourceV1,
96            content_type: ContentType::OctetStream,
97            content_encoding: ContentEncoding::None,
98            content_language: ContentLanguage::None,
99            schema: None,
100        }
101    }
102
103    #[test]
104    fn test_generate_emit_meta_calldata_success() {
105        let meta = create_test_meta("test content");
106        let calldata = generate_emit_meta_calldata(meta.clone()).unwrap();
107
108        // Verify calldata is not empty
109        assert!(!calldata.is_empty());
110
111        // Decode and verify structure
112        let decoded = emitMetaCall::abi_decode(&calldata).unwrap();
113        let expected_hash = meta.hash(false).unwrap();
114        let expected_meta_bytes =
115            RainMetaDocumentV1Item::cbor_encode_seq(&vec![meta], KnownMagic::RainMetaDocumentV1)
116                .unwrap();
117
118        assert_eq!(decoded.subject, FixedBytes::from(expected_hash));
119        assert_eq!(decoded.meta.as_ref(), expected_meta_bytes.as_slice());
120    }
121
122    /// `IMetaBoardV1_2.emitMeta` reverts `NotRainMetaV1` on anything that is
123    /// not magic-prefixed (`LibMeta.checkMetaUnhashedV1`), so calldata this
124    /// function builds is only submittable if the meta carries the prefix.
125    #[test]
126    fn test_emit_meta_bytes_are_a_rain_meta_document() {
127        let meta = create_test_meta("test content");
128        let calldata = generate_emit_meta_calldata(meta.clone()).unwrap();
129        let emitted = emitMetaCall::abi_decode(&calldata).unwrap().meta;
130
131        // The magic number as the eight wire bytes, independent of KnownMagic.
132        assert_eq!(hex::encode(&emitted[..8]), "ff0a89c674ee7874");
133
134        // Prefix and nothing else: the item still round-trips out of it.
135        let decoded = RainMetaDocumentV1Item::cbor_decode(&emitted).unwrap();
136        assert_eq!(decoded.len(), 1);
137        assert_eq!(decoded[0], meta);
138
139        // The bare item map is what the subject is over, not what is emitted.
140        let bare = meta.cbor_encode().unwrap();
141        assert_ne!(emitted.as_ref(), bare.as_slice());
142        assert_eq!(&emitted[8..], bare.as_slice());
143    }
144
145    #[test]
146    fn test_validate_dotrain_content() {
147        // Valid content
148        assert!(validate_dotrain_content("#main _ _: int-add(1 2)").is_ok());
149        assert!(validate_dotrain_content("/* comment */\n#main _ _: 1").is_ok());
150
151        // Invalid content
152        assert!(validate_dotrain_content("").is_err());
153        assert!(validate_dotrain_content("   ").is_err());
154        assert!(validate_dotrain_content("\n\t\r").is_err());
155    }
156
157    #[test]
158    fn test_generate_dotrain_source_emit_tx_data_success() {
159        let content = "#main _ _: int-add(1 2) int-add(2 3)";
160        let deployment = generate_dotrain_source_emit_tx_data(content).unwrap();
161
162        // Check all fields have 0x prefix
163        assert!(deployment.subject.starts_with("0x"));
164        assert!(deployment.meta_bytes.starts_with("0x"));
165        assert!(deployment.calldata.starts_with("0x"));
166
167        // Check lengths are reasonable
168        assert_eq!(deployment.subject.len(), 66); // 0x + 64 hex chars
169        assert!(deployment.meta_bytes.len() > 10); // Should have some content
170        assert!(deployment.calldata.len() > 10); // Should have some content
171
172        // Optional: verify subject in calldata matches the declared subject
173        let calldata_bytes =
174            alloy::hex::decode(deployment.calldata.trim_start_matches("0x")).unwrap();
175        let decoded = emitMetaCall::abi_decode(&calldata_bytes).unwrap();
176        let subject_hex = deployment.subject.to_lowercase();
177        assert_eq!(hex::encode_prefixed(decoded.subject), subject_hex);
178
179        // meta in calldata must equal the meta_bytes field
180        let meta_bytes_from_field =
181            alloy::hex::decode(deployment.meta_bytes.trim_start_matches("0x")).unwrap();
182        assert_eq!(decoded.meta.as_ref(), meta_bytes_from_field.as_slice());
183
184        // meta_bytes is a cbor-seq under the rain meta document magic
185        // prefix 0xff0a89c674ee7874.
186        assert!(deployment.meta_bytes.starts_with("0xff0a89c674ee7874"));
187
188        // The subject is the keccak256 of the BARE cbor item map — no
189        // rain-meta-document magic prefix — pinned here against an
190        // independently constructed item.
191        let item = RainMetaDocumentV1Item {
192            payload: serde_bytes::ByteBuf::from(content.as_bytes().to_vec()),
193            magic: KnownMagic::DotrainSourceV1,
194            content_type: ContentType::OctetStream,
195            content_encoding: ContentEncoding::None,
196            content_language: ContentLanguage::None,
197            schema: None,
198        };
199        let expected_subject = alloy::primitives::keccak256(item.cbor_encode().unwrap());
200        assert_eq!(deployment.subject, hex::encode_prefixed(expected_subject));
201    }
202
203    #[test]
204    fn test_generate_dotrain_source_emit_tx_data_empty_content() {
205        let result = generate_dotrain_source_emit_tx_data("");
206        assert!(result.is_err());
207
208        let result = generate_dotrain_source_emit_tx_data("   ");
209        assert!(result.is_err());
210    }
211
212    #[test]
213    fn test_cas_property_same_content() {
214        // Content Addressable Storage: same content -> same calldata
215        let meta1 = create_test_meta("identical content");
216        let meta2 = create_test_meta("identical content");
217
218        let calldata1 = generate_emit_meta_calldata(meta1).unwrap();
219        let calldata2 = generate_emit_meta_calldata(meta2).unwrap();
220
221        // Same content should produce identical calldata
222        assert_eq!(calldata1, calldata2);
223    }
224
225    #[test]
226    fn test_cas_property_different_content() {
227        let meta1 = create_test_meta("content A");
228        let meta2 = create_test_meta("content B");
229
230        let calldata1 = generate_emit_meta_calldata(meta1).unwrap();
231        let calldata2 = generate_emit_meta_calldata(meta2).unwrap();
232
233        // Different content should produce different calldata
234        assert_ne!(calldata1, calldata2);
235
236        // Verify different subjects
237        let decoded1 = emitMetaCall::abi_decode(&calldata1).unwrap();
238        let decoded2 = emitMetaCall::abi_decode(&calldata2).unwrap();
239        assert_ne!(decoded1.subject, decoded2.subject);
240    }
241
242    #[test]
243    fn test_different_magic_same_content() {
244        let meta1 = create_test_meta("same content");
245        let mut meta2 = create_test_meta("same content");
246        meta2.magic = KnownMagic::AuthoringMetaV1; // Different magic
247
248        let calldata1 = generate_emit_meta_calldata(meta1).unwrap();
249        let calldata2 = generate_emit_meta_calldata(meta2).unwrap();
250
251        // Different magic should produce different calldata (even with same payload)
252        assert_ne!(calldata1, calldata2);
253    }
254
255    #[test]
256    fn test_hash_deterministic() {
257        let meta = create_test_meta("test for deterministic hash");
258
259        // Generate calldata multiple times
260        let calldata1 = generate_emit_meta_calldata(meta.clone()).unwrap();
261        let calldata2 = generate_emit_meta_calldata(meta.clone()).unwrap();
262        let calldata3 = generate_emit_meta_calldata(meta).unwrap();
263
264        // All should be identical (deterministic hashing)
265        assert_eq!(calldata1, calldata2);
266        assert_eq!(calldata2, calldata3);
267    }
268
269    #[test]
270    fn test_subject_matches_document_hash() {
271        let meta = create_test_meta("content for hash verification");
272        let expected_hash = meta.hash(false).unwrap();
273
274        let calldata = generate_emit_meta_calldata(meta).unwrap();
275        let decoded = emitMetaCall::abi_decode(&calldata).unwrap();
276
277        // Subject in calldata should match document's own hash
278        assert_eq!(decoded.subject, FixedBytes::from(expected_hash));
279    }
280}