Skip to main content

dpp/data_contract/config/moderation/
mod.rs

1//! Contract moderation: the declaration, inside a data contract's config, that the contract
2//! keeps a banlist, a suspension list and/or a warning list of identities, and who may edit
3//! them.
4//!
5//! An identity on the banlist, or on the suspension list with a suspension that has not lapsed,
6//! cannot act on the contract at the document level: every document transition it signs against
7//! the contract is refused. Token transitions are not affected. A warning bars nothing: it is
8//! a record, with a reason and a time, that the identity and everyone else can read, and that
9//! accumulates until a moderator clears it. The lists live under the contract's own subtree in
10//! Drive (keys `128`, `192` and `224` of its other tree, `[64, id, 2]`) and are edited by the
11//! `ContractUserModeration` state transition.
12//!
13//! The same moderators may delete the documents of the document types that say so
14//! (`moderatorAbilities.delete`), and write the fields a document type keeps for them
15//! (`moderatorAbilities.changeFields`), with the same transition. Each removal leaves a
16//! [`ContractDocumentRemoval`] under the contract (key `16` of its other tree).
17//!
18//! A contract may instead declare that its moderators are an [elected team](elected): until
19//! one is seated, the interim moderators the declaration names moderate as the merged kinds
20//! do, or nobody does and the moderated document types wait.
21
22use 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
54/// The `moderatorAbilities` object of a raw document type schema, `None` when it has none
55/// or it is not an object.
56fn 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
64/// Whether a raw document type schema lets the contract's moderators delete its documents
65/// (`moderatorAbilities.delete: true`).
66pub 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
75/// Whether a raw document type schema lets the members of a seated moderation team delete its
76/// documents once settled, past `deleteWithin` (a `moderatorAbilities.deleteSettled` object).
77pub 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
85/// Whether a raw document type schema lists fields only the contract's moderators write
86/// (a non-empty `moderatorAbilities.changeFields`).
87pub 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
96/// Whether a raw document type schema gives the contract's moderators an ability over its
97/// documents: deleting them, or changing their moderator fields.
98pub 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/// Who may send a `ContractUserModeration` transition for the contract.
104///
105/// With the first two kinds the contract owner always may, named or not; a moderator set is
106/// fixed in the config and changed only by a contract update. With the third the moderators
107/// are an elected team, and until one is seated the interim moderators the declaration
108/// names; the declaration is fixed at creation and never changes.
109#[derive(Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted)]
110pub enum ContractModerators {
111    /// Only the contract owner moderates.
112    #[default]
113    ContractOwner,
114    /// The moderators the contract appoints, beside its owner, who always may moderate.
115    /// Non-empty, at most `SystemLimits::max_contract_moderators`, each an identity that
116    /// exists. The owner may be appointed too, and then counts toward that limit; appointing
117    /// it changes nothing about who may moderate.
118    AppointedModerators(BTreeSet<Identifier>),
119    /// A team elected by masternodes moderates, once one is seated; until then the interim
120    /// moderators of the declaration do. Declarable only when the contract is created, and
121    /// never left or changed by an update. Boxed: the declaration is the largest kind by
122    /// far, and a contract's config is embedded by value wherever a contract is.
123    Elected(Box<ElectedModerators>),
124}
125
126impl ContractModerators {
127    /// The identities the kind names, the owner among them only when it is named: the
128    /// appointed set, or the appointed interim set of an elected declaration. `None` when
129    /// nobody is named.
130    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    /// Whether `identity_id` is one of the identities the kind names.
139    pub fn names(&self, identity_id: &Identifier) -> bool {
140        self.identity_ids()
141            .is_some_and(|ids| ids.contains(identity_id))
142    }
143
144    /// The elected declaration, `None` for the merged kinds.
145    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    /// Whether `identity_id` may moderate a contract owned by `owner_id`. Under an elected
153    /// declaration, whether it may during the interim: the owner alone, the owner and the
154    /// appointed interim set, or nobody, the moderated types unusable or unmoderated meanwhile.
155    ///
156    /// Once a charter is seated on an elected contract its team moderates instead, and the
157    /// interim moderators no longer may. Who is on the team is read from the moderation
158    /// charters contract, so that is decided where state is read, not here.
159    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    /// Whether `identity_id` is protected from moderation on a contract owned by `owner_id`:
171    /// it can be neither banned nor suspended, and its documents can not be deleted. Whoever
172    /// may moderate is, and so is the owner of an elected contract whose declaration says so.
173    /// Under an elected declaration this is the interim's protection; once a charter is
174    /// seated, the leader and the active members of its team are protected instead, with the
175    /// owner when the declaration says so, as state says.
176    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    /// Whether every document transition of the document type is refused while no team is
185    /// seated: an elected declaration in its interim with nobody moderating blocks its
186    /// moderated types until one is. Whether one is, is state's to say.
187    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    /// The moderation team of a contract owned by `owner_id`: the identities that share its
193    /// moderators fee pot. It is the set the contract appoints, the owner among them only
194    /// when appointed, and the owner alone when nobody is appointed. Under an elected
195    /// declaration it is the interim's team: the same by kind, and nobody while the
196    /// moderated types are not yet usable, so that the pot accumulates for the team to come.
197    ///
198    /// The team is about earnings, not authority: an owner who is not on it still may
199    /// moderate ([`Self::may_moderate`]).
200    ///
201    /// Like [`Self::may_moderate`] and [`Self::protects`], this reads the config alone, so for
202    /// an elected contract it describes the interim only. Once a charter is seated its claim of
203    /// the moderators pot is refused and its moderations too; who is on the seated team is read
204    /// from the moderation charters contract, which a client asks rather than this.
205    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
214// The wire shape is a flat `{"$type": "contractOwner"}`,
215// `{"$type": "appointedModerators", "identities": [...]}` or `{"$type": "elected", ...}` map
216// with the declaration's keys beside its `$type`, the style of `AuthorizedActionTakers`.
217// Bincode is untouched.
218impl 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                // Only a contestable seat has a cool-down
245                if let Some(cool_down) = elected.challenge_cool_down {
246                    m.serialize_entry(elected_names::CHALLENGE_COOL_DOWN, &cool_down)?;
247                }
248                // Absent, not null, when the declaration has no delay: the wire form of a
249                // declaration that left it out is unchanged
250                if let Some(delay) = elected.election_delay {
251                    m.serialize_entry(elected_names::ELECTION_DELAY, &delay)?;
252                }
253                // Absent when no member may be added, for the same reason
254                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        /// The keys of an elected declaration, each read at most once.
292        #[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            /// The cool-down of the seat, `None` for a seat that can not be contested.
319            /// `seatContestable` is required with no default: false would make every team
320            /// permanent, true would opt every contract into challenges unasked. The
321            /// cool-down comes with a contestable seat and only with one.
322            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        /// Reads the value of `key` into `slot`, refusing a second occurrence.
336        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                        // Refused rather than skipped: the declaration can hardly be changed
418                        // after the contract is created, so a misspelled key must not pass.
419                        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/// Which of the moderation lists an action or a query refers to.
495#[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    /// The banlist: identities barred until an unban.
513    Banlist,
514    /// The suspension list: identities barred until a block time.
515    Suspensions,
516    /// The warning list: identities warned, and why, barred from nothing.
517    Warnings,
518}
519
520impl ContractModerationList {
521    /// Whether an entry on the list bars the identity from the contract's documents. A
522    /// warning does not.
523    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/// The moderation a data contract declares in its config.
542///
543/// An unknown key is refused: which lists a contract keeps is fixed when it is created, so a
544/// misspelled `suspensions` must not quietly leave the contract without the list for good.
545#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, DecodeUntrusted, Serialize, Deserialize)]
546#[serde(rename_all = "camelCase", deny_unknown_fields)]
547pub struct ContractModerationConfig {
548    /// The contract keeps a banlist (Drive key `128` of the contract's other tree).
549    #[serde(default)]
550    pub banlist: bool,
551    /// The contract keeps a suspension list (Drive key `192` of the contract's other tree).
552    #[serde(default)]
553    pub suspensions: bool,
554    /// Who may edit the lists.
555    #[serde(default)]
556    pub moderators: ContractModerators,
557    /// The contract keeps a warning list (Drive key `224` of the contract's other tree).
558    /// Last in the bincode layout: it joined after the rest, and a declaration stored by a
559    /// 4.2 beta without it fails to decode at its end rather than misreading the moderators.
560    /// Such networks are reset.
561    #[serde(default)]
562    pub warnings: bool,
563}
564
565impl ContractModerationConfig {
566    /// Whether the contract keeps `list`.
567    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    /// The lists the contract keeps, in tree key order: the banlist, the suspension list, the
576    /// warning list.
577    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    /// The lists the contract keeps whose entries bar an identity from its documents: the
588    /// banlist and the suspension list, never the warning list. What the document gate reads,
589    /// and what a ban's proof covers.
590    pub fn barring_lists(&self) -> impl Iterator<Item = ContractModerationList> + '_ {
591        self.lists().filter(ContractModerationList::bars)
592    }
593
594    /// Whether `identity_id` may moderate a contract owned by `owner_id`. Whoever may moderate
595    /// cannot be put on a list either, though an entry it already carries may be removed.
596    pub fn may_moderate(&self, owner_id: &Identifier, identity_id: &Identifier) -> bool {
597        self.moderators.may_moderate(owner_id, identity_id)
598    }
599
600    /// Whether `identity_id` is protected from moderation on a contract owned by `owner_id`.
601    /// See [`ContractModerators::protects`].
602    pub fn protects(&self, owner_id: &Identifier, identity_id: &Identifier) -> bool {
603        self.moderators.protects(owner_id, identity_id)
604    }
605
606    /// Whether every document transition of the document type is refused until a moderation
607    /// team is seated. See [`ContractModerators::interim_blocks_document_type`].
608    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    /// The moderation team of a contract owned by `owner_id`: the identities that share its
614    /// moderators fee pot. See [`ContractModerators::team`].
615    pub fn team(&self, owner_id: &Identifier) -> BTreeSet<Identifier> {
616        self.moderators.team(owner_id)
617    }
618
619    /// The pure-data rules of the declaration: it gives the moderators something to do, and a
620    /// moderator set is non-empty and within `SystemLimits::max_contract_moderators` (a named
621    /// owner counts). Something to do is a list to edit or, failing that, a document type whose
622    /// documents they may delete, read from the raw `document_schemas` of the contract the
623    /// declaration belongs to, which an elected declaration is also checked against: its
624    /// moderated types must name document types of the contract
625    /// ([`ElectedModerators::validation_error`] has its rules).
626    /// Whether the named identities exist is state validation, done by the contract create
627    /// and update transitions: a moderator that does not exist can never sign, so naming one
628    /// is a mistake, caught where it is cheapest. The `network` is the one the node runs: an
629    /// elected declaration's windows have a floor on mainnet only.
630    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/// A banlist entry.
716#[derive(
717    Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted, Serialize, Deserialize,
718)]
719#[serde(rename_all = "camelCase")]
720pub struct ContractBan {
721    /// Why the moderator banned the identity.
722    pub reason: ContractModerationReason,
723}
724
725/// A suspension list entry.
726#[derive(
727    Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted, Serialize, Deserialize,
728)]
729#[serde(rename_all = "camelCase")]
730pub struct ContractSuspension {
731    /// The block time, in milliseconds, at which the suspension lapses.
732    pub until: TimestampMillis,
733    /// Why the moderator suspended the identity.
734    pub reason: ContractModerationReason,
735}
736
737/// One warning of a warning list entry.
738#[derive(
739    Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted, Serialize, Deserialize,
740)]
741#[serde(rename_all = "camelCase")]
742pub struct ContractWarning {
743    /// The time of the block that issued the warning, in milliseconds.
744    pub warned_at: TimestampMillis,
745    /// Why the moderator warned the identity.
746    pub reason: ContractModerationReason,
747}
748
749/// What a contract's moderation lists say about one identity.
750#[derive(
751    Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted, Serialize, Deserialize,
752)]
753#[serde(rename_all = "camelCase")]
754pub struct ContractModerationStatus {
755    /// The identity's banlist entry, `None` when it is not banned.
756    pub ban: Option<ContractBan>,
757    /// The identity's suspension list entry, `None` when it is not suspended. A lapsed
758    /// suspension (at or before the block time) still appears here until it is swept.
759    pub suspension: Option<ContractSuspension>,
760    /// The identity's warnings, oldest first; empty when it carries none. They stay until a
761    /// moderator clears them, and bar nothing.
762    #[serde(default)]
763    pub warnings: Vec<ContractWarning>,
764}
765
766impl ContractModerationStatus {
767    /// Whether the identity is on the banlist.
768    pub fn banned(&self) -> bool {
769        self.ban.is_some()
770    }
771
772    /// Whether the identity carries at least one warning.
773    pub fn warned(&self) -> bool {
774        !self.warnings.is_empty()
775    }
776
777    /// The block time, in milliseconds, until which the identity is suspended, lapsed or not.
778    pub fn suspended_until(&self) -> Option<TimestampMillis> {
779        self.suspension.as_ref().map(|suspension| suspension.until)
780    }
781
782    /// Whether the identity is barred from acting on the contract at the document level at
783    /// `block_time_ms`: it is banned, or under a suspension that has not lapsed.
784    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    /// Whether the identity carries a suspension that has lapsed at `block_time_ms`.
792    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/// What one of a contract's moderation lists says about one identity, and nothing about the
799/// other list. It is what the proof of a moderation transition's execution shows: that proof
800/// holds the edited entry only, so the other list stays unknown rather than being reported as
801/// empty (an identity unsuspended a moment ago may well be banned).
802#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, Serialize, Deserialize)]
803#[serde(tag = "list", rename_all = "camelCase")]
804pub enum ContractModerationListStatus {
805    /// The banlist entry
806    #[serde(rename_all = "camelCase")]
807    Banlist {
808        /// The identity's banlist entry, `None` when it is not banned.
809        ban: Option<ContractBan>,
810    },
811    /// The suspension list entry
812    #[serde(rename_all = "camelCase")]
813    Suspensions {
814        /// The identity's suspension list entry, `None` when it is not suspended. A lapsed
815        /// suspension still appears here until it is swept.
816        suspension: Option<ContractSuspension>,
817    },
818    /// The warning list entry
819    #[serde(rename_all = "camelCase")]
820    Warnings {
821        /// The identity's warnings, oldest first; empty when it carries none.
822        warnings: Vec<ContractWarning>,
823    },
824}
825
826impl ContractModerationListStatus {
827    /// The part of a full `status` that `list` holds.
828    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    /// The list this status was read from.
843    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/// One identity's status on the lists that were read, one entry per list, in the order read:
853/// what a status query answers for the lists it names, and what the proof of a moderation
854/// transition's execution shows. A list the query did not name is absent, not empty: an identity that is not
855/// suspended may still be banned when the banlist was not read. Query every list the contract
856/// keeps for the whole picture.
857#[derive(Debug, Clone, PartialEq, Eq, Default, Encode, Decode, Serialize, Deserialize)]
858pub struct ContractModerationListStatuses(pub Vec<ContractModerationListStatus>);
859
860impl ContractModerationListStatuses {
861    /// The part of `status` that `lists` cover.
862    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    /// The identity's banlist entry (`Some(None)`: not banned), `None` when the banlist was
875    /// not queried.
876    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    /// The identity's suspension list entry (`Some(None)`: not suspended), `None` when the
885    /// suspension list was not queried.
886    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    /// The identity's warnings, oldest first (`Some(&[])`: none), `None` when the warning list
895    /// was not queried.
896    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    /// Whether the identity is banned, `None` when the banlist was not queried.
905    pub fn banned(&self) -> Option<bool> {
906        self.ban().map(|ban| ban.is_some())
907    }
908
909    /// Until when the identity is suspended (`Some(None)`: not suspended), `None` when the
910    /// suspension list was not queried.
911    pub fn suspended_until(&self) -> Option<Option<TimestampMillis>> {
912        self.suspension()
913            .map(|suspension| suspension.map(|suspension| suspension.until))
914    }
915
916    /// Whether one of the lists queried bars the identity at `block_time_ms`. `false` says
917    /// nothing about a list that was not queried.
918    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    /// One document type, `post`, whose documents moderators may delete
937    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        // `suspension` for `suspensions`: dropped, it would leave the contract without the
963        // list for good.
964        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        // Naming the owner changes nothing about who may moderate or who is protected.
1065        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        // Identity `[1; 32]`, the first of every set below, stands for the owner: it counts.
1073        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        // Warnings bar nothing: the document gate reads no list of this contract.
1179        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        // A declaration written before warning lists existed keeps no warning list.
1218        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}