Skip to main content

dpp/data_contract/associated_token/token_configuration/
mod.rs

1use crate::consensus::basic::data_contract::TokenShieldedPoolIncompatibleRulesError;
2use crate::consensus::basic::unsupported_version_error::UnsupportedVersionError;
3use crate::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters;
4use crate::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Getters;
5use crate::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0;
6use crate::data_contract::associated_token::token_configuration::v1::TokenConfigurationV1;
7use crate::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers;
8use crate::data_contract::change_control_rules::ChangeControlRules;
9use crate::data_contract::errors::DataContractError;
10use crate::data_contract::TokenContractPosition;
11#[cfg(feature = "json-conversion")]
12use crate::serialization::JsonConvertible;
13#[cfg(feature = "value-conversion")]
14use crate::serialization::ValueConvertible;
15use crate::validation::SimpleConsensusValidationResult;
16use bincode::{Decode, DecodeUntrusted, Encode};
17use derive_more::From;
18use platform_version::version::PlatformVersion;
19use serde::{Deserialize, Serialize};
20use std::borrow::Cow;
21use std::collections::BTreeMap;
22use std::fmt;
23
24pub mod accessors;
25mod methods;
26pub mod v0;
27pub mod v1;
28
29#[cfg_attr(feature = "json-conversion", derive(JsonConvertible))]
30#[cfg_attr(feature = "value-conversion", derive(ValueConvertible))]
31#[derive(
32    Serialize, Deserialize, Encode, Decode, Debug, Clone, PartialEq, Eq, From, DecodeUntrusted,
33)]
34#[serde(tag = "$formatVersion")]
35pub enum TokenConfiguration {
36    #[serde(rename = "0")]
37    V0(TokenConfigurationV0),
38    /// V0 plus the per-token shielded pool opt-in. Admitted from protocol version 14
39    /// (`token_versions.token_configuration_format`).
40    #[serde(rename = "1")]
41    V1(TokenConfigurationV1),
42}
43impl TokenConfiguration {
44    pub fn as_cow_v0(&self) -> Cow<'_, TokenConfigurationV0> {
45        match self {
46            TokenConfiguration::V0(v0) => Cow::Borrowed(v0),
47            TokenConfiguration::V1(v1) => Cow::Borrowed(&v1.base),
48        }
49    }
50
51    /// The `$formatVersion` this configuration is serialized with.
52    pub fn format_version(&self) -> u16 {
53        match self {
54            TokenConfiguration::V0(_) => 0,
55            TokenConfiguration::V1(_) => 1,
56        }
57    }
58
59    /// Checks the configuration's format version against the bounds the platform version
60    /// admits (`dpp.contract_versions.token_versions.token_configuration_format`).
61    ///
62    /// A format above the bound is a consensus error, not a decode failure: nodes running
63    /// software that knows the newer variant must still refuse it until the protocol version
64    /// that introduces it activates, so a mixed-version network agrees.
65    pub fn validate_format_version(
66        &self,
67        platform_version: &PlatformVersion,
68    ) -> SimpleConsensusValidationResult {
69        let bounds = &platform_version
70            .dpp
71            .contract_versions
72            .token_versions
73            .token_configuration_format;
74        let format_version = self.format_version();
75        if format_version < bounds.min_version || format_version > bounds.max_version {
76            SimpleConsensusValidationResult::new_with_error(
77                UnsupportedVersionError::new(
78                    format_version,
79                    bounds.min_version,
80                    bounds.max_version,
81                )
82                .into(),
83            )
84        } else {
85            SimpleConsensusValidationResult::new()
86        }
87    }
88
89    /// A shielded pool makes freezing and confiscation unenforceable: shielded notes belong to
90    /// no identity account, so a holder who expects a freeze simply shields first. Rather than
91    /// let an issuer advertise controls that only cover transparent balances, a token with
92    /// `hasShieldedPool` must permanently disable `freezeRules`, `unfreezeRules` and
93    /// `destroyFrozenFundsRules`: no one may take the action and no one may administer the
94    /// rule, so no configuration update can ever switch them on. Checked on contract create
95    /// and update; the flag itself is immutable.
96    pub fn validate_shielded_pool_rules(
97        &self,
98        token_contract_position: TokenContractPosition,
99    ) -> SimpleConsensusValidationResult {
100        if !self.has_shielded_pool() {
101            return SimpleConsensusValidationResult::new();
102        }
103        let rules: [(&ChangeControlRules, &str); 3] = [
104            (self.freeze_rules(), "freezeRules"),
105            (self.unfreeze_rules(), "unfreezeRules"),
106            (self.destroy_frozen_funds_rules(), "destroyFrozenFundsRules"),
107        ];
108        for (rule, name) in rules {
109            let disabled = *rule.authorized_to_make_change_action_takers()
110                == AuthorizedActionTakers::NoOne
111                && *rule.admin_action_takers() == AuthorizedActionTakers::NoOne;
112            if !disabled {
113                return SimpleConsensusValidationResult::new_with_error(
114                    TokenShieldedPoolIncompatibleRulesError::new(
115                        token_contract_position,
116                        name.to_string(),
117                    )
118                    .into(),
119                );
120            }
121        }
122        SimpleConsensusValidationResult::new()
123    }
124
125    /// The pool's outgoing notes threshold may not exceed
126    /// `max_token_pool_notes_for_outgoing`: a threshold the pool never reaches would refuse
127    /// every outflow and strand every shielded balance, irreversibly on a readonly contract.
128    /// Checked on contract create and update and on the configuration a `TokenConfigUpdate`
129    /// proposes.
130    pub fn validate_minimum_pool_notes_for_outgoing(
131        &self,
132        token_contract_position: TokenContractPosition,
133        platform_version: &PlatformVersion,
134    ) -> SimpleConsensusValidationResult {
135        validate_minimum_pool_notes_for_outgoing_bound(
136            self.minimum_pool_notes_for_outgoing(),
137            token_contract_position,
138            platform_version,
139        )
140    }
141}
142
143/// The bound on a token shielded pool's outgoing notes threshold: at most
144/// `max_token_pool_notes_for_outgoing`, refused as `KeyWrongBounds`. Shared by the token
145/// configuration validation and the `TokenConfigUpdate` that changes the threshold.
146pub fn validate_minimum_pool_notes_for_outgoing_bound(
147    minimum_pool_notes: u64,
148    token_contract_position: TokenContractPosition,
149    platform_version: &PlatformVersion,
150) -> SimpleConsensusValidationResult {
151    let max_minimum_pool_notes = platform_version
152        .system_limits
153        .max_token_pool_notes_for_outgoing;
154    if minimum_pool_notes > max_minimum_pool_notes {
155        return SimpleConsensusValidationResult::new_with_error(
156            DataContractError::KeyWrongBounds(format!(
157                "token at position {token_contract_position}: minimumPoolNotesForOutgoing \
158                 {minimum_pool_notes} is above the maximum {max_minimum_pool_notes}"
159            ))
160            .into(),
161        );
162    }
163    SimpleConsensusValidationResult::new()
164}
165
166/// Validates every token configuration of a contract for `platform_version`: the format
167/// version must be admitted, a pooled token's rules must be compatible with a pool and its
168/// outgoing notes threshold within bounds. Returns the first error. Shared by the contract
169/// create and update basic structure generations and by the pre-activation gate, so the three
170/// cannot drift.
171pub fn validate_token_configurations(
172    tokens: &BTreeMap<TokenContractPosition, TokenConfiguration>,
173    platform_version: &PlatformVersion,
174) -> SimpleConsensusValidationResult {
175    for (position, configuration) in tokens {
176        let result = configuration.validate_format_version(platform_version);
177        if !result.is_valid() {
178            return result;
179        }
180        let result = configuration.validate_shielded_pool_rules(*position);
181        if !result.is_valid() {
182            return result;
183        }
184        let result =
185            configuration.validate_minimum_pool_notes_for_outgoing(*position, platform_version);
186        if !result.is_valid() {
187            return result;
188        }
189    }
190    SimpleConsensusValidationResult::new()
191}
192
193impl fmt::Display for TokenConfiguration {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        match self {
196            TokenConfiguration::V0(v0) => write!(f, "{}", v0),
197            TokenConfiguration::V1(v1) => write!(f, "{}", v1),
198        }
199    }
200}
201
202#[cfg(all(test, feature = "json-conversion"))]
203mod tests {
204    use super::*;
205    use crate::serialization::JsonConvertible;
206
207    #[test]
208    fn token_configuration_large_supply_json_round_trip() {
209        let mut config = TokenConfigurationV0::default_most_restrictive();
210        config.base_supply = u64::MAX;
211        let config = TokenConfiguration::V0(config);
212
213        let json = config.to_json().expect("to_json should succeed");
214
215        // u64::MAX > JS MAX_SAFE_INTEGER, so it should be serialized as a string
216        assert!(
217            json["baseSupply"].is_string(),
218            "baseSupply should be a string for large values, got: {:?}",
219            json["baseSupply"]
220        );
221        assert_eq!(json["baseSupply"].as_str().unwrap(), u64::MAX.to_string());
222
223        let restored = TokenConfiguration::from_json(json).expect("from_json should succeed");
224        assert_eq!(config, restored);
225    }
226}
227
228#[cfg(all(
229    test,
230    feature = "json-conversion",
231    feature = "value-conversion",
232    feature = "serde-conversion"
233))]
234mod json_convertible_tests {
235    use super::*;
236    use crate::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0;
237
238    /// `default_most_restrictive` already populates ~25 inner fields with
239    /// non-default values (decimals=8, base_supply=100_000, etc.) — exactly
240    /// what we want for the round-trip structural check below.
241    fn fixture() -> TokenConfiguration {
242        TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive())
243    }
244
245    /// Tier 3: TokenConfiguration embeds ~25 fields, several of which are
246    /// themselves versioned enums (TokenConfigurationConvention,
247    /// ChangeControlRules x7, TokenKeepsHistoryRules, TokenDistributionRules,
248    /// TokenMarketplaceRules). An inline wire-shape literal would be 200+
249    /// lines and would re-test the nested types' own assertions. Instead we
250    /// assert only the envelope (top-level keys + `$formatVersion`) and trust
251    /// the nested types' tests for inner shape correctness.
252    #[test]
253    fn json_round_trip_with_envelope_shape() {
254        use crate::serialization::JsonConvertible;
255        let original = fixture();
256        let json = original.to_json().expect("to_json");
257        // Envelope check: format version + top-level keys present.
258        assert_eq!(
259            json.get("$formatVersion").and_then(|v| v.as_str()),
260            Some("0")
261        );
262        for key in [
263            "conventions",
264            "conventionsChangeRules",
265            "baseSupply",
266            "maxSupply",
267            "keepsHistory",
268            "startAsPaused",
269            "allowTransferToFrozenBalance",
270            "maxSupplyChangeRules",
271            "distributionRules",
272            "marketplaceRules",
273            "manualMintingRules",
274            "manualBurningRules",
275            "freezeRules",
276            "unfreezeRules",
277            "destroyFrozenFundsRules",
278            "emergencyActionRules",
279            "mainControlGroup",
280            "mainControlGroupCanBeModified",
281            "description",
282        ] {
283            assert!(
284                json.get(key).is_some(),
285                "expected top-level key {:?} in JSON envelope",
286                key
287            );
288        }
289        let recovered = TokenConfiguration::from_json(json).expect("from_json");
290        assert_eq!(original, recovered);
291    }
292
293    #[test]
294    fn value_round_trip_with_envelope_shape() {
295        use crate::serialization::ValueConvertible;
296        let original = fixture();
297        let value = original.to_object().expect("to_object");
298        // Same envelope-only check on the platform_value side.
299        let map = value.as_map().expect("value is a Map");
300        let has_key = |k: &str| {
301            map.iter()
302                .any(|(key, _)| matches!(key, platform_value::Value::Text(t) if t == k))
303        };
304        assert!(has_key("$formatVersion"));
305        for key in [
306            "conventions",
307            "conventionsChangeRules",
308            "baseSupply",
309            "maxSupply",
310            "keepsHistory",
311            "startAsPaused",
312            "allowTransferToFrozenBalance",
313            "maxSupplyChangeRules",
314            "distributionRules",
315            "marketplaceRules",
316            "manualMintingRules",
317            "manualBurningRules",
318            "freezeRules",
319            "unfreezeRules",
320            "destroyFrozenFundsRules",
321            "emergencyActionRules",
322            "mainControlGroup",
323            "mainControlGroupCanBeModified",
324            "description",
325        ] {
326            assert!(
327                has_key(key),
328                "expected top-level key {:?} in Value envelope",
329                key
330            );
331        }
332        let recovered = TokenConfiguration::from_object(value).expect("from_object");
333        assert_eq!(original, recovered);
334    }
335
336    mod shielded_pool_rules {
337        use super::*;
338        use crate::consensus::basic::BasicError;
339        use crate::consensus::ConsensusError;
340        use crate::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Setters;
341        use crate::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters;
342        use crate::data_contract::change_control_rules::v0::ChangeControlRulesV0;
343        use crate::prelude::Identifier;
344
345        fn rules(
346            authorized: AuthorizedActionTakers,
347            admin: AuthorizedActionTakers,
348        ) -> ChangeControlRules {
349            ChangeControlRules::V0(ChangeControlRulesV0 {
350                authorized_to_make_change: authorized,
351                admin_action_takers: admin,
352                changing_authorized_action_takers_to_no_one_allowed: false,
353                changing_admin_action_takers_to_no_one_allowed: false,
354                self_changing_admin_action_takers_allowed: false,
355            })
356        }
357
358        fn pooled_configuration() -> TokenConfiguration {
359            let mut configuration =
360                TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive());
361            configuration.set_has_shielded_pool(true);
362            configuration
363        }
364
365        fn rejected_rule(configuration: &TokenConfiguration) -> Option<String> {
366            let result = configuration.validate_shielded_pool_rules(3);
367            match result.errors.as_slice() {
368                [] => None,
369                [ConsensusError::BasicError(
370                    BasicError::TokenShieldedPoolIncompatibleRulesError(error),
371                )] => {
372                    assert_eq!(error.token_contract_position(), 3);
373                    Some(error.rule().to_string())
374                }
375                other => panic!("unexpected errors: {other:?}"),
376            }
377        }
378
379        #[test]
380        fn most_restrictive_defaults_are_compatible_with_a_pool() {
381            assert_eq!(rejected_rule(&pooled_configuration()), None);
382        }
383
384        #[test]
385        fn a_token_without_a_pool_may_keep_freeze_rules() {
386            let mut configuration =
387                TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive());
388            configuration.set_freeze_rules(rules(
389                AuthorizedActionTakers::ContractOwner,
390                AuthorizedActionTakers::ContractOwner,
391            ));
392            assert_eq!(rejected_rule(&configuration), None);
393        }
394
395        #[test]
396        fn a_pooled_token_rejects_an_authorized_freezer() {
397            let mut configuration = pooled_configuration();
398            configuration.set_freeze_rules(rules(
399                AuthorizedActionTakers::ContractOwner,
400                AuthorizedActionTakers::NoOne,
401            ));
402            assert_eq!(
403                rejected_rule(&configuration),
404                Some("freezeRules".to_string())
405            );
406        }
407
408        #[test]
409        fn a_pooled_token_rejects_an_admin_who_could_enable_unfreezing_later() {
410            let mut configuration = pooled_configuration();
411            configuration.set_unfreeze_rules(rules(
412                AuthorizedActionTakers::NoOne,
413                AuthorizedActionTakers::MainGroup,
414            ));
415            assert_eq!(
416                rejected_rule(&configuration),
417                Some("unfreezeRules".to_string())
418            );
419        }
420
421        #[test]
422        fn a_pooled_token_rejects_frozen_funds_destruction() {
423            let mut configuration = pooled_configuration();
424            configuration.set_destroy_frozen_funds_rules(rules(
425                AuthorizedActionTakers::Identity(Identifier::from([1u8; 32])),
426                AuthorizedActionTakers::NoOne,
427            ));
428            assert_eq!(
429                rejected_rule(&configuration),
430                Some("destroyFrozenFundsRules".to_string())
431            );
432        }
433    }
434}
435
436#[cfg(test)]
437mod minimum_pool_notes_tests {
438    use super::*;
439    use crate::consensus::basic::BasicError;
440    use crate::consensus::codes::ErrorWithCode;
441    use crate::consensus::ConsensusError;
442    use crate::data_contract::associated_token::token_configuration::accessors::v0::{
443        TokenConfigurationV0Getters, TokenConfigurationV0Setters,
444    };
445    use crate::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters;
446    use crate::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem;
447    use crate::data_contract::change_control_rules::v0::ChangeControlRulesV0;
448    use crate::group::action_taker::{ActionGoal, ActionTaker};
449    use crate::prelude::Identifier;
450
451    fn pooled() -> TokenConfiguration {
452        let mut configuration =
453            TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive());
454        configuration.set_has_shielded_pool(true);
455        configuration
456    }
457
458    fn pooled_with_threshold(minimum_pool_notes: u64) -> TokenConfiguration {
459        let mut configuration = pooled();
460        let TokenConfiguration::V1(v1) = &mut configuration else {
461            panic!("a pooled configuration is V1");
462        };
463        v1.minimum_pool_notes_for_outgoing = Some(minimum_pool_notes);
464        configuration
465    }
466
467    fn rules(authorized: AuthorizedActionTakers) -> ChangeControlRules {
468        ChangeControlRules::V0(ChangeControlRulesV0 {
469            authorized_to_make_change: authorized,
470            admin_action_takers: authorized,
471            changing_authorized_action_takers_to_no_one_allowed: false,
472            changing_admin_action_takers_to_no_one_allowed: false,
473            self_changing_admin_action_takers_allowed: false,
474        })
475    }
476
477    #[test]
478    fn should_read_a_configuration_without_a_threshold_as_zero() {
479        assert_eq!(
480            TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive())
481                .minimum_pool_notes_for_outgoing(),
482            0
483        );
484        let configuration = pooled();
485        let TokenConfiguration::V1(v1) = &configuration else {
486            panic!("a pooled configuration is V1");
487        };
488        assert_eq!(v1.minimum_pool_notes_for_outgoing, None);
489        assert_eq!(configuration.minimum_pool_notes_for_outgoing(), 0);
490        // Nobody may change a threshold the issuer did not open to change.
491        assert_eq!(
492            configuration.authorized_action_takers_for_configuration_item(
493                &TokenConfigurationChangeItem::MinimumPoolNotesForOutgoing(1)
494            ),
495            AuthorizedActionTakers::NoOne
496        );
497    }
498
499    #[test]
500    fn should_bound_the_threshold_by_the_system_limit_with_error_10241() {
501        let platform_version = PlatformVersion::latest();
502        let max = platform_version
503            .system_limits
504            .max_token_pool_notes_for_outgoing;
505        assert_eq!(max, 250);
506
507        let at_the_limit = BTreeMap::from([(0, pooled_with_threshold(max))]);
508        let result = validate_token_configurations(&at_the_limit, platform_version);
509        assert!(result.is_valid(), "unexpected errors: {:?}", result.errors);
510
511        let over_the_limit = BTreeMap::from([(3, pooled_with_threshold(max + 1))]);
512        let result = validate_token_configurations(&over_the_limit, platform_version);
513        assert_matches::assert_matches!(
514            result.errors.as_slice(),
515            [error @ ConsensusError::BasicError(BasicError::ContractError(
516                DataContractError::KeyWrongBounds(message)
517            ))] if error.code() == 10241 && message.contains("position 3")
518        );
519    }
520
521    #[test]
522    fn should_govern_the_threshold_by_its_own_rules_on_a_pooled_token_only() {
523        let owner = Identifier::from([1; 32]);
524        let other = Identifier::from([2; 32]);
525        let groups = BTreeMap::new();
526        let change = TokenConfigurationChangeItem::MinimumPoolNotesForOutgoing(12);
527        let can_apply = |configuration: &TokenConfiguration, action_taker: Identifier| {
528            configuration.can_apply_token_configuration_item(
529                &change,
530                &owner,
531                None,
532                &groups,
533                &ActionTaker::SingleIdentity(action_taker),
534                ActionGoal::ActionCompletion,
535            )
536        };
537
538        let mut governed = pooled();
539        let TokenConfiguration::V1(v1) = &mut governed else {
540            panic!("a pooled configuration is V1");
541        };
542        v1.minimum_pool_notes_for_outgoing_change_rules =
543            rules(AuthorizedActionTakers::ContractOwner);
544        assert!(can_apply(&governed, owner));
545        assert!(!can_apply(&governed, other));
546        assert_eq!(
547            governed.controlling_action_takers_for_configuration_item(&change),
548            AuthorizedActionTakers::ContractOwner
549        );
550        governed.apply_token_configuration_item(change.clone());
551        assert_eq!(governed.minimum_pool_notes_for_outgoing(), 12);
552
553        // A token without a pool has no threshold, whoever asks and whatever its other rules.
554        let mut unpooled = TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive());
555        unpooled.set_max_supply_change_rules(rules(AuthorizedActionTakers::ContractOwner));
556        assert!(!can_apply(&unpooled, owner));
557        let before = unpooled.clone();
558        unpooled.apply_token_configuration_item(change);
559        assert_eq!(unpooled, before);
560    }
561
562    /// The control item moves who may set the threshold and the admin item moves who
563    /// administers that, each under the threshold's own admin rule and neither touching the
564    /// other or any rule of the nested V0 configuration.
565    #[test]
566    fn should_route_the_threshold_control_and_admin_items_to_their_own_rules() {
567        let owner = Identifier::from([1; 32]);
568        let heir = Identifier::from([3; 32]);
569        let groups = BTreeMap::new();
570        let can_apply = |configuration: &TokenConfiguration,
571                         change: &TokenConfigurationChangeItem,
572                         action_taker: Identifier| {
573            configuration.can_apply_token_configuration_item(
574                change,
575                &owner,
576                None,
577                &groups,
578                &ActionTaker::SingleIdentity(action_taker),
579                ActionGoal::ActionCompletion,
580            )
581        };
582        let threshold_rules = |configuration: &TokenConfiguration| {
583            let TokenConfiguration::V1(v1) = configuration else {
584                panic!("a pooled configuration is V1");
585            };
586            v1.minimum_pool_notes_for_outgoing_change_rules.clone()
587        };
588
589        let mut configuration = pooled();
590        let TokenConfiguration::V1(v1) = &mut configuration else {
591            panic!("a pooled configuration is V1");
592        };
593        v1.minimum_pool_notes_for_outgoing_change_rules =
594            ChangeControlRules::V0(ChangeControlRulesV0 {
595                authorized_to_make_change: AuthorizedActionTakers::NoOne,
596                admin_action_takers: AuthorizedActionTakers::ContractOwner,
597                changing_authorized_action_takers_to_no_one_allowed: false,
598                changing_admin_action_takers_to_no_one_allowed: false,
599                self_changing_admin_action_takers_allowed: true,
600            });
601        let max_supply_rules_before = configuration.max_supply_change_rules().clone();
602        let set_threshold = TokenConfigurationChangeItem::MinimumPoolNotesForOutgoing(8);
603        let control = TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingControlGroup(
604            AuthorizedActionTakers::Identity(heir),
605        );
606        let admin = TokenConfigurationChangeItem::MinimumPoolNotesForOutgoingAdminGroup(
607            AuthorizedActionTakers::Identity(heir),
608        );
609
610        for item in [&control, &admin] {
611            assert_eq!(
612                configuration.authorized_action_takers_for_configuration_item(item),
613                AuthorizedActionTakers::ContractOwner
614            );
615            assert!(can_apply(&configuration, item, owner));
616            assert!(!can_apply(&configuration, item, heir));
617        }
618        assert!(!can_apply(&configuration, &set_threshold, owner));
619
620        configuration.apply_token_configuration_item(control);
621        let rules = threshold_rules(&configuration);
622        assert_eq!(
623            *rules.authorized_to_make_change_action_takers(),
624            AuthorizedActionTakers::Identity(heir)
625        );
626        assert_eq!(
627            *rules.admin_action_takers(),
628            AuthorizedActionTakers::ContractOwner
629        );
630        assert!(can_apply(&configuration, &set_threshold, heir));
631        assert!(!can_apply(&configuration, &set_threshold, owner));
632
633        configuration.apply_token_configuration_item(admin);
634        let rules = threshold_rules(&configuration);
635        assert_eq!(
636            *rules.authorized_to_make_change_action_takers(),
637            AuthorizedActionTakers::Identity(heir)
638        );
639        assert_eq!(
640            *rules.admin_action_takers(),
641            AuthorizedActionTakers::Identity(heir)
642        );
643        assert_eq!(
644            configuration.max_supply_change_rules(),
645            &max_supply_rules_before
646        );
647    }
648
649    #[test]
650    fn should_hand_every_other_item_of_a_pooled_token_to_its_v0_rules() {
651        let owner = Identifier::from([1; 32]);
652        let groups = BTreeMap::new();
653        let mut configuration = pooled();
654        configuration.set_max_supply_change_rules(rules(AuthorizedActionTakers::ContractOwner));
655        let change = TokenConfigurationChangeItem::MaxSupply(Some(5_000));
656        assert!(configuration.can_apply_token_configuration_item(
657            &change,
658            &owner,
659            None,
660            &groups,
661            &ActionTaker::SingleIdentity(owner),
662            ActionGoal::ActionCompletion,
663        ));
664        configuration.apply_token_configuration_item(change);
665        assert_eq!(configuration.max_supply(), Some(5_000));
666        assert!(configuration.has_shielded_pool());
667    }
668
669    #[test]
670    fn should_count_the_threshold_rules_among_the_tokens_rules_and_groups() {
671        let mut configuration = pooled();
672        let TokenConfiguration::V1(v1) = &mut configuration else {
673            panic!("a pooled configuration is V1");
674        };
675        v1.minimum_pool_notes_for_outgoing_change_rules = rules(AuthorizedActionTakers::Group(4));
676        let (group_positions, _) = configuration.all_used_group_positions();
677        assert!(group_positions.contains(&4));
678        assert!(configuration
679            .all_change_control_rules()
680            .iter()
681            .any(
682                |(name, rules)| *name == "minimum_pool_notes_for_outgoing_change_rules"
683                    && *rules.authorized_to_make_change_action_takers()
684                        == AuthorizedActionTakers::Group(4)
685            ));
686    }
687}
688
689#[cfg(all(
690    test,
691    feature = "json-conversion",
692    feature = "value-conversion",
693    feature = "serde-conversion"
694))]
695mod minimum_pool_notes_json_tests {
696    use super::*;
697    use crate::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters;
698    use crate::serialization::JsonConvertible;
699
700    /// A pooled configuration written before the threshold existed, or by a client that leaves
701    /// it out, reads with no threshold and no one allowed to change it.
702    #[test]
703    fn should_read_a_pooled_configuration_without_the_threshold_keys_as_none() {
704        let mut configuration =
705            TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive());
706        configuration.set_has_shielded_pool(true);
707        let mut json = configuration.to_json().expect("to_json");
708        let object = json.as_object_mut().expect("configuration object");
709        assert!(object.remove("minimumPoolNotesForOutgoing").is_some());
710        assert!(object
711            .remove("minimumPoolNotesForOutgoingChangeRules")
712            .is_some());
713
714        let decoded = TokenConfiguration::from_json(json).expect("from_json");
715        assert_eq!(decoded, configuration);
716        assert_eq!(decoded.minimum_pool_notes_for_outgoing(), 0);
717    }
718}
719
720#[cfg(all(
721    test,
722    feature = "json-conversion",
723    feature = "value-conversion",
724    feature = "serde-conversion"
725))]
726mod unknown_configuration_key_tests {
727    use super::*;
728    use crate::data_contract::associated_token::token_configuration::accessors::v1::TokenConfigurationV1Setters;
729    use crate::serialization::{JsonConvertible, ValueConvertible};
730
731    /// A V0 configuration's JSON with `hasShieldedPool` bolted on: what a caller writes when
732    /// they ask for a pool but leave the format version at 0.
733    fn v0_json_asking_for_a_pool() -> serde_json::Value {
734        let mut json = TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive())
735            .to_json()
736            .expect("to_json");
737        json.as_object_mut()
738            .expect("configuration object")
739            .insert("hasShieldedPool".to_string(), serde_json::Value::Bool(true));
740        json
741    }
742
743    /// The same fixture on the Value wire, which is the path a contract is ingested through.
744    fn v0_value_asking_for_a_pool() -> platform_value::Value {
745        let mut value = TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive())
746            .to_object()
747            .expect("to_object");
748        value.as_map_mut().expect("configuration map").push((
749            platform_value::Value::Text("hasShieldedPool".to_string()),
750            platform_value::Value::Bool(true),
751        ));
752        value
753    }
754
755    #[test]
756    fn should_refuse_a_pool_asked_for_at_format_version_0_rather_than_drop_it() {
757        let json = v0_json_asking_for_a_pool();
758
759        // The control. Take the pool request back out and the very same configuration decodes,
760        // at format version 0 and without a pool. It is what makes the refusal below evidence
761        // about the pool request rather than about a fixture that stopped building a valid V0.
762        let mut without_the_request = json.clone();
763        without_the_request
764            .as_object_mut()
765            .expect("configuration object")
766            .remove("hasShieldedPool");
767        let control = TokenConfiguration::from_json(without_the_request)
768            .expect("the configuration decodes once the pool request is taken out");
769        assert_eq!(control.format_version(), 0);
770        assert!(!control.has_shielded_pool());
771
772        // A configuration asking for a pool must not decode as a token that can never have one,
773        // and the refusal has to name the key it refused: any other message means the decoder
774        // tripped over something else and the pool request was never the reason.
775        let error = TokenConfiguration::from_json(json).expect_err("the pool request is refused");
776        assert!(
777            error.to_string().contains("hasShieldedPool"),
778            "the refusal must name the key it refused, got {error}"
779        );
780    }
781
782    #[test]
783    fn should_refuse_a_pool_asked_for_at_format_version_0_on_the_value_wire_too() {
784        let value = v0_value_asking_for_a_pool();
785
786        let mut without_the_request = value.clone();
787        without_the_request
788            .as_map_mut()
789            .expect("configuration map")
790            .retain(|(key, _)| key.as_text() != Some("hasShieldedPool"));
791        let control = TokenConfiguration::from_object(without_the_request)
792            .expect("the configuration decodes once the pool request is taken out");
793        assert_eq!(control.format_version(), 0);
794        assert!(!control.has_shielded_pool());
795
796        let error =
797            TokenConfiguration::from_object(value).expect_err("the pool request is refused");
798        assert!(
799            error.to_string().contains("hasShieldedPool"),
800            "the refusal must name the key it refused, got {error}"
801        );
802    }
803
804    /// Asking for a pool moves the configuration to format version 1, whose V0 fields sit at the
805    /// top level of the wire through `serde(flatten)`. Both wires have to read that shape back.
806    #[test]
807    fn should_still_decode_a_pooled_configuration_whose_v0_fields_are_flattened() {
808        let mut configuration =
809            TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive());
810        configuration.set_has_shielded_pool(true);
811
812        let json = configuration.to_json().expect("to_json");
813        assert_eq!(
814            json.get("$formatVersion").and_then(|v| v.as_str()),
815            Some("1")
816        );
817        assert_eq!(
818            TokenConfiguration::from_json(json).expect("from_json"),
819            configuration
820        );
821
822        let value = configuration.to_object().expect("to_object");
823        assert_eq!(
824            TokenConfiguration::from_object(value).expect("from_object"),
825            configuration
826        );
827    }
828
829    /// A key no format version carries is refused at format version 1, as it is at 0.
830    ///
831    /// Pinned because the failure it prevents is invisible: a dropped key lets a caller's typo
832    /// reach the chain as a configuration quietly missing whatever they meant to set, and what
833    /// a token can do is fixed when it is created.
834    ///
835    /// The refusal has to hold on both wires. `TokenConfigurationV1` takes version 0's fields
836    /// through `serde(flatten)`, and an unknown key arriving through a flattened field is not
837    /// offered to the inner struct at all, so version 0's own refusal cannot reach it — the
838    /// attribute has to sit on the version that owns the flatten.
839    #[test]
840    fn should_refuse_an_unrecognized_key_at_format_version_1() {
841        let mut configuration =
842            TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive());
843        configuration.set_has_shielded_pool(true);
844
845        const UNRECOGNIZED_KEY: &str = "thisKeyIsInNoFormatVersion";
846
847        let mut json = configuration.to_json().expect("to_json");
848        json.as_object_mut()
849            .expect("configuration object")
850            .insert(UNRECOGNIZED_KEY.to_string(), serde_json::Value::Bool(true));
851        let error = TokenConfiguration::from_json(json)
852            .expect_err("an unrecognized key must be refused, not dropped")
853            .to_string();
854        assert!(
855            error.contains(UNRECOGNIZED_KEY),
856            "the refusal must name the key it refused, got {error}"
857        );
858
859        let mut value = configuration.to_object().expect("to_object");
860        value.as_map_mut().expect("configuration map").push((
861            platform_value::Value::Text(UNRECOGNIZED_KEY.to_string()),
862            platform_value::Value::Bool(true),
863        ));
864        let error = TokenConfiguration::from_object(value)
865            .expect_err("the contract ingest path must refuse it too")
866            .to_string();
867        assert!(
868            error.contains(UNRECOGNIZED_KEY),
869            "the refusal must name the key it refused, got {error}"
870        );
871
872        // The same configuration without the key still decodes, so the refusal is the key's
873        // doing and not a fixture that stopped building.
874        let clean = TokenConfiguration::from_json(configuration.to_json().expect("to_json"))
875            .expect("the configuration itself still decodes");
876        assert_eq!(clean, configuration);
877        assert_eq!(clean.format_version(), 1);
878        assert!(clean.has_shielded_pool());
879    }
880}