dpp/data_contract/config/moderation/settled_deletion.rs
1use crate::data_contract::config::moderation::ContractModerationReason;
2use crate::identity::TimestampMillis;
3use crate::prelude::{IdentityNonce, Revision};
4use crate::util::hash::hash_double;
5use bincode::{Decode, DecodeUntrusted, Encode};
6use platform_value::Identifier;
7use serde::{Deserialize, Serialize};
8
9/// Who must approve a moderator's deletion of a settled document: one past the window its
10/// document type gives its moderators (`moderatorAbilities.deleteWithin`). The document type's
11/// `moderatorAbilities.deleteSettled` (protocol version 14), on a contract whose moderators are
12/// an elected team: so many members of the seated team, the leader counted among them when
13/// it approves, and the leader among them only when `leader` is set; with `leader` unset, any
14/// members meet it. While `approvers_predate_document` is set, a member the leader added
15/// counts only for documents created after its addition.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)]
17pub struct SettledDeletionRule {
18 /// Whether the team's leader must be among the approvals.
19 pub leader: bool,
20 /// How many moderators of the seated team must approve, the leader counted among them: at
21 /// least 1, and at registration at most the members the declared team can hold (its
22 /// leader, `SystemLimits::max_moderation_charter_elected_members` elected members and the
23 /// declaration's `maxAddedModerators`). A seated team that can hold fewer, its charter
24 /// electing fewer members, must have all it can hold approve.
25 pub approvals: u16,
26 /// Whether a member the leader added (an `addedModerator`) proposes or approves the
27 /// deletion of a document only when its addition was made before the document was created
28 /// (`approversPredateDocument`, default `true` when `approvals` is above 1: a rule one
29 /// approval meets, the leader meets alone). The leader names whom it adds, so without this
30 /// it could add members who approve whatever it proposes, and take them off again once
31 /// they had. The leader and the elected members always count: the election seated them,
32 /// not the leader. Read from the document's `$createdAt`, which the type must then require
33 /// at registration; a document without it admits no added member. What the rule needs is
34 /// not lowered for it (see [`Self::approvals_needed`]), so a team whose members from before
35 /// a document are too few never deletes that document once settled. Every addition comes
36 /// after the seat, so a document written before the seat counts only the leader and the
37 /// elected members: under a rule asking for more approvals than those, the team never
38 /// deletes any document older than the seat once settled.
39 pub approvers_predate_document: bool,
40}
41
42impl SettledDeletionRule {
43 /// How many approvals meet the rule on a team that holds at most `team_capacity` members,
44 /// the leader counted: its `approvals`, or all the team can hold when it asks for more, so
45 /// that it can be met by the team seated.
46 pub fn approvals_needed(&self, team_capacity: usize) -> usize {
47 usize::from(self.approvals).min(team_capacity)
48 }
49
50 /// Whether `approvals`, identities of the seated team led by `leader_id`, each at most once,
51 /// meet the rule on a team that holds at most `team_capacity` members
52 /// ([`Self::approvals_needed`]), the leader among them when the rule says so.
53 pub fn is_met_by(
54 &self,
55 approvals: &[Identifier],
56 leader_id: Identifier,
57 team_capacity: usize,
58 ) -> bool {
59 approvals.len() >= self.approvals_needed(team_capacity)
60 && (!self.leader || approvals.contains(&leader_id))
61 }
62
63 /// Whether a member the leader added at `added_at` (its `addedModerator`'s `$createdAt`)
64 /// may approve the deletion of a document created at `document_created_at`: always when
65 /// `approvers_predate_document` is unset, and otherwise only when added before it. An
66 /// addition in the same block as the document, at the same time, does not count: which of
67 /// the two came first is not something the leader should be able to choose.
68 pub fn admits_addition(
69 &self,
70 added_at: TimestampMillis,
71 document_created_at: TimestampMillis,
72 ) -> bool {
73 !self.approvers_predate_document || added_at < document_created_at
74 }
75}
76
77/// A proposal the seated moderation team of an elected contract votes on, kept under the
78/// contract (protocol version 14): the team's counterpart of a token group action.
79///
80/// A member proposes, which is its own approval, and the others approve it by its id. Once
81/// the approvals meet what the proposal needs, the action runs and the proposal moves from
82/// the contract's active team actions to its closed ones, with the approvals that counted,
83/// for good: those of members who left the team are dropped. A proposal that never gets there
84/// stays active: nothing lapses, though an approval of a settled document's deletion is
85/// refused once the document has changed since the proposal.
86#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, DecodeUntrusted, Serialize, Deserialize)]
87#[serde(rename_all = "camelCase")]
88pub struct ContractTeamAction {
89 /// The member of the seated team that proposed it, its first approval.
90 pub proposer_id: Identifier,
91 /// The time of the block of the proposal, in milliseconds.
92 pub proposed_at: TimestampMillis,
93 /// What runs once the approvals meet the rule.
94 pub event: ContractTeamActionEvent,
95}
96
97/// What a [`ContractTeamAction`] does once approved.
98#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, DecodeUntrusted, Serialize, Deserialize)]
99#[serde(tag = "$type", rename_all = "camelCase")]
100pub enum ContractTeamActionEvent {
101 /// Deletes a settled document: one past the window its type gives its moderators
102 /// (`moderatorAbilities.deleteWithin`), which the type's `moderatorAbilities.deleteSettled`
103 /// lets the seated team delete together. The document goes as a moderator's
104 /// `DeleteDocument` deletes it.
105 #[serde(rename_all = "camelCase")]
106 DeleteSettledDocument {
107 /// The document's type.
108 document_type_name: String,
109 /// The document.
110 document_id: Identifier,
111 /// The document's last modification when proposed: its `$updatedAt`, or `$createdAt`
112 /// on a type that carries no `$updatedAt`.
113 document_last_modified_at: TimestampMillis,
114 /// The document's `$revision` when proposed, `None` on a type whose documents carry
115 /// none. Every change of the document moves it, a moderator's change of its fields
116 /// included, which leaves `$updatedAt` alone.
117 document_revision: Option<Revision>,
118 /// Why, as the proposer gave it: stored with the removal record the deletion leaves.
119 reason: ContractModerationReason,
120 },
121}
122
123impl ContractTeamAction {
124 /// The id of the proposal of a settled document's deletion: a double SHA-256 of the
125 /// contract, the proposer, the proposer's nonce for the contract, the document type name,
126 /// the document id and the reason. A proposer's nonce is used once, so no two proposals share
127 /// an id, and the proposer's client knows it before broadcasting. The reason is part of it so
128 /// that the proof of the proposal, which shows the proposer's approval under this id, is the
129 /// proof of this proposal and not of another one signed with the same nonce.
130 pub fn settled_deletion_action_id(
131 contract_id: Identifier,
132 proposer_id: Identifier,
133 identity_contract_nonce: IdentityNonce,
134 document_type_name: &str,
135 document_id: Identifier,
136 reason: &ContractModerationReason,
137 ) -> Identifier {
138 let mut bytes = b"action_contract_settled_deletion".to_vec();
139 bytes.extend_from_slice(contract_id.as_slice());
140 bytes.extend_from_slice(proposer_id.as_slice());
141 bytes.extend_from_slice(&identity_contract_nonce.to_be_bytes());
142 // A document type name is at most 64 bytes, so its length fits a byte and no two
143 // (name, id) pairs hash alike
144 bytes.push(document_type_name.len().min(u8::MAX as usize) as u8);
145 bytes.extend_from_slice(document_type_name.as_bytes());
146 bytes.extend_from_slice(document_id.as_slice());
147 // The reason, every part of it in a shape no two reasons share: each optional part
148 // behind a presence byte, the documents behind their count and each name behind its
149 // length (both bounded far below a byte by the reason's validation), the text last
150 // behind its length.
151 match reason.code {
152 Some(code) => {
153 bytes.push(1);
154 bytes.extend_from_slice(&code.to_be_bytes());
155 }
156 None => bytes.push(0),
157 }
158 match reason.reason_document_id {
159 Some(reason_document_id) => {
160 bytes.push(1);
161 bytes.extend_from_slice(reason_document_id.as_slice());
162 }
163 None => bytes.push(0),
164 }
165 bytes.push(reason.documents.len().min(u8::MAX as usize) as u8);
166 for document in &reason.documents {
167 bytes.push(document.document_type_name.len().min(u8::MAX as usize) as u8);
168 bytes.extend_from_slice(document.document_type_name.as_bytes());
169 bytes.extend_from_slice(document.document_id.as_slice());
170 }
171 bytes.extend_from_slice(&(reason.text.len() as u64).to_be_bytes());
172 bytes.extend_from_slice(reason.text.as_bytes());
173 hash_double(bytes).into()
174 }
175
176 /// The document a settled deletion proposal names, as its document type name and its id.
177 pub fn settled_document(&self) -> (&str, Identifier) {
178 match &self.event {
179 ContractTeamActionEvent::DeleteSettledDocument {
180 document_type_name,
181 document_id,
182 ..
183 } => (document_type_name.as_str(), *document_id),
184 }
185 }
186
187 /// Whether the document a settled deletion proposal names is still as it was proposed:
188 /// last modified at `document_last_modified_at` and at `document_revision`. An approval
189 /// of a document changed since is refused, since the team would be approving the deletion
190 /// of content it never saw.
191 pub fn names_document_as(
192 &self,
193 document_last_modified_at: TimestampMillis,
194 document_revision: Option<Revision>,
195 ) -> bool {
196 match &self.event {
197 ContractTeamActionEvent::DeleteSettledDocument {
198 document_last_modified_at: proposed_last_modified_at,
199 document_revision: proposed_revision,
200 ..
201 } => {
202 *proposed_last_modified_at == document_last_modified_at
203 && *proposed_revision == document_revision
204 }
205 }
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn should_need_the_leader_among_the_approvals_when_the_rule_says_so() {
215 let leader = Identifier::from([1; 32]);
216 let member = Identifier::from([2; 32]);
217 let other = Identifier::from([3; 32]);
218 let leader_and_two = SettledDeletionRule {
219 leader: true,
220 approvals: 3,
221 approvers_predate_document: true,
222 };
223 assert!(!leader_and_two.is_met_by(&[leader, member], leader, 31));
224 assert!(!leader_and_two.is_met_by(&[member, other, Identifier::from([4; 32])], leader, 31));
225 assert!(leader_and_two.is_met_by(&[member, leader, other], leader, 31));
226
227 let leader_alone = SettledDeletionRule {
228 leader: true,
229 approvals: 1,
230 approvers_predate_document: true,
231 };
232 assert!(leader_alone.is_met_by(&[leader], leader, 31));
233 assert!(!leader_alone.is_met_by(&[member], leader, 31));
234
235 let any_two = SettledDeletionRule {
236 leader: false,
237 approvals: 2,
238 approvers_predate_document: true,
239 };
240 assert!(any_two.is_met_by(&[member, other], leader, 31));
241 assert!(!any_two.is_met_by(&[member], leader, 31));
242 }
243
244 #[test]
245 fn should_ask_a_team_that_holds_fewer_than_the_rule_for_all_it_holds() {
246 let leader = Identifier::from([1; 32]);
247 let member = Identifier::from([2; 32]);
248 let other = Identifier::from([3; 32]);
249 let thirty_one = SettledDeletionRule {
250 leader: true,
251 approvals: 31,
252 approvers_predate_document: true,
253 };
254 // A team of three at most: all three meet the rule, two do not.
255 assert!(thirty_one.is_met_by(&[member, leader, other], leader, 3));
256 assert!(!thirty_one.is_met_by(&[member, leader], leader, 3));
257 // The leader still has to be among them.
258 assert!(!thirty_one.is_met_by(&[member, other], leader, 2));
259 }
260
261 #[test]
262 fn should_admit_only_members_added_before_the_document_while_the_rule_says_so() {
263 let predating = SettledDeletionRule {
264 leader: true,
265 approvals: 3,
266 approvers_predate_document: true,
267 };
268 assert!(predating.admits_addition(999, 1_000));
269 // Added in the block that created the document, or after it
270 assert!(!predating.admits_addition(1_000, 1_000));
271 assert!(!predating.admits_addition(1_001, 1_000));
272
273 let any_member = SettledDeletionRule {
274 approvers_predate_document: false,
275 ..predating
276 };
277 assert!(any_member.admits_addition(1_001, 1_000));
278 }
279
280 #[test]
281 fn should_refuse_a_document_changed_since_the_proposal() {
282 let proposal = ContractTeamAction {
283 proposer_id: Identifier::from([2; 32]),
284 proposed_at: 1_000,
285 event: ContractTeamActionEvent::DeleteSettledDocument {
286 document_type_name: "post".to_string(),
287 document_id: Identifier::from([5; 32]),
288 document_last_modified_at: 10,
289 document_revision: Some(2),
290 reason: ContractModerationReason::from_text("spam"),
291 },
292 };
293 assert!(proposal.names_document_as(10, Some(2)));
294 // The document was replaced since
295 assert!(!proposal.names_document_as(11, Some(3)));
296 // A moderator changed its fields since: a new revision, the same `$updatedAt`
297 assert!(!proposal.names_document_as(10, Some(3)));
298 assert_eq!(
299 proposal.settled_document(),
300 ("post", Identifier::from([5; 32]))
301 );
302 }
303
304 #[test]
305 fn should_give_every_proposal_its_own_id() {
306 let contract = Identifier::from([1; 32]);
307 let proposer = Identifier::from([2; 32]);
308 let document = Identifier::from([5; 32]);
309 let spam = ContractModerationReason::from_text("spam");
310 let id_of =
311 |proposer: Identifier, nonce: u64, name: &str, reason: &ContractModerationReason| {
312 ContractTeamAction::settled_deletion_action_id(
313 contract, proposer, nonce, name, document, reason,
314 )
315 };
316 let id = id_of(proposer, 7, "post", &spam);
317 assert_eq!(id, id_of(proposer, 7, "post", &spam));
318 assert_ne!(id, id_of(proposer, 8, "post", &spam));
319 assert_ne!(id, id_of(Identifier::from([3; 32]), 7, "post", &spam));
320 assert_ne!(id, id_of(proposer, 7, "posts", &spam));
321 // The same nonce with another reason is another proposal: its proof is not this one's
322 assert_ne!(
323 id,
324 id_of(
325 proposer,
326 7,
327 "post",
328 &ContractModerationReason::from_text("doxxing")
329 )
330 );
331 assert_ne!(
332 id,
333 id_of(
334 proposer,
335 7,
336 "post",
337 &ContractModerationReason::from_text("spam").with_reason_document(document)
338 )
339 );
340 }
341}