Skip to main content

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

1use crate::block::block_info::BlockInfo;
2use crate::data_contract::DataContract;
3use crate::validation::SimpleConsensusValidationResult;
4use crate::ProtocolError;
5use platform_version::version::PlatformVersion;
6
7pub trait DataContractUpdateValidationMethodsV0 {
8    fn validate_update(
9        &self,
10        data_contract: &DataContract,
11        block_info: &BlockInfo,
12        platform_version: &PlatformVersion,
13    ) -> Result<SimpleConsensusValidationResult, ProtocolError>;
14}
15
16impl DataContract {
17    #[inline(always)]
18    pub(super) fn validate_update_v0(
19        &self,
20        new_data_contract: &DataContract,
21        block_info: &BlockInfo,
22        platform_version: &PlatformVersion,
23    ) -> Result<SimpleConsensusValidationResult, ProtocolError> {
24        let result = self.validate_update_ownership_and_version(new_data_contract);
25        if !result.is_valid() {
26            return Ok(result);
27        }
28
29        let result = self.validate_update_config(new_data_contract, platform_version)?;
30        if !result.is_valid() {
31            return Ok(result);
32        }
33
34        let result =
35            self.validate_update_existing_document_types(new_data_contract, platform_version)?;
36        if !result.is_valid() {
37            return Ok(result);
38        }
39
40        let result = self.validate_update_schema_defs(new_data_contract, platform_version)?;
41        if !result.is_valid() {
42            return Ok(result);
43        }
44
45        let result = self.validate_update_groups(new_data_contract);
46        if !result.is_valid() {
47            return Ok(result);
48        }
49
50        let result = self.validate_update_tokens(new_data_contract, block_info);
51        if !result.is_valid() {
52            return Ok(result);
53        }
54
55        let result = self.validate_update_keywords(new_data_contract);
56        if !result.is_valid() {
57            return Ok(result);
58        }
59
60        Ok(self.validate_update_description(new_data_contract))
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use crate::consensus::basic::basic_error::BasicError;
68    use crate::consensus::state::state_error::StateError;
69    use crate::consensus::ConsensusError;
70    use crate::data_contract::accessors::v0::DataContractV0Getters;
71    use crate::data_contract::accessors::v1::DataContractV1Getters;
72    use crate::data_contract::config::v0::DataContractConfigSettersV0;
73    use crate::data_contract::methods::validate_update::DataContractUpdateValidationMethodsV0;
74    use crate::data_contract::schema::DataContractSchemaMethodsV0;
75    use crate::prelude::IdentityNonce;
76    use crate::tests::fixtures::get_data_contract_fixture;
77    use assert_matches::assert_matches;
78    use platform_value::platform_value;
79    use platform_value::Identifier;
80
81    mod validate_update {
82        use std::collections::BTreeMap;
83
84        use super::*;
85        use crate::data_contract::accessors::v0::DataContractV0Setters;
86        use crate::data_contract::accessors::v1::DataContractV1Setters;
87        use crate::data_contract::associated_token::token_configuration::accessors::v0::{
88            TokenConfigurationV0Getters, TokenConfigurationV0Setters,
89        };
90        use crate::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters;
91        use crate::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0;
92        use crate::data_contract::associated_token::token_configuration::v1::TokenConfigurationV1;
93        use crate::data_contract::associated_token::token_configuration_convention::v0::TokenConfigurationConventionV0;
94        use crate::data_contract::associated_token::token_configuration_convention::TokenConfigurationConvention;
95        use crate::data_contract::associated_token::token_configuration_localization::v0::TokenConfigurationLocalizationV0;
96        use crate::data_contract::associated_token::token_configuration_localization::TokenConfigurationLocalization;
97        use crate::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Setters;
98        use crate::data_contract::associated_token::token_pre_programmed_distribution::v0::TokenPreProgrammedDistributionV0;
99        use crate::data_contract::associated_token::token_pre_programmed_distribution::TokenPreProgrammedDistribution;
100        use crate::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers;
101        use crate::data_contract::document_type::DocumentTypeMutRef;
102        use crate::data_contract::group::accessors::v0::{GroupV0Getters, GroupV0Setters};
103        use crate::data_contract::group::v0::GroupV0;
104        use crate::data_contract::group::Group;
105        use crate::data_contract::TokenConfiguration;
106        use crate::identity::accessors::IdentityGettersV0;
107        use crate::prelude::Identity;
108
109        #[test]
110        fn should_return_invalid_result_if_owner_id_is_not_the_same() {
111            let platform_version = PlatformVersion::latest();
112
113            let old_data_contract = get_data_contract_fixture(
114                None,
115                IdentityNonce::default(),
116                platform_version.protocol_version,
117            )
118            .data_contract_owned();
119
120            let mut new_data_contract = old_data_contract.clone();
121
122            new_data_contract.set_owner_id(Identifier::random());
123
124            let result = old_data_contract
125                .validate_update(&new_data_contract, &BlockInfo::default(), platform_version)
126                .expect("failed validate update");
127
128            assert_matches!(
129                result.errors.as_slice(),
130                [ConsensusError::StateError(
131                    StateError::DataContractUpdatePermissionError(e)
132                )] if *e.data_contract_id() == old_data_contract.id() && *e.identity_id() == new_data_contract.owner_id()
133            );
134        }
135
136        #[test]
137        fn should_return_invalid_result_if_contract_version_is_not_greater_for_one() {
138            let platform_version = PlatformVersion::latest();
139
140            let old_data_contract = get_data_contract_fixture(
141                None,
142                IdentityNonce::default(),
143                platform_version.protocol_version,
144            )
145            .data_contract_owned();
146
147            let new_data_contract = old_data_contract.clone();
148
149            let result = old_data_contract
150                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
151                .expect("failed validate update");
152
153            assert_matches!(
154                result.errors.as_slice(),
155                [ConsensusError::BasicError(
156                    BasicError::InvalidDataContractVersionError(e)
157                )] if e.expected_version() == old_data_contract.version() + 1 && e.version() == new_data_contract.version()
158            );
159        }
160
161        #[test]
162        fn should_return_invalid_result_if_config_was_updated() {
163            let platform_version = PlatformVersion::latest();
164
165            let old_data_contract = get_data_contract_fixture(
166                None,
167                IdentityNonce::default(),
168                platform_version.protocol_version,
169            )
170            .data_contract_owned();
171
172            let mut new_data_contract = old_data_contract.clone();
173
174            new_data_contract.set_version(old_data_contract.version() + 1);
175            new_data_contract.config_mut().set_readonly(true);
176
177            let result = old_data_contract
178                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
179                .expect("failed validate update");
180
181            assert_matches!(
182                result.errors.as_slice(),
183                [ConsensusError::StateError(
184                    StateError::DataContractConfigUpdateError(e)
185                )] if e.additional_message() == "contract can not be changed to readonly"
186            );
187        }
188
189        #[test]
190        fn should_return_invalid_result_when_document_type_is_removed() {
191            let platform_version = PlatformVersion::latest();
192
193            let old_data_contract = get_data_contract_fixture(
194                None,
195                IdentityNonce::default(),
196                platform_version.protocol_version,
197            )
198            .data_contract_owned();
199
200            let mut new_data_contract = old_data_contract.clone();
201
202            new_data_contract.set_version(old_data_contract.version() + 1);
203            new_data_contract
204                .document_types_mut()
205                .remove("niceDocument");
206
207            let result = old_data_contract
208                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
209                .expect("failed validate update");
210
211            assert_matches!(
212                result.errors.as_slice(),
213                [ConsensusError::StateError(
214                    StateError::DocumentTypeUpdateError(e)
215                )] if e.additional_message() == "document type can't be removed"
216            );
217        }
218
219        #[test]
220        fn should_return_invalid_result_when_document_type_has_incompatible_change() {
221            let platform_version = PlatformVersion::latest();
222
223            let old_data_contract = get_data_contract_fixture(
224                None,
225                IdentityNonce::default(),
226                platform_version.protocol_version,
227            )
228            .data_contract_owned();
229
230            let mut new_data_contract = old_data_contract.clone();
231
232            new_data_contract.set_version(old_data_contract.version() + 1);
233
234            match new_data_contract
235                .document_types_mut()
236                .get_mut("niceDocument")
237                .unwrap()
238                .as_mut_ref()
239            {
240                DocumentTypeMutRef::V0(dt) => dt.documents_mutable = false,
241                DocumentTypeMutRef::V1(dt) => dt.documents_mutable = false,
242                DocumentTypeMutRef::V2(dt) => dt.documents_mutable = false,
243            }
244
245            let result = old_data_contract
246                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
247                .expect("failed validate update");
248
249            assert_matches!(
250                result.errors.as_slice(),
251                [ConsensusError::StateError(
252                    StateError::DocumentTypeUpdateError(e)
253                )] if e.additional_message() == "document type can not change whether its documents are mutable: changing from true to false"
254            );
255        }
256
257        #[test]
258        fn should_return_invalid_result_when_defs_is_removed() {
259            let platform_version = PlatformVersion::latest();
260
261            let mut old_data_contract = get_data_contract_fixture(
262                None,
263                IdentityNonce::default(),
264                platform_version.protocol_version,
265            )
266            .data_contract_owned();
267
268            // Remove document that uses $defs, so we can safely remove it for testing
269            old_data_contract
270                .document_types_mut()
271                .remove("prettyDocument");
272
273            let mut new_data_contract = old_data_contract.clone();
274
275            new_data_contract.set_version(old_data_contract.version() + 1);
276            new_data_contract
277                .set_schema_defs(None, false, &mut Vec::new(), platform_version)
278                .expect("failed to set schema defs");
279
280            let result = old_data_contract
281                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
282                .expect("failed validate update");
283
284            assert_matches!(
285                result.errors.as_slice(),
286                [ConsensusError::BasicError(
287                    BasicError::IncompatibleDataContractSchemaError(e)
288                )] if e.operation() == "remove" && e.field_path() == "/$defs"
289            );
290        }
291
292        #[test]
293        fn should_return_invalid_result_when_updated_defs_is_incompatible() {
294            let platform_version = PlatformVersion::latest();
295
296            let old_data_contract = get_data_contract_fixture(
297                None,
298                IdentityNonce::default(),
299                platform_version.protocol_version,
300            )
301            .data_contract_owned();
302
303            let mut new_data_contract = old_data_contract.clone();
304
305            let incompatible_defs_value = platform_value!({
306                "lastName": {
307                    "type": "number",
308                },
309            });
310            let incompatible_defs = incompatible_defs_value
311                .into_btree_string_map()
312                .expect("should convert to map");
313
314            new_data_contract.set_version(old_data_contract.version() + 1);
315            new_data_contract
316                .set_schema_defs(
317                    Some(incompatible_defs),
318                    false,
319                    &mut Vec::new(),
320                    platform_version,
321                )
322                .expect("failed to set schema defs");
323
324            let result = old_data_contract
325                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
326                .expect("failed validate update");
327
328            assert_matches!(
329                result.errors.as_slice(),
330                [ConsensusError::BasicError(
331                    BasicError::IncompatibleDataContractSchemaError(e)
332                )] if e.operation() == "replace" && e.field_path() == "/$defs/lastName/type"
333            );
334        }
335
336        #[test]
337        fn should_pass_when_all_changes_are_compatible() {
338            let platform_version = PlatformVersion::latest();
339
340            let old_data_contract = get_data_contract_fixture(
341                None,
342                IdentityNonce::default(),
343                platform_version.protocol_version,
344            )
345            .data_contract_owned();
346
347            let mut new_data_contract = old_data_contract.clone();
348
349            new_data_contract.set_version(old_data_contract.version() + 1);
350
351            let result = old_data_contract
352                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
353                .expect("failed validate update");
354
355            assert!(result.is_valid());
356        }
357
358        //
359        // ──────────────────────────────────────────────────────────────────────────
360        //  Group‑related rules
361        // ──────────────────────────────────────────────────────────────────────────
362        //
363
364        #[test]
365        fn should_return_invalid_result_when_group_is_removed() {
366            let platform_version = PlatformVersion::latest();
367
368            let identity_1 = Identity::random_identity(3, Some(14), platform_version)
369                .expect("expected a platform identity");
370            let identity_1_id = identity_1.id();
371            let identity_2 = Identity::random_identity(3, Some(506), platform_version)
372                .expect("expected a platform identity");
373            let identity_2_id = identity_2.id();
374
375            let mut old_data_contract =
376                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
377            old_data_contract.set_groups(BTreeMap::from([(
378                0,
379                Group::V0(GroupV0 {
380                    members: [(identity_1_id, 1), (identity_2_id, 1)].into(),
381                    required_power: 2,
382                }),
383            )]));
384
385            // Clone & bump version
386            let mut new_data_contract = old_data_contract.clone();
387            new_data_contract.set_version(old_data_contract.version() + 1);
388
389            // Remove the first (and normally only) group
390            let first_group_pos = *old_data_contract
391                .groups()
392                .keys()
393                .next()
394                .expect("fixture must have at least one group");
395            new_data_contract
396                .groups_mut()
397                .unwrap()
398                .remove(&first_group_pos);
399
400            let result = old_data_contract
401                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
402                .expect("failed validate update");
403
404            assert_matches!(
405                result.errors.as_slice(),
406                [ConsensusError::StateError(
407                    StateError::DataContractUpdateActionNotAllowedError(e)
408                )] if e.action() == "remove group"
409            );
410        }
411
412        #[test]
413        fn should_return_invalid_result_when_group_is_changed() {
414            let platform_version = PlatformVersion::latest();
415
416            let identity_1 = Identity::random_identity(3, Some(14), platform_version)
417                .expect("expected a platform identity");
418            let identity_1_id = identity_1.id();
419            let identity_2 = Identity::random_identity(3, Some(506), platform_version)
420                .expect("expected a platform identity");
421            let identity_2_id = identity_2.id();
422
423            let mut old_data_contract =
424                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
425            old_data_contract.set_groups(BTreeMap::from([(
426                0,
427                Group::V0(GroupV0 {
428                    members: [(identity_1_id, 1), (identity_2_id, 1)].into(),
429                    required_power: 2,
430                }),
431            )]));
432
433            // Clone & bump version
434            let mut new_data_contract = old_data_contract.clone();
435            new_data_contract.set_version(old_data_contract.version() + 1);
436
437            // Mutate the first group in some trivial way so that
438            // `old_group != new_group` evaluates to true.
439            let first_group_pos = *new_data_contract
440                .groups()
441                .keys()
442                .next()
443                .expect("fixture must have at least one group");
444            let mut altered_group = new_data_contract
445                .groups()
446                .get(&first_group_pos)
447                .cloned()
448                .expect("group must exist");
449            // Tweak required power
450            altered_group.set_required_power(altered_group.required_power() + 1);
451            new_data_contract
452                .groups_mut()
453                .unwrap()
454                .insert(first_group_pos, altered_group);
455
456            let result = old_data_contract
457                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
458                .expect("failed validate update");
459
460            assert_matches!(
461                result.errors.as_slice(),
462                [ConsensusError::StateError(
463                    StateError::DataContractUpdateActionNotAllowedError(e)
464                )] if e.action() == format!(
465                        "change group at position {} is not allowed",
466                        first_group_pos
467                    )
468            );
469        }
470
471        //
472        // ──────────────────────────────────────────────────────────────────────────
473        //  Token‑related rules
474        // ──────────────────────────────────────────────────────────────────────────
475        //
476
477        #[test]
478        fn should_return_invalid_result_when_token_is_removed() {
479            let platform_version = PlatformVersion::latest();
480
481            let mut old_data_contract =
482                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
483            old_data_contract.set_tokens(BTreeMap::from([(
484                0,
485                TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()),
486            )]));
487
488            let mut new_data_contract = old_data_contract.clone();
489            new_data_contract.set_version(old_data_contract.version() + 1);
490
491            // Remove an existing token
492            let first_token_pos = *old_data_contract
493                .tokens()
494                .keys()
495                .next()
496                .expect("fixture must have at least one token");
497            new_data_contract
498                .tokens_mut()
499                .unwrap()
500                .remove(&first_token_pos);
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::DataContractUpdateActionNotAllowedError(e)
510                )] if e.action() == format!("remove token at position {}", first_token_pos)
511            );
512        }
513
514        #[test]
515        fn should_return_invalid_result_when_token_is_updated() {
516            let platform_version = PlatformVersion::latest();
517
518            let mut old_data_contract =
519                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
520            old_data_contract.set_tokens(BTreeMap::from([(
521                0,
522                TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()),
523            )]));
524
525            let mut new_data_contract = old_data_contract.clone();
526            new_data_contract.set_version(old_data_contract.version() + 1);
527
528            // Modify an existing token configuration
529            let first_token_pos = *new_data_contract
530                .tokens()
531                .keys()
532                .next()
533                .expect("fixture must have at least one token");
534            let mut altered_token_cfg = new_data_contract
535                .tokens()
536                .get(&first_token_pos)
537                .cloned()
538                .expect("token must exist");
539            // Tweak base supply
540            altered_token_cfg.set_base_supply(altered_token_cfg.base_supply() + 1);
541            new_data_contract
542                .tokens_mut()
543                .unwrap()
544                .insert(first_token_pos, altered_token_cfg);
545
546            let result = old_data_contract
547                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
548                .expect("failed validate update");
549
550            assert_matches!(
551                result.errors.as_slice(),
552                [ConsensusError::StateError(
553                    StateError::DataContractUpdateActionNotAllowedError(e)
554                )] if e.action() == format!("update token at position {}", first_token_pos)
555            );
556        }
557
558        /// An existing token is immutable through a contract update because its whole
559        /// configuration is compared, so every field `TokenConfigurationV1` carries is immutable
560        /// with no code of its own. The destructuring names each field without `..`, so a field
561        /// appended later stops this test compiling until the pattern names it too; that is the
562        /// moment to add a case changing it to the list below.
563        #[test]
564        fn should_return_invalid_result_when_any_token_configuration_v1_field_is_updated() {
565            let platform_version = PlatformVersion::latest();
566            let mut pooled =
567                TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive());
568            pooled.set_has_shielded_pool(true);
569            let TokenConfiguration::V1(original) = &pooled else {
570                panic!("a pooled configuration is V1");
571            };
572            let TokenConfigurationV1 {
573                base: _,
574                has_shielded_pool: _,
575                minimum_pool_notes_for_outgoing: _,
576                minimum_pool_notes_for_outgoing_change_rules: _,
577            } = original;
578
579            type Change = fn(&mut TokenConfigurationV1);
580            let changes: [(&str, Change); 3] = [
581                ("has_shielded_pool", |v1| v1.has_shielded_pool = false),
582                ("minimum_pool_notes_for_outgoing", |v1| {
583                    v1.minimum_pool_notes_for_outgoing = Some(1)
584                }),
585                ("minimum_pool_notes_for_outgoing_change_rules", |v1| {
586                    v1.minimum_pool_notes_for_outgoing_change_rules
587                        .set_authorized_to_make_change_action_takers(
588                            AuthorizedActionTakers::ContractOwner,
589                        )
590                }),
591            ];
592            for (field, change) in changes {
593                let mut old_data_contract =
594                    get_data_contract_fixture(None, IdentityNonce::default(), 9)
595                        .data_contract_owned();
596                old_data_contract.set_tokens(BTreeMap::from([(0, pooled.clone())]));
597
598                let mut changed = original.clone();
599                change(&mut changed);
600                assert_ne!(&changed, original, "{field} must actually change");
601                let mut new_data_contract = old_data_contract.clone();
602                new_data_contract.set_version(old_data_contract.version() + 1);
603                new_data_contract
604                    .set_tokens(BTreeMap::from([(0, TokenConfiguration::V1(changed))]));
605
606                let result = old_data_contract
607                    .validate_update(&new_data_contract, &BlockInfo::default(), platform_version)
608                    .expect("failed validate update");
609
610                assert_matches!(
611                    result.errors.as_slice(),
612                    [ConsensusError::StateError(
613                        StateError::DataContractUpdateActionNotAllowedError(e)
614                    )] if e.action() == "update token at position 0",
615                    "a contract update changing {field} must be refused"
616                );
617            }
618        }
619
620        #[test]
621        fn should_return_invalid_result_when_token_is_added_with_past_timestamp() {
622            let platform_version = PlatformVersion::latest();
623
624            let mut old_data_contract =
625                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
626            let mut token_cfg =
627                TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive());
628            token_cfg.set_conventions(TokenConfigurationConvention::V0(
629                TokenConfigurationConventionV0 {
630                    localizations: BTreeMap::from([(
631                        "en".to_string(),
632                        TokenConfigurationLocalization::V0(TokenConfigurationLocalizationV0 {
633                            should_capitalize: false,
634                            singular_form: "test".to_string(),
635                            plural_form: "tests".to_string(),
636                        }),
637                    )]),
638                    decimals: 8,
639                },
640            ));
641            old_data_contract.set_tokens(BTreeMap::from([(0, token_cfg)]));
642
643            let mut new_data_contract = old_data_contract.clone();
644            new_data_contract.set_version(old_data_contract.version() + 1);
645
646            // Create a new token with a past timestamp
647            let existing_cfg = new_data_contract
648                .tokens()
649                .values()
650                .next()
651                .expect("fixture must have at least one token")
652                .clone();
653            let new_position = old_data_contract
654                .tokens()
655                .keys()
656                .max()
657                .expect("fixture must have at least one token")
658                + 1;
659            let mut new_token_cfg = existing_cfg.clone();
660            new_token_cfg
661                .distribution_rules_mut()
662                .set_pre_programmed_distribution(Some(TokenPreProgrammedDistribution::V0(
663                    TokenPreProgrammedDistributionV0 {
664                        distributions: BTreeMap::from([(
665                            0,
666                            BTreeMap::from([(new_data_contract.owner_id(), 100)]),
667                        )]),
668                    },
669                )));
670            new_data_contract
671                .tokens_mut()
672                .unwrap()
673                .insert(new_position, new_token_cfg);
674
675            let result = old_data_contract
676                .validate_update_v0(
677                    &new_data_contract,
678                    &BlockInfo::default_with_time(100000),
679                    platform_version,
680                )
681                .expect("failed validate update");
682
683            assert_matches!(
684                result.errors.as_slice(),
685                [ConsensusError::StateError(
686                    StateError::PreProgrammedDistributionTimestampInPastError(e)
687                )] if e.token_position() == new_position
688            );
689        }
690
691        #[test]
692        fn should_pass_when_a_well_formed_new_token_is_added() {
693            let platform_version = PlatformVersion::latest();
694
695            let old_data_contract =
696                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
697
698            let mut new_data_contract = old_data_contract.clone();
699            new_data_contract.set_version(old_data_contract.version() + 1);
700
701            // build a fully valid token configuration
702            let valid_token_cfg = {
703                let mut cfg =
704                    TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive());
705
706                cfg.set_base_supply(1_000_000); // within limits
707
708                cfg.set_conventions(TokenConfigurationConvention::V0(
709                    TokenConfigurationConventionV0 {
710                        localizations: BTreeMap::from([(
711                            "en".to_string(),
712                            TokenConfigurationLocalization::V0(TokenConfigurationLocalizationV0 {
713                                should_capitalize: true,
714                                singular_form: "credit".to_string(),
715                                plural_form: "credits".to_string(),
716                            }),
717                        )]),
718                        decimals: 8,
719                    },
720                ));
721
722                cfg
723            };
724
725            // insert at contiguous position 0 (old contract had no tokens)
726            new_data_contract
727                .tokens_mut()
728                .unwrap()
729                .insert(0, valid_token_cfg);
730
731            let result = old_data_contract
732                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
733                .expect("failed validate update");
734
735            assert!(result.is_valid(), "well‑formed token should be accepted");
736        }
737
738        //
739        // ──────────────────────────────────────────────────────────────────────────
740        //  Happy‑path check: no token / group changes
741        // ──────────────────────────────────────────────────────────────────────────
742        //
743
744        #[test]
745        fn should_pass_when_groups_and_tokens_unchanged() {
746            let platform_version = PlatformVersion::latest();
747
748            let old_data_contract =
749                get_data_contract_fixture(None, IdentityNonce::default(), 9).data_contract_owned();
750
751            let mut new_data_contract = old_data_contract.clone();
752            new_data_contract.set_version(old_data_contract.version() + 1);
753
754            let result = old_data_contract
755                .validate_update_v0(&new_data_contract, &BlockInfo::default(), platform_version)
756                .expect("failed validate update");
757
758            assert!(result.is_valid());
759        }
760    }
761}