Skip to main content

dpp/moderation_charter/
mod.rs

1//! Moderation charters.
2//!
3//! A data contract may declare that its moderation team is elected by the masternodes
4//! (decentralized moderation teams, protocol version 14). The moderation charters system
5//! contract holds how a team comes to be:
6//!
7//! - a `reason` is a ground for a moderation action, keyed by its owner and a three-letter code;
8//! - a `submittedCharter` is a leader's proposal to moderate one contract on that contract's own
9//!   terms: the reasons its actions may name, the share of the moderators fee it takes and how
10//!   it splits the pay;
11//! - a `joinRequest` is an identity's offer to serve on the team of a proposal, with a message
12//!   only the leader can read;
13//! - an `electedCharter` is a proposal put to the vote with its team, chosen from the identities
14//!   that asked to join it. Creating one opens or joins the contest for the target contract;
15//! - once a charter is seated, its leader may add members from the same join requests, at most
16//!   the target's `maxAddedModerators` at a time (`addedModerator`, taken back by deleting it),
17//!   and remove elected members (`removedModerator`, undone by deleting it); a member asks to
18//!   leave with a `resignationRequest`, which the leader acts on and the member withdraws by
19//!   deleting it.
20//!
21//! The team that acts is the leader plus [`ElectedCharter::active_members`]: the elected
22//! members and the additions, less the removals.
23//!
24//! Seating writes nothing. Awarding the contest for a target writes the winning
25//! `electedCharter` to the contract's storage, the only one ever written there for that target
26//! (contenders live in the contest, and in protocol version 14 a seat is never replaced), so the
27//! charter seated on a contract is the one its `byTargetContract` index finds. The moderation
28//! paths of the target read it from there: its team moderates, its proposal's
29//! [`SubmittedCharter::moderators_share`] discounts the moderators part of an action fee
30//! ([`moderators_share_of`]), and its additions are capped by the target's
31//! `maxAddedModerators`.
32//!
33//! The schema carries almost every rule through its keywords (references, lookups, key
34//! requirements, `distinctFrom`, `maxBytes` for the description's byte cap, and the
35//! `propertyConstraints` rule holding the reward split to 100). What is here only reads:
36//! [`SubmittedCharter`] and [`ElectedCharter`] read the documents' properties. Nothing here
37//! reads state.
38
39mod reward_split;
40
41use crate::balances::credits::Credits;
42use crate::consensus::basic::moderation_charter::ModerationCharterMalformedFieldError;
43use crate::data_contract::document_type::contested_index_identifier;
44use crate::validation::ConsensusValidationResult;
45use platform_value::{Identifier, IdentifierBytes32, Value, ValueMap};
46use std::collections::{BTreeMap, BTreeSet};
47
48/// The id of the moderation charters system contract, `EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88`.
49///
50/// Spelled here so that consensus code can name the contract without the optional contract
51/// crates; the crate's own constant is pinned to this one by a test.
52pub const MODERATION_CHARTERS_CONTRACT_ID: Identifier = Identifier(IdentifierBytes32([
53    197, 6, 230, 72, 106, 198, 82, 129, 253, 135, 43, 86, 185, 182, 17, 112, 164, 127, 96, 5, 107,
54    185, 156, 46, 14, 10, 109, 237, 77, 228, 248, 129,
55]));
56
57/// The name of the reason document type.
58pub const REASON_DOCUMENT_TYPE_NAME: &str = "reason";
59/// The name of the proposal document type.
60pub const SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME: &str = "submittedCharter";
61/// The name of the join request document type.
62pub const JOIN_REQUEST_DOCUMENT_TYPE_NAME: &str = "joinRequest";
63/// The name of the elected charter document type, the one on the contested index.
64pub const ELECTED_CHARTER_DOCUMENT_TYPE_NAME: &str = "electedCharter";
65/// The name of the document type of a member the leader adds after the election.
66pub const ADDED_MODERATOR_DOCUMENT_TYPE_NAME: &str = "addedModerator";
67/// The name of the document type of a member the leader removes.
68pub const REMOVED_MODERATOR_DOCUMENT_TYPE_NAME: &str = "removedModerator";
69/// The name of the document type of a member asking to leave the team.
70pub const RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME: &str = "resignationRequest";
71
72/// The moderators share a proposal takes when it declares none: the full declared fee.
73pub const FULL_MODERATORS_SHARE: u8 = 100;
74
75/// Whether a contest on the contested index of `document_type_name` in the contract
76/// `contract_id` is a moderation election: an `electedCharter` of the moderation charters
77/// contract, contending for the seat of its target contract. A moderation election runs on the
78/// join and vote windows its target declares and is prefunded with the moderation fund; every
79/// other contest keeps the generic windows and fund.
80pub fn is_charter_election(contract_id: &Identifier, document_type_name: &str) -> bool {
81    *contract_id == MODERATION_CHARTERS_CONTRACT_ID
82        && document_type_name == ELECTED_CHARTER_DOCUMENT_TYPE_NAME
83}
84
85/// The contract a moderation election contends for: the single value of the contested index's
86/// key, `targetContractId`, in any form validation accepts for an identifier (from protocol
87/// version 14 a contest's index values are written as `Value::Identifier` anyway, see
88/// `Index::extract_contested_values`). `None` for every other contest, and for index values
89/// that do not name one contract, a base58 string included.
90pub fn charter_election_target(
91    contract_id: &Identifier,
92    document_type_name: &str,
93    index_values: &[Value],
94) -> Option<Identifier> {
95    if !is_charter_election(contract_id, document_type_name) {
96        return None;
97    }
98    match index_values {
99        [target] => contested_index_identifier(target).map(Identifier::new),
100        _ => None,
101    }
102}
103
104/// The moderators part a seated charter's team charges for an action whose document type
105/// declares `declared_moderators`: `moderators_share` percent of it, rounded down to the credit.
106/// A document action on a type the target moderates may agree to exactly this amount instead
107/// of the declared one; it is then charged this amount, and nothing else below the declared
108/// amount is accepted. A share of 100 (or none declared) gives the declared amount itself.
109pub fn moderators_share_of(declared_moderators: Credits, moderators_share: u8) -> Credits {
110    let share = (declared_moderators as u128) * (moderators_share as u128)
111        / (FULL_MODERATORS_SHARE as u128);
112    // At most 100 percent of an amount that fits, so the share fits; a stored share over 100
113    // is refused by the schema, and is held at the declared amount if one ever got through.
114    Credits::try_from(share)
115        .unwrap_or(declared_moderators)
116        .min(declared_moderators)
117}
118
119/// The properties of the charter document types.
120pub mod property_names {
121    pub const TARGET_CONTRACT_ID: &str = "targetContractId";
122    pub const DESCRIPTION: &str = "description";
123    pub const REASONS: &str = "reasons";
124    pub const MODERATORS_SHARE: &str = "moderatorsShare";
125    pub const REWARD_SPLIT: &str = "rewardSplit";
126    pub const REWARD_SPLIT_LEADER: &str = "leader";
127    pub const REWARD_SPLIT_EQUAL: &str = "equal";
128    pub const REWARD_SPLIT_ACTIONS: &str = "actions";
129    pub const SUBMITTED_CHARTER_ID: &str = "submittedCharterId";
130    pub const MEMBERS: &str = "members";
131    pub const ELECTED_CHARTER_ID: &str = "electedCharterId";
132    pub const MEMBER_ID: &str = "memberId";
133}
134
135/// How a team splits every settle of the moderators pot, a claim or a change of the team: three
136/// percentages summing to 100.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
138pub struct ModerationCharterRewardSplit {
139    /// The share of the leader.
140    pub leader: u8,
141    /// The share split equally between the members other than the leader; the leader's when
142    /// it has no member.
143    pub equal: u8,
144    /// The share split between the team, the leader included, by the moderation actions each
145    /// signed since the pot was last settled, or equally when nobody acted. See
146    /// [`ModerationCharterRewardSplit::payouts`].
147    pub actions: u8,
148}
149
150/// A proposal to moderate a contract, as read out of a `submittedCharter` document. Its owner
151/// is the leader.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct SubmittedCharter {
154    /// The contract the team proposes to moderate. Its elected moderation declaration is the
155    /// team's whole mandate.
156    pub target_contract_id: Identifier,
157    /// What the team would moderate and how, for joiners and voters. Informational.
158    pub description: String,
159    /// The ids of the `reason` documents the team's actions may name, in declared order and
160    /// never repeated. A team with none can take no action.
161    pub reasons: Vec<Identifier>,
162    /// The percentage, 0 to 100, of each moderated type's declared moderators fee the team
163    /// takes. `None` is the full amount; 0 is a team that will not moderate and takes no
164    /// rewards. See [`SubmittedCharter::moderators_share_or_full`].
165    pub moderators_share: Option<u8>,
166    /// How the team splits every claim of the moderators pot.
167    pub reward_split: ModerationCharterRewardSplit,
168}
169
170/// A proposal put to the vote with its team, as read out of an `electedCharter` document. Its
171/// owner is the leader, the owner of the proposal.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct ElectedCharter {
174    /// The contract contended for, the proposal's target.
175    pub target_contract_id: Identifier,
176    /// The `submittedCharter` document the team runs on.
177    pub submitted_charter_id: Identifier,
178    /// The team besides the leader, in declared order and never repeated: each filed a
179    /// `joinRequest` for the proposal, which the schema's lookup reference checks.
180    pub members: Vec<Identifier>,
181}
182
183fn malformed(field: &str, reason: impl Into<String>) -> ModerationCharterMalformedFieldError {
184    ModerationCharterMalformedFieldError::for_field(field, reason)
185}
186
187fn get<'a>(
188    properties: &'a BTreeMap<String, Value>,
189    field: &'static str,
190) -> Result<&'a Value, ModerationCharterMalformedFieldError> {
191    properties
192        .get(field)
193        .ok_or_else(|| malformed(field, "missing"))
194}
195
196fn identifier_list(
197    properties: &BTreeMap<String, Value>,
198    field: &'static str,
199) -> Result<Vec<Identifier>, ModerationCharterMalformedFieldError> {
200    get(properties, field)?
201        .as_array()
202        .ok_or_else(|| malformed(field, "not a list"))?
203        .iter()
204        .map(|element| {
205            element
206                .to_identifier()
207                .map_err(|e| malformed(field, e.to_string()))
208        })
209        .collect()
210}
211
212fn identifier_list_value(identifiers: &[Identifier]) -> Value {
213    Value::Array(
214        identifiers
215            .iter()
216            .map(|id| Value::Identifier(id.to_buffer()))
217            .collect(),
218    )
219}
220
221impl SubmittedCharter {
222    /// The share of each moderated type's declared moderators fee the team takes, with an
223    /// absent share read as the full amount.
224    pub fn moderators_share_or_full(&self) -> u8 {
225        self.moderators_share.unwrap_or(FULL_MODERATORS_SHARE)
226    }
227
228    /// Reads a proposal out of the properties of a `submittedCharter` document.
229    ///
230    /// The result carries a consensus error, never a proposal, when a property is missing or
231    /// of the wrong type. The proposal's rules are the contract's own keywords (the reward
232    /// split's `propertyConstraints` rule `rewardSplitIsWhole`, the description's `maxBytes`),
233    /// checked wherever the document is validated, not here.
234    pub fn from_document_properties(
235        properties: &BTreeMap<String, Value>,
236    ) -> ConsensusValidationResult<Self> {
237        match Self::try_from_document_properties(properties) {
238            Ok(charter) => ConsensusValidationResult::new_with_data(charter),
239            Err(error) => ConsensusValidationResult::new_with_error(error.into()),
240        }
241    }
242
243    fn try_from_document_properties(
244        properties: &BTreeMap<String, Value>,
245    ) -> Result<Self, ModerationCharterMalformedFieldError> {
246        let target_contract_id = get(properties, property_names::TARGET_CONTRACT_ID)?
247            .to_identifier()
248            .map_err(|e| malformed(property_names::TARGET_CONTRACT_ID, e.to_string()))?;
249
250        let description = get(properties, property_names::DESCRIPTION)?
251            .to_str()
252            .map_err(|e| malformed(property_names::DESCRIPTION, e.to_string()))?
253            .to_string();
254
255        let reasons = identifier_list(properties, property_names::REASONS)?;
256
257        let moderators_share = properties
258            .get(property_names::MODERATORS_SHARE)
259            .map(|value| {
260                value
261                    .to_integer::<u8>()
262                    .map_err(|e| malformed(property_names::MODERATORS_SHARE, e.to_string()))
263            })
264            .transpose()?;
265
266        let reward_split = get(properties, property_names::REWARD_SPLIT)?;
267        let share = |name: &str| {
268            reward_split
269                .get_integer::<u8>(name)
270                .map_err(|e| malformed(property_names::REWARD_SPLIT, format!("{name}: {e}")))
271        };
272        let reward_split = ModerationCharterRewardSplit {
273            leader: share(property_names::REWARD_SPLIT_LEADER)?,
274            equal: share(property_names::REWARD_SPLIT_EQUAL)?,
275            actions: share(property_names::REWARD_SPLIT_ACTIONS)?,
276        };
277
278        Ok(Self {
279            target_contract_id,
280            description,
281            reasons,
282            moderators_share,
283            reward_split,
284        })
285    }
286
287    /// The properties of the `submittedCharter` document that carries this proposal, the way
288    /// [`SubmittedCharter::from_document_properties`] reads them back. An absent share stays
289    /// absent.
290    pub fn to_document_properties(&self) -> BTreeMap<String, Value> {
291        let mut properties = BTreeMap::from([
292            (
293                property_names::TARGET_CONTRACT_ID.to_string(),
294                Value::Identifier(self.target_contract_id.to_buffer()),
295            ),
296            (
297                property_names::DESCRIPTION.to_string(),
298                Value::Text(self.description.clone()),
299            ),
300            (
301                property_names::REASONS.to_string(),
302                identifier_list_value(&self.reasons),
303            ),
304            (
305                property_names::REWARD_SPLIT.to_string(),
306                Value::Map(ValueMap::from([
307                    (
308                        Value::Text(property_names::REWARD_SPLIT_LEADER.to_string()),
309                        Value::U8(self.reward_split.leader),
310                    ),
311                    (
312                        Value::Text(property_names::REWARD_SPLIT_EQUAL.to_string()),
313                        Value::U8(self.reward_split.equal),
314                    ),
315                    (
316                        Value::Text(property_names::REWARD_SPLIT_ACTIONS.to_string()),
317                        Value::U8(self.reward_split.actions),
318                    ),
319                ])),
320            ),
321        ]);
322        if let Some(share) = self.moderators_share {
323            properties.insert(
324                property_names::MODERATORS_SHARE.to_string(),
325                Value::U8(share),
326            );
327        }
328        properties
329    }
330}
331
332impl ElectedCharter {
333    /// The most members its team can hold: the leader, the members the charter elected and the
334    /// additions the target contract's elected declaration allows (`max_added_moderators`),
335    /// each seat filled now or not. A member the leader removed still holds its seat, which the
336    /// leader fills again by deleting the removal, so removing members never lowers it. A
337    /// `moderatorAbilities.deleteSettled` rule asking for more approvals than this needs every
338    /// seat's.
339    pub fn seats(&self, max_added_moderators: u16) -> u16 {
340        Self::seats_for(self.members.len(), max_added_moderators)
341    }
342
343    /// [`ElectedCharter::seats`] of a charter that elected `elected_members` members, for a
344    /// holder of the count alone.
345    pub fn seats_for(elected_members: usize, max_added_moderators: u16) -> u16 {
346        u16::try_from(elected_members)
347            .unwrap_or(u16::MAX)
348            .saturating_add(max_added_moderators)
349            .saturating_add(1)
350    }
351
352    /// The members a seated team acts with besides its leader, `leader_id`: the elected
353    /// members and those the leader added after the election, less those the leader removed.
354    /// `added` and `removed` are the `memberId`s of the charter's `addedModerator` and
355    /// `removedModerator` documents that exist now: the leader takes an addition back by
356    /// deleting it, and a removal, which only names an elected member, puts the member back
357    /// when it is deleted. A removal wins over an addition of the same member, so the order
358    /// the documents were filed in does not matter. A `resignationRequest` changes nothing by
359    /// itself: the leader acts on it by deleting the member's addition or removing an elected
360    /// member. The leader is never among the result: neither list may name it.
361    pub fn active_members<'a>(
362        &self,
363        leader_id: Identifier,
364        added: impl IntoIterator<Item = &'a Identifier>,
365        removed: impl IntoIterator<Item = &'a Identifier>,
366    ) -> BTreeSet<Identifier> {
367        let mut active: BTreeSet<Identifier> = self.members.iter().copied().collect();
368        active.extend(added.into_iter().copied());
369        for gone in removed {
370            active.remove(gone);
371        }
372        active.remove(&leader_id);
373        active
374    }
375
376    /// Reads an elected charter out of the properties of an `electedCharter` document. The
377    /// result carries a consensus error, never a charter, when a property is missing or of the
378    /// wrong type.
379    pub fn from_document_properties(
380        properties: &BTreeMap<String, Value>,
381    ) -> ConsensusValidationResult<Self> {
382        match Self::try_from_document_properties(properties) {
383            Ok(charter) => ConsensusValidationResult::new_with_data(charter),
384            Err(error) => ConsensusValidationResult::new_with_error(error.into()),
385        }
386    }
387
388    fn try_from_document_properties(
389        properties: &BTreeMap<String, Value>,
390    ) -> Result<Self, ModerationCharterMalformedFieldError> {
391        let target_contract_id = get(properties, property_names::TARGET_CONTRACT_ID)?
392            .to_identifier()
393            .map_err(|e| malformed(property_names::TARGET_CONTRACT_ID, e.to_string()))?;
394        let submitted_charter_id = get(properties, property_names::SUBMITTED_CHARTER_ID)?
395            .to_identifier()
396            .map_err(|e| malformed(property_names::SUBMITTED_CHARTER_ID, e.to_string()))?;
397        let members = identifier_list(properties, property_names::MEMBERS)?;
398        Ok(Self {
399            target_contract_id,
400            submitted_charter_id,
401            members,
402        })
403    }
404
405    /// The properties of the `electedCharter` document that carries this charter, the way
406    /// [`ElectedCharter::from_document_properties`] reads them back.
407    pub fn to_document_properties(&self) -> BTreeMap<String, Value> {
408        BTreeMap::from([
409            (
410                property_names::TARGET_CONTRACT_ID.to_string(),
411                Value::Identifier(self.target_contract_id.to_buffer()),
412            ),
413            (
414                property_names::SUBMITTED_CHARTER_ID.to_string(),
415                Value::Identifier(self.submitted_charter_id.to_buffer()),
416            ),
417            (
418                property_names::MEMBERS.to_string(),
419                identifier_list_value(&self.members),
420            ),
421        ])
422    }
423}
424
425#[cfg(test)]
426mod tests;