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;