Skip to main content

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}