Skip to main content

dpp/data_contract/config/moderation/
elected.rs

1//! Elected moderation: the declaration that a contract's moderators are a team chosen by
2//! masternodes and evonodes instead of by the contract owner (protocol version 14).
3//!
4//! The declaration is fixed at the contract's creation and never changes: the election
5//! parameters, whether the seat can be contested again once a team is seated, the document
6//! types the team moderates with the abilities it holds on each, who moderates until the
7//! first team is seated, and whether the owner is protected from the team. A charter does
8//! not price the moderators part of a document action: the type's own
9//! `actionFees.moderators` amount is the most a team may charge, and a charter charges a
10//! share of it. No election exists yet: until one does, the contract is in its
11//! **interim**,
12//! moderated by the interim moderators the declaration names, or by nobody: with the
13//! moderated types not yet usable, or usable and unmoderated meanwhile.
14
15use crate::data_contract::config::moderation::{
16    document_schema_lets_moderators_change_fields, document_schema_lets_moderators_delete,
17    document_schema_lets_moderators_delete_settled, ContractModerationConfig,
18};
19use crate::data_contract::DocumentName;
20use crate::prelude::TimestampMillis;
21#[cfg(feature = "json-conversion")]
22use crate::serialization::JsonSafeFields;
23use bincode::{Decode, DecodeUntrusted, Encode};
24use dashcore::Network;
25use platform_value::{Identifier, Value};
26use platform_version::version::PlatformVersion;
27use serde::{Deserialize, Serialize};
28use std::collections::{BTreeMap, BTreeSet};
29use std::fmt;
30
31/// One week: the join window and the vote window a declaration gets when it leaves them out.
32pub const DEFAULT_ELECTION_WINDOW_SECONDS: u32 = 604_800;
33
34/// The keys of an elected declaration on the wire, beside the `$type` of its variant.
35pub mod property_names {
36    /// The join window, in seconds
37    pub const JOIN_WINDOW: &str = "joinWindow";
38    /// The vote window, in seconds
39    pub const VOTE_WINDOW: &str = "voteWindow";
40    /// Whether the seat can be contested again once a team is seated
41    pub const SEAT_CONTESTABLE: &str = "seatContestable";
42    /// The challenge cool-down, in seconds, of a contestable seat
43    pub const CHALLENGE_COOL_DOWN: &str = "challengeCoolDown";
44    /// The election delay, in seconds after the contract's creation
45    pub const ELECTION_DELAY: &str = "electionDelay";
46    /// How many members a seated team's leader may add after the election
47    pub const MAX_ADDED_MODERATORS: &str = "maxAddedModerators";
48    /// The moderated document types, each with the abilities the seated team holds on it
49    pub const MODERATED_DOCUMENT_TYPES: &str = "moderatedDocumentTypes";
50    /// The interim moderators
51    pub const INTERIM: &str = "interim";
52    /// Whether the owner is protected from the team
53    pub const OWNER_PROTECTED: &str = "ownerProtected";
54    /// The `$type` of the variant and of the interim
55    pub const TYPE: &str = "$type";
56    /// The identities of an appointed interim set
57    pub const IDENTITIES: &str = "identities";
58}
59
60/// What a moderation team may do to a contract's users and content. The contract declares
61/// which of these a seated team holds on each moderated document type. A team holds every
62/// ability the declaration gives it; its charter narrows what it may act on only through the
63/// moderation reasons it lists, since every action names one.
64///
65/// Append-only: the discriminant is stored in every declaration.
66#[derive(
67    Debug,
68    Clone,
69    Copy,
70    PartialEq,
71    Eq,
72    PartialOrd,
73    Ord,
74    Hash,
75    Encode,
76    Decode,
77    DecodeUntrusted,
78    Serialize,
79    Deserialize,
80)]
81#[serde(rename_all = "camelCase")]
82pub enum ModerationAbility {
83    /// Delete documents of the type, within its `moderatorAbilities.deleteWithin` window, and
84    /// restore them. Needs the type to set `moderatorAbilities.delete`.
85    DeleteDocuments,
86    /// Put identities on the banlist and take them off it, over their documents of the
87    /// type. Needs the banlist.
88    Ban,
89    /// Put identities on the suspension list and take them off it, over their documents of
90    /// the type. Needs the suspension list.
91    Suspend,
92    /// Warn identities and clear their warnings, over their documents of the type. Needs
93    /// the warning list.
94    Warn,
95    /// Write the fields only moderators write on documents of the type, with a moderation
96    /// transition or in the team members' own creates and replaces. Needs the type to list
97    /// them under `moderatorAbilities.changeFields`.
98    ChangeDocumentFields,
99}
100
101impl ModerationAbility {
102    /// The wire name of the ability
103    pub fn as_str(&self) -> &'static str {
104        match self {
105            ModerationAbility::DeleteDocuments => "deleteDocuments",
106            ModerationAbility::Ban => "ban",
107            ModerationAbility::Suspend => "suspend",
108            ModerationAbility::Warn => "warn",
109            ModerationAbility::ChangeDocumentFields => "changeDocumentFields",
110        }
111    }
112}
113
114impl fmt::Display for ModerationAbility {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        f.write_str(self.as_str())
117    }
118}
119
120/// Who moderates an elected contract until its first team is seated.
121///
122/// The first two are the merged kinds of [`ContractModerators`](super::ContractModerators),
123/// with the same authority: the owner alone, or the owner and a fixed set. The other two
124/// name nobody, so nothing is moderated in the meantime and nobody claims the moderators
125/// pot: under `NotYetUsable` the moderated document types can not be used until a team is
126/// seated (their transitions are refused), and a contract that never attracts a team keeps
127/// them unusable for good; under `NoModeration` they are used unmoderated until then.
128#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, DecodeUntrusted)]
129pub enum InterimModerators {
130    /// Only the contract owner moderates until a team is seated.
131    ContractOwner,
132    /// The identities the contract appoints moderate until a team is seated, beside its
133    /// owner, who always may. Non-empty, at most `SystemLimits::max_contract_moderators`,
134    /// each an identity that exists; a named owner counts toward the limit.
135    AppointedModerators(BTreeSet<Identifier>),
136    /// Nobody moderates until a team is seated, and the moderated document types can not be
137    /// used until then.
138    NotYetUsable,
139    /// Nobody moderates until a team is seated, and the moderated document types are used
140    /// unmoderated until then.
141    NoModeration,
142}
143
144impl InterimModerators {
145    /// The identities the interim names, `None` unless a set is appointed.
146    pub fn identity_ids(&self) -> Option<&BTreeSet<Identifier>> {
147        match self {
148            InterimModerators::AppointedModerators(ids) => Some(ids),
149            InterimModerators::ContractOwner
150            | InterimModerators::NotYetUsable
151            | InterimModerators::NoModeration => None,
152        }
153    }
154
155    /// Whether `identity_id` may moderate a contract owned by `owner_id` during the interim.
156    pub fn may_moderate(&self, owner_id: &Identifier, identity_id: &Identifier) -> bool {
157        match self {
158            InterimModerators::ContractOwner => owner_id == identity_id,
159            InterimModerators::AppointedModerators(ids) => {
160                owner_id == identity_id || ids.contains(identity_id)
161            }
162            InterimModerators::NotYetUsable | InterimModerators::NoModeration => false,
163        }
164    }
165
166    /// The interim team of a contract owned by `owner_id`: who shares its moderators fee pot
167    /// until a team is seated. Nobody under [`InterimModerators::NotYetUsable`] or
168    /// [`InterimModerators::NoModeration`]: the pot accumulates for the team that gets seated.
169    pub fn team(&self, owner_id: &Identifier) -> BTreeSet<Identifier> {
170        match self {
171            InterimModerators::ContractOwner => BTreeSet::from([*owner_id]),
172            InterimModerators::AppointedModerators(ids) => ids.clone(),
173            InterimModerators::NotYetUsable | InterimModerators::NoModeration => BTreeSet::new(),
174        }
175    }
176
177    /// Whether the moderated document types are unusable during the interim.
178    pub fn blocks_moderated_document_types(&self) -> bool {
179        matches!(self, InterimModerators::NotYetUsable)
180    }
181
182    /// The wire name of the kind
183    fn type_name(&self) -> &'static str {
184        match self {
185            InterimModerators::ContractOwner => "contractOwner",
186            InterimModerators::AppointedModerators(_) => "appointedModerators",
187            InterimModerators::NotYetUsable => "notYetUsable",
188            InterimModerators::NoModeration => "noModeration",
189        }
190    }
191}
192
193// The wire shape is the moderators' own: a flat `{"$type": "contractOwner"}`,
194// `{"$type": "appointedModerators", "identities": [...]}`, `{"$type": "notYetUsable"}` or
195// `{"$type": "noModeration"}` map.
196impl Serialize for InterimModerators {
197    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
198        use serde::ser::SerializeMap;
199        let identities = self.identity_ids();
200        let mut m = serializer.serialize_map(Some(1 + usize::from(identities.is_some())))?;
201        m.serialize_entry(property_names::TYPE, self.type_name())?;
202        if let Some(ids) = identities {
203            m.serialize_entry(property_names::IDENTITIES, ids)?;
204        }
205        m.end()
206    }
207}
208
209impl<'de> Deserialize<'de> for InterimModerators {
210    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
211        use serde::de::{self, MapAccess, Visitor};
212
213        struct V;
214
215        impl<'de> Visitor<'de> for V {
216            type Value = InterimModerators;
217
218            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
219                f.write_str(
220                    "InterimModerators as a map with a `$type` discriminator, \
221                     e.g. {\"$type\": \"contractOwner\"}, \
222                     {\"$type\": \"appointedModerators\", \"identities\": [\"<base58>\"]}, \
223                     {\"$type\": \"notYetUsable\"} or {\"$type\": \"noModeration\"}",
224                )
225            }
226
227            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
228                let mut variant: Option<String> = None;
229                let mut identities: Option<BTreeSet<Identifier>> = None;
230
231                while let Some(key) = map.next_key::<String>()? {
232                    match key.as_str() {
233                        property_names::TYPE => {
234                            if variant.is_some() {
235                                return Err(de::Error::duplicate_field(property_names::TYPE));
236                            }
237                            variant = Some(map.next_value()?);
238                        }
239                        property_names::IDENTITIES => {
240                            if identities.is_some() {
241                                return Err(de::Error::duplicate_field(property_names::IDENTITIES));
242                            }
243                            identities = Some(map.next_value()?);
244                        }
245                        // Refused rather than skipped: the declaration is frozen, so a
246                        // misspelled key must not pass.
247                        other => {
248                            return Err(de::Error::unknown_field(
249                                other,
250                                &[property_names::TYPE, property_names::IDENTITIES],
251                            ));
252                        }
253                    }
254                }
255
256                let variant =
257                    variant.ok_or_else(|| de::Error::missing_field(property_names::TYPE))?;
258                let without_identities = |kind: InterimModerators| {
259                    if identities.is_some() {
260                        return Err(de::Error::custom(
261                            "`identities` is only valid for `appointedModerators`",
262                        ));
263                    }
264                    Ok(kind)
265                };
266                match variant.as_str() {
267                    "contractOwner" => without_identities(InterimModerators::ContractOwner),
268                    "notYetUsable" => without_identities(InterimModerators::NotYetUsable),
269                    "noModeration" => without_identities(InterimModerators::NoModeration),
270                    "appointedModerators" => {
271                        let ids = identities
272                            .ok_or_else(|| de::Error::missing_field(property_names::IDENTITIES))?;
273                        Ok(InterimModerators::AppointedModerators(ids))
274                    }
275                    other => Err(de::Error::unknown_variant(
276                        other,
277                        &[
278                            "contractOwner",
279                            "appointedModerators",
280                            "notYetUsable",
281                            "noModeration",
282                        ],
283                    )),
284                }
285            }
286        }
287
288        deserializer.deserialize_map(V)
289    }
290}
291
292impl fmt::Display for InterimModerators {
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        match self {
295            InterimModerators::ContractOwner => write!(f, "the contract owner"),
296            InterimModerators::AppointedModerators(ids) => {
297                write!(
298                    f,
299                    "the contract owner and {} appointed moderators",
300                    ids.len()
301                )
302            }
303            InterimModerators::NotYetUsable => {
304                write!(f, "nobody, its moderated document types not yet usable")
305            }
306            InterimModerators::NoModeration => {
307                write!(
308                    f,
309                    "nobody, its moderated document types unmoderated meanwhile"
310                )
311            }
312        }
313    }
314}
315
316/// The declaration that a contract's moderators are an elected team. Every field is fixed
317/// at the contract's creation.
318#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, DecodeUntrusted)]
319pub struct ElectedModerators {
320    /// How long, in seconds, applicants may join an election once the first one applied.
321    /// At most `SystemLimits::max_contract_moderation_election_window_seconds` (four weeks),
322    /// and on mainnet at least
323    /// `SystemLimits::min_mainnet_contract_moderation_election_window_seconds` (one day);
324    /// any other network takes 0. [`DEFAULT_ELECTION_WINDOW_SECONDS`] when the declaration
325    /// leaves it out.
326    pub join_window: u32,
327    /// How long, in seconds, masternodes vote once the join window closed. The same bounds
328    /// and default.
329    pub vote_window: u32,
330    /// Whether the seat can be contested again once a team is seated, and if so how long,
331    /// in seconds, a seated team is safe from a challenge after a seat change.
332    ///
333    /// `Some` when the seat is contestable (`seatContestable: true` on the wire, with the
334    /// cool-down as `challengeCoolDown`), within
335    /// `SystemLimits::min_contract_moderation_challenge_cool_down_seconds` to
336    /// `SystemLimits::max_contract_moderation_challenge_cool_down_seconds` (two weeks to
337    /// three years). `None` when it is not (`seatContestable: false`, no `challengeCoolDown`):
338    /// the first team seated keeps the seat for good, whatever becomes of its leader. One
339    /// field rather than a flag beside a cool-down, so the two can not disagree.
340    ///
341    /// Nothing reads it yet: challenges come after protocol version 14, and until they do a
342    /// seat is never contested again, whatever this says. The key exists now because the
343    /// declaration is frozen at the contract's creation.
344    pub challenge_cool_down: Option<u32>,
345    /// How long, in seconds after the contract's creation, before the first charter may be
346    /// filed against the contract: the notice the contract gives before its first election
347    /// can be called. Unbounded, and `None` when the declaration leaves it out, in which
348    /// case the election may be called at once. A reference declaring
349    /// `contractRequirements: { "moderation": "electionOpen" }` is what reads it.
350    pub election_delay: Option<u32>,
351    /// How many members the leader of a seated team may add after the election, each one
352    /// an identity that asked to join the team's proposal: the additions a seated charter
353    /// holds at a time, the leader taking one back by deleting it. 0 when the declaration
354    /// leaves it out, a team then being exactly what was elected; at most
355    /// `SystemLimits::max_contract_moderation_added_moderators`. The moderation charters
356    /// contract's `addedModerator` documents are what it counts.
357    pub max_added_moderators: u16,
358    /// The document types the team moderates, each with the abilities the seated team holds
359    /// on it: non-empty, each type a document type of the contract, each ability set
360    /// non-empty and backed by the contract (`Ban`, `Suspend` and `Warn` by the list the
361    /// contract keeps, `DeleteDocuments` by the type setting `moderatorAbilities.delete`,
362    /// `ChangeDocumentFields` by the type listing `moderatorAbilities.changeFields`). The
363    /// lists themselves stay contract-wide: an ability on
364    /// a type is what a team may do over the documents of that type. The set also bounds
365    /// the interim block. A charter does not price the moderators part of an action: the
366    /// type's `actionFees.moderators` amount is the most a team may charge, and a charter
367    /// charges a share of it.
368    pub moderated_document_types: BTreeMap<DocumentName, BTreeSet<ModerationAbility>>,
369    /// Who moderates until the first team is seated.
370    pub interim: InterimModerators,
371    /// Whether the contract owner is protected from the team, as the owner and the
372    /// moderators of the merged kinds are: it can then be neither banned nor suspended, and
373    /// its documents can not be deleted. Not protected by default. During the interim the
374    /// owner is protected whenever it moderates, flag or not.
375    pub owner_protected: bool,
376}
377
378impl ElectedModerators {
379    /// Whether the seat can be contested again once a team is seated: the declaration's
380    /// `seatContestable`. A challenge will be allowed only on a contract that says so.
381    pub fn seat_contestable(&self) -> bool {
382        self.challenge_cool_down.is_some()
383    }
384
385    /// Whether the first election may be called at `block_time_ms` on a contract created at
386    /// `contract_created_at`: the declaration has no election delay, or the delay has passed
387    /// since the creation. An elected declaration is made at the contract's creation and
388    /// never changes, so the creation is the declaration's own time. A contract without a
389    /// recorded creation time and with a delay is of unknown age, and its election is not
390    /// open.
391    pub fn election_is_open(
392        &self,
393        contract_created_at: Option<TimestampMillis>,
394        block_time_ms: TimestampMillis,
395    ) -> bool {
396        match self.election_delay {
397            None => true,
398            Some(delay) => contract_created_at.is_some_and(|created_at| {
399                block_time_ms
400                    >= created_at.saturating_add(TimestampMillis::from(delay).saturating_mul(1000))
401            }),
402        }
403    }
404
405    /// Whether the team moderates the document type
406    pub fn moderates_document_type(&self, document_type_name: &str) -> bool {
407        self.moderated_document_types
408            .contains_key(document_type_name)
409    }
410
411    /// Whether the seated team holds the ability on the document type
412    pub fn allows(&self, document_type_name: &str, ability: ModerationAbility) -> bool {
413        self.moderated_document_types
414            .get(document_type_name)
415            .is_some_and(|abilities| abilities.contains(&ability))
416    }
417
418    /// Whether the seated team holds the ability on some moderated document type. The lists
419    /// are contract-wide, so this is what lets the team ban, suspend or warn (and lift each):
420    /// an ability on a type is what the team may do over the documents of that type, and an
421    /// identity is barred from the whole contract.
422    pub fn allows_on_any_type(&self, ability: ModerationAbility) -> bool {
423        self.moderated_document_types
424            .values()
425            .any(|abilities| abilities.contains(&ability))
426    }
427
428    /// Whether the interim refuses every document transition of the document type: the
429    /// interim names nobody and the type is moderated. This is the declaration's side only:
430    /// the block ends once a charter is seated on the contract, which only state says, so the
431    /// document gate reads whether one is before it refuses.
432    pub fn interim_blocks_document_type(&self, document_type_name: &str) -> bool {
433        self.interim.blocks_moderated_document_types()
434            && self.moderates_document_type(document_type_name)
435    }
436
437    /// The pure-data rules of the declaration beyond those every moderator kind shares (a
438    /// named set non-empty and within the limit, checked on the interim set by the caller):
439    /// the windows, and the cool-down of a contestable seat, within the limits, the moderated
440    /// set non-empty, each of its types a document type of the contract with a non-empty
441    /// ability set the contract backs. The first rule broken is the reason returned.
442    ///
443    /// The windows have a floor on mainnet only: any other network takes a window of 0, so
444    /// an election there can be run through in a block or two.
445    pub(super) fn validation_error(
446        &self,
447        config: &ContractModerationConfig,
448        document_schemas: &BTreeMap<DocumentName, Value>,
449        network: Network,
450        platform_version: &PlatformVersion,
451    ) -> Option<String> {
452        let limits = &platform_version.system_limits;
453        let within = |what: &str, seconds: u32, min: u32, max: u32| {
454            (seconds < min || seconds > max).then(|| {
455                format!("the {what} of {seconds} seconds is outside {min} to {max} seconds")
456            })
457        };
458        let window_min = match network {
459            Network::Mainnet => limits.min_mainnet_contract_moderation_election_window_seconds,
460            _ => 0,
461        };
462        let window_max = limits.max_contract_moderation_election_window_seconds;
463        if let Some(reason) = within("join window", self.join_window, window_min, window_max)
464            .or_else(|| within("vote window", self.vote_window, window_min, window_max))
465            .or_else(|| {
466                // A seat that can not be contested has no cool-down to bound
467                self.challenge_cool_down.and_then(|cool_down| {
468                    within(
469                        "challenge cool-down",
470                        cool_down,
471                        limits.min_contract_moderation_challenge_cool_down_seconds,
472                        limits.max_contract_moderation_challenge_cool_down_seconds,
473                    )
474                })
475            })
476        {
477            return Some(reason);
478        }
479        let max_added = limits.max_contract_moderation_added_moderators;
480        if self.max_added_moderators > max_added {
481            return Some(format!(
482                "the {} members a leader may add exceed the limit of {max_added}",
483                self.max_added_moderators
484            ));
485        }
486
487        if self.moderated_document_types.is_empty() {
488            return Some("the moderated document type set is empty".to_string());
489        }
490        for (document_type_name, abilities) in &self.moderated_document_types {
491            let Some(schema) = document_schemas.get(document_type_name) else {
492                return Some(format!(
493                    "the moderated document type \"{document_type_name}\" is not a document \
494                     type of the contract"
495                ));
496            };
497            if abilities.is_empty() {
498                return Some(format!(
499                    "the ability set of the moderated document type \"{document_type_name}\" \
500                     is empty"
501                ));
502            }
503            for ability in abilities {
504                let unbacked = match ability {
505                    ModerationAbility::Ban if !config.banlist => {
506                        Some("bans, but the contract keeps no banlist")
507                    }
508                    ModerationAbility::Suspend if !config.suspensions => {
509                        Some("suspensions, but the contract keeps no suspension list")
510                    }
511                    ModerationAbility::Warn if !config.warnings => {
512                        Some("warnings, but the contract keeps no warning list")
513                    }
514                    ModerationAbility::DeleteDocuments
515                        if !document_schema_lets_moderators_delete(schema) =>
516                    {
517                        Some("document deletions, but the type can not be deleted by moderators")
518                    }
519                    ModerationAbility::ChangeDocumentFields
520                        if !document_schema_lets_moderators_change_fields(schema) =>
521                    {
522                        Some(
523                            "document field changes, but the type lists no field moderators \
524                             write",
525                        )
526                    }
527                    _ => None,
528                };
529                if let Some(unbacked) = unbacked {
530                    return Some(format!(
531                        "the moderated document type \"{document_type_name}\" allows {unbacked}"
532                    ));
533                }
534            }
535        }
536
537        // Once a team is seated only it writes the fields a type keeps for moderators, so a
538        // type keeping any must give the team the ability: without it nobody could ever write
539        // them again, and the declaration can not change.
540        if let Some(document_type_name) = document_schemas
541            .iter()
542            .filter(|(_, schema)| document_schema_lets_moderators_change_fields(schema))
543            .map(|(name, _)| name)
544            .find(|name| !self.allows(name, ModerationAbility::ChangeDocumentFields))
545        {
546            return Some(format!(
547                "the document type \"{document_type_name}\" keeps fields only moderators write, \
548                 but the moderated set does not give the team `changeDocumentFields` on it, so \
549                 nobody could write them once a team is seated"
550            ));
551        }
552
553        // Only a seated team deletes a settled document, so a type that says who of the team
554        // must approve such a deletion must give the team the deletion, or the rule could never
555        // be used.
556        if let Some(document_type_name) = document_schemas
557            .iter()
558            .filter(|(_, schema)| document_schema_lets_moderators_delete_settled(schema))
559            .map(|(name, _)| name)
560            .find(|name| !self.allows(name, ModerationAbility::DeleteDocuments))
561        {
562            return Some(format!(
563                "the document type \"{document_type_name}\" says who of the team must approve the \
564                 deletion of a settled document, but the moderated set does not give the team \
565                 `deleteDocuments` on it, so no team could ever delete one"
566            ));
567        }
568
569        None
570    }
571}
572
573impl fmt::Display for ElectedModerators {
574    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
575        write!(
576            f,
577            "an elected moderation team, in its interim moderated by {}",
578            self.interim
579        )?;
580        match self.challenge_cool_down {
581            Some(cool_down) => write!(
582                f,
583                ", its seat open to a challenge {cool_down} seconds after each seat change"
584            )?,
585            None => write!(f, ", its seat never contested once a team is seated")?,
586        }
587        if let Some(delay) = self.election_delay {
588            write!(
589                f,
590                ", its first election open {delay} seconds after the contract's creation"
591            )?;
592        }
593        if self.max_added_moderators > 0 {
594            write!(
595                f,
596                ", its leader free to add {} members after the election",
597                self.max_added_moderators
598            )?;
599        }
600        Ok(())
601    }
602}
603
604#[cfg(feature = "json-conversion")]
605impl JsonSafeFields for ModerationAbility {}
606#[cfg(feature = "json-conversion")]
607impl JsonSafeFields for InterimModerators {}
608#[cfg(feature = "json-conversion")]
609impl JsonSafeFields for ElectedModerators {}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use crate::consensus::ConsensusError;
615    use crate::data_contract::config::moderation::ContractModerators;
616    use platform_value::platform_value;
617
618    fn set(ids: &[u8]) -> BTreeSet<Identifier> {
619        ids.iter().map(|b| Identifier::from([*b; 32])).collect()
620    }
621
622    /// `post`, which moderators may delete, and `like`, which they may not
623    fn schemas() -> BTreeMap<DocumentName, Value> {
624        BTreeMap::from([
625            (
626                "post".to_string(),
627                platform_value!({ "type": "object", "moderatorAbilities": { "delete": true } }),
628            ),
629            ("like".to_string(), platform_value!({ "type": "object" })),
630        ])
631    }
632
633    /// [`schemas`] and `report`, whose `status` only moderators write
634    fn schemas_with_a_report() -> BTreeMap<DocumentName, Value> {
635        let mut schemas = schemas();
636        schemas.insert(
637            "report".to_string(),
638            platform_value!({
639                "type": "object",
640                "moderatorAbilities": { "changeFields": ["status"] },
641            }),
642        );
643        schemas
644    }
645
646    /// A declaration within every bound: both lists, bans and suspensions allowed, `post`
647    /// moderated, the owner in the interim, the seat contestable two weeks after a change.
648    fn elected() -> ElectedModerators {
649        ElectedModerators {
650            join_window: DEFAULT_ELECTION_WINDOW_SECONDS,
651            vote_window: DEFAULT_ELECTION_WINDOW_SECONDS,
652            challenge_cool_down: Some(1_209_600),
653            moderated_document_types: BTreeMap::from([(
654                "post".to_string(),
655                moderated(&[ModerationAbility::Ban, ModerationAbility::Suspend]),
656            )]),
657            interim: InterimModerators::ContractOwner,
658            election_delay: None,
659            max_added_moderators: 0,
660            owner_protected: false,
661        }
662    }
663
664    /// The ability set of a moderated type
665    fn moderated(abilities: &[ModerationAbility]) -> BTreeSet<ModerationAbility> {
666        abilities.iter().copied().collect()
667    }
668
669    /// The ability set of a moderated type, to edit
670    fn abilities_of<'a>(
671        declaration: &'a mut ElectedModerators,
672        name: &str,
673    ) -> &'a mut BTreeSet<ModerationAbility> {
674        declaration
675            .moderated_document_types
676            .get_mut(name)
677            .expect("the type is moderated")
678    }
679
680    fn config(elected: ElectedModerators) -> ContractModerationConfig {
681        ContractModerationConfig {
682            banlist: true,
683            suspensions: true,
684            warnings: false,
685            moderators: ContractModerators::Elected(Box::new(elected)),
686        }
687    }
688
689    /// The reason the declaration is refused for on mainnet, whose bounds are the strictest,
690    /// `None` when it is accepted
691    fn refusal(config: &ContractModerationConfig) -> Option<String> {
692        refusal_on(Network::Mainnet, config)
693    }
694
695    /// The reason the declaration is refused for on `network`, `None` when it is accepted
696    fn refusal_on(network: Network, config: &ContractModerationConfig) -> Option<String> {
697        let result = config
698            .validate(&schemas(), network, PlatformVersion::latest())
699            .expect("validate");
700        (!result.is_valid()).then(|| rendered(&result.errors))
701    }
702
703    /// The errors' messages, one per line; `Debug` would escape the quotes the messages
704    /// put around document type names.
705    fn rendered(errors: &[ConsensusError]) -> String {
706        errors
707            .iter()
708            .map(ToString::to_string)
709            .collect::<Vec<_>>()
710            .join("\n")
711    }
712
713    #[test]
714    fn should_accept_a_declaration_within_every_bound() {
715        assert_eq!(refusal(&config(elected())), None);
716    }
717
718    #[test]
719    fn should_accept_every_bound_and_refuse_one_second_outside_each() {
720        let limits = &PlatformVersion::latest().system_limits;
721        let window_min = limits.min_mainnet_contract_moderation_election_window_seconds;
722        let window_max = limits.max_contract_moderation_election_window_seconds;
723        let cool_down_min = limits.min_contract_moderation_challenge_cool_down_seconds;
724        let cool_down_max = limits.max_contract_moderation_challenge_cool_down_seconds;
725        assert_eq!(window_min, 86_400);
726        assert_eq!(window_max, 2_419_200);
727        assert_eq!(cool_down_min, 1_209_600);
728        assert_eq!(cool_down_max, 94_608_000);
729
730        let with = |f: fn(&mut ElectedModerators, u32), seconds: u32| {
731            let mut declaration = elected();
732            f(&mut declaration, seconds);
733            config(declaration)
734        };
735        let join = |d: &mut ElectedModerators, s: u32| d.join_window = s;
736        let vote = |d: &mut ElectedModerators, s: u32| d.vote_window = s;
737        let cool_down = |d: &mut ElectedModerators, s: u32| d.challenge_cool_down = Some(s);
738        for (name, field, min, max) in [
739            (
740                "join window",
741                join as fn(&mut ElectedModerators, u32),
742                window_min,
743                window_max,
744            ),
745            ("vote window", vote, window_min, window_max),
746            (
747                "challenge cool-down",
748                cool_down,
749                cool_down_min,
750                cool_down_max,
751            ),
752        ] {
753            assert_eq!(refusal(&with(field, min)), None, "{name} at its minimum");
754            assert_eq!(refusal(&with(field, max)), None, "{name} at its maximum");
755            let below = refusal(&with(field, min - 1)).expect("refused below the minimum");
756            assert!(below.contains(name), "{below}");
757            let above = refusal(&with(field, max + 1)).expect("refused above the maximum");
758            assert!(above.contains(name), "{above}");
759        }
760    }
761
762    /// The windows have a floor on mainnet only, one day: every other network takes 0, and
763    /// its elections resolve in a block or two. The four-week ceiling holds everywhere.
764    #[test]
765    fn should_floor_the_windows_on_mainnet_only() {
766        let with_windows = |seconds: u32| {
767            let mut declaration = elected();
768            declaration.join_window = seconds;
769            declaration.vote_window = seconds;
770            config(declaration)
771        };
772
773        let on_mainnet = refusal_on(Network::Mainnet, &with_windows(0)).expect("refused");
774        assert!(
775            on_mainnet.contains("the join window of 0 seconds is outside 86400 to 2419200 seconds"),
776            "{on_mainnet}"
777        );
778        assert_eq!(refusal_on(Network::Mainnet, &with_windows(86_400)), None);
779
780        for network in [Network::Testnet, Network::Devnet, Network::Regtest] {
781            assert_eq!(
782                refusal_on(network, &with_windows(0)),
783                None,
784                "0 on {network:?}"
785            );
786            let above = refusal_on(network, &with_windows(2_419_201)).expect("refused");
787            assert!(
788                above.contains("the join window of 2419201 seconds is outside 0 to 2419200"),
789                "{above}"
790            );
791        }
792    }
793
794    #[test]
795    fn should_bound_the_cool_down_of_a_contestable_seat_only() {
796        let mut permanent = elected();
797        permanent.challenge_cool_down = None;
798        assert!(!permanent.seat_contestable());
799        assert_eq!(refusal(&config(permanent)), None);
800
801        let contestable = elected();
802        assert!(contestable.seat_contestable());
803        assert_eq!(refusal(&config(contestable)), None);
804    }
805
806    #[test]
807    fn should_require_a_moderated_set_of_document_types_the_contract_has() {
808        let mut empty = elected();
809        empty.moderated_document_types.clear();
810        assert!(refusal(&config(empty))
811            .expect("refused")
812            .contains("moderated document type set is empty"));
813
814        let mut unknown = elected();
815        unknown
816            .moderated_document_types
817            .insert("comment".to_string(), moderated(&[ModerationAbility::Ban]));
818        assert!(refusal(&config(unknown))
819            .expect("refused")
820            .contains("\"comment\" is not a document type"));
821
822        // A moderated type need not be deletable by moderators.
823        let mut not_deletable = elected();
824        not_deletable.moderated_document_types =
825            BTreeMap::from([("like".to_string(), moderated(&[ModerationAbility::Ban]))]);
826        assert_eq!(refusal(&config(not_deletable)), None);
827    }
828
829    #[test]
830    fn should_require_a_non_empty_envelope_the_contract_can_back() {
831        let mut empty = elected();
832        abilities_of(&mut empty, "post").clear();
833        assert!(refusal(&config(empty))
834            .expect("refused")
835            .contains("ability set of the moderated document type \"post\" is empty"));
836
837        let mut bans_without_banlist = config(elected());
838        bans_without_banlist.banlist = false;
839        bans_without_banlist.suspensions = true;
840        assert!(refusal(&bans_without_banlist)
841            .expect("refused")
842            .contains("keeps no banlist"));
843
844        let mut suspensions_without_list = config(elected());
845        suspensions_without_list.suspensions = false;
846        assert!(refusal(&suspensions_without_list)
847            .expect("refused")
848            .contains("keeps no suspension list"));
849
850        let mut warnings = elected();
851        abilities_of(&mut warnings, "post").insert(ModerationAbility::Warn);
852        let mut warnings_without_list = config(warnings);
853        assert!(refusal(&warnings_without_list)
854            .expect("refused")
855            .contains("keeps no warning list"));
856        warnings_without_list.warnings = true;
857        assert_eq!(refusal(&warnings_without_list), None);
858
859        // Deletions on `post` are backed by its flag; on `like` they are not.
860        let mut deletions = elected();
861        *abilities_of(&mut deletions, "post") =
862            BTreeSet::from([ModerationAbility::DeleteDocuments]);
863        assert_eq!(refusal(&config(deletions)), None);
864        let mut deletions_on_like = elected();
865        deletions_on_like.moderated_document_types.insert(
866            "like".to_string(),
867            moderated(&[ModerationAbility::DeleteDocuments]),
868        );
869        assert!(refusal(&config(deletions_on_like))
870            .expect("refused")
871            .contains("\"like\" allows document deletions, but the type can not be deleted"));
872
873        // Field changes on `report` are backed by the fields it keeps for moderators; on `post`,
874        // which keeps none, they are not.
875        let with_a_report = |elected: ElectedModerators| {
876            let result = config(elected)
877                .validate(
878                    &schemas_with_a_report(),
879                    Network::Mainnet,
880                    PlatformVersion::latest(),
881                )
882                .expect("validate");
883            (!result.is_valid()).then(|| rendered(&result.errors))
884        };
885        let mut changes = elected();
886        changes.moderated_document_types.insert(
887            "report".to_string(),
888            moderated(&[ModerationAbility::ChangeDocumentFields]),
889        );
890        assert_eq!(with_a_report(changes.clone()), None);
891        abilities_of(&mut changes, "post").insert(ModerationAbility::ChangeDocumentFields);
892        assert!(with_a_report(changes)
893            .expect("refused")
894            .contains("\"post\" allows document field changes, but the type lists no field"));
895
896        // A type keeping such fields must be moderated with the ability, or nobody could write
897        // them once a team is seated.
898        for without in [elected(), {
899            let mut deletions_only = elected();
900            deletions_only
901                .moderated_document_types
902                .insert("report".to_string(), moderated(&[ModerationAbility::Ban]));
903            deletions_only
904        }] {
905            assert!(with_a_report(without)
906                .expect("refused")
907                .contains("\"report\" keeps fields only moderators write"));
908        }
909    }
910
911    #[test]
912    fn should_require_deletions_on_a_type_that_says_who_approves_a_settled_deletion() {
913        let schemas = BTreeMap::from([(
914            "post".to_string(),
915            platform_value!({
916                "type": "object",
917                "moderatorAbilities": {
918                    "delete": true,
919                    "deleteWithin": 86400,
920                    "deleteSettled": { "leader": true },
921                },
922            }),
923        )]);
924        let refusal = |elected: ElectedModerators| {
925            let result = config(elected)
926                .validate(&schemas, Network::Mainnet, PlatformVersion::latest())
927                .expect("validate");
928            (!result.is_valid()).then(|| rendered(&result.errors))
929        };
930        // Only a seated team deletes a settled document: without the ability, nobody could.
931        assert!(refusal(elected())
932            .expect("refused")
933            .contains("\"post\" says who of the team must approve"));
934        let mut deletions = elected();
935        abilities_of(&mut deletions, "post").insert(ModerationAbility::DeleteDocuments);
936        assert_eq!(refusal(deletions), None);
937    }
938
939    #[test]
940    fn should_bound_the_interim_set_like_an_appointed_set() {
941        let max = PlatformVersion::latest()
942            .system_limits
943            .max_contract_moderators as u8;
944        let with = |ids: &[u8]| {
945            let mut declaration = elected();
946            declaration.interim = InterimModerators::AppointedModerators(set(ids));
947            config(declaration)
948        };
949        assert!(refusal(&with(&[])).expect("refused").contains("empty"));
950        assert_eq!(refusal(&with(&(1..=max).collect::<Vec<u8>>())), None);
951        assert!(refusal(&with(&(1..=max + 1).collect::<Vec<u8>>()))
952            .expect("refused")
953            .contains("at most"));
954    }
955
956    #[test]
957    fn should_give_each_interim_kind_its_authority_team_and_block() {
958        let owner = Identifier::from([9; 32]);
959        let moderator = Identifier::from([1; 32]);
960        let user = Identifier::from([2; 32]);
961        let with = |interim: InterimModerators, owner_protected: bool| {
962            let mut declaration = elected();
963            declaration.interim = interim;
964            declaration.owner_protected = owner_protected;
965            ContractModerators::Elected(Box::new(declaration))
966        };
967
968        let owner_alone = with(InterimModerators::ContractOwner, false);
969        assert!(owner_alone.may_moderate(&owner, &owner));
970        assert!(!owner_alone.may_moderate(&owner, &moderator));
971        assert_eq!(owner_alone.team(&owner), BTreeSet::from([owner]));
972        assert_eq!(owner_alone.identity_ids(), None);
973        assert!(owner_alone.protects(&owner, &owner));
974        assert!(!owner_alone.protects(&owner, &user));
975        assert!(!owner_alone.interim_blocks_document_type("post"));
976
977        let appointed = with(InterimModerators::AppointedModerators(set(&[1])), false);
978        assert!(appointed.may_moderate(&owner, &owner));
979        assert!(appointed.may_moderate(&owner, &moderator));
980        assert!(!appointed.may_moderate(&owner, &user));
981        assert_eq!(appointed.team(&owner), set(&[1]));
982        assert_eq!(appointed.identity_ids(), Some(&set(&[1])));
983        assert!(appointed.protects(&owner, &moderator));
984
985        let nobody = with(InterimModerators::NotYetUsable, false);
986        assert!(!nobody.may_moderate(&owner, &owner));
987        assert!(nobody.team(&owner).is_empty());
988        assert!(!nobody.protects(&owner, &owner));
989        assert!(nobody.interim_blocks_document_type("post"));
990        assert!(!nobody.interim_blocks_document_type("like"));
991
992        // The flag protects the owner where nothing else does.
993        let protected = with(InterimModerators::NotYetUsable, true);
994        assert!(!protected.may_moderate(&owner, &owner));
995        assert!(protected.protects(&owner, &owner));
996        assert!(!protected.protects(&owner, &user));
997    }
998
999    #[test]
1000    fn should_give_a_seated_team_the_abilities_of_any_moderated_type_on_the_lists_only() {
1001        let mut declaration = elected();
1002        declaration
1003            .moderated_document_types
1004            .insert("like".to_string(), moderated(&[ModerationAbility::Warn]));
1005        declaration.moderated_document_types.insert(
1006            "post".to_string(),
1007            moderated(&[ModerationAbility::Ban, ModerationAbility::DeleteDocuments]),
1008        );
1009
1010        // The lists are contract-wide: an ability on any moderated type lets the team use it.
1011        assert!(declaration.allows_on_any_type(ModerationAbility::Ban));
1012        assert!(declaration.allows_on_any_type(ModerationAbility::Warn));
1013        assert!(!declaration.allows_on_any_type(ModerationAbility::Suspend));
1014        // A deletion is of one type's documents: only where that type carries the ability.
1015        assert!(declaration.allows("post", ModerationAbility::DeleteDocuments));
1016        assert!(!declaration.allows("like", ModerationAbility::DeleteDocuments));
1017        assert!(!declaration.allows("comment", ModerationAbility::Ban));
1018    }
1019
1020    #[test]
1021    fn should_bound_the_members_a_leader_may_add() {
1022        let max = PlatformVersion::latest()
1023            .system_limits
1024            .max_contract_moderation_added_moderators;
1025        assert_eq!(max, 15);
1026        let with = |added: u16| {
1027            let mut declaration = elected();
1028            declaration.max_added_moderators = added;
1029            config(declaration)
1030        };
1031        assert_eq!(refusal(&with(0)), None);
1032        assert_eq!(refusal(&with(max)), None);
1033        let over = refusal(&with(max + 1)).expect("refused over the limit");
1034        assert!(over.contains("members a leader may add"), "{over}");
1035    }
1036
1037    #[test]
1038    fn should_round_trip_through_json_and_platform_value() {
1039        let mut declaration = elected();
1040        declaration.interim = InterimModerators::AppointedModerators(set(&[1, 2]));
1041        declaration.owner_protected = true;
1042        declaration.election_delay = Some(86_400);
1043        declaration.max_added_moderators = 3;
1044        let moderators = ContractModerators::Elected(Box::new(declaration));
1045
1046        let json = serde_json::to_value(&moderators).expect("serialize");
1047        assert_eq!(json["$type"], "elected");
1048        assert_eq!(json["joinWindow"], 604_800);
1049        assert_eq!(json["seatContestable"], true);
1050        assert_eq!(json["challengeCoolDown"], 1_209_600);
1051        assert_eq!(json["electionDelay"], 86_400);
1052        assert_eq!(json["maxAddedModerators"], 3);
1053        assert_eq!(
1054            json["moderatedDocumentTypes"],
1055            serde_json::json!({ "post": ["ban", "suspend"] })
1056        );
1057        assert_eq!(json["interim"]["$type"], "appointedModerators");
1058        assert_eq!(
1059            json["interim"]["identities"].as_array().map(|a| a.len()),
1060            Some(2)
1061        );
1062        assert_eq!(json["ownerProtected"], true);
1063        let back: ContractModerators = serde_json::from_value(json).expect("deserialize");
1064        assert_eq!(back, moderators);
1065
1066        let value = platform_value::to_value(&moderators).expect("to value");
1067        let back: ContractModerators = platform_value::from_value(value).expect("from value");
1068        assert_eq!(back, moderators);
1069
1070        let config = ContractModerationConfig {
1071            banlist: true,
1072            suspensions: true,
1073            warnings: false,
1074            moderators: moderators.clone(),
1075        };
1076        let value = platform_value::to_value(&config).expect("to value");
1077        let back: ContractModerationConfig = platform_value::from_value(value).expect("from value");
1078        assert_eq!(back, config);
1079    }
1080
1081    #[test]
1082    fn should_round_trip_each_value_of_seat_contestable_through_json_and_platform_value() {
1083        for challenge_cool_down in [Some(1_209_600), Some(94_608_000), None] {
1084            let mut declaration = elected();
1085            declaration.challenge_cool_down = challenge_cool_down;
1086            let moderators = ContractModerators::Elected(Box::new(declaration));
1087
1088            let json = serde_json::to_value(&moderators).expect("serialize");
1089            assert_eq!(
1090                json["seatContestable"],
1091                challenge_cool_down.is_some(),
1092                "{json}"
1093            );
1094            assert_eq!(
1095                json.get("challengeCoolDown").and_then(|v| v.as_u64()),
1096                challenge_cool_down.map(u64::from),
1097                "the cool-down is on the wire exactly when the seat is contestable: {json}"
1098            );
1099            let back: ContractModerators = serde_json::from_value(json).expect("from json");
1100            assert_eq!(back, moderators);
1101
1102            let value = platform_value::to_value(&moderators).expect("to value");
1103            assert_eq!(
1104                value
1105                    .get_optional_bool("seatContestable")
1106                    .expect("a bool")
1107                    .expect("always present"),
1108                challenge_cool_down.is_some()
1109            );
1110            let back: ContractModerators = platform_value::from_value(value).expect("from value");
1111            assert_eq!(back, moderators);
1112        }
1113    }
1114
1115    #[test]
1116    fn should_refuse_a_declaration_without_seat_contestable() {
1117        for cool_down in [Some(1_209_600), None] {
1118            let mut json = serde_json::json!({
1119                "$type": "elected",
1120                "moderatedDocumentTypes": { "post": ["ban"] },
1121                "interim": { "$type": "contractOwner" },
1122            });
1123            if let Some(cool_down) = cool_down {
1124                json["challengeCoolDown"] = cool_down.into();
1125            }
1126            let error = serde_json::from_value::<ContractModerators>(json.clone())
1127                .expect_err("refused without the key")
1128                .to_string();
1129            assert!(error.contains("missing field `seatContestable`"), "{error}");
1130
1131            let value = platform_value::to_value(&json).expect("to value");
1132            assert!(
1133                platform_value::from_value::<ContractModerators>(value).is_err(),
1134                "{json}"
1135            );
1136        }
1137    }
1138
1139    #[test]
1140    fn should_require_the_cool_down_of_a_contestable_seat_and_refuse_it_on_a_permanent_one() {
1141        let with = |seat_contestable: bool, cool_down: Option<u32>| {
1142            let mut json = serde_json::json!({
1143                "$type": "elected",
1144                "seatContestable": seat_contestable,
1145                "moderatedDocumentTypes": { "post": ["ban"] },
1146                "interim": { "$type": "contractOwner" },
1147            });
1148            if let Some(cool_down) = cool_down {
1149                json["challengeCoolDown"] = cool_down.into();
1150            }
1151            serde_json::from_value::<ContractModerators>(json).map_err(|e| e.to_string())
1152        };
1153
1154        let contestable = with(true, Some(1_209_600)).expect("accepted");
1155        assert_eq!(
1156            contestable.elected().map(|e| e.challenge_cool_down),
1157            Some(Some(1_209_600))
1158        );
1159        let permanent = with(false, None).expect("accepted");
1160        assert_eq!(
1161            permanent.elected().map(|e| e.challenge_cool_down),
1162            Some(None)
1163        );
1164        assert_eq!(
1165            permanent.elected().map(ElectedModerators::seat_contestable),
1166            Some(false)
1167        );
1168
1169        let missing = with(true, None).expect_err("a contestable seat needs its cool-down");
1170        assert!(
1171            missing.contains("missing field `challengeCoolDown`"),
1172            "{missing}"
1173        );
1174        let stray = with(false, Some(1_209_600)).expect_err("a permanent seat has no cool-down");
1175        assert!(
1176            stray.contains("only valid with `seatContestable: true`"),
1177            "{stray}"
1178        );
1179        // Not even a zero one
1180        assert!(with(false, Some(0)).is_err());
1181    }
1182
1183    #[test]
1184    fn should_default_the_windows_and_the_flag_and_refuse_a_misspelled_key() {
1185        let minimal = serde_json::json!({
1186            "$type": "elected",
1187            "seatContestable": false,
1188            "moderatedDocumentTypes": { "post": ["ban"] },
1189            "interim": { "$type": "notYetUsable" },
1190        });
1191        let parsed: ContractModerators = serde_json::from_value(minimal).expect("deserialize");
1192        let elected = parsed.elected().expect("elected");
1193        assert_eq!(elected.join_window, DEFAULT_ELECTION_WINDOW_SECONDS);
1194        assert_eq!(elected.challenge_cool_down, None);
1195        assert_eq!(elected.election_delay, None);
1196        let json = serde_json::to_value(&parsed).expect("serialize");
1197        assert!(
1198            json.get("electionDelay").is_none(),
1199            "a declaration without a delay serializes none: {json}"
1200        );
1201        assert_eq!(elected.max_added_moderators, 0);
1202        assert!(
1203            json.get("maxAddedModerators").is_none(),
1204            "a declaration letting no member be added serializes none: {json}"
1205        );
1206        assert_eq!(elected.vote_window, DEFAULT_ELECTION_WINDOW_SECONDS);
1207        assert!(!elected.owner_protected);
1208        assert_eq!(elected.interim, InterimModerators::NotYetUsable);
1209        let no_moderation: InterimModerators =
1210            serde_json::from_value(serde_json::json!({ "$type": "noModeration" }))
1211                .expect("deserialize");
1212        assert_eq!(no_moderation, InterimModerators::NoModeration);
1213        assert_eq!(
1214            serde_json::to_value(&no_moderation).expect("serialize"),
1215            serde_json::json!({ "$type": "noModeration" })
1216        );
1217
1218        let refused = [
1219            // The cool-down of a contestable seat has no default.
1220            serde_json::json!({
1221                "$type": "elected",
1222                "seatContestable": true,
1223                "moderatedDocumentTypes": { "post": ["ban"] },
1224                "interim": { "$type": "contractOwner" },
1225            }),
1226            // Nor has whether the seat is contestable.
1227            serde_json::json!({
1228                "$type": "elected",
1229                "challengeCoolDown": 1_209_600,
1230                "moderatedDocumentTypes": { "post": ["ban"] },
1231                "interim": { "$type": "contractOwner" },
1232            }),
1233            // It is a boolean.
1234            serde_json::json!({
1235                "$type": "elected",
1236                "seatContestable": 1,
1237                "challengeCoolDown": 1_209_600,
1238                "moderatedDocumentTypes": { "post": ["ban"] },
1239                "interim": { "$type": "contractOwner" },
1240            }),
1241            // A misspelled key is refused, not dropped.
1242            serde_json::json!({
1243                "$type": "elected",
1244                "seatContestable": true,
1245                "challengeCoolDown": 1_209_600,
1246                "moderatedDocumentTypes": { "post": ["ban"] },
1247                "interim": { "$type": "contractOwner" },
1248                "ownerProtcted": true,
1249            }),
1250            // So is one inside the interim, and an unknown interim kind.
1251            serde_json::json!({
1252                "$type": "elected",
1253                "seatContestable": true,
1254                "challengeCoolDown": 1_209_600,
1255                "moderatedDocumentTypes": { "post": ["ban"] },
1256                "interim": { "$type": "contractOwner", "identity": [] },
1257            }),
1258            serde_json::json!({
1259                "$type": "elected",
1260                "seatContestable": true,
1261                "challengeCoolDown": 1_209_600,
1262                "moderatedDocumentTypes": { "post": ["ban"] },
1263                "interim": { "$type": "seatedTeam" },
1264            }),
1265            // An unknown ability.
1266            serde_json::json!({
1267                "$type": "elected",
1268                "seatContestable": true,
1269                "challengeCoolDown": 1_209_600,
1270                "moderatedDocumentTypes": { "post": ["silence"] },
1271                "interim": { "$type": "contractOwner" },
1272            }),
1273            // A charter does not price actions: fee maximums are no key of the declaration.
1274            serde_json::json!({
1275                "$type": "elected",
1276                "seatContestable": true,
1277                "challengeCoolDown": 1_209_600,
1278                "moderatedDocumentTypes": { "post": ["ban"] },
1279                "moderatorsActionFeeMaximums": { "post": { "create": 1 } },
1280                "interim": { "$type": "contractOwner" },
1281            }),
1282            // The abilities live under each moderated type, not beside them.
1283            serde_json::json!({
1284                "$type": "elected",
1285                "seatContestable": true,
1286                "challengeCoolDown": 1_209_600,
1287                "moderatedDocumentTypes": ["post"],
1288                "abilities": ["ban"],
1289                "interim": { "$type": "contractOwner" },
1290            }),
1291            // The elected keys under another kind, and `identities` under elected.
1292            serde_json::json!({ "$type": "contractOwner", "ownerProtected": true }),
1293            serde_json::json!({ "$type": "appointedModerators", "identities": [], "seatContestable": false }),
1294            serde_json::json!({
1295                "$type": "elected",
1296                "seatContestable": true,
1297                "challengeCoolDown": 1_209_600,
1298                "moderatedDocumentTypes": { "post": ["ban"] },
1299                "interim": { "$type": "contractOwner" },
1300                "identities": [],
1301            }),
1302        ];
1303        for json in refused {
1304            assert!(
1305                serde_json::from_value::<ContractModerators>(json.clone()).is_err(),
1306                "{json}"
1307            );
1308        }
1309    }
1310
1311    #[test]
1312    fn should_open_the_election_after_the_delay_from_the_contract_creation() {
1313        let created_at: TimestampMillis = 1_700_000_000_000;
1314        let mut declaration = elected();
1315
1316        // No delay: open at once, whether or not the creation time is recorded
1317        assert!(declaration.election_is_open(Some(created_at), created_at));
1318        assert!(declaration.election_is_open(None, 0));
1319
1320        declaration.election_delay = Some(3600);
1321        assert!(!declaration.election_is_open(Some(created_at), created_at + 3_599_999));
1322        assert!(declaration.election_is_open(Some(created_at), created_at + 3_600_000));
1323        assert!(declaration.election_is_open(Some(created_at), TimestampMillis::MAX));
1324        // A delay on a contract of unknown age never opens
1325        assert!(!declaration.election_is_open(None, TimestampMillis::MAX));
1326        // The bound saturates rather than wrapping around into the past
1327        declaration.election_delay = Some(u32::MAX);
1328        assert!(
1329            !declaration.election_is_open(Some(TimestampMillis::MAX - 1), TimestampMillis::MAX - 1)
1330        );
1331    }
1332
1333    #[test]
1334    fn should_describe_itself() {
1335        let mut declaration = elected();
1336        assert_eq!(
1337            ContractModerators::Elected(Box::new(declaration.clone())).to_string(),
1338            "an elected moderation team, in its interim moderated by the contract owner, its \
1339             seat open to a challenge 1209600 seconds after each seat change"
1340        );
1341        declaration.challenge_cool_down = None;
1342        assert_eq!(
1343            declaration.to_string(),
1344            "an elected moderation team, in its interim moderated by the contract owner, its \
1345             seat never contested once a team is seated"
1346        );
1347        declaration.election_delay = Some(86_400);
1348        assert_eq!(
1349            declaration.to_string(),
1350            "an elected moderation team, in its interim moderated by the contract owner, its \
1351             seat never contested once a team is seated, its first election open 86400 \
1352             seconds after the contract's creation"
1353        );
1354        declaration.election_delay = None;
1355        declaration.max_added_moderators = 2;
1356        assert_eq!(
1357            declaration.to_string(),
1358            "an elected moderation team, in its interim moderated by the contract owner, its \
1359             seat never contested once a team is seated, its leader free to add 2 members \
1360             after the election"
1361        );
1362        declaration.max_added_moderators = 0;
1363        declaration.interim = InterimModerators::NotYetUsable;
1364        assert_eq!(
1365            declaration.to_string(),
1366            "an elected moderation team, in its interim moderated by nobody, its moderated \
1367             document types not yet usable, its seat never contested once a team is seated"
1368        );
1369        declaration.interim = InterimModerators::NoModeration;
1370        assert_eq!(
1371            declaration.to_string(),
1372            "an elected moderation team, in its interim moderated by nobody, its moderated \
1373             document types unmoderated meanwhile, its seat never contested once a team is \
1374             seated"
1375        );
1376        assert_eq!(
1377            ModerationAbility::DeleteDocuments.to_string(),
1378            "deleteDocuments"
1379        );
1380    }
1381
1382    #[test]
1383    fn should_leave_the_moderated_types_usable_and_unmoderated_under_no_moderation() {
1384        let mut declaration = elected();
1385        declaration.interim = InterimModerators::NoModeration;
1386        let owner = Identifier::from([9u8; 32]);
1387        assert!(!declaration.interim_blocks_document_type("post"));
1388        assert!(!declaration.interim.may_moderate(&owner, &owner));
1389        assert!(declaration.interim.team(&owner).is_empty());
1390        assert_eq!(declaration.interim.identity_ids(), None);
1391        assert!(declaration.allows("post", ModerationAbility::Ban));
1392        assert!(!declaration.allows("post", ModerationAbility::Warn));
1393        assert!(!declaration.allows("like", ModerationAbility::Ban));
1394        assert_eq!(refusal(&config(declaration)), None);
1395    }
1396}