Skip to main content

dpp/
system_data_contracts.rs

1use crate::data_contract::DataContractFactory;
2use crate::prelude::*;
3use crate::ProtocolError;
4use std::collections::{BTreeMap, BTreeSet};
5
6use crate::data_contract::accessors::v0::DataContractV0Setters;
7use crate::data_contract::config::v1::DataContractConfigSettersV1;
8use crate::data_contract::config::DataContractConfig;
9pub use data_contracts::*;
10use platform_version::version::PlatformVersion;
11
12pub trait ConfigurationForSystemContract {
13    fn configuration_in_platform_version(
14        &self,
15        version: &PlatformVersion,
16    ) -> Result<DataContractConfig, ProtocolError>;
17}
18
19impl ConfigurationForSystemContract for SystemDataContract {
20    fn configuration_in_platform_version(
21        &self,
22        platform_version: &PlatformVersion,
23    ) -> Result<DataContractConfig, ProtocolError> {
24        match self {
25            SystemDataContract::Withdrawals => {
26                let mut config = DataContractConfig::default_for_version(platform_version)?;
27                config.set_sized_integer_types_enabled(false);
28                Ok(config)
29            }
30            SystemDataContract::MasternodeRewards => {
31                let mut config = DataContractConfig::default_for_version(platform_version)?;
32                config.set_sized_integer_types_enabled(false);
33                Ok(config)
34            }
35            // Reserved slot with no implementation. Any caller that reaches here
36            // has a bug (they should have short-circuited on `source()` returning
37            // `ContractReserved`). Return a harmless default config rather than
38            // panicking so this failure mode stays non-fatal.
39            SystemDataContract::FeatureFlags => {
40                DataContractConfig::default_for_version(platform_version)
41            }
42            SystemDataContract::DPNS => {
43                let mut config = DataContractConfig::default_for_version(platform_version)?;
44                config.set_sized_integer_types_enabled(false);
45                Ok(config)
46            }
47            SystemDataContract::Dashpay => {
48                let mut config = DataContractConfig::default_for_version(platform_version)?;
49                config.set_sized_integer_types_enabled(false);
50                Ok(config)
51            }
52            SystemDataContract::WalletUtils => {
53                let mut config = DataContractConfig::default_for_version(platform_version)?;
54                config.set_sized_integer_types_enabled(false);
55                Ok(config)
56            }
57            SystemDataContract::TokenHistory => {
58                let mut config = DataContractConfig::default_for_version(platform_version)?;
59                config.set_sized_integer_types_enabled(true);
60                Ok(config)
61            }
62            SystemDataContract::KeywordSearch => {
63                let mut config = DataContractConfig::default_for_version(platform_version)?;
64                config.set_sized_integer_types_enabled(true);
65                Ok(config)
66            }
67            SystemDataContract::DocumentHistory => {
68                let mut config = DataContractConfig::default_for_version(platform_version)?;
69                config.set_sized_integer_types_enabled(true);
70                Ok(config)
71            }
72            SystemDataContract::AppConnect => {
73                let mut config = DataContractConfig::default_for_version(platform_version)?;
74                config.set_sized_integer_types_enabled(true);
75                Ok(config)
76            }
77            SystemDataContract::ModerationCharters => {
78                let mut config = DataContractConfig::default_for_version(platform_version)?;
79                config.set_sized_integer_types_enabled(true);
80                Ok(config)
81            }
82        }
83    }
84}
85
86/// Builds a system contract from its source, with full validation, under its published id.
87///
88/// The contract is parsed under the published id rather than one derived from the owner and
89/// a nonce and renamed afterwards: the document types remember the contract id they belong
90/// to, and the checks that compare against it read that one. The moderation charters
91/// contract's key references require keys bound to its own document types (`boundTo`), which
92/// would name the wrong contract under any other id.
93fn create_data_contract(
94    factory: &DataContractFactory,
95    system_contract: SystemDataContract,
96    platform_version: &PlatformVersion,
97) -> Result<DataContract, ProtocolError> {
98    let DataContractSource {
99        id_bytes,
100        owner_id_bytes,
101        version,
102        definitions,
103        document_schemas,
104    } = system_contract
105        .source(platform_version)
106        .map_err(|e| ProtocolError::Generic(e.to_string()))?;
107
108    let mut data_contract = factory.create_with_id(
109        Identifier::from(id_bytes),
110        Identifier::from(owner_id_bytes),
111        0,
112        document_schemas.into(),
113        Some(system_contract.configuration_in_platform_version(platform_version)?),
114        definitions.map(|def| def.into()),
115    )?;
116
117    data_contract.data_contract_mut().set_version(version);
118
119    Ok(data_contract.data_contract_owned())
120}
121
122pub fn load_system_data_contract(
123    system_contract: SystemDataContract,
124    platform_version: &PlatformVersion,
125) -> Result<DataContract, ProtocolError> {
126    let factory = DataContractFactory::new(platform_version.protocol_version)?;
127
128    create_data_contract(&factory, system_contract, platform_version)
129}
130
131pub fn load_system_data_contracts(
132    system_contracts: BTreeSet<SystemDataContract>,
133    platform_version: &PlatformVersion,
134) -> Result<BTreeMap<SystemDataContract, DataContract>, ProtocolError> {
135    let factory = DataContractFactory::new(platform_version.protocol_version)?;
136
137    system_contracts
138        .into_iter()
139        .map(|system_contract| {
140            let data_contract = create_data_contract(&factory, system_contract, platform_version)?;
141
142            Ok((system_contract, data_contract))
143        })
144        .collect()
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::data_contract::serialized_version::DataContractInSerializationFormat;
151    use crate::serialization::PlatformSerializableWithPlatformVersion;
152    use platform_version::TryIntoPlatformVersioned;
153    #[test]
154    fn test_load_system_data_contract_v8_vs_v9() {
155        let contract_1 = load_system_data_contract(
156            SystemDataContract::TokenHistory,
157            PlatformVersion::get(8).unwrap(),
158        )
159        .expect("data_contract");
160        let contract_2 = load_system_data_contract(
161            SystemDataContract::TokenHistory,
162            PlatformVersion::get(9).unwrap(),
163        )
164        .expect("data_contract");
165        assert_ne!(contract_1, contract_2);
166    }
167
168    #[test]
169    fn serialize_withdrawal_contract_v1_vs_v9() {
170        let contract_1 = load_system_data_contract(
171            SystemDataContract::Withdrawals,
172            PlatformVersion::get(1).unwrap(),
173        )
174        .expect("data_contract");
175        let contract_2 = load_system_data_contract(
176            SystemDataContract::Withdrawals,
177            PlatformVersion::get(9).unwrap(),
178        )
179        .expect("data_contract");
180
181        assert_ne!(contract_1, contract_2);
182        let v1_ser: DataContractInSerializationFormat = contract_1
183            .clone()
184            .try_into_platform_versioned(PlatformVersion::get(1).unwrap())
185            .expect("expected to serialize");
186        let v2_ser: DataContractInSerializationFormat = contract_2
187            .clone()
188            .try_into_platform_versioned(PlatformVersion::get(1).unwrap())
189            .expect("expected to serialize");
190        assert_eq!(v1_ser, v2_ser);
191
192        let v1_bytes = contract_1
193            .serialize_to_bytes_with_platform_version(PlatformVersion::get(1).unwrap())
194            .expect("expected to serialize");
195        let v8_bytes = contract_1
196            .serialize_to_bytes_with_platform_version(PlatformVersion::get(8).unwrap())
197            .expect("expected to serialize");
198        let v9_bytes = contract_1
199            .serialize_to_bytes_with_platform_version(PlatformVersion::get(9).unwrap())
200            .expect("expected to serialize");
201        assert_eq!(v1_bytes.len(), 1747);
202        assert_eq!(v8_bytes.len(), 1747);
203        assert_eq!(v9_bytes.len(), 1757); // this will still use a config v0 without sized_integer_types
204
205        let v1_bytes = contract_2
206            .serialize_to_bytes_with_platform_version(PlatformVersion::get(8).unwrap())
207            .expect("expected to serialize");
208        let v8_bytes = contract_2
209            .serialize_to_bytes_with_platform_version(PlatformVersion::get(8).unwrap())
210            .expect("expected to serialize");
211        let v9_bytes = contract_2
212            .serialize_to_bytes_with_platform_version(PlatformVersion::get(9).unwrap())
213            .expect("expected to serialize");
214        assert_eq!(v1_bytes.len(), 1747);
215        assert_eq!(v8_bytes.len(), 1747);
216        assert_eq!(v9_bytes.len(), 1758); // this will use a config v1 in serialization with sized_integer_types
217    }
218}
219
220#[cfg(all(test, feature = "app-connect-contract", feature = "validation"))]
221mod app_connect_tests {
222    use super::*;
223    use crate::data_contract::accessors::v0::DataContractV0Getters;
224    use crate::data_contract::document_type::random_document::CreateRandomDocument;
225    use crate::data_contract::validate_document::DataContractDocumentValidationMethodsV0;
226    use crate::document::{Document, DocumentV0Getters, DocumentV0Setters};
227    use platform_value::Value;
228
229    fn response(contract: &DataContract) -> Document {
230        let mut document = contract
231            .document_type_for_name("loginKeyResponse")
232            .expect("response type")
233            .random_document(Some(42), PlatformVersion::latest())
234            .expect("response document");
235        document.set_properties(BTreeMap::from([
236            (
237                "appEphemeralPubKeyHash".into(),
238                Value::Bytes(vec![0x11; 20]),
239            ),
240            ("walletEphemeralPubKey".into(), Value::Bytes(vec![0x22; 33])),
241            ("encryptedPayload".into(), Value::Bytes(vec![0x33; 60])),
242        ]));
243        document
244    }
245
246    #[test]
247    fn should_validate_app_connect_response_lengths() {
248        let platform_version = PlatformVersion::latest();
249        let contract = load_system_data_contract(SystemDataContract::AppConnect, platform_version)
250            .expect("system contract");
251        for (property, length, expected) in [
252            ("appEphemeralPubKeyHash", 19, false),
253            ("appEphemeralPubKeyHash", 20, true),
254            ("appEphemeralPubKeyHash", 21, false),
255            ("walletEphemeralPubKey", 32, false),
256            ("walletEphemeralPubKey", 33, true),
257            ("walletEphemeralPubKey", 34, false),
258            ("encryptedPayload", 59, false),
259            ("encryptedPayload", 60, true),
260            ("encryptedPayload", 572, true),
261            ("encryptedPayload", 573, false),
262        ] {
263            let mut document = response(&contract);
264            document.set(property, Value::Bytes(vec![0x44; length]));
265            let result = contract
266                .validate_document("loginKeyResponse", &document, platform_version)
267                .expect("validation executes");
268            assert_eq!(
269                result.is_valid(),
270                expected,
271                "{property}: {length} bytes: {result:?}"
272            );
273        }
274    }
275
276    #[test]
277    fn should_require_only_the_three_app_connect_response_properties() {
278        let platform_version = PlatformVersion::latest();
279        let contract = load_system_data_contract(SystemDataContract::AppConnect, platform_version)
280            .expect("system contract");
281        assert_eq!(contract.document_types().len(), 1);
282        for property in [
283            "appEphemeralPubKeyHash",
284            "walletEphemeralPubKey",
285            "encryptedPayload",
286        ] {
287            let mut document = response(&contract);
288            document.properties_mut().remove(property);
289            assert!(
290                !contract
291                    .validate_document("loginKeyResponse", &document, platform_version)
292                    .expect("validation executes")
293                    .is_valid(),
294                "{property} is required"
295            );
296        }
297        let mut document = response(&contract);
298        document.set("contractId", Value::Bytes(vec![0x55; 32]));
299        assert!(
300            !contract
301                .validate_document("loginKeyResponse", &document, platform_version)
302                .expect("validation executes")
303                .is_valid(),
304            "the former contractId field is not admitted"
305        );
306    }
307}
308
309#[cfg(all(test, feature = "moderation-charters-contract", feature = "validation"))]
310mod moderation_charters_tests {
311    use super::*;
312    use crate::consensus::basic::document::PropertyConstraintViolation;
313    use crate::consensus::basic::BasicError;
314    use crate::consensus::ConsensusError;
315    use crate::data_contract::accessors::v0::DataContractV0Getters;
316    use crate::data_contract::document_type::accessors::{
317        DocumentTypeV0Getters, DocumentTypeV2Getters,
318    };
319    use crate::data_contract::document_type::random_document::CreateRandomDocument;
320    use crate::data_contract::document_type::{
321        ContestedIndexResolution, ContractReferenceModeration, DistinctFrom,
322        DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentType, EncryptedForRecipient,
323        EncryptionScheme, KeyReferenceIdentityProperty, LookupKeySource, PropertyReference,
324        StringPropertySizes,
325    };
326    use crate::data_contract::validate_document::DataContractDocumentValidationMethodsV0;
327    use crate::document::{Document, DocumentV0Getters, DocumentV0Setters};
328    use crate::identity::Purpose;
329    use crate::moderation_charter::{
330        property_names, ElectedCharter, ModerationCharterRewardSplit, SubmittedCharter,
331        ADDED_MODERATOR_DOCUMENT_TYPE_NAME, ELECTED_CHARTER_DOCUMENT_TYPE_NAME,
332        JOIN_REQUEST_DOCUMENT_TYPE_NAME, MODERATION_CHARTERS_CONTRACT_ID,
333        REASON_DOCUMENT_TYPE_NAME, REMOVED_MODERATOR_DOCUMENT_TYPE_NAME,
334        RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME,
335    };
336    use platform_value::{Identifier, Value};
337
338    fn contract() -> DataContract {
339        load_system_data_contract(
340            SystemDataContract::ModerationCharters,
341            PlatformVersion::latest(),
342        )
343        .expect("the moderation charters contract loads")
344    }
345
346    fn document_type<'a>(contract: &'a DataContract, name: &str) -> &'a DocumentType {
347        contract
348            .document_types()
349            .get(name)
350            .unwrap_or_else(|| panic!("the {name} type"))
351    }
352
353    fn reference<'a>(
354        contract: &'a DataContract,
355        type_name: &str,
356        property: &str,
357    ) -> PropertyReference<'a> {
358        document_type(contract, type_name)
359            .flattened_properties()
360            .get(property)
361            .unwrap_or_else(|| panic!("{type_name}.{property}"))
362            .property_type
363            .reference()
364            .unwrap_or_else(|| panic!("{type_name}.{property} declares a reference"))
365    }
366
367    fn proposal() -> SubmittedCharter {
368        SubmittedCharter {
369            target_contract_id: Identifier::from([9u8; 32]),
370            description: "We remove spam and doxing within a day.".to_string(),
371            reasons: vec![Identifier::from([3u8; 32]), Identifier::from([4u8; 32])],
372            moderators_share: None,
373            reward_split: ModerationCharterRewardSplit {
374                leader: 10,
375                equal: 40,
376                actions: 50,
377            },
378        }
379    }
380
381    fn document_with(
382        contract: &DataContract,
383        type_name: &str,
384        properties: std::collections::BTreeMap<String, Value>,
385    ) -> Document {
386        let mut document = document_type(contract, type_name)
387            .random_document(Some(42), PlatformVersion::latest())
388            .expect("a random document");
389        document.set_properties(properties);
390        document
391    }
392
393    fn schema_validation(
394        contract: &DataContract,
395        type_name: &str,
396        document: &Document,
397    ) -> Vec<ConsensusError> {
398        contract
399            .validate_document(type_name, document, PlatformVersion::latest())
400            .expect("validation executes")
401            .errors
402    }
403
404    /// The members a charter elects bound how many approvals a `deleteSettled` rule may ask
405    /// for, through `SystemLimits::max_moderation_charter_elected_members`: the limit and the
406    /// schema must agree.
407    #[test]
408    fn should_elect_as_many_members_as_the_system_limit_says() {
409        let contract = contract();
410        let members = document_type(&contract, ELECTED_CHARTER_DOCUMENT_TYPE_NAME)
411            .schema()
412            .get_optional_value("properties")
413            .ok()
414            .flatten()
415            .and_then(|properties| properties.get_optional_value("members").ok().flatten())
416            .and_then(|members| {
417                members
418                    .get_optional_integer::<u16>("maxItems")
419                    .ok()
420                    .flatten()
421            })
422            .expect("electedCharter.members declares maxItems");
423        assert_eq!(
424            members,
425            PlatformVersion::latest()
426                .system_limits
427                .max_moderation_charter_elected_members
428        );
429    }
430
431    #[test]
432    fn should_spell_the_same_id_in_the_crate_and_in_dpp() {
433        assert_eq!(
434            moderation_charters_contract::ID,
435            MODERATION_CHARTERS_CONTRACT_ID
436        );
437        assert_eq!(contract().id(), MODERATION_CHARTERS_CONTRACT_ID);
438    }
439
440    /// The document types remember the contract id they were parsed under, and the key bound
441    /// and lookup checks compare against it, so the contract has to be built under its
442    /// published id rather than renamed afterwards.
443    #[test]
444    fn should_parse_every_document_type_under_the_published_id() {
445        let contract = contract();
446        let mut names: Vec<&str> = contract
447            .document_types()
448            .keys()
449            .map(String::as_str)
450            .collect();
451        names.sort();
452        assert_eq!(
453            names,
454            vec![
455                ADDED_MODERATOR_DOCUMENT_TYPE_NAME,
456                ELECTED_CHARTER_DOCUMENT_TYPE_NAME,
457                JOIN_REQUEST_DOCUMENT_TYPE_NAME,
458                REASON_DOCUMENT_TYPE_NAME,
459                REMOVED_MODERATOR_DOCUMENT_TYPE_NAME,
460                RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME,
461                SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME,
462            ]
463        );
464        for name in names {
465            let document_type = document_type(&contract, name);
466            assert_eq!(
467                document_type.data_contract_id(),
468                MODERATION_CHARTERS_CONTRACT_ID
469            );
470            assert!(!document_type.documents_mutable(), "{name} is immutable");
471            // A team change is undone by deleting it: an addition takes the member off, a
472            // removal puts them back, a resignation request is withdrawn. What makes the
473            // charter is final.
474            assert_eq!(
475                document_type.documents_can_be_deleted(),
476                [
477                    ADDED_MODERATOR_DOCUMENT_TYPE_NAME,
478                    REMOVED_MODERATOR_DOCUMENT_TYPE_NAME,
479                    RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME,
480                ]
481                .contains(&name),
482                "{name}: only the team changes can be deleted"
483            );
484        }
485    }
486
487    #[test]
488    fn should_declare_only_the_elected_charter_as_a_no_locking_contest() {
489        let contract = contract();
490        for name in [
491            REASON_DOCUMENT_TYPE_NAME,
492            SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME,
493            JOIN_REQUEST_DOCUMENT_TYPE_NAME,
494            ADDED_MODERATOR_DOCUMENT_TYPE_NAME,
495            REMOVED_MODERATOR_DOCUMENT_TYPE_NAME,
496            RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME,
497        ] {
498            assert!(
499                document_type(&contract, name)
500                    .find_contested_index()
501                    .is_none(),
502                "{name} is not contested"
503            );
504        }
505        let index = document_type(&contract, ELECTED_CHARTER_DOCUMENT_TYPE_NAME)
506            .find_contested_index()
507            .expect("the elected charter type has a contested index");
508        assert_eq!(index.name, "byTargetContract");
509        assert!(index.unique);
510        assert_eq!(
511            index
512                .properties
513                .iter()
514                .map(|p| p.name.as_str())
515                .collect::<Vec<_>>(),
516            vec![property_names::TARGET_CONTRACT_ID]
517        );
518        assert_eq!(
519            index
520                .contested_index
521                .as_ref()
522                .expect("contested")
523                .resolution,
524            ContestedIndexResolution::MasternodeVoteNoLocking
525        );
526    }
527
528    #[test]
529    fn should_let_teams_form_before_the_election_opens() {
530        let contract = contract();
531        let moderation =
532            |type_name| match reference(&contract, type_name, property_names::TARGET_CONTRACT_ID) {
533                PropertyReference::Value(DocumentPropertyReferenceTarget::Contract {
534                    contract_requirements,
535                }) => contract_requirements.moderation,
536                other => panic!("{type_name}.targetContractId: {other:?}"),
537            };
538        assert_eq!(
539            moderation(SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME),
540            Some(ContractReferenceModeration::Elected)
541        );
542        assert_eq!(
543            moderation(ELECTED_CHARTER_DOCUMENT_TYPE_NAME),
544            Some(ContractReferenceModeration::ElectionOpen)
545        );
546    }
547
548    #[test]
549    fn should_list_reasons_as_references_to_reason_documents() {
550        let contract = contract();
551        match reference(
552            &contract,
553            SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME,
554            property_names::REASONS,
555        ) {
556            PropertyReference::Elements {
557                target:
558                    DocumentPropertyReferenceTarget::PermanentDocument {
559                        contract_id: None,
560                        document_type_name,
561                        ..
562                    },
563                max_items,
564            } => {
565                assert_eq!(document_type_name, REASON_DOCUMENT_TYPE_NAME);
566                assert_eq!(max_items, 64);
567            }
568            other => panic!("reasons: {other:?}"),
569        }
570    }
571
572    #[test]
573    fn should_address_a_join_request_to_the_leader_under_bound_keys() {
574        let contract = contract();
575        match reference(
576            &contract,
577            JOIN_REQUEST_DOCUMENT_TYPE_NAME,
578            "submittedCharterId",
579        ) {
580            PropertyReference::Value(DocumentPropertyReferenceTarget::PermanentDocument {
581                document_type_name,
582                property_agreement,
583                ..
584            }) => {
585                assert_eq!(document_type_name, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME);
586                assert_eq!(
587                    property_agreement.get("recipientId").map(String::as_str),
588                    Some("$ownerId"),
589                    "the recipient is the proposal's owner"
590                );
591            }
592            other => panic!("submittedCharterId: {other:?}"),
593        }
594        match reference(&contract, JOIN_REQUEST_DOCUMENT_TYPE_NAME, "recipientId") {
595            PropertyReference::Value(DocumentPropertyReferenceTarget::IdentityPublicKey {
596                key_id_property,
597                key_requirements,
598            }) => {
599                assert_eq!(key_id_property, "recipientKeyId");
600                assert_eq!(key_requirements.purpose, Some(Purpose::DECRYPTION));
601                assert_eq!(
602                    key_requirements.bound_to.as_deref(),
603                    Some(SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME)
604                );
605            }
606            other => panic!("recipientId: {other:?}"),
607        }
608        match reference(&contract, JOIN_REQUEST_DOCUMENT_TYPE_NAME, "senderKeyId") {
609            PropertyReference::KeyId(key_reference) => {
610                assert_eq!(
611                    key_reference.identity_property,
612                    KeyReferenceIdentityProperty::OwnerId
613                );
614                assert_eq!(
615                    key_reference.key_requirements.purpose,
616                    Some(Purpose::ENCRYPTION)
617                );
618                assert_eq!(
619                    key_reference.key_requirements.bound_to.as_deref(),
620                    Some(JOIN_REQUEST_DOCUMENT_TYPE_NAME)
621                );
622            }
623            other => panic!("senderKeyId: {other:?}"),
624        }
625        let encrypted_for = document_type(&contract, JOIN_REQUEST_DOCUMENT_TYPE_NAME)
626            .flattened_properties()
627            .get("encryptedMessage")
628            .and_then(|property| property.encrypted_for.clone())
629            .expect("the message declares its envelope");
630        assert_eq!(
631            encrypted_for.recipient,
632            EncryptedForRecipient::Property("recipientId".to_string())
633        );
634        assert_eq!(encrypted_for.recipient_key, "recipientKeyId");
635        assert_eq!(encrypted_for.sender_key, "senderKeyId");
636        assert_eq!(
637            encrypted_for.scheme,
638            EncryptionScheme::EcdhSecp256k1Aes256Cbc
639        );
640    }
641
642    #[test]
643    fn should_open_the_contest_only_from_the_leaders_own_proposal() {
644        let contract = contract();
645        match reference(
646            &contract,
647            ELECTED_CHARTER_DOCUMENT_TYPE_NAME,
648            property_names::SUBMITTED_CHARTER_ID,
649        ) {
650            PropertyReference::Value(DocumentPropertyReferenceTarget::PermanentDocument {
651                document_type_name,
652                property_agreement,
653                ..
654            }) => {
655                assert_eq!(document_type_name, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME);
656                assert_eq!(
657                    property_agreement.get("$ownerId").map(String::as_str),
658                    Some("$ownerId")
659                );
660                assert_eq!(
661                    property_agreement
662                        .get(property_names::TARGET_CONTRACT_ID)
663                        .map(String::as_str),
664                    Some(property_names::TARGET_CONTRACT_ID)
665                );
666            }
667            other => panic!("submittedCharterId: {other:?}"),
668        }
669    }
670
671    #[test]
672    fn should_choose_members_only_from_the_proposals_join_requests() {
673        let contract = contract();
674        let members = document_type(&contract, ELECTED_CHARTER_DOCUMENT_TYPE_NAME)
675            .flattened_properties()
676            .get(property_names::MEMBERS)
677            .expect("members")
678            .clone();
679        match members.property_type.reference() {
680            Some(PropertyReference::Elements {
681                target:
682                    DocumentPropertyReferenceTarget::PermanentDocumentLookup {
683                        document_type_name,
684                        lookup,
685                        ..
686                    },
687                max_items,
688            }) => {
689                assert_eq!(document_type_name, JOIN_REQUEST_DOCUMENT_TYPE_NAME);
690                assert_eq!(max_items, 15);
691                assert_eq!(
692                    lookup.resolve_index(
693                        document_type(&contract, JOIN_REQUEST_DOCUMENT_TYPE_NAME).as_ref()
694                    ),
695                    Ok("bySubmittedCharter".to_string())
696                );
697                assert_eq!(
698                    lookup.keys.get(property_names::SUBMITTED_CHARTER_ID),
699                    Some(&LookupKeySource::Property(
700                        property_names::SUBMITTED_CHARTER_ID.to_string()
701                    ))
702                );
703                assert_eq!(
704                    lookup.keys.get("$ownerId"),
705                    Some(&LookupKeySource::ReferenceValue)
706                );
707            }
708            other => panic!("members: {other:?}"),
709        }
710        let DocumentPropertyType::TypedArray(typed_array) = &members.property_type else {
711            panic!("members is a typed array");
712        };
713        assert!(typed_array.unique_items);
714        assert_eq!(typed_array.min_items.unwrap_or_default(), 0);
715        assert_eq!(
716            members.distinct_from,
717            Some(DistinctFrom::OwnerId),
718            "the leader cannot list themself"
719        );
720        let join_request = document_type(&contract, JOIN_REQUEST_DOCUMENT_TYPE_NAME);
721        let index = join_request
722            .indexes()
723            .get("bySubmittedCharter")
724            .expect("the join request's unique index");
725        assert!(index.unique);
726        assert!(
727            !join_request.documents_transferable().is_transferable(),
728            "a lookup may key on $ownerId only on a type that cannot change hands"
729        );
730    }
731
732    #[test]
733    fn should_round_trip_a_proposal_through_the_system_contract() {
734        let contract = contract();
735        let proposal = proposal();
736        let document = document_with(
737            &contract,
738            SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME,
739            proposal.to_document_properties(),
740        );
741        assert_eq!(
742            schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document),
743            vec![],
744            "the encoded proposal passes the schema"
745        );
746        let read = SubmittedCharter::from_document_properties(document.properties())
747            .into_data()
748            .expect("the proposal reads");
749        assert_eq!(read, proposal);
750    }
751
752    /// A proposal's reward split is held to 100 by a `propertyConstraints` rule, which
753    /// the contract's document validation applies, so consensus checks it on every create
754    /// and a client validating the document sees the same answer.
755    #[test]
756    fn should_hold_a_proposal_reward_split_to_one_hundred() {
757        let contract = contract();
758        let submitted_charter = document_type(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME);
759        assert_eq!(
760            submitted_charter
761                .property_constraints()
762                .keys()
763                .collect::<Vec<_>>(),
764            ["rewardSplitIsWhole"]
765        );
766        let judge = |leader: u8, equal: u8, actions: u8| {
767            let mut proposal = proposal();
768            proposal.reward_split = ModerationCharterRewardSplit {
769                leader,
770                equal,
771                actions,
772            };
773            let document = document_with(
774                &contract,
775                SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME,
776                proposal.to_document_properties(),
777            );
778            schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document)
779        };
780        assert_eq!(judge(10, 40, 50), vec![]);
781        assert_eq!(judge(100, 0, 0), vec![]);
782        for (leader, equal, actions) in [(10, 40, 40), (40, 40, 40), (0, 0, 0)] {
783            let errors = judge(leader, equal, actions);
784            assert!(
785                matches!(
786                    errors.as_slice(),
787                    [ConsensusError::BasicError(
788                        BasicError::DocumentPropertyConstraintViolatedError(e)
789                    )] if e.constraint() == "rewardSplitIsWhole"
790                        && e.violation() == PropertyConstraintViolation::NotMet
791                ),
792                "{leader} + {equal} + {actions}: {errors:?}"
793            );
794        }
795    }
796
797    #[test]
798    fn should_round_trip_an_elected_charter_through_the_system_contract() {
799        let contract = contract();
800        let charter = ElectedCharter {
801            target_contract_id: Identifier::from([9u8; 32]),
802            submitted_charter_id: Identifier::from([7u8; 32]),
803            members: vec![Identifier::from([2u8; 32]), Identifier::from([5u8; 32])],
804        };
805        let document = document_with(
806            &contract,
807            ELECTED_CHARTER_DOCUMENT_TYPE_NAME,
808            charter.to_document_properties(),
809        );
810        assert_eq!(
811            schema_validation(&contract, ELECTED_CHARTER_DOCUMENT_TYPE_NAME, &document),
812            vec![],
813            "the encoded elected charter passes the schema"
814        );
815        let read = ElectedCharter::from_document_properties(document.properties())
816            .into_data()
817            .expect("the elected charter reads back");
818        assert_eq!(read, charter);
819    }
820
821    #[test]
822    fn should_refuse_through_the_schema_what_it_can_express() {
823        let contract = contract();
824        let too_many =
825            |count: u8| Value::Array((0..count).map(|i| Value::Identifier([i; 32])).collect());
826        for (type_name, property, value, expected_keyword) in [
827            (
828                SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME,
829                property_names::DESCRIPTION,
830                Value::Text("a".repeat(4097)),
831                "maxLength",
832            ),
833            (
834                SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME,
835                property_names::DESCRIPTION,
836                Value::Text(String::new()),
837                "minLength",
838            ),
839            (
840                SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME,
841                property_names::REASONS,
842                too_many(65),
843                "maxItems",
844            ),
845            (
846                SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME,
847                property_names::REASONS,
848                Value::Array(vec![Value::Identifier([3; 32]), Value::Identifier([3; 32])]),
849                "uniqueItems",
850            ),
851            (
852                SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME,
853                property_names::MODERATORS_SHARE,
854                Value::U8(101),
855                "maximum",
856            ),
857            (
858                ELECTED_CHARTER_DOCUMENT_TYPE_NAME,
859                property_names::MEMBERS,
860                too_many(16),
861                "maxItems",
862            ),
863        ] {
864            let properties = if type_name == SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME {
865                proposal().to_document_properties()
866            } else {
867                ElectedCharter {
868                    target_contract_id: Identifier::from([9u8; 32]),
869                    submitted_charter_id: Identifier::from([7u8; 32]),
870                    members: vec![],
871                }
872                .to_document_properties()
873            };
874            let mut document = document_with(&contract, type_name, properties);
875            document.set(property, value);
876            let errors = schema_validation(&contract, type_name, &document);
877            let message = format!("{errors:?}");
878            assert!(
879                !errors.is_empty() && message.contains(expected_keyword),
880                "{type_name}.{property}: expected a {expected_keyword} error, got {message}"
881            );
882        }
883        for property in [
884            property_names::TARGET_CONTRACT_ID,
885            property_names::DESCRIPTION,
886            property_names::REASONS,
887            property_names::REWARD_SPLIT,
888        ] {
889            let mut document = document_with(
890                &contract,
891                SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME,
892                proposal().to_document_properties(),
893            );
894            document.properties_mut().remove(property);
895            assert!(
896                !schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document)
897                    .is_empty(),
898                "{property} is required"
899            );
900        }
901    }
902
903    /// The description's cap is 4096 bytes, not just 4096 characters: the schema's `maxBytes`,
904    /// which document validation checks after the JSON schema, so clients refuse an oversized
905    /// description before they broadcast it.
906    #[test]
907    fn should_refuse_a_description_over_4096_bytes_within_4096_characters() {
908        let contract = contract();
909        assert_eq!(
910            document_type(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME)
911                .flattened_properties()
912                .get(property_names::DESCRIPTION)
913                .expect("the description")
914                .property_type,
915            DocumentPropertyType::String(StringPropertySizes {
916                min_length: Some(1),
917                max_length: Some(4096),
918                max_bytes: Some(4096),
919            })
920        );
921        let with_description = |description: String| {
922            document_with(
923                &contract,
924                SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME,
925                SubmittedCharter {
926                    description,
927                    ..proposal()
928                }
929                .to_document_properties(),
930            )
931        };
932
933        // At the cap, in one-byte and in two-byte characters
934        for description in ["a".repeat(4096), "é".repeat(2048)] {
935            let document = with_description(description);
936            assert_eq!(
937                schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document),
938                vec![]
939            );
940        }
941
942        // 2049 characters are within maxLength, but their 4098 bytes are over maxBytes
943        let document = with_description("é".repeat(2049));
944        assert!(matches!(
945            schema_validation(&contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, &document)
946                .as_slice(),
947            [ConsensusError::BasicError(BasicError::DocumentPropertyMaxBytesExceededError(e))]
948                if e.property() == property_names::DESCRIPTION
949                    && e.byte_length() == 4098
950                    && e.max_bytes() == 4096
951        ));
952    }
953
954    /// After the election the leader adds members from the same join requests and removes
955    /// elected members, and a member asks to leave: each change is written once per member,
956    /// and only by the one entitled to it.
957    #[test]
958    fn should_let_only_the_leader_change_the_team_and_only_a_member_resign() {
959        let contract = contract();
960        let charter_agreement =
961            |type_name| match reference(&contract, type_name, property_names::ELECTED_CHARTER_ID) {
962                PropertyReference::Value(DocumentPropertyReferenceTarget::PermanentDocument {
963                    document_type_name,
964                    property_agreement,
965                    ..
966                }) => {
967                    assert_eq!(document_type_name, ELECTED_CHARTER_DOCUMENT_TYPE_NAME);
968                    property_agreement.clone()
969                }
970                other => panic!("{type_name}.electedCharterId: {other:?}"),
971            };
972
973        let added = charter_agreement(ADDED_MODERATOR_DOCUMENT_TYPE_NAME);
974        assert_eq!(added.get("$ownerId").map(String::as_str), Some("$ownerId"));
975        assert_eq!(
976            added
977                .get(property_names::SUBMITTED_CHARTER_ID)
978                .map(String::as_str),
979            Some(property_names::SUBMITTED_CHARTER_ID)
980        );
981        let removed = charter_agreement(REMOVED_MODERATOR_DOCUMENT_TYPE_NAME);
982        assert_eq!(
983            removed.get("$ownerId").map(String::as_str),
984            Some("$ownerId")
985        );
986        let resignation = charter_agreement(RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME);
987        assert_eq!(
988            resignation.get("recipientId").map(String::as_str),
989            Some("$ownerId"),
990            "a resignation is addressed to the charter's leader"
991        );
992
993        match reference(
994            &contract,
995            ADDED_MODERATOR_DOCUMENT_TYPE_NAME,
996            property_names::MEMBER_ID,
997        ) {
998            PropertyReference::Value(
999                DocumentPropertyReferenceTarget::PermanentDocumentLookup {
1000                    document_type_name,
1001                    lookup,
1002                    ..
1003                },
1004            ) => {
1005                assert_eq!(document_type_name, JOIN_REQUEST_DOCUMENT_TYPE_NAME);
1006                assert_eq!(
1007                    lookup.resolve_index(
1008                        document_type(&contract, JOIN_REQUEST_DOCUMENT_TYPE_NAME).as_ref()
1009                    ),
1010                    Ok("bySubmittedCharter".to_string())
1011                );
1012                assert_eq!(
1013                    lookup.keys.get("$ownerId"),
1014                    Some(&LookupKeySource::ReferenceValue)
1015                );
1016            }
1017            other => panic!("addedModerator.memberId: {other:?}"),
1018        }
1019        // Only an elected member can be removed; an added one is taken off by deleting the
1020        // addition
1021        match reference(
1022            &contract,
1023            REMOVED_MODERATOR_DOCUMENT_TYPE_NAME,
1024            property_names::MEMBER_ID,
1025        ) {
1026            PropertyReference::Value(DocumentPropertyReferenceTarget::ListElement(listed)) => {
1027                assert_eq!(
1028                    listed.document_type_name,
1029                    ELECTED_CHARTER_DOCUMENT_TYPE_NAME
1030                );
1031                assert_eq!(listed.in_list, property_names::MEMBERS);
1032                assert_eq!(
1033                    listed.document_id_property(),
1034                    Some(property_names::ELECTED_CHARTER_ID)
1035                );
1036            }
1037            other => panic!("removedModerator.memberId: {other:?}"),
1038        }
1039        for type_name in [
1040            ADDED_MODERATOR_DOCUMENT_TYPE_NAME,
1041            REMOVED_MODERATOR_DOCUMENT_TYPE_NAME,
1042        ] {
1043            let member = document_type(&contract, type_name)
1044                .flattened_properties()
1045                .get(property_names::MEMBER_ID)
1046                .expect("memberId");
1047            assert_eq!(
1048                member.distinct_from,
1049                Some(DistinctFrom::OwnerId),
1050                "{type_name}: the leader is not a member"
1051            );
1052        }
1053
1054        for (type_name, index_name, member_property) in [
1055            (
1056                ADDED_MODERATOR_DOCUMENT_TYPE_NAME,
1057                "byElectedCharterMember",
1058                property_names::MEMBER_ID,
1059            ),
1060            (
1061                REMOVED_MODERATOR_DOCUMENT_TYPE_NAME,
1062                "byElectedCharterMember",
1063                property_names::MEMBER_ID,
1064            ),
1065            (
1066                RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME,
1067                "byElectedCharterOwner",
1068                "$ownerId",
1069            ),
1070        ] {
1071            let index = document_type(&contract, type_name)
1072                .indexes()
1073                .get(index_name)
1074                .unwrap_or_else(|| panic!("{type_name}.{index_name}"));
1075            assert!(index.unique, "{type_name}: once per member and charter");
1076            assert_eq!(
1077                index
1078                    .properties
1079                    .iter()
1080                    .map(|p| p.name.as_str())
1081                    .collect::<Vec<_>>(),
1082                vec![property_names::ELECTED_CHARTER_ID, member_property]
1083            );
1084        }
1085    }
1086
1087    /// Only a member of the seated team may ask to leave: the writer is listed in the elected
1088    /// charter's `members`, or the leader added it after the election and has not deleted the
1089    /// addition. The request is
1090    /// deletable, which withdraws it, and carries a message only the leader can read.
1091    #[test]
1092    fn should_let_only_a_team_member_ask_to_leave() {
1093        let contract = contract();
1094        let resignation = document_type(&contract, RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME);
1095        assert!(resignation.documents_can_be_deleted());
1096
1097        let Some(DocumentPropertyReferenceTarget::AnyOf(operands)) = resignation.owner_reference()
1098        else {
1099            panic!(
1100                "the writer must meet any of the membership targets: {:?}",
1101                resignation.owner_reference()
1102            );
1103        };
1104        match operands.operands() {
1105            [DocumentPropertyReferenceTarget::ListElement(listed), DocumentPropertyReferenceTarget::DeletableDocumentLookup {
1106                document_type_name,
1107                lookup,
1108                ..
1109            }] => {
1110                assert_eq!(
1111                    listed.document_type_name,
1112                    ELECTED_CHARTER_DOCUMENT_TYPE_NAME
1113                );
1114                assert_eq!(listed.in_list, property_names::MEMBERS);
1115                assert_eq!(
1116                    listed.document_id_property(),
1117                    Some(property_names::ELECTED_CHARTER_ID)
1118                );
1119                assert_eq!(document_type_name, ADDED_MODERATOR_DOCUMENT_TYPE_NAME);
1120                assert_eq!(
1121                    lookup.resolve_index(
1122                        document_type(&contract, ADDED_MODERATOR_DOCUMENT_TYPE_NAME).as_ref()
1123                    ),
1124                    Ok("byElectedCharterMember".to_string())
1125                );
1126                assert_eq!(
1127                    lookup.keys.get(property_names::MEMBER_ID),
1128                    Some(&LookupKeySource::ReferenceValue)
1129                );
1130            }
1131            other => panic!("resignation membership operands: {other:?}"),
1132        }
1133
1134        let encrypted_for = resignation
1135            .flattened_properties()
1136            .get("encryptedMessage")
1137            .and_then(|property| property.encrypted_for.clone())
1138            .expect("the message declares its envelope");
1139        assert_eq!(
1140            encrypted_for.recipient,
1141            EncryptedForRecipient::Property("recipientId".to_string())
1142        );
1143        match reference(
1144            &contract,
1145            RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME,
1146            "senderKeyId",
1147        ) {
1148            PropertyReference::KeyId(key_reference) => {
1149                assert_eq!(
1150                    key_reference.key_requirements.bound_to.as_deref(),
1151                    Some(JOIN_REQUEST_DOCUMENT_TYPE_NAME),
1152                    "the member's encryption key is the one its join request used"
1153                );
1154            }
1155            other => panic!("senderKeyId: {other:?}"),
1156        }
1157    }
1158}