Skip to main content

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                DocumentTypeMutRef::V2(dt) => dt.documents_mutable = false,
501            }
502
503            let result = old_data_contract
504                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
505                .expect("failed validate update");
506
507            assert_matches!(
508                result.errors.as_slice(),
509                [ConsensusError::StateError(
510                    StateError::DocumentTypeUpdateError(e)
511                )] if e.additional_message() == "document type can not change whether its documents are mutable: changing from true to false"
512            );
513        }
514
515        #[test]
516        fn should_return_invalid_result_when_defs_is_removed() {
517            let platform_version = PlatformVersion::latest();
518
519            let mut old_data_contract = get_data_contract_fixture(
520                None,
521                IdentityNonce::default(),
522                platform_version.protocol_version,
523            )
524            .data_contract_owned();
525
526            // Remove document that uses $defs, so we can safely remove it for testing
527            old_data_contract
528                .document_types_mut()
529                .remove("prettyDocument");
530
531            let mut new_data_contract = old_data_contract.clone();
532
533            new_data_contract.set_version(old_data_contract.version() + 1);
534            new_data_contract
535                .set_schema_defs(None, false, &mut Vec::new(), platform_version)
536                .expect("failed to set schema defs");
537
538            let result = old_data_contract
539                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
540                .expect("failed validate update");
541
542            assert_matches!(
543                result.errors.as_slice(),
544                [ConsensusError::BasicError(
545                    BasicError::IncompatibleDataContractSchemaError(e)
546                )] if e.operation() == "remove" && e.field_path() == "/$defs"
547            );
548        }
549
550        #[test]
551        fn should_return_invalid_result_when_updated_defs_is_incompatible() {
552            let platform_version = PlatformVersion::latest();
553
554            let old_data_contract = get_data_contract_fixture(
555                None,
556                IdentityNonce::default(),
557                platform_version.protocol_version,
558            )
559            .data_contract_owned();
560
561            let mut new_data_contract = old_data_contract.clone();
562
563            let incompatible_defs_value = platform_value!({
564                "lastName": {
565                    "type": "number",
566                },
567            });
568            let incompatible_defs = incompatible_defs_value
569                .into_btree_string_map()
570                .expect("should convert to map");
571
572            new_data_contract.set_version(old_data_contract.version() + 1);
573            new_data_contract
574                .set_schema_defs(
575                    Some(incompatible_defs),
576                    false,
577                    &mut Vec::new(),
578                    platform_version,
579                )
580                .expect("failed to set schema defs");
581
582            let result = old_data_contract
583                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
584                .expect("failed validate update");
585
586            assert_matches!(
587                result.errors.as_slice(),
588                [ConsensusError::BasicError(
589                    BasicError::IncompatibleDataContractSchemaError(e)
590                )] if e.operation() == "replace" && e.field_path() == "/$defs/lastName/type"
591            );
592        }
593
594        #[test]
595        fn should_pass_when_all_changes_are_compatible() {
596            let platform_version = PlatformVersion::latest();
597
598            let old_data_contract = get_data_contract_fixture(
599                None,
600                IdentityNonce::default(),
601                platform_version.protocol_version,
602            )
603            .data_contract_owned();
604
605            let mut new_data_contract = old_data_contract.clone();
606
607            new_data_contract.set_version(old_data_contract.version() + 1);
608
609            let result = old_data_contract
610                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
611                .expect("failed validate update");
612
613            assert!(result.is_valid());
614        }
615
616        //
617        // ──────────────────────────────────────────────────────────────────────────
618        //  Group‑related rules
619        // ──────────────────────────────────────────────────────────────────────────
620        //
621
622        #[test]
623        fn should_return_invalid_result_when_group_is_removed() {
624            let platform_version = PlatformVersion::latest();
625
626            let identity_1 = Identity::random_identity(3, Some(14), platform_version)
627                .expect("expected a platform identity");
628            let identity_1_id = identity_1.id();
629            let identity_2 = Identity::random_identity(3, Some(506), platform_version)
630                .expect("expected a platform identity");
631            let identity_2_id = identity_2.id();
632
633            let mut old_data_contract =
634                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
635            old_data_contract.set_groups(BTreeMap::from([(
636                0,
637                Group::V0(GroupV0 {
638                    members: [(identity_1_id, 1), (identity_2_id, 1)].into(),
639                    required_power: 2,
640                }),
641            )]));
642
643            // Clone & bump version
644            let mut new_data_contract = old_data_contract.clone();
645            new_data_contract.set_version(old_data_contract.version() + 1);
646
647            // Remove the first (and normally only) group
648            let first_group_pos = *old_data_contract
649                .groups()
650                .keys()
651                .next()
652                .expect("fixture must have at least one group");
653            new_data_contract
654                .groups_mut()
655                .unwrap()
656                .remove(&first_group_pos);
657
658            let result = old_data_contract
659                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
660                .expect("failed validate update");
661
662            assert_matches!(
663                result.errors.as_slice(),
664                [ConsensusError::StateError(
665                    StateError::DataContractUpdateActionNotAllowedError(e)
666                )] if e.action() == "remove group"
667            );
668        }
669
670        #[test]
671        fn should_return_invalid_result_when_group_is_changed() {
672            let platform_version = PlatformVersion::latest();
673
674            let identity_1 = Identity::random_identity(3, Some(14), platform_version)
675                .expect("expected a platform identity");
676            let identity_1_id = identity_1.id();
677            let identity_2 = Identity::random_identity(3, Some(506), platform_version)
678                .expect("expected a platform identity");
679            let identity_2_id = identity_2.id();
680
681            let mut old_data_contract =
682                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
683            old_data_contract.set_groups(BTreeMap::from([(
684                0,
685                Group::V0(GroupV0 {
686                    members: [(identity_1_id, 1), (identity_2_id, 1)].into(),
687                    required_power: 2,
688                }),
689            )]));
690
691            // Clone & bump version
692            let mut new_data_contract = old_data_contract.clone();
693            new_data_contract.set_version(old_data_contract.version() + 1);
694
695            // Mutate the first group in some trivial way so that
696            // `old_group != new_group` evaluates to true.
697            let first_group_pos = *new_data_contract
698                .groups()
699                .keys()
700                .next()
701                .expect("fixture must have at least one group");
702            let mut altered_group = new_data_contract
703                .groups()
704                .get(&first_group_pos)
705                .cloned()
706                .expect("group must exist");
707            // Tweak required power
708            altered_group.set_required_power(altered_group.required_power() + 1);
709            new_data_contract
710                .groups_mut()
711                .unwrap()
712                .insert(first_group_pos, altered_group);
713
714            let result = old_data_contract
715                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
716                .expect("failed validate update");
717
718            assert_matches!(
719                result.errors.as_slice(),
720                [ConsensusError::StateError(
721                    StateError::DataContractUpdateActionNotAllowedError(e)
722                )] if e.action() == format!(
723                        "change group at position {} is not allowed",
724                        first_group_pos
725                    )
726            );
727        }
728
729        //
730        // ──────────────────────────────────────────────────────────────────────────
731        //  Token‑related rules
732        // ──────────────────────────────────────────────────────────────────────────
733        //
734
735        #[test]
736        fn should_return_invalid_result_when_token_is_removed() {
737            let platform_version = PlatformVersion::latest();
738
739            let mut old_data_contract =
740                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
741            old_data_contract.set_tokens(BTreeMap::from([(
742                0,
743                TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()),
744            )]));
745
746            let mut new_data_contract = old_data_contract.clone();
747            new_data_contract.set_version(old_data_contract.version() + 1);
748
749            // Remove an existing token
750            let first_token_pos = *old_data_contract
751                .tokens()
752                .keys()
753                .next()
754                .expect("fixture must have at least one token");
755            new_data_contract
756                .tokens_mut()
757                .unwrap()
758                .remove(&first_token_pos);
759
760            let result = old_data_contract
761                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
762                .expect("failed validate update");
763
764            assert_matches!(
765                result.errors.as_slice(),
766                [ConsensusError::StateError(
767                    StateError::DataContractUpdateActionNotAllowedError(e)
768                )] if e.action() == format!("remove token at position {}", first_token_pos)
769            );
770        }
771
772        #[test]
773        fn should_return_invalid_result_when_token_is_updated() {
774            let platform_version = PlatformVersion::latest();
775
776            let mut old_data_contract =
777                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
778            old_data_contract.set_tokens(BTreeMap::from([(
779                0,
780                TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()),
781            )]));
782
783            let mut new_data_contract = old_data_contract.clone();
784            new_data_contract.set_version(old_data_contract.version() + 1);
785
786            // Modify an existing token configuration
787            let first_token_pos = *new_data_contract
788                .tokens()
789                .keys()
790                .next()
791                .expect("fixture must have at least one token");
792            let mut altered_token_cfg = new_data_contract
793                .tokens()
794                .get(&first_token_pos)
795                .cloned()
796                .expect("token must exist");
797            // Tweak base supply
798            altered_token_cfg.set_base_supply(altered_token_cfg.base_supply() + 1);
799            new_data_contract
800                .tokens_mut()
801                .unwrap()
802                .insert(first_token_pos, altered_token_cfg);
803
804            let result = old_data_contract
805                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
806                .expect("failed validate update");
807
808            assert_matches!(
809                result.errors.as_slice(),
810                [ConsensusError::StateError(
811                    StateError::DataContractUpdateActionNotAllowedError(e)
812                )] if e.action() == format!("update token at position {}", first_token_pos)
813            );
814        }
815
816        #[test]
817        fn should_return_invalid_result_when_token_is_added_with_past_timestamp() {
818            let platform_version = PlatformVersion::latest();
819
820            let mut old_data_contract =
821                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
822            let mut token_cfg =
823                TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive());
824            token_cfg.set_conventions(TokenConfigurationConvention::V0(
825                TokenConfigurationConventionV0 {
826                    localizations: BTreeMap::from([(
827                        "en".to_string(),
828                        TokenConfigurationLocalization::V0(TokenConfigurationLocalizationV0 {
829                            should_capitalize: false,
830                            singular_form: "test".to_string(),
831                            plural_form: "tests".to_string(),
832                        }),
833                    )]),
834                    decimals: 8,
835                },
836            ));
837            old_data_contract.set_tokens(BTreeMap::from([(0, token_cfg)]));
838
839            let mut new_data_contract = old_data_contract.clone();
840            new_data_contract.set_version(old_data_contract.version() + 1);
841
842            // Create a new token with a past timestamp
843            let existing_cfg = new_data_contract
844                .tokens()
845                .values()
846                .next()
847                .expect("fixture must have at least one token")
848                .clone();
849            let new_position = old_data_contract
850                .tokens()
851                .keys()
852                .max()
853                .expect("fixture must have at least one token")
854                + 1;
855            let mut new_token_cfg = existing_cfg.clone();
856            new_token_cfg
857                .distribution_rules_mut()
858                .set_pre_programmed_distribution(Some(TokenPreProgrammedDistribution::V0(
859                    TokenPreProgrammedDistributionV0 {
860                        distributions: BTreeMap::from([(
861                            0,
862                            BTreeMap::from([(new_data_contract.owner_id(), 100)]),
863                        )]),
864                    },
865                )));
866            new_data_contract
867                .tokens_mut()
868                .unwrap()
869                .insert(new_position, new_token_cfg);
870
871            let result = old_data_contract
872                .validate_update_v0(
873                    &new_data_contract,
874                    &BlockInfo::default_with_time(100000),
875                    platform_version,
876                )
877                .expect("failed validate update");
878
879            assert_matches!(
880                result.errors.as_slice(),
881                [ConsensusError::StateError(
882                    StateError::PreProgrammedDistributionTimestampInPastError(e)
883                )] if e.token_position() == new_position
884            );
885        }
886
887        #[test]
888        fn should_pass_when_a_well_formed_new_token_is_added() {
889            let platform_version = PlatformVersion::latest();
890
891            let old_data_contract =
892                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
893
894            let mut new_data_contract = old_data_contract.clone();
895            new_data_contract.set_version(old_data_contract.version() + 1);
896
897            // build a fully valid token configuration
898            let valid_token_cfg = {
899                let mut cfg =
900                    TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive());
901
902                cfg.set_base_supply(1_000_000); // within limits
903
904                cfg.set_conventions(TokenConfigurationConvention::V0(
905                    TokenConfigurationConventionV0 {
906                        localizations: BTreeMap::from([(
907                            "en".to_string(),
908                            TokenConfigurationLocalization::V0(TokenConfigurationLocalizationV0 {
909                                should_capitalize: true,
910                                singular_form: "credit".to_string(),
911                                plural_form: "credits".to_string(),
912                            }),
913                        )]),
914                        decimals: 8,
915                    },
916                ));
917
918                cfg
919            };
920
921            // insert at contiguous position 0 (old contract had no tokens)
922            new_data_contract
923                .tokens_mut()
924                .unwrap()
925                .insert(0, valid_token_cfg);
926
927            let result = old_data_contract
928                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
929                .expect("failed validate update");
930
931            assert!(result.is_valid(), "well‑formed token should be accepted");
932        }
933
934        //
935        // ──────────────────────────────────────────────────────────────────────────
936        //  Happy‑path check: no token / group changes
937        // ──────────────────────────────────────────────────────────────────────────
938        //
939
940        #[test]
941        fn should_pass_when_groups_and_tokens_unchanged() {
942            let platform_version = PlatformVersion::latest();
943
944            let old_data_contract =
945                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
946
947            let mut new_data_contract = old_data_contract.clone();
948            new_data_contract.set_version(old_data_contract.version() + 1);
949
950            let result = old_data_contract
951                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
952                .expect("failed validate update");
953
954            assert!(result.is_valid());
955        }
956    }
957}