dpp/data_contract/methods/validate_update/v0/
mod.rs

1use std::collections::HashSet;
2
3use crate::block::block_info::BlockInfo;
4use crate::consensus::state::state_error::StateError;
5use crate::consensus::state::token::PreProgrammedDistributionTimestampInPastError;
6use crate::data_contract::accessors::v0::DataContractV0Getters;
7
8use crate::consensus::basic::data_contract::{
9    DuplicateKeywordsError, IncompatibleDataContractSchemaError, InvalidDataContractVersionError,
10    InvalidDescriptionLengthError, InvalidKeywordCharacterError, InvalidKeywordLengthError,
11    TooManyKeywordsError,
12};
13use crate::consensus::state::data_contract::data_contract_update_action_not_allowed_error::DataContractUpdateActionNotAllowedError;
14use crate::consensus::state::data_contract::data_contract_update_permission_error::DataContractUpdatePermissionError;
15use crate::consensus::state::data_contract::document_type_update_error::DocumentTypeUpdateError;
16use crate::data_contract::accessors::v1::DataContractV1Getters;
17use crate::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters;
18use crate::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters;
19use crate::data_contract::associated_token::token_pre_programmed_distribution::accessors::v0::TokenPreProgrammedDistributionV0Methods;
20use crate::data_contract::document_type::schema::validate_schema_compatibility;
21use crate::data_contract::schema::DataContractSchemaMethodsV0;
22use crate::data_contract::DataContract;
23use crate::validation::SimpleConsensusValidationResult;
24use crate::ProtocolError;
25use platform_value::Value;
26use platform_version::version::PlatformVersion;
27use serde_json::json;
28
29pub trait DataContractUpdateValidationMethodsV0 {
30    fn validate_update(
31        &self,
32        data_contract: &DataContract,
33        block_info: &BlockInfo,
34        platform_version: &PlatformVersion,
35    ) -> Result<SimpleConsensusValidationResult, ProtocolError>;
36}
37
38impl DataContract {
39    #[inline(always)]
40    pub(super) fn validate_update_v0(
41        &self,
42        new_data_contract: &DataContract,
43        block_info: &BlockInfo,
44        platform_version: &PlatformVersion,
45    ) -> Result<SimpleConsensusValidationResult, ProtocolError> {
46        // Check if the contract is owned by the same identity
47        if self.owner_id() != new_data_contract.owner_id() {
48            return Ok(SimpleConsensusValidationResult::new_with_error(
49                DataContractUpdatePermissionError::new(self.id(), new_data_contract.owner_id())
50                    .into(),
51            ));
52        }
53
54        // Check version is bumped
55        // Failure (version != previous version + 1): Keep ST and transform it to a nonce bump action.
56        // How: A user pushed an update that was not the next version.
57
58        let new_version = new_data_contract.version();
59        let old_version = self.version();
60        if new_version < old_version || new_version - old_version != 1 {
61            return Ok(SimpleConsensusValidationResult::new_with_error(
62                InvalidDataContractVersionError::new(old_version + 1, new_version).into(),
63            ));
64        }
65
66        // Validate that the config was not updated
67        // * Includes verifications that:
68        //     - Old contract is not read_only
69        //     - New contract is not read_only
70        //     - Keeps history did not change
71        //     - Can be deleted did not change
72        //     - Documents keep history did not change
73        //     - Documents can be deleted contract default did not change
74        //     - Documents mutable contract default did not change
75        //     - Requires identity encryption bounded key did not change
76        //     - Requires identity decryption bounded key did not change
77        // * Failure (contract does not exist): Keep ST and transform it to a nonce bump action.
78        // * How: A user pushed an update to a contract that changed its configuration.
79
80        let config_validation_result = self.config().validate_update(
81            new_data_contract.config(),
82            self.id(),
83            platform_version,
84        )?;
85
86        if !config_validation_result.is_valid() {
87            return Ok(SimpleConsensusValidationResult::new_with_errors(
88                config_validation_result.errors,
89            ));
90        }
91
92        // Validate updates for existing document types to make sure that previously created
93        // documents will be still valid with a new version of the data contract
94        for (document_type_name, old_document_type) in self.document_types() {
95            // Make sure that existing document aren't removed
96            let Some(new_document_type) =
97                new_data_contract.document_type_optional_for_name(document_type_name)
98            else {
99                return Ok(SimpleConsensusValidationResult::new_with_error(
100                    DocumentTypeUpdateError::new(
101                        self.id(),
102                        document_type_name,
103                        "document type can't be removed",
104                    )
105                    .into(),
106                ));
107            };
108
109            // Validate document type update rules
110            let validate_update_result = old_document_type
111                .as_ref()
112                .validate_update(new_document_type, platform_version)?;
113
114            if !validate_update_result.is_valid() {
115                return Ok(SimpleConsensusValidationResult::new_with_errors(
116                    validate_update_result.errors,
117                ));
118            }
119        }
120
121        // Schema $defs should be compatible
122        if let Some(old_defs_map) = self.schema_defs() {
123            // If new contract doesn't have $defs, it means that it's $defs was removed and compatibility is broken
124            let Some(new_defs_map) = new_data_contract.schema_defs() else {
125                return Ok(SimpleConsensusValidationResult::new_with_error(
126                    IncompatibleDataContractSchemaError::new(
127                        self.id(),
128                        "remove".to_string(),
129                        "/$defs".to_string(),
130                    )
131                    .into(),
132                ));
133            };
134
135            // If $defs is updated we need to make sure that our data contract is still compatible
136            // with previously created data
137            if old_defs_map != new_defs_map {
138                // both new and old $defs already validated as a part of new and old contract
139                let old_defs_json = Value::from(old_defs_map)
140                    .try_into_validating_json()
141                    .map_err(ProtocolError::ValueError)?;
142
143                let new_defs_json = Value::from(new_defs_map)
144                    .try_into_validating_json()
145                    .map_err(ProtocolError::ValueError)?;
146
147                let old_defs_schema = json!({
148                    "$defs": old_defs_json
149                });
150
151                let new_defs_schema = json!({
152                    "$defs": new_defs_json
153                });
154
155                // We do not allow to remove or modify $ref in document type schemas
156                // it means that compatible changes in $defs won't break the overall compatibility
157                // Make sure that updated $defs schema is compatible
158                let compatibility_validation_result = validate_schema_compatibility(
159                    &old_defs_schema,
160                    &new_defs_schema,
161                    platform_version,
162                )?;
163
164                if !compatibility_validation_result.is_valid() {
165                    let errors = compatibility_validation_result
166                        .errors
167                        .into_iter()
168                        .map(|operation| {
169                            IncompatibleDataContractSchemaError::new(
170                                self.id(),
171                                operation.name,
172                                operation.path,
173                            )
174                            .into()
175                        })
176                        .collect();
177
178                    return Ok(SimpleConsensusValidationResult::new_with_errors(errors));
179                }
180            }
181        }
182
183        if self.groups() != new_data_contract.groups() {
184            // No groups can have been removed
185            for old_group_position in self.groups().keys() {
186                if !new_data_contract.groups().contains_key(old_group_position) {
187                    return Ok(SimpleConsensusValidationResult::new_with_error(
188                        DataContractUpdateActionNotAllowedError::new(
189                            self.id(),
190                            "remove group".to_string(),
191                        )
192                        .into(),
193                    ));
194                }
195            }
196
197            // Ensure no group has been changed
198            for (old_group_position, old_group) in self.groups() {
199                if let Some(new_group) = new_data_contract.groups().get(old_group_position) {
200                    if old_group != new_group {
201                        return Ok(SimpleConsensusValidationResult::new_with_error(
202                            DataContractUpdateActionNotAllowedError::new(
203                                self.id(),
204                                format!(
205                                    "change group at position {} is not allowed",
206                                    old_group_position
207                                ),
208                            )
209                            .into(),
210                        ));
211                    }
212                }
213            }
214        }
215
216        if self.tokens() != new_data_contract.tokens() {
217            for (token_position, old_token_config) in self.tokens() {
218                // Check if a token has been removed
219                if !new_data_contract.tokens().contains_key(token_position) {
220                    return Ok(SimpleConsensusValidationResult::new_with_error(
221                        DataContractUpdateActionNotAllowedError::new(
222                            self.id(),
223                            format!("remove token at position {}", token_position),
224                        )
225                        .into(),
226                    ));
227                }
228
229                // Check if a token configuration has been changed
230                if let Some(new_token_config) = new_data_contract.tokens().get(token_position) {
231                    if old_token_config != new_token_config {
232                        return Ok(SimpleConsensusValidationResult::new_with_error(
233                            DataContractUpdateActionNotAllowedError::new(
234                                self.id(),
235                                format!("update token at position {}", token_position),
236                            )
237                            .into(),
238                        ));
239                    }
240                }
241            }
242
243            // Validate any newly added tokens
244            for (token_contract_position, token_configuration) in new_data_contract.tokens() {
245                if !self.tokens().contains_key(token_contract_position) {
246                    if let Some(distribution) = token_configuration
247                        .distribution_rules()
248                        .pre_programmed_distribution()
249                    {
250                        if let Some((timestamp, _)) = distribution.distributions().iter().next() {
251                            if timestamp < &block_info.time_ms {
252                                return Ok(SimpleConsensusValidationResult::new_with_error(
253                                    StateError::PreProgrammedDistributionTimestampInPastError(
254                                        PreProgrammedDistributionTimestampInPastError::new(
255                                            new_data_contract.id(),
256                                            *token_contract_position,
257                                            *timestamp,
258                                            block_info.time_ms,
259                                        ),
260                                    )
261                                    .into(),
262                                ));
263                            }
264                        }
265                    }
266                }
267            }
268        }
269
270        if self.keywords() != new_data_contract.keywords() {
271            // Validate there are no more than 50 keywords
272            if new_data_contract.keywords().len() > 50 {
273                return Ok(SimpleConsensusValidationResult::new_with_error(
274                    TooManyKeywordsError::new(self.id(), new_data_contract.keywords().len() as u8)
275                        .into(),
276                ));
277            }
278
279            // Validate the keywords are all unique and between 3 and 50 characters
280            let mut seen_keywords = HashSet::new();
281            for keyword in new_data_contract.keywords() {
282                // First check keyword length
283                if keyword.len() < 3 || keyword.len() > 50 {
284                    return Ok(SimpleConsensusValidationResult::new_with_error(
285                        InvalidKeywordLengthError::new(self.id(), keyword.to_string()).into(),
286                    ));
287                }
288
289                if !keyword
290                    .chars()
291                    .all(|c| !c.is_control() && !c.is_whitespace())
292                {
293                    // This would mean we have an invalid character
294                    return Ok(SimpleConsensusValidationResult::new_with_error(
295                        InvalidKeywordCharacterError::new(
296                            new_data_contract.id(),
297                            keyword.to_string(),
298                        )
299                        .into(),
300                    ));
301                }
302
303                // Then check uniqueness
304                if !seen_keywords.insert(keyword) {
305                    return Ok(SimpleConsensusValidationResult::new_with_error(
306                        DuplicateKeywordsError::new(self.id(), keyword.to_string()).into(),
307                    ));
308                }
309            }
310        }
311
312        if self.description() != new_data_contract.description() {
313            // Validate the description is between 3 and 100 characters
314            if let Some(description) = new_data_contract.description() {
315                let char_count = description.chars().count();
316                if !(3..=100).contains(&char_count) {
317                    return Ok(SimpleConsensusValidationResult::new_with_error(
318                        InvalidDescriptionLengthError::new(self.id(), description.to_string())
319                            .into(),
320                    ));
321                }
322            }
323        }
324
325        Ok(SimpleConsensusValidationResult::new())
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::consensus::basic::basic_error::BasicError;
333    use crate::consensus::state::state_error::StateError;
334    use crate::consensus::ConsensusError;
335    use crate::data_contract::config::v0::DataContractConfigSettersV0;
336    use crate::prelude::IdentityNonce;
337    use crate::tests::fixtures::get_data_contract_fixture;
338    use assert_matches::assert_matches;
339    use platform_value::platform_value;
340    use platform_value::Identifier;
341
342    mod validate_update {
343        use std::collections::BTreeMap;
344
345        use super::*;
346        use crate::data_contract::accessors::v0::DataContractV0Setters;
347        use crate::data_contract::accessors::v1::DataContractV1Setters;
348        use crate::data_contract::associated_token::token_configuration::accessors::v0::{
349            TokenConfigurationV0Getters, TokenConfigurationV0Setters,
350        };
351        use crate::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0;
352        use crate::data_contract::associated_token::token_configuration_convention::v0::TokenConfigurationConventionV0;
353        use crate::data_contract::associated_token::token_configuration_convention::TokenConfigurationConvention;
354        use crate::data_contract::associated_token::token_configuration_localization::v0::TokenConfigurationLocalizationV0;
355        use crate::data_contract::associated_token::token_configuration_localization::TokenConfigurationLocalization;
356        use crate::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Setters;
357        use crate::data_contract::associated_token::token_pre_programmed_distribution::v0::TokenPreProgrammedDistributionV0;
358        use crate::data_contract::associated_token::token_pre_programmed_distribution::TokenPreProgrammedDistribution;
359        use crate::data_contract::document_type::DocumentTypeMutRef;
360        use crate::data_contract::group::accessors::v0::{GroupV0Getters, GroupV0Setters};
361        use crate::data_contract::group::v0::GroupV0;
362        use crate::data_contract::group::Group;
363        use crate::data_contract::TokenConfiguration;
364        use crate::identity::accessors::IdentityGettersV0;
365        use crate::prelude::Identity;
366
367        #[test]
368        fn should_return_invalid_result_if_owner_id_is_not_the_same() {
369            let platform_version = PlatformVersion::latest();
370
371            let old_data_contract = get_data_contract_fixture(
372                None,
373                IdentityNonce::default(),
374                platform_version.protocol_version,
375            )
376            .data_contract_owned();
377
378            let mut new_data_contract = old_data_contract.clone();
379
380            new_data_contract.set_owner_id(Identifier::random());
381
382            let result = old_data_contract
383                .validate_update(&new_data_contract, &BlockInfo::default(), platform_version)
384                .expect("failed validate update");
385
386            assert_matches!(
387                result.errors.as_slice(),
388                [ConsensusError::StateError(
389                    StateError::DataContractUpdatePermissionError(e)
390                )] if *e.data_contract_id() == old_data_contract.id() && *e.identity_id() == new_data_contract.owner_id()
391            );
392        }
393
394        #[test]
395        fn should_return_invalid_result_if_contract_version_is_not_greater_for_one() {
396            let platform_version = PlatformVersion::latest();
397
398            let old_data_contract = get_data_contract_fixture(
399                None,
400                IdentityNonce::default(),
401                platform_version.protocol_version,
402            )
403            .data_contract_owned();
404
405            let new_data_contract = old_data_contract.clone();
406
407            let result = old_data_contract
408                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
409                .expect("failed validate update");
410
411            assert_matches!(
412                result.errors.as_slice(),
413                [ConsensusError::BasicError(
414                    BasicError::InvalidDataContractVersionError(e)
415                )] if e.expected_version() == old_data_contract.version() + 1 && e.version() == new_data_contract.version()
416            );
417        }
418
419        #[test]
420        fn should_return_invalid_result_if_config_was_updated() {
421            let platform_version = PlatformVersion::latest();
422
423            let old_data_contract = get_data_contract_fixture(
424                None,
425                IdentityNonce::default(),
426                platform_version.protocol_version,
427            )
428            .data_contract_owned();
429
430            let mut new_data_contract = old_data_contract.clone();
431
432            new_data_contract.set_version(old_data_contract.version() + 1);
433            new_data_contract.config_mut().set_readonly(true);
434
435            let result = old_data_contract
436                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
437                .expect("failed validate update");
438
439            assert_matches!(
440                result.errors.as_slice(),
441                [ConsensusError::StateError(
442                    StateError::DataContractConfigUpdateError(e)
443                )] if e.additional_message() == "contract can not be changed to readonly"
444            );
445        }
446
447        #[test]
448        fn should_return_invalid_result_when_document_type_is_removed() {
449            let platform_version = PlatformVersion::latest();
450
451            let old_data_contract = get_data_contract_fixture(
452                None,
453                IdentityNonce::default(),
454                platform_version.protocol_version,
455            )
456            .data_contract_owned();
457
458            let mut new_data_contract = old_data_contract.clone();
459
460            new_data_contract.set_version(old_data_contract.version() + 1);
461            new_data_contract
462                .document_types_mut()
463                .remove("niceDocument");
464
465            let result = old_data_contract
466                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
467                .expect("failed validate update");
468
469            assert_matches!(
470                result.errors.as_slice(),
471                [ConsensusError::StateError(
472                    StateError::DocumentTypeUpdateError(e)
473                )] if e.additional_message() == "document type can't be removed"
474            );
475        }
476
477        #[test]
478        fn should_return_invalid_result_when_document_type_has_incompatible_change() {
479            let platform_version = PlatformVersion::latest();
480
481            let old_data_contract = get_data_contract_fixture(
482                None,
483                IdentityNonce::default(),
484                platform_version.protocol_version,
485            )
486            .data_contract_owned();
487
488            let mut new_data_contract = old_data_contract.clone();
489
490            new_data_contract.set_version(old_data_contract.version() + 1);
491
492            match new_data_contract
493                .document_types_mut()
494                .get_mut("niceDocument")
495                .unwrap()
496                .as_mut_ref()
497            {
498                DocumentTypeMutRef::V0(dt) => dt.documents_mutable = false,
499                DocumentTypeMutRef::V1(dt) => dt.documents_mutable = false,
500            }
501
502            let result = old_data_contract
503                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
504                .expect("failed validate update");
505
506            assert_matches!(
507                result.errors.as_slice(),
508                [ConsensusError::StateError(
509                    StateError::DocumentTypeUpdateError(e)
510                )] if e.additional_message() == "document type can not change whether its documents are mutable: changing from true to false"
511            );
512        }
513
514        #[test]
515        fn should_return_invalid_result_when_defs_is_removed() {
516            let platform_version = PlatformVersion::latest();
517
518            let mut old_data_contract = get_data_contract_fixture(
519                None,
520                IdentityNonce::default(),
521                platform_version.protocol_version,
522            )
523            .data_contract_owned();
524
525            // Remove document that uses $defs, so we can safely remove it for testing
526            old_data_contract
527                .document_types_mut()
528                .remove("prettyDocument");
529
530            let mut new_data_contract = old_data_contract.clone();
531
532            new_data_contract.set_version(old_data_contract.version() + 1);
533            new_data_contract
534                .set_schema_defs(None, false, &mut Vec::new(), platform_version)
535                .expect("failed to set schema defs");
536
537            let result = old_data_contract
538                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
539                .expect("failed validate update");
540
541            assert_matches!(
542                result.errors.as_slice(),
543                [ConsensusError::BasicError(
544                    BasicError::IncompatibleDataContractSchemaError(e)
545                )] if e.operation() == "remove" && e.field_path() == "/$defs"
546            );
547        }
548
549        #[test]
550        fn should_return_invalid_result_when_updated_defs_is_incompatible() {
551            let platform_version = PlatformVersion::latest();
552
553            let old_data_contract = get_data_contract_fixture(
554                None,
555                IdentityNonce::default(),
556                platform_version.protocol_version,
557            )
558            .data_contract_owned();
559
560            let mut new_data_contract = old_data_contract.clone();
561
562            let incompatible_defs_value = platform_value!({
563                "lastName": {
564                    "type": "number",
565                },
566            });
567            let incompatible_defs = incompatible_defs_value
568                .into_btree_string_map()
569                .expect("should convert to map");
570
571            new_data_contract.set_version(old_data_contract.version() + 1);
572            new_data_contract
573                .set_schema_defs(
574                    Some(incompatible_defs),
575                    false,
576                    &mut Vec::new(),
577                    platform_version,
578                )
579                .expect("failed to set schema defs");
580
581            let result = old_data_contract
582                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
583                .expect("failed validate update");
584
585            assert_matches!(
586                result.errors.as_slice(),
587                [ConsensusError::BasicError(
588                    BasicError::IncompatibleDataContractSchemaError(e)
589                )] if e.operation() == "replace" && e.field_path() == "/$defs/lastName/type"
590            );
591        }
592
593        #[test]
594        fn should_pass_when_all_changes_are_compatible() {
595            let platform_version = PlatformVersion::latest();
596
597            let old_data_contract = get_data_contract_fixture(
598                None,
599                IdentityNonce::default(),
600                platform_version.protocol_version,
601            )
602            .data_contract_owned();
603
604            let mut new_data_contract = old_data_contract.clone();
605
606            new_data_contract.set_version(old_data_contract.version() + 1);
607
608            let result = old_data_contract
609                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
610                .expect("failed validate update");
611
612            assert!(result.is_valid());
613        }
614
615        //
616        // ──────────────────────────────────────────────────────────────────────────
617        //  Group‑related rules
618        // ──────────────────────────────────────────────────────────────────────────
619        //
620
621        #[test]
622        fn should_return_invalid_result_when_group_is_removed() {
623            let platform_version = PlatformVersion::latest();
624
625            let identity_1 = Identity::random_identity(3, Some(14), platform_version)
626                .expect("expected a platform identity");
627            let identity_1_id = identity_1.id();
628            let identity_2 = Identity::random_identity(3, Some(506), platform_version)
629                .expect("expected a platform identity");
630            let identity_2_id = identity_2.id();
631
632            let mut old_data_contract =
633                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
634            old_data_contract.set_groups(BTreeMap::from([(
635                0,
636                Group::V0(GroupV0 {
637                    members: [(identity_1_id, 1), (identity_2_id, 1)].into(),
638                    required_power: 2,
639                }),
640            )]));
641
642            // Clone & bump version
643            let mut new_data_contract = old_data_contract.clone();
644            new_data_contract.set_version(old_data_contract.version() + 1);
645
646            // Remove the first (and normally only) group
647            let first_group_pos = *old_data_contract
648                .groups()
649                .keys()
650                .next()
651                .expect("fixture must have at least one group");
652            new_data_contract
653                .groups_mut()
654                .unwrap()
655                .remove(&first_group_pos);
656
657            let result = old_data_contract
658                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
659                .expect("failed validate update");
660
661            assert_matches!(
662                result.errors.as_slice(),
663                [ConsensusError::StateError(
664                    StateError::DataContractUpdateActionNotAllowedError(e)
665                )] if e.action() == "remove group"
666            );
667        }
668
669        #[test]
670        fn should_return_invalid_result_when_group_is_changed() {
671            let platform_version = PlatformVersion::latest();
672
673            let identity_1 = Identity::random_identity(3, Some(14), platform_version)
674                .expect("expected a platform identity");
675            let identity_1_id = identity_1.id();
676            let identity_2 = Identity::random_identity(3, Some(506), platform_version)
677                .expect("expected a platform identity");
678            let identity_2_id = identity_2.id();
679
680            let mut old_data_contract =
681                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
682            old_data_contract.set_groups(BTreeMap::from([(
683                0,
684                Group::V0(GroupV0 {
685                    members: [(identity_1_id, 1), (identity_2_id, 1)].into(),
686                    required_power: 2,
687                }),
688            )]));
689
690            // Clone & bump version
691            let mut new_data_contract = old_data_contract.clone();
692            new_data_contract.set_version(old_data_contract.version() + 1);
693
694            // Mutate the first group in some trivial way so that
695            // `old_group != new_group` evaluates to true.
696            let first_group_pos = *new_data_contract
697                .groups()
698                .keys()
699                .next()
700                .expect("fixture must have at least one group");
701            let mut altered_group = new_data_contract
702                .groups()
703                .get(&first_group_pos)
704                .cloned()
705                .expect("group must exist");
706            // Tweak required power
707            altered_group.set_required_power(altered_group.required_power() + 1);
708            new_data_contract
709                .groups_mut()
710                .unwrap()
711                .insert(first_group_pos, altered_group);
712
713            let result = old_data_contract
714                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
715                .expect("failed validate update");
716
717            assert_matches!(
718                result.errors.as_slice(),
719                [ConsensusError::StateError(
720                    StateError::DataContractUpdateActionNotAllowedError(e)
721                )] if e.action() == format!(
722                        "change group at position {} is not allowed",
723                        first_group_pos
724                    )
725            );
726        }
727
728        //
729        // ──────────────────────────────────────────────────────────────────────────
730        //  Token‑related rules
731        // ──────────────────────────────────────────────────────────────────────────
732        //
733
734        #[test]
735        fn should_return_invalid_result_when_token_is_removed() {
736            let platform_version = PlatformVersion::latest();
737
738            let mut old_data_contract =
739                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
740            old_data_contract.set_tokens(BTreeMap::from([(
741                0,
742                TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()),
743            )]));
744
745            let mut new_data_contract = old_data_contract.clone();
746            new_data_contract.set_version(old_data_contract.version() + 1);
747
748            // Remove an existing token
749            let first_token_pos = *old_data_contract
750                .tokens()
751                .keys()
752                .next()
753                .expect("fixture must have at least one token");
754            new_data_contract
755                .tokens_mut()
756                .unwrap()
757                .remove(&first_token_pos);
758
759            let result = old_data_contract
760                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
761                .expect("failed validate update");
762
763            assert_matches!(
764                result.errors.as_slice(),
765                [ConsensusError::StateError(
766                    StateError::DataContractUpdateActionNotAllowedError(e)
767                )] if e.action() == format!("remove token at position {}", first_token_pos)
768            );
769        }
770
771        #[test]
772        fn should_return_invalid_result_when_token_is_updated() {
773            let platform_version = PlatformVersion::latest();
774
775            let mut old_data_contract =
776                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
777            old_data_contract.set_tokens(BTreeMap::from([(
778                0,
779                TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()),
780            )]));
781
782            let mut new_data_contract = old_data_contract.clone();
783            new_data_contract.set_version(old_data_contract.version() + 1);
784
785            // Modify an existing token configuration
786            let first_token_pos = *new_data_contract
787                .tokens()
788                .keys()
789                .next()
790                .expect("fixture must have at least one token");
791            let mut altered_token_cfg = new_data_contract
792                .tokens()
793                .get(&first_token_pos)
794                .cloned()
795                .expect("token must exist");
796            // Tweak base supply
797            altered_token_cfg.set_base_supply(altered_token_cfg.base_supply() + 1);
798            new_data_contract
799                .tokens_mut()
800                .unwrap()
801                .insert(first_token_pos, altered_token_cfg);
802
803            let result = old_data_contract
804                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
805                .expect("failed validate update");
806
807            assert_matches!(
808                result.errors.as_slice(),
809                [ConsensusError::StateError(
810                    StateError::DataContractUpdateActionNotAllowedError(e)
811                )] if e.action() == format!("update token at position {}", first_token_pos)
812            );
813        }
814
815        #[test]
816        fn should_return_invalid_result_when_token_is_added_with_past_timestamp() {
817            let platform_version = PlatformVersion::latest();
818
819            let mut old_data_contract =
820                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
821            let mut token_cfg =
822                TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive());
823            token_cfg.set_conventions(TokenConfigurationConvention::V0(
824                TokenConfigurationConventionV0 {
825                    localizations: BTreeMap::from([(
826                        "en".to_string(),
827                        TokenConfigurationLocalization::V0(TokenConfigurationLocalizationV0 {
828                            should_capitalize: false,
829                            singular_form: "test".to_string(),
830                            plural_form: "tests".to_string(),
831                        }),
832                    )]),
833                    decimals: 8,
834                },
835            ));
836            old_data_contract.set_tokens(BTreeMap::from([(0, token_cfg)]));
837
838            let mut new_data_contract = old_data_contract.clone();
839            new_data_contract.set_version(old_data_contract.version() + 1);
840
841            // Create a new token with a past timestamp
842            let existing_cfg = new_data_contract
843                .tokens()
844                .values()
845                .next()
846                .expect("fixture must have at least one token")
847                .clone();
848            let new_position = old_data_contract
849                .tokens()
850                .keys()
851                .max()
852                .expect("fixture must have at least one token")
853                + 1;
854            let mut new_token_cfg = existing_cfg.clone();
855            new_token_cfg
856                .distribution_rules_mut()
857                .set_pre_programmed_distribution(Some(TokenPreProgrammedDistribution::V0(
858                    TokenPreProgrammedDistributionV0 {
859                        distributions: BTreeMap::from([(
860                            0,
861                            BTreeMap::from([(new_data_contract.owner_id(), 100)]),
862                        )]),
863                    },
864                )));
865            new_data_contract
866                .tokens_mut()
867                .unwrap()
868                .insert(new_position, new_token_cfg);
869
870            let result = old_data_contract
871                .validate_update_v0(
872                    &new_data_contract,
873                    &BlockInfo::default_with_time(100000),
874                    platform_version,
875                )
876                .expect("failed validate update");
877
878            assert_matches!(
879                result.errors.as_slice(),
880                [ConsensusError::StateError(
881                    StateError::PreProgrammedDistributionTimestampInPastError(e)
882                )] if e.token_position() == new_position
883            );
884        }
885
886        #[test]
887        fn should_pass_when_a_well_formed_new_token_is_added() {
888            let platform_version = PlatformVersion::latest();
889
890            let old_data_contract =
891                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
892
893            let mut new_data_contract = old_data_contract.clone();
894            new_data_contract.set_version(old_data_contract.version() + 1);
895
896            // build a fully valid token configuration
897            let valid_token_cfg = {
898                let mut cfg =
899                    TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive());
900
901                cfg.set_base_supply(1_000_000); // within limits
902
903                cfg.set_conventions(TokenConfigurationConvention::V0(
904                    TokenConfigurationConventionV0 {
905                        localizations: BTreeMap::from([(
906                            "en".to_string(),
907                            TokenConfigurationLocalization::V0(TokenConfigurationLocalizationV0 {
908                                should_capitalize: true,
909                                singular_form: "credit".to_string(),
910                                plural_form: "credits".to_string(),
911                            }),
912                        )]),
913                        decimals: 8,
914                    },
915                ));
916
917                cfg
918            };
919
920            // insert at contiguous position 0 (old contract had no tokens)
921            new_data_contract
922                .tokens_mut()
923                .unwrap()
924                .insert(0, valid_token_cfg);
925
926            let result = old_data_contract
927                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
928                .expect("failed validate update");
929
930            assert!(result.is_valid(), "well‑formed token should be accepted");
931        }
932
933        //
934        // ──────────────────────────────────────────────────────────────────────────
935        //  Happy‑path check: no token / group changes
936        // ──────────────────────────────────────────────────────────────────────────
937        //
938
939        #[test]
940        fn should_pass_when_groups_and_tokens_unchanged() {
941            let platform_version = PlatformVersion::latest();
942
943            let old_data_contract =
944                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
945
946            let mut new_data_contract = old_data_contract.clone();
947            new_data_contract.set_version(old_data_contract.version() + 1);
948
949            let result = old_data_contract
950                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
951                .expect("failed validate update");
952
953            assert!(result.is_valid());
954        }
955    }
956}