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    let dotrain_source = DotrainSourceV1(content.to_string());
62    let subject_hash = dotrain_source.hash();
63    let document: RainMetaDocumentV1Item = dotrain_source.into();
64    let documents = vec![document];
65
66    // Generate CBOR bytes
67    let meta_bytes =
68        RainMetaDocumentV1Item::cbor_encode_seq(&documents, crate::KnownMagic::RainMetaDocumentV1)?;
69
70    // Prepare hex-encoded meta first to avoid cloning later
71    let meta_bytes_hex = hex::encode_prefixed(&meta_bytes);
72    // Generate calldata
73    let calldata = generate_emit_data_calldata(subject_hash, meta_bytes);
74
75    Ok(DotrainSourceEmitData {
76        subject: hex::encode_prefixed(subject_hash),
77        meta_bytes: meta_bytes_hex,
78        calldata: hex::encode_prefixed(calldata),
79    })
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::meta::{ContentEncoding, ContentLanguage, ContentType, KnownMagic};
86
87    fn create_test_meta(content: &str) -> RainMetaDocumentV1Item {
88        RainMetaDocumentV1Item {
89            payload: serde_bytes::ByteBuf::from(content),
90            magic: KnownMagic::DotrainSourceV1,
91            content_type: ContentType::OctetStream,
92            content_encoding: ContentEncoding::None,
93            content_language: ContentLanguage::None,
94            schema: None,
95        }
96    }
97
98    #[test]
99    fn test_generate_emit_meta_calldata_success() {
100        let meta = create_test_meta("test content");
101        let calldata = generate_emit_meta_calldata(meta.clone()).unwrap();
102
103        // Verify calldata is not empty
104        assert!(!calldata.is_empty());
105
106        // Decode and verify structure
107        let decoded = emitMetaCall::abi_decode(&calldata).unwrap();
108        let expected_hash = meta.hash(false).unwrap();
109        let expected_meta_bytes =
110            RainMetaDocumentV1Item::cbor_encode_seq(&vec![meta], KnownMagic::RainMetaDocumentV1)
111                .unwrap();
112
113        assert_eq!(decoded.subject, FixedBytes::from(expected_hash));
114        assert_eq!(decoded.meta.as_ref(), expected_meta_bytes.as_slice());
115    }
116
117    /// `IMetaBoardV1_2.emitMeta` reverts `NotRainMetaV1` on anything that is
118    /// not magic-prefixed (`LibMeta.checkMetaUnhashedV1`), so calldata this
119    /// function builds is only submittable if the meta carries the prefix.
120    #[test]
121    fn test_emit_meta_bytes_are_a_rain_meta_document() {
122        let meta = create_test_meta("test content");
123        let calldata = generate_emit_meta_calldata(meta.clone()).unwrap();
124        let emitted = emitMetaCall::abi_decode(&calldata).unwrap().meta;
125
126        // The magic number as the eight wire bytes, independent of KnownMagic.
127        assert_eq!(hex::encode(&emitted[..8]), "ff0a89c674ee7874");
128
129        // Prefix and nothing else: the item still round-trips out of it.
130        let decoded = RainMetaDocumentV1Item::cbor_decode(&emitted).unwrap();
131        assert_eq!(decoded.len(), 1);
132        assert_eq!(decoded[0], meta);
133
134        // The bare item map is what the subject is over, not what is emitted.
135        let bare = meta.cbor_encode().unwrap();
136        assert_ne!(emitted.as_ref(), bare.as_slice());
137        assert_eq!(&emitted[8..], bare.as_slice());
138    }
139
140    #[test]
141    fn test_validate_dotrain_content() {
142        // Valid content
143        assert!(validate_dotrain_content("#main _ _: int-add(1 2)").is_ok());
144        assert!(validate_dotrain_content("/* comment */\n#main _ _: 1").is_ok());
145
146        // Invalid content
147        assert!(validate_dotrain_content("").is_err());
148        assert!(validate_dotrain_content("   ").is_err());
149        assert!(validate_dotrain_content("\n\t\r").is_err());
150    }
151
152    #[test]
153    fn test_generate_dotrain_source_emit_tx_data_success() {
154        let content = "#main _ _: int-add(1 2) int-add(2 3)";
155        let deployment = generate_dotrain_source_emit_tx_data(content).unwrap();
156
157        // Check all fields have 0x prefix
158        assert!(deployment.subject.starts_with("0x"));
159        assert!(deployment.meta_bytes.starts_with("0x"));
160        assert!(deployment.calldata.starts_with("0x"));
161
162        // Check lengths are reasonable
163        assert_eq!(deployment.subject.len(), 66); // 0x + 64 hex chars
164        assert!(deployment.meta_bytes.len() > 10); // Should have some content
165        assert!(deployment.calldata.len() > 10); // Should have some content
166
167        // Optional: verify subject in calldata matches the declared subject
168        let calldata_bytes =
169            alloy::hex::decode(deployment.calldata.trim_start_matches("0x")).unwrap();
170        let decoded = emitMetaCall::abi_decode(&calldata_bytes).unwrap();
171        let subject_hex = deployment.subject.to_lowercase();
172        assert_eq!(hex::encode_prefixed(decoded.subject), subject_hex);
173
174        // meta in calldata must equal the meta_bytes field
175        let meta_bytes_from_field =
176            alloy::hex::decode(deployment.meta_bytes.trim_start_matches("0x")).unwrap();
177        assert_eq!(decoded.meta.as_ref(), meta_bytes_from_field.as_slice());
178
179        // meta_bytes is a cbor-seq under the rain meta document magic
180        // prefix 0xff0a89c674ee7874.
181        assert!(deployment.meta_bytes.starts_with("0xff0a89c674ee7874"));
182
183        // Pinned against an independently computed digest of the raw source
184        // bytes, not against either hash helper.
185        let expected_subject = alloy::primitives::keccak256(content.as_bytes());
186        assert_eq!(deployment.subject, hex::encode_prefixed(expected_subject));
187    }
188
189    #[test]
190    fn test_emit_subject_is_the_fetch_by_subject_key() {
191        // The emitted subject and DotrainSourceV1::hash() — the key
192        // DotrainSourceV1::fetch_by_subject is called with — are one digest,
193        // not two. The cbor item map hash is neither the content hash nor the
194        // hash of the emitted meta bytes, so it must not be the subject.
195        for content in ["x", "#main _ _: int-add(1 2)", "/* c */\n#main _: 1;"] {
196            let deployment = generate_dotrain_source_emit_tx_data(content).unwrap();
197            let source = DotrainSourceV1(content.to_string());
198
199            assert_eq!(deployment.subject, hex::encode_prefixed(source.hash()));
200
201            let document: RainMetaDocumentV1Item = source.into();
202            let item_map_hash = document.hash(false).unwrap();
203            assert_ne!(deployment.subject, hex::encode_prefixed(item_map_hash));
204        }
205    }
206
207    #[test]
208    fn test_generate_dotrain_source_emit_tx_data_empty_content() {
209        let result = generate_dotrain_source_emit_tx_data("");
210        assert!(result.is_err());
211
212        let result = generate_dotrain_source_emit_tx_data("   ");
213        assert!(result.is_err());
214    }
215
216    #[test]
217    fn test_cas_property_same_content() {
218        // Content Addressable Storage: same content -> same calldata
219        let meta1 = create_test_meta("identical content");
220        let meta2 = create_test_meta("identical content");
221
222        let calldata1 = generate_emit_meta_calldata(meta1).unwrap();
223        let calldata2 = generate_emit_meta_calldata(meta2).unwrap();
224
225        // Same content should produce identical calldata
226        assert_eq!(calldata1, calldata2);
227    }
228
229    #[test]
230    fn test_cas_property_different_content() {
231        let meta1 = create_test_meta("content A");
232        let meta2 = create_test_meta("content B");
233
234        let calldata1 = generate_emit_meta_calldata(meta1).unwrap();
235        let calldata2 = generate_emit_meta_calldata(meta2).unwrap();
236
237        // Different content should produce different calldata
238        assert_ne!(calldata1, calldata2);
239
240        // Verify different subjects
241        let decoded1 = emitMetaCall::abi_decode(&calldata1).unwrap();
242        let decoded2 = emitMetaCall::abi_decode(&calldata2).unwrap();
243        assert_ne!(decoded1.subject, decoded2.subject);
244    }
245
246    #[test]
247    fn test_different_magic_same_content() {
248        let meta1 = create_test_meta("same content");
249        let mut meta2 = create_test_meta("same content");
250        meta2.magic = KnownMagic::AuthoringMetaV1; // Different magic
251
252        let calldata1 = generate_emit_meta_calldata(meta1).unwrap();
253        let calldata2 = generate_emit_meta_calldata(meta2).unwrap();
254
255        // Different magic should produce different calldata (even with same payload)
256        assert_ne!(calldata1, calldata2);
257    }
258
259    #[test]
260    fn test_hash_deterministic() {
261        let meta = create_test_meta("test for deterministic hash");
262
263        // Generate calldata multiple times
264        let calldata1 = generate_emit_meta_calldata(meta.clone()).unwrap();
265        let calldata2 = generate_emit_meta_calldata(meta.clone()).unwrap();
266        let calldata3 = generate_emit_meta_calldata(meta).unwrap();
267
268        // All should be identical (deterministic hashing)
269        assert_eq!(calldata1, calldata2);
270        assert_eq!(calldata2, calldata3);
271    }
272
273    #[test]
274    fn test_subject_matches_document_hash() {
275        let meta = create_test_meta("content for hash verification");
276        let expected_hash = meta.hash(false).unwrap();
277
278        let calldata = generate_emit_meta_calldata(meta).unwrap();
279        let decoded = emitMetaCall::abi_decode(&calldata).unwrap();
280
281        // Subject in calldata should match document's own hash
282        assert_eq!(decoded.subject, FixedBytes::from(expected_hash));
283    }
284}