Skip to main content

aleph_types/message/
store.rs

1use crate::chain::Address;
2use crate::cid::Cid;
3use crate::item_hash::{AlephItemHash, ItemHash};
4use crate::message::execution::base::{Payment, PaymentType};
5use memsizes::Bytes;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt::{Display, Formatter};
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11#[serde(tag = "item_type", rename_all = "lowercase")]
12pub enum StorageBackend {
13    Ipfs { item_hash: Cid },
14    Storage { item_hash: AlephItemHash },
15}
16
17/// User's choice of storage backend for file uploads.
18///
19/// Distinct from [`StorageBackend`], which pairs the engine choice with the
20/// file hash and is used for serde of [`StoreContent`].
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum StorageEngine {
23    Storage,
24    Ipfs,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(untagged)]
29/// File reference, as deserialized in STORE messages. Does not contain
30/// information about the owner.
31pub enum RawFileRef {
32    ItemHash(ItemHash),
33    UserDefined(String),
34}
35
36impl Display for RawFileRef {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            RawFileRef::ItemHash(hash) => write!(f, "{}", hash),
40            RawFileRef::UserDefined(name) => write!(f, "{}", name),
41        }
42    }
43}
44
45#[derive(Debug, Clone, PartialEq)]
46/// Explicit file reference, tagged with the owner address for the user-defined case.
47/// This is required to perform unambiguous queries on user-defined file references
48/// as multiple users may use the same references.
49pub enum FileRef {
50    ItemHash(ItemHash),
51    UserDefined { owner: Address, reference: String },
52}
53
54impl Display for FileRef {
55    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
56        match self {
57            FileRef::ItemHash(item_hash) => write!(f, "{}", item_hash),
58            FileRef::UserDefined { owner, reference } => write!(f, "{}/{}", owner, reference),
59        }
60    }
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct StoreContent {
65    #[serde(flatten)]
66    /// A combination of the `item_hash` and `item_type` fields, deserialized together to detect
67    /// inconsistencies. Use the `file_hash()` method to access the file hash.
68    file_hash: StorageBackend,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    /// Size of the file. Generated by CCNs upon processing.
71    pub size: Option<Bytes>,
72    /// Generated by CCNs upon processing.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub content_type: Option<String>,
75    #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
76    pub reference: Option<RawFileRef>,
77    /// Metadata of the VM.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub metadata: Option<HashMap<String, serde_json::Value>>,
80    /// Payment information for storage. Only `hold` and `credit` types are supported.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub payment: Option<Payment>,
83}
84
85impl StoreContent {
86    pub fn new(
87        file_hash: StorageBackend,
88        reference: Option<RawFileRef>,
89        metadata: Option<HashMap<String, serde_json::Value>>,
90        payment: Option<Payment>,
91    ) -> Self {
92        Self {
93            file_hash,
94            size: None,
95            content_type: None,
96            reference,
97            metadata,
98            payment,
99        }
100    }
101
102    /// Returns `true` if the payment type is valid for a STORE message.
103    /// Only `hold` and `credit` are supported; `superfluid` is not allowed.
104    pub fn has_valid_payment(&self) -> bool {
105        match &self.payment {
106            None => true,
107            Some(p) => !matches!(p.payment_type, PaymentType::Superfluid),
108        }
109    }
110
111    pub fn file_hash(&self) -> ItemHash {
112        match &self.file_hash {
113            StorageBackend::Ipfs { item_hash: cid } => ItemHash::Ipfs(cid.clone()),
114            StorageBackend::Storage { item_hash } => ItemHash::Native(*item_hash),
115        }
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::chain::{Address, Chain, Signature};
123    use crate::channel::Channel;
124    use crate::item_hash;
125    use crate::message::base_message::{Message, MessageContentEnum};
126    use crate::message::{ContentSource, MessageType};
127    use crate::timestamp::Timestamp;
128    use assert_matches::assert_matches;
129
130    const STORE_IPFS_FIXTURE: &str = include_str!(concat!(
131        env!("CARGO_MANIFEST_DIR"),
132        "/../../fixtures/messages/store/store-ipfs.json"
133    ));
134
135    #[test]
136    fn test_deserialize_store_message() {
137        let message: Message = serde_json::from_str(STORE_IPFS_FIXTURE).unwrap();
138
139        assert_eq!(
140            message.sender,
141            Address::from("0x238224C744F4b90b4494516e074D2676ECfC6803".to_string())
142        );
143        assert_eq!(message.chain, Chain::Ethereum);
144        assert_eq!(
145                message.signature,
146                Some(Signature::from(
147                    "0x9c87f5d659b9165be7cbd4b9f0bd5df5c66b9bb9a384a0a33b1277428be21244595a0731697035c4b085064cd3fc088bc5b3cddeb22159e7f462e6e5b5e7e8181c".to_string()
148                ))
149            );
150        assert_matches!(message.message_type, MessageType::Store);
151        assert_matches!(
152            message.content_source,
153            ContentSource::Inline { item_content: _ }
154        );
155        assert_eq!(
156            &message.item_hash.to_string(),
157            "afe106f1fd70b6b806e0452cc2f9485e518143581ffd046ae19fc64af7b6bbaa"
158        );
159        assert_eq!(message.time, Timestamp::from(1761047957.74837));
160        assert_matches!(message.channel, Some(ref channel) if channel == &Channel::from("ALEPH-CLOUDSOLUTIONS".to_string()));
161
162        // Check content fields
163        assert_eq!(
164            &message.content.address,
165            &Address::from("0x238224C744F4b90b4494516e074D2676ECfC6803".to_string())
166        );
167        assert_eq!(&message.content.time, &Timestamp::from(1761047957.7483068));
168        assert_eq!(message.sent_at(), &message.content.time);
169
170        // Check STORE-specific fields
171        match message.content() {
172            MessageContentEnum::Store(store) => {
173                assert_eq!(
174                    store.file_hash,
175                    StorageBackend::Ipfs {
176                        item_hash: Cid::try_from("QmYULJoNGPDmoRq4WNWTDTUvJGJv1hosox8H6vVd1kCsY8")
177                            .unwrap()
178                    }
179                );
180                assert_eq!(
181                    store.file_hash(),
182                    item_hash!("QmYULJoNGPDmoRq4WNWTDTUvJGJv1hosox8H6vVd1kCsY8")
183                );
184
185                assert!(store.size.is_none());
186                assert!(store.content_type.is_none());
187                assert!(store.reference.is_none());
188                assert!(store.metadata.is_none());
189            }
190            other => {
191                panic!("Expected MessageContentEnum::Store, got {:?}", other)
192            }
193        }
194
195        // Check confirmations
196        assert!(message.confirmed());
197        assert_eq!(message.confirmations.len(), 1);
198
199        let confirmation = &message.confirmations[0];
200        assert_eq!(confirmation.chain, Chain::Ethereum);
201        assert_eq!(confirmation.height, 23626206);
202        assert_eq!(
203            confirmation.hash,
204            "0x7e73ff97d7920fcfc289a899aeac4bc2898d1482a9876bd2ac4584ae876d22be"
205        );
206        assert!(confirmation.time.is_none());
207        assert!(confirmation.publisher.is_none());
208
209        message.verify_item_hash().unwrap();
210    }
211
212    #[test]
213    fn test_deserialize_serialized_store_message() {
214        let message: Message = serde_json::from_str(STORE_IPFS_FIXTURE).unwrap();
215        message.verify_item_hash().unwrap();
216
217        let serialized_message = serde_json::to_string(&message).unwrap();
218        let deserialized_message: Message = serde_json::from_str(&serialized_message).unwrap();
219        deserialized_message.verify_item_hash().unwrap();
220
221        assert_eq!(message, deserialized_message);
222    }
223
224    const TEST_HASH: &str = "d281eb8a69ba1f4dda2d71aaf3ded06caa92edd690ef3d0632f41aa91167762c";
225
226    #[test]
227    fn test_store_content_without_payment() {
228        let json = format!(r#"{{"item_type":"storage","item_hash":"{}"}}"#, TEST_HASH);
229        let content: StoreContent = serde_json::from_str(&json).unwrap();
230        assert!(content.payment.is_none());
231        assert!(content.has_valid_payment());
232    }
233
234    #[test]
235    fn test_store_content_with_credit_payment() {
236        let json = format!(
237            r#"{{"item_type":"storage","item_hash":"{}","payment":{{"type":"credit"}}}}"#,
238            TEST_HASH
239        );
240        let content: StoreContent = serde_json::from_str(&json).unwrap();
241        let payment = content.payment.as_ref().unwrap();
242        assert_eq!(payment.payment_type, PaymentType::Credit);
243        assert!(payment.chain.is_none());
244        assert!(payment.receiver.is_none());
245        assert!(content.has_valid_payment());
246    }
247
248    #[test]
249    fn test_store_content_with_hold_payment() {
250        let json = format!(
251            r#"{{"item_type":"storage","item_hash":"{}","payment":{{"type":"hold"}}}}"#,
252            TEST_HASH
253        );
254        let content: StoreContent = serde_json::from_str(&json).unwrap();
255        assert_eq!(
256            content.payment.as_ref().unwrap().payment_type,
257            PaymentType::Hold
258        );
259        assert!(content.has_valid_payment());
260    }
261
262    #[test]
263    fn test_store_content_superfluid_payment_invalid() {
264        let json = format!(
265            r#"{{"item_type":"storage","item_hash":"{}","payment":{{"type":"superfluid"}}}}"#,
266            TEST_HASH
267        );
268        let content: StoreContent = serde_json::from_str(&json).unwrap();
269        assert!(!content.has_valid_payment());
270    }
271
272    #[test]
273    fn test_store_content_credit_payment_round_trip() {
274        let json = format!(
275            r#"{{"item_type":"storage","item_hash":"{}","payment":{{"type":"credit"}}}}"#,
276            TEST_HASH
277        );
278        let content: StoreContent = serde_json::from_str(&json).unwrap();
279        let serialized = serde_json::to_string(&content).unwrap();
280        let deserialized: StoreContent = serde_json::from_str(&serialized).unwrap();
281        assert_eq!(content, deserialized);
282    }
283
284    #[test]
285    fn test_store_content_payment_not_serialized_when_none() {
286        let content = StoreContent::new(
287            StorageBackend::Storage {
288                item_hash: AlephItemHash::from_bytes(b"test"),
289            },
290            None,
291            None,
292            None,
293        );
294        let json = serde_json::to_string(&content).unwrap();
295        assert!(!json.contains("payment"));
296    }
297}