Skip to main content

dpp/document/document_factory/v0/
mod.rs

1use crate::consensus::basic::document::InvalidDocumentTypeError;
2use crate::data_contract::accessors::v0::DataContractV0Getters;
3use crate::data_contract::document_type::accessors::{
4    DocumentTypeV0Getters, DocumentTypeV2Getters,
5};
6use crate::data_contract::document_type::DocumentTypeRef;
7use crate::data_contract::errors::DataContractError;
8use crate::data_contract::DataContract;
9use crate::document::errors::DocumentError;
10use crate::document::{Document, DocumentV0Getters, DocumentV0Setters, INITIAL_REVISION};
11use chrono::Utc;
12use std::collections::BTreeMap;
13
14use crate::util::entropy_generator::{DefaultEntropyGenerator, EntropyGenerator};
15use crate::version::PlatformVersion;
16use crate::ProtocolError;
17
18use platform_value::{Bytes32, Identifier, Value};
19
20use crate::data_contract::document_type::methods::DocumentTypeV0Methods;
21use crate::document::document_methods::DocumentMethodsV0;
22#[cfg(feature = "extended-document")]
23use crate::document::{
24    extended_document::v0::ExtendedDocumentV0,
25    ExtendedDocument, serialization_traits::DocumentPlatformConversionMethodsV0,
26};
27use crate::prelude::{BlockHeight, CoreBlockHeight, TimestampMillis};
28#[cfg(feature = "state-transitions")]
29use crate::state_transition::batch_transition::{
30    batched_transition::{
31        document_transition_action_type::DocumentTransitionActionType, DocumentCreateTransition,
32        DocumentDeleteTransition, DocumentIndexOnlyDeleteTransition, DocumentReplaceTransition,
33    },
34    BatchTransition, BatchTransitionV0,
35};
36use itertools::Itertools;
37#[cfg(feature = "state-transitions")]
38use crate::state_transition::state_transitions::document::batch_transition::batched_transition::document_transition::DocumentTransition;
39use crate::tokens::token_payment_info::TokenPaymentInfo;
40
41/// Factory for creating documents
42pub struct DocumentFactoryV0 {
43    protocol_version: u32,
44    entropy_generator: Box<dyn EntropyGenerator>,
45}
46
47impl DocumentFactoryV0 {
48    pub fn new(protocol_version: u32) -> Self {
49        DocumentFactoryV0 {
50            protocol_version,
51            entropy_generator: Box::new(DefaultEntropyGenerator),
52        }
53    }
54
55    pub fn new_with_entropy_generator(
56        protocol_version: u32,
57        entropy_generator: Box<dyn EntropyGenerator>,
58    ) -> Self {
59        DocumentFactoryV0 {
60            protocol_version,
61            entropy_generator,
62        }
63    }
64
65    pub fn create_document(
66        &self,
67        data_contract: &DataContract,
68        owner_id: Identifier,
69        block_time: BlockHeight,
70        core_block_height: CoreBlockHeight,
71        document_type_name: String,
72        data: Value,
73    ) -> Result<Document, ProtocolError> {
74        let platform_version = PlatformVersion::get(self.protocol_version)?;
75        if !data_contract.has_document_type_for_name(&document_type_name) {
76            return Err(DataContractError::InvalidDocumentTypeError(
77                InvalidDocumentTypeError::new(document_type_name, data_contract.id()),
78            )
79            .into());
80        }
81
82        let document_entropy = self.entropy_generator.generate()?;
83
84        let document_type = data_contract.document_type_for_name(document_type_name.as_str())?;
85
86        document_type.create_document_from_data(
87            data,
88            owner_id,
89            block_time,
90            core_block_height,
91            document_entropy,
92            platform_version,
93        )
94    }
95
96    pub fn create_document_without_time_based_properties(
97        &self,
98        data_contract: &DataContract,
99        owner_id: Identifier,
100        document_type_name: String,
101        data: Value,
102    ) -> Result<Document, ProtocolError> {
103        let platform_version = PlatformVersion::get(self.protocol_version)?;
104        if !data_contract.has_document_type_for_name(&document_type_name) {
105            return Err(DataContractError::InvalidDocumentTypeError(
106                InvalidDocumentTypeError::new(document_type_name, data_contract.id()),
107            )
108            .into());
109        }
110
111        let document_entropy = self.entropy_generator.generate()?;
112
113        let document_type = data_contract.document_type_for_name(document_type_name.as_str())?;
114
115        document_type.create_document_from_data(
116            data,
117            owner_id,
118            0,
119            0,
120            document_entropy,
121            platform_version,
122        )
123    }
124
125    #[cfg(feature = "extended-document")]
126    pub fn create_extended_document(
127        &self,
128        data_contract: &DataContract,
129        owner_id: Identifier,
130        document_type_name: String,
131        data: Value,
132    ) -> Result<ExtendedDocument, ProtocolError> {
133        let platform_version = PlatformVersion::get(self.protocol_version)?;
134        if !data_contract.has_document_type_for_name(&document_type_name) {
135            return Err(DataContractError::InvalidDocumentTypeError(
136                InvalidDocumentTypeError::new(document_type_name, data_contract.id()),
137            )
138            .into());
139        }
140
141        let document_entropy = self.entropy_generator.generate()?;
142
143        let document_type = data_contract.document_type_for_name(document_type_name.as_str())?;
144
145        // Extended documents are client side, so we don't need to fill in their timestamp properties
146        let document = document_type.create_document_from_data(
147            data,
148            owner_id,
149            0,
150            0,
151            document_entropy,
152            platform_version,
153        )?;
154
155        let extended_document = match platform_version
156            .dpp
157            .document_versions
158            .extended_document_structure_version
159        {
160            0 => Ok(ExtendedDocumentV0 {
161                document_type_name,
162                data_contract_id: data_contract.id(),
163                document,
164                data_contract: data_contract.clone(),
165                metadata: None,
166                entropy: Bytes32::new(document_entropy),
167                token_payment_info: None,
168            }
169            .into()),
170            version => Err(ProtocolError::UnknownVersionMismatch {
171                method: "DocumentFactory::create_extended_document".to_string(),
172                known_versions: vec![0],
173                received: version,
174            }),
175        }?;
176
177        Ok(extended_document)
178    }
179    #[cfg(feature = "state-transitions")]
180    pub fn create_state_transition<'a>(
181        &self,
182        documents_iter: impl IntoIterator<
183            Item = (
184                DocumentTransitionActionType,
185                Vec<(
186                    Document,
187                    DocumentTypeRef<'a>,
188                    Bytes32,
189                    Option<TokenPaymentInfo>,
190                )>,
191            ),
192        >,
193        nonce_counter: &mut BTreeMap<(Identifier, Identifier), u64>, //IdentityID/ContractID -> nonce
194    ) -> Result<BatchTransition, ProtocolError> {
195        let platform_version = PlatformVersion::get(self.protocol_version)?;
196        // TODO: Use struct or types
197        #[allow(clippy::type_complexity)]
198        let documents: Vec<(
199            DocumentTransitionActionType,
200            Vec<(Document, DocumentTypeRef, Bytes32, Option<TokenPaymentInfo>)>,
201        )> = documents_iter.into_iter().collect();
202        let mut flattened_documents_iter = documents.iter().flat_map(|(_, v)| v).peekable();
203
204        let Some((first_document, _, _, _)) = flattened_documents_iter.peek() else {
205            return Err(DocumentError::NoDocumentsSuppliedError.into());
206        };
207
208        let owner_id = first_document.owner_id();
209
210        let is_the_same_owner =
211            flattened_documents_iter.all(|(document, _, _, _)| document.owner_id() == owner_id);
212        if !is_the_same_owner {
213            return Err(DocumentError::MismatchOwnerIdsError {
214                documents: documents
215                    .into_iter()
216                    .flat_map(|(_, v)| {
217                        v.into_iter()
218                            .map(|(document, _, _, _)| document)
219                            .collect::<Vec<_>>()
220                    })
221                    .collect(),
222            }
223            .into());
224        }
225
226        let transitions: Vec<_> = documents
227            .into_iter()
228            .map(|(action, documents)| match action {
229                DocumentTransitionActionType::Create => {
230                    Self::document_create_transitions(documents, nonce_counter, platform_version)
231                }
232                DocumentTransitionActionType::Delete => Self::document_delete_transitions(
233                    documents
234                        .into_iter()
235                        .map(|(document, document_type, _, token_payment_info)| {
236                            (document, document_type, token_payment_info)
237                        })
238                        .collect(),
239                    nonce_counter,
240                    platform_version,
241                ),
242                DocumentTransitionActionType::Replace => Self::document_replace_transitions(
243                    documents
244                        .into_iter()
245                        .map(|(document, document_type, _, token_payment_info)| {
246                            (document, document_type, token_payment_info)
247                        })
248                        .collect(),
249                    nonce_counter,
250                    platform_version,
251                ),
252                _ => Err(ProtocolError::InvalidStateTransitionType(
253                    "action type not accounted for".to_string(),
254                )),
255            })
256            .collect::<Result<Vec<_>, ProtocolError>>()?
257            .into_iter()
258            .flatten()
259            .collect();
260
261        if transitions.is_empty() {
262            return Err(DocumentError::NoDocumentsSuppliedError.into());
263        }
264
265        Ok(BatchTransitionV0 {
266            owner_id,
267            transitions,
268            user_fee_increase: 0,
269            signature_public_key_id: 0,
270            signature: Default::default(),
271        }
272        .into())
273    }
274
275    #[cfg(feature = "extended-document")]
276    pub fn create_extended_from_document_buffer(
277        &self,
278        buffer: &[u8],
279        document_type_name: &str,
280        data_contract: &DataContract,
281        platform_version: &PlatformVersion,
282    ) -> Result<ExtendedDocument, ProtocolError> {
283        let document_type = data_contract.document_type_for_name(document_type_name)?;
284
285        let document = Document::from_bytes(buffer, document_type, platform_version)?;
286
287        match platform_version
288            .dpp
289            .document_versions
290            .extended_document_structure_version
291        {
292            0 => Ok(ExtendedDocumentV0 {
293                document_type_name: document_type_name.to_string(),
294                data_contract_id: data_contract.id(),
295                document,
296                data_contract: data_contract.clone(),
297                metadata: None,
298                entropy: Bytes32::default(),
299                token_payment_info: None,
300            }
301            .into()),
302            version => Err(ProtocolError::UnknownVersionMismatch {
303                method: "DocumentFactory::create_extended_from_document_buffer".to_string(),
304                known_versions: vec![0],
305                received: version,
306            }),
307        }
308    }
309    //
310    // pub fn create_from_buffer(
311    //     &self,
312    //     buffer: impl AsRef<[u8]>,
313    // ) -> Result<ExtendedDocument, ProtocolError> {
314    //     let document = <ExtendedDocument as PlatformDeserializable>::deserialize(buffer.as_ref())
315    //         .map_err(|e| {
316    //             ConsensusError::BasicError(BasicError::SerializedObjectParsingError(
317    //                 SerializedObjectParsingError::new(format!("Decode protocol entity: {:#?}", e)),
318    //             ))
319    //         })?;
320    //     self.create_from_object(document.to_value()?).await
321    // }
322    //
323    // pub fn create_from_object(
324    //     &self,
325    //     raw_document: Value,
326    // ) -> Result<ExtendedDocument, ProtocolError> {
327    //     ExtendedDocument::from_untrusted_platform_value(raw_document, data_contract)
328    // }
329    // //
330    // // async fn validate_data_contract_for_extended_document(
331    // //     &self,
332    // //     raw_document: &Value,
333    // //     options: FactoryOptions,
334    // // ) -> Result<DataContract, ProtocolError> {
335    // //     let result = self
336    // //         .data_contract_fetcher_and_validator
337    // //         .validate_extended(raw_document)
338    // //         .await?;
339    // //
340    // //     if !result.is_valid() {
341    // //         return Err(ProtocolError::Document(Box::new(
342    // //             DocumentError::InvalidDocumentError {
343    // //                 errors: result.errors,
344    // //                 raw_document: raw_document.clone(),
345    // //             },
346    // //         )));
347    // //     }
348    // //     let data_contract = result
349    // //         .into_data()
350    // //         .context("Validator didn't return Data Contract. This shouldn't happen")?;
351    // //
352    // //     if !options.skip_validation {
353    // //         let result = self
354    // //             .document_validator
355    // //             .validate_extended(raw_document, &data_contract)?;
356    // //         if !result.is_valid() {
357    // //             return Err(ProtocolError::Document(Box::new(
358    // //                 DocumentError::InvalidDocumentError {
359    // //                     errors: result.errors,
360    // //                     raw_document: raw_document.clone(),
361    // //                 },
362    // //             )));
363    // //         }
364    // //     }
365    // //
366    // //     Ok(data_contract)
367    // // }
368    //
369    #[cfg(feature = "state-transitions")]
370    fn document_create_transitions(
371        documents: Vec<(Document, DocumentTypeRef, Bytes32, Option<TokenPaymentInfo>)>,
372        nonce_counter: &mut BTreeMap<(Identifier, Identifier), u64>, //IdentityID/ContractID -> nonce
373        platform_version: &PlatformVersion,
374    ) -> Result<Vec<DocumentTransition>, ProtocolError> {
375        documents
376            .into_iter()
377            .map(|(document, document_type, entropy, token_payment_info)| {
378                if document_type.documents_mutable() {
379                    //we need to have revisions
380                    let Some(revision) = document.revision() else {
381                        return Err(DocumentError::RevisionAbsentError {
382                            document: Box::new(document),
383                        }
384                        .into());
385                    };
386                    if revision != INITIAL_REVISION {
387                        return Err(DocumentError::InvalidInitialRevisionError {
388                            document: Box::new(document),
389                        }
390                        .into());
391                    }
392                }
393                let nonce = nonce_counter
394                    .entry((document.owner_id(), document_type.data_contract_id()))
395                    .or_default();
396
397                let transition = DocumentCreateTransition::from_document(
398                    document,
399                    document_type,
400                    entropy.to_buffer(),
401                    token_payment_info,
402                    *nonce,
403                    platform_version,
404                    None,
405                    None,
406                )?;
407
408                *nonce += 1;
409
410                Ok(transition.into())
411            })
412            .collect()
413    }
414
415    #[cfg(feature = "state-transitions")]
416    fn document_replace_transitions(
417        documents: Vec<(Document, DocumentTypeRef, Option<TokenPaymentInfo>)>,
418        nonce_counter: &mut BTreeMap<(Identifier, Identifier), u64>, //IdentityID/ContractID -> nonce
419        platform_version: &PlatformVersion,
420    ) -> Result<Vec<DocumentTransition>, ProtocolError> {
421        documents
422            .into_iter()
423            .map(|(mut document, document_type, token_payment_info)| {
424                if !document_type.documents_mutable() {
425                    return Err(DocumentError::TryingToReplaceImmutableDocument {
426                        document: Box::new(document),
427                    }
428                    .into());
429                }
430                if document.revision().is_none() {
431                    return Err(DocumentError::RevisionAbsentError {
432                        document: Box::new(document),
433                    }
434                    .into());
435                };
436
437                document.increment_revision()?;
438                document.set_updated_at(Some(Utc::now().timestamp_millis() as TimestampMillis));
439
440                let nonce = nonce_counter
441                    .entry((document.owner_id(), document_type.data_contract_id()))
442                    .or_default();
443
444                let transition = DocumentReplaceTransition::from_document(
445                    document,
446                    document_type,
447                    token_payment_info,
448                    *nonce,
449                    platform_version,
450                    None,
451                    None,
452                )?;
453
454                *nonce += 1;
455
456                Ok(transition.into())
457            })
458            .collect()
459        // let mut raw_transitions = vec![];
460        // for (document, document_type) in documents {
461        //     if !document_type.documents_mutable() {
462        //         return Err(DocumentError::TryingToReplaceImmutableDocument {
463        //             document: Box::new(document),
464        //         }
465        //         .into());
466        //     }
467        //     let Some(document_revision) = document.revision() else {
468        //         return Err(DocumentError::RevisionAbsentError {
469        //             document: Box::new(document),
470        //         }.into());
471        //     };
472        //     let mut map = document.to_map_value()?;
473        //
474        //     map.retain(|key, _| {
475        //         !key.starts_with('$') || DOCUMENT_REPLACE_KEYS_TO_STAY.contains(&key.as_str())
476        //     });
477        //     map.insert(
478        //         PROPERTY_ACTION.to_string(),
479        //         Value::U8(DocumentTransitionActionType::Replace as u8),
480        //     );
481        //     let new_revision = document_revision + 1;
482        //     map.insert(PROPERTY_REVISION.to_string(), Value::U64(new_revision));
483        //
484        //     // If document have an originally set `updatedAt`
485        //     // we should update it then
486        //     let contains_updated_at = document_type
487        //         .required_fields()
488        //         .contains(PROPERTY_UPDATED_AT);
489        //
490        //     if contains_updated_at {
491        //         let now = Utc::now().timestamp_millis() as TimestampMillis;
492        //         map.insert(PROPERTY_UPDATED_AT.to_string(), Value::U64(now));
493        //     }
494        //
495        //     raw_transitions.push(map.into());
496        // }
497        // Ok(raw_transitions)
498    }
499
500    #[cfg(feature = "state-transitions")]
501    fn document_delete_transitions(
502        documents: Vec<(Document, DocumentTypeRef, Option<TokenPaymentInfo>)>,
503        nonce_counter: &mut BTreeMap<(Identifier, Identifier), u64>, //IdentityID/ContractID -> nonce
504        platform_version: &PlatformVersion,
505    ) -> Result<Vec<DocumentTransition>, ProtocolError> {
506        documents
507            .into_iter()
508            .map(|(document, document_type, token_payment_info)| {
509                if !document_type.documents_can_be_deleted() {
510                    return Err(DocumentError::TryingToDeleteIndelibleDocument {
511                        document: Box::new(document),
512                    }
513                    .into());
514                }
515
516                // Deleting an indexOnly document is a different OPERATION
517                // from deleting a stored one (delete-by-values vs
518                // delete-by-id), so the factory picks the transition kind
519                // from the doctype's storage mode. Index-only documents
520                // carry no revision (there is no stored row to revise), so
521                // the revision gate only applies to the by-id path.
522                if document_type.index_only() {
523                    let nonce = nonce_counter
524                        .entry((document.owner_id(), document_type.data_contract_id()))
525                        .or_default();
526                    let transition = DocumentIndexOnlyDeleteTransition::from_document(
527                        document,
528                        document_type,
529                        token_payment_info,
530                        *nonce,
531                        platform_version,
532                        None,
533                        None,
534                    )?;
535
536                    *nonce += 1;
537
538                    return Ok(transition.into());
539                }
540
541                let Some(_document_revision) = document.revision() else {
542                    return Err(DocumentError::RevisionAbsentError {
543                        document: Box::new(document),
544                    }
545                    .into());
546                };
547
548                let nonce = nonce_counter
549                    .entry((document.owner_id(), document_type.data_contract_id()))
550                    .or_default();
551                let transition = DocumentDeleteTransition::from_document(
552                    document,
553                    document_type,
554                    token_payment_info,
555                    *nonce,
556                    platform_version,
557                    None,
558                    None,
559                )?;
560
561                *nonce += 1;
562
563                Ok(transition.into())
564            })
565            .collect()
566    }
567
568    fn is_ownership_the_same<'a>(ids: impl IntoIterator<Item = &'a Identifier>) -> bool {
569        ids.into_iter().all_equal()
570    }
571}
572
573#[cfg(test)]
574mod test {
575    use data_contracts::SystemDataContract;
576    use platform_value::platform_value;
577    use platform_version::version::PlatformVersion;
578    use std::collections::BTreeMap;
579
580    use crate::data_contract::accessors::v0::DataContractV0Getters;
581    use crate::data_contract::config::DataContractConfig;
582    use crate::data_contract::document_type::DocumentType;
583    use crate::document::document_factory::DocumentFactoryV0;
584    use crate::document::{Document, DocumentV0};
585    use crate::identifier::Identifier;
586    use crate::state_transition::batch_transition::batched_transition::document_index_only_delete_transition::v0::v0_methods::DocumentIndexOnlyDeleteTransitionV0Methods;
587    use crate::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition;
588    use crate::system_data_contracts::load_system_data_contract;
589
590    #[test]
591    /// Create a delete transition in DocumentFactoryV0 for an immutable but deletable document type
592    fn delete_immutable_but_deletable_documents() {
593        // Get a ref to the dpns preorder document type to pass to the factory
594        let dpns_contract =
595            load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()).unwrap();
596        let document_type = dpns_contract
597            .document_type_borrowed_for_name("preorder")
598            .unwrap();
599        let document_type_ref = document_type.as_ref();
600
601        // Create a preorder document
602        let document_id = Identifier::random();
603        let owner_id = Identifier::random();
604        let mut properties = BTreeMap::new();
605        properties.insert(
606            "saltedDomainHash".to_string(),
607            platform_value::Value::Array(vec![]),
608        );
609        let document_v0 = DocumentV0 {
610            contract_version: None,
611            id: document_id,
612            owner_id,
613            properties,
614            revision: Some(1),
615            created_at: None,
616            updated_at: None,
617            transferred_at: None,
618            created_at_block_height: None,
619            updated_at_block_height: None,
620            transferred_at_block_height: None,
621            created_at_core_block_height: None,
622            updated_at_core_block_height: None,
623            transferred_at_core_block_height: None,
624            creator_id: None,
625        };
626        let document = Document::V0(document_v0);
627
628        // This will be passed to the factory
629        let documents = vec![(document, document_type_ref, None)];
630        let mut nonce_counter = BTreeMap::new();
631        let platform_version = PlatformVersion::latest();
632
633        // Try to create the delete transition in the factory
634        let result = DocumentFactoryV0::document_delete_transitions(
635            documents,
636            &mut nonce_counter,
637            platform_version,
638        );
639
640        // Checks
641        assert!(result.is_ok(), "The function should succeed");
642        let transitions = result.unwrap();
643        assert_eq!(transitions.len(), 1, "There should be one transition");
644    }
645
646    #[test]
647    /// The factory picks the delete KIND from the doctype's storage mode:
648    /// an indexOnly type gets a delete-by-values transition carrying the
649    /// document's properties, and no revision is required (synthesized
650    /// index-only documents carry none).
651    fn delete_index_only_documents_produces_index_only_delete_kind() {
652        let platform_version = PlatformVersion::latest();
653        let config = DataContractConfig::default_for_version(platform_version)
654            .expect("default config available");
655
656        let schema = platform_value!({
657            "type": "object",
658            "indexOnly": true,
659            "documentsMutable": false,
660            "canBeDeleted": true,
661            "properties": {
662                "hashtag": {
663                    "type": "string",
664                    "maxLength": 63,
665                    "position": 0
666                },
667                "postId": {
668                    "type": "array",
669                    "byteArray": true,
670                    "minItems": 32,
671                    "maxItems": 32,
672                    "contentMediaType": "application/x.dash.dpp.identifier",
673                    "refersTo": { "type": "identity" },
674                    "position": 1
675                }
676            },
677            "required": ["hashtag", "postId"],
678            "indices": [
679                {
680                    "name": "byPost",
681                    "properties": [{ "postId": "asc" }]
682                },
683                {
684                    "name": "byLiker",
685                    "properties": [{ "$ownerId": "asc" }],
686                    "terminal": "postId"
687                },
688                {
689                    "name": "byHashtagPost",
690                    "properties": [{ "hashtag": "asc" }, { "postId": "asc" }],
691                    "terminal": "$ownerId"
692                }
693            ],
694            "additionalProperties": false
695        });
696
697        let contract_id = Identifier::new([1; 32]);
698        let document_type = DocumentType::try_from_schema(
699            contract_id,
700            1,
701            config.version(),
702            "like",
703            schema,
704            None,
705            &BTreeMap::new(),
706            &config,
707            false,
708            &mut vec![],
709            platform_version,
710        )
711        .expect("the indexOnly like schema parses");
712        let document_type_ref = document_type.as_ref();
713
714        let mut properties = BTreeMap::new();
715        properties.insert(
716            "hashtag".to_string(),
717            platform_value::Value::Text("dash".to_string()),
718        );
719        properties.insert(
720            "postId".to_string(),
721            platform_value::Value::Identifier([2; 32]),
722        );
723        let document = Document::V0(DocumentV0 {
724            contract_version: None,
725            id: Identifier::random(),
726            owner_id: Identifier::random(),
727            properties,
728            // Synthesized index-only documents have no revision — the
729            // by-id path's revision gate must not apply here.
730            revision: None,
731            created_at: None,
732            updated_at: None,
733            transferred_at: None,
734            created_at_block_height: None,
735            updated_at_block_height: None,
736            transferred_at_block_height: None,
737            created_at_core_block_height: None,
738            updated_at_core_block_height: None,
739            transferred_at_core_block_height: None,
740            creator_id: None,
741        });
742
743        let mut nonce_counter = BTreeMap::new();
744        let transitions = DocumentFactoryV0::document_delete_transitions(
745            vec![(document, document_type_ref, None)],
746            &mut nonce_counter,
747            platform_version,
748        )
749        .expect("the delete transition builds");
750
751        assert_eq!(transitions.len(), 1, "There should be one transition");
752        let DocumentTransition::IndexOnlyDelete(transition) = &transitions[0] else {
753            panic!(
754                "an indexOnly doctype must produce an indexOnlyDelete transition, got {}",
755                transitions[0]
756            );
757        };
758        let data = transition.data();
759        assert_eq!(
760            data.get("hashtag"),
761            Some(&platform_value::Value::Text("dash".to_string()))
762        );
763        assert_eq!(
764            data.get("postId"),
765            Some(&platform_value::Value::Identifier([2; 32]))
766        );
767        assert!(
768            !data.contains_key("$createdAt"),
769            "$createdAt must not ride along when the doctype does not require it"
770        );
771    }
772}