From 1ae56e16f6a01699edecd8f2c04aacd937f2ca39 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 22 Sep 2026 11:29:01 +0700 Subject: [PATCH 1/2] feat(platform)!: contract references may require the referenced contract to declare elected moderation A `contract` refersTo declaration may carry `contractFields`, what the referenced contract must declare beyond existing, each key an aspect of the contract and its value the requirement: `moderation: "elected"` requires an elected moderation team. The document reference validation checks it against the contract it already fetched for the existence check, at no further read, and refuses an unmet requirement with a new state error (40135). A changed `contractFields` is an incompatible schema change on update. Co-Authored-By: Claude Fable 5.1 --- book/src/data-model/contract-moderation.md | 2 + .../document/v3/document-meta.json | 25 +++ .../class_methods/try_from_schema/mod.rs | 153 +++++++++++++++++- .../methods/validate_update/common/mod.rs | 34 ++++ .../src/data_contract/document_type/mod.rs | 2 + .../document_type/property/mod.rs | 143 +++++++++++++++- packages/rs-dpp/src/errors/consensus/codes.rs | 1 + .../errors/consensus/state/document/mod.rs | 1 + ...nced_contract_requirement_not_met_error.rs | 75 +++++++++ .../src/errors/consensus/state/state_error.rs | 19 ++- .../document_reference_validation/v0/mod.rs | 28 +++- .../batch/tests/document/creation.rs | 143 +++++++++++++++- ...idation-contract-elected-contract-ref.json | 38 +++++ .../rs-platform-version/src/version/v14.rs | 11 ++ .../src/errors/consensus/consensus_error.rs | 4 + packages/wasm-dpp2/src/consensus_error.rs | 20 ++- .../data_contract/document_type_reference.rs | 32 +++- 17 files changed, 707 insertions(+), 24 deletions(-) create mode 100644 packages/rs-dpp/src/errors/consensus/state/document/referenced_contract_requirement_not_met_error.rs create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-elected-contract-ref.json diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index 8ea94db8eaf..6f7d8a9f293 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", "contractFields": { "moderation": "elected" } }`. `contractFields` 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 `contractFields` 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..a877cfccfe7 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}$" }, + "contractFields": { + "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": { + "contractFields": 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..5b21e4489f1 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, ContractReferenceFields, ContractReferenceModeration, DocumentProperty, + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentType, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; @@ -367,12 +367,23 @@ 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_FIELDS) { + return Err(DataContractError::InvalidContractStructure(format!( + "{} refersTo does not take contractFields", + reference_type + ))); + } + + let target = match reference_type { "identity" => DocumentPropertyReferenceTarget::Identity, - "contract" => DocumentPropertyReferenceTarget::Contract, + "contract" => DocumentPropertyReferenceTarget::Contract { + contract_fields: parse_contract_reference_fields(&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 +523,45 @@ fn apply_property_reference_v0( Ok(DocumentPropertyType::IdentifierWithReference(target)) } +/// The `contractFields` 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_fields( + refers_to_map: &BTreeMap, +) -> Result { + let Some(fields_value) = refers_to_map.get(property_names::CONTRACT_FIELDS) else { + return Ok(ContractReferenceFields::default()); + }; + let fields_map = fields_value.to_btree_ref_string_map()?; + if fields_map.is_empty() { + return Err(DataContractError::InvalidContractStructure( + "contract refersTo contractFields must declare at least one requirement".to_string(), + )); + } + let mut fields = ContractReferenceFields::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 contractFields moderation must be a string".to_string(), + ) + })?; + fields.moderation = Some(ContractReferenceModeration::from_wire_name(name).ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "contract refersTo contractFields moderation {name:?} is unknown, expected \"elected\"" + )) + })?); + } + other => { + return Err(DataContractError::InvalidContractStructure(format!( + "contract refersTo contractFields {other:?} is unknown" + ))); + } + } + } + Ok(fields) +} + #[cfg(test)] mod tests { use super::*; @@ -986,6 +1036,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_fields_as_no_requirement() { + assert_eq!( + contract_reference_target(json!({ "type": "contract" })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_fields: ContractReferenceFields::default(), + } + ) + ); + } + + #[test] + fn should_parse_contract_refers_to_requiring_elected_moderation() { + assert_eq!( + contract_reference_target(json!({ + "type": "contract", + "contractFields": { "moderation": "elected" } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::Contract { + contract_fields: ContractReferenceFields { + moderation: Some(ContractReferenceModeration::Elected), + }, + } + ) + ); + } + + #[test] + fn should_reject_contract_fields_that_are_empty_unknown_or_on_another_type() { + for (refers_to, fragment) in [ + ( + json!({ "type": "contract", "contractFields": {} }), + "at least one requirement", + ), + ( + json!({ "type": "contract", "contractFields": { "moderation": "appointed" } }), + "is unknown", + ), + ( + json!({ "type": "contract", "contractFields": { "moderation": 1 } }), + "must be a string", + ), + ( + json!({ "type": "contract", "contractFields": { "tokens": "any" } }), + "is unknown", + ), + ( + json!({ "type": "identity", "contractFields": { "moderation": "elected" } }), + "does not take contractFields", + ), + ] { + 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..0229d5d79fd 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", "contractFields": { "moderation": "elected" } }), + "/properties/toUserId/refersTo/contractFields", + ), + ( + platform_value!({ "type": "contract", "contractFields": { "moderation": "elected" } }), + platform_value!({ "type": "contract" }), + "/properties/toUserId/refersTo/contractFields", + ), + ] { + 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..56105ffbcc8 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_FIELDS: &str = "contractFields"; + 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..ce282ba076b 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", "contractFields": { ... } }`: 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 ContractReferenceFields { + /// 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 `contractFields.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 [`ContractReferenceFields`] 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 `contractFields` 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 ContractReferenceFields { + /// 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,12 @@ 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 [`ContractReferenceFields`], if any. + Contract { + #[serde(default, skip_serializing_if = "ContractReferenceFields::is_empty")] + contract_fields: ContractReferenceFields, + }, Token, /// A document of a document type whose documents can never be deleted /// (`canBeDeleted: false`). Only such document types may be referenced: @@ -221,7 +330,7 @@ impl DocumentPropertyReferenceTarget { permanent: false, }), DocumentPropertyReferenceTarget::Identity - | DocumentPropertyReferenceTarget::Contract + | DocumentPropertyReferenceTarget::Contract { .. } | DocumentPropertyReferenceTarget::Token | DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => None, } @@ -267,7 +376,13 @@ 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_fields } => { + write!(f, "contract")?; + if let Some(moderation) = contract_fields.moderation { + write!(f, " with {} moderation", moderation.as_str())?; + } + Ok(()) + } DocumentPropertyReferenceTarget::Token => write!(f, "token"), DocumentPropertyReferenceTarget::PermanentDocument { contract_id: Some(contract_id), @@ -7519,9 +7634,21 @@ mod tests { "identity" ); assert_eq!( - DocumentPropertyReferenceTarget::Contract.to_string(), + DocumentPropertyReferenceTarget::Contract { + contract_fields: Default::default() + } + .to_string(), "contract" ); + assert_eq!( + DocumentPropertyReferenceTarget::Contract { + contract_fields: ContractReferenceFields { + moderation: Some(ContractReferenceModeration::Elected), + }, + } + .to_string(), + "contract with elected moderation" + ); assert_eq!(DocumentPropertyReferenceTarget::Token.to_string(), "token"); assert_eq!( DocumentPropertyReferenceTarget::PermanentDocument { @@ -7603,7 +7730,9 @@ mod tests { fn reference_targets_are_exhaustively_mirrored() { let targets = [ DocumentPropertyReferenceTarget::Identity, - DocumentPropertyReferenceTarget::Contract, + DocumentPropertyReferenceTarget::Contract { + contract_fields: Default::default(), + }, DocumentPropertyReferenceTarget::Token, DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, @@ -7624,7 +7753,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..44eb50cd573 --- /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 `contractFields` 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..c6cdbe1b96c 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,7 @@ fn validate_document_type_references_v0( .fetch_identity_revision(referenced_id, true, transaction, platform_version)? .is_some() } - DocumentPropertyReferenceTarget::Contract => { + DocumentPropertyReferenceTarget::Contract { contract_fields } => { let (fee, referenced_contract) = platform.drive.get_contract_with_fetch_info_and_fee( referenced_id, @@ -297,7 +298,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_fields.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..dcb01d0b958 --- /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", + "contractFields": { + "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..554912c7af7 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 `contractFields`, what the referenced +/// contract must declare beyond existing, with `moderation: "elected"` as +/// the first requirement (meta-schema v3, `apply_property_reference` 0, +/// `ContractReferenceFields` 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 +/// `contractFields` 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..07e9a05a22e 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 `contractFields` 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..f6c50f0d2be 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. + */ + contractFields?: { 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,21 @@ 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_fields } => { + // Absent, not `{}`-valued, when the declaration requires nothing, + // matching the schema's own omission. + if let Some(moderation) = contract_fields.moderation { + let fields = Object::new(); + set_field( + &fields, + "moderation", + &JsValue::from_str(moderation.as_str()), + path, + )?; + set_field(&object, "contractFields", &fields, path)?; + } + } DocumentPropertyReferenceTarget::PermanentDocument { contract_id, document_type_name, From cdd7b0ecd6aaf0482a7c22aa2220fcc3e8b4ab18 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 22 Sep 2026 11:38:25 +0700 Subject: [PATCH 2/2] refactor(dpp): name the contract reference's requirements contractRequirements The object holds what the referenced contract must declare, predicates and not field values, so the key, the types and the field say requirements. Co-Authored-By: Claude Fable 5.1 --- book/src/data-model/contract-moderation.md | 2 +- .../document/v3/document-meta.json | 4 +- .../class_methods/try_from_schema/mod.rs | 54 ++++++++++--------- .../methods/validate_update/common/mod.rs | 8 +-- .../src/data_contract/document_type/mod.rs | 2 +- .../document_type/property/mod.rs | 33 +++++++----- ...nced_contract_requirement_not_met_error.rs | 2 +- .../document_reference_validation/v0/mod.rs | 6 ++- ...idation-contract-elected-contract-ref.json | 2 +- .../rs-platform-version/src/version/v14.rs | 6 +-- packages/wasm-dpp2/src/consensus_error.rs | 2 +- .../data_contract/document_type_reference.rs | 10 ++-- 12 files changed, 72 insertions(+), 59 deletions(-) diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index 6f7d8a9f293..7eeabd3acaf 100644 --- a/book/src/data-model/contract-moderation.md +++ b/book/src/data-model/contract-moderation.md @@ -293,7 +293,7 @@ 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", "contractFields": { "moderation": "elected" } }`. `contractFields` 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 `contractFields` is an incompatible schema change on update, like the rest of a `refersTo`. The charter system contract's `targetContractId` is the first user. +**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. 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 a877cfccfe7..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,7 +132,7 @@ "maxLength": 64, "pattern": "^[a-zA-Z0-9-_]{1,64}$" }, - "contractFields": { + "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": { @@ -229,7 +229,7 @@ "then": {}, "else": { "properties": { - "contractFields": false + "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 5b21e4489f1..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,7 +4,7 @@ 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, ContractReferenceFields, ContractReferenceModeration, DocumentProperty, + property_names, ContractReferenceModeration, ContractReferenceRequirements, DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentType, }; use crate::data_contract::errors::DataContractError; @@ -372,9 +372,11 @@ fn apply_property_reference_v0( .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_FIELDS) { + if reference_type != "contract" + && refers_to_map.contains_key(property_names::CONTRACT_REQUIREMENTS) + { return Err(DataContractError::InvalidContractStructure(format!( - "{} refersTo does not take contractFields", + "{} refersTo does not take contractRequirements", reference_type ))); } @@ -382,7 +384,7 @@ fn apply_property_reference_v0( let target = match reference_type { "identity" => DocumentPropertyReferenceTarget::Identity, "contract" => DocumentPropertyReferenceTarget::Contract { - contract_fields: parse_contract_reference_fields(&refers_to_map)?, + contract_requirements: parse_contract_reference_requirements(&refers_to_map)?, }, "token" => DocumentPropertyReferenceTarget::Token, // The two document targets share one declaration shape; they differ @@ -523,38 +525,40 @@ fn apply_property_reference_v0( Ok(DocumentPropertyType::IdentifierWithReference(target)) } -/// The `contractFields` of a `contract` reference: each key an aspect of the referenced +/// 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_fields( +fn parse_contract_reference_requirements( refers_to_map: &BTreeMap, -) -> Result { - let Some(fields_value) = refers_to_map.get(property_names::CONTRACT_FIELDS) else { - return Ok(ContractReferenceFields::default()); +) -> 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 contractFields must declare at least one requirement".to_string(), + "contract refersTo contractRequirements must declare at least one requirement" + .to_string(), )); } - let mut fields = ContractReferenceFields::default(); + 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 contractFields moderation must be a string".to_string(), + "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 contractFields moderation {name:?} is unknown, expected \"elected\"" + "contract refersTo contractRequirements moderation {name:?} is unknown, expected \"elected\"" )) })?); } other => { return Err(DataContractError::InvalidContractStructure(format!( - "contract refersTo contractFields {other:?} is unknown" + "contract refersTo contractRequirements {other:?} is unknown" ))); } } @@ -1066,12 +1070,12 @@ mod tests { } #[test] - fn should_parse_contract_refers_to_without_contract_fields_as_no_requirement() { + 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_fields: ContractReferenceFields::default(), + contract_requirements: ContractReferenceRequirements::default(), } ) ); @@ -1082,11 +1086,11 @@ mod tests { assert_eq!( contract_reference_target(json!({ "type": "contract", - "contractFields": { "moderation": "elected" } + "contractRequirements": { "moderation": "elected" } })), DocumentPropertyType::IdentifierWithReference( DocumentPropertyReferenceTarget::Contract { - contract_fields: ContractReferenceFields { + contract_requirements: ContractReferenceRequirements { moderation: Some(ContractReferenceModeration::Elected), }, } @@ -1095,27 +1099,27 @@ mod tests { } #[test] - fn should_reject_contract_fields_that_are_empty_unknown_or_on_another_type() { + fn should_reject_contract_requirements_that_are_empty_unknown_or_on_another_type() { for (refers_to, fragment) in [ ( - json!({ "type": "contract", "contractFields": {} }), + json!({ "type": "contract", "contractRequirements": {} }), "at least one requirement", ), ( - json!({ "type": "contract", "contractFields": { "moderation": "appointed" } }), + json!({ "type": "contract", "contractRequirements": { "moderation": "appointed" } }), "is unknown", ), ( - json!({ "type": "contract", "contractFields": { "moderation": 1 } }), + json!({ "type": "contract", "contractRequirements": { "moderation": 1 } }), "must be a string", ), ( - json!({ "type": "contract", "contractFields": { "tokens": "any" } }), + json!({ "type": "contract", "contractRequirements": { "tokens": "any" } }), "is unknown", ), ( - json!({ "type": "identity", "contractFields": { "moderation": "elected" } }), - "does not take contractFields", + json!({ "type": "identity", "contractRequirements": { "moderation": "elected" } }), + "does not take contractRequirements", ), ] { let err = try_document_type_from_schema(contract_reference_schema(refers_to.clone())) 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 0229d5d79fd..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 @@ -2003,13 +2003,13 @@ mod tests { for (old_fields, new_fields, changed_path) in [ ( platform_value!({ "type": "contract" }), - platform_value!({ "type": "contract", "contractFields": { "moderation": "elected" } }), - "/properties/toUserId/refersTo/contractFields", + platform_value!({ "type": "contract", "contractRequirements": { "moderation": "elected" } }), + "/properties/toUserId/refersTo/contractRequirements", ), ( - platform_value!({ "type": "contract", "contractFields": { "moderation": "elected" } }), + platform_value!({ "type": "contract", "contractRequirements": { "moderation": "elected" } }), platform_value!({ "type": "contract" }), - "/properties/toUserId/refersTo/contractFields", + "/properties/toUserId/refersTo/contractRequirements", ), ] { let old_document_type = 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 56105ffbcc8..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,7 +107,7 @@ 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_FIELDS: &str = "contractFields"; + pub const CONTRACT_REQUIREMENTS: &str = "contractRequirements"; pub const MODERATION: &str = "moderation"; pub const DOCUMENTS_COUNTABLE: &str = "documentsCountable"; pub const RANGE_COUNTABLE: &str = "rangeCountable"; 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 ce282ba076b..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 @@ -90,7 +90,7 @@ pub struct ByteArrayPropertySizes { /// What a `contract` reference requires of the contract it points at, beyond its existence. /// -/// Declared as `refersTo: { "type": "contract", "contractFields": { ... } }`: each key names an +/// 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 @@ -99,7 +99,7 @@ pub struct ByteArrayPropertySizes { Debug, PartialEq, Eq, Clone, Default, Serialize, Deserialize, Encode, Decode, DecodeUntrusted, )] #[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct ContractReferenceFields { +pub struct ContractReferenceRequirements { /// The moderation the referenced contract must declare. #[serde(default, skip_serializing_if = "Option::is_none")] pub moderation: Option, @@ -117,7 +117,7 @@ pub enum ContractReferenceModeration { } impl ContractReferenceModeration { - /// The wire name, the value of `contractFields.moderation`. + /// The wire name, the value of `contractRequirements.moderation`. pub fn as_str(&self) -> &'static str { match self { ContractReferenceModeration::Elected => "elected", @@ -144,7 +144,7 @@ impl ContractReferenceModeration { } } -/// One requirement of a [`ContractReferenceFields`] declaration, named the way the +/// 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 { @@ -152,7 +152,7 @@ pub enum ContractReferenceRequirement { } impl ContractReferenceRequirement { - /// The `contractFields` key the requirement was declared under. + /// The `contractRequirements` key the requirement was declared under. pub fn field(&self) -> &'static str { match self { ContractReferenceRequirement::Moderation(_) => property_names::MODERATION, @@ -167,7 +167,7 @@ impl ContractReferenceRequirement { } } -impl ContractReferenceFields { +impl ContractReferenceRequirements { /// Whether the declaration requires nothing beyond the contract's existence. pub fn is_empty(&self) -> bool { self.moderation.is_none() @@ -207,10 +207,13 @@ impl ContractReferenceFields { pub enum DocumentPropertyReferenceTarget { Identity, /// A data contract, which must exist when the referring document is written and meet the - /// declared [`ContractReferenceFields`], if any. + /// declared [`ContractReferenceRequirements`], if any. Contract { - #[serde(default, skip_serializing_if = "ContractReferenceFields::is_empty")] - contract_fields: ContractReferenceFields, + #[serde( + default, + skip_serializing_if = "ContractReferenceRequirements::is_empty" + )] + contract_requirements: ContractReferenceRequirements, }, Token, /// A document of a document type whose documents can never be deleted @@ -376,9 +379,11 @@ 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 { contract_fields } => { + DocumentPropertyReferenceTarget::Contract { + contract_requirements, + } => { write!(f, "contract")?; - if let Some(moderation) = contract_fields.moderation { + if let Some(moderation) = contract_requirements.moderation { write!(f, " with {} moderation", moderation.as_str())?; } Ok(()) @@ -7635,14 +7640,14 @@ mod tests { ); assert_eq!( DocumentPropertyReferenceTarget::Contract { - contract_fields: Default::default() + contract_requirements: Default::default() } .to_string(), "contract" ); assert_eq!( DocumentPropertyReferenceTarget::Contract { - contract_fields: ContractReferenceFields { + contract_requirements: ContractReferenceRequirements { moderation: Some(ContractReferenceModeration::Elected), }, } @@ -7731,7 +7736,7 @@ mod tests { let targets = [ DocumentPropertyReferenceTarget::Identity, DocumentPropertyReferenceTarget::Contract { - contract_fields: Default::default(), + contract_requirements: Default::default(), }, DocumentPropertyReferenceTarget::Token, DocumentPropertyReferenceTarget::PermanentDocument { 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 index 44eb50cd573..ed221ae81a5 100644 --- 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 @@ -52,7 +52,7 @@ impl ReferencedContractRequirementNotMetError { &self.contract_id } - /// The `contractFields` key of the requirement, `moderation` for one + /// The `contractRequirements` key of the requirement, `moderation` for one pub fn field(&self) -> &str { &self.field } 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 c6cdbe1b96c..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 @@ -281,7 +281,9 @@ fn validate_document_type_references_v0( .fetch_identity_revision(referenced_id, true, transaction, platform_version)? .is_some() } - DocumentPropertyReferenceTarget::Contract { contract_fields } => { + DocumentPropertyReferenceTarget::Contract { + contract_requirements, + } => { let (fee, referenced_contract) = platform.drive.get_contract_with_fetch_info_and_fee( referenced_id, @@ -305,7 +307,7 @@ fn validate_document_type_references_v0( // just fetched, so they cost no further read; the first unmet one // refuses the write if let Some(requirement) = - contract_fields.first_unmet_by(&fetch_info.contract) + contract_requirements.first_unmet_by(&fetch_info.contract) { return Ok(SimpleConsensusValidationResult::new_with_error( ReferencedContractRequirementNotMetError::new( 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 index dcb01d0b958..c7ff58554d1 100644 --- 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 @@ -17,7 +17,7 @@ "position": 0, "refersTo": { "type": "contract", - "contractFields": { + "contractRequirements": { "moderation": "elected" } } diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 554912c7af7..2dcdc60b4e3 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -527,15 +527,15 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// 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 `contractFields`, what the referenced +/// `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, -/// `ContractReferenceFields` on `DocumentPropertyReferenceTarget::Contract`). +/// `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 -/// `contractFields` is an incompatible schema change on update. +/// `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 diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 07e9a05a22e..ae2be344027 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -52,7 +52,7 @@ pub enum DocumentReferenceErrorCodeWasm { /// declaring `canBeDeleted: false`. ReferencedDocumentTypeNotDeletable = 40131, /// The referenced contract exists but does not declare what the - /// reference's `contractFields` require of it, elected moderation for + /// reference's `contractRequirements` require of it, elected moderation for /// one. ReferencedContractRequirementNotMet = 40135, } 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 f6c50f0d2be..f359688bcf7 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -41,7 +41,7 @@ export type DocumentPropertyReferenceTarget = * requires an elected moderation team (code 40135 when unmet). * Absent when the declaration carries no requirement. */ - contractFields?: { moderation?: 'elected' }; + contractRequirements?: { moderation?: 'elected' }; } | { type: 'token' } | { @@ -176,10 +176,12 @@ fn reference_to_js( match target { DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::Token => {} - DocumentPropertyReferenceTarget::Contract { contract_fields } => { + DocumentPropertyReferenceTarget::Contract { + contract_requirements, + } => { // Absent, not `{}`-valued, when the declaration requires nothing, // matching the schema's own omission. - if let Some(moderation) = contract_fields.moderation { + if let Some(moderation) = contract_requirements.moderation { let fields = Object::new(); set_field( &fields, @@ -187,7 +189,7 @@ fn reference_to_js( &JsValue::from_str(moderation.as_str()), path, )?; - set_field(&object, "contractFields", &fields, path)?; + set_field(&object, "contractRequirements", &fields, path)?; } } DocumentPropertyReferenceTarget::PermanentDocument {