1use crate::consensus::basic::contract_moderation::InvalidContractModerationConfigError;
23use crate::data_contract::document_type::property_names::{
24 moderator_abilities, MODERATOR_ABILITIES,
25};
26use crate::data_contract::DocumentName;
27use crate::identity::TimestampMillis;
28#[cfg(feature = "json-conversion")]
29use crate::serialization::JsonSafeFields;
30use crate::validation::SimpleConsensusValidationResult;
31use crate::ProtocolError;
32use bincode::{Decode, DecodeUntrusted, Encode};
33use dashcore::Network;
34use platform_value::{Identifier, Value};
35use platform_version::version::PlatformVersion;
36use serde::{Deserialize, Serialize};
37use std::collections::{BTreeMap, BTreeSet};
38use std::fmt;
39
40mod document_removal;
41pub mod elected;
42mod reason;
43mod settled_deletion;
44pub use document_removal::{
45 decode_kept_fields, encode_kept_fields, kept_value_at, ContractDocumentRemoval,
46 ContractDocumentRestoration,
47};
48pub use elected::{
49 ElectedModerators, InterimModerators, ModerationAbility, DEFAULT_ELECTION_WINDOW_SECONDS,
50};
51pub use reason::{ContractModerationDocument, ContractModerationReason};
52pub use settled_deletion::{ContractTeamAction, ContractTeamActionEvent, SettledDeletionRule};
53
54fn document_schema_moderator_abilities(schema: &Value) -> Option<&[(Value, Value)]> {
57 let schema_map = schema.to_map().ok()?;
58 Value::get_optional_from_map(schema_map, MODERATOR_ABILITIES)?
59 .to_map()
60 .ok()
61 .map(Vec::as_slice)
62}
63
64pub fn document_schema_lets_moderators_delete(schema: &Value) -> bool {
67 document_schema_moderator_abilities(schema)
68 .and_then(|abilities| {
69 Value::inner_optional_bool_value(abilities, moderator_abilities::DELETE).ok()
70 })
71 .flatten()
72 .unwrap_or(false)
73}
74
75pub fn document_schema_lets_moderators_delete_settled(schema: &Value) -> bool {
78 document_schema_moderator_abilities(schema)
79 .and_then(|abilities| {
80 Value::get_optional_from_map(abilities, moderator_abilities::DELETE_SETTLED)
81 })
82 .is_some()
83}
84
85pub fn document_schema_lets_moderators_change_fields(schema: &Value) -> bool {
88 document_schema_moderator_abilities(schema)
89 .and_then(|abilities| {
90 Value::get_optional_from_map(abilities, moderator_abilities::CHANGE_FIELDS)
91 })
92 .and_then(|fields| fields.as_array())
93 .is_some_and(|fields| !fields.is_empty())
94}
95
96pub fn document_schema_grants_moderator_abilities(schema: &Value) -> bool {
99 document_schema_lets_moderators_delete(schema)
100 || document_schema_lets_moderators_change_fields(schema)
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted)]
110pub enum ContractModerators {
111 #[default]
113 ContractOwner,
114 AppointedModerators(BTreeSet<Identifier>),
119 Elected(Box<ElectedModerators>),
124}
125
126impl ContractModerators {
127 pub fn identity_ids(&self) -> Option<&BTreeSet<Identifier>> {
131 match self {
132 ContractModerators::ContractOwner => None,
133 ContractModerators::AppointedModerators(ids) => Some(ids),
134 ContractModerators::Elected(elected) => elected.interim.identity_ids(),
135 }
136 }
137
138 pub fn names(&self, identity_id: &Identifier) -> bool {
140 self.identity_ids()
141 .is_some_and(|ids| ids.contains(identity_id))
142 }
143
144 pub fn elected(&self) -> Option<&ElectedModerators> {
146 match self {
147 ContractModerators::Elected(elected) => Some(elected.as_ref()),
148 ContractModerators::ContractOwner | ContractModerators::AppointedModerators(_) => None,
149 }
150 }
151
152 pub fn may_moderate(&self, owner_id: &Identifier, identity_id: &Identifier) -> bool {
160 match self {
161 ContractModerators::ContractOwner | ContractModerators::AppointedModerators(_) => {
162 owner_id == identity_id || self.names(identity_id)
163 }
164 ContractModerators::Elected(elected) => {
165 elected.interim.may_moderate(owner_id, identity_id)
166 }
167 }
168 }
169
170 pub fn protects(&self, owner_id: &Identifier, identity_id: &Identifier) -> bool {
177 self.may_moderate(owner_id, identity_id)
178 || (owner_id == identity_id
179 && self
180 .elected()
181 .is_some_and(|elected| elected.owner_protected))
182 }
183
184 pub fn interim_blocks_document_type(&self, document_type_name: &str) -> bool {
188 self.elected()
189 .is_some_and(|elected| elected.interim_blocks_document_type(document_type_name))
190 }
191
192 pub fn team(&self, owner_id: &Identifier) -> BTreeSet<Identifier> {
206 match self {
207 ContractModerators::ContractOwner => BTreeSet::from([*owner_id]),
208 ContractModerators::AppointedModerators(ids) => ids.clone(),
209 ContractModerators::Elected(elected) => elected.interim.team(owner_id),
210 }
211 }
212}
213
214impl Serialize for ContractModerators {
219 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
220 use elected::property_names as elected_names;
221 use serde::ser::SerializeMap;
222 match self {
223 ContractModerators::ContractOwner => {
224 let mut m = serializer.serialize_map(Some(1))?;
225 m.serialize_entry("$type", "contractOwner")?;
226 m.end()
227 }
228 ContractModerators::AppointedModerators(ids) => {
229 let mut m = serializer.serialize_map(Some(2))?;
230 m.serialize_entry("$type", "appointedModerators")?;
231 m.serialize_entry("identities", ids)?;
232 m.end()
233 }
234 ContractModerators::Elected(elected) => {
235 let entries = 7
236 + usize::from(elected.challenge_cool_down.is_some())
237 + usize::from(elected.election_delay.is_some())
238 + usize::from(elected.max_added_moderators > 0);
239 let mut m = serializer.serialize_map(Some(entries))?;
240 m.serialize_entry("$type", "elected")?;
241 m.serialize_entry(elected_names::JOIN_WINDOW, &elected.join_window)?;
242 m.serialize_entry(elected_names::VOTE_WINDOW, &elected.vote_window)?;
243 m.serialize_entry(elected_names::SEAT_CONTESTABLE, &elected.seat_contestable())?;
244 if let Some(cool_down) = elected.challenge_cool_down {
246 m.serialize_entry(elected_names::CHALLENGE_COOL_DOWN, &cool_down)?;
247 }
248 if let Some(delay) = elected.election_delay {
251 m.serialize_entry(elected_names::ELECTION_DELAY, &delay)?;
252 }
253 if elected.max_added_moderators > 0 {
255 m.serialize_entry(
256 elected_names::MAX_ADDED_MODERATORS,
257 &elected.max_added_moderators,
258 )?;
259 }
260 m.serialize_entry(
261 elected_names::MODERATED_DOCUMENT_TYPES,
262 &elected.moderated_document_types,
263 )?;
264 m.serialize_entry(elected_names::INTERIM, &elected.interim)?;
265 m.serialize_entry(elected_names::OWNER_PROTECTED, &elected.owner_protected)?;
266 m.end()
267 }
268 }
269 }
270}
271
272impl<'de> Deserialize<'de> for ContractModerators {
273 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
274 use elected::property_names as elected_names;
275 use serde::de::{self, MapAccess, Visitor};
276
277 const KEYS: &[&str] = &[
278 "$type",
279 "identities",
280 elected_names::JOIN_WINDOW,
281 elected_names::VOTE_WINDOW,
282 elected_names::SEAT_CONTESTABLE,
283 elected_names::CHALLENGE_COOL_DOWN,
284 elected_names::ELECTION_DELAY,
285 elected_names::MAX_ADDED_MODERATORS,
286 elected_names::MODERATED_DOCUMENT_TYPES,
287 elected_names::INTERIM,
288 elected_names::OWNER_PROTECTED,
289 ];
290
291 #[derive(Default)]
293 struct ElectedKeys {
294 join_window: Option<u32>,
295 vote_window: Option<u32>,
296 seat_contestable: Option<bool>,
297 challenge_cool_down: Option<u32>,
298 election_delay: Option<u32>,
299 max_added_moderators: Option<u16>,
300 moderated_document_types: Option<BTreeMap<DocumentName, BTreeSet<ModerationAbility>>>,
301 interim: Option<InterimModerators>,
302 owner_protected: Option<bool>,
303 }
304
305 impl ElectedKeys {
306 fn any(&self) -> bool {
307 self.join_window.is_some()
308 || self.vote_window.is_some()
309 || self.seat_contestable.is_some()
310 || self.challenge_cool_down.is_some()
311 || self.election_delay.is_some()
312 || self.max_added_moderators.is_some()
313 || self.moderated_document_types.is_some()
314 || self.interim.is_some()
315 || self.owner_protected.is_some()
316 }
317
318 fn challenge_cool_down<E: de::Error>(&self) -> Result<Option<u32>, E> {
323 match (self.seat_contestable, self.challenge_cool_down) {
324 (None, _) => Err(E::missing_field(elected_names::SEAT_CONTESTABLE)),
325 (Some(true), None) => Err(E::missing_field(elected_names::CHALLENGE_COOL_DOWN)),
326 (Some(false), Some(_)) => Err(E::custom(
327 "`challengeCoolDown` is only valid with `seatContestable: true`: a seat \
328 that can not be contested has no cool-down",
329 )),
330 (Some(_), cool_down) => Ok(cool_down),
331 }
332 }
333 }
334
335 fn read_once<'de, A: MapAccess<'de>, T: Deserialize<'de>>(
337 map: &mut A,
338 key: &'static str,
339 slot: &mut Option<T>,
340 ) -> Result<(), A::Error> {
341 if slot.is_some() {
342 return Err(de::Error::duplicate_field(key));
343 }
344 *slot = Some(map.next_value()?);
345 Ok(())
346 }
347
348 struct V;
349
350 impl<'de> Visitor<'de> for V {
351 type Value = ContractModerators;
352
353 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
354 f.write_str(
355 "ContractModerators as a map with a `$type` discriminator, \
356 e.g. {\"$type\": \"contractOwner\"}, \
357 {\"$type\": \"appointedModerators\", \"identities\": [\"<base58>\"]} or \
358 {\"$type\": \"elected\", \"seatContestable\": true, \
359 \"challengeCoolDown\": 1209600, \
360 \"moderatedDocumentTypes\": {\"post\": [\"ban\"]}, \
361 \"interim\": {\"$type\": \"contractOwner\"}}",
362 )
363 }
364
365 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
366 let mut variant: Option<String> = None;
367 let mut identities: Option<BTreeSet<Identifier>> = None;
368 let mut elected = ElectedKeys::default();
369
370 while let Some(key) = map.next_key::<String>()? {
371 match key.as_str() {
372 "$type" => read_once(&mut map, "$type", &mut variant)?,
373 "identities" => read_once(&mut map, "identities", &mut identities)?,
374 elected_names::JOIN_WINDOW => read_once(
375 &mut map,
376 elected_names::JOIN_WINDOW,
377 &mut elected.join_window,
378 )?,
379 elected_names::VOTE_WINDOW => read_once(
380 &mut map,
381 elected_names::VOTE_WINDOW,
382 &mut elected.vote_window,
383 )?,
384 elected_names::SEAT_CONTESTABLE => read_once(
385 &mut map,
386 elected_names::SEAT_CONTESTABLE,
387 &mut elected.seat_contestable,
388 )?,
389 elected_names::CHALLENGE_COOL_DOWN => read_once(
390 &mut map,
391 elected_names::CHALLENGE_COOL_DOWN,
392 &mut elected.challenge_cool_down,
393 )?,
394 elected_names::ELECTION_DELAY => read_once(
395 &mut map,
396 elected_names::ELECTION_DELAY,
397 &mut elected.election_delay,
398 )?,
399 elected_names::MAX_ADDED_MODERATORS => read_once(
400 &mut map,
401 elected_names::MAX_ADDED_MODERATORS,
402 &mut elected.max_added_moderators,
403 )?,
404 elected_names::MODERATED_DOCUMENT_TYPES => read_once(
405 &mut map,
406 elected_names::MODERATED_DOCUMENT_TYPES,
407 &mut elected.moderated_document_types,
408 )?,
409 elected_names::INTERIM => {
410 read_once(&mut map, elected_names::INTERIM, &mut elected.interim)?
411 }
412 elected_names::OWNER_PROTECTED => read_once(
413 &mut map,
414 elected_names::OWNER_PROTECTED,
415 &mut elected.owner_protected,
416 )?,
417 other => return Err(de::Error::unknown_field(other, KEYS)),
420 }
421 }
422
423 let variant = variant.ok_or_else(|| de::Error::missing_field("$type"))?;
424 if variant != "elected" && elected.any() {
425 return Err(de::Error::custom(
426 "the keys of an elected declaration are only valid for `elected`",
427 ));
428 }
429 match variant.as_str() {
430 "contractOwner" => {
431 if identities.is_some() {
432 return Err(de::Error::custom(
433 "`identities` is only valid for `appointedModerators`",
434 ));
435 }
436 Ok(ContractModerators::ContractOwner)
437 }
438 "appointedModerators" => {
439 let ids =
440 identities.ok_or_else(|| de::Error::missing_field("identities"))?;
441 Ok(ContractModerators::AppointedModerators(ids))
442 }
443 "elected" => {
444 if identities.is_some() {
445 return Err(de::Error::custom(
446 "`identities` is only valid for `appointedModerators`; an \
447 elected declaration names its interim set under `interim`",
448 ));
449 }
450 let required = |key: &'static str| move || de::Error::missing_field(key);
451 Ok(ContractModerators::Elected(Box::new(ElectedModerators {
452 join_window: elected
453 .join_window
454 .unwrap_or(DEFAULT_ELECTION_WINDOW_SECONDS),
455 vote_window: elected
456 .vote_window
457 .unwrap_or(DEFAULT_ELECTION_WINDOW_SECONDS),
458 challenge_cool_down: elected.challenge_cool_down()?,
459 election_delay: elected.election_delay,
460 max_added_moderators: elected.max_added_moderators.unwrap_or(0),
461 moderated_document_types: elected
462 .moderated_document_types
463 .ok_or_else(required(elected_names::MODERATED_DOCUMENT_TYPES))?,
464 interim: elected
465 .interim
466 .ok_or_else(required(elected_names::INTERIM))?,
467 owner_protected: elected.owner_protected.unwrap_or(false),
468 })))
469 }
470 other => Err(de::Error::unknown_variant(
471 other,
472 &["contractOwner", "appointedModerators", "elected"],
473 )),
474 }
475 }
476 }
477
478 deserializer.deserialize_map(V)
479 }
480}
481
482impl fmt::Display for ContractModerators {
483 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
484 match self {
485 ContractModerators::ContractOwner => write!(f, "contract owner"),
486 ContractModerators::AppointedModerators(ids) => {
487 write!(f, "contract owner and {} appointed moderators", ids.len())
488 }
489 ContractModerators::Elected(elected) => elected.fmt(f),
490 }
491 }
492}
493
494#[derive(
496 Debug,
497 Clone,
498 Copy,
499 PartialEq,
500 Eq,
501 PartialOrd,
502 Ord,
503 Encode,
504 Decode,
505 DecodeUntrusted,
506 Hash,
507 Serialize,
508 Deserialize,
509)]
510#[serde(rename_all = "camelCase")]
511pub enum ContractModerationList {
512 Banlist,
514 Suspensions,
516 Warnings,
518}
519
520impl ContractModerationList {
521 pub fn bars(&self) -> bool {
524 match self {
525 ContractModerationList::Banlist | ContractModerationList::Suspensions => true,
526 ContractModerationList::Warnings => false,
527 }
528 }
529}
530
531impl fmt::Display for ContractModerationList {
532 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
533 match self {
534 ContractModerationList::Banlist => write!(f, "banlist"),
535 ContractModerationList::Suspensions => write!(f, "suspensions"),
536 ContractModerationList::Warnings => write!(f, "warning list"),
537 }
538 }
539}
540
541#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, DecodeUntrusted, Serialize, Deserialize)]
546#[serde(rename_all = "camelCase", deny_unknown_fields)]
547pub struct ContractModerationConfig {
548 #[serde(default)]
550 pub banlist: bool,
551 #[serde(default)]
553 pub suspensions: bool,
554 #[serde(default)]
556 pub moderators: ContractModerators,
557 #[serde(default)]
562 pub warnings: bool,
563}
564
565impl ContractModerationConfig {
566 pub fn keeps(&self, list: ContractModerationList) -> bool {
568 match list {
569 ContractModerationList::Banlist => self.banlist,
570 ContractModerationList::Suspensions => self.suspensions,
571 ContractModerationList::Warnings => self.warnings,
572 }
573 }
574
575 pub fn lists(&self) -> impl Iterator<Item = ContractModerationList> + '_ {
578 [
579 ContractModerationList::Banlist,
580 ContractModerationList::Suspensions,
581 ContractModerationList::Warnings,
582 ]
583 .into_iter()
584 .filter(|list| self.keeps(*list))
585 }
586
587 pub fn barring_lists(&self) -> impl Iterator<Item = ContractModerationList> + '_ {
591 self.lists().filter(ContractModerationList::bars)
592 }
593
594 pub fn may_moderate(&self, owner_id: &Identifier, identity_id: &Identifier) -> bool {
597 self.moderators.may_moderate(owner_id, identity_id)
598 }
599
600 pub fn protects(&self, owner_id: &Identifier, identity_id: &Identifier) -> bool {
603 self.moderators.protects(owner_id, identity_id)
604 }
605
606 pub fn interim_blocks_document_type(&self, document_type_name: &str) -> bool {
609 self.moderators
610 .interim_blocks_document_type(document_type_name)
611 }
612
613 pub fn team(&self, owner_id: &Identifier) -> BTreeSet<Identifier> {
616 self.moderators.team(owner_id)
617 }
618
619 pub fn validate(
631 &self,
632 document_schemas: &BTreeMap<DocumentName, Value>,
633 network: Network,
634 platform_version: &PlatformVersion,
635 ) -> Result<SimpleConsensusValidationResult, ProtocolError> {
636 match platform_version
637 .dpp
638 .contract_versions
639 .methods
640 .validate_moderation_config
641 {
642 0 => Ok(self.validate_v0(document_schemas, network, platform_version)),
643 version => Err(ProtocolError::UnknownVersionMismatch {
644 method: "ContractModerationConfig::validate".to_string(),
645 known_versions: vec![0],
646 received: version,
647 }),
648 }
649 }
650
651 #[inline(always)]
652 fn validate_v0(
653 &self,
654 document_schemas: &BTreeMap<DocumentName, Value>,
655 network: Network,
656 platform_version: &PlatformVersion,
657 ) -> SimpleConsensusValidationResult {
658 let has_document_type_with_moderator_abilities = document_schemas
659 .values()
660 .any(document_schema_grants_moderator_abilities);
661 if !self.banlist
662 && !self.suspensions
663 && !self.warnings
664 && !has_document_type_with_moderator_abilities
665 {
666 return SimpleConsensusValidationResult::new_with_error(
667 InvalidContractModerationConfigError::new(
668 "moderation declares neither a banlist, a suspension list nor a warning \
669 list, and no document type gives its moderators an ability"
670 .to_string(),
671 )
672 .into(),
673 );
674 }
675 if let Some(ids) = self.moderators.identity_ids() {
676 if ids.is_empty() {
677 return SimpleConsensusValidationResult::new_with_error(
678 InvalidContractModerationConfigError::new(
679 "the moderator identity set is empty".to_string(),
680 )
681 .into(),
682 );
683 }
684 let max = platform_version.system_limits.max_contract_moderators as usize;
685 if ids.len() > max {
686 return SimpleConsensusValidationResult::new_with_error(
687 InvalidContractModerationConfigError::new(format!(
688 "{} moderator identities named, at most {} allowed",
689 ids.len(),
690 max
691 ))
692 .into(),
693 );
694 }
695 }
696 if let Some(reason) = self.moderators.elected().and_then(|elected| {
697 elected.validation_error(self, document_schemas, network, platform_version)
698 }) {
699 return SimpleConsensusValidationResult::new_with_error(
700 InvalidContractModerationConfigError::new(format!("elected moderation: {reason}"))
701 .into(),
702 );
703 }
704 SimpleConsensusValidationResult::new()
705 }
706}
707
708#[cfg(feature = "json-conversion")]
709impl JsonSafeFields for ContractModerators {}
710#[cfg(feature = "json-conversion")]
711impl JsonSafeFields for ContractModerationConfig {}
712#[cfg(feature = "json-conversion")]
713impl JsonSafeFields for ContractModerationList {}
714
715#[derive(
717 Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted, Serialize, Deserialize,
718)]
719#[serde(rename_all = "camelCase")]
720pub struct ContractBan {
721 pub reason: ContractModerationReason,
723}
724
725#[derive(
727 Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted, Serialize, Deserialize,
728)]
729#[serde(rename_all = "camelCase")]
730pub struct ContractSuspension {
731 pub until: TimestampMillis,
733 pub reason: ContractModerationReason,
735}
736
737#[derive(
739 Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted, Serialize, Deserialize,
740)]
741#[serde(rename_all = "camelCase")]
742pub struct ContractWarning {
743 pub warned_at: TimestampMillis,
745 pub reason: ContractModerationReason,
747}
748
749#[derive(
751 Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted, Serialize, Deserialize,
752)]
753#[serde(rename_all = "camelCase")]
754pub struct ContractModerationStatus {
755 pub ban: Option<ContractBan>,
757 pub suspension: Option<ContractSuspension>,
760 #[serde(default)]
763 pub warnings: Vec<ContractWarning>,
764}
765
766impl ContractModerationStatus {
767 pub fn banned(&self) -> bool {
769 self.ban.is_some()
770 }
771
772 pub fn warned(&self) -> bool {
774 !self.warnings.is_empty()
775 }
776
777 pub fn suspended_until(&self) -> Option<TimestampMillis> {
779 self.suspension.as_ref().map(|suspension| suspension.until)
780 }
781
782 pub fn is_barred_at(&self, block_time_ms: TimestampMillis) -> bool {
785 self.banned()
786 || self
787 .suspended_until()
788 .is_some_and(|until| until > block_time_ms)
789 }
790
791 pub fn has_lapsed_suspension_at(&self, block_time_ms: TimestampMillis) -> bool {
793 self.suspended_until()
794 .is_some_and(|until| until <= block_time_ms)
795 }
796}
797
798#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, Serialize, Deserialize)]
803#[serde(tag = "list", rename_all = "camelCase")]
804pub enum ContractModerationListStatus {
805 #[serde(rename_all = "camelCase")]
807 Banlist {
808 ban: Option<ContractBan>,
810 },
811 #[serde(rename_all = "camelCase")]
813 Suspensions {
814 suspension: Option<ContractSuspension>,
817 },
818 #[serde(rename_all = "camelCase")]
820 Warnings {
821 warnings: Vec<ContractWarning>,
823 },
824}
825
826impl ContractModerationListStatus {
827 pub fn from_status(list: ContractModerationList, status: &ContractModerationStatus) -> Self {
829 match list {
830 ContractModerationList::Banlist => Self::Banlist {
831 ban: status.ban.clone(),
832 },
833 ContractModerationList::Suspensions => Self::Suspensions {
834 suspension: status.suspension.clone(),
835 },
836 ContractModerationList::Warnings => Self::Warnings {
837 warnings: status.warnings.clone(),
838 },
839 }
840 }
841
842 pub fn list(&self) -> ContractModerationList {
844 match self {
845 Self::Banlist { .. } => ContractModerationList::Banlist,
846 Self::Suspensions { .. } => ContractModerationList::Suspensions,
847 Self::Warnings { .. } => ContractModerationList::Warnings,
848 }
849 }
850}
851
852#[derive(Debug, Clone, PartialEq, Eq, Default, Encode, Decode, Serialize, Deserialize)]
858pub struct ContractModerationListStatuses(pub Vec<ContractModerationListStatus>);
859
860impl ContractModerationListStatuses {
861 pub fn from_status(
863 lists: &[ContractModerationList],
864 status: &ContractModerationStatus,
865 ) -> Self {
866 Self(
867 lists
868 .iter()
869 .map(|list| ContractModerationListStatus::from_status(*list, status))
870 .collect(),
871 )
872 }
873
874 pub fn ban(&self) -> Option<Option<&ContractBan>> {
877 self.0.iter().find_map(|status| match status {
878 ContractModerationListStatus::Banlist { ban } => Some(ban.as_ref()),
879 ContractModerationListStatus::Suspensions { .. }
880 | ContractModerationListStatus::Warnings { .. } => None,
881 })
882 }
883
884 pub fn suspension(&self) -> Option<Option<&ContractSuspension>> {
887 self.0.iter().find_map(|status| match status {
888 ContractModerationListStatus::Suspensions { suspension } => Some(suspension.as_ref()),
889 ContractModerationListStatus::Banlist { .. }
890 | ContractModerationListStatus::Warnings { .. } => None,
891 })
892 }
893
894 pub fn warnings(&self) -> Option<&[ContractWarning]> {
897 self.0.iter().find_map(|status| match status {
898 ContractModerationListStatus::Warnings { warnings } => Some(warnings.as_slice()),
899 ContractModerationListStatus::Banlist { .. }
900 | ContractModerationListStatus::Suspensions { .. } => None,
901 })
902 }
903
904 pub fn banned(&self) -> Option<bool> {
906 self.ban().map(|ban| ban.is_some())
907 }
908
909 pub fn suspended_until(&self) -> Option<Option<TimestampMillis>> {
912 self.suspension()
913 .map(|suspension| suspension.map(|suspension| suspension.until))
914 }
915
916 pub fn is_barred_on_queried_lists_at(&self, block_time_ms: TimestampMillis) -> bool {
919 self.banned() == Some(true)
920 || self
921 .suspended_until()
922 .flatten()
923 .is_some_and(|until| until > block_time_ms)
924 }
925}
926
927#[cfg(test)]
928mod tests {
929 use super::*;
930 use platform_value::platform_value;
931
932 fn set(ids: &[u8]) -> BTreeSet<Identifier> {
933 ids.iter().map(|b| Identifier::from([*b; 32])).collect()
934 }
935
936 fn schemas_with_a_deletable_type() -> BTreeMap<DocumentName, Value> {
938 BTreeMap::from([(
939 "post".to_string(),
940 platform_value!({ "type": "object", "moderatorAbilities": { "delete": true } }),
941 )])
942 }
943
944 #[test]
945 fn should_round_trip_moderators_through_json() {
946 for moderators in [
947 ContractModerators::ContractOwner,
948 ContractModerators::AppointedModerators(set(&[1, 2])),
949 ] {
950 let json = serde_json::to_value(&moderators).expect("serialize");
951 let back: ContractModerators = serde_json::from_value(json).expect("deserialize");
952 assert_eq!(moderators, back);
953 }
954 let json = serde_json::to_value(ContractModerators::AppointedModerators(set(&[1])))
955 .expect("serialize");
956 assert_eq!(json["$type"], "appointedModerators");
957 assert_eq!(json["identities"].as_array().map(|a| a.len()), Some(1));
958 }
959
960 #[test]
961 fn should_refuse_a_misspelled_key_instead_of_dropping_it() {
962 let misspelled_list = serde_json::json!({ "banlist": true, "suspension": true });
965 assert!(serde_json::from_value::<ContractModerationConfig>(misspelled_list).is_err());
966
967 let misspelled_moderators = serde_json::json!({
968 "banlist": true,
969 "moderators": { "$type": "appointedModerators", "identity": [] },
970 });
971 assert!(serde_json::from_value::<ContractModerationConfig>(misspelled_moderators).is_err());
972
973 let identities_under_the_owner = serde_json::json!({
974 "$type": "contractOwner",
975 "identities": [],
976 });
977 assert!(serde_json::from_value::<ContractModerators>(identities_under_the_owner).is_err());
978
979 let well_formed = serde_json::json!({ "banlist": true, "suspensions": true });
980 let config: ContractModerationConfig =
981 serde_json::from_value(well_formed).expect("deserialize");
982 assert!(config.banlist && config.suspensions);
983 }
984
985 #[test]
986 fn should_reject_a_config_with_no_list() {
987 let config = ContractModerationConfig {
988 banlist: false,
989 suspensions: false,
990 warnings: false,
991 moderators: ContractModerators::ContractOwner,
992 };
993 let result = config
994 .validate(
995 &BTreeMap::new(),
996 Network::Mainnet,
997 PlatformVersion::latest(),
998 )
999 .expect("validate");
1000 assert!(!result.is_valid());
1001 }
1002
1003 #[test]
1004 fn should_accept_a_config_with_no_list_when_a_document_type_can_be_deleted_by_moderators() {
1005 let config = ContractModerationConfig {
1006 banlist: false,
1007 suspensions: false,
1008 warnings: false,
1009 moderators: ContractModerators::ContractOwner,
1010 };
1011 let result = config
1012 .validate(
1013 &schemas_with_a_deletable_type(),
1014 Network::Mainnet,
1015 PlatformVersion::latest(),
1016 )
1017 .expect("validate");
1018 assert!(result.is_valid(), "{:?}", result.errors);
1019 assert_eq!(config.lists().count(), 0);
1020 }
1021
1022 #[test]
1023 fn should_accept_a_config_with_no_list_when_a_document_type_keeps_fields_for_moderators() {
1024 let config = ContractModerationConfig {
1025 banlist: false,
1026 suspensions: false,
1027 warnings: false,
1028 moderators: ContractModerators::ContractOwner,
1029 };
1030 let schemas = BTreeMap::from([(
1031 "report".to_string(),
1032 platform_value!({
1033 "type": "object",
1034 "moderatorAbilities": { "changeFields": ["status"] },
1035 }),
1036 )]);
1037 let result = config
1038 .validate(&schemas, Network::Mainnet, PlatformVersion::latest())
1039 .expect("validate");
1040 assert!(result.is_valid(), "{:?}", result.errors);
1041 assert!(document_schema_grants_moderator_abilities(
1042 &schemas["report"]
1043 ));
1044 assert!(!document_schema_lets_moderators_delete(&schemas["report"]));
1045 }
1046
1047 #[test]
1048 fn should_accept_the_owner_among_the_moderators() {
1049 let owner = Identifier::from([9; 32]);
1050 let config = ContractModerationConfig {
1051 banlist: true,
1052 suspensions: false,
1053 warnings: false,
1054 moderators: ContractModerators::AppointedModerators(set(&[9, 1])),
1055 };
1056 let result = config
1057 .validate(
1058 &BTreeMap::new(),
1059 Network::Mainnet,
1060 PlatformVersion::latest(),
1061 )
1062 .expect("validate");
1063 assert!(result.is_valid(), "{:?}", result.errors);
1064 assert!(config.may_moderate(&owner, &owner));
1066 }
1067
1068 #[test]
1069 fn should_count_a_named_owner_toward_the_moderator_limit() {
1070 let platform_version = PlatformVersion::latest();
1071 let max = platform_version.system_limits.max_contract_moderators as u8;
1072 let config = |count: u8| ContractModerationConfig {
1074 banlist: true,
1075 suspensions: false,
1076 warnings: false,
1077 moderators: ContractModerators::AppointedModerators(set(
1078 &(1..=count).collect::<Vec<u8>>()
1079 )),
1080 };
1081 assert!(config(max)
1082 .validate(&BTreeMap::new(), Network::Mainnet, platform_version)
1083 .expect("validate")
1084 .is_valid());
1085 assert!(!config(max + 1)
1086 .validate(&BTreeMap::new(), Network::Mainnet, platform_version)
1087 .expect("validate")
1088 .is_valid());
1089 }
1090
1091 #[test]
1092 fn should_reject_an_empty_or_oversized_moderator_set() {
1093 let platform_version = PlatformVersion::latest();
1094 let empty = ContractModerationConfig {
1095 banlist: true,
1096 suspensions: true,
1097 warnings: false,
1098 moderators: ContractModerators::AppointedModerators(BTreeSet::new()),
1099 };
1100 assert!(!empty
1101 .validate(&BTreeMap::new(), Network::Mainnet, platform_version)
1102 .expect("validate")
1103 .is_valid());
1104 let too_many: Vec<u8> =
1105 (1..=(platform_version.system_limits.max_contract_moderators as u8 + 1)).collect();
1106 let oversized = ContractModerationConfig {
1107 banlist: true,
1108 suspensions: true,
1109 warnings: false,
1110 moderators: ContractModerators::AppointedModerators(set(&too_many)),
1111 };
1112 assert!(!oversized
1113 .validate(&BTreeMap::new(), Network::Mainnet, platform_version)
1114 .expect("validate")
1115 .is_valid());
1116 }
1117
1118 #[test]
1119 fn should_accept_a_well_formed_config() {
1120 let owner = Identifier::from([9; 32]);
1121 let config = ContractModerationConfig {
1122 banlist: true,
1123 suspensions: true,
1124 warnings: false,
1125 moderators: ContractModerators::AppointedModerators(set(&[1, 2, 3])),
1126 };
1127 assert!(config
1128 .validate(
1129 &BTreeMap::new(),
1130 Network::Mainnet,
1131 PlatformVersion::latest()
1132 )
1133 .expect("validate")
1134 .is_valid());
1135 assert!(config.may_moderate(&owner, &owner));
1136 assert!(config.may_moderate(&owner, &Identifier::from([2; 32])));
1137 assert!(!config.may_moderate(&owner, &Identifier::from([7; 32])));
1138 assert_eq!(config.lists().count(), 2);
1139 }
1140
1141 #[test]
1142 fn should_put_the_owner_on_the_team_only_when_appointed_or_alone() {
1143 let owner = Identifier::from([9; 32]);
1144 assert_eq!(
1145 ContractModerators::ContractOwner.team(&owner),
1146 BTreeSet::from([owner])
1147 );
1148 assert_eq!(
1149 ContractModerators::AppointedModerators(set(&[1, 2])).team(&owner),
1150 set(&[1, 2])
1151 );
1152 assert_eq!(
1153 ContractModerators::AppointedModerators(set(&[1, 9])).team(&owner),
1154 set(&[1, 9])
1155 );
1156 }
1157
1158 #[test]
1159 fn should_keep_a_warning_list_alone_and_bar_nobody_with_it() {
1160 let config = ContractModerationConfig {
1161 banlist: false,
1162 suspensions: false,
1163 warnings: true,
1164 moderators: ContractModerators::ContractOwner,
1165 };
1166 let result = config
1167 .validate(
1168 &BTreeMap::new(),
1169 Network::Mainnet,
1170 PlatformVersion::latest(),
1171 )
1172 .expect("validate");
1173 assert!(result.is_valid(), "{:?}", result.errors);
1174 assert_eq!(
1175 config.lists().collect::<Vec<_>>(),
1176 vec![ContractModerationList::Warnings]
1177 );
1178 assert_eq!(config.barring_lists().count(), 0);
1180
1181 let all = ContractModerationConfig {
1182 banlist: true,
1183 suspensions: true,
1184 warnings: true,
1185 moderators: ContractModerators::ContractOwner,
1186 };
1187 assert_eq!(
1188 all.lists().collect::<Vec<_>>(),
1189 vec![
1190 ContractModerationList::Banlist,
1191 ContractModerationList::Suspensions,
1192 ContractModerationList::Warnings,
1193 ]
1194 );
1195 assert_eq!(
1196 all.barring_lists().collect::<Vec<_>>(),
1197 vec![
1198 ContractModerationList::Banlist,
1199 ContractModerationList::Suspensions,
1200 ]
1201 );
1202 }
1203
1204 #[test]
1205 fn should_round_trip_a_config_with_warnings_through_json_and_default_them_off() {
1206 let config = ContractModerationConfig {
1207 banlist: true,
1208 suspensions: false,
1209 warnings: true,
1210 moderators: ContractModerators::ContractOwner,
1211 };
1212 let json = serde_json::to_value(&config).expect("to json");
1213 assert_eq!(json["warnings"], true);
1214 let back: ContractModerationConfig = serde_json::from_value(json).expect("from json");
1215 assert_eq!(back, config);
1216
1217 let older: ContractModerationConfig = serde_json::from_value(serde_json::json!({
1219 "banlist": true,
1220 "moderators": { "$type": "contractOwner" },
1221 }))
1222 .expect("from json");
1223 assert!(!older.warnings);
1224 }
1225
1226 #[test]
1227 fn should_report_the_warnings_of_the_list_queried_only() {
1228 let warned = ContractModerationStatus {
1229 ban: None,
1230 suspension: None,
1231 warnings: vec![ContractWarning {
1232 warned_at: 5,
1233 reason: ContractModerationReason::from_text("first strike"),
1234 }],
1235 };
1236 assert!(warned.warned());
1237 assert!(!warned.is_barred_at(0));
1238
1239 let warnings_only = ContractModerationListStatuses::from_status(
1240 &[ContractModerationList::Warnings],
1241 &warned,
1242 );
1243 assert_eq!(warnings_only.warnings().map(<[_]>::len), Some(1));
1244 assert_eq!(warnings_only.banned(), None);
1245 assert_eq!(warnings_only.suspended_until(), None);
1246 assert!(!warnings_only.is_barred_on_queried_lists_at(0));
1247
1248 let banlist_only = ContractModerationListStatuses::from_status(
1249 &[ContractModerationList::Banlist],
1250 &warned,
1251 );
1252 assert_eq!(banlist_only.warnings(), None);
1253 assert_eq!(
1254 ContractModerationListStatus::Warnings { warnings: vec![] }.list(),
1255 ContractModerationList::Warnings
1256 );
1257 }
1258
1259 #[test]
1260 fn should_tell_barred_from_lapsed() {
1261 let banned = ContractModerationStatus {
1262 ban: Some(ContractBan::default()),
1263 suspension: None,
1264 warnings: vec![],
1265 };
1266 assert!(banned.banned());
1267 assert!(banned.is_barred_at(0));
1268 let suspended = ContractModerationStatus {
1269 ban: None,
1270 suspension: Some(ContractSuspension {
1271 until: 100,
1272 reason: ContractModerationReason::from_text("flooding"),
1273 }),
1274 warnings: vec![],
1275 };
1276 assert!(!suspended.banned());
1277 assert_eq!(suspended.suspended_until(), Some(100));
1278 assert!(suspended.is_barred_at(99));
1279 assert!(!suspended.is_barred_at(100));
1280 assert!(suspended.has_lapsed_suspension_at(100));
1281 assert!(!suspended.has_lapsed_suspension_at(99));
1282 }
1283
1284 #[test]
1285 fn should_report_only_the_lists_read() {
1286 let status = ContractModerationStatus {
1287 ban: Some(ContractBan {
1288 reason: ContractModerationReason::from_text("spam"),
1289 }),
1290 suspension: None,
1291 warnings: vec![],
1292 };
1293
1294 let banlist_only = ContractModerationListStatuses::from_status(
1295 &[ContractModerationList::Banlist],
1296 &status,
1297 );
1298 assert_eq!(banlist_only.banned(), Some(true));
1299 assert_eq!(
1300 banlist_only
1301 .ban()
1302 .flatten()
1303 .map(|ban| ban.reason.text.as_str()),
1304 Some("spam")
1305 );
1306 assert_eq!(banlist_only.suspension(), None);
1307 assert_eq!(banlist_only.suspended_until(), None);
1308
1309 let both = ContractModerationListStatuses::from_status(
1310 &[
1311 ContractModerationList::Banlist,
1312 ContractModerationList::Suspensions,
1313 ],
1314 &status,
1315 );
1316 assert_eq!(both.suspension(), Some(None));
1317 assert_eq!(both.suspended_until(), Some(None));
1318 assert_eq!(
1319 serde_json::to_value(&both).expect("to json"),
1320 serde_json::json!([
1321 {"list": "banlist", "ban": {"reason": {"code": null, "text": "spam"}}},
1322 {"list": "suspensions", "suspension": null},
1323 ])
1324 );
1325 }
1326}