From 7cf3e56954f6413041f964c610965a96a9c08cb8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 21 Sep 2026 00:02:25 +0700 Subject: [PATCH 1/2] feat(platform)!: a window after a document's last modification for moderators to delete it A document type that sets `canBeDeletedByModerators` may also set `canBeDeletedByModeratorsFor`, a number of seconds. Moderators can then delete a document only until that long after its last modification (`$updatedAt`): past it the document is settled and nobody removes it, the contract owner included (`DocumentModerationWindowElapsedError`, 41116). A replace moves `$updatedAt` and opens the window again. The window needs the flag and `$updatedAt` in the type's `required`, and is fixed with the type. It says nothing about a document's own owner. Gated at protocol version 14 with the rest of moderator deletion. Co-Authored-By: Claude Fable 5.1 --- book/src/data-model/contract-moderation.md | 7 +- book/src/error-handling/error-codes.md | 2 +- packages/js-evo-sdk/src/contracts/facade.ts | 4 +- .../document/v3/document-meta.json | 8 +- .../document_type/accessors/mod.rs | 24 +++ .../document_type/accessors/v2/mod.rs | 6 + .../try_from_schema/common/mod.rs | 78 +++++++++- .../class_methods/try_from_schema/v3/mod.rs | 7 + .../v3/moderators_delete_tests.rs | 85 ++++++++++ .../methods/validate_update/v1/mod.rs | 108 ++++++++++++- .../src/data_contract/document_type/mod.rs | 7 + .../document_type/v2/accessors.rs | 4 + .../src/data_contract/document_type/v2/mod.rs | 9 ++ packages/rs-dpp/src/errors/consensus/codes.rs | 1 + ...ocument_moderation_window_elapsed_error.rs | 92 +++++++++++ .../state/contract_moderation/mod.rs | 2 + .../src/errors/consensus/state/state_error.rs | 16 +- .../contract_user_moderation/state/v0/mod.rs | 34 +++- .../contract_user_moderation/tests.rs | 145 +++++++++++++++++- .../rs-platform-version/src/version/v14.rs | 5 + .../src/errors/consensus/consensus_error.rs | 6 +- .../src/state_transitions/contract.rs | 4 +- 22 files changed, 635 insertions(+), 19 deletions(-) create mode 100644 packages/rs-dpp/src/errors/consensus/state/contract_moderation/document_moderation_window_elapsed_error.rs diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index cf5b155b837..300835a4d15 100644 --- a/book/src/data-model/contract-moderation.md +++ b/book/src/data-model/contract-moderation.md @@ -116,6 +116,7 @@ A banlist keeps an identity out; it does not take down what the identity already - **It is fixed with the type.** A contract update can not add the keyword to an existing document type or take it away (`DocumentTypeUpdateError`, 40212): authors keep the rules they wrote under. A document type an update adds may carry it. - **It is independent of `canBeDeleted`**, which rules what a document's own owner may do. `canBeDeleted: false` with `canBeDeletedByModerators: true` is a post its author can not retract and moderation can remove. - **Some types can not carry it**: one that keeps history (Drive refuses to delete such documents), an indexOnly one (there is no stored row to name by id), and one that restricts creation (its documents are the contract owner's, which no moderator may delete). Transferable and tradeable types may, and so may a type with a deletion token cost, which a moderator does not pay. +- **It may come with a window.** `canBeDeletedByModeratorsFor: 86400` lets the moderators delete a document for that many seconds after its last modification (`$updatedAt`), and no longer: once block time is past `$updatedAt` plus the window the document is settled, and nobody removes it, the contract owner included (`DocumentModerationWindowElapsedError`, 41116). Moderation acts on what was just written; it does not reach back into what has stood unchallenged. A replace or a price update moves `$updatedAt`, so new content opens the window again; a transfer or a purchase does not. The window needs the flag, at least one second, and `$updatedAt` in the type's `required`, so that every document carries the clock; like the flag it is fixed with the type, in both directions (a longer one would reopen documents that had settled). It is in seconds, as the other durations of a document type are, and it says nothing about a document's own owner, whose deletion `canBeDeleted` rules at any age. - **For references it counts as deletable.** A `permanentDocument` reference refuses such a type (`ReferencedDocumentTypeDeletableError`, 40122) whatever its `canBeDeleted` says, so the guarantee that a validated permanent reference never dangles holds; a `deletableDocument` reference accepts it, `canBeDeleted: false` included. Both checks, at contract registration and at document write, read "deletable" as deletable by anyone. A like or a reply that points at a post moderators can remove therefore declares `refersTo: deletableDocument`: the join reports a removed post as a missing id, and the removal record says why it is missing. The deletion is a fifth action of the same transition: @@ -128,7 +129,7 @@ ContractUserModerationAction::DeleteDocument { } ``` -It names no identity (`identity_id()` is `None`): whose document it is is only known once the document is read. The transform checks, in order and each refusal paid: the document type exists (10406), it carries the keyword (`DocumentTypeNotDeletableByModeratorsError`, 41115), the signer is the owner or a moderator (41101), the document exists (`DocumentNotFoundError`), and its owner is neither the contract owner nor a moderator (41102, the rule that protects them from a ban protects what they wrote). The document is read the way a document's own deletion reads it, billed the same. The action carries the contract, the document's owner and the block time, so Drive reads nothing again. Nothing the document type prices is charged: neither its deletion token cost nor its `actionFees` deletion fee, both of which are what a document's own owner pays for deleting it. +It names no identity (`identity_id()` is `None`): whose document it is is only known once the document is read. The transform checks, in order and each refusal paid: the document type exists (10406), it carries the keyword (`DocumentTypeNotDeletableByModeratorsError`, 41115), the signer is the owner or a moderator (41101), the document exists (`DocumentNotFoundError`), its owner is neither the contract owner nor a moderator (41102, the rule that protects them from a ban protects what they wrote), and block time is within the type's window after the document's `$updatedAt`, when the type sets one (41116). The document is read the way a document's own deletion reads it, billed the same. The action carries the contract, the document's owner and the block time, so Drive reads nothing again. Nothing the document type prices is charged: neither its deletion token cost nor its `actionFees` deletion fee, both of which are what a document's own owner pays for deleting it. Drive then runs `DocumentOperationType::DeleteDocumentByModerator`, the ordinary deletion (so every index and aggregate of the type stays right) without its `canBeDeleted` guard, which is the owner's rule and not the moderators', and writes a **removal record**: @@ -240,10 +241,10 @@ Deleting indexOnly documents (the action would have to carry the owner and the v ## Tests -- `packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs`: the keyword's rules; `validate_update/common`: the keyword frozen across updates. +- `packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs`: the keyword's rules and the window's (it needs the flag and `$updatedAt`, and its shape is refused on the stored path too); `validate_update/common`: the keyword frozen across updates. - `packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs`: the trees created with the contract and with a document type an update adds, records written, replaced, read by ids and by page with proofs that verify to the same, the bounds of a read, estimate against applied cost, and a moderator's deletion refunding nobody where the author's own refunds the author. - `packages/rs-drive-abci/src/query/contract_moderation_queries/contract_document_removals`: the query by ids and by page, its proof read back by the verifier, and every request it refuses. - `packages/rs-dpp/src/data_contract/config/moderation/mod.rs` and `config/methods/validate_update/v2`: the declaration's rules and the update rules. - `packages/rs-drive/src/drive/contract/moderation/tests.rs`: tree creation on insert, the trees and their entries surviving a contract update, every writer with estimation, status and page proofs, paging, the refund going to the first moderator after another one replaces its suspension, and a status proof over one list saying nothing about the other. - `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/mod.rs`: the gate is silent before protocol version 14 and for an unmoderated contract, and refuses each barred operation of one batch on its own while keeping the deletions. -- `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs`: the whole pipeline, including a moderator deleting a post (record, execution proof, the author's balance unchanged, and the control where the author deletes it and is refunded), every refusal of a deletion, an update adding a document type moderators can delete from, a permanent reference to such a type refused, the mempool refusal, the lapse sweep, the moderator set, every refusal code, the lists staying as the contract was created with them, a barred identity deleting its own documents in a block and in the mempool, a barred identity refused as the recipient of a transfer and as the seller of a purchase, the ban's proof covering the suspension it removed, lifting the entry of an identity an update made moderator, the per-list execution proof, a named owner, a create or an update naming a moderator that does not exist, an update keeping its moderators, and inactivity of the transition and of a moderated contract create or update before protocol version 14. +- `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs`: the whole pipeline, including the moderators' window (a deletion to the millisecond it ends on, refused one later for the contract owner too while the author's own still passes, reopened by a replace, fixed on update), a moderator deleting a post (record, execution proof, the author's balance unchanged, and the control where the author deletes it and is refunded), every refusal of a deletion, an update adding a document type moderators can delete from, a permanent reference to such a type refused, the mempool refusal, the lapse sweep, the moderator set, every refusal code, the lists staying as the contract was created with them, a barred identity deleting its own documents in a block and in the mempool, a barred identity refused as the recipient of a transfer and as the seller of a purchase, the ban's proof covering the suspension it removed, lifting the entry of an identity an update made moderator, the per-list execution proof, a named owner, a create or an update naming a moderator that does not exist, an update keeping its moderators, and inactivity of the transition and of a moderated contract create or update before protocol version 14. diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index 40f4812a852..4ff2f8272b6 100644 --- a/book/src/error-handling/error-codes.md +++ b/book/src/error-handling/error-codes.md @@ -117,7 +117,7 @@ The fee category currently has a single code. The 30000 range is reserved for fu | 40800-40804 | Groups | `IdentityNotMemberOfGroupError` (40800), `GroupActionAlreadyCompletedError` (40802) | | 40900-40904 | Shielded | `InvalidAnchorError` (40900), `NullifierAlreadySpentError` (40901), `InsufficientShieldedFeeError` (40904) | | 41000-41003 | Contract Groups | `ContractGroupAlreadyExistsError` (41000), `ContractGroupNotFoundError` (41001), `IdentityNotContractGroupOwnerOrAdminError` (41002), `ContractGroupAdminNotFoundError` (41003) | -| 41100-41115 | Contract Moderation | `ContractModerationNotEnabledError` (41100), `IdentityNotContractModeratorError` (41101), `ContractUserBannedError` (41107), `ContractUserSuspendedError` (41108), `ContractModerationTargetNotFoundError` (41109), `ContractModeratorIdentityNotFoundError` (41110), `ContractFeesAlreadyClaimedThisEpochError` (41111), `ContractFeesNothingToClaimError` (41112), `ContractFeeClaimNotAllowedError` (41113), `ContractModerationCounterpartyBarredError` (41114), `DocumentTypeNotDeletableByModeratorsError` (41115) | +| 41100-41116 | Contract Moderation | `ContractModerationNotEnabledError` (41100), `IdentityNotContractModeratorError` (41101), `ContractUserBannedError` (41107), `ContractUserSuspendedError` (41108), `ContractModerationTargetNotFoundError` (41109), `ContractModeratorIdentityNotFoundError` (41110), `ContractFeesAlreadyClaimedThisEpochError` (41111), `ContractFeesNothingToClaimError` (41112), `ContractFeeClaimNotAllowedError` (41113), `ContractModerationCounterpartyBarredError` (41114), `DocumentTypeNotDeletableByModeratorsError` (41115), `DocumentModerationWindowElapsedError` (41116) | Notice how the `DataTriggerError` sub-enum has its own `ErrorWithCode` implementation that the `StateError` delegates to: diff --git a/packages/js-evo-sdk/src/contracts/facade.ts b/packages/js-evo-sdk/src/contracts/facade.ts index 8db220dc720..7e6017e1cba 100644 --- a/packages/js-evo-sdk/src/contracts/facade.ts +++ b/packages/js-evo-sdk/src/contracts/facade.ts @@ -144,7 +144,9 @@ export class ContractsFacade { /** * Deletes one document on a moderated contract as a moderator, whoever owns it, except the - * contract owner and the moderators. The document type must set `canBeDeletedByModerators`. + * contract owner and the moderators. The document type must set `canBeDeletedByModerators`; + * when it also sets `canBeDeletedByModeratorsFor`, the deletion is refused (41116) once that + * many seconds have passed since the document's last modification. * Signed like the other moderations. `options.reason` is optional here: left out, no code and * an empty text are stored. Resolves with the record the deletion left under the contract; * the document's owner gets no storage refund. diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 526044fc830..a6664d5215a 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -891,7 +891,13 @@ }, "canBeDeletedByModerators": { "type": "boolean", - "description": "When true, the contract's moderators (its owner and the identities its moderation config appoints) may delete documents of this type with a contract user moderation transition, leaving a removal record under the contract. Independent of `canBeDeleted`, which rules what a document's own owner may do. Requires the contract to declare `moderation` in its config, and is refused on a type that keeps history, is indexOnly, or restricts document creation. Fixed when the document type is created. A type carrying it can not be the target of a permanentDocument reference. Available from protocol version 14." + "description": "When true, the contract's moderators (its owner and the identities its moderation config appoints) may delete documents of this type with a contract user moderation transition, leaving a removal record under the contract. Independent of `canBeDeleted`, which rules what a document's own owner may do. Requires the contract to declare `moderation` in its config, and is refused on a type that keeps history, is indexOnly, or restricts document creation. Fixed when the document type is created. For references a type carrying it is deletable: a permanentDocument reference refuses it and a deletableDocument reference accepts it. `canBeDeletedByModeratorsFor` limits it to a window after a document's last modification. Available from protocol version 14." + }, + "canBeDeletedByModeratorsFor": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295, + "description": "For how many seconds after a document's last modification (`$updatedAt`) the contract's moderators may still delete it. Once block time is past `$updatedAt` plus this many seconds the document is settled: no moderator can delete it any more. A replace or a price update moves `$updatedAt` and opens the window again; a transfer or a purchase does not. Absent means no limit. Requires `canBeDeletedByModerators: true` and `$updatedAt` in `required`. Fixed when the document type is created. Says nothing about what a document's own owner may do. Available from protocol version 14." }, "indexOnly": { "type": "boolean", diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs index 18a33b9046a..0e34a7b57d2 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs @@ -995,6 +995,14 @@ impl DocumentTypeV2Getters for DocumentType { } } + fn documents_can_be_deleted_by_moderators_for(&self) -> Option { + match self { + DocumentType::V0(_) => None, + DocumentType::V1(_) => None, + DocumentType::V2(v2) => v2.documents_can_be_deleted_by_moderators_for(), + } + } + fn immutable_fields(&self) -> &BTreeSet { match self { DocumentType::V0(_) => &NO_IMMUTABLE_FIELDS, @@ -1103,6 +1111,14 @@ impl DocumentTypeV2Getters for DocumentTypeRef<'_> { } } + fn documents_can_be_deleted_by_moderators_for(&self) -> Option { + match self { + DocumentTypeRef::V0(_) => None, + DocumentTypeRef::V1(_) => None, + DocumentTypeRef::V2(v2) => v2.documents_can_be_deleted_by_moderators_for(), + } + } + fn immutable_fields(&self) -> &BTreeSet { match self { DocumentTypeRef::V0(_) => &NO_IMMUTABLE_FIELDS, @@ -1177,6 +1193,14 @@ impl DocumentTypeV2Getters for DocumentTypeMutRef<'_> { } } + fn documents_can_be_deleted_by_moderators_for(&self) -> Option { + match self { + DocumentTypeMutRef::V0(_) => None, + DocumentTypeMutRef::V1(_) => None, + DocumentTypeMutRef::V2(v2) => v2.documents_can_be_deleted_by_moderators_for(), + } + } + fn immutable_fields(&self) -> &BTreeSet { match self { DocumentTypeMutRef::V0(_) => &NO_IMMUTABLE_FIELDS, diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs index cf30288af2a..7c904f213cb 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs @@ -40,6 +40,12 @@ pub trait DocumentTypeV2Getters { /// own owner may do. False on document types that predate the keyword. fn documents_can_be_deleted_by_moderators(&self) -> bool; + /// For how many seconds after a document's last modification (`$updatedAt`) + /// the moderators may still delete it (the `canBeDeletedByModeratorsFor` + /// keyword, protocol version 14). `None` means no limit, and is what every + /// document type that predates the keyword answers. + fn documents_can_be_deleted_by_moderators_for(&self) -> Option; + /// The top-level properties frozen at document creation on a mutable /// document type (the `immutable` keyword, protocol version 14). A /// replace that changes, adds or removes any of them is rejected with diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index 86176090ae8..9954c719f83 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -27,10 +27,11 @@ use crate::data_contract::document_type::index_level::IndexLevel; use crate::data_contract::document_type::property::DocumentProperty; use crate::data_contract::document_type::property::DocumentPropertyType; use crate::data_contract::document_type::property_names::{ - CAN_BE_DELETED, CAN_BE_DELETED_BY_MODERATORS, CREATION_RESTRICTION_MODE, DOCUMENTS_AVERAGEABLE, - DOCUMENTS_COUNTABLE, DOCUMENTS_KEEP_HISTORY, DOCUMENTS_MUTABLE, DOCUMENTS_SUMMABLE, INDEX_ONLY, - KEEPS_PRICING_HISTORY, KEEPS_PURCHASE_HISTORY, KEEPS_TRANSFER_HISTORY, RANGE_AVERAGEABLE, - RANGE_COUNTABLE, RANGE_SUMMABLE, TRADE_MODE, TRANSFERABLE, + CAN_BE_DELETED, CAN_BE_DELETED_BY_MODERATORS, CAN_BE_DELETED_BY_MODERATORS_FOR, + CREATION_RESTRICTION_MODE, DOCUMENTS_AVERAGEABLE, DOCUMENTS_COUNTABLE, DOCUMENTS_KEEP_HISTORY, + DOCUMENTS_MUTABLE, DOCUMENTS_SUMMABLE, INDEX_ONLY, KEEPS_PRICING_HISTORY, + KEEPS_PURCHASE_HISTORY, KEEPS_TRANSFER_HISTORY, RANGE_AVERAGEABLE, RANGE_COUNTABLE, + RANGE_SUMMABLE, TRADE_MODE, TRANSFERABLE, }; use crate::data_contract::document_type::restricted_creation::CreationRestrictionMode; use crate::data_contract::document_type::token_costs::v0::TokenCostsV0; @@ -41,6 +42,7 @@ use crate::data_contract::document_type::{property_names, DocumentType}; use crate::data_contract::errors::DataContractError; use crate::data_contract::storage_requirements::keys_for_document_type::StorageKeyRequirements; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; +use crate::document::property_names::UPDATED_AT; use crate::document::transfer::Transferable; use crate::identity::SecurityLevel; use crate::nft::TradeMode; @@ -2064,6 +2066,74 @@ pub(super) fn apply_can_be_deleted_by_moderators( Ok(()) } +/// Reads the doctype-level `canBeDeletedByModeratorsFor` keyword, a number of +/// seconds, before the core parse consumes `schema`. Its shape is enforced here +/// and not left to the meta-schema: a stored contract is read without one, and +/// no doctype-level keyword of this generation is read more leniently there. +pub(super) fn parse_can_be_deleted_by_moderators_for_keyword( + schema: &Value, +) -> Result, ProtocolError> { + schema + .get_optional_integer::(CAN_BE_DELETED_BY_MODERATORS_FOR) + .map_err(consensus_or_protocol_value_error) +} + +/// Applies the `canBeDeletedByModeratorsFor` window and checks what it +/// requires. +/// +/// The window limits how long after a document's last modification the +/// moderators may delete it, so: +/// - the type must let moderators delete its documents at all, or the window +/// would limit nothing; +/// - the type must require `$updatedAt`, the clock the window is measured on: +/// every document then carries it, set at creation and moved by every +/// replace; +/// - it lasts at least a second: a window of none would be a type moderators +/// can never delete from, which is said by not setting the flag. +/// +/// Runs after `apply_can_be_deleted_by_moderators`, which sets the flag read +/// here. +pub(super) fn apply_can_be_deleted_by_moderators_for( + document_type: &mut DocumentTypeV2, + can_be_deleted_by_moderators_for: Option, + name: &str, +) -> Result<(), ProtocolError> { + let Some(seconds) = can_be_deleted_by_moderators_for else { + return Ok(()); + }; + let structure_error = |message: String| { + consensus_or_protocol_data_contract_error(DataContractError::InvalidContractStructure( + message, + )) + }; + + if !document_type.documents_can_be_deleted_by_moderators { + return Err(structure_error(format!( + "document type \"{}\" sets `canBeDeletedByModeratorsFor`, which limits \ + `canBeDeletedByModerators: true` and means nothing without it", + name, + ))); + } + if seconds == 0 { + return Err(structure_error(format!( + "document type \"{}\" sets `canBeDeletedByModeratorsFor: 0`: a window lasts at \ + least one second (leave `canBeDeletedByModerators` out for a type moderators can \ + not delete from)", + name, + ))); + } + if !document_type.required_fields.contains(UPDATED_AT) { + return Err(structure_error(format!( + "document type \"{}\" sets `canBeDeletedByModeratorsFor`, which is measured from \ + a document's last modification: list `$updatedAt` in `required`", + name, + ))); + } + + document_type.documents_can_be_deleted_by_moderators_for = Some(seconds); + Ok(()) +} + /// Reads a doctype-level array of top-level property names (`immutable`, the /// properties frozen at document creation on a mutable type, or /// `immutableAllowSetting`, the frozen properties a replace may still set diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 589c23e3e36..89833f017cb 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -289,6 +289,8 @@ fn try_from_schema_generation_3( let index_only = common::parse_index_only_keyword(&schema)?; let action_fees = DocumentActionFees::try_from_document_schema(&schema, name)?; let can_be_deleted_by_moderators = common::parse_can_be_deleted_by_moderators_keyword(&schema)?; + let can_be_deleted_by_moderators_for = + common::parse_can_be_deleted_by_moderators_for_keyword(&schema)?; let immutable_fields = common::parse_property_name_list_keyword(&schema, name, property_names::IMMUTABLE)?; let immutable_fields_allow_setting = common::parse_property_name_list_keyword( @@ -383,6 +385,11 @@ fn try_from_schema_generation_3( data_contact_config, name, )?; + common::apply_can_be_deleted_by_moderators_for( + &mut v2, + can_be_deleted_by_moderators_for, + name, + )?; // The flags are read from the parsed result (not the raw schema) so // the check sees `canBeDeleted` resolved against the contract config diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs index cc056a8548b..3af7ac2e4d6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs @@ -192,3 +192,88 @@ fn should_refuse_the_keyword_before_protocol_version_14() { let result = parse_with_config(post_schema(platform_value!({})), &config, 13, true); assert!(result.is_err(), "the keyword must not pass meta-schema v2"); } + +// ---- canBeDeletedByModeratorsFor: the window the moderators have ---------------------- + +fn windowed_schema(extra: Value) -> Value { + let mut schema = post_schema(platform_value!({ + "canBeDeletedByModeratorsFor": 86400, + "required": ["$updatedAt"], + })); + if let (Value::Map(schema_map), Value::Map(extra_map)) = (&mut schema, extra) { + for (key, value) in extra_map { + schema_map.retain(|(existing, _)| existing != &key); + schema_map.push((key, value)); + } + } + schema +} + +#[test] +fn should_parse_the_window_the_moderators_have() { + let document_type = parse_moderated(windowed_schema(platform_value!({}))).expect("parse"); + assert!(document_type.documents_can_be_deleted_by_moderators()); + assert_eq!( + document_type.documents_can_be_deleted_by_moderators_for(), + Some(86400) + ); +} + +#[test] +fn should_leave_moderators_no_limit_without_the_window() { + let document_type = parse_moderated(post_schema(platform_value!({}))).expect("parse"); + assert_eq!( + document_type.documents_can_be_deleted_by_moderators_for(), + None + ); +} + +#[test] +fn should_refuse_a_window_on_a_type_moderators_can_not_delete_from() { + // The flag set to false, and the flag left out: the window limits nothing either way. + let flag_off = windowed_schema(platform_value!({ "canBeDeletedByModerators": false })); + let mut flag_absent = windowed_schema(platform_value!({})); + if let Value::Map(map) = &mut flag_absent { + map.retain(|(key, _)| key != &Value::Text("canBeDeletedByModerators".to_string())); + } + for schema in [flag_off, flag_absent] { + assert_refused_naming( + parse_moderated(schema), + &["canBeDeletedByModeratorsFor", "canBeDeletedByModerators"], + ); + } +} + +#[test] +fn should_refuse_a_window_on_a_type_that_does_not_require_updated_at() { + assert_refused_naming( + parse_moderated(windowed_schema(platform_value!({ "required": ["text"] }))), + &["canBeDeletedByModeratorsFor", "$updatedAt"], + ); +} + +#[test] +fn should_refuse_a_window_that_is_not_a_positive_number_of_seconds_on_the_stored_path_too() { + // No meta-schema stands in front of a stored contract, and no keyword is read more + // leniently there: the parser refuses the shape itself. + let platform_version = PlatformVersion::latest(); + for full_validation in [true, false] { + for window in [ + platform_value!(0), + platform_value!(-5), + platform_value!(4294967296u64), + platform_value!("a day"), + ] { + let result = parse_with_config( + windowed_schema(platform_value!({ "canBeDeletedByModeratorsFor": window.clone() })), + &moderated_config(platform_version), + platform_version.protocol_version, + full_validation, + ); + assert!( + result.is_err(), + "window {window:?} must be refused (full validation: {full_validation})" + ); + } + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs index 999052770d2..6d8af642615 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs @@ -152,7 +152,33 @@ impl DocumentTypeRef<'_> { if new_document_type.documents_can_be_deleted_by_moderators() == self.documents_can_be_deleted_by_moderators() { - return SimpleConsensusValidationResult::new(); + // The window the moderators have is fixed with the flag: a longer one would + // reopen documents that had settled, and one rule for both directions keeps + // what an author was told when they wrote. + let (old_window, new_window) = ( + self.documents_can_be_deleted_by_moderators_for(), + new_document_type.documents_can_be_deleted_by_moderators_for(), + ); + if old_window == new_window { + return SimpleConsensusValidationResult::new(); + } + let seconds = |window: Option| { + window.map_or("no limit".to_string(), |seconds| { + format!("{seconds} seconds") + }) + }; + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change for how long after a document's last modification moderators can delete it: changing from {} to {}", + seconds(old_window), + seconds(new_window) + ), + ) + .into(), + ); } SimpleConsensusValidationResult::new_with_error( DocumentTypeUpdateError::new( @@ -525,6 +551,86 @@ mod tests { } } + #[test] + fn should_return_invalid_result_when_the_moderators_window_is_changed() { + use crate::data_contract::config::moderation::{ + ContractModerationConfig, ContractModerators, + }; + + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config") + .with_moderation(Some(ContractModerationConfig { + banlist: true, + suspensions: false, + moderators: ContractModerators::ContractOwner, + })); + let make_document_type = |window: Option| { + let mut schema = platform_value!({ + "type": "object", + "properties": { + "text": { "type": "string", "maxLength": 50, "position": 0 }, + }, + "required": ["$updatedAt"], + "additionalProperties": false, + "canBeDeletedByModerators": true, + }); + if let Some(seconds) = window { + schema + .insert("canBeDeletedByModeratorsFor".to_string(), seconds.into()) + .expect("expected to set the window"); + } + DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + "post", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("document type should parse") + }; + + // Longer would reopen documents that had settled; shorter, given or taken away, would + // still change what an author was told. One rule for every direction. + for (old_window, new_window, from, to) in [ + (Some(86400), Some(172800), "86400 seconds", "172800 seconds"), + (Some(86400), Some(3600), "86400 seconds", "3600 seconds"), + (Some(86400), None, "86400 seconds", "no limit"), + (None, Some(86400), "no limit", "86400 seconds"), + ] { + let result = make_document_type(old_window) + .as_ref() + .validate_update(make_document_type(new_window).as_ref(), 2, platform_version) + .expect("validate_update should not error"); + let expected = format!( + "document type can not change for how long after a document's last modification moderators can delete it: changing from {from} to {to}" + ); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError(StateError::DocumentTypeUpdateError(e))] + if e.additional_message() == expected + ); + } + + // Unchanged, it passes. + let result = make_document_type(Some(86400)) + .as_ref() + .validate_update( + make_document_type(Some(86400)).as_ref(), + 2, + platform_version, + ) + .expect("validate_update should not error"); + assert!(result.is_valid(), "{:?}", result.errors); + } + #[test] fn should_reject_removing_an_immutable_property() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 9cd9e15aa3d..c97a4b094ef 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -150,6 +150,13 @@ pub(crate) mod property_names { /// See `apply_can_be_deleted_by_moderators` in `try_from_schema::common` /// for what the flag requires of the type and of the contract. pub const CAN_BE_DELETED_BY_MODERATORS: &str = "canBeDeletedByModerators"; + /// Doctype-level limit on `canBeDeletedByModerators`: for how many seconds + /// after a document's last modification (`$updatedAt`) the moderators may + /// still delete it. Past that the document is settled and no moderator can + /// remove it; a replace moves `$updatedAt` and opens the window again. + /// Absent means no limit. Meta-schema v3+ (protocol version 14). See + /// `apply_can_be_deleted_by_moderators_for` in `try_from_schema::common`. + pub const CAN_BE_DELETED_BY_MODERATORS_FOR: &str = "canBeDeletedByModeratorsFor"; } #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs index b81e9fc4cc6..2c9432fdfdb 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs @@ -236,6 +236,10 @@ impl DocumentTypeV2Getters for DocumentTypeV2 { self.documents_can_be_deleted_by_moderators } + fn documents_can_be_deleted_by_moderators_for(&self) -> Option { + self.documents_can_be_deleted_by_moderators_for + } + fn immutable_fields(&self) -> &BTreeSet { &self.immutable_fields } diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index 6fd850215a7..a41946bd462 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -133,6 +133,13 @@ pub struct DocumentTypeV2 { /// declares moderation, and refuses it on a type that keeps history, is /// indexOnly or restricts document creation. pub(in crate::data_contract) documents_can_be_deleted_by_moderators: bool, + /// For how many seconds after a document's last modification + /// (`$updatedAt`) the moderators may still delete it + /// (`canBeDeletedByModeratorsFor` keyword, protocol version 14). `None` + /// means no limit. Only ever `Some` beside + /// `documents_can_be_deleted_by_moderators`, on a type that requires + /// `$updatedAt` (`apply_can_be_deleted_by_moderators_for`). + pub(in crate::data_contract) documents_can_be_deleted_by_moderators_for: Option, } impl DocumentTypeBasicMethods for DocumentTypeV2 {} @@ -205,6 +212,7 @@ impl From for DocumentTypeV2 { index_only: false, action_fees: None, documents_can_be_deleted_by_moderators: false, + documents_can_be_deleted_by_moderators_for: None, } } } @@ -249,6 +257,7 @@ impl From for DocumentTypeV2 { index_only: false, action_fees: None, documents_can_be_deleted_by_moderators: false, + documents_can_be_deleted_by_moderators_for: None, } } } diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 53a61e95a5d..234c496a887 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -463,6 +463,7 @@ impl ErrorWithCode for StateError { Self::ContractFeeClaimNotAllowedError(_) => 41113, Self::ContractModerationCounterpartyBarredError(_) => 41114, Self::DocumentTypeNotDeletableByModeratorsError(_) => 41115, + Self::DocumentModerationWindowElapsedError(_) => 41116, } } } diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/document_moderation_window_elapsed_error.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/document_moderation_window_elapsed_error.rs new file mode 100644 index 00000000000..3b4f6982a94 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/document_moderation_window_elapsed_error.rs @@ -0,0 +1,92 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use crate::identity::TimestampMillis; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Document {} on contract {} was last modified at {} and could be deleted by moderators for {} seconds after that, which block time {} is past", + document_id, + contract_id, + updated_at, + window_seconds, + block_time +)] +#[platform_serialize(unversioned)] +pub struct DocumentModerationWindowElapsedError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + contract_id: Identifier, + document_id: Identifier, + updated_at: TimestampMillis, + window_seconds: u32, + block_time: TimestampMillis, +} + +impl DocumentModerationWindowElapsedError { + pub fn new( + contract_id: Identifier, + document_id: Identifier, + updated_at: TimestampMillis, + window_seconds: u32, + block_time: TimestampMillis, + ) -> Self { + Self { + contract_id, + document_id, + updated_at, + window_seconds, + block_time, + } + } + + pub fn contract_id(&self) -> Identifier { + self.contract_id + } + + pub fn document_id(&self) -> Identifier { + self.document_id + } + + /// The document's last modification, in milliseconds + pub fn updated_at(&self) -> TimestampMillis { + self.updated_at + } + + /// For how long after it moderators could delete the document + pub fn window_seconds(&self) -> u32 { + self.window_seconds + } + + /// The block time the deletion was judged at, in milliseconds + pub fn block_time(&self) -> TimestampMillis { + self.block_time + } +} + +impl From for ConsensusError { + fn from(err: DocumentModerationWindowElapsedError) -> Self { + Self::StateError(StateError::DocumentModerationWindowElapsedError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/mod.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/mod.rs index 686fc4ce35c..6300e904ff4 100644 --- a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/mod.rs @@ -12,6 +12,7 @@ mod contract_user_banned_error; mod contract_user_not_banned_error; mod contract_user_not_suspended_error; mod contract_user_suspended_error; +mod document_moderation_window_elapsed_error; mod document_type_not_deletable_by_moderators_error; mod identity_not_contract_moderator_error; @@ -29,5 +30,6 @@ pub use contract_user_banned_error::*; pub use contract_user_not_banned_error::*; pub use contract_user_not_suspended_error::*; pub use contract_user_suspended_error::*; +pub use document_moderation_window_elapsed_error::*; pub use document_type_not_deletable_by_moderators_error::*; pub use identity_not_contract_moderator_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index f6822a2745b..69890de57b6 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -19,7 +19,8 @@ use crate::consensus::state::contract_moderation::{ ContractModeratorIdentityNotFoundError, ContractSuspensionNotInFutureError, ContractUserAlreadyBannedError, ContractUserBannedError, ContractUserNotBannedError, ContractUserNotSuspendedError, ContractUserSuspendedError, - DocumentTypeNotDeletableByModeratorsError, IdentityNotContractModeratorError, + DocumentModerationWindowElapsedError, DocumentTypeNotDeletableByModeratorsError, + IdentityNotContractModeratorError, }; use crate::consensus::state::contract_group::{ ContractGroupAlreadyExistsError, ContractGroupNotFoundError, @@ -532,6 +533,10 @@ pub enum StateError { // Document deletion by moderators (protocol version 14). #[error(transparent)] DocumentTypeNotDeletableByModeratorsError(DocumentTypeNotDeletableByModeratorsError), + + // The moderators' deletion window (protocol version 14). + #[error(transparent)] + DocumentModerationWindowElapsedError(DocumentModerationWindowElapsedError), } impl From for ConsensusError { @@ -896,12 +901,19 @@ mod tests { )), 129 ); - // Document deletion by moderators (protocol version 14): the tail of the enum. + // Document deletion by moderators (protocol version 14). assert_eq!( discriminant_of(StateError::DocumentTypeNotDeletableByModeratorsError( DocumentTypeNotDeletableByModeratorsError::new(group_id, "post".to_string()) )), 130 ); + // The moderators' deletion window (protocol version 14): the tail of the enum. + assert_eq!( + discriminant_of(StateError::DocumentModerationWindowElapsedError( + DocumentModerationWindowElapsedError::new(group_id, identity_id, 1, 2, 3) + )), + 131 + ); } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs index c28e138e5c3..0426d200500 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs @@ -14,8 +14,8 @@ use dpp::consensus::state::contract_moderation::{ ContractModerationNotEnabledError, ContractModerationTargetNotAllowedError, ContractModerationTargetNotFoundError, ContractSuspensionNotInFutureError, ContractUserAlreadyBannedError, ContractUserBannedError, ContractUserNotBannedError, - ContractUserNotSuspendedError, DocumentTypeNotDeletableByModeratorsError, - IdentityNotContractModeratorError, + ContractUserNotSuspendedError, DocumentModerationWindowElapsedError, + DocumentTypeNotDeletableByModeratorsError, IdentityNotContractModeratorError, }; use dpp::consensus::state::document::document_not_found_error::DocumentNotFoundError; use dpp::consensus::state::state_error::StateError; @@ -42,6 +42,10 @@ use drive::state_transition_action::system::bump_identity_data_contract_nonce_ac use drive::state_transition_action::StateTransitionAction; use std::sync::Arc; +/// `canBeDeletedByModeratorsFor` is declared in seconds, as the other durations of a document +/// type are; block time and `$updatedAt` are in milliseconds. +const MILLIS_PER_SECOND: u64 = 1_000; + pub(in crate::execution::validation::state_transition::state_transitions::contract_user_moderation) trait ContractUserModerationStateTransitionStateValidationV0 { fn transform_into_action_v0( @@ -214,7 +218,8 @@ impl ContractUserModerationStateTransitionStateValidationV0 for ContractUserMode /// A document deletion: the document type exists and says moderators may delete its /// documents, the signer is the contract's owner or one of its moderators, the document -/// exists, and it is not the owner's or a moderator's. Every refusal is paid for by +/// exists, it is not the owner's or a moderator's, and it was last modified within the window +/// the document type gives its moderators, if it gives one. Every refusal is paid for by /// bumping the signer's contract nonce. /// /// The action carries the contract and the document's owner, so Drive deletes the document @@ -299,6 +304,29 @@ fn transform_document_deletion_v0( ); } + // A document type may give its moderators a window: so many seconds after a document's + // last modification, past which the document is settled and nobody removes it. A replace + // moves `$updatedAt` and opens the window again, since what it wrote is new content. The + // type requires `$updatedAt`, so every document carries it; one that did not would read as + // modified at time zero, which is settled: the refusal that protects the author. + if let Some(window_seconds) = document_type.documents_can_be_deleted_by_moderators_for() { + let updated_at = document.updated_at().unwrap_or_default(); + let settled_at = + updated_at.saturating_add(u64::from(window_seconds).saturating_mul(MILLIS_PER_SECOND)); + if block_info.time_ms > settled_at { + return refuse( + DocumentModerationWindowElapsedError::new( + contract_id, + document_id, + updated_at, + window_seconds, + block_info.time_ms, + ) + .into(), + ); + } + } + // The record this deletion leaves is always new: a document id is produced at most once // (it commits to the nonce of its create transition), so no earlier removal can have // recorded this id and nothing has to be read to write it. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs index 5a83c4ec469..ed99cd32466 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs @@ -29,6 +29,7 @@ use dpp::data_contract::document_type::random_document::{ }; use dpp::data_contract::schema::DataContractSchemaMethodsV0; use dpp::data_contract::DataContract; +use dpp::document::document_methods::DocumentMethodsV0; use dpp::document::Document; use dpp::document::{DocumentV0Getters, DocumentV0Setters}; use dpp::fee::Credits; @@ -84,6 +85,7 @@ const CONTRACT_MODERATION_TARGET_NOT_FOUND: u32 = 41109; const CONTRACT_MODERATOR_IDENTITY_NOT_FOUND: u32 = 41110; const CONTRACT_MODERATION_COUNTERPARTY_BARRED: u32 = 41114; const DOCUMENT_TYPE_NOT_DELETABLE_BY_MODERATORS: u32 = 41115; +const DOCUMENT_MODERATION_WINDOW_ELAPSED: u32 = 41116; const INVALID_DOCUMENT_TYPE: u32 = 10406; const INVALID_CONTRACT_STRUCTURE: u32 = 10231; const INVALID_CONTRACT_MODERATION_CONFIG: u32 = 10900; @@ -2101,7 +2103,11 @@ async fn should_fix_the_keyword_of_a_document_type_and_let_an_update_add_a_type_ fn post_schema_with(extra: Value) -> Value { let mut schema = post_schema(true); if let (Value::Map(schema_map), Value::Map(extra_map)) = (&mut schema, extra) { - schema_map.extend(extra_map); + for (key, value) in extra_map { + // A keyword given again replaces the one the base schema has. + schema_map.retain(|(existing, _)| existing != &key); + schema_map.push((key, value)); + } } schema } @@ -2308,3 +2314,140 @@ async fn should_charge_a_moderator_no_token_for_a_post_whose_deletion_costs_toke assert_success(&setup.process(&delete, &transaction)); assert!(setup.post_removal(post.id(), Some(&transaction)).is_some()); } + +/// The window the posts of the tests below give their moderators +const MODERATION_WINDOW_SECONDS: u64 = 60; +const MODERATION_WINDOW_MS: TimestampMillis = MODERATION_WINDOW_SECONDS * 1_000; + +/// A contract whose posts are mutable and can be deleted by moderators for a minute after +/// their last modification +async fn setup_with_a_moderation_window() -> Setup { + Setup::new_at_with( + Some(moderators_without_lists()), + PlatformVersion::latest(), + |contract| { + add_document_type( + contract, + POST, + post_schema_with(platform_value!({ + "canBeDeletedByModeratorsFor": MODERATION_WINDOW_SECONDS, + "documentsMutable": true, + "required": ["text", "$updatedAt"], + })), + ) + }, + ) + .await +} + +#[tokio::test] +async fn should_let_moderators_delete_a_post_only_within_the_window_after_its_last_modification() { + let setup = setup_with_a_moderation_window().await; + + // Two posts, written at the block time of the tests. + let transaction = setup.platform.drive.grove.start_transaction(); + let (in_time, create) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create, &transaction)); + let (settled, create) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create, &transaction)); + + // To the millisecond the window ends on, a moderator may delete. + let delete = setup + .moderate(&setup.moderator, delete_action(POST, in_time.id())) + .await; + assert_success(&setup.process_at(&delete, BLOCK_TIME_MS + MODERATION_WINDOW_MS, &transaction)); + + // One millisecond later the post is settled: nobody removes it, the owner of the contract + // included, and the refusal is paid. + let past_the_window = BLOCK_TIME_MS + MODERATION_WINDOW_MS + 1; + for actor in [&setup.moderator, &setup.owner] { + let too_late = setup + .moderate(actor, delete_action(POST, settled.id())) + .await; + assert_paid_with_code( + &setup.process_at(&too_late, past_the_window, &transaction), + DOCUMENT_MODERATION_WINDOW_ELAPSED, + ); + } + assert_eq!(setup.post_removal(settled.id(), Some(&transaction)), None); + + // Its author can still delete it: the window is the moderators', not the author's. + let own_delete = own_post_deletion(&setup, &setup.user, settled.clone()).await; + assert_success(&setup.process_at(&own_delete, past_the_window, &transaction)); +} + +#[tokio::test] +async fn should_open_the_window_again_when_the_author_modifies_the_post() { + let setup = setup_with_a_moderation_window().await; + + let transaction = setup.platform.drive.grove.start_transaction(); + let (mut post, create) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create, &transaction)); + + // Long after the window closed, the author rewrites the post. What it says now is new + // content, which the moderators get their minute for. + let edited_at = BLOCK_TIME_MS + 10 * MODERATION_WINDOW_MS; + post.set("text", "rewritten".into()); + post.increment_revision() + .expect("expected to bump the revision"); + let replace = BatchTransition::new_document_replacement_transition_from_document( + post.clone(), + setup + .contract + .document_type_for_name(POST) + .expect("expected the post type"), + &setup.user.key, + setup.user.contract_nonce(), + 0, + None, + &setup.user.signer, + PlatformVersion::latest(), + None, + ) + .await + .expect("expected to build the replacement"); + assert_success(&setup.process_at(&replace, edited_at, &transaction)); + + let delete = setup + .moderate(&setup.moderator, delete_action(POST, post.id())) + .await; + assert_success(&setup.process_at(&delete, edited_at + MODERATION_WINDOW_MS, &transaction)); + let removal = setup + .post_removal(post.id(), Some(&transaction)) + .expect("expected the record"); + assert_eq!(removal.removed_at, edited_at + MODERATION_WINDOW_MS); +} + +#[tokio::test] +async fn should_fix_the_window_of_a_document_type() { + let setup = setup_with_a_moderation_window().await; + let transaction = setup.platform.drive.grove.start_transaction(); + + // A longer window would reopen posts that had settled; none at all, every post ever. + for window in [Some(10 * MODERATION_WINDOW_SECONDS), None] { + let mut changed = setup.contract.clone(); + changed.increment_version(); + let mut schema = changed + .document_type_for_name(POST) + .expect("expected the post type") + .schema() + .clone(); + match window { + Some(seconds) => { + schema + .insert("canBeDeletedByModeratorsFor".to_string(), seconds.into()) + .expect("expected to set the window"); + } + None => { + if let Value::Map(map) = &mut schema { + map.retain(|(key, _)| { + key != &Value::Text("canBeDeletedByModeratorsFor".to_string()) + }); + } + } + } + add_document_type(&mut changed, POST, schema); + let update = setup.contract_update(changed).await; + assert_paid_with_code(&setup.process(&update, &transaction), DOCUMENT_TYPE_UPDATE); + } +} diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 4c8722ca6f2..69b5fcdb378 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -429,6 +429,11 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// fee is charged. /// The moderation method table, the verify table and the query table gain /// the document removal methods (`getContractDocumentRemovals`). +/// `canBeDeletedByModeratorsFor` bounds the deletion in time: so many +/// seconds after a document's last modification (`$updatedAt`, which the +/// type must then require), past which nobody removes it +/// (`DocumentModerationWindowElapsedError`). A replace opens the window +/// again. Fixed with the type, like the flag. /// /// * `ShieldFromIdentity` (state transition type 21) activates: /// `SHIELD_FROM_IDENTITY_INITIAL_PROTOCOL_VERSION = 14` gates it in diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 86a89b7a457..7a1d8f7cc69 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -104,7 +104,8 @@ use dpp::consensus::state::contract_moderation::{ ContractSuspensionNotInFutureError, ContractUserAlreadyBannedError, ContractUserBannedError, ContractUserNotBannedError, ContractUserNotSuspendedError, ContractUserSuspendedError, - DocumentTypeNotDeletableByModeratorsError, IdentityNotContractModeratorError, + DocumentModerationWindowElapsedError, DocumentTypeNotDeletableByModeratorsError, + IdentityNotContractModeratorError, }; use dpp::consensus::state::contract_group::{ ContractGroupAdminNotFoundError, ContractGroupAlreadyExistsError, ContractGroupNotFoundError, @@ -640,6 +641,9 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::DocumentTypeNotDeletableByModeratorsError(e) => { generic_consensus_error!(DocumentTypeNotDeletableByModeratorsError, e).into() } + StateError::DocumentModerationWindowElapsedError(e) => { + generic_consensus_error!(DocumentModerationWindowElapsedError, e).into() + } } } diff --git a/packages/wasm-sdk/src/state_transitions/contract.rs b/packages/wasm-sdk/src/state_transitions/contract.rs index 2a040e8cdf6..cd4e5cc26ed 100644 --- a/packages/wasm-sdk/src/state_transitions/contract.rs +++ b/packages/wasm-sdk/src/state_transitions/contract.rs @@ -314,7 +314,9 @@ export interface ContractModerationResult { /** * Options for deleting one document on a moderated data contract as a moderator (protocol * version 14), whoever owns it, except the contract owner and the moderators. The document - * type must set `canBeDeletedByModerators`. As for the other moderations, the signer must hold + * type must set `canBeDeletedByModerators`; when it also sets `canBeDeletedByModeratorsFor`, + * the deletion is refused (41116) once that many seconds have passed since the document's + * last modification. As for the other moderations, the signer must hold * a CRITICAL authentication key without contract bounds of the moderating identity. */ export interface ContractDeleteDocumentOptions { From 550334bb807d2edbd2168d7616695b2995a46a35 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 21 Sep 2026 00:41:03 +0700 Subject: [PATCH 2/2] feat(platform): measure the moderators' window from `$createdAt` where a type has no `$updatedAt` A document type whose documents never change has no modification after the creation, so it need not carry `$updatedAt` to give its moderators a window: `$createdAt` is then the clock. The deletion reads `$updatedAt` and falls back to `$createdAt`. A type whose documents can be replaced must still require `$updatedAt`: measured from creation alone, an author could wait the window out and then rewrite a document into something no moderator can remove. The error's timestamp is named for what it is, `last_modified_at`. Co-Authored-By: Claude Fable 5.1 --- book/src/data-model/contract-moderation.md | 8 +-- .../document/v3/document-meta.json | 2 +- .../document_type/accessors/v2/mod.rs | 3 +- .../try_from_schema/common/mod.rs | 26 +++++++--- .../v3/moderators_delete_tests.rs | 49 ++++++++++++++++-- .../src/data_contract/document_type/mod.rs | 3 +- .../src/data_contract/document_type/v2/mod.rs | 12 ++--- ...ocument_moderation_window_elapsed_error.rs | 15 +++--- .../contract_user_moderation/state/v0/mod.rs | 23 +++++---- .../contract_user_moderation/tests.rs | 51 +++++++++++++++++++ .../rs-platform-version/src/version/v14.rs | 5 +- 11 files changed, 157 insertions(+), 40 deletions(-) diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index 9b5fd7e23fc..767bce86ed7 100644 --- a/book/src/data-model/contract-moderation.md +++ b/book/src/data-model/contract-moderation.md @@ -116,7 +116,7 @@ A banlist keeps an identity out; it does not take down what the identity already - **It is fixed with the type.** A contract update can not add the keyword to an existing document type or take it away (`DocumentTypeUpdateError`, 40212): authors keep the rules they wrote under. A document type an update adds may carry it. - **It is independent of `canBeDeleted`**, which rules what a document's own owner may do. `canBeDeleted: false` with `canBeDeletedByModerators: true` is a post its author can not retract and moderation can remove. - **Some types can not carry it**: one that keeps history (Drive refuses to delete such documents), an indexOnly one (there is no stored row to name by id), and one that restricts creation (its documents are the contract owner's, which no moderator may delete). Transferable and tradeable types may, and so may a type with a deletion token cost, which a moderator does not pay. -- **It may come with a window.** `canBeDeletedByModeratorsFor: 86400` lets the moderators delete a document for that many seconds after its last modification (`$updatedAt`), and no longer: once block time is past `$updatedAt` plus the window the document is settled, and no moderator deletes it any more, the contract owner included (`DocumentModerationWindowElapsedError`, 41116). At exactly `$updatedAt` plus the window the deletion still passes; the document's own owner still deletes it as `canBeDeleted` allows. Moderation acts on what was just written; it does not reach back into what has stood unchallenged. A replace or a price update moves `$updatedAt`, so new content opens the window again; a transfer or a purchase does not. The window needs the flag, at least one second, and `$updatedAt` in the type's `required`, so that every document carries the clock; like the flag it is fixed with the type, in both directions (a longer one would reopen documents that had settled). It is in seconds, as the other durations of a document type are, and it says nothing about a document's own owner, whose deletion `canBeDeleted` rules at any age. +- **It may come with a window.** `canBeDeletedByModeratorsFor: 86400` lets the moderators delete a document for that many seconds after its last modification (`$updatedAt`), and no longer: once block time is past `$updatedAt` plus the window the document is settled, and no moderator deletes it any more, the contract owner included (`DocumentModerationWindowElapsedError`, 41116). At exactly `$updatedAt` plus the window the deletion still passes; the document's own owner still deletes it as `canBeDeleted` allows. Moderation acts on what was just written; it does not reach back into what has stood unchallenged. A replace or a price update moves `$updatedAt`, so new content opens the window again; a transfer or a purchase does not. The window needs the flag, at least one second, and the clock in the type's `required`, so that every document carries it: `$updatedAt`, or for a type with `documentsMutable: false` `$createdAt` instead, since nothing modifies such a document after its creation. A type whose documents can be replaced must require `$updatedAt`: measured from creation alone, an author could wait the window out and then rewrite a post into something no moderator can remove. The transform reads `$updatedAt` and falls back to `$createdAt`; like the flag it is fixed with the type, in both directions (a longer one would reopen documents that had settled). It is in seconds, as the other durations of a document type are, and it says nothing about a document's own owner, whose deletion `canBeDeleted` rules at any age. - **For references it counts as deletable.** A `permanentDocument` reference refuses such a type (`ReferencedDocumentTypeDeletableError`, 40122) whatever its `canBeDeleted` says, so the guarantee that a validated permanent reference never dangles holds; a `deletableDocument` reference accepts it, `canBeDeleted: false` included. Both checks, at contract registration and at document write, read "deletable" as deletable by anyone. A like or a reply that points at a post moderators can remove therefore declares `refersTo: deletableDocument`: the join reports a removed post as a missing id, and the removal record says why it is missing. The deletion is a fifth action of the same transition: @@ -129,7 +129,7 @@ ContractUserModerationAction::DeleteDocument { } ``` -It names no identity (`identity_id()` is `None`): whose document it is is only known once the document is read. The transform checks, in order and each refusal paid: the document type exists (10406), it carries the keyword (`DocumentTypeNotDeletableByModeratorsError`, 41115), the signer is the owner or a moderator (41101), the document exists (`DocumentNotFoundError`), its owner is neither the contract owner nor a moderator (41102, the rule that protects them from a ban protects what they wrote), and block time is within the type's window after the document's `$updatedAt`, when the type sets one (41116). The document is read the way a document's own deletion reads it, billed the same. The action carries the contract, the document's owner and the block time, so Drive reads nothing again. Nothing the document type prices is charged: neither its deletion token cost nor its `actionFees` deletion fee, both of which are what a document's own owner pays for deleting it. +It names no identity (`identity_id()` is `None`): whose document it is is only known once the document is read. The transform checks, in order and each refusal paid: the document type exists (10406), it carries the keyword (`DocumentTypeNotDeletableByModeratorsError`, 41115), the signer is the owner or a moderator (41101), the document exists (`DocumentNotFoundError`), its owner is neither the contract owner nor a moderator (41102, the rule that protects them from a ban protects what they wrote), and block time is within the type's window after the document's last modification (`$updatedAt`, else `$createdAt`), when the type sets one (41116). The document is read the way a document's own deletion reads it, billed the same. The action carries the contract, the document's owner and the block time, so Drive reads nothing again. Nothing the document type prices is charged: neither its deletion token cost nor its `actionFees` deletion fee, both of which are what a document's own owner pays for deleting it. Drive then runs `DocumentOperationType::DeleteDocumentByModerator`, the ordinary deletion (so every index and aggregate of the type stays right) without its `canBeDeleted` guard, which is the owner's rule and not the moderators', and writes a **removal record**: @@ -241,10 +241,10 @@ Deleting indexOnly documents (the action would have to carry the owner and the v ## Tests -- `packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs`: the keyword's rules and the window's (it needs the flag and `$updatedAt`, and its shape is refused on the stored path too); `validate_update/common`: the keyword frozen across updates. +- `packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs`: the keyword's rules and the window's (it needs the flag and a clock, `$updatedAt` or for documents that never change `$createdAt`, and its shape is refused on the stored path too); `validate_update/common`: the keyword frozen across updates. - `packages/rs-drive/src/drive/contract/moderation/document_removal_tests.rs`: the trees created with the contract and with a document type an update adds, records written, replaced, read by ids and by page with proofs that verify to the same, the bounds of a read, estimate against applied cost, and a moderator's deletion refunding nobody where the author's own refunds the author. - `packages/rs-drive-abci/src/query/contract_moderation_queries/contract_document_removals`: the query by ids and by page, its proof read back by the verifier, and every request it refuses. - `packages/rs-dpp/src/data_contract/config/moderation/mod.rs` and `config/methods/validate_update/v2`: the declaration's rules and the update rules. - `packages/rs-drive/src/drive/contract/moderation/tests.rs`: tree creation on insert, the trees and their entries surviving a contract update, every writer with estimation, status and page proofs, paging, the refund going to the first moderator after another one replaces its suspension, and a status proof over one list saying nothing about the other. - `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/contract_moderation_gate/mod.rs`: the gate is silent before protocol version 14 and for an unmoderated contract, and refuses each barred operation of one batch on its own while keeping the deletions. -- `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs`: the whole pipeline, including the moderators' window (a deletion to the millisecond it ends on, refused one later for the contract owner too while the author's own still passes, reopened by a replace, fixed on update), a moderator deleting a post (record, execution proof, the author's balance unchanged, and the control where the author deletes it and is refunded), every refusal of a deletion, an update adding a document type moderators can delete from, a permanent reference to such a type refused, the mempool refusal, the lapse sweep, the moderator set, every refusal code, the lists staying as the contract was created with them, a barred identity deleting its own documents in a block and in the mempool, a barred identity refused as the recipient of a transfer and as the seller of a purchase, the ban's proof covering the suspension it removed, lifting the entry of an identity an update made moderator, the per-list execution proof, a named owner, a create or an update naming a moderator that does not exist, an update keeping its moderators, and inactivity of the transition and of a moderated contract create or update before protocol version 14. +- `packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs`: the whole pipeline, including the moderators' window (a deletion to the millisecond it ends on, refused one later for the contract owner too while the author's own still passes, reopened by a replace, measured from `$createdAt` on a type that never changes, fixed on update), a moderator deleting a post (record, execution proof, the author's balance unchanged, and the control where the author deletes it and is refunded), every refusal of a deletion, an update adding a document type moderators can delete from, a permanent reference to such a type refused, the mempool refusal, the lapse sweep, the moderator set, every refusal code, the lists staying as the contract was created with them, a barred identity deleting its own documents in a block and in the mempool, a barred identity refused as the recipient of a transfer and as the seller of a purchase, the ban's proof covering the suspension it removed, lifting the entry of an identity an update made moderator, the per-list execution proof, a named owner, a create or an update naming a moderator that does not exist, an update keeping its moderators, and inactivity of the transition and of a moderated contract create or update before protocol version 14. diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 6b754e235e8..b20906d47f3 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -897,7 +897,7 @@ "type": "integer", "minimum": 1, "maximum": 4294967295, - "description": "For how many seconds after a document's last modification (`$updatedAt`) the contract's moderators may still delete it. Once block time is later than `$updatedAt` plus this many seconds the document is settled: no moderator can delete it any more, the contract owner included. A replace or a price update moves `$updatedAt` and opens the window again; a transfer or a purchase does not. Absent means no limit. Requires `canBeDeletedByModerators: true` and `$updatedAt` in `required`. Fixed when the document type is created. Says nothing about what a document's own owner may do. Available from protocol version 14." + "description": "For how many seconds after a document's last modification the contract's moderators may still delete it. The last modification is the document's `$updatedAt`, or its `$createdAt` on a type that carries no `$updatedAt`. Once block time is later than that plus this many seconds the document is settled: no moderator can delete it any more, the contract owner included. A replace or a price update moves `$updatedAt` and opens the window again; a transfer or a purchase does not. Absent means no limit. Requires `canBeDeletedByModerators: true` and `$updatedAt` in `required`; a type with `documentsMutable: false`, whose documents never change after their creation, may list `$createdAt` instead. Fixed when the document type is created. Says nothing about what a document's own owner may do. Available from protocol version 14." }, "indexOnly": { "type": "boolean", diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs index 7c904f213cb..f492facd064 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs @@ -40,7 +40,8 @@ pub trait DocumentTypeV2Getters { /// own owner may do. False on document types that predate the keyword. fn documents_can_be_deleted_by_moderators(&self) -> bool; - /// For how many seconds after a document's last modification (`$updatedAt`) + /// For how many seconds after a document's last modification (`$updatedAt`, + /// or `$createdAt` on a type that carries no `$updatedAt`) /// the moderators may still delete it (the `canBeDeletedByModeratorsFor` /// keyword, protocol version 14). `None` means no limit, and is what every /// document type that predates the keyword answers. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index 9954c719f83..caf79778b86 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -42,7 +42,7 @@ use crate::data_contract::document_type::{property_names, DocumentType}; use crate::data_contract::errors::DataContractError; use crate::data_contract::storage_requirements::keys_for_document_type::StorageKeyRequirements; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; -use crate::document::property_names::UPDATED_AT; +use crate::document::property_names::{CREATED_AT, UPDATED_AT}; use crate::document::transfer::Transferable; use crate::identity::SecurityLevel; use crate::nft::TradeMode; @@ -2085,9 +2085,13 @@ pub(super) fn parse_can_be_deleted_by_moderators_for_keyword( /// moderators may delete it, so: /// - the type must let moderators delete its documents at all, or the window /// would limit nothing; -/// - the type must require `$updatedAt`, the clock the window is measured on: -/// every document then carries it, set at creation and moved by every -/// replace; +/// - the type must require the clock the window is measured on. That is +/// `$updatedAt`, set at creation and moved by every replace, and where a type +/// does not carry it, `$createdAt`. A type whose documents can be replaced +/// must require `$updatedAt`: measured from creation alone, its author could +/// wait the window out and then rewrite the document into something no +/// moderator can remove any more. A type whose documents never change has +/// no modification after the creation, so `$createdAt` says as much; /// - it lasts at least a second: a window of none would be a type moderators /// can never delete from, which is said by not setting the flag. /// @@ -2122,10 +2126,20 @@ pub(super) fn apply_can_be_deleted_by_moderators_for( name, ))); } - if !document_type.required_fields.contains(UPDATED_AT) { + let requires_updated_at = document_type.required_fields.contains(UPDATED_AT); + if document_type.documents_mutable && !requires_updated_at { return Err(structure_error(format!( "document type \"{}\" sets `canBeDeletedByModeratorsFor`, which is measured from \ - a document's last modification: list `$updatedAt` in `required`", + a document's last modification, and its documents can be replaced: list \ + `$updatedAt` in `required`", + name, + ))); + } + if !requires_updated_at && !document_type.required_fields.contains(CREATED_AT) { + return Err(structure_error(format!( + "document type \"{}\" sets `canBeDeletedByModeratorsFor`, which is measured from \ + a document's last modification: list `$updatedAt`, or `$createdAt` for documents \ + that never change, in `required`", name, ))); } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs index 3af7ac2e4d6..36f8ab58762 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/moderators_delete_tests.rs @@ -245,11 +245,54 @@ fn should_refuse_a_window_on_a_type_moderators_can_not_delete_from() { } #[test] -fn should_refuse_a_window_on_a_type_that_does_not_require_updated_at() { +fn should_measure_the_window_of_documents_that_never_change_from_their_creation() { + // Nothing modifies such a document after it is created, so `$createdAt` is its last + // modification and `$updatedAt` is not needed. + let document_type = parse_moderated(windowed_schema(platform_value!({ + "documentsMutable": false, + "required": ["$createdAt"], + }))) + .expect("parse"); + assert_eq!( + document_type.documents_can_be_deleted_by_moderators_for(), + Some(86400) + ); +} + +#[test] +fn should_refuse_a_window_on_a_type_that_carries_no_clock() { + for mutable in [true, false] { + assert_refused_naming( + parse_moderated(windowed_schema(platform_value!({ + "documentsMutable": mutable, + "required": ["text"], + }))), + &["canBeDeletedByModeratorsFor", "$updatedAt"], + ); + } +} + +#[test] +fn should_refuse_a_window_measured_from_creation_on_documents_that_can_be_replaced() { + // Measured from creation alone, an author could wait the window out and then rewrite the + // document into something no moderator can remove any more. assert_refused_naming( - parse_moderated(windowed_schema(platform_value!({ "required": ["text"] }))), - &["canBeDeletedByModeratorsFor", "$updatedAt"], + parse_moderated(windowed_schema(platform_value!({ + "documentsMutable": true, + "required": ["$createdAt"], + }))), + &[ + "canBeDeletedByModeratorsFor", + "can be replaced", + "$updatedAt", + ], ); + // With both, `$updatedAt` is the clock and the type is fine. + parse_moderated(windowed_schema(platform_value!({ + "documentsMutable": true, + "required": ["$createdAt", "$updatedAt"], + }))) + .expect("parse"); } #[test] diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index c97a4b094ef..5ebdfc67648 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -151,7 +151,8 @@ pub(crate) mod property_names { /// for what the flag requires of the type and of the contract. pub const CAN_BE_DELETED_BY_MODERATORS: &str = "canBeDeletedByModerators"; /// Doctype-level limit on `canBeDeletedByModerators`: for how many seconds - /// after a document's last modification (`$updatedAt`) the moderators may + /// after a document's last modification (`$updatedAt`, or `$createdAt` on a + /// type whose documents never change and carry no `$updatedAt`) the moderators may /// still delete it. Past that the document is settled and no moderator can /// remove it; a replace moves `$updatedAt` and opens the window again. /// Absent means no limit. Meta-schema v3+ (protocol version 14). See diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index a41946bd462..9bfefc4259e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -133,12 +133,12 @@ pub struct DocumentTypeV2 { /// declares moderation, and refuses it on a type that keeps history, is /// indexOnly or restricts document creation. pub(in crate::data_contract) documents_can_be_deleted_by_moderators: bool, - /// For how many seconds after a document's last modification - /// (`$updatedAt`) the moderators may still delete it - /// (`canBeDeletedByModeratorsFor` keyword, protocol version 14). `None` - /// means no limit. Only ever `Some` beside - /// `documents_can_be_deleted_by_moderators`, on a type that requires - /// `$updatedAt` (`apply_can_be_deleted_by_moderators_for`). + /// For how many seconds after a document's last modification the moderators + /// may still delete it (`canBeDeletedByModeratorsFor` keyword, protocol + /// version 14). `None` means no limit. Only ever `Some` beside + /// `documents_can_be_deleted_by_moderators`, on a type that requires the + /// clock: `$updatedAt`, or `$createdAt` when its documents never change + /// (`apply_can_be_deleted_by_moderators_for`). pub(in crate::data_contract) documents_can_be_deleted_by_moderators_for: Option, } diff --git a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/document_moderation_window_elapsed_error.rs b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/document_moderation_window_elapsed_error.rs index 3b4f6982a94..370493042f8 100644 --- a/packages/rs-dpp/src/errors/consensus/state/contract_moderation/document_moderation_window_elapsed_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/contract_moderation/document_moderation_window_elapsed_error.rs @@ -26,7 +26,7 @@ use thiserror::Error; "Document {} on contract {} was last modified at {} and could be deleted by moderators for {} seconds after that, which block time {} is past", document_id, contract_id, - updated_at, + last_modified_at, window_seconds, block_time )] @@ -39,7 +39,7 @@ pub struct DocumentModerationWindowElapsedError { */ contract_id: Identifier, document_id: Identifier, - updated_at: TimestampMillis, + last_modified_at: TimestampMillis, window_seconds: u32, block_time: TimestampMillis, } @@ -48,14 +48,14 @@ impl DocumentModerationWindowElapsedError { pub fn new( contract_id: Identifier, document_id: Identifier, - updated_at: TimestampMillis, + last_modified_at: TimestampMillis, window_seconds: u32, block_time: TimestampMillis, ) -> Self { Self { contract_id, document_id, - updated_at, + last_modified_at, window_seconds, block_time, } @@ -69,9 +69,10 @@ impl DocumentModerationWindowElapsedError { self.document_id } - /// The document's last modification, in milliseconds - pub fn updated_at(&self) -> TimestampMillis { - self.updated_at + /// The document's last modification, in milliseconds: its `$updatedAt`, or its + /// `$createdAt` on a document type that carries no `$updatedAt` + pub fn last_modified_at(&self) -> TimestampMillis { + self.last_modified_at } /// For how long after it moderators could delete the document diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs index 1dd442de6f2..ca208f27fb3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs @@ -43,7 +43,7 @@ use drive::state_transition_action::StateTransitionAction; use std::sync::Arc; /// `canBeDeletedByModeratorsFor` is declared in seconds, as the other durations of a document -/// type are; block time and `$updatedAt` are in milliseconds. +/// type are; block time, `$updatedAt` and `$createdAt` are in milliseconds. const MILLIS_PER_SECOND: u64 = 1_000; pub(in crate::execution::validation::state_transition::state_transitions::contract_user_moderation) trait ContractUserModerationStateTransitionStateValidationV0 @@ -306,20 +306,25 @@ fn transform_document_deletion_v0( // A document type may give its moderators a window: so many seconds after a document's // last modification, past which the document is settled and no moderator deletes it (its - // own owner's deletion is `canBeDeleted`'s business, at any age). A replace - // moves `$updatedAt` and opens the window again, since what it wrote is new content. The - // type requires `$updatedAt`, so every document carries it; one that did not would read as - // modified at time zero, which is settled: the refusal that protects the author. + // own owner's deletion is `canBeDeleted`'s business, at any age). The last modification is + // `$updatedAt`, which a replace moves, opening the window again since what it wrote is new + // content; a type whose documents never change may carry `$createdAt` alone, and that is + // then the clock. The type requires one of the two, so every document carries it; one that + // carried neither would read as modified at time zero, which is settled: the refusal that + // protects the author. if let Some(window_seconds) = document_type.documents_can_be_deleted_by_moderators_for() { - let updated_at = document.updated_at().unwrap_or_default(); - let settled_at = - updated_at.saturating_add(u64::from(window_seconds).saturating_mul(MILLIS_PER_SECOND)); + let last_modified_at = document + .updated_at() + .or(document.created_at()) + .unwrap_or_default(); + let settled_at = last_modified_at + .saturating_add(u64::from(window_seconds).saturating_mul(MILLIS_PER_SECOND)); if block_info.time_ms > settled_at { return refuse( DocumentModerationWindowElapsedError::new( contract_id, document_id, - updated_at, + last_modified_at, window_seconds, block_info.time_ms, ) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs index ce6cede1a7d..179f093386a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs @@ -2418,6 +2418,57 @@ async fn should_open_the_window_again_when_the_author_modifies_the_post() { assert_eq!(removal.removed_at, edited_at + MODERATION_WINDOW_MS); } +#[tokio::test] +async fn should_measure_the_window_from_the_creation_of_a_post_that_never_changes() { + // An immutable type needs no `$updatedAt`: nothing modifies a post after it is created, so + // `$createdAt` is its last modification. + let setup = Setup::new_at_with( + Some(moderators_without_lists()), + PlatformVersion::latest(), + |contract| { + add_document_type( + contract, + POST, + post_schema_with(platform_value!({ + "canBeDeletedByModeratorsFor": MODERATION_WINDOW_SECONDS, + "documentsMutable": false, + "required": ["text", "$createdAt"], + })), + ) + }, + ) + .await; + + let transaction = setup.platform.drive.grove.start_transaction(); + let (in_time, create) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create, &transaction)); + let (settled, create) = setup.create_document_of_type(&setup.user, POST).await; + assert_success(&setup.process(&create, &transaction)); + + let delete = setup + .moderate(&setup.moderator, delete_action(POST, in_time.id())) + .await; + assert_success(&setup.process_at(&delete, BLOCK_TIME_MS + MODERATION_WINDOW_MS, &transaction)); + + let too_late = setup + .moderate(&setup.moderator, delete_action(POST, settled.id())) + .await; + let execution = setup.process_at( + &too_late, + BLOCK_TIME_MS + MODERATION_WINDOW_MS + 1, + &transaction, + ); + assert_paid_with_code(&execution, DOCUMENT_MODERATION_WINDOW_ELAPSED); + // The refusal names the creation time as the last modification. + assert!( + matches!(&execution, StateTransitionExecutionResult::PaidConsensusError { error, .. } + if matches!(error, ConsensusError::StateError( + StateError::DocumentModerationWindowElapsedError(e) + ) if e.last_modified_at() == BLOCK_TIME_MS)), + "expected the creation time as the last modification, got {execution:?}" + ); +} + #[tokio::test] async fn should_fix_the_window_of_a_document_type() { let setup = setup_with_a_moderation_window().await; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 52fa0ffbe63..16fb299b088 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -428,8 +428,9 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// The moderation method table, the verify table and the query table gain /// the document removal methods (`getContractDocumentRemovals`). /// `canBeDeletedByModeratorsFor` bounds the deletion in time: so many -/// seconds after a document's last modification (`$updatedAt`, which the -/// type must then require), past which no moderator deletes it, the +/// seconds after a document's last modification (`$updatedAt`, or +/// `$createdAt` on a type whose documents never change; the type must +/// require its clock), past which no moderator deletes it, the /// contract owner included (`DocumentModerationWindowElapsedError`); a /// document's own owner still deletes it as `canBeDeleted` allows. A /// replace opens the window again. Fixed with the type, like the flag.