Skip to main content

dpp/document/v0/
cbor_conversion.rs

1use crate::document::property_names;
2
3use crate::identity::TimestampMillis;
4use crate::prelude::{BlockHeight, CoreBlockHeight, Revision};
5
6use crate::ProtocolError;
7
8use crate::document::serialization_traits::DocumentCborMethodsV0;
9use crate::document::v0::DocumentV0;
10use crate::version::PlatformVersion;
11use ciborium::Value as CborValue;
12use integer_encoding::VarIntWriter;
13use platform_value::btreemap_extensions::BTreeValueRemoveFromMapHelper;
14use platform_value::{Identifier, Value};
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17use std::convert::{TryFrom, TryInto};
18
19#[cfg(feature = "cbor")]
20#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
21pub struct DocumentForCbor {
22    /// The unique document ID.
23    #[serde(rename = "$id")]
24    pub id: [u8; 32],
25
26    /// The document's properties (data).
27    #[serde(flatten)]
28    pub properties: BTreeMap<String, CborValue>,
29
30    /// The ID of the document's owner.
31    #[serde(rename = "$ownerId")]
32    pub owner_id: [u8; 32],
33
34    /// The document revision.
35    #[serde(rename = "$revision")]
36    pub revision: Option<Revision>,
37
38    #[serde(rename = "$createdAt")]
39    pub created_at: Option<TimestampMillis>,
40    #[serde(rename = "$updatedAt")]
41    pub updated_at: Option<TimestampMillis>,
42    #[serde(rename = "$transferredAt")]
43    pub transferred_at: Option<TimestampMillis>,
44
45    #[serde(rename = "$createdAtBlockHeight")]
46    pub created_at_block_height: Option<BlockHeight>,
47    #[serde(rename = "$updatedAtBlockHeight")]
48    pub updated_at_block_height: Option<BlockHeight>,
49    #[serde(rename = "$transferredAtBlockHeight")]
50    pub transferred_at_block_height: Option<BlockHeight>,
51
52    #[serde(rename = "$createdAtCoreBlockHeight")]
53    pub created_at_core_block_height: Option<CoreBlockHeight>,
54    #[serde(rename = "$updatedAtCoreBlockHeight")]
55    pub updated_at_core_block_height: Option<CoreBlockHeight>,
56    #[serde(rename = "$transferredAtCoreBlockHeight")]
57    pub transferred_at_core_block_height: Option<CoreBlockHeight>,
58
59    #[serde(rename = "$creatorId")]
60    pub creator_id: Option<Identifier>,
61}
62
63#[cfg(feature = "cbor")]
64impl TryFrom<DocumentV0> for DocumentForCbor {
65    type Error = ProtocolError;
66
67    fn try_from(value: DocumentV0) -> Result<Self, Self::Error> {
68        let DocumentV0 {
69            id,
70            properties,
71            owner_id,
72            revision,
73            created_at,
74            updated_at,
75            transferred_at,
76            created_at_block_height,
77            updated_at_block_height,
78            transferred_at_block_height,
79            created_at_core_block_height,
80            updated_at_core_block_height,
81            transferred_at_core_block_height,
82            creator_id,
83        } = value;
84        Ok(DocumentForCbor {
85            id: id.to_buffer(),
86            properties: Value::convert_to_cbor_map(properties)
87                .map_err(ProtocolError::ValueError)?,
88            owner_id: owner_id.to_buffer(),
89            revision,
90            created_at,
91            updated_at,
92            transferred_at,
93            created_at_block_height,
94            updated_at_block_height,
95            transferred_at_block_height,
96            created_at_core_block_height,
97            updated_at_core_block_height,
98            transferred_at_core_block_height,
99            creator_id,
100        })
101    }
102}
103
104impl DocumentV0 {
105    /// Reads a CBOR-serialized document and creates a Document from it.
106    /// If Document and Owner IDs are provided, they are used, otherwise they are created.
107    fn from_map(
108        mut document_map: BTreeMap<String, Value>,
109        document_id: Option<[u8; 32]>,
110        owner_id: Option<[u8; 32]>,
111    ) -> Result<Self, ProtocolError> {
112        let owner_id = match owner_id {
113            None => document_map
114                .remove_hash256_bytes(property_names::OWNER_ID)
115                .map_err(ProtocolError::ValueError)?,
116            Some(owner_id) => owner_id,
117        };
118
119        let id = match document_id {
120            None => document_map
121                .remove_hash256_bytes(property_names::ID)
122                .map_err(ProtocolError::ValueError)?,
123            Some(document_id) => document_id,
124        };
125
126        let revision = document_map.remove_optional_integer(property_names::REVISION)?;
127
128        let created_at = document_map.remove_optional_integer(property_names::CREATED_AT)?;
129        let updated_at = document_map.remove_optional_integer(property_names::UPDATED_AT)?;
130        let transferred_at =
131            document_map.remove_optional_integer(property_names::TRANSFERRED_AT)?;
132        let created_at_block_height =
133            document_map.remove_optional_integer(property_names::CREATED_AT_BLOCK_HEIGHT)?;
134        let updated_at_block_height =
135            document_map.remove_optional_integer(property_names::UPDATED_AT_BLOCK_HEIGHT)?;
136        let transferred_at_block_height =
137            document_map.remove_optional_integer(property_names::TRANSFERRED_AT_BLOCK_HEIGHT)?;
138        let created_at_core_block_height =
139            document_map.remove_optional_integer(property_names::CREATED_AT_CORE_BLOCK_HEIGHT)?;
140        let updated_at_core_block_height =
141            document_map.remove_optional_integer(property_names::UPDATED_AT_CORE_BLOCK_HEIGHT)?;
142        let transferred_at_core_block_height = document_map
143            .remove_optional_integer(property_names::TRANSFERRED_AT_CORE_BLOCK_HEIGHT)?;
144
145        let creator_id = document_map
146            .remove_optional_identifier(property_names::CREATOR_ID)
147            .map_err(ProtocolError::ValueError)?;
148
149        // dev-note: properties is everything other than the id and owner id
150        Ok(DocumentV0 {
151            properties: document_map,
152            owner_id: Identifier::new(owner_id),
153            id: Identifier::new(id),
154            revision,
155            created_at,
156            updated_at,
157            transferred_at,
158            created_at_block_height,
159            updated_at_block_height,
160            transferred_at_block_height,
161            created_at_core_block_height,
162            updated_at_core_block_height,
163            transferred_at_core_block_height,
164            creator_id,
165        })
166    }
167}
168
169impl DocumentCborMethodsV0 for DocumentV0 {
170    /// Reads a CBOR-serialized document and creates a Document from it.
171    /// If Document and Owner IDs are provided, they are used, otherwise they are created.
172    fn from_cbor(
173        document_cbor: &[u8],
174        document_id: Option<[u8; 32]>,
175        owner_id: Option<[u8; 32]>,
176        _platform_version: &PlatformVersion,
177    ) -> Result<Self, ProtocolError> {
178        // first we need to deserialize the document and contract indices
179        // we would need dedicated deserialization functions based on the document type
180        let document_cbor_map: BTreeMap<String, CborValue> =
181            ciborium::de::from_reader(document_cbor).map_err(|_| {
182                ProtocolError::InvalidCBOR(
183                    "unable to decode document for document call".to_string(),
184                )
185            })?;
186        let document_map: BTreeMap<String, Value> =
187            Value::convert_from_cbor_map(document_cbor_map).map_err(ProtocolError::ValueError)?;
188        Self::from_map(document_map, document_id, owner_id)
189    }
190
191    fn to_cbor_value(&self) -> Result<CborValue, ProtocolError> {
192        // After Phase D step 8 slice A, the V0 trait `to_object` was
193        // deleted (1:1 canonical equivalent). Inline the body —
194        // `IdentityPublicKeyV0` doesn't derive `ValueConvertible` directly
195        // (only the outer `Document` enum does), so we go through
196        // `platform_value::to_value` directly.
197        let value = platform_value::to_value(self).map_err(ProtocolError::ValueError)?;
198        value.try_into().map_err(ProtocolError::ValueError)
199    }
200
201    /// Serializes the Document to CBOR.
202    fn to_cbor(&self) -> Result<Vec<u8>, ProtocolError> {
203        let mut buffer: Vec<u8> = Vec::new();
204        buffer.write_varint(0).map_err(|_| {
205            ProtocolError::EncodingError("error writing protocol version".to_string())
206        })?;
207        let cbor_document = DocumentForCbor::try_from(self.clone())?;
208        ciborium::ser::into_writer(&cbor_document, &mut buffer).map_err(|_| {
209            ProtocolError::EncodingError("unable to serialize into cbor".to_string())
210        })?;
211        Ok(buffer)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::data_contract::accessors::v0::DataContractV0Getters;
219    use crate::data_contract::document_type::random_document::CreateRandomDocument;
220    use crate::document::serialization_traits::DocumentCborMethodsV0;
221    use crate::document::DocumentV0Getters;
222    use crate::tests::json_document::json_document_to_contract;
223    use platform_version::version::PlatformVersion;
224
225    fn make_document_v0_with_timestamps() -> DocumentV0 {
226        let id = Identifier::new([1u8; 32]);
227        let owner_id = Identifier::new([2u8; 32]);
228        let mut properties = BTreeMap::new();
229        properties.insert("name".to_string(), Value::Text("Alice".to_string()));
230        properties.insert("age".to_string(), Value::U64(30));
231        DocumentV0 {
232            id,
233            owner_id,
234            properties,
235            revision: Some(1),
236            created_at: Some(1_700_000_000_000),
237            updated_at: Some(1_700_000_100_000),
238            transferred_at: None,
239            created_at_block_height: Some(100),
240            updated_at_block_height: Some(200),
241            transferred_at_block_height: None,
242            created_at_core_block_height: Some(50),
243            updated_at_core_block_height: Some(60),
244            transferred_at_core_block_height: None,
245            creator_id: None,
246        }
247    }
248
249    // ================================================================
250    //  Round-trip: to_cbor -> from_cbor preserves document data
251    // ================================================================
252
253    #[test]
254    fn cbor_round_trip_with_random_dashpay_profile() {
255        let platform_version = PlatformVersion::latest();
256        let contract = json_document_to_contract(
257            "../rs-drive/tests/supporting_files/contract/dashpay/dashpay-contract.json",
258            false,
259            platform_version,
260        )
261        .expect("expected to load dashpay contract");
262
263        let document_type = contract
264            .document_type_for_name("profile")
265            .expect("expected profile document type");
266
267        for seed in 0..10u64 {
268            let document = document_type
269                .random_document(Some(seed), platform_version)
270                .expect("expected random document");
271
272            // Use Document-level from_cbor which handles the version prefix
273            let cbor_bytes = document.to_cbor().expect("to_cbor should succeed");
274            let recovered =
275                crate::document::Document::from_cbor(&cbor_bytes, None, None, platform_version)
276                    .expect("from_cbor should succeed");
277
278            assert_eq!(document.id(), recovered.id(), "id mismatch for seed {seed}");
279            assert_eq!(
280                document.owner_id(),
281                recovered.owner_id(),
282                "owner_id mismatch for seed {seed}"
283            );
284            assert_eq!(
285                document.revision(),
286                recovered.revision(),
287                "revision mismatch for seed {seed}"
288            );
289            assert_eq!(
290                document.properties(),
291                recovered.properties(),
292                "properties mismatch for seed {seed}"
293            );
294        }
295    }
296
297    #[test]
298    fn cbor_round_trip_with_explicit_ids_overrides_embedded_ids() {
299        let platform_version = PlatformVersion::latest();
300        let contract = json_document_to_contract(
301            "../rs-drive/tests/supporting_files/contract/dashpay/dashpay-contract.json",
302            false,
303            platform_version,
304        )
305        .expect("expected to load dashpay contract");
306
307        let document_type = contract
308            .document_type_for_name("profile")
309            .expect("expected profile document type");
310
311        let document = document_type
312            .random_document(Some(42), platform_version)
313            .expect("expected random document");
314
315        let cbor_bytes = document.to_cbor().expect("to_cbor should succeed");
316
317        let override_id = [0xAA; 32];
318        let override_owner = [0xBB; 32];
319
320        let recovered = crate::document::Document::from_cbor(
321            &cbor_bytes,
322            Some(override_id),
323            Some(override_owner),
324            platform_version,
325        )
326        .expect("from_cbor with explicit ids should succeed");
327
328        assert_eq!(
329            recovered.id(),
330            Identifier::new(override_id),
331            "explicit document_id should override the one in CBOR"
332        );
333        assert_eq!(
334            recovered.owner_id(),
335            Identifier::new(override_owner),
336            "explicit owner_id should override the one in CBOR"
337        );
338    }
339
340    // ================================================================
341    //  to_cbor_value produces a valid CborValue
342    // ================================================================
343
344    #[test]
345    fn to_cbor_value_returns_map_for_document_with_properties() {
346        let doc = make_document_v0_with_timestamps();
347        let cbor_val = doc.to_cbor_value().expect("to_cbor_value should succeed");
348        // CborValue should be a Map at the top level
349        assert!(
350            cbor_val.is_map(),
351            "CBOR value of a document should be a Map, got {:?}",
352            cbor_val
353        );
354    }
355
356    // ================================================================
357    //  to_cbor output starts with varint-encoded version prefix (0)
358    // ================================================================
359
360    #[test]
361    fn to_cbor_starts_with_version_zero_varint() {
362        let doc = make_document_v0_with_timestamps();
363        let cbor_bytes = doc.to_cbor().expect("to_cbor should succeed");
364        // The first byte should be the varint encoding of 0
365        assert!(!cbor_bytes.is_empty(), "CBOR output should not be empty");
366        assert_eq!(
367            cbor_bytes[0], 0,
368            "first byte should be varint(0) for version"
369        );
370    }
371
372    // ================================================================
373    //  from_cbor rejects invalid CBOR data
374    // ================================================================
375
376    #[test]
377    fn from_cbor_rejects_invalid_cbor_bytes() {
378        let platform_version = PlatformVersion::latest();
379        let garbage = vec![0xFF, 0xFE, 0xFD, 0x00, 0x01];
380        let result = DocumentV0::from_cbor(&garbage, None, None, platform_version);
381        assert!(
382            result.is_err(),
383            "from_cbor should fail on invalid CBOR bytes"
384        );
385    }
386
387    // ================================================================
388    //  DocumentForCbor TryFrom preserves all timestamp fields
389    // ================================================================
390
391    #[test]
392    fn document_for_cbor_preserves_all_fields() {
393        let doc = make_document_v0_with_timestamps();
394        let cbor_doc = DocumentForCbor::try_from(doc.clone()).expect("TryFrom should succeed");
395        assert_eq!(cbor_doc.id, doc.id.to_buffer());
396        assert_eq!(cbor_doc.owner_id, doc.owner_id.to_buffer());
397        assert_eq!(cbor_doc.revision, doc.revision);
398        assert_eq!(cbor_doc.created_at, doc.created_at);
399        assert_eq!(cbor_doc.updated_at, doc.updated_at);
400        assert_eq!(cbor_doc.transferred_at, doc.transferred_at);
401        assert_eq!(
402            cbor_doc.created_at_block_height,
403            doc.created_at_block_height
404        );
405        assert_eq!(
406            cbor_doc.updated_at_block_height,
407            doc.updated_at_block_height
408        );
409        assert_eq!(
410            cbor_doc.transferred_at_block_height,
411            doc.transferred_at_block_height
412        );
413        assert_eq!(
414            cbor_doc.created_at_core_block_height,
415            doc.created_at_core_block_height
416        );
417        assert_eq!(
418            cbor_doc.updated_at_core_block_height,
419            doc.updated_at_core_block_height
420        );
421        assert_eq!(
422            cbor_doc.transferred_at_core_block_height,
423            doc.transferred_at_core_block_height
424        );
425    }
426
427    // ================================================================
428    //  from_map populates fields correctly from a BTreeMap<String, Value>
429    // ================================================================
430
431    #[test]
432    fn from_map_extracts_system_fields_and_leaves_properties() {
433        let id_bytes = [3u8; 32];
434        let owner_bytes = [4u8; 32];
435
436        let mut map = BTreeMap::new();
437        map.insert(property_names::ID.to_string(), Value::Bytes32(id_bytes));
438        map.insert(
439            property_names::OWNER_ID.to_string(),
440            Value::Bytes32(owner_bytes),
441        );
442        map.insert(property_names::REVISION.to_string(), Value::U64(5));
443        map.insert(
444            property_names::CREATED_AT.to_string(),
445            Value::U64(1_000_000),
446        );
447        map.insert(
448            property_names::UPDATED_AT.to_string(),
449            Value::U64(2_000_000),
450        );
451        map.insert("customField".to_string(), Value::Text("hello".to_string()));
452
453        let doc = DocumentV0::from_map(map, None, None).expect("from_map should succeed");
454
455        assert_eq!(doc.id, Identifier::new(id_bytes));
456        assert_eq!(doc.owner_id, Identifier::new(owner_bytes));
457        assert_eq!(doc.revision, Some(5));
458        assert_eq!(doc.created_at, Some(1_000_000));
459        assert_eq!(doc.updated_at, Some(2_000_000));
460        // The custom field should remain in properties
461        assert_eq!(
462            doc.properties.get("customField"),
463            Some(&Value::Text("hello".to_string()))
464        );
465        // System fields should NOT be in properties
466        assert!(!doc.properties.contains_key(property_names::ID));
467        assert!(!doc.properties.contains_key(property_names::OWNER_ID));
468        assert!(!doc.properties.contains_key(property_names::REVISION));
469    }
470
471    #[test]
472    fn from_map_with_explicit_ids_overrides_map_ids() {
473        let map_id = [10u8; 32];
474        let map_owner = [11u8; 32];
475        let override_id = [20u8; 32];
476        let override_owner = [21u8; 32];
477
478        let mut map = BTreeMap::new();
479        map.insert(property_names::ID.to_string(), Value::Bytes32(map_id));
480        map.insert(
481            property_names::OWNER_ID.to_string(),
482            Value::Bytes32(map_owner),
483        );
484
485        let doc = DocumentV0::from_map(map, Some(override_id), Some(override_owner))
486            .expect("from_map should succeed");
487
488        assert_eq!(
489            doc.id,
490            Identifier::new(override_id),
491            "explicit document_id should take precedence"
492        );
493        assert_eq!(
494            doc.owner_id,
495            Identifier::new(override_owner),
496            "explicit owner_id should take precedence"
497        );
498    }
499
500    // ================================================================
501    //  Round-trip via from_map: construct map, parse, verify
502    // ================================================================
503
504    // ================================================================
505    //  from_map missing $id / $ownerId errors
506    // ================================================================
507
508    #[test]
509    fn from_map_missing_id_fails_when_not_provided() {
510        // Only owner_id in the map, no explicit document_id — from_map should
511        // error on the ID extraction step.
512        let mut map = BTreeMap::new();
513        map.insert(
514            property_names::OWNER_ID.to_string(),
515            Value::Bytes32([4u8; 32]),
516        );
517
518        let result = DocumentV0::from_map(map, None, None);
519        assert!(
520            result.is_err(),
521            "from_map without $id or explicit document_id should fail"
522        );
523    }
524
525    #[test]
526    fn from_map_missing_owner_id_fails_when_not_provided() {
527        let mut map = BTreeMap::new();
528        map.insert(property_names::ID.to_string(), Value::Bytes32([3u8; 32]));
529
530        let result = DocumentV0::from_map(map, None, None);
531        assert!(
532            result.is_err(),
533            "from_map without $ownerId or explicit owner_id should fail"
534        );
535    }
536
537    // ================================================================
538    //  from_map: creator id parsing
539    // ================================================================
540
541    #[test]
542    fn from_map_extracts_creator_id_when_present_as_identifier() {
543        let creator = Identifier::new([0xCD; 32]);
544        let mut map = BTreeMap::new();
545        map.insert(property_names::ID.to_string(), Value::Bytes32([1u8; 32]));
546        map.insert(
547            property_names::OWNER_ID.to_string(),
548            Value::Bytes32([2u8; 32]),
549        );
550        map.insert(
551            property_names::CREATOR_ID.to_string(),
552            Value::Identifier(creator.to_buffer()),
553        );
554
555        let doc = DocumentV0::from_map(map, None, None).expect("from_map should succeed");
556        assert_eq!(doc.creator_id, Some(creator));
557    }
558
559    #[test]
560    fn from_map_creator_id_missing_stays_none() {
561        let mut map = BTreeMap::new();
562        map.insert(property_names::ID.to_string(), Value::Bytes32([1u8; 32]));
563        map.insert(
564            property_names::OWNER_ID.to_string(),
565            Value::Bytes32([2u8; 32]),
566        );
567
568        let doc = DocumentV0::from_map(map, None, None).expect("from_map should succeed");
569        assert_eq!(doc.creator_id, None);
570    }
571
572    // ================================================================
573    //  from_cbor: bytes starting with truncated ciborium data fail
574    // ================================================================
575
576    #[test]
577    fn from_cbor_rejects_empty_buffer() {
578        let platform_version = PlatformVersion::latest();
579        let result = DocumentV0::from_cbor(&[], None, None, platform_version);
580        assert!(
581            result.is_err(),
582            "from_cbor should fail on an empty input buffer"
583        );
584    }
585
586    #[test]
587    fn from_cbor_rejects_truncated_map_bytes() {
588        // A map header byte that claims to be a map but has no content.
589        let platform_version = PlatformVersion::latest();
590        let result = DocumentV0::from_cbor(&[0xA1], None, None, platform_version);
591        assert!(
592            result.is_err(),
593            "from_cbor should fail on a truncated map prefix"
594        );
595    }
596
597    // ================================================================
598    //  to_cbor round-trip preserves owner_id/creator_id etc
599    // ================================================================
600
601    #[test]
602    fn cbor_round_trip_via_to_cbor_and_from_cbor_preserves_fields() {
603        let platform_version = PlatformVersion::latest();
604        let doc = make_document_v0_with_timestamps();
605
606        let bytes = doc.to_cbor().expect("to_cbor succeeds");
607        let recovered = crate::document::Document::from_cbor(&bytes, None, None, platform_version)
608            .expect("from_cbor succeeds");
609        assert_eq!(doc.id, recovered.id());
610        assert_eq!(doc.owner_id, recovered.owner_id());
611        assert_eq!(doc.revision, recovered.revision());
612    }
613
614    // ================================================================
615    //  DocumentForCbor: TryFrom returns a CBOR-ready structure whose
616    //  `properties` map preserves user-defined keys.
617    // ================================================================
618
619    #[test]
620    fn document_for_cbor_preserves_user_properties() {
621        let doc = make_document_v0_with_timestamps();
622        let cbor = DocumentForCbor::try_from(doc.clone()).expect("try_from succeeds");
623        // "name" and "age" are part of the test fixture
624        assert!(cbor.properties.contains_key("name"));
625        assert!(cbor.properties.contains_key("age"));
626    }
627
628    #[test]
629    fn from_map_with_all_timestamp_variants() {
630        let mut map = BTreeMap::new();
631        map.insert(property_names::ID.to_string(), Value::Bytes32([5u8; 32]));
632        map.insert(
633            property_names::OWNER_ID.to_string(),
634            Value::Bytes32([6u8; 32]),
635        );
636        map.insert(
637            property_names::CREATED_AT_BLOCK_HEIGHT.to_string(),
638            Value::U64(100),
639        );
640        map.insert(
641            property_names::UPDATED_AT_BLOCK_HEIGHT.to_string(),
642            Value::U64(200),
643        );
644        map.insert(
645            property_names::TRANSFERRED_AT.to_string(),
646            Value::U64(3_000_000),
647        );
648        map.insert(
649            property_names::TRANSFERRED_AT_BLOCK_HEIGHT.to_string(),
650            Value::U64(300),
651        );
652        map.insert(
653            property_names::CREATED_AT_CORE_BLOCK_HEIGHT.to_string(),
654            Value::U32(50),
655        );
656        map.insert(
657            property_names::UPDATED_AT_CORE_BLOCK_HEIGHT.to_string(),
658            Value::U32(60),
659        );
660        map.insert(
661            property_names::TRANSFERRED_AT_CORE_BLOCK_HEIGHT.to_string(),
662            Value::U32(70),
663        );
664
665        let doc = DocumentV0::from_map(map, None, None).expect("from_map should succeed");
666
667        assert_eq!(doc.created_at_block_height, Some(100));
668        assert_eq!(doc.updated_at_block_height, Some(200));
669        assert_eq!(doc.transferred_at, Some(3_000_000));
670        assert_eq!(doc.transferred_at_block_height, Some(300));
671        assert_eq!(doc.created_at_core_block_height, Some(50));
672        assert_eq!(doc.updated_at_core_block_height, Some(60));
673        assert_eq!(doc.transferred_at_core_block_height, Some(70));
674    }
675}