Skip to main content

dpp/document/
mod.rs

1pub use fields::{property_names, IDENTIFIER_FIELDS};
2
3mod accessors;
4#[cfg(feature = "client")]
5mod document_facade;
6#[cfg(feature = "factories")]
7pub mod document_factory;
8pub mod document_methods;
9mod document_patch;
10pub mod errors;
11#[cfg(feature = "extended-document")]
12pub mod extended_document;
13mod fields;
14pub mod generate_document_id;
15pub mod serialization_traits;
16#[cfg(feature = "factories")]
17pub mod specialized_document_factory;
18pub mod transfer;
19mod v0;
20
21pub use accessors::*;
22pub use v0::*;
23
24#[cfg(feature = "extended-document")]
25pub use extended_document::property_names as extended_document_property_names;
26#[cfg(feature = "extended-document")]
27pub use extended_document::ExtendedDocument;
28#[cfg(feature = "extended-document")]
29pub use extended_document::IDENTIFIER_FIELDS as EXTENDED_DOCUMENT_IDENTIFIER_FIELDS;
30
31/// the initial revision of newly created document
32pub const INITIAL_REVISION: u64 = 1;
33
34use crate::data_contract::document_type::DocumentTypeRef;
35use crate::data_contract::DataContract;
36use crate::document::document_methods::{
37    DocumentGetRawForContractV0, DocumentGetRawForDocumentTypeV0, DocumentHashV0Method,
38    DocumentIsEqualIgnoringTimestampsV0, DocumentMethodsV0,
39};
40use crate::document::errors::DocumentError;
41use crate::version::PlatformVersion;
42use crate::ProtocolError;
43use derive_more::From;
44
45use std::fmt;
46use std::fmt::Formatter;
47
48#[derive(Clone, Debug, PartialEq, From)]
49#[cfg_attr(
50    feature = "serde-conversion",
51    derive(serde::Serialize, serde::Deserialize),
52    serde(tag = "$formatVersion")
53)]
54pub enum Document {
55    #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))]
56    V0(DocumentV0),
57}
58
59#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))]
60impl crate::serialization::JsonConvertible for Document {}
61
62#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))]
63impl crate::serialization::ValueConvertible for Document {}
64
65impl fmt::Display for Document {
66    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
67        match self {
68            Document::V0(v0) => {
69                write!(f, "v0 : {} ", v0)?;
70            }
71        }
72        Ok(())
73    }
74}
75
76impl DocumentMethodsV0 for Document {
77    /// Return a value given the path to its key and the document type for a contract.
78    fn get_raw_for_contract(
79        &self,
80        key: &str,
81        document_type_name: &str,
82        contract: &DataContract,
83        owner_id: Option<[u8; 32]>,
84        platform_version: &PlatformVersion,
85    ) -> Result<Option<Vec<u8>>, ProtocolError> {
86        match self {
87            Document::V0(document_v0) => {
88                match platform_version
89                    .dpp
90                    .document_versions
91                    .document_method_versions
92                    .get_raw_for_contract
93                {
94                    0 => document_v0.get_raw_for_contract_v0(
95                        key,
96                        document_type_name,
97                        contract,
98                        owner_id,
99                        platform_version,
100                    ),
101                    version => Err(ProtocolError::UnknownVersionMismatch {
102                        method: "DocumentMethodV0::get_raw_for_contract".to_string(),
103                        known_versions: vec![0],
104                        received: version,
105                    }),
106                }
107            }
108        }
109    }
110
111    /// Return a value given the path to its key for a document type.
112    fn get_raw_for_document_type(
113        &self,
114        key_path: &str,
115        document_type: DocumentTypeRef,
116        owner_id: Option<[u8; 32]>,
117        platform_version: &PlatformVersion,
118    ) -> Result<Option<Vec<u8>>, ProtocolError> {
119        match self {
120            Document::V0(document_v0) => {
121                match platform_version
122                    .dpp
123                    .document_versions
124                    .document_method_versions
125                    .get_raw_for_document_type
126                {
127                    0 => document_v0.get_raw_for_document_type_v0(
128                        key_path,
129                        document_type,
130                        owner_id,
131                        platform_version,
132                    ),
133                    version => Err(ProtocolError::UnknownVersionMismatch {
134                        method: "DocumentMethodV0::get_raw_for_document_type".to_string(),
135                        known_versions: vec![0],
136                        received: version,
137                    }),
138                }
139            }
140        }
141    }
142
143    fn hash(
144        &self,
145        contract: &DataContract,
146        document_type: DocumentTypeRef,
147        platform_version: &PlatformVersion,
148    ) -> Result<Vec<u8>, ProtocolError> {
149        match self {
150            Document::V0(document_v0) => {
151                match platform_version
152                    .dpp
153                    .document_versions
154                    .document_method_versions
155                    .hash
156                {
157                    0 => document_v0.hash_v0(contract, document_type, platform_version),
158                    version => Err(ProtocolError::UnknownVersionMismatch {
159                        method: "DocumentMethodV0::hash".to_string(),
160                        known_versions: vec![0],
161                        received: version,
162                    }),
163                }
164            }
165        }
166    }
167
168    fn increment_revision(&mut self) -> Result<(), ProtocolError> {
169        let Some(revision) = self.revision() else {
170            return Err(ProtocolError::Document(Box::new(
171                DocumentError::DocumentNoRevisionError {
172                    document: Box::new(self.clone()),
173                },
174            )));
175        };
176
177        let new_revision = revision
178            .checked_add(1)
179            .ok_or(ProtocolError::Overflow("overflow when adding 1"))?;
180
181        self.set_revision(Some(new_revision));
182
183        Ok(())
184    }
185
186    fn is_equal_ignoring_time_based_fields(
187        &self,
188        rhs: &Self,
189        also_ignore_fields: Option<Vec<&str>>,
190        platform_version: &PlatformVersion,
191    ) -> Result<bool, ProtocolError> {
192        match (self, rhs) {
193            (Document::V0(document_v0), Document::V0(rhs_v0)) => {
194                match platform_version
195                    .dpp
196                    .document_versions
197                    .document_method_versions
198                    .is_equal_ignoring_timestamps
199                {
200                    0 => Ok(document_v0
201                        .is_equal_ignoring_time_based_fields_v0(rhs_v0, also_ignore_fields)),
202                    version => Err(ProtocolError::UnknownVersionMismatch {
203                        method: "DocumentMethodV0::is_equal_ignoring_time_based_fields".to_string(),
204                        known_versions: vec![0],
205                        received: version,
206                    }),
207                }
208            }
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::data_contract::accessors::v0::DataContractV0Getters;
217    use crate::data_contract::document_type::random_document::CreateRandomDocument;
218    use crate::document::serialization_traits::DocumentPlatformConversionMethodsV0;
219    use crate::tests::json_document::json_document_to_contract;
220
221    use regex::Regex;
222
223    #[test]
224    fn test_document_display() {
225        let platform_version = PlatformVersion::first();
226        let contract = json_document_to_contract(
227            "../rs-drive/tests/supporting_files/contract/dashpay/dashpay-contract.json",
228            false,
229            platform_version,
230        )
231        .expect("expected to get contract");
232
233        let document_type = contract
234            .document_type_for_name("profile")
235            .expect("expected to get profile document type");
236        let document = document_type
237            .random_document(Some(3333), platform_version)
238            .expect("expected to get a random document");
239
240        let document_string = format!("{}", document);
241        let pattern = r"v\d+ : id:45ZNwGcxeMpLpYmiVEKKBKXbZfinrhjZLkau1GWizPFX owner_id:2vq574DjKi7ZD8kJ6dMHxT5wu6ZKD2bW5xKAyKAGW7qZ created_at:(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) updated_at:(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) avatarUrl:string y8RD1DbW18RuyblDX7hx\[...\(670\)\] displayName:string y94Itl6mn1yBE publicMessage:string SvAQrzsslj0ESc15GQBQ\[...\(105\)\] .*";
242        let re = Regex::new(pattern).unwrap();
243        assert!(
244            re.is_match(document_string.as_str()),
245            "pattern: {} does not match {}",
246            pattern,
247            document_string
248        );
249    }
250
251    #[test]
252    fn test_serialization_and_deserialization() {
253        let platform_version = PlatformVersion::latest();
254        let contract = json_document_to_contract(
255            "../rs-drive/tests/supporting_files/contract/dpns/dpns-contract.json",
256            false,
257            platform_version,
258        )
259        .expect("expected to get contract");
260
261        let document_type = contract
262            .document_type_for_name("domain")
263            .expect("expected to get document type");
264        for _ in 0..20 {
265            let document = document_type
266                .random_document(None, platform_version)
267                .expect("expected a document");
268            let serialized = <Document as DocumentPlatformConversionMethodsV0>::serialize(
269                &document,
270                document_type,
271                &contract,
272                platform_version,
273            )
274            .expect("should serialize");
275            let _deserialized = Document::from_bytes(&serialized, document_type, platform_version)
276                .expect("expected to deserialize domain document");
277        }
278    }
279
280    #[test]
281    fn test_serialize_deserialize_over_different_versions_of_document_type() {
282        let platform_version = PlatformVersion::latest();
283        let contract = json_document_to_contract(
284            "../rs-drive/tests/supporting_files/contract/dpns/dpns-contract.json",
285            false,
286            platform_version,
287        )
288        .expect("expected to get contract");
289
290        let updated_contract = json_document_to_contract(
291            "../rs-drive/tests/supporting_files/contract/dpns/dpns-contract-update-v2-test.json",
292            false,
293            platform_version,
294        )
295        .expect("expected to get contract");
296
297        let document_type = contract
298            .document_type_for_name("domain")
299            .expect("expected to get document type");
300
301        let updated_document_type = updated_contract
302            .document_type_for_name("domain")
303            .expect("expected to get document type");
304
305        // let's test from a document created in the old version, and we try to deserialize it in the new version
306        for _ in 0..20 {
307            let document = document_type
308                .random_document(None, platform_version)
309                .expect("expected a document");
310            let serialized = <Document as DocumentPlatformConversionMethodsV0>::serialize(
311                &document,
312                document_type,
313                &contract,
314                platform_version,
315            )
316            .expect("should serialize");
317            let _deserialized =
318                Document::from_bytes(&serialized, updated_document_type, platform_version)
319                    .expect("expected to deserialize domain document");
320        }
321
322        // let's test from a document created in the new version, and we try to deserialize it with the old version
323        for _ in 0..20 {
324            let document = updated_document_type
325                .random_document(None, platform_version)
326                .expect("expected a document");
327            let serialized = <Document as DocumentPlatformConversionMethodsV0>::serialize(
328                &document,
329                document_type,
330                &contract,
331                platform_version,
332            )
333            .expect("should serialize");
334            let _deserialized = Document::from_bytes(&serialized, document_type, platform_version)
335                .expect("expected to deserialize domain document");
336        }
337    }
338
339    // ================================================================
340    //  Display impl tests for Document
341    // ================================================================
342
343    #[test]
344    fn display_document_with_no_properties() {
345        let doc = Document::V0(DocumentV0 {
346            id: platform_value::Identifier::new([0xAA; 32]),
347            owner_id: platform_value::Identifier::new([0xBB; 32]),
348            properties: Default::default(),
349            revision: None,
350            created_at: None,
351            updated_at: None,
352            transferred_at: None,
353            created_at_block_height: None,
354            updated_at_block_height: None,
355            transferred_at_block_height: None,
356            created_at_core_block_height: None,
357            updated_at_core_block_height: None,
358            transferred_at_core_block_height: None,
359            creator_id: None,
360        });
361
362        let s = format!("{}", doc);
363        assert!(
364            s.contains("no properties"),
365            "should say 'no properties' when the BTreeMap is empty, got: {}",
366            s
367        );
368    }
369
370    #[test]
371    fn display_document_shows_transferred_at_fields() {
372        let doc = Document::V0(DocumentV0 {
373            id: platform_value::Identifier::new([1u8; 32]),
374            owner_id: platform_value::Identifier::new([2u8; 32]),
375            properties: Default::default(),
376            revision: None,
377            created_at: None,
378            updated_at: None,
379            transferred_at: Some(1_700_000_000_000),
380            created_at_block_height: None,
381            updated_at_block_height: None,
382            transferred_at_block_height: Some(500),
383            created_at_core_block_height: None,
384            updated_at_core_block_height: None,
385            transferred_at_core_block_height: Some(42),
386            creator_id: None,
387        });
388
389        let s = format!("{}", doc);
390        assert!(
391            s.contains("transferred_at:"),
392            "should contain transferred_at, got: {}",
393            s
394        );
395        assert!(
396            s.contains("transferred_at_block_height:500"),
397            "should contain transferred_at_block_height:500, got: {}",
398            s
399        );
400        assert!(
401            s.contains("transferred_at_core_block_height:42"),
402            "should contain transferred_at_core_block_height:42, got: {}",
403            s
404        );
405    }
406
407    #[test]
408    fn display_document_shows_creator_id() {
409        let creator = platform_value::Identifier::new([0xCC; 32]);
410        let doc = Document::V0(DocumentV0 {
411            id: platform_value::Identifier::new([1u8; 32]),
412            owner_id: platform_value::Identifier::new([2u8; 32]),
413            properties: Default::default(),
414            revision: None,
415            created_at: None,
416            updated_at: None,
417            transferred_at: None,
418            created_at_block_height: None,
419            updated_at_block_height: None,
420            transferred_at_block_height: None,
421            created_at_core_block_height: None,
422            updated_at_core_block_height: None,
423            transferred_at_core_block_height: None,
424            creator_id: Some(creator),
425        });
426
427        let s = format!("{}", doc);
428        assert!(
429            s.contains("creator_id:"),
430            "should contain creator_id, got: {}",
431            s
432        );
433    }
434
435    #[test]
436    fn display_document_shows_block_height_fields() {
437        let doc = Document::V0(DocumentV0 {
438            id: platform_value::Identifier::new([1u8; 32]),
439            owner_id: platform_value::Identifier::new([2u8; 32]),
440            properties: Default::default(),
441            revision: None,
442            created_at: None,
443            updated_at: None,
444            transferred_at: None,
445            created_at_block_height: Some(100),
446            updated_at_block_height: Some(200),
447            transferred_at_block_height: None,
448            created_at_core_block_height: Some(50),
449            updated_at_core_block_height: Some(60),
450            transferred_at_core_block_height: None,
451            creator_id: None,
452        });
453
454        let s = format!("{}", doc);
455        assert!(s.contains("created_at_block_height:100"), "got: {}", s);
456        assert!(s.contains("updated_at_block_height:200"), "got: {}", s);
457        assert!(s.contains("created_at_core_block_height:50"), "got: {}", s);
458        assert!(s.contains("updated_at_core_block_height:60"), "got: {}", s);
459    }
460
461    // ================================================================
462    //  Version dispatch: increment_revision
463    // ================================================================
464
465    #[test]
466    fn increment_revision_works_on_mutable_document() {
467        let mut doc = Document::V0(DocumentV0 {
468            id: platform_value::Identifier::new([1u8; 32]),
469            owner_id: platform_value::Identifier::new([2u8; 32]),
470            properties: Default::default(),
471            revision: Some(1),
472            created_at: None,
473            updated_at: None,
474            transferred_at: None,
475            created_at_block_height: None,
476            updated_at_block_height: None,
477            transferred_at_block_height: None,
478            created_at_core_block_height: None,
479            updated_at_core_block_height: None,
480            transferred_at_core_block_height: None,
481            creator_id: None,
482        });
483
484        doc.increment_revision()
485            .expect("increment_revision should succeed");
486        assert_eq!(doc.revision(), Some(2));
487    }
488
489    #[test]
490    fn increment_revision_fails_when_no_revision() {
491        let mut doc = Document::V0(DocumentV0 {
492            id: platform_value::Identifier::new([1u8; 32]),
493            owner_id: platform_value::Identifier::new([2u8; 32]),
494            properties: Default::default(),
495            revision: None,
496            created_at: None,
497            updated_at: None,
498            transferred_at: None,
499            created_at_block_height: None,
500            updated_at_block_height: None,
501            transferred_at_block_height: None,
502            created_at_core_block_height: None,
503            updated_at_core_block_height: None,
504            transferred_at_core_block_height: None,
505            creator_id: None,
506        });
507
508        let result = doc.increment_revision();
509        assert!(
510            result.is_err(),
511            "increment_revision should fail when revision is None"
512        );
513    }
514
515    // ================================================================
516    //  Version dispatch: is_equal_ignoring_time_based_fields
517    // ================================================================
518
519    #[test]
520    fn is_equal_ignoring_time_based_fields_dispatches_correctly() {
521        let platform_version = PlatformVersion::latest();
522        let contract = json_document_to_contract(
523            "../rs-drive/tests/supporting_files/contract/dashpay/dashpay-contract.json",
524            false,
525            platform_version,
526        )
527        .expect("expected to get contract");
528
529        let document_type = contract
530            .document_type_for_name("profile")
531            .expect("expected to get profile document type");
532
533        let doc1 = document_type
534            .random_document(Some(42), platform_version)
535            .expect("expected random document");
536
537        let mut doc2 = doc1.clone();
538        // Change timestamps
539        doc2.set_created_at(Some(9_999_999));
540        doc2.set_updated_at(Some(8_888_888));
541
542        let result = doc1
543            .is_equal_ignoring_time_based_fields(&doc2, None, platform_version)
544            .expect("should succeed");
545        assert!(
546            result,
547            "same document with different timestamps should be equal ignoring time fields"
548        );
549    }
550
551    // ================================================================
552    //  Version dispatch: get_raw_for_contract
553    // ================================================================
554
555    #[test]
556    fn get_raw_for_contract_dispatches_to_v0() {
557        let platform_version = PlatformVersion::latest();
558        let contract = json_document_to_contract(
559            "../rs-drive/tests/supporting_files/contract/dashpay/dashpay-contract.json",
560            false,
561            platform_version,
562        )
563        .expect("expected to get contract");
564
565        let document_type = contract
566            .document_type_for_name("profile")
567            .expect("expected to get profile document type");
568
569        let document = document_type
570            .random_document(Some(7), platform_version)
571            .expect("expected random document");
572
573        let raw_id = document
574            .get_raw_for_contract("$id", "profile", &contract, None, platform_version)
575            .expect("should succeed");
576        assert_eq!(raw_id, Some(document.id().to_vec()));
577    }
578
579    // ================================================================
580    //  Version dispatch: hash
581    // ================================================================
582
583    #[test]
584    fn document_hash_is_deterministic() {
585        let platform_version = PlatformVersion::latest();
586        let contract = json_document_to_contract(
587            "../rs-drive/tests/supporting_files/contract/dashpay/dashpay-contract.json",
588            false,
589            platform_version,
590        )
591        .expect("expected to get contract");
592
593        let document_type = contract
594            .document_type_for_name("profile")
595            .expect("expected to get profile document type");
596
597        let document = document_type
598            .random_document(Some(42), platform_version)
599            .expect("expected random document");
600
601        let hash1 = document
602            .hash(&contract, document_type, platform_version)
603            .expect("hash should succeed");
604        let hash2 = document
605            .hash(&contract, document_type, platform_version)
606            .expect("hash should succeed");
607        assert_eq!(hash1, hash2, "hash should be deterministic");
608        assert!(!hash1.is_empty(), "hash should not be empty");
609    }
610
611    // ================================================================
612    //  increment_revision: overflow from Revision::MAX surfaces an
613    //  Overflow ProtocolError (not a silent saturate — this is the
614    //  Document-enum path which uses checked_add).
615    // ================================================================
616
617    #[test]
618    fn increment_revision_errors_on_overflow() {
619        let mut doc = Document::V0(DocumentV0 {
620            id: platform_value::Identifier::new([1u8; 32]),
621            owner_id: platform_value::Identifier::new([2u8; 32]),
622            properties: Default::default(),
623            revision: Some(crate::prelude::Revision::MAX),
624            created_at: None,
625            updated_at: None,
626            transferred_at: None,
627            created_at_block_height: None,
628            updated_at_block_height: None,
629            transferred_at_block_height: None,
630            created_at_core_block_height: None,
631            updated_at_core_block_height: None,
632            transferred_at_core_block_height: None,
633            creator_id: None,
634        });
635        let err = doc.increment_revision().expect_err("MAX + 1 must overflow");
636        match err {
637            ProtocolError::Overflow(_) => {}
638            other => panic!("expected ProtocolError::Overflow, got {:?}", other),
639        }
640    }
641
642    // ================================================================
643    //  From<DocumentV0> for Document produces a V0 variant.
644    // ================================================================
645
646    #[test]
647    fn from_document_v0_produces_v0_variant() {
648        let v0 = DocumentV0 {
649            id: platform_value::Identifier::new([1u8; 32]),
650            owner_id: platform_value::Identifier::new([2u8; 32]),
651            properties: Default::default(),
652            revision: Some(7),
653            created_at: None,
654            updated_at: None,
655            transferred_at: None,
656            created_at_block_height: None,
657            updated_at_block_height: None,
658            transferred_at_block_height: None,
659            created_at_core_block_height: None,
660            updated_at_core_block_height: None,
661            transferred_at_core_block_height: None,
662            creator_id: None,
663        };
664        let document: Document = v0.clone().into();
665        match document {
666            Document::V0(inner) => assert_eq!(inner, v0),
667        }
668    }
669
670    // ================================================================
671    //  Document Display forwards to DocumentV0 Display with a version
672    //  prefix.
673    // ================================================================
674
675    #[test]
676    fn document_display_has_version_prefix() {
677        let doc = Document::V0(DocumentV0 {
678            id: platform_value::Identifier::new([1u8; 32]),
679            owner_id: platform_value::Identifier::new([2u8; 32]),
680            properties: Default::default(),
681            revision: None,
682            created_at: None,
683            updated_at: None,
684            transferred_at: None,
685            created_at_block_height: None,
686            updated_at_block_height: None,
687            transferred_at_block_height: None,
688            created_at_core_block_height: None,
689            updated_at_core_block_height: None,
690            transferred_at_core_block_height: None,
691            creator_id: None,
692        });
693        let s = format!("{}", doc);
694        assert!(
695            s.starts_with("v0 : "),
696            "Display should prefix with version, got: {s}"
697        );
698    }
699
700    // ================================================================
701    //  get_raw_for_document_type dispatches via platform version 0
702    //  to the V0 implementation.
703    // ================================================================
704
705    #[test]
706    fn get_raw_for_document_type_dispatch_path_returns_id() {
707        let platform_version = PlatformVersion::latest();
708        let contract = json_document_to_contract(
709            "../rs-drive/tests/supporting_files/contract/dashpay/dashpay-contract.json",
710            false,
711            platform_version,
712        )
713        .expect("expected contract");
714        let document_type = contract
715            .document_type_for_name("profile")
716            .expect("expected document type");
717
718        let document = document_type
719            .random_document(Some(11), platform_version)
720            .expect("expected random document");
721
722        let raw = document
723            .get_raw_for_document_type("$id", document_type, None, platform_version)
724            .expect("should succeed");
725        assert_eq!(raw, Some(document.id().to_vec()));
726    }
727}
728
729#[cfg(all(
730    test,
731    feature = "json-conversion",
732    feature = "value-conversion",
733    feature = "serde-conversion"
734))]
735mod json_convertible_tests {
736    use super::*;
737
738    use platform_value::{platform_value, Identifier};
739    use serde_json::json;
740    use std::collections::BTreeMap;
741
742    fn fixture() -> Document {
743        Document::V0(DocumentV0 {
744            id: Identifier::new([0xa1; 32]),
745            owner_id: Identifier::new([0xb2; 32]),
746            properties: BTreeMap::new(),
747            revision: Some(2),
748            created_at: Some(1_700_000_000_000),
749            updated_at: Some(1_700_000_001_000),
750            transferred_at: None,
751            created_at_block_height: Some(100),
752            updated_at_block_height: Some(101),
753            transferred_at_block_height: None,
754            created_at_core_block_height: Some(50),
755            updated_at_core_block_height: Some(51),
756            transferred_at_core_block_height: None,
757            creator_id: Some(Identifier::new([0xc3; 32])),
758        })
759    }
760
761    #[test]
762    fn json_round_trip_with_full_wire_shape() {
763        use crate::serialization::JsonConvertible;
764        let original = fixture();
765        let json = original.to_json().expect("to_json");
766        // Sized-int fields whose JSON wire encoding loses size info:
767        // `$revision`/`$createdAt`/`$updatedAt`/`$createdAtBlockHeight`/
768        // `$updatedAtBlockHeight` (u64), `$createdAtCoreBlockHeight`/
769        // `$updatedAtCoreBlockHeight` (u32). The value-path locks variants
770        // via explicit suffixes. `properties` is flattened into the document
771        // root; for an empty `BTreeMap`, no extra keys appear.
772        assert_eq!(
773            json,
774            json!({
775                "$formatVersion": "0",
776                "$id": Identifier::new([0xa1; 32]),
777                "$ownerId": Identifier::new([0xb2; 32]),
778                "$revision": 2,
779                "$createdAt": 1_700_000_000_000u64,
780                "$updatedAt": 1_700_000_001_000u64,
781                "$transferredAt": serde_json::Value::Null,
782                "$createdAtBlockHeight": 100,
783                "$updatedAtBlockHeight": 101,
784                "$transferredAtBlockHeight": serde_json::Value::Null,
785                "$createdAtCoreBlockHeight": 50,
786                "$updatedAtCoreBlockHeight": 51,
787                "$transferredAtCoreBlockHeight": serde_json::Value::Null,
788                "$creatorId": Identifier::new([0xc3; 32]),
789            })
790        );
791        let recovered = Document::from_json(json).expect("from_json");
792        assert_eq!(original, recovered);
793    }
794
795    #[test]
796    fn value_round_trip_with_full_wire_shape() {
797        use crate::serialization::ValueConvertible;
798        let original = fixture();
799        let value = original.to_object().expect("to_object");
800        // Explicit suffixes lock in sized variants: revision / *At /
801        // *AtBlockHeight are u64; *AtCoreBlockHeight are u32.
802        assert_eq!(
803            value,
804            platform_value!({
805                "$formatVersion": "0",
806                "$id": Identifier::new([0xa1; 32]),
807                "$ownerId": Identifier::new([0xb2; 32]),
808                "$revision": 2u64,
809                "$createdAt": 1_700_000_000_000u64,
810                "$updatedAt": 1_700_000_001_000u64,
811                "$transferredAt": platform_value::Value::Null,
812                "$createdAtBlockHeight": 100u64,
813                "$updatedAtBlockHeight": 101u64,
814                "$transferredAtBlockHeight": platform_value::Value::Null,
815                "$createdAtCoreBlockHeight": 50u32,
816                "$updatedAtCoreBlockHeight": 51u32,
817                "$transferredAtCoreBlockHeight": platform_value::Value::Null,
818                "$creatorId": Identifier::new([0xc3; 32]),
819            })
820        );
821        let recovered = Document::from_object(value).expect("from_object");
822        assert_eq!(original, recovered);
823    }
824}