1use crate::data_contract::config::moderation::{
16 document_schema_lets_moderators_change_fields, document_schema_lets_moderators_delete,
17 document_schema_lets_moderators_delete_settled, ContractModerationConfig,
18};
19use crate::data_contract::DocumentName;
20use crate::prelude::TimestampMillis;
21#[cfg(feature = "json-conversion")]
22use crate::serialization::JsonSafeFields;
23use bincode::{Decode, DecodeUntrusted, Encode};
24use dashcore::Network;
25use platform_value::{Identifier, Value};
26use platform_version::version::PlatformVersion;
27use serde::{Deserialize, Serialize};
28use std::collections::{BTreeMap, BTreeSet};
29use std::fmt;
30
31pub const DEFAULT_ELECTION_WINDOW_SECONDS: u32 = 604_800;
33
34pub mod property_names {
36 pub const JOIN_WINDOW: &str = "joinWindow";
38 pub const VOTE_WINDOW: &str = "voteWindow";
40 pub const SEAT_CONTESTABLE: &str = "seatContestable";
42 pub const CHALLENGE_COOL_DOWN: &str = "challengeCoolDown";
44 pub const ELECTION_DELAY: &str = "electionDelay";
46 pub const MAX_ADDED_MODERATORS: &str = "maxAddedModerators";
48 pub const MODERATED_DOCUMENT_TYPES: &str = "moderatedDocumentTypes";
50 pub const INTERIM: &str = "interim";
52 pub const OWNER_PROTECTED: &str = "ownerProtected";
54 pub const TYPE: &str = "$type";
56 pub const IDENTITIES: &str = "identities";
58}
59
60#[derive(
67 Debug,
68 Clone,
69 Copy,
70 PartialEq,
71 Eq,
72 PartialOrd,
73 Ord,
74 Hash,
75 Encode,
76 Decode,
77 DecodeUntrusted,
78 Serialize,
79 Deserialize,
80)]
81#[serde(rename_all = "camelCase")]
82pub enum ModerationAbility {
83 DeleteDocuments,
86 Ban,
89 Suspend,
92 Warn,
95 ChangeDocumentFields,
99}
100
101impl ModerationAbility {
102 pub fn as_str(&self) -> &'static str {
104 match self {
105 ModerationAbility::DeleteDocuments => "deleteDocuments",
106 ModerationAbility::Ban => "ban",
107 ModerationAbility::Suspend => "suspend",
108 ModerationAbility::Warn => "warn",
109 ModerationAbility::ChangeDocumentFields => "changeDocumentFields",
110 }
111 }
112}
113
114impl fmt::Display for ModerationAbility {
115 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116 f.write_str(self.as_str())
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, DecodeUntrusted)]
129pub enum InterimModerators {
130 ContractOwner,
132 AppointedModerators(BTreeSet<Identifier>),
136 NotYetUsable,
139 NoModeration,
142}
143
144impl InterimModerators {
145 pub fn identity_ids(&self) -> Option<&BTreeSet<Identifier>> {
147 match self {
148 InterimModerators::AppointedModerators(ids) => Some(ids),
149 InterimModerators::ContractOwner
150 | InterimModerators::NotYetUsable
151 | InterimModerators::NoModeration => None,
152 }
153 }
154
155 pub fn may_moderate(&self, owner_id: &Identifier, identity_id: &Identifier) -> bool {
157 match self {
158 InterimModerators::ContractOwner => owner_id == identity_id,
159 InterimModerators::AppointedModerators(ids) => {
160 owner_id == identity_id || ids.contains(identity_id)
161 }
162 InterimModerators::NotYetUsable | InterimModerators::NoModeration => false,
163 }
164 }
165
166 pub fn team(&self, owner_id: &Identifier) -> BTreeSet<Identifier> {
170 match self {
171 InterimModerators::ContractOwner => BTreeSet::from([*owner_id]),
172 InterimModerators::AppointedModerators(ids) => ids.clone(),
173 InterimModerators::NotYetUsable | InterimModerators::NoModeration => BTreeSet::new(),
174 }
175 }
176
177 pub fn blocks_moderated_document_types(&self) -> bool {
179 matches!(self, InterimModerators::NotYetUsable)
180 }
181
182 fn type_name(&self) -> &'static str {
184 match self {
185 InterimModerators::ContractOwner => "contractOwner",
186 InterimModerators::AppointedModerators(_) => "appointedModerators",
187 InterimModerators::NotYetUsable => "notYetUsable",
188 InterimModerators::NoModeration => "noModeration",
189 }
190 }
191}
192
193impl Serialize for InterimModerators {
197 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
198 use serde::ser::SerializeMap;
199 let identities = self.identity_ids();
200 let mut m = serializer.serialize_map(Some(1 + usize::from(identities.is_some())))?;
201 m.serialize_entry(property_names::TYPE, self.type_name())?;
202 if let Some(ids) = identities {
203 m.serialize_entry(property_names::IDENTITIES, ids)?;
204 }
205 m.end()
206 }
207}
208
209impl<'de> Deserialize<'de> for InterimModerators {
210 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
211 use serde::de::{self, MapAccess, Visitor};
212
213 struct V;
214
215 impl<'de> Visitor<'de> for V {
216 type Value = InterimModerators;
217
218 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
219 f.write_str(
220 "InterimModerators as a map with a `$type` discriminator, \
221 e.g. {\"$type\": \"contractOwner\"}, \
222 {\"$type\": \"appointedModerators\", \"identities\": [\"<base58>\"]}, \
223 {\"$type\": \"notYetUsable\"} or {\"$type\": \"noModeration\"}",
224 )
225 }
226
227 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
228 let mut variant: Option<String> = None;
229 let mut identities: Option<BTreeSet<Identifier>> = None;
230
231 while let Some(key) = map.next_key::<String>()? {
232 match key.as_str() {
233 property_names::TYPE => {
234 if variant.is_some() {
235 return Err(de::Error::duplicate_field(property_names::TYPE));
236 }
237 variant = Some(map.next_value()?);
238 }
239 property_names::IDENTITIES => {
240 if identities.is_some() {
241 return Err(de::Error::duplicate_field(property_names::IDENTITIES));
242 }
243 identities = Some(map.next_value()?);
244 }
245 other => {
248 return Err(de::Error::unknown_field(
249 other,
250 &[property_names::TYPE, property_names::IDENTITIES],
251 ));
252 }
253 }
254 }
255
256 let variant =
257 variant.ok_or_else(|| de::Error::missing_field(property_names::TYPE))?;
258 let without_identities = |kind: InterimModerators| {
259 if identities.is_some() {
260 return Err(de::Error::custom(
261 "`identities` is only valid for `appointedModerators`",
262 ));
263 }
264 Ok(kind)
265 };
266 match variant.as_str() {
267 "contractOwner" => without_identities(InterimModerators::ContractOwner),
268 "notYetUsable" => without_identities(InterimModerators::NotYetUsable),
269 "noModeration" => without_identities(InterimModerators::NoModeration),
270 "appointedModerators" => {
271 let ids = identities
272 .ok_or_else(|| de::Error::missing_field(property_names::IDENTITIES))?;
273 Ok(InterimModerators::AppointedModerators(ids))
274 }
275 other => Err(de::Error::unknown_variant(
276 other,
277 &[
278 "contractOwner",
279 "appointedModerators",
280 "notYetUsable",
281 "noModeration",
282 ],
283 )),
284 }
285 }
286 }
287
288 deserializer.deserialize_map(V)
289 }
290}
291
292impl fmt::Display for InterimModerators {
293 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294 match self {
295 InterimModerators::ContractOwner => write!(f, "the contract owner"),
296 InterimModerators::AppointedModerators(ids) => {
297 write!(
298 f,
299 "the contract owner and {} appointed moderators",
300 ids.len()
301 )
302 }
303 InterimModerators::NotYetUsable => {
304 write!(f, "nobody, its moderated document types not yet usable")
305 }
306 InterimModerators::NoModeration => {
307 write!(
308 f,
309 "nobody, its moderated document types unmoderated meanwhile"
310 )
311 }
312 }
313 }
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, DecodeUntrusted)]
319pub struct ElectedModerators {
320 pub join_window: u32,
327 pub vote_window: u32,
330 pub challenge_cool_down: Option<u32>,
345 pub election_delay: Option<u32>,
351 pub max_added_moderators: u16,
358 pub moderated_document_types: BTreeMap<DocumentName, BTreeSet<ModerationAbility>>,
369 pub interim: InterimModerators,
371 pub owner_protected: bool,
376}
377
378impl ElectedModerators {
379 pub fn seat_contestable(&self) -> bool {
382 self.challenge_cool_down.is_some()
383 }
384
385 pub fn election_is_open(
392 &self,
393 contract_created_at: Option<TimestampMillis>,
394 block_time_ms: TimestampMillis,
395 ) -> bool {
396 match self.election_delay {
397 None => true,
398 Some(delay) => contract_created_at.is_some_and(|created_at| {
399 block_time_ms
400 >= created_at.saturating_add(TimestampMillis::from(delay).saturating_mul(1000))
401 }),
402 }
403 }
404
405 pub fn moderates_document_type(&self, document_type_name: &str) -> bool {
407 self.moderated_document_types
408 .contains_key(document_type_name)
409 }
410
411 pub fn allows(&self, document_type_name: &str, ability: ModerationAbility) -> bool {
413 self.moderated_document_types
414 .get(document_type_name)
415 .is_some_and(|abilities| abilities.contains(&ability))
416 }
417
418 pub fn allows_on_any_type(&self, ability: ModerationAbility) -> bool {
423 self.moderated_document_types
424 .values()
425 .any(|abilities| abilities.contains(&ability))
426 }
427
428 pub fn interim_blocks_document_type(&self, document_type_name: &str) -> bool {
433 self.interim.blocks_moderated_document_types()
434 && self.moderates_document_type(document_type_name)
435 }
436
437 pub(super) fn validation_error(
446 &self,
447 config: &ContractModerationConfig,
448 document_schemas: &BTreeMap<DocumentName, Value>,
449 network: Network,
450 platform_version: &PlatformVersion,
451 ) -> Option<String> {
452 let limits = &platform_version.system_limits;
453 let within = |what: &str, seconds: u32, min: u32, max: u32| {
454 (seconds < min || seconds > max).then(|| {
455 format!("the {what} of {seconds} seconds is outside {min} to {max} seconds")
456 })
457 };
458 let window_min = match network {
459 Network::Mainnet => limits.min_mainnet_contract_moderation_election_window_seconds,
460 _ => 0,
461 };
462 let window_max = limits.max_contract_moderation_election_window_seconds;
463 if let Some(reason) = within("join window", self.join_window, window_min, window_max)
464 .or_else(|| within("vote window", self.vote_window, window_min, window_max))
465 .or_else(|| {
466 self.challenge_cool_down.and_then(|cool_down| {
468 within(
469 "challenge cool-down",
470 cool_down,
471 limits.min_contract_moderation_challenge_cool_down_seconds,
472 limits.max_contract_moderation_challenge_cool_down_seconds,
473 )
474 })
475 })
476 {
477 return Some(reason);
478 }
479 let max_added = limits.max_contract_moderation_added_moderators;
480 if self.max_added_moderators > max_added {
481 return Some(format!(
482 "the {} members a leader may add exceed the limit of {max_added}",
483 self.max_added_moderators
484 ));
485 }
486
487 if self.moderated_document_types.is_empty() {
488 return Some("the moderated document type set is empty".to_string());
489 }
490 for (document_type_name, abilities) in &self.moderated_document_types {
491 let Some(schema) = document_schemas.get(document_type_name) else {
492 return Some(format!(
493 "the moderated document type \"{document_type_name}\" is not a document \
494 type of the contract"
495 ));
496 };
497 if abilities.is_empty() {
498 return Some(format!(
499 "the ability set of the moderated document type \"{document_type_name}\" \
500 is empty"
501 ));
502 }
503 for ability in abilities {
504 let unbacked = match ability {
505 ModerationAbility::Ban if !config.banlist => {
506 Some("bans, but the contract keeps no banlist")
507 }
508 ModerationAbility::Suspend if !config.suspensions => {
509 Some("suspensions, but the contract keeps no suspension list")
510 }
511 ModerationAbility::Warn if !config.warnings => {
512 Some("warnings, but the contract keeps no warning list")
513 }
514 ModerationAbility::DeleteDocuments
515 if !document_schema_lets_moderators_delete(schema) =>
516 {
517 Some("document deletions, but the type can not be deleted by moderators")
518 }
519 ModerationAbility::ChangeDocumentFields
520 if !document_schema_lets_moderators_change_fields(schema) =>
521 {
522 Some(
523 "document field changes, but the type lists no field moderators \
524 write",
525 )
526 }
527 _ => None,
528 };
529 if let Some(unbacked) = unbacked {
530 return Some(format!(
531 "the moderated document type \"{document_type_name}\" allows {unbacked}"
532 ));
533 }
534 }
535 }
536
537 if let Some(document_type_name) = document_schemas
541 .iter()
542 .filter(|(_, schema)| document_schema_lets_moderators_change_fields(schema))
543 .map(|(name, _)| name)
544 .find(|name| !self.allows(name, ModerationAbility::ChangeDocumentFields))
545 {
546 return Some(format!(
547 "the document type \"{document_type_name}\" keeps fields only moderators write, \
548 but the moderated set does not give the team `changeDocumentFields` on it, so \
549 nobody could write them once a team is seated"
550 ));
551 }
552
553 if let Some(document_type_name) = document_schemas
557 .iter()
558 .filter(|(_, schema)| document_schema_lets_moderators_delete_settled(schema))
559 .map(|(name, _)| name)
560 .find(|name| !self.allows(name, ModerationAbility::DeleteDocuments))
561 {
562 return Some(format!(
563 "the document type \"{document_type_name}\" says who of the team must approve the \
564 deletion of a settled document, but the moderated set does not give the team \
565 `deleteDocuments` on it, so no team could ever delete one"
566 ));
567 }
568
569 None
570 }
571}
572
573impl fmt::Display for ElectedModerators {
574 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
575 write!(
576 f,
577 "an elected moderation team, in its interim moderated by {}",
578 self.interim
579 )?;
580 match self.challenge_cool_down {
581 Some(cool_down) => write!(
582 f,
583 ", its seat open to a challenge {cool_down} seconds after each seat change"
584 )?,
585 None => write!(f, ", its seat never contested once a team is seated")?,
586 }
587 if let Some(delay) = self.election_delay {
588 write!(
589 f,
590 ", its first election open {delay} seconds after the contract's creation"
591 )?;
592 }
593 if self.max_added_moderators > 0 {
594 write!(
595 f,
596 ", its leader free to add {} members after the election",
597 self.max_added_moderators
598 )?;
599 }
600 Ok(())
601 }
602}
603
604#[cfg(feature = "json-conversion")]
605impl JsonSafeFields for ModerationAbility {}
606#[cfg(feature = "json-conversion")]
607impl JsonSafeFields for InterimModerators {}
608#[cfg(feature = "json-conversion")]
609impl JsonSafeFields for ElectedModerators {}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614 use crate::consensus::ConsensusError;
615 use crate::data_contract::config::moderation::ContractModerators;
616 use platform_value::platform_value;
617
618 fn set(ids: &[u8]) -> BTreeSet<Identifier> {
619 ids.iter().map(|b| Identifier::from([*b; 32])).collect()
620 }
621
622 fn schemas() -> BTreeMap<DocumentName, Value> {
624 BTreeMap::from([
625 (
626 "post".to_string(),
627 platform_value!({ "type": "object", "moderatorAbilities": { "delete": true } }),
628 ),
629 ("like".to_string(), platform_value!({ "type": "object" })),
630 ])
631 }
632
633 fn schemas_with_a_report() -> BTreeMap<DocumentName, Value> {
635 let mut schemas = schemas();
636 schemas.insert(
637 "report".to_string(),
638 platform_value!({
639 "type": "object",
640 "moderatorAbilities": { "changeFields": ["status"] },
641 }),
642 );
643 schemas
644 }
645
646 fn elected() -> ElectedModerators {
649 ElectedModerators {
650 join_window: DEFAULT_ELECTION_WINDOW_SECONDS,
651 vote_window: DEFAULT_ELECTION_WINDOW_SECONDS,
652 challenge_cool_down: Some(1_209_600),
653 moderated_document_types: BTreeMap::from([(
654 "post".to_string(),
655 moderated(&[ModerationAbility::Ban, ModerationAbility::Suspend]),
656 )]),
657 interim: InterimModerators::ContractOwner,
658 election_delay: None,
659 max_added_moderators: 0,
660 owner_protected: false,
661 }
662 }
663
664 fn moderated(abilities: &[ModerationAbility]) -> BTreeSet<ModerationAbility> {
666 abilities.iter().copied().collect()
667 }
668
669 fn abilities_of<'a>(
671 declaration: &'a mut ElectedModerators,
672 name: &str,
673 ) -> &'a mut BTreeSet<ModerationAbility> {
674 declaration
675 .moderated_document_types
676 .get_mut(name)
677 .expect("the type is moderated")
678 }
679
680 fn config(elected: ElectedModerators) -> ContractModerationConfig {
681 ContractModerationConfig {
682 banlist: true,
683 suspensions: true,
684 warnings: false,
685 moderators: ContractModerators::Elected(Box::new(elected)),
686 }
687 }
688
689 fn refusal(config: &ContractModerationConfig) -> Option<String> {
692 refusal_on(Network::Mainnet, config)
693 }
694
695 fn refusal_on(network: Network, config: &ContractModerationConfig) -> Option<String> {
697 let result = config
698 .validate(&schemas(), network, PlatformVersion::latest())
699 .expect("validate");
700 (!result.is_valid()).then(|| rendered(&result.errors))
701 }
702
703 fn rendered(errors: &[ConsensusError]) -> String {
706 errors
707 .iter()
708 .map(ToString::to_string)
709 .collect::<Vec<_>>()
710 .join("\n")
711 }
712
713 #[test]
714 fn should_accept_a_declaration_within_every_bound() {
715 assert_eq!(refusal(&config(elected())), None);
716 }
717
718 #[test]
719 fn should_accept_every_bound_and_refuse_one_second_outside_each() {
720 let limits = &PlatformVersion::latest().system_limits;
721 let window_min = limits.min_mainnet_contract_moderation_election_window_seconds;
722 let window_max = limits.max_contract_moderation_election_window_seconds;
723 let cool_down_min = limits.min_contract_moderation_challenge_cool_down_seconds;
724 let cool_down_max = limits.max_contract_moderation_challenge_cool_down_seconds;
725 assert_eq!(window_min, 86_400);
726 assert_eq!(window_max, 2_419_200);
727 assert_eq!(cool_down_min, 1_209_600);
728 assert_eq!(cool_down_max, 94_608_000);
729
730 let with = |f: fn(&mut ElectedModerators, u32), seconds: u32| {
731 let mut declaration = elected();
732 f(&mut declaration, seconds);
733 config(declaration)
734 };
735 let join = |d: &mut ElectedModerators, s: u32| d.join_window = s;
736 let vote = |d: &mut ElectedModerators, s: u32| d.vote_window = s;
737 let cool_down = |d: &mut ElectedModerators, s: u32| d.challenge_cool_down = Some(s);
738 for (name, field, min, max) in [
739 (
740 "join window",
741 join as fn(&mut ElectedModerators, u32),
742 window_min,
743 window_max,
744 ),
745 ("vote window", vote, window_min, window_max),
746 (
747 "challenge cool-down",
748 cool_down,
749 cool_down_min,
750 cool_down_max,
751 ),
752 ] {
753 assert_eq!(refusal(&with(field, min)), None, "{name} at its minimum");
754 assert_eq!(refusal(&with(field, max)), None, "{name} at its maximum");
755 let below = refusal(&with(field, min - 1)).expect("refused below the minimum");
756 assert!(below.contains(name), "{below}");
757 let above = refusal(&with(field, max + 1)).expect("refused above the maximum");
758 assert!(above.contains(name), "{above}");
759 }
760 }
761
762 #[test]
765 fn should_floor_the_windows_on_mainnet_only() {
766 let with_windows = |seconds: u32| {
767 let mut declaration = elected();
768 declaration.join_window = seconds;
769 declaration.vote_window = seconds;
770 config(declaration)
771 };
772
773 let on_mainnet = refusal_on(Network::Mainnet, &with_windows(0)).expect("refused");
774 assert!(
775 on_mainnet.contains("the join window of 0 seconds is outside 86400 to 2419200 seconds"),
776 "{on_mainnet}"
777 );
778 assert_eq!(refusal_on(Network::Mainnet, &with_windows(86_400)), None);
779
780 for network in [Network::Testnet, Network::Devnet, Network::Regtest] {
781 assert_eq!(
782 refusal_on(network, &with_windows(0)),
783 None,
784 "0 on {network:?}"
785 );
786 let above = refusal_on(network, &with_windows(2_419_201)).expect("refused");
787 assert!(
788 above.contains("the join window of 2419201 seconds is outside 0 to 2419200"),
789 "{above}"
790 );
791 }
792 }
793
794 #[test]
795 fn should_bound_the_cool_down_of_a_contestable_seat_only() {
796 let mut permanent = elected();
797 permanent.challenge_cool_down = None;
798 assert!(!permanent.seat_contestable());
799 assert_eq!(refusal(&config(permanent)), None);
800
801 let contestable = elected();
802 assert!(contestable.seat_contestable());
803 assert_eq!(refusal(&config(contestable)), None);
804 }
805
806 #[test]
807 fn should_require_a_moderated_set_of_document_types_the_contract_has() {
808 let mut empty = elected();
809 empty.moderated_document_types.clear();
810 assert!(refusal(&config(empty))
811 .expect("refused")
812 .contains("moderated document type set is empty"));
813
814 let mut unknown = elected();
815 unknown
816 .moderated_document_types
817 .insert("comment".to_string(), moderated(&[ModerationAbility::Ban]));
818 assert!(refusal(&config(unknown))
819 .expect("refused")
820 .contains("\"comment\" is not a document type"));
821
822 let mut not_deletable = elected();
824 not_deletable.moderated_document_types =
825 BTreeMap::from([("like".to_string(), moderated(&[ModerationAbility::Ban]))]);
826 assert_eq!(refusal(&config(not_deletable)), None);
827 }
828
829 #[test]
830 fn should_require_a_non_empty_envelope_the_contract_can_back() {
831 let mut empty = elected();
832 abilities_of(&mut empty, "post").clear();
833 assert!(refusal(&config(empty))
834 .expect("refused")
835 .contains("ability set of the moderated document type \"post\" is empty"));
836
837 let mut bans_without_banlist = config(elected());
838 bans_without_banlist.banlist = false;
839 bans_without_banlist.suspensions = true;
840 assert!(refusal(&bans_without_banlist)
841 .expect("refused")
842 .contains("keeps no banlist"));
843
844 let mut suspensions_without_list = config(elected());
845 suspensions_without_list.suspensions = false;
846 assert!(refusal(&suspensions_without_list)
847 .expect("refused")
848 .contains("keeps no suspension list"));
849
850 let mut warnings = elected();
851 abilities_of(&mut warnings, "post").insert(ModerationAbility::Warn);
852 let mut warnings_without_list = config(warnings);
853 assert!(refusal(&warnings_without_list)
854 .expect("refused")
855 .contains("keeps no warning list"));
856 warnings_without_list.warnings = true;
857 assert_eq!(refusal(&warnings_without_list), None);
858
859 let mut deletions = elected();
861 *abilities_of(&mut deletions, "post") =
862 BTreeSet::from([ModerationAbility::DeleteDocuments]);
863 assert_eq!(refusal(&config(deletions)), None);
864 let mut deletions_on_like = elected();
865 deletions_on_like.moderated_document_types.insert(
866 "like".to_string(),
867 moderated(&[ModerationAbility::DeleteDocuments]),
868 );
869 assert!(refusal(&config(deletions_on_like))
870 .expect("refused")
871 .contains("\"like\" allows document deletions, but the type can not be deleted"));
872
873 let with_a_report = |elected: ElectedModerators| {
876 let result = config(elected)
877 .validate(
878 &schemas_with_a_report(),
879 Network::Mainnet,
880 PlatformVersion::latest(),
881 )
882 .expect("validate");
883 (!result.is_valid()).then(|| rendered(&result.errors))
884 };
885 let mut changes = elected();
886 changes.moderated_document_types.insert(
887 "report".to_string(),
888 moderated(&[ModerationAbility::ChangeDocumentFields]),
889 );
890 assert_eq!(with_a_report(changes.clone()), None);
891 abilities_of(&mut changes, "post").insert(ModerationAbility::ChangeDocumentFields);
892 assert!(with_a_report(changes)
893 .expect("refused")
894 .contains("\"post\" allows document field changes, but the type lists no field"));
895
896 for without in [elected(), {
899 let mut deletions_only = elected();
900 deletions_only
901 .moderated_document_types
902 .insert("report".to_string(), moderated(&[ModerationAbility::Ban]));
903 deletions_only
904 }] {
905 assert!(with_a_report(without)
906 .expect("refused")
907 .contains("\"report\" keeps fields only moderators write"));
908 }
909 }
910
911 #[test]
912 fn should_require_deletions_on_a_type_that_says_who_approves_a_settled_deletion() {
913 let schemas = BTreeMap::from([(
914 "post".to_string(),
915 platform_value!({
916 "type": "object",
917 "moderatorAbilities": {
918 "delete": true,
919 "deleteWithin": 86400,
920 "deleteSettled": { "leader": true },
921 },
922 }),
923 )]);
924 let refusal = |elected: ElectedModerators| {
925 let result = config(elected)
926 .validate(&schemas, Network::Mainnet, PlatformVersion::latest())
927 .expect("validate");
928 (!result.is_valid()).then(|| rendered(&result.errors))
929 };
930 assert!(refusal(elected())
932 .expect("refused")
933 .contains("\"post\" says who of the team must approve"));
934 let mut deletions = elected();
935 abilities_of(&mut deletions, "post").insert(ModerationAbility::DeleteDocuments);
936 assert_eq!(refusal(deletions), None);
937 }
938
939 #[test]
940 fn should_bound_the_interim_set_like_an_appointed_set() {
941 let max = PlatformVersion::latest()
942 .system_limits
943 .max_contract_moderators as u8;
944 let with = |ids: &[u8]| {
945 let mut declaration = elected();
946 declaration.interim = InterimModerators::AppointedModerators(set(ids));
947 config(declaration)
948 };
949 assert!(refusal(&with(&[])).expect("refused").contains("empty"));
950 assert_eq!(refusal(&with(&(1..=max).collect::<Vec<u8>>())), None);
951 assert!(refusal(&with(&(1..=max + 1).collect::<Vec<u8>>()))
952 .expect("refused")
953 .contains("at most"));
954 }
955
956 #[test]
957 fn should_give_each_interim_kind_its_authority_team_and_block() {
958 let owner = Identifier::from([9; 32]);
959 let moderator = Identifier::from([1; 32]);
960 let user = Identifier::from([2; 32]);
961 let with = |interim: InterimModerators, owner_protected: bool| {
962 let mut declaration = elected();
963 declaration.interim = interim;
964 declaration.owner_protected = owner_protected;
965 ContractModerators::Elected(Box::new(declaration))
966 };
967
968 let owner_alone = with(InterimModerators::ContractOwner, false);
969 assert!(owner_alone.may_moderate(&owner, &owner));
970 assert!(!owner_alone.may_moderate(&owner, &moderator));
971 assert_eq!(owner_alone.team(&owner), BTreeSet::from([owner]));
972 assert_eq!(owner_alone.identity_ids(), None);
973 assert!(owner_alone.protects(&owner, &owner));
974 assert!(!owner_alone.protects(&owner, &user));
975 assert!(!owner_alone.interim_blocks_document_type("post"));
976
977 let appointed = with(InterimModerators::AppointedModerators(set(&[1])), false);
978 assert!(appointed.may_moderate(&owner, &owner));
979 assert!(appointed.may_moderate(&owner, &moderator));
980 assert!(!appointed.may_moderate(&owner, &user));
981 assert_eq!(appointed.team(&owner), set(&[1]));
982 assert_eq!(appointed.identity_ids(), Some(&set(&[1])));
983 assert!(appointed.protects(&owner, &moderator));
984
985 let nobody = with(InterimModerators::NotYetUsable, false);
986 assert!(!nobody.may_moderate(&owner, &owner));
987 assert!(nobody.team(&owner).is_empty());
988 assert!(!nobody.protects(&owner, &owner));
989 assert!(nobody.interim_blocks_document_type("post"));
990 assert!(!nobody.interim_blocks_document_type("like"));
991
992 let protected = with(InterimModerators::NotYetUsable, true);
994 assert!(!protected.may_moderate(&owner, &owner));
995 assert!(protected.protects(&owner, &owner));
996 assert!(!protected.protects(&owner, &user));
997 }
998
999 #[test]
1000 fn should_give_a_seated_team_the_abilities_of_any_moderated_type_on_the_lists_only() {
1001 let mut declaration = elected();
1002 declaration
1003 .moderated_document_types
1004 .insert("like".to_string(), moderated(&[ModerationAbility::Warn]));
1005 declaration.moderated_document_types.insert(
1006 "post".to_string(),
1007 moderated(&[ModerationAbility::Ban, ModerationAbility::DeleteDocuments]),
1008 );
1009
1010 assert!(declaration.allows_on_any_type(ModerationAbility::Ban));
1012 assert!(declaration.allows_on_any_type(ModerationAbility::Warn));
1013 assert!(!declaration.allows_on_any_type(ModerationAbility::Suspend));
1014 assert!(declaration.allows("post", ModerationAbility::DeleteDocuments));
1016 assert!(!declaration.allows("like", ModerationAbility::DeleteDocuments));
1017 assert!(!declaration.allows("comment", ModerationAbility::Ban));
1018 }
1019
1020 #[test]
1021 fn should_bound_the_members_a_leader_may_add() {
1022 let max = PlatformVersion::latest()
1023 .system_limits
1024 .max_contract_moderation_added_moderators;
1025 assert_eq!(max, 15);
1026 let with = |added: u16| {
1027 let mut declaration = elected();
1028 declaration.max_added_moderators = added;
1029 config(declaration)
1030 };
1031 assert_eq!(refusal(&with(0)), None);
1032 assert_eq!(refusal(&with(max)), None);
1033 let over = refusal(&with(max + 1)).expect("refused over the limit");
1034 assert!(over.contains("members a leader may add"), "{over}");
1035 }
1036
1037 #[test]
1038 fn should_round_trip_through_json_and_platform_value() {
1039 let mut declaration = elected();
1040 declaration.interim = InterimModerators::AppointedModerators(set(&[1, 2]));
1041 declaration.owner_protected = true;
1042 declaration.election_delay = Some(86_400);
1043 declaration.max_added_moderators = 3;
1044 let moderators = ContractModerators::Elected(Box::new(declaration));
1045
1046 let json = serde_json::to_value(&moderators).expect("serialize");
1047 assert_eq!(json["$type"], "elected");
1048 assert_eq!(json["joinWindow"], 604_800);
1049 assert_eq!(json["seatContestable"], true);
1050 assert_eq!(json["challengeCoolDown"], 1_209_600);
1051 assert_eq!(json["electionDelay"], 86_400);
1052 assert_eq!(json["maxAddedModerators"], 3);
1053 assert_eq!(
1054 json["moderatedDocumentTypes"],
1055 serde_json::json!({ "post": ["ban", "suspend"] })
1056 );
1057 assert_eq!(json["interim"]["$type"], "appointedModerators");
1058 assert_eq!(
1059 json["interim"]["identities"].as_array().map(|a| a.len()),
1060 Some(2)
1061 );
1062 assert_eq!(json["ownerProtected"], true);
1063 let back: ContractModerators = serde_json::from_value(json).expect("deserialize");
1064 assert_eq!(back, moderators);
1065
1066 let value = platform_value::to_value(&moderators).expect("to value");
1067 let back: ContractModerators = platform_value::from_value(value).expect("from value");
1068 assert_eq!(back, moderators);
1069
1070 let config = ContractModerationConfig {
1071 banlist: true,
1072 suspensions: true,
1073 warnings: false,
1074 moderators: moderators.clone(),
1075 };
1076 let value = platform_value::to_value(&config).expect("to value");
1077 let back: ContractModerationConfig = platform_value::from_value(value).expect("from value");
1078 assert_eq!(back, config);
1079 }
1080
1081 #[test]
1082 fn should_round_trip_each_value_of_seat_contestable_through_json_and_platform_value() {
1083 for challenge_cool_down in [Some(1_209_600), Some(94_608_000), None] {
1084 let mut declaration = elected();
1085 declaration.challenge_cool_down = challenge_cool_down;
1086 let moderators = ContractModerators::Elected(Box::new(declaration));
1087
1088 let json = serde_json::to_value(&moderators).expect("serialize");
1089 assert_eq!(
1090 json["seatContestable"],
1091 challenge_cool_down.is_some(),
1092 "{json}"
1093 );
1094 assert_eq!(
1095 json.get("challengeCoolDown").and_then(|v| v.as_u64()),
1096 challenge_cool_down.map(u64::from),
1097 "the cool-down is on the wire exactly when the seat is contestable: {json}"
1098 );
1099 let back: ContractModerators = serde_json::from_value(json).expect("from json");
1100 assert_eq!(back, moderators);
1101
1102 let value = platform_value::to_value(&moderators).expect("to value");
1103 assert_eq!(
1104 value
1105 .get_optional_bool("seatContestable")
1106 .expect("a bool")
1107 .expect("always present"),
1108 challenge_cool_down.is_some()
1109 );
1110 let back: ContractModerators = platform_value::from_value(value).expect("from value");
1111 assert_eq!(back, moderators);
1112 }
1113 }
1114
1115 #[test]
1116 fn should_refuse_a_declaration_without_seat_contestable() {
1117 for cool_down in [Some(1_209_600), None] {
1118 let mut json = serde_json::json!({
1119 "$type": "elected",
1120 "moderatedDocumentTypes": { "post": ["ban"] },
1121 "interim": { "$type": "contractOwner" },
1122 });
1123 if let Some(cool_down) = cool_down {
1124 json["challengeCoolDown"] = cool_down.into();
1125 }
1126 let error = serde_json::from_value::<ContractModerators>(json.clone())
1127 .expect_err("refused without the key")
1128 .to_string();
1129 assert!(error.contains("missing field `seatContestable`"), "{error}");
1130
1131 let value = platform_value::to_value(&json).expect("to value");
1132 assert!(
1133 platform_value::from_value::<ContractModerators>(value).is_err(),
1134 "{json}"
1135 );
1136 }
1137 }
1138
1139 #[test]
1140 fn should_require_the_cool_down_of_a_contestable_seat_and_refuse_it_on_a_permanent_one() {
1141 let with = |seat_contestable: bool, cool_down: Option<u32>| {
1142 let mut json = serde_json::json!({
1143 "$type": "elected",
1144 "seatContestable": seat_contestable,
1145 "moderatedDocumentTypes": { "post": ["ban"] },
1146 "interim": { "$type": "contractOwner" },
1147 });
1148 if let Some(cool_down) = cool_down {
1149 json["challengeCoolDown"] = cool_down.into();
1150 }
1151 serde_json::from_value::<ContractModerators>(json).map_err(|e| e.to_string())
1152 };
1153
1154 let contestable = with(true, Some(1_209_600)).expect("accepted");
1155 assert_eq!(
1156 contestable.elected().map(|e| e.challenge_cool_down),
1157 Some(Some(1_209_600))
1158 );
1159 let permanent = with(false, None).expect("accepted");
1160 assert_eq!(
1161 permanent.elected().map(|e| e.challenge_cool_down),
1162 Some(None)
1163 );
1164 assert_eq!(
1165 permanent.elected().map(ElectedModerators::seat_contestable),
1166 Some(false)
1167 );
1168
1169 let missing = with(true, None).expect_err("a contestable seat needs its cool-down");
1170 assert!(
1171 missing.contains("missing field `challengeCoolDown`"),
1172 "{missing}"
1173 );
1174 let stray = with(false, Some(1_209_600)).expect_err("a permanent seat has no cool-down");
1175 assert!(
1176 stray.contains("only valid with `seatContestable: true`"),
1177 "{stray}"
1178 );
1179 assert!(with(false, Some(0)).is_err());
1181 }
1182
1183 #[test]
1184 fn should_default_the_windows_and_the_flag_and_refuse_a_misspelled_key() {
1185 let minimal = serde_json::json!({
1186 "$type": "elected",
1187 "seatContestable": false,
1188 "moderatedDocumentTypes": { "post": ["ban"] },
1189 "interim": { "$type": "notYetUsable" },
1190 });
1191 let parsed: ContractModerators = serde_json::from_value(minimal).expect("deserialize");
1192 let elected = parsed.elected().expect("elected");
1193 assert_eq!(elected.join_window, DEFAULT_ELECTION_WINDOW_SECONDS);
1194 assert_eq!(elected.challenge_cool_down, None);
1195 assert_eq!(elected.election_delay, None);
1196 let json = serde_json::to_value(&parsed).expect("serialize");
1197 assert!(
1198 json.get("electionDelay").is_none(),
1199 "a declaration without a delay serializes none: {json}"
1200 );
1201 assert_eq!(elected.max_added_moderators, 0);
1202 assert!(
1203 json.get("maxAddedModerators").is_none(),
1204 "a declaration letting no member be added serializes none: {json}"
1205 );
1206 assert_eq!(elected.vote_window, DEFAULT_ELECTION_WINDOW_SECONDS);
1207 assert!(!elected.owner_protected);
1208 assert_eq!(elected.interim, InterimModerators::NotYetUsable);
1209 let no_moderation: InterimModerators =
1210 serde_json::from_value(serde_json::json!({ "$type": "noModeration" }))
1211 .expect("deserialize");
1212 assert_eq!(no_moderation, InterimModerators::NoModeration);
1213 assert_eq!(
1214 serde_json::to_value(&no_moderation).expect("serialize"),
1215 serde_json::json!({ "$type": "noModeration" })
1216 );
1217
1218 let refused = [
1219 serde_json::json!({
1221 "$type": "elected",
1222 "seatContestable": true,
1223 "moderatedDocumentTypes": { "post": ["ban"] },
1224 "interim": { "$type": "contractOwner" },
1225 }),
1226 serde_json::json!({
1228 "$type": "elected",
1229 "challengeCoolDown": 1_209_600,
1230 "moderatedDocumentTypes": { "post": ["ban"] },
1231 "interim": { "$type": "contractOwner" },
1232 }),
1233 serde_json::json!({
1235 "$type": "elected",
1236 "seatContestable": 1,
1237 "challengeCoolDown": 1_209_600,
1238 "moderatedDocumentTypes": { "post": ["ban"] },
1239 "interim": { "$type": "contractOwner" },
1240 }),
1241 serde_json::json!({
1243 "$type": "elected",
1244 "seatContestable": true,
1245 "challengeCoolDown": 1_209_600,
1246 "moderatedDocumentTypes": { "post": ["ban"] },
1247 "interim": { "$type": "contractOwner" },
1248 "ownerProtcted": true,
1249 }),
1250 serde_json::json!({
1252 "$type": "elected",
1253 "seatContestable": true,
1254 "challengeCoolDown": 1_209_600,
1255 "moderatedDocumentTypes": { "post": ["ban"] },
1256 "interim": { "$type": "contractOwner", "identity": [] },
1257 }),
1258 serde_json::json!({
1259 "$type": "elected",
1260 "seatContestable": true,
1261 "challengeCoolDown": 1_209_600,
1262 "moderatedDocumentTypes": { "post": ["ban"] },
1263 "interim": { "$type": "seatedTeam" },
1264 }),
1265 serde_json::json!({
1267 "$type": "elected",
1268 "seatContestable": true,
1269 "challengeCoolDown": 1_209_600,
1270 "moderatedDocumentTypes": { "post": ["silence"] },
1271 "interim": { "$type": "contractOwner" },
1272 }),
1273 serde_json::json!({
1275 "$type": "elected",
1276 "seatContestable": true,
1277 "challengeCoolDown": 1_209_600,
1278 "moderatedDocumentTypes": { "post": ["ban"] },
1279 "moderatorsActionFeeMaximums": { "post": { "create": 1 } },
1280 "interim": { "$type": "contractOwner" },
1281 }),
1282 serde_json::json!({
1284 "$type": "elected",
1285 "seatContestable": true,
1286 "challengeCoolDown": 1_209_600,
1287 "moderatedDocumentTypes": ["post"],
1288 "abilities": ["ban"],
1289 "interim": { "$type": "contractOwner" },
1290 }),
1291 serde_json::json!({ "$type": "contractOwner", "ownerProtected": true }),
1293 serde_json::json!({ "$type": "appointedModerators", "identities": [], "seatContestable": false }),
1294 serde_json::json!({
1295 "$type": "elected",
1296 "seatContestable": true,
1297 "challengeCoolDown": 1_209_600,
1298 "moderatedDocumentTypes": { "post": ["ban"] },
1299 "interim": { "$type": "contractOwner" },
1300 "identities": [],
1301 }),
1302 ];
1303 for json in refused {
1304 assert!(
1305 serde_json::from_value::<ContractModerators>(json.clone()).is_err(),
1306 "{json}"
1307 );
1308 }
1309 }
1310
1311 #[test]
1312 fn should_open_the_election_after_the_delay_from_the_contract_creation() {
1313 let created_at: TimestampMillis = 1_700_000_000_000;
1314 let mut declaration = elected();
1315
1316 assert!(declaration.election_is_open(Some(created_at), created_at));
1318 assert!(declaration.election_is_open(None, 0));
1319
1320 declaration.election_delay = Some(3600);
1321 assert!(!declaration.election_is_open(Some(created_at), created_at + 3_599_999));
1322 assert!(declaration.election_is_open(Some(created_at), created_at + 3_600_000));
1323 assert!(declaration.election_is_open(Some(created_at), TimestampMillis::MAX));
1324 assert!(!declaration.election_is_open(None, TimestampMillis::MAX));
1326 declaration.election_delay = Some(u32::MAX);
1328 assert!(
1329 !declaration.election_is_open(Some(TimestampMillis::MAX - 1), TimestampMillis::MAX - 1)
1330 );
1331 }
1332
1333 #[test]
1334 fn should_describe_itself() {
1335 let mut declaration = elected();
1336 assert_eq!(
1337 ContractModerators::Elected(Box::new(declaration.clone())).to_string(),
1338 "an elected moderation team, in its interim moderated by the contract owner, its \
1339 seat open to a challenge 1209600 seconds after each seat change"
1340 );
1341 declaration.challenge_cool_down = None;
1342 assert_eq!(
1343 declaration.to_string(),
1344 "an elected moderation team, in its interim moderated by the contract owner, its \
1345 seat never contested once a team is seated"
1346 );
1347 declaration.election_delay = Some(86_400);
1348 assert_eq!(
1349 declaration.to_string(),
1350 "an elected moderation team, in its interim moderated by the contract owner, its \
1351 seat never contested once a team is seated, its first election open 86400 \
1352 seconds after the contract's creation"
1353 );
1354 declaration.election_delay = None;
1355 declaration.max_added_moderators = 2;
1356 assert_eq!(
1357 declaration.to_string(),
1358 "an elected moderation team, in its interim moderated by the contract owner, its \
1359 seat never contested once a team is seated, its leader free to add 2 members \
1360 after the election"
1361 );
1362 declaration.max_added_moderators = 0;
1363 declaration.interim = InterimModerators::NotYetUsable;
1364 assert_eq!(
1365 declaration.to_string(),
1366 "an elected moderation team, in its interim moderated by nobody, its moderated \
1367 document types not yet usable, its seat never contested once a team is seated"
1368 );
1369 declaration.interim = InterimModerators::NoModeration;
1370 assert_eq!(
1371 declaration.to_string(),
1372 "an elected moderation team, in its interim moderated by nobody, its moderated \
1373 document types unmoderated meanwhile, its seat never contested once a team is \
1374 seated"
1375 );
1376 assert_eq!(
1377 ModerationAbility::DeleteDocuments.to_string(),
1378 "deleteDocuments"
1379 );
1380 }
1381
1382 #[test]
1383 fn should_leave_the_moderated_types_usable_and_unmoderated_under_no_moderation() {
1384 let mut declaration = elected();
1385 declaration.interim = InterimModerators::NoModeration;
1386 let owner = Identifier::from([9u8; 32]);
1387 assert!(!declaration.interim_blocks_document_type("post"));
1388 assert!(!declaration.interim.may_moderate(&owner, &owner));
1389 assert!(declaration.interim.team(&owner).is_empty());
1390 assert_eq!(declaration.interim.identity_ids(), None);
1391 assert!(declaration.allows("post", ModerationAbility::Ban));
1392 assert!(!declaration.allows("post", ModerationAbility::Warn));
1393 assert!(!declaration.allows("like", ModerationAbility::Ban));
1394 assert_eq!(refusal(&config(declaration)), None);
1395 }
1396}