diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index 8ea94db8eaf..7eeabd3acaf 100644 --- a/book/src/data-model/contract-moderation.md +++ b/book/src/data-model/contract-moderation.md @@ -293,6 +293,8 @@ The declaration lives in `packages/rs-dpp/src/data_contract/config/moderation/el **The interim block.** The batch transformer's `contract_moderation_gate` v0 runs it before the lists: on an elected contract whose interim is `NotYetUsable`, every document transition of a moderated document type, deletions included (nothing of those types was ever written), is refused, paid, with `ContractModeratedDocumentTypeNotYetUsableError` (41200) and its contract nonce bump, in a block and in the mempool. The lists are read only for the transitions on the other types, and not at all when nothing is left. The interim moderators of the other two kinds moderate through the same transition, the same gate and the same claim as the merged kinds; a moderation transition against a `NotYetUsable` contract fails as by a non-moderator (41101). +**Referencing an elected contract.** A document type that must point at a contract of this kind says so in its reference: `"refersTo": { "type": "contract", "contractRequirements": { "moderation": "elected" } }`. `contractRequirements` holds what the referenced contract must declare beyond existing, each key an aspect of the contract with a closed set of values (`moderation: "elected"` is the first). Consensus checks it when the referring document is written, against the contract it has already fetched for the existence check, so it costs no further read; a contract that exists but does not declare an elected team refuses the write, paid, with `ReferencedContractRequirementNotMetError` (40135), where a contract that does not exist is still 40120. A changed `contractRequirements` is an incompatible schema change on update, like the rest of a `refersTo`. The charter system contract's `targetContractId` is the first user. + **What comes next.** The charter system contract, applications and the election (new vote poll kinds), the seated team under the contract with its per-ability powers, charter-priced moderators amounts within the maximums, and challenges and amendments. Issue #4865 holds the design. ## Versioning Touchpoints 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 949d5016af1..2b5e528b794 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 @@ -132,6 +132,19 @@ "maxLength": 64, "pattern": "^[a-zA-Z0-9-_]{1,64}$" }, + "contractRequirements": { + "description": "contract references only: what the referenced contract must declare beyond existing, checked when the referring document is written against the contract already fetched for the existence check, so a requirement costs no further read. Each key names an aspect of the referenced contract and its value the requirement: moderation \"elected\" requires the contract to declare an elected moderation team. An unmet requirement refuses the write (ReferencedContractRequirementNotMetError, 40135)", + "type": "object", + "properties": { + "moderation": { + "enum": [ + "elected" + ] + } + }, + "minProperties": 1, + "additionalProperties": false + }, "keyIdProperty": { "description": "The property of the same document type whose value carries the referenced key id; the reference property's value carries the identity id", "type": "string", @@ -207,6 +220,18 @@ "keyIdProperty": false } } + }, + { + "if": { + "properties": { "type": { "const": "contract" } }, + "required": ["type"] + }, + "then": {}, + "else": { + "properties": { + "contractRequirements": false + } + } } ] }, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index d806fbe3552..798166ad26f 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -4,8 +4,8 @@ use crate::data_contract::document_type::v0::DocumentTypeV0; use crate::data_contract::document_type::v1::DocumentTypeV1; use crate::data_contract::document_type::{ is_referenced_system_agreement_property, is_referring_system_agreement_property, - property_names, DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, - DocumentType, + property_names, ContractReferenceModeration, ContractReferenceRequirements, DocumentProperty, + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentType, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; @@ -367,12 +367,25 @@ fn apply_property_reference_v0( let refers_to_map = refers_to_value.to_btree_ref_string_map()?; - let target = match refers_to_map + let reference_type = refers_to_map .get_str(property_names::TYPE) - .map_err(|e| DataContractError::ValueWrongType(e.to_string()))? + .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; + + // Requirements on the referenced contract belong to contract references alone + if reference_type != "contract" + && refers_to_map.contains_key(property_names::CONTRACT_REQUIREMENTS) { + return Err(DataContractError::InvalidContractStructure(format!( + "{} refersTo does not take contractRequirements", + reference_type + ))); + } + + let target = match reference_type { "identity" => DocumentPropertyReferenceTarget::Identity, - "contract" => DocumentPropertyReferenceTarget::Contract, + "contract" => DocumentPropertyReferenceTarget::Contract { + contract_requirements: parse_contract_reference_requirements(&refers_to_map)?, + }, "token" => DocumentPropertyReferenceTarget::Token, // The two document targets share one declaration shape; they differ // only in whether the referenced document type must forbid deletion, @@ -512,6 +525,47 @@ fn apply_property_reference_v0( Ok(DocumentPropertyType::IdentifierWithReference(target)) } +/// The `contractRequirements` of a `contract` reference: each key an aspect of the referenced +/// contract with a closed set of values, at least one when the object is given at all. +fn parse_contract_reference_requirements( + refers_to_map: &BTreeMap, +) -> Result { + let Some(fields_value) = refers_to_map.get(property_names::CONTRACT_REQUIREMENTS) else { + return Ok(ContractReferenceRequirements::default()); + }; + let fields_map = fields_value.to_btree_ref_string_map()?; + if fields_map.is_empty() { + return Err(DataContractError::InvalidContractStructure( + "contract refersTo contractRequirements must declare at least one requirement" + .to_string(), + )); + } + let mut fields = ContractReferenceRequirements::default(); + for (field, value) in fields_map { + match field.as_str() { + property_names::MODERATION => { + let name = value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "contract refersTo contractRequirements moderation must be a string" + .to_string(), + ) + })?; + fields.moderation = Some(ContractReferenceModeration::from_wire_name(name).ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "contract refersTo contractRequirements moderation {name:?} is unknown, expected \"elected\"" + )) + })?); + } + other => { + return Err(DataContractError::InvalidContractStructure(format!( + "contract refersTo contractRequirements {other:?} is unknown" + ))); + } + } + } + Ok(fields) +} + #[cfg(test)] mod tests { use super::*; @@ -986,6 +1040,97 @@ mod tests { ); } + fn contract_reference_schema(refers_to: serde_json::Value) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "targetContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": refers_to + } + }, + "required": [], + "additionalProperties": false + }) + } + + fn contract_reference_target(refers_to: serde_json::Value) -> DocumentPropertyType { + try_document_type_from_schema(contract_reference_schema(refers_to)) + .expect("should parse") + .as_ref() + .flattened_properties() + .get("targetContractId") + .map(|p| p.property_type.clone()) + .expect("property should be present") + } + + #[test] + fn should_parse_contract_refers_to_without_contract_requirements_as_no_requirement() { + assert_eq!( + contract_reference_target(json!({ "type": "contract" })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements::default(), + } + ) + ); + } + + #[test] + fn should_parse_contract_refers_to_requiring_elected_moderation() { + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractRequirements": { "moderation": "elected" } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + }, + } + ) + ); + } + + #[test] + fn should_reject_contract_requirements_that_are_empty_unknown_or_on_another_type() { + for (refers_to, fragment) in [ + ( + json!({ "type": "contract", "contractRequirements": {} }), + "at least one requirement", + ), + ( + json!({ "type": "contract", "contractRequirements": { "moderation": "appointed" } }), + "is unknown", + ), + ( + json!({ "type": "contract", "contractRequirements": { "moderation": 1 } }), + "must be a string", + ), + ( + json!({ "type": "contract", "contractRequirements": { "tokens": "any" } }), + "is unknown", + ), + ( + json!({ "type": "identity", "contractRequirements": { "moderation": "elected" } }), + "does not take contractRequirements", + ), + ] { + let err = try_document_type_from_schema(contract_reference_schema(refers_to.clone())) + .expect_err("should be refused"); + assert!( + err.to_string().contains(fragment), + "{refers_to}: expected {fragment:?}, got {err}" + ); + } + } + #[test] fn should_reject_permanent_document_refers_to_with_invalid_contract_id() { let err = try_document_type_from_schema(json!({ diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs index 790c0a6d7fa..4a955ea64d6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs @@ -1996,6 +1996,40 @@ mod tests { ); } } + #[test] + fn should_return_invalid_result_when_a_contract_reference_requirement_changes() { + let platform_version = PlatformVersion::latest(); + + for (old_fields, new_fields, changed_path) in [ + ( + platform_value!({ "type": "contract" }), + platform_value!({ "type": "contract", "contractRequirements": { "moderation": "elected" } }), + "/properties/toUserId/refersTo/contractRequirements", + ), + ( + platform_value!({ "type": "contract", "contractRequirements": { "moderation": "elected" } }), + platform_value!({ "type": "contract" }), + "/properties/toUserId/refersTo/contractRequirements", + ), + ] { + let old_document_type = + identifier_document_type(Some(old_fields), platform_version); + let new_document_type = + identifier_document_type(Some(new_fields), platform_version); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.property_path() == changed_path + ); + } + } } mod validate_byte_array_encoding { 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 158f230179b..0b69e95b725 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -107,6 +107,8 @@ pub(crate) mod property_names { pub const DOCUMENT_TYPE: &str = "documentType"; pub const KEY_ID_PROPERTY: &str = "keyIdProperty"; pub const PROPERTY_AGREEMENT: &str = "propertyAgreement"; + pub const CONTRACT_REQUIREMENTS: &str = "contractRequirements"; + pub const MODERATION: &str = "moderation"; pub const DOCUMENTS_COUNTABLE: &str = "documentsCountable"; pub const RANGE_COUNTABLE: &str = "rangeCountable"; /// Doctype-level flag naming the property whose values are summed into diff --git a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs index 239f4b9a505..577cd9d9553 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs @@ -10,9 +10,13 @@ use platform_serialization_derive::{ }; use crate::consensus::basic::decode::DecodingError; +use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::config::moderation::ContractModerators; use crate::data_contract::config::v1::DataContractConfigGettersV1; +use crate::data_contract::config::v2::DataContractConfigGettersV2; use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::property_names; +use crate::data_contract::DataContract; use crate::document::property_names::{CREATOR_ID, OWNER_ID}; use crate::prelude::TimestampMillis; use crate::ProtocolError; @@ -27,7 +31,7 @@ use platform_version::version::PlatformVersion; use rand::distributions::{Alphanumeric, Standard}; use rand::rngs::StdRng; use rand::Rng; -use serde::Serialize; +use serde::{Deserialize, Serialize}; pub mod array; @@ -84,6 +88,106 @@ pub struct ByteArrayPropertySizes { pub max_size: Option, } +/// What a `contract` reference requires of the contract it points at, beyond its existence. +/// +/// Declared as `refersTo: { "type": "contract", "contractRequirements": { ... } }`: each key names an +/// aspect of the referenced contract and its value the requirement on it. Consensus checks the +/// requirements when the referring document is written, against the contract it has already +/// fetched for the existence check, so a requirement costs no further read. An unmet one +/// refuses the write with `ReferencedContractRequirementNotMetError` (40135). +#[derive( + Debug, PartialEq, Eq, Clone, Default, Serialize, Deserialize, Encode, Decode, DecodeUntrusted, +)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ContractReferenceRequirements { + /// The moderation the referenced contract must declare. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub moderation: Option, +} + +/// The moderation a `contract` reference may require of the referenced contract. +#[derive( + Debug, PartialEq, Eq, Clone, Copy, Serialize, Deserialize, Encode, Decode, DecodeUntrusted, +)] +#[serde(rename_all = "camelCase")] +pub enum ContractReferenceModeration { + /// The contract declares an elected moderation team (`ContractModerators::Elected`), + /// whatever its interim and whether a team is seated yet. + Elected, +} + +impl ContractReferenceModeration { + /// The wire name, the value of `contractRequirements.moderation`. + pub fn as_str(&self) -> &'static str { + match self { + ContractReferenceModeration::Elected => "elected", + } + } + + /// The moderation a wire name names, `None` for any other name. + pub fn from_wire_name(name: &str) -> Option { + match name { + "elected" => Some(ContractReferenceModeration::Elected), + _ => None, + } + } + + /// Whether `contract` declares what this requires. + pub fn is_met_by(&self, contract: &DataContract) -> bool { + match self { + ContractReferenceModeration::Elected => { + contract.config().moderation().is_some_and(|moderation| { + matches!(moderation.moderators, ContractModerators::Elected(_)) + }) + } + } + } +} + +/// One requirement of a [`ContractReferenceRequirements`] declaration, named the way the +/// declaration spells it, for the error that reports it unmet. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum ContractReferenceRequirement { + Moderation(ContractReferenceModeration), +} + +impl ContractReferenceRequirement { + /// The `contractRequirements` key the requirement was declared under. + pub fn field(&self) -> &'static str { + match self { + ContractReferenceRequirement::Moderation(_) => property_names::MODERATION, + } + } + + /// The value the declaration requires, as spelled in the schema. + pub fn required(&self) -> &'static str { + match self { + ContractReferenceRequirement::Moderation(moderation) => moderation.as_str(), + } + } +} + +impl ContractReferenceRequirements { + /// Whether the declaration requires nothing beyond the contract's existence. + pub fn is_empty(&self) -> bool { + self.moderation.is_none() + } + + /// The requirements, in declaration order. + pub fn requirements(&self) -> impl Iterator + '_ { + self.moderation + .into_iter() + .map(ContractReferenceRequirement::Moderation) + } + + /// The first requirement `contract` does not meet, `None` when it meets them all. + pub fn first_unmet_by(&self, contract: &DataContract) -> Option { + self.requirements().find(|requirement| match requirement { + ContractReferenceRequirement::Moderation(moderation) => !moderation.is_met_by(contract), + }) + } +} + // This enum is embedded in consensus errors, so it is consensus-serialized. // @append_only #[derive( @@ -102,7 +206,15 @@ pub struct ByteArrayPropertySizes { #[serde(rename_all = "lowercase")] pub enum DocumentPropertyReferenceTarget { Identity, - Contract, + /// A data contract, which must exist when the referring document is written and meet the + /// declared [`ContractReferenceRequirements`], if any. + Contract { + #[serde( + default, + skip_serializing_if = "ContractReferenceRequirements::is_empty" + )] + contract_requirements: ContractReferenceRequirements, + }, Token, /// A document of a document type whose documents can never be deleted /// (`canBeDeleted: false`). Only such document types may be referenced: @@ -221,7 +333,7 @@ impl DocumentPropertyReferenceTarget { permanent: false, }), DocumentPropertyReferenceTarget::Identity - | DocumentPropertyReferenceTarget::Contract + | DocumentPropertyReferenceTarget::Contract { .. } | DocumentPropertyReferenceTarget::Token | DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => None, } @@ -267,7 +379,15 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DocumentPropertyReferenceTarget::Identity => write!(f, "identity"), - DocumentPropertyReferenceTarget::Contract => write!(f, "contract"), + DocumentPropertyReferenceTarget::Contract { + contract_requirements, + } => { + write!(f, "contract")?; + if let Some(moderation) = contract_requirements.moderation { + write!(f, " with {} moderation", moderation.as_str())?; + } + Ok(()) + } DocumentPropertyReferenceTarget::Token => write!(f, "token"), DocumentPropertyReferenceTarget::PermanentDocument { contract_id: Some(contract_id), @@ -7519,9 +7639,21 @@ mod tests { "identity" ); assert_eq!( - DocumentPropertyReferenceTarget::Contract.to_string(), + DocumentPropertyReferenceTarget::Contract { + contract_requirements: Default::default() + } + .to_string(), "contract" ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + }, + } + .to_string(), + "contract with elected moderation" + ); assert_eq!(DocumentPropertyReferenceTarget::Token.to_string(), "token"); assert_eq!( DocumentPropertyReferenceTarget::PermanentDocument { @@ -7603,7 +7735,9 @@ mod tests { fn reference_targets_are_exhaustively_mirrored() { let targets = [ DocumentPropertyReferenceTarget::Identity, - DocumentPropertyReferenceTarget::Contract, + DocumentPropertyReferenceTarget::Contract { + contract_requirements: Default::default(), + }, DocumentPropertyReferenceTarget::Token, DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, @@ -7624,7 +7758,7 @@ mod tests { // No `_ =>` arm: a new variant is a compile error. let json_tag = match target { DocumentPropertyReferenceTarget::Identity => "identity", - DocumentPropertyReferenceTarget::Contract => "contract", + DocumentPropertyReferenceTarget::Contract { .. } => "contract", DocumentPropertyReferenceTarget::Token => "token", DocumentPropertyReferenceTarget::PermanentDocument { .. } => "permanentDocument", DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => "identityPublicKey", diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index dd220c68b59..8048f541b18 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -358,6 +358,7 @@ impl ErrorWithCode for StateError { Self::DocumentActionFeeAgreementNotSetError(_) => 40132, Self::DocumentActionFeeAgreementMismatchError(_) => 40133, Self::DocumentActionFeeMultiplierNotToleratedError(_) => 40134, + Self::ReferencedContractRequirementNotMetError(_) => 40135, // Identity Errors: 40200-40299 Self::IdentityAlreadyExistsError(_) => 40200, diff --git a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs index b889f6bc30c..b22abf538ff 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs @@ -19,6 +19,7 @@ pub mod document_timestamps_are_equal_error; pub mod document_timestamps_mismatch_error; pub mod duplicate_unique_index_error; pub mod invalid_document_revision_error; +pub mod referenced_contract_requirement_not_met_error; pub mod referenced_document_property_agreement_invalid_error; pub mod referenced_document_property_mismatch_error; pub mod referenced_document_type_deletable_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_contract_requirement_not_met_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_contract_requirement_not_met_error.rs new file mode 100644 index 00000000000..ed221ae81a5 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_contract_requirement_not_met_error.rs @@ -0,0 +1,75 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +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( + "referenced contract {contract_id} for path {path} does not declare {field} {required}, which the reference requires" +)] +#[platform_serialize(unversioned)] +pub struct ReferencedContractRequirementNotMetError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + contract_id: Identifier, + field: String, + required: String, + path: String, +} + +impl ReferencedContractRequirementNotMetError { + pub fn new(contract_id: Identifier, field: String, required: String, path: String) -> Self { + Self { + contract_id, + field, + required, + path, + } + } + + /// The referenced contract, which exists but does not meet the requirement + pub fn contract_id(&self) -> &Identifier { + &self.contract_id + } + + /// The `contractRequirements` key of the requirement, `moderation` for one + pub fn field(&self) -> &str { + &self.field + } + + /// The value the reference requires, `elected` for one + pub fn required(&self) -> &str { + &self.required + } + + /// The referring property + pub fn path(&self) -> &str { + &self.path + } +} + +impl From for ConsensusError { + fn from(err: ReferencedContractRequirementNotMetError) -> Self { + Self::StateError(StateError::ReferencedContractRequirementNotMetError(err)) + } +} 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 c48ab728c22..a2793685e97 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -68,6 +68,7 @@ use crate::consensus::state::document::document_contest_not_required_error::Docu use crate::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; use crate::consensus::state::document::referenced_document_type_not_deletable_error::ReferencedDocumentTypeNotDeletableError; use crate::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; +use crate::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; use crate::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; use crate::consensus::state::document::referenced_identity_key_disabled_error::ReferencedIdentityKeyDisabledError; use crate::consensus::state::document::referenced_identity_key_not_found_error::ReferencedIdentityKeyNotFoundError; @@ -582,6 +583,10 @@ pub enum StateError { // Contested indexes resolved without a Lock choice (protocol version 14). #[error(transparent)] VoteChoiceNotAllowedForVotePollError(VoteChoiceNotAllowedForVotePollError), + + // Requirements on a referenced contract (protocol version 14). + #[error(transparent)] + ReferencedContractRequirementNotMetError(ReferencedContractRequirementNotMetError), } impl From for ConsensusError { @@ -1064,7 +1069,7 @@ mod tests { )), 141 ); - // Contested indexes without a Lock choice (protocol version 14): the tail of the enum. + // Contested indexes without a Lock choice (protocol version 14). assert_eq!( discriminant_of(StateError::VoteChoiceNotAllowedForVotePollError( VoteChoiceNotAllowedForVotePollError::new( @@ -1081,5 +1086,17 @@ mod tests { )), 142 ); + // Requirements on a referenced contract (protocol version 14): the tail of the enum. + assert_eq!( + discriminant_of(StateError::ReferencedContractRequirementNotMetError( + ReferencedContractRequirementNotMetError::new( + group_id, + "moderation".to_string(), + "elected".to_string(), + "targetContractId".to_string(), + ) + )), + 143 + ); } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs index f1136808a96..dc0b2fec5a8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs @@ -21,6 +21,7 @@ use dpp::errors::consensus::state::document::referenced_document_property_mismat use dpp::errors::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; use dpp::errors::consensus::state::document::referenced_document_type_not_deletable_error::ReferencedDocumentTypeNotDeletableError; use dpp::errors::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; +use dpp::errors::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; use dpp::errors::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; use dpp::errors::consensus::state::document::referenced_identity_key_disabled_error::ReferencedIdentityKeyDisabledError; use dpp::errors::consensus::state::document::referenced_identity_key_not_found_error::ReferencedIdentityKeyNotFoundError; @@ -249,7 +250,7 @@ fn validate_document_type_references_v0( is_changed_field(changed, key_id_property) } DocumentPropertyReferenceTarget::Identity - | DocumentPropertyReferenceTarget::Contract + | DocumentPropertyReferenceTarget::Contract { .. } | DocumentPropertyReferenceTarget::Token => false, }; if !is_changed_field(changed, path) && !bound_property_changed { @@ -280,7 +281,9 @@ fn validate_document_type_references_v0( .fetch_identity_revision(referenced_id, true, transaction, platform_version)? .is_some() } - DocumentPropertyReferenceTarget::Contract => { + DocumentPropertyReferenceTarget::Contract { + contract_requirements, + } => { let (fee, referenced_contract) = platform.drive.get_contract_with_fetch_info_and_fee( referenced_id, @@ -297,7 +300,28 @@ fn validate_document_type_references_v0( // The cost is added even if the referenced contract does not exist or was cached execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); - referenced_contract.is_some() + match referenced_contract { + None => false, + Some(fetch_info) => { + // The declaration's requirements are checked against the contract + // just fetched, so they cost no further read; the first unmet one + // refuses the write + if let Some(requirement) = + contract_requirements.first_unmet_by(&fetch_info.contract) + { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedContractRequirementNotMetError::new( + Identifier::from(referenced_id), + requirement.field().to_string(), + requirement.required().to_string(), + path.to_string(), + ) + .into(), + )); + } + true + } + } } DocumentPropertyReferenceTarget::Token => { // Token contract info is written for every token when its contract is diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index 0f464ee7ed6..382555a1187 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -28,6 +28,8 @@ mod creation_tests { use drive::query::vote_poll_vote_state_query::ContestedDocumentVotePollDriveQueryResultType::DocumentsAndVoteTally; use drive::query::vote_poll_vote_state_query::ResolvedContestedDocumentVotePollDriveQuery; use drive::util::test_helpers::setup_contract; + use crate::test::helpers::setup::TempPlatform; + use crate::rpc::core::MockCoreRPCLike; use crate::execution::validation::state_transition::state_transitions::tests::{add_contender_to_dpns_name_contest, create_dpns_identity_name_contest, create_dpns_name_contest_give_key_info, perform_votes_multi}; use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult::PaidConsensusError; @@ -5295,6 +5297,12 @@ mod creation_tests { /// references it since it is the one contract known to exist in state. const REFERENCE_VALIDATION_CONTRACT_REF_CONTRACT_ID: &str = "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd"; + const REFERENCE_VALIDATION_ELECTED_CONTRACT_REF_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-elected-contract-ref.json"; + /// The `id` of the elected-contract-reference fixture: the one contract in state in its + /// tests, and one that declares no moderation, so a reference to it is unmet. + const REFERENCE_VALIDATION_ELECTED_CONTRACT_REF_CONTRACT_ID: &str = + "9k3RE6kHNTsDmyXFwEPpiFQ3ipXfp5FuXGXpQ1rDHDJb"; const REFERENCE_VALIDATION_TOKEN_REF_CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-token-ref.json"; const REFERENCE_VALIDATION_OPTIONAL_CONTRACT_PATH: &str = @@ -5315,6 +5323,26 @@ mod creation_tests { ) -> StateTransitionExecutionResult where F: FnOnce(&mut Document, &ReferenceTargets), + { + run_reference_validation_creation_with_setup_and_mutator( + contract_path, + |_, _| Identifier::default(), + |document, targets, _| mutator(document, targets), + ) + .await + } + + /// Like `run_reference_validation_creation_with_mutator`, with a `setup` step that writes + /// whatever else the test needs into state before the contract, and hands the mutator an + /// id it produced (a contract's, say). + async fn run_reference_validation_creation_with_setup_and_mutator( + contract_path: &str, + setup: S, + mutator: F, + ) -> StateTransitionExecutionResult + where + S: FnOnce(&mut TempPlatform, &PlatformVersion) -> Identifier, + F: FnOnce(&mut Document, &ReferenceTargets, Identifier), { let platform_version = PlatformVersion::latest(); let mut platform = TestPlatformBuilder::new() @@ -5345,6 +5373,8 @@ mod creation_tests { token_id, }; + let setup_id = setup(&mut platform, platform_version); + let contract = setup_contract( &platform.drive, contract_path, @@ -5375,7 +5405,7 @@ mod creation_tests { .set_id_for_creation(message, &entropy.0, 2, platform_version) .expect("expected to set the document id"); - mutator(&mut document, &targets); + mutator(&mut document, &targets, setup_id); let documents_batch_create_transition = BatchTransition::new_document_creation_transition_from_document( @@ -5572,6 +5602,117 @@ mod creation_tests { ); } + /// A contract with an elected moderation team, for a reference that requires one. It is + /// written to state directly, the way the fixtures are, so the moderated type needs no + /// list behind it. + fn insert_elected_contract( + platform: &mut TempPlatform, + _platform_version: &PlatformVersion, + ) -> Identifier { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::config::moderation::{ + ContractModerationConfig, ContractModerators, ElectedModerators, InterimModerators, + ModerationAbility, DEFAULT_ELECTION_WINDOW_SECONDS, + }; + use std::collections::{BTreeMap, BTreeSet}; + + let contract = setup_contract( + &platform.drive, + REFERENCE_VALIDATION_CONTRACT_REF_CONTRACT_PATH, + Some([0xE1; 32]), + None, + Some(|contract: &mut DataContract| { + contract.set_config(contract.config().clone().with_moderation(Some( + ContractModerationConfig { + banlist: true, + suspensions: true, + warnings: false, + moderators: ContractModerators::Elected(Box::new(ElectedModerators { + join_window: DEFAULT_ELECTION_WINDOW_SECONDS, + vote_window: DEFAULT_ELECTION_WINDOW_SECONDS, + challenge_cool_down: 1_209_600, + moderated_document_types: BTreeMap::from([( + "message".to_string(), + BTreeSet::from([ModerationAbility::Ban]), + )]), + interim: InterimModerators::ContractOwner, + owner_protected: false, + })), + }, + ))); + }), + None, + None, + ); + contract.id() + } + + #[tokio::test] + async fn should_document_creation_fail_when_referenced_contract_is_not_elected_moderated() { + // The fixture contract itself exists in state and declares no moderation at all + let existing_contract_id = Identifier::from_string( + REFERENCE_VALIDATION_ELECTED_CONTRACT_REF_CONTRACT_ID, + Encoding::Base58, + ) + .expect("expected a valid contract id"); + + let result = run_reference_validation_creation_with_mutator( + REFERENCE_VALIDATION_ELECTED_CONTRACT_REF_CONTRACT_PATH, + |document, _| { + document.set("refContractId", existing_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError(ref e)), + .. + } if e.contract_id() == &existing_contract_id + && e.field() == "moderation" + && e.required() == "elected" + && e.path() == "refContractId" + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_required_elected_contract_missing() { + // A missing contract is still reported as missing, not as unmet + let result = run_reference_validation_creation_with_mutator( + REFERENCE_VALIDATION_ELECTED_CONTRACT_REF_CONTRACT_PATH, + |document, _| { + document.set("refContractId", Identifier::random().into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(_)), + .. + } + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_referenced_contract_is_elected_moderated() { + let result = run_reference_validation_creation_with_setup_and_mutator( + REFERENCE_VALIDATION_ELECTED_CONTRACT_REF_CONTRACT_PATH, + insert_elected_contract, + |document, _, elected_contract_id| { + document.set("refContractId", elected_contract_id.into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + #[tokio::test] async fn should_document_creation_succeed_with_nested_and_multiple_references() { let result = run_reference_validation_creation_with_mutator( diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-elected-contract-ref.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-elected-contract-ref.json new file mode 100644 index 00000000000..c7ff58554d1 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-elected-contract-ref.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "9k3RE6kHNTsDmyXFwEPpiFQ3ipXfp5FuXGXpQ1rDHDJb", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "refContractId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "contract", + "contractRequirements": { + "moderation": "elected" + } + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [ + "refContractId" + ], + "indices": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index e83cbe90de7..2dcdc60b4e3 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -526,6 +526,17 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// document id) for every resolution, where the shipped rule awarded the /// latest; DPNS contests ending from this version on follow the new rule. /// +/// 24. **Contract references may require elected moderation**: a `contract` +/// `refersTo` declaration may carry `contractRequirements`, what the referenced +/// contract must declare beyond existing, with `moderation: "elected"` as +/// the first requirement (meta-schema v3, `apply_property_reference` 0, +/// `ContractReferenceRequirements` on `DocumentPropertyReferenceTarget::Contract`). +/// The document reference validation checks it against the contract it +/// fetched for the existence check, so it costs no further read, and +/// refuses an unmet requirement with +/// `ReferencedContractRequirementNotMetError` (40135). A changed +/// `contractRequirements` is an incompatible schema change on update. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 91fa6068698..06fa5f6d474 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -158,6 +158,7 @@ use dpp::consensus::state::shielded::invalid_anchor_error::InvalidAnchorError; use dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShieldedProofError; use dpp::consensus::state::shielded::nullifier_already_spent_error::NullifierAlreadySpentError; use dpp::consensus::basic::state_transition::{StateTransitionNotActiveError, TransitionOverMaxInputsError, TransitionOverMaxOutputsError, InputWitnessCountMismatchError, TransitionNoInputsError, TransitionNoOutputsError, FeeStrategyEmptyError, FeeStrategyDuplicateError, FeeStrategyIndexOutOfBoundsError, FeeStrategyTooManyStepsError, InputBelowMinimumError, OutputBelowMinimumError, InputOutputBalanceMismatchError, OutputsNotGreaterThanInputsError, WithdrawalBalanceMismatchError, InsufficientFundingAmountError, InputsNotLessThanOutputsError, OutputAddressAlsoInputError, InvalidRemainderOutputCountError, WithdrawalBelowMinAmountError, ShieldedNoActionsError, ShieldedTooManyActionsError, ShieldedEmptyProofError, ShieldedZeroAnchorError, ShieldedInvalidValueBalanceError, ShieldedEncryptedNoteSizeMismatchError, ShieldedImplicitFeeCapExceededError, ShieldedInvalidDenominationError}; +use dpp::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; use dpp::consensus::state::voting::masternode_incorrect_voter_identity_id_error::MasternodeIncorrectVoterIdentityIdError; use dpp::consensus::state::voting::masternode_incorrect_voting_address_error::MasternodeIncorrectVotingAddressError; use dpp::consensus::state::voting::masternode_not_found_error::MasternodeNotFoundError; @@ -683,6 +684,9 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::VoteChoiceNotAllowedForVotePollError(e) => { generic_consensus_error!(VoteChoiceNotAllowedForVotePollError, e).into() } + StateError::ReferencedContractRequirementNotMetError(e) => { + generic_consensus_error!(ReferencedContractRequirementNotMetError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 5c59abe6315..ae2be344027 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -51,11 +51,15 @@ pub enum DocumentReferenceErrorCodeWasm { /// reference; a `permanentDocument` reference is the one for a type /// declaring `canBeDeleted: false`. ReferencedDocumentTypeNotDeletable = 40131, + /// The referenced contract exists but does not declare what the + /// reference's `contractRequirements` require of it, elected moderation for + /// one. + ReferencedContractRequirementNotMet = 40135, } impl DocumentReferenceErrorCodeWasm { /// The reference-validation error a code names, or `None` when the code - /// is neither in the 40120-40125 range nor 40131. + /// is not in the 40120-40125 range, 40131 or 40135. fn from_code(code: u32) -> Option { match code { 40120 => Some(Self::ReferencedEntityNotFound), @@ -65,6 +69,7 @@ impl DocumentReferenceErrorCodeWasm { 40124 => Some(Self::ReferencedIdentityKeyDisabled), 40125 => Some(Self::ReferencedKeyIdPropertyInvalid), 40131 => Some(Self::ReferencedDocumentTypeNotDeletable), + 40135 => Some(Self::ReferencedContractRequirementNotMet), _ => None, } } @@ -150,6 +155,7 @@ impl_wasm_type_info!(ConsensusErrorWasm, ConsensusError); #[cfg(test)] mod tests { use super::*; + use dpp::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; use dpp::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; use dpp::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; use dpp::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; @@ -215,6 +221,18 @@ mod tests { .into(), DocumentReferenceErrorCodeWasm::ReferencedEntityNotFound, ), + ( + StateError::ReferencedContractRequirementNotMetError( + ReferencedContractRequirementNotMetError::new( + id(), + "moderation".to_string(), + "elected".to_string(), + "targetContractId".to_string(), + ), + ) + .into(), + DocumentReferenceErrorCodeWasm::ReferencedContractRequirementNotMet, + ), ( StateError::ReferencedDocumentTypeNotFoundError( ReferencedDocumentTypeNotFoundError::new( diff --git a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index 7ff15ebf4f7..f359688bcf7 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -32,7 +32,17 @@ const DOCUMENT_PROPERTY_REFERENCE_TS: &'static str = r#" */ export type DocumentPropertyReferenceTarget = | { type: 'identity' } - | { type: 'contract' } + | { + type: 'contract'; + /** + * What the referenced contract must declare beyond existing, checked + * by consensus when the referring document is written against the + * contract fetched for the existence check: `moderation: 'elected'` + * requires an elected moderation team (code 40135 when unmet). + * Absent when the declaration carries no requirement. + */ + contractRequirements?: { moderation?: 'elected' }; + } | { type: 'token' } | { type: 'permanentDocument'; @@ -156,7 +166,7 @@ fn reference_to_js( let kind = match target { DocumentPropertyReferenceTarget::Identity => "identity", - DocumentPropertyReferenceTarget::Contract => "contract", + DocumentPropertyReferenceTarget::Contract { .. } => "contract", DocumentPropertyReferenceTarget::Token => "token", DocumentPropertyReferenceTarget::PermanentDocument { .. } => "permanentDocument", DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => "identityPublicKey", @@ -165,9 +175,23 @@ fn reference_to_js( set_field(&object, "type", &JsValue::from_str(kind), path)?; match target { - DocumentPropertyReferenceTarget::Identity - | DocumentPropertyReferenceTarget::Contract - | DocumentPropertyReferenceTarget::Token => {} + DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::Token => {} + DocumentPropertyReferenceTarget::Contract { + contract_requirements, + } => { + // Absent, not `{}`-valued, when the declaration requires nothing, + // matching the schema's own omission. + if let Some(moderation) = contract_requirements.moderation { + let fields = Object::new(); + set_field( + &fields, + "moderation", + &JsValue::from_str(moderation.as_str()), + path, + )?; + set_field(&object, "contractRequirements", &fields, path)?; + } + } DocumentPropertyReferenceTarget::PermanentDocument { contract_id, document_type_name,