From 6dab3cf3f423f45c045b2fb0fd1b15400a2b7bf8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 5 Aug 2026 11:05:28 +0700 Subject: [PATCH 1/6] fix(dpp): make document type index update validation name-order independent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IndexLevel::validate_update compared index trees by level_identifier equality, but identifiers are assigned by an incrementing counter that walks the document type's indices — a BTreeMap keyed by index NAME. An index change whose name sorted before existing indexes renumbered every level and was rejected with an opaque "Invalid path", while the semantically identical change under a late-sorting name passed the tree comparison and instead hard-errored later in the JSON-schema compatibility check (which has no rule for the `indices` keyword), surfacing as an InternalError tx result. Which consensus outcome a contract owner got therefore depended purely on the index name string. At protocol v14, validate_update v1 replaces the tree comparison with a name-keyed comparison of the parsed index definitions: any added, removed or modified index is rejected with a deterministic DataContractInvalidIndexDefinitionUpdateError naming the offending index, independent of name sort order. The accepted set is unchanged — under v0 no index modification could ever pass the full pipeline, so this only makes the rejection deterministic and correctly labeled. Gated per the new-version-module convention: new DPP_VALIDATION_VERSIONS_V5 (document_type.validate_update: 1) wired into v14 only; the v0 flow is byte-for-byte frozen for protocol versions <= 13 and pinned by a regression test. Generation-independent helpers (validate_config, byte-array encoding stability, validate_schema) move verbatim to a shared common module, following the try_from_schema extraction precedent (#4276). Co-Authored-By: Claude Fable 5 --- .../methods/validate_update/common/mod.rs | 1565 ++++++++++++++++ .../methods/validate_update/mod.rs | 5 +- .../methods/validate_update/v0/mod.rs | 1641 +---------------- .../methods/validate_update/v1/mod.rs | 357 ++++ .../dpp_validation_versions/mod.rs | 1 + .../dpp_validation_versions/v5.rs | 22 + .../rs-platform-version/src/version/v14.rs | 4 +- 7 files changed, 2051 insertions(+), 1544 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs create mode 100644 packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs create mode 100644 packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs 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 new file mode 100644 index 0000000000..17061ea86e --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs @@ -0,0 +1,1565 @@ +//! Helpers shared by every generation of `DocumentTypeRef::validate_update` +//! (`v0`, `v1`, …). Only the parts of the update-validation flow that differ +//! between generations live in the per-version modules; the config, byte-array +//! encoding and JSON-schema compatibility checks below are generation +//! independent. + +use crate::consensus::basic::data_contract::IncompatibleDocumentTypeSchemaError; +use crate::consensus::state::data_contract::document_type_update_error::DocumentTypeUpdateError; +use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; +use crate::data_contract::document_type::property::{ByteArrayPropertySizes, DocumentPropertyType}; +use crate::data_contract::document_type::schema::validate_schema_compatibility; +use crate::data_contract::document_type::DocumentTypeRef; +use crate::data_contract::errors::DataContractError; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +impl DocumentTypeRef<'_> { + /// A byte array property whose `minItems == maxItems` is serialized as raw, + /// fixed-length bytes with no length prefix; any other size bounds make it + /// serialized with a variable-length (varint) length prefix. Crossing that + /// boundary -- or changing the fixed length itself -- silently changes the + /// on-disk layout of every already-stored document, so re-decoding old bytes + /// against the new type misreads them. JSON-schema compatibility treats + /// widening/removing `maxItems` as compatible, so this layout invariant must + /// be enforced separately. Runs before `validate_schema` so it cannot be + /// bypassed by a JSON-schema-compatible widening. + pub(super) fn validate_byte_array_encoding_stability( + &self, + new_document_type: DocumentTypeRef, + ) -> SimpleConsensusValidationResult { + // Mirror the encoder/decoder exactly (see `encode_value_ref_with_size`): + // the raw, no-length-prefix path is used ONLY when BOTH bounds are present + // and equal. Any other shape -- including an omitted `minItems` (`None`) -- + // is varint length-prefixed, so an implicit `minItems: 0` must NOT be + // treated as fixed-length here or this guard would diverge from the actual + // on-disk layout. `Some(n)` => fixed raw encoding of length `n`; `None` => + // variable (varint length-prefixed) encoding. + fn fixed_length(sizes: &ByteArrayPropertySizes) -> Option { + match (sizes.min_size, sizes.max_size) { + (Some(min), Some(max)) if min == max => Some(min), + _ => None, + } + } + + let new_properties = new_document_type.flattened_properties(); + + for (path, old_property) in self.flattened_properties() { + let DocumentPropertyType::ByteArray(old_sizes) = &old_property.property_type else { + continue; + }; + + let Some(new_property) = new_properties.get(path) else { + continue; + }; + + let DocumentPropertyType::ByteArray(new_sizes) = &new_property.property_type else { + continue; + }; + + if fixed_length(old_sizes) != fixed_length(new_sizes) { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change the byte array encoding of property \ + '{}': changing its size bounds from (minItems: {:?}, maxItems: {:?}) \ + to (minItems: {:?}, maxItems: {:?}) alters the on-disk layout of \ + existing documents", + path, + old_sizes.min_size, + old_sizes.max_size, + new_sizes.min_size, + new_sizes.max_size, + ), + ) + .into(), + ); + } + } + + SimpleConsensusValidationResult::new() + } + + pub(super) fn validate_config( + &self, + new_document_type: DocumentTypeRef, + ) -> SimpleConsensusValidationResult { + if new_document_type.creation_restriction_mode() != self.creation_restriction_mode() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change creation restriction mode: changing from {} to {}", + self.creation_restriction_mode(), + new_document_type.creation_restriction_mode() + ), + ) + .into(), + ); + } + + if new_document_type.trade_mode() != self.trade_mode() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change trade mode: changing from {} to {}", + self.trade_mode(), + new_document_type.trade_mode() + ), + ) + .into(), + ); + } + + if new_document_type.documents_transferable() != self.documents_transferable() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether its documents are transferable: changing from {} to {}", + self.documents_transferable(), + new_document_type.documents_transferable() + ), + ) + .into(), + ); + } + + if new_document_type.documents_can_be_deleted() != self.documents_can_be_deleted() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether its documents can be deleted: changing from {} to {}", + self.documents_can_be_deleted(), + new_document_type.documents_can_be_deleted() + ), + ) + .into(), + ); + } + + if new_document_type.documents_keep_history() != self.documents_keep_history() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether it keeps history: changing from {} to {}", + self.documents_keep_history(), + new_document_type.documents_keep_history() + ), + ) + .into(), + ); + } + + if new_document_type.documents_keep_transfer_history() + != self.documents_keep_transfer_history() + { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether it keeps transfer history: changing from {} to {}", + self.documents_keep_transfer_history(), + new_document_type.documents_keep_transfer_history() + ), + ) + .into(), + ); + } + + if new_document_type.documents_keep_purchase_history() + != self.documents_keep_purchase_history() + { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether it keeps purchase history: changing from {} to {}", + self.documents_keep_purchase_history(), + new_document_type.documents_keep_purchase_history() + ), + ) + .into(), + ); + } + + if new_document_type.documents_keep_pricing_history() + != self.documents_keep_pricing_history() + { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether it keeps pricing history: changing from {} to {}", + self.documents_keep_pricing_history(), + new_document_type.documents_keep_pricing_history() + ), + ) + .into(), + ); + } + + if new_document_type.documents_mutable() != self.documents_mutable() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether its documents are mutable: changing from {} to {}", + self.documents_mutable(), + new_document_type.documents_mutable() + ), + ) + .into(), + ); + } + + if new_document_type.requires_identity_encryption_bounded_key() + != self.requires_identity_encryption_bounded_key() + { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether it required an identity encryption bounded key: changing from {:?} to {:?}", + self.requires_identity_encryption_bounded_key(), + new_document_type.requires_identity_encryption_bounded_key() + ), + ) + .into(), + ); + } + + if new_document_type.requires_identity_decryption_bounded_key() + != self.requires_identity_decryption_bounded_key() + { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether it required an identity decryption bounded key: changing from {:?} to {:?}", + self.requires_identity_decryption_bounded_key(), + new_document_type.requires_identity_decryption_bounded_key() + ), + ) + .into(), + ); + } + + if new_document_type.security_level_requirement() != self.security_level_requirement() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change the security level requirement for its updates: changing from {:?} to {:?}", + self.security_level_requirement(), + new_document_type.security_level_requirement() + ), + ) + .into(), + ); + } + + if new_document_type.documents_countable() != self.documents_countable() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether its documents are countable: changing from {} to {}", + self.documents_countable(), + new_document_type.documents_countable() + ), + ) + .into(), + ); + } + + if new_document_type.range_countable() != self.range_countable() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether it is range countable: changing from {} to {}", + self.range_countable(), + new_document_type.range_countable() + ), + ) + .into(), + ); + } + + // Sum-tree immutability — parallels the count flags above. + // Two checks: (1) whether the doctype is summable at all (the + // presence/absence of `documents_summable`), and (2) the *name* of + // the summed property. Changing either invalidates every on-disk + // sum contribution because grovedb's sum trees aggregate `i64` + // per merk node — a renamed property would silently double-count + // or under-count depending on which document field gets read. + if new_document_type.documents_summable() != self.documents_summable() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether or how its documents are summable: changing from {:?} to {:?}", + self.documents_summable(), + new_document_type.documents_summable() + ), + ) + .into(), + ); + } + + if new_document_type.range_summable() != self.range_summable() { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change whether it is range summable: changing from {} to {}", + self.range_summable(), + new_document_type.range_summable() + ), + ) + .into(), + ); + } + + SimpleConsensusValidationResult::new() + } + + pub(super) fn validate_schema( + &self, + new_document_type: DocumentTypeRef, + platform_version: &PlatformVersion, + ) -> Result { + // All good if schema is the same + if self.schema() == new_document_type.schema() { + return Ok(SimpleConsensusValidationResult::new()); + } + + let old_document_schema_json = match self.schema().try_to_validating_json() { + Ok(json_value) => json_value, + Err(e) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + DataContractError::ValueDecodingError(format!( + "invalid existing json schema structure for document type {}: {e}", + self.name() + )) + .into(), + )); + } + }; + + let new_document_schema_json = match new_document_type.schema().try_to_validating_json() { + Ok(json_value) => json_value, + Err(e) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + DataContractError::ValueDecodingError(format!( + "invalid new json schema structure for document type {}: {e}", + self.name() + )) + .into(), + )); + } + }; + + let compatibility_validation_result = validate_schema_compatibility( + &old_document_schema_json, + &new_document_schema_json, + platform_version, + )?; + + // Convert the compatibility errors to consensus errors + let errors = compatibility_validation_result + .errors + .into_iter() + .map(|operation| { + IncompatibleDocumentTypeSchemaError::new( + self.name().clone(), + operation.name, + operation.path, + ) + .into() + }) + .collect(); + + Ok(SimpleConsensusValidationResult::new_with_errors(errors)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::consensus::state::state_error::StateError; + use crate::consensus::ConsensusError; + use crate::data_contract::config::DataContractConfig; + use crate::data_contract::document_type::DocumentType; + use assert_matches::assert_matches; + use platform_value::platform_value; + use platform_value::Identifier; + + mod validate_config { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn should_return_invalid_result_when_creation_restriction_mode_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "creationRestrictionMode": 1, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "creationRestrictionMode": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change creation restriction mode: changing from Owner Only to No Restrictions" + ); + } + + #[test] + fn should_return_invalid_result_when_trade_mode_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "tradeMode": 1, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "tradeMode": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change trade mode: changing from Direct Purchase to No Trading" + ); + } + + #[test] + fn should_return_invalid_result_when_documents_transferable_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "transferable": 1, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "transferable": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change whether its documents are transferable: changing from Always to Never" + ); + } + + #[test] + fn should_return_invalid_result_when_documents_can_be_deleted_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "canBeDeleted": true, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "canBeDeleted": false, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change whether its documents can be deleted: changing from true to false" + ); + } + + #[test] + fn should_return_invalid_result_when_documents_keep_history_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "documentsKeepHistory": true, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "documentsKeepHistory": false, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change whether it keeps history: changing from true to false" + ); + } + + #[test] + fn should_return_invalid_result_when_documents_mutable_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "documentsMutable": true, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "documentsMutable": false, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change whether its documents are mutable: changing from true to false" + ); + } + + #[test] + fn should_return_invalid_result_when_requires_identity_encryption_bounded_key_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "requiresIdentityEncryptionBoundedKey": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "requiresIdentityEncryptionBoundedKey": 1, + "additionalProperties": false, + }); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change whether it required an identity encryption bounded key: changing from Some(Unique) to Some(Multiple)" + ); + } + + #[test] + fn should_return_invalid_result_when_requires_identity_decryption_bounded_key_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "requiresIdentityDecryptionBoundedKey": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "requiresIdentityDecryptionBoundedKey": 2, + "additionalProperties": false, + }); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change whether it required an identity decryption bounded key: changing from Some(Unique) to Some(MultipleReferenceToLatest)" + ); + } + + #[test] + fn should_return_invalid_result_when_security_level_requirement_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "signatureSecurityLevelRequirement": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "signatureSecurityLevelRequirement": 1, + "additionalProperties": false, + }); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change the security level requirement for its updates: changing from MASTER to CRITICAL" + ); + } + + #[test] + fn should_return_invalid_result_when_documents_countable_is_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "documentsCountable": true, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "documentsCountable": false, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change whether its documents are countable: changing from true to false" + ); + } + + #[test] + fn should_return_invalid_result_when_range_countable_is_changed() { + // documents_countable must remain equal across old/new so that + // validate_config reaches the range_countable check below it. + // Setting documentsCountable: true on both keeps the + // documents_countable() getter true regardless of range_countable. + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "documentsCountable": true, + "rangeCountable": false, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "documentsCountable": true, + "rangeCountable": true, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == "document type can not change whether it is range countable: changing from false to true" + ); + } + } + + mod validate_schema { + use super::*; + use crate::consensus::basic::BasicError; + use std::collections::BTreeMap; + + #[test] + fn should_pass_when_schema_is_not_changed() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "signatureSecurityLevelRequirement": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema.clone(), + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert!(result.is_valid()); + } + + #[test] + fn should_return_invalid_result_when_schemas_are_not_backward_compatible() { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + "signatureSecurityLevelRequirement": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema.clone(), + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let schema = platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "number", + "position": 0, + } + }, + "signatureSecurityLevelRequirement": 0, + "additionalProperties": false, + }); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + 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.operation() == "replace" && e.property_path() == "/properties/test/type" + ); + } + } + + mod validate_byte_array_encoding { + use super::*; + use std::collections::BTreeMap; + + fn document_type_with_byte_array( + byte_array: platform_value::Value, + platform_version: &PlatformVersion, + ) -> DocumentType { + let schema = platform_value!({ + "type": "object", + "properties": { "blob": byte_array }, + "additionalProperties": false, + }); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + "test", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + // Exercises the PUBLIC `validate_update` dispatcher (latest protocol + // version), so it also covers the dispatch into the current + // generation (v1 as of protocol v14). + fn validate_update_latest( + old_ba: platform_value::Value, + new_ba: platform_value::Value, + ) -> SimpleConsensusValidationResult { + let platform_version = PlatformVersion::latest(); + let old = document_type_with_byte_array(old_ba, platform_version); + let new = document_type_with_byte_array(new_ba, platform_version); + old.as_ref() + .validate_update(new.as_ref(), platform_version) + .expect("validate_update should not error") + } + + fn assert_rejected(old_ba: platform_value::Value, new_ba: platform_value::Value) { + let result = validate_update_latest(old_ba, new_ba); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError(StateError::DocumentTypeUpdateError(e))] + if e.additional_message().contains("byte array encoding") + ); + } + + fn assert_accepted(old_ba: platform_value::Value, new_ba: platform_value::Value) { + let result = validate_update_latest(old_ba, new_ba); + assert!( + result.is_valid(), + "expected the update to be accepted, got {:?}", + result.errors + ); + } + + #[test] + fn rejects_widening_fixed_byte_array_max_items() { + // The exact attack: a fixed (raw, no length prefix) 32-byte field + // widened to min 32 / max 64 flips it to the varint length-prefixed + // encoding, making every already-stored document undecodable. + assert_rejected( + platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), + platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":64,"position":0}), + ); + } + + #[test] + fn rejects_removing_max_items_from_fixed_byte_array() { + // Removing `maxItems` turns a fixed (raw, no length prefix) byte array + // into a variable (varint length-prefixed) one, so it must be rejected. + assert_rejected( + platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), + platform_value!({"type":"array","byteArray":true,"minItems":32,"position":0}), + ); + } + + #[test] + fn rejects_changing_fixed_byte_array_size() { + // The byte-array check runs before validate_schema, so a fixed-size + // change is caught here as an encoding change. + assert_rejected( + platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), + platform_value!({"type":"array","byteArray":true,"minItems":64,"maxItems":64,"position":0}), + ); + } + + #[test] + fn rejects_tightening_variable_to_fixed_byte_array() { + // The reverse flip: a variable (varint length-prefixed) byte array + // narrowed to fixed (raw) also changes the on-disk layout -- old docs + // carry a length prefix the new fixed type would misread. + assert_rejected( + platform_value!({"type":"array","byteArray":true,"minItems":1,"maxItems":32,"position":0}), + platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), + ); + } + + #[test] + fn accepts_unchanged_fixed_byte_array() { + assert_accepted( + platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), + platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), + ); + } + + #[test] + fn accepts_widening_already_variable_byte_array() { + // Variable-length on both sides: the on-disk encoding does not change, + // so widening the bound stays allowed. + assert_accepted( + platform_value!({"type":"array","byteArray":true,"minItems":1,"maxItems":32,"position":0}), + platform_value!({"type":"array","byteArray":true,"minItems":1,"maxItems":64,"position":0}), + ); + } + + #[test] + fn accepts_max_items_change_when_min_items_is_omitted() { + // With `minItems` omitted (None) the encoder always uses the variable + // (varint length-prefixed) path regardless of `maxItems` -- the raw + // path requires BOTH bounds present and equal. So changing `maxItems` + // does not change the on-disk encoding and must stay allowed. An + // implicit `minItems: 0` is NOT fixed-length (mirrors the encoder). + assert_accepted( + platform_value!({"type":"array","byteArray":true,"maxItems":0,"position":0}), + platform_value!({"type":"array","byteArray":true,"maxItems":1,"position":0}), + ); + } + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/mod.rs index 8accc4522f..a8fd6b443c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/mod.rs @@ -3,7 +3,9 @@ use crate::validation::SimpleConsensusValidationResult; use crate::ProtocolError; use platform_version::version::PlatformVersion; +mod common; mod v0; +mod v1; impl DocumentTypeRef<'_> { /// Verify that the update to the document type is valid. @@ -20,9 +22,10 @@ impl DocumentTypeRef<'_> { .validate_update { 0 => self.validate_update_v0(new_document_type, platform_version), + 1 => self.validate_update_v1(new_document_type, platform_version), version => Err(ProtocolError::UnknownVersionMismatch { method: "validate_update".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs index e79860e7fa..075c2fb7af 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs @@ -1,12 +1,5 @@ -use crate::consensus::basic::data_contract::IncompatibleDocumentTypeSchemaError; -use crate::consensus::state::data_contract::document_type_update_error::DocumentTypeUpdateError; -use crate::data_contract::document_type::accessors::{ - DocumentTypeV0Getters, DocumentTypeV2Getters, -}; -use crate::data_contract::document_type::property::{ByteArrayPropertySizes, DocumentPropertyType}; -use crate::data_contract::document_type::schema::validate_schema_compatibility; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::DocumentTypeRef; -use crate::data_contract::errors::DataContractError; use crate::validation::SimpleConsensusValidationResult; use crate::ProtocolError; use platform_version::version::PlatformVersion; @@ -44,1548 +37,114 @@ impl DocumentTypeRef<'_> { // Validate schema compatibility self.validate_schema(new_document_type, platform_version) } - - /// A byte array property whose `minItems == maxItems` is serialized as raw, - /// fixed-length bytes with no length prefix; any other size bounds make it - /// serialized with a variable-length (varint) length prefix. Crossing that - /// boundary -- or changing the fixed length itself -- silently changes the - /// on-disk layout of every already-stored document, so re-decoding old bytes - /// against the new type misreads them. JSON-schema compatibility treats - /// widening/removing `maxItems` as compatible, so this layout invariant must - /// be enforced separately. Runs before `validate_schema` so it cannot be - /// bypassed by a JSON-schema-compatible widening. - fn validate_byte_array_encoding_stability( - &self, - new_document_type: DocumentTypeRef, - ) -> SimpleConsensusValidationResult { - // Mirror the encoder/decoder exactly (see `encode_value_ref_with_size`): - // the raw, no-length-prefix path is used ONLY when BOTH bounds are present - // and equal. Any other shape -- including an omitted `minItems` (`None`) -- - // is varint length-prefixed, so an implicit `minItems: 0` must NOT be - // treated as fixed-length here or this guard would diverge from the actual - // on-disk layout. `Some(n)` => fixed raw encoding of length `n`; `None` => - // variable (varint length-prefixed) encoding. - fn fixed_length(sizes: &ByteArrayPropertySizes) -> Option { - match (sizes.min_size, sizes.max_size) { - (Some(min), Some(max)) if min == max => Some(min), - _ => None, - } - } - - let new_properties = new_document_type.flattened_properties(); - - for (path, old_property) in self.flattened_properties() { - let DocumentPropertyType::ByteArray(old_sizes) = &old_property.property_type else { - continue; - }; - - let Some(new_property) = new_properties.get(path) else { - continue; - }; - - let DocumentPropertyType::ByteArray(new_sizes) = &new_property.property_type else { - continue; - }; - - if fixed_length(old_sizes) != fixed_length(new_sizes) { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change the byte array encoding of property \ - '{}': changing its size bounds from (minItems: {:?}, maxItems: {:?}) \ - to (minItems: {:?}, maxItems: {:?}) alters the on-disk layout of \ - existing documents", - path, - old_sizes.min_size, - old_sizes.max_size, - new_sizes.min_size, - new_sizes.max_size, - ), - ) - .into(), - ); - } - } - - SimpleConsensusValidationResult::new() - } - - fn validate_config( - &self, - new_document_type: DocumentTypeRef, - ) -> SimpleConsensusValidationResult { - if new_document_type.creation_restriction_mode() != self.creation_restriction_mode() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change creation restriction mode: changing from {} to {}", - self.creation_restriction_mode(), - new_document_type.creation_restriction_mode() - ), - ) - .into(), - ); - } - - if new_document_type.trade_mode() != self.trade_mode() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change trade mode: changing from {} to {}", - self.trade_mode(), - new_document_type.trade_mode() - ), - ) - .into(), - ); - } - - if new_document_type.documents_transferable() != self.documents_transferable() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether its documents are transferable: changing from {} to {}", - self.documents_transferable(), - new_document_type.documents_transferable() - ), - ) - .into(), - ); - } - - if new_document_type.documents_can_be_deleted() != self.documents_can_be_deleted() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether its documents can be deleted: changing from {} to {}", - self.documents_can_be_deleted(), - new_document_type.documents_can_be_deleted() - ), - ) - .into(), - ); - } - - if new_document_type.documents_keep_history() != self.documents_keep_history() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether it keeps history: changing from {} to {}", - self.documents_keep_history(), - new_document_type.documents_keep_history() - ), - ) - .into(), - ); - } - - if new_document_type.documents_keep_transfer_history() - != self.documents_keep_transfer_history() - { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether it keeps transfer history: changing from {} to {}", - self.documents_keep_transfer_history(), - new_document_type.documents_keep_transfer_history() - ), - ) - .into(), - ); - } - - if new_document_type.documents_keep_purchase_history() - != self.documents_keep_purchase_history() - { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether it keeps purchase history: changing from {} to {}", - self.documents_keep_purchase_history(), - new_document_type.documents_keep_purchase_history() - ), - ) - .into(), - ); - } - - if new_document_type.documents_keep_pricing_history() - != self.documents_keep_pricing_history() - { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether it keeps pricing history: changing from {} to {}", - self.documents_keep_pricing_history(), - new_document_type.documents_keep_pricing_history() - ), - ) - .into(), - ); - } - - if new_document_type.documents_mutable() != self.documents_mutable() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether its documents are mutable: changing from {} to {}", - self.documents_mutable(), - new_document_type.documents_mutable() - ), - ) - .into(), - ); - } - - if new_document_type.requires_identity_encryption_bounded_key() - != self.requires_identity_encryption_bounded_key() - { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether it required an identity encryption bounded key: changing from {:?} to {:?}", - self.requires_identity_encryption_bounded_key(), - new_document_type.requires_identity_encryption_bounded_key() - ), - ) - .into(), - ); - } - - if new_document_type.requires_identity_decryption_bounded_key() - != self.requires_identity_decryption_bounded_key() - { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether it required an identity decryption bounded key: changing from {:?} to {:?}", - self.requires_identity_decryption_bounded_key(), - new_document_type.requires_identity_decryption_bounded_key() - ), - ) - .into(), - ); - } - - if new_document_type.security_level_requirement() != self.security_level_requirement() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change the security level requirement for its updates: changing from {:?} to {:?}", - self.security_level_requirement(), - new_document_type.security_level_requirement() - ), - ) - .into(), - ); - } - - if new_document_type.documents_countable() != self.documents_countable() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether its documents are countable: changing from {} to {}", - self.documents_countable(), - new_document_type.documents_countable() - ), - ) - .into(), - ); - } - - if new_document_type.range_countable() != self.range_countable() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether it is range countable: changing from {} to {}", - self.range_countable(), - new_document_type.range_countable() - ), - ) - .into(), - ); - } - - // Sum-tree immutability — parallels the count flags above. - // Two checks: (1) whether the doctype is summable at all (the - // presence/absence of `documents_summable`), and (2) the *name* of - // the summed property. Changing either invalidates every on-disk - // sum contribution because grovedb's sum trees aggregate `i64` - // per merk node — a renamed property would silently double-count - // or under-count depending on which document field gets read. - if new_document_type.documents_summable() != self.documents_summable() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether or how its documents are summable: changing from {:?} to {:?}", - self.documents_summable(), - new_document_type.documents_summable() - ), - ) - .into(), - ); - } - - if new_document_type.range_summable() != self.range_summable() { - return SimpleConsensusValidationResult::new_with_error( - DocumentTypeUpdateError::new( - self.data_contract_id(), - self.name(), - format!( - "document type can not change whether it is range summable: changing from {} to {}", - self.range_summable(), - new_document_type.range_summable() - ), - ) - .into(), - ); - } - - SimpleConsensusValidationResult::new() - } - - fn validate_schema( - &self, - new_document_type: DocumentTypeRef, - platform_version: &PlatformVersion, - ) -> Result { - // All good if schema is the same - if self.schema() == new_document_type.schema() { - return Ok(SimpleConsensusValidationResult::new()); - } - - let old_document_schema_json = match self.schema().try_to_validating_json() { - Ok(json_value) => json_value, - Err(e) => { - return Ok(SimpleConsensusValidationResult::new_with_error( - DataContractError::ValueDecodingError(format!( - "invalid existing json schema structure for document type {}: {e}", - self.name() - )) - .into(), - )); - } - }; - - let new_document_schema_json = match new_document_type.schema().try_to_validating_json() { - Ok(json_value) => json_value, - Err(e) => { - return Ok(SimpleConsensusValidationResult::new_with_error( - DataContractError::ValueDecodingError(format!( - "invalid new json schema structure for document type {}: {e}", - self.name() - )) - .into(), - )); - } - }; - - let compatibility_validation_result = validate_schema_compatibility( - &old_document_schema_json, - &new_document_schema_json, - platform_version, - )?; - - // Convert the compatibility errors to consensus errors - let errors = compatibility_validation_result - .errors - .into_iter() - .map(|operation| { - IncompatibleDocumentTypeSchemaError::new( - self.name().clone(), - operation.name, - operation.path, - ) - .into() - }) - .collect(); - - Ok(SimpleConsensusValidationResult::new_with_errors(errors)) - } } #[cfg(test)] mod tests { - use super::*; - - use crate::consensus::state::state_error::StateError; + use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::DocumentType; use assert_matches::assert_matches; - use platform_value::platform_value; - use platform_value::Identifier; - - mod validate_config { - use super::*; - use std::collections::BTreeMap; - - #[test] - fn should_return_invalid_result_when_creation_restriction_mode_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "creationRestrictionMode": 1, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "creationRestrictionMode": 0, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change creation restriction mode: changing from Owner Only to No Restrictions" - ); - } - - #[test] - fn should_return_invalid_result_when_trade_mode_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "tradeMode": 1, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "tradeMode": 0, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change trade mode: changing from Direct Purchase to No Trading" - ); - } - - #[test] - fn should_return_invalid_result_when_documents_transferable_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "transferable": 1, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "transferable": 0, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change whether its documents are transferable: changing from Always to Never" - ); - } - - #[test] - fn should_return_invalid_result_when_documents_can_be_deleted_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "canBeDeleted": true, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "canBeDeleted": false, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change whether its documents can be deleted: changing from true to false" - ); - } - - #[test] - fn should_return_invalid_result_when_documents_keep_history_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "documentsKeepHistory": true, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "documentsKeepHistory": false, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change whether it keeps history: changing from true to false" - ); - } - - #[test] - fn should_return_invalid_result_when_documents_mutable_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "documentsMutable": true, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "documentsMutable": false, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change whether its documents are mutable: changing from true to false" - ); - } - - #[test] - fn should_return_invalid_result_when_requires_identity_encryption_bounded_key_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "requiresIdentityEncryptionBoundedKey": 0, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "requiresIdentityEncryptionBoundedKey": 1, - "additionalProperties": false, - }); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change whether it required an identity encryption bounded key: changing from Some(Unique) to Some(Multiple)" - ); - } - - #[test] - fn should_return_invalid_result_when_requires_identity_decryption_bounded_key_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "requiresIdentityDecryptionBoundedKey": 0, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "requiresIdentityDecryptionBoundedKey": 2, - "additionalProperties": false, - }); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change whether it required an identity decryption bounded key: changing from Some(Unique) to Some(MultipleReferenceToLatest)" - ); - } - - #[test] - fn should_return_invalid_result_when_security_level_requirement_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "signatureSecurityLevelRequirement": 0, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "signatureSecurityLevelRequirement": 1, - "additionalProperties": false, - }); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change the security level requirement for its updates: changing from MASTER to CRITICAL" - ); - } - - #[test] - fn should_return_invalid_result_when_documents_countable_is_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "documentsCountable": true, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "documentsCountable": false, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change whether its documents are countable: changing from true to false" - ); - } - - #[test] - fn should_return_invalid_result_when_range_countable_is_changed() { - // documents_countable must remain equal across old/new so that - // validate_config reaches the range_countable check below it. - // Setting documentsCountable: true on both keeps the - // documents_countable() getter true regardless of range_countable. - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "documentsCountable": true, - "rangeCountable": false, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "documentsCountable": true, - "rangeCountable": true, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_config(new_document_type.as_ref()); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError( - StateError::DocumentTypeUpdateError(e) - )] if e.additional_message() == "document type can not change whether it is range countable: changing from false to true" - ); - } - } - - mod validate_schema { - use super::*; - use crate::consensus::basic::BasicError; - use std::collections::BTreeMap; - - #[test] - fn should_pass_when_schema_is_not_changed() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "signatureSecurityLevelRequirement": 0, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema.clone(), - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - let result = old_document_type - .as_ref() - .validate_schema(new_document_type.as_ref(), platform_version) - .expect("failed to validate schema compatibility"); - - assert!(result.is_valid()); - } - - #[test] - fn should_return_invalid_result_when_schemas_are_not_backward_compatible() { - let platform_version = PlatformVersion::latest(); - let data_contract_id = Identifier::random(); - let document_type_name = "test"; - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "string", - "position": 0, - } - }, - "signatureSecurityLevelRequirement": 0, - "additionalProperties": false, - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - let old_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema.clone(), - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create old document type"); - - let schema = platform_value!({ - "type": "object", - "properties": { - "test": { - "type": "number", - "position": 0, - } - }, - "signatureSecurityLevelRequirement": 0, - "additionalProperties": false, - }); - - let new_document_type = DocumentType::try_from_schema( - data_contract_id, - 1, - config.version(), - document_type_name, - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create new document type"); - - 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.operation() == "replace" && e.property_path() == "/properties/test/type" - ); - } + use platform_value::{platform_value, Identifier, Value}; + use platform_version::version::PlatformVersion; + use std::collections::BTreeMap; + + fn doc_type_with_indices(indices: Value, platform_version: &PlatformVersion) -> DocumentType { + let schema = platform_value!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + "b": {"type": "string", "position": 1, "maxLength": 60_u32}, + "c": {"type": "string", "position": 2, "maxLength": 60_u32}, + }, + "indices": indices, + "additionalProperties": false, + }); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "test", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") } - mod validate_byte_array_encoding { - use super::*; - use std::collections::BTreeMap; - - fn document_type_with_byte_array( - byte_array: platform_value::Value, - platform_version: &PlatformVersion, - ) -> DocumentType { - let schema = platform_value!({ - "type": "object", - "properties": { "blob": byte_array }, - "additionalProperties": false, - }); - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - DocumentType::try_from_schema( - Identifier::random(), - 1, - config.version(), - "test", - schema, - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create document type") - } - - // Exercises the PUBLIC `validate_update` dispatcher (latest protocol - // version), so it also covers the dispatch into v0. - fn validate_update_latest( - old_ba: platform_value::Value, - new_ba: platform_value::Value, - ) -> SimpleConsensusValidationResult { - let platform_version = PlatformVersion::latest(); - let old = document_type_with_byte_array(old_ba, platform_version); - let new = document_type_with_byte_array(new_ba, platform_version); - old.as_ref() - .validate_update(new.as_ref(), platform_version) - .expect("validate_update should not error") - } - - fn assert_rejected(old_ba: platform_value::Value, new_ba: platform_value::Value) { - let result = validate_update_latest(old_ba, new_ba); - assert_matches!( - result.errors.as_slice(), - [ConsensusError::StateError(StateError::DocumentTypeUpdateError(e))] - if e.additional_message().contains("byte array encoding") - ); - } - - fn assert_accepted(old_ba: platform_value::Value, new_ba: platform_value::Value) { - let result = validate_update_latest(old_ba, new_ba); - assert!( - result.is_valid(), - "expected the update to be accepted, got {:?}", - result.errors - ); - } - - #[test] - fn rejects_widening_fixed_byte_array_max_items() { - // The exact attack: a fixed (raw, no length prefix) 32-byte field - // widened to min 32 / max 64 flips it to the varint length-prefixed - // encoding, making every already-stored document undecodable. - assert_rejected( - platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), - platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":64,"position":0}), - ); - } - - #[test] - fn rejects_removing_max_items_from_fixed_byte_array() { - // Removing `maxItems` turns a fixed (raw, no length prefix) byte array - // into a variable (varint length-prefixed) one, so it must be rejected. - assert_rejected( - platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), - platform_value!({"type":"array","byteArray":true,"minItems":32,"position":0}), - ); - } - - #[test] - fn rejects_changing_fixed_byte_array_size() { - // The byte-array check runs before validate_schema, so a fixed-size - // change is caught here as an encoding change. - assert_rejected( - platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), - platform_value!({"type":"array","byteArray":true,"minItems":64,"maxItems":64,"position":0}), - ); - } - - #[test] - fn rejects_tightening_variable_to_fixed_byte_array() { - // The reverse flip: a variable (varint length-prefixed) byte array - // narrowed to fixed (raw) also changes the on-disk layout -- old docs - // carry a length prefix the new fixed type would misread. - assert_rejected( - platform_value!({"type":"array","byteArray":true,"minItems":1,"maxItems":32,"position":0}), - platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), - ); - } - - #[test] - fn accepts_unchanged_fixed_byte_array() { - assert_accepted( - platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), - platform_value!({"type":"array","byteArray":true,"minItems":32,"maxItems":32,"position":0}), - ); - } - - #[test] - fn accepts_widening_already_variable_byte_array() { - // Variable-length on both sides: the on-disk encoding does not change, - // so widening the bound stays allowed. - assert_accepted( - platform_value!({"type":"array","byteArray":true,"minItems":1,"maxItems":32,"position":0}), - platform_value!({"type":"array","byteArray":true,"minItems":1,"maxItems":64,"position":0}), - ); - } - - #[test] - fn accepts_max_items_change_when_min_items_is_omitted() { - // With `minItems` omitted (None) the encoder always uses the variable - // (varint length-prefixed) path regardless of `maxItems` -- the raw - // path requires BOTH bounds present and equal. So changing `maxItems` - // does not change the on-disk encoding and must stay allowed. An - // implicit `minItems: 0` is NOT fixed-length (mirrors the encoder). - assert_accepted( - platform_value!({"type":"array","byteArray":true,"maxItems":0,"position":0}), - platform_value!({"type":"array","byteArray":true,"maxItems":1,"position":0}), - ); - } + // Pins the frozen v0 behavior that motivated v1 (protocol v14): the + // `IndexLevel` tree numbers its levels with a counter that follows the + // iteration order of `indices` — a BTreeMap keyed by index NAME — so the + // outcome of a semantically identical index addition depends on where the + // new index's name sorts relative to existing ones. + // + // Old contract: "j" on [c], "k" on [a, b]. Adding an index on [a] (a + // prefix of "k", terminating at an existing tree level): + // - named "i" (sorts first): every level is renumbered, so the + // identifier-equality subset check rejects with an opaque + // "a -> Invalid path". + // - named "z" (sorts last): the numbering is unchanged, the tree + // comparison passes, and the update falls through to the JSON-schema + // compatibility check, which hard-errors because no compatibility rule + // exists for the `indices` keyword. + // + // v0 stays byte-for-byte at this behavior for replay of protocol + // versions <= 13; v1 replaces the tree comparison with a name-keyed + // index-definition comparison. + #[test] + fn v0_index_addition_outcome_depends_on_index_name_sort_order() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13 must exist"); + + let old = doc_type_with_indices( + platform_value!([ + {"name": "j", "properties": [{"c": "asc"}]}, + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + ]), + platform_version, + ); + + let new_early_name = doc_type_with_indices( + platform_value!([ + {"name": "i", "properties": [{"a": "asc"}]}, + {"name": "j", "properties": [{"c": "asc"}]}, + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + ]), + platform_version, + ); + + let new_late_name = doc_type_with_indices( + platform_value!([ + {"name": "j", "properties": [{"c": "asc"}]}, + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + {"name": "z", "properties": [{"a": "asc"}]}, + ]), + platform_version, + ); + + let early_result = old + .as_ref() + .validate_update(new_early_name.as_ref(), platform_version) + .expect("early-name addition should produce a validation result"); + + assert_matches!( + early_result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidIndexDefinitionUpdateError(e) + )] if e.index_path() == "a -> Invalid path" + ); + + // The identical addition under a late-sorting name passes the tree + // comparison and instead hard-errors in the schema compatibility + // check ("schema keyword 'indices' ... is not supported"). + old.as_ref() + .validate_update(new_late_name.as_ref(), platform_version) + .expect_err("late-name addition should error in schema compatibility"); } } diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs new file mode 100644 index 0000000000..adc2be5d68 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs @@ -0,0 +1,357 @@ +//! Protocol v14 generation of document type update validation. +//! +//! v0 validated index changes by comparing `IndexLevel` trees whose +//! `level_identifier`s are assigned by an incrementing counter while walking +//! `indices` — a BTreeMap keyed by index NAME. Adding an index whose name +//! sorted before an existing one renumbered every level, so the identifier +//! equality check rejected the update with an opaque "Invalid path", while +//! the semantically identical addition under a late-sorting name passed the +//! tree comparison (and then hard-errored in the JSON-schema compatibility +//! check, which has no rule for the `indices` keyword). Which consensus +//! outcome a contract owner got therefore depended purely on how the new +//! index's name sorted. +//! +//! v1 drops the tree comparison and compares the parsed index definitions +//! by name instead: any added, removed or modified index is rejected with a +//! deterministic `DataContractInvalidIndexDefinitionUpdateError` naming the +//! offending index, independent of name sort order. This does not change +//! which updates are ultimately acceptable — under v0 no index modification +//! could ever pass the full pipeline (whatever survived the tree comparison +//! was always rejected by the `indices` schema-compatibility hard error) — +//! it makes the rejection deterministic, clean, and correctly labeled. + +use crate::consensus::basic::data_contract::DataContractInvalidIndexDefinitionUpdateError; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::DocumentTypeRef; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +impl DocumentTypeRef<'_> { + #[inline(always)] + pub(super) fn validate_update_v1( + &self, + new_document_type: DocumentTypeRef, + platform_version: &PlatformVersion, + ) -> Result { + // Validate configuration + let result = self.validate_config(new_document_type); + + if !result.is_valid() { + return Ok(result); + } + + // Validate that index definitions are unchanged + let result = self.validate_index_definitions_unchanged(new_document_type); + + if !result.is_valid() { + return Ok(result); + } + + // Validate that no byte array property changes its on-disk encoding + let result = self.validate_byte_array_encoding_stability(new_document_type); + + if !result.is_valid() { + return Ok(result); + } + + // Validate schema compatibility + self.validate_schema(new_document_type, platform_version) + } + + /// Index definitions are immutable once a document type is registered: + /// Drive lays out the index trees at contract creation and never + /// backfills them, so an added index would silently miss every + /// pre-update document and a removed or modified one would orphan + /// on-disk subtrees. Compare the definitions by index name — the + /// comparison must not depend on where a changed index's name sorts + /// relative to the document type's other indexes. + fn validate_index_definitions_unchanged( + &self, + new_document_type: DocumentTypeRef, + ) -> SimpleConsensusValidationResult { + let old_indexes = self.indexes(); + let new_indexes = new_document_type.indexes(); + + for (name, old_index) in old_indexes { + match new_indexes.get(name) { + None => { + return SimpleConsensusValidationResult::new_with_error( + DataContractInvalidIndexDefinitionUpdateError::new( + self.name().to_string(), + format!("removed index '{name}'"), + ) + .into(), + ); + } + Some(new_index) if new_index != old_index => { + return SimpleConsensusValidationResult::new_with_error( + DataContractInvalidIndexDefinitionUpdateError::new( + self.name().to_string(), + format!("changed index '{name}'"), + ) + .into(), + ); + } + _ => {} + } + } + + for name in new_indexes.keys() { + if !old_indexes.contains_key(name) { + return SimpleConsensusValidationResult::new_with_error( + DataContractInvalidIndexDefinitionUpdateError::new( + self.name().to_string(), + format!("added index '{name}'"), + ) + .into(), + ); + } + } + + SimpleConsensusValidationResult::new() + } +} + +#[cfg(test)] +mod tests { + use crate::consensus::basic::BasicError; + use crate::consensus::ConsensusError; + use crate::data_contract::config::DataContractConfig; + use crate::data_contract::document_type::DocumentType; + use assert_matches::assert_matches; + use platform_value::{platform_value, Identifier, Value}; + use platform_version::version::PlatformVersion; + use std::collections::BTreeMap; + + fn doc_type_with_indices(indices: Value, platform_version: &PlatformVersion) -> DocumentType { + let schema = platform_value!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + "b": {"type": "string", "position": 1, "maxLength": 60_u32}, + "c": {"type": "string", "position": 2, "maxLength": 60_u32}, + }, + "indices": indices, + "additionalProperties": false, + }); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "test", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + fn old_doc_type(platform_version: &PlatformVersion) -> DocumentType { + doc_type_with_indices( + platform_value!([ + {"name": "j", "properties": [{"c": "asc"}]}, + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + ]), + platform_version, + ) + } + + // The v0 regression this generation fixes: the outcome of adding an + // index must not depend on where its name sorts relative to the + // document type's existing indexes. Under v0, adding "i" on [a] was + // rejected with an opaque "Invalid path" (level renumbering) while the + // semantically identical "z" on [a] passed the tree comparison and + // hard-errored later in schema compatibility. Under v1 both get the + // same clean rejection naming the added index. + #[test] + fn should_reject_added_index_identically_regardless_of_name_sort_order() { + let platform_version = PlatformVersion::latest(); + + let old = old_doc_type(platform_version); + + let new_early_name = doc_type_with_indices( + platform_value!([ + {"name": "i", "properties": [{"a": "asc"}]}, + {"name": "j", "properties": [{"c": "asc"}]}, + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + ]), + platform_version, + ); + + let new_late_name = doc_type_with_indices( + platform_value!([ + {"name": "j", "properties": [{"c": "asc"}]}, + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + {"name": "z", "properties": [{"a": "asc"}]}, + ]), + platform_version, + ); + + let early_result = old + .as_ref() + .validate_update(new_early_name.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert_matches!( + early_result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidIndexDefinitionUpdateError(e) + )] if e.index_path() == "added index 'i'" + ); + + let late_result = old + .as_ref() + .validate_update(new_late_name.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert_matches!( + late_result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidIndexDefinitionUpdateError(e) + )] if e.index_path() == "added index 'z'" + ); + } + + // Renaming an index leaves the `IndexLevel` tree unchanged (index names + // are not part of it), so under v0 a rename either slipped through to a + // schema-compatibility hard error or — when it shifted the name-order + // level numbering — was rejected as "Invalid path". Under v1 it is a + // clean, deterministic rejection. + #[test] + fn should_reject_renamed_index_with_clean_error() { + let platform_version = PlatformVersion::latest(); + + let old = old_doc_type(platform_version); + + // "j" renamed to "zz" — this also shifts the v0 level numbering + // because "zz" sorts after "k" while "j" sorted before it. + let new = doc_type_with_indices( + platform_value!([ + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + {"name": "zz", "properties": [{"c": "asc"}]}, + ]), + platform_version, + ); + + let result = old + .as_ref() + .validate_update(new.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidIndexDefinitionUpdateError(e) + )] if e.index_path() == "removed index 'j'" + ); + } + + #[test] + fn should_reject_removed_index() { + let platform_version = PlatformVersion::latest(); + + let old = old_doc_type(platform_version); + + let new = doc_type_with_indices( + platform_value!([ + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + ]), + platform_version, + ); + + let result = old + .as_ref() + .validate_update(new.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidIndexDefinitionUpdateError(e) + )] if e.index_path() == "removed index 'j'" + ); + } + + #[test] + fn should_reject_index_with_added_property() { + let platform_version = PlatformVersion::latest(); + + let old = old_doc_type(platform_version); + + let new = doc_type_with_indices( + platform_value!([ + {"name": "j", "properties": [{"c": "asc"}, {"a": "asc"}]}, + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + ]), + platform_version, + ); + + let result = old + .as_ref() + .validate_update(new.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidIndexDefinitionUpdateError(e) + )] if e.index_path() == "changed index 'j'" + ); + } + + // Flipping `unique` leaves the v0 `IndexLevel` subset comparison + // blind (it never compared terminator info), so v0 let it through to + // the schema-compatibility hard error. v1 rejects it cleanly. + #[test] + fn should_reject_index_with_changed_unique_flag() { + let platform_version = PlatformVersion::latest(); + + let old = old_doc_type(platform_version); + + let new = doc_type_with_indices( + platform_value!([ + {"name": "j", "properties": [{"c": "asc"}], "unique": true}, + {"name": "k", "properties": [{"a": "asc"}, {"b": "asc"}]}, + ]), + platform_version, + ); + + let result = old + .as_ref() + .validate_update(new.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidIndexDefinitionUpdateError(e) + )] if e.index_path() == "changed index 'j'" + ); + } + + #[test] + fn should_pass_when_indices_are_unchanged() { + let platform_version = PlatformVersion::latest(); + + let old = old_doc_type(platform_version); + let new = old_doc_type(platform_version); + + let result = old + .as_ref() + .validate_update(new.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert!( + result.is_valid(), + "unchanged document type should be accepted, got {:?}", + result.errors + ); + } +} diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs index 222dc4104c..1149cdaa32 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs @@ -4,6 +4,7 @@ pub mod v1; pub mod v2; pub mod v3; pub mod v4; +pub mod v5; #[derive(Clone, Debug, Default)] pub struct DPPValidationVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs new file mode 100644 index 0000000000..cce9f719d5 --- /dev/null +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v5.rs @@ -0,0 +1,22 @@ +use crate::version::dpp_versions::dpp_validation_versions::{ + DPPValidationVersions, DocumentTypeValidationVersions, +}; + +use super::v4::DPP_VALIDATION_VERSIONS_V4; + +/// Protocol v14 validation versions. +/// +/// v1 document-type update validation compares index definitions by name +/// instead of comparing `IndexLevel` trees whose level identifiers are +/// assigned by an iteration-order counter. In v0 the counter follows the +/// index-name sort order, so whether a rejected index change surfaced as a +/// proper consensus error or as an opaque "Invalid path" / internal error +/// depended on where the changed index's name sorted relative to the +/// document type's other indexes. +pub const DPP_VALIDATION_VERSIONS_V5: DPPValidationVersions = DPPValidationVersions { + document_type: DocumentTypeValidationVersions { + validate_update: 1, + ..DPP_VALIDATION_VERSIONS_V4.document_type + }, + ..DPP_VALIDATION_VERSIONS_V4 +}; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index a6c307f3d4..4a5be44815 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -11,7 +11,7 @@ use crate::version::dpp_versions::dpp_state_transition_method_versions::v1::STAT use crate::version::dpp_versions::dpp_state_transition_serialization_versions::v2::STATE_TRANSITION_SERIALIZATION_VERSIONS_V2; use crate::version::dpp_versions::dpp_state_transition_versions::v3::STATE_TRANSITION_VERSIONS_V3; use crate::version::dpp_versions::dpp_token_versions::v2::TOKEN_VERSIONS_V2; -use crate::version::dpp_versions::dpp_validation_versions::v4::DPP_VALIDATION_VERSIONS_V4; +use crate::version::dpp_versions::dpp_validation_versions::v5::DPP_VALIDATION_VERSIONS_V5; use crate::version::dpp_versions::dpp_voting_versions::v2::VOTING_VERSION_V2; use crate::version::dpp_versions::DPPVersion; use crate::version::drive_abci_versions::drive_abci_checkpoint_parameters::v1::DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1; @@ -68,7 +68,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { }, dpp: DPPVersion { costs: DPP_COSTS_VERSIONS_V1, - validation: DPP_VALIDATION_VERSIONS_V4, + validation: DPP_VALIDATION_VERSIONS_V5, state_transition_serialization_versions: STATE_TRANSITION_SERIALIZATION_VERSIONS_V2, state_transition_conversion_versions: STATE_TRANSITION_CONVERSION_VERSIONS_V2, state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V1, From 23f40c610aaaa7dadf3ddf65f252e520f1daaf96 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 5 Aug 2026 11:28:08 +0700 Subject: [PATCH 2/6] test(dpp): move ranked-index update tests to validate_update v1 The ranked aggregate index grammar (#4266) only exists at protocol v14, where validate_update now dispatches to v1, so the rankedAverageable update tests added to the v0 module assert the v1 rejection shape ("changed index 'byRestaurant'") and belong in the v1 module. The ranked flags ride the same name-keyed index-definition comparison as every other index property. Co-Authored-By: Claude Fable 5 --- .../methods/validate_update/v0/mod.rs | 104 ----------------- .../methods/validate_update/v1/mod.rs | 107 ++++++++++++++++++ 2 files changed, 107 insertions(+), 104 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs index 442ed9ff32..075c2fb7af 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs @@ -147,108 +147,4 @@ mod tests { .validate_update(new_late_name.as_ref(), platform_version) .expect_err("late-name addition should error in schema compatibility"); } - - /// The ranking axes are index-level, so `validate_config` — which covers - /// the *doctype*-level count / sum flags just above — is deliberately not - /// where they are enforced. They ride the index-structure comparison - /// (`IndexLevel::validate_update`) that `validate_update_v0` runs right - /// after the config check. These tests exercise the PUBLIC dispatcher so - /// that routing is pinned, not just the helper in isolation. - mod validate_update_ranked_indices { - use super::*; - use std::collections::BTreeMap; - - /// `review` doctype, one averageable index over `restaurantId`, with - /// `rankedAverageable` set to the supplied value. - fn document_type_with_ranked_index( - ranked_averageable: bool, - platform_version: &PlatformVersion, - ) -> DocumentType { - let schema = platform_value!({ - "type": "object", - "properties": { - // 32 rather than the generic 63-character index limit: - // an index declaring a ranking axis bounds its group key - // more tightly (59 characters on the Avg axis), and both - // halves of these tests have to build the same doctype - // shape with only `rankedAverageable` differing. - "restaurantId": { - "type": "string", - "maxLength": 32, - "position": 0, - }, - "grade": { - "type": "integer", - "minimum": 0, - "maximum": 100, - "position": 1, - }, - }, - "required": ["restaurantId", "grade"], - "additionalProperties": false, - "indices": [{ - "name": "byRestaurant", - "properties": [{ "restaurantId": "asc" }], - "averageable": "grade", - "rangeAverageable": true, - "rankedAverageable": ranked_averageable, - }], - }); - - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create a default config"); - - DocumentType::try_from_schema( - Identifier::random(), - 1, - config.version(), - "review", - schema, - None, - &BTreeMap::new(), - &config, - true, - &mut Vec::new(), - platform_version, - ) - .expect("failed to create document type") - } - - #[test] - fn should_return_invalid_result_when_ranked_averageable_is_changed() { - let platform_version = PlatformVersion::latest(); - let old = document_type_with_ranked_index(false, platform_version); - let new = document_type_with_ranked_index(true, platform_version); - - let result = old - .as_ref() - .validate_update(new.as_ref(), platform_version) - .expect("validate_update should not error"); - - assert_matches!( - result.errors.as_slice(), - [ConsensusError::BasicError( - crate::consensus::basic::BasicError::DataContractInvalidIndexDefinitionUpdateError(e) - )] if e.index_path() == "restaurantId -> (ranked_averageable: false -> true)" - ); - } - - #[test] - fn should_pass_when_ranked_averageable_is_unchanged() { - let platform_version = PlatformVersion::latest(); - let old = document_type_with_ranked_index(true, platform_version); - let new = document_type_with_ranked_index(true, platform_version); - - let result = old - .as_ref() - .validate_update(new.as_ref(), platform_version) - .expect("validate_update should not error"); - - assert!( - result.is_valid(), - "an unchanged ranked index must not be rejected, got {:?}", - result.errors - ); - } - } } diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs index adc2be5d68..bd950436ea 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs @@ -354,4 +354,111 @@ mod tests { result.errors ); } + + // Ranked aggregate indexes (protocol v14 grammar) are covered by the + // same name-keyed definition comparison as every other index flag: + // toggling a ranking axis after creation changes the on-disk tree + // variant, so it must be rejected. The ranking axes are index-level, + // so `validate_config` — which covers the *doctype*-level count / sum + // flags — is deliberately not where they are enforced. These tests + // exercise the PUBLIC dispatcher so that routing is pinned, not just + // the helper in isolation; they live here rather than in v0 because + // the ranked grammar only exists at protocol v14, where + // validate_update dispatches to v1. + mod validate_update_ranked_indices { + use super::*; + + /// `review` doctype, one averageable index over `restaurantId`, with + /// `rankedAverageable` set to the supplied value. + fn document_type_with_ranked_index( + ranked_averageable: bool, + platform_version: &PlatformVersion, + ) -> DocumentType { + let schema = platform_value!({ + "type": "object", + "properties": { + // 32 rather than the generic 63-character index limit: + // an index declaring a ranking axis bounds its group key + // more tightly (59 characters on the Avg axis), and both + // halves of these tests have to build the same doctype + // shape with only `rankedAverageable` differing. + "restaurantId": { + "type": "string", + "maxLength": 32, + "position": 0, + }, + "grade": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "position": 1, + }, + }, + "required": ["restaurantId", "grade"], + "additionalProperties": false, + "indices": [{ + "name": "byRestaurant", + "properties": [{ "restaurantId": "asc" }], + "averageable": "grade", + "rangeAverageable": true, + "rankedAverageable": ranked_averageable, + }], + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + "review", + schema, + None, + &BTreeMap::new(), + &config, + true, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + #[test] + fn should_return_invalid_result_when_ranked_averageable_is_changed() { + let platform_version = PlatformVersion::latest(); + let old = document_type_with_ranked_index(false, platform_version); + let new = document_type_with_ranked_index(true, platform_version); + + let result = old + .as_ref() + .validate_update(new.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidIndexDefinitionUpdateError(e) + )] if e.index_path() == "changed index 'byRestaurant'" + ); + } + + #[test] + fn should_pass_when_ranked_averageable_is_unchanged() { + let platform_version = PlatformVersion::latest(); + let old = document_type_with_ranked_index(true, platform_version); + let new = document_type_with_ranked_index(true, platform_version); + + let result = old + .as_ref() + .validate_update(new.as_ref(), platform_version) + .expect("validate_update should not error"); + + assert!( + result.is_valid(), + "an unchanged ranked index must not be rejected, got {:?}", + result.errors + ); + } + } } From 17cd413827ed4af89002c891e77922df9e55f9d1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 5 Aug 2026 11:42:52 +0700 Subject: [PATCH 3/6] test(dpp): cover remaining validate_config guards and pin v0 late-name error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the five validate_config guard tests that were missing (keeps transfer/purchase/pricing history, documentsSummable, rangeSummable — the latter with documentsSummable held equal so the guard below it is reached), and pins the v0 late-name index addition to the exact schema-compatibility "indices" keyword error instead of accepting any ProtocolError. Co-Authored-By: Claude Fable 5 --- .../methods/validate_update/common/mod.rs | 147 ++++++++++++++++++ .../methods/validate_update/v0/mod.rs | 10 +- 2 files changed, 156 insertions(+), 1 deletion(-) 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 17061ea86e..2117ce8dff 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 @@ -1279,6 +1279,153 @@ mod tests { )] if e.additional_message() == "document type can not change whether it is range countable: changing from false to true" ); } + + /// Builds old/new document types from two schemas and runs + /// `validate_config`, asserting the exact rejection message. The + /// per-flag tests below only differ in one schema keyword, so the + /// boilerplate lives here. + fn assert_config_change_rejected( + old_schema: platform_value::Value, + new_schema: platform_value::Value, + expected_message: &str, + ) { + let platform_version = PlatformVersion::latest(); + let data_contract_id = Identifier::random(); + let document_type_name = "test"; + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + let old_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + old_schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let new_document_type = DocumentType::try_from_schema( + data_contract_id, + 1, + config.version(), + document_type_name, + new_schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create new document type"); + + let result = old_document_type + .as_ref() + .validate_config(new_document_type.as_ref()); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::DocumentTypeUpdateError(e) + )] if e.additional_message() == expected_message + ); + } + + fn schema_with_keep_flag(flag: &str, value: bool) -> platform_value::Value { + platform_value!({ + "type": "object", + "properties": { + "test": { + "type": "string", + "position": 0, + } + }, + flag: value, + "additionalProperties": false, + }) + } + + #[test] + fn should_return_invalid_result_when_documents_keep_transfer_history_is_changed() { + assert_config_change_rejected( + schema_with_keep_flag("keepsTransferHistory", true), + schema_with_keep_flag("keepsTransferHistory", false), + "document type can not change whether it keeps transfer history: changing from true to false", + ); + } + + #[test] + fn should_return_invalid_result_when_documents_keep_purchase_history_is_changed() { + assert_config_change_rejected( + schema_with_keep_flag("keepsPurchaseHistory", true), + schema_with_keep_flag("keepsPurchaseHistory", false), + "document type can not change whether it keeps purchase history: changing from true to false", + ); + } + + #[test] + fn should_return_invalid_result_when_documents_keep_pricing_history_is_changed() { + assert_config_change_rejected( + schema_with_keep_flag("keepsPricingHistory", true), + schema_with_keep_flag("keepsPricingHistory", false), + "document type can not change whether it keeps pricing history: changing from true to false", + ); + } + + /// `documentsSummable` must name an integer property listed in + /// `required`, so the summable schemas carry an `amount` field. + fn schema_with_summable( + documents_summable: bool, + range_summable: bool, + ) -> platform_value::Value { + let mut schema = platform_value!({ + "type": "object", + "properties": { + "amount": { + "type": "integer", + "position": 0, + } + }, + "required": ["amount"], + "additionalProperties": false, + }); + let map = schema.as_map_mut().expect("schema must be a map"); + if documents_summable { + map.push(("documentsSummable".into(), "amount".into())); + } + if range_summable { + map.push(("rangeSummable".into(), true.into())); + } + schema + } + + #[test] + fn should_return_invalid_result_when_documents_summable_is_changed() { + assert_config_change_rejected( + schema_with_summable(true, false), + schema_with_summable(false, false), + "document type can not change whether or how its documents are summable: changing from Some(\"amount\") to None", + ); + } + + #[test] + fn should_return_invalid_result_when_range_summable_is_changed() { + // `documentsSummable` stays equal across old and new so that + // validate_config reaches the range_summable check below it + // (mirrors the range_countable test above). + assert_config_change_rejected( + schema_with_summable(true, false), + schema_with_summable(true, true), + "document type can not change whether it is range summable: changing from false to true", + ); + } } mod validate_schema { diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs index 075c2fb7af..15f9c1ce8f 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs @@ -143,8 +143,16 @@ mod tests { // The identical addition under a late-sorting name passes the tree // comparison and instead hard-errors in the schema compatibility // check ("schema keyword 'indices' ... is not supported"). - old.as_ref() + let late_error = old + .as_ref() .validate_update(new_late_name.as_ref(), platform_version) .expect_err("late-name addition should error in schema compatibility"); + + assert!( + late_error + .to_string() + .contains("schema keyword 'indices' at path '/indices/2' is not supported"), + "expected the schema compatibility hard error for the `indices` keyword, got {late_error}" + ); } } From 5078c6ac0134cbef6d30aba31a693ed1e54aaf98 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 5 Aug 2026 12:14:52 +0700 Subject: [PATCH 4/6] test(dpp): pin validate_update v1 tests to protocol version 14 PlatformVersion::latest() is a moving target: a future protocol version selecting a v2 dispatcher would silently redirect these tests and leave the v14 consensus behavior without permanent regression coverage. Pin to version 14 explicitly, matching the v0 module's version-13 pin. Co-Authored-By: Claude Fable 5 --- .../methods/validate_update/v1/mod.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs index bd950436ea..8980653135 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs @@ -172,7 +172,7 @@ mod tests { // same clean rejection naming the added index. #[test] fn should_reject_added_index_identically_regardless_of_name_sort_order() { - let platform_version = PlatformVersion::latest(); + let platform_version = PlatformVersion::get(14).expect("protocol version 14 must exist"); let old = old_doc_type(platform_version); @@ -226,7 +226,7 @@ mod tests { // clean, deterministic rejection. #[test] fn should_reject_renamed_index_with_clean_error() { - let platform_version = PlatformVersion::latest(); + let platform_version = PlatformVersion::get(14).expect("protocol version 14 must exist"); let old = old_doc_type(platform_version); @@ -255,7 +255,7 @@ mod tests { #[test] fn should_reject_removed_index() { - let platform_version = PlatformVersion::latest(); + let platform_version = PlatformVersion::get(14).expect("protocol version 14 must exist"); let old = old_doc_type(platform_version); @@ -281,7 +281,7 @@ mod tests { #[test] fn should_reject_index_with_added_property() { - let platform_version = PlatformVersion::latest(); + let platform_version = PlatformVersion::get(14).expect("protocol version 14 must exist"); let old = old_doc_type(platform_version); @@ -311,7 +311,7 @@ mod tests { // the schema-compatibility hard error. v1 rejects it cleanly. #[test] fn should_reject_index_with_changed_unique_flag() { - let platform_version = PlatformVersion::latest(); + let platform_version = PlatformVersion::get(14).expect("protocol version 14 must exist"); let old = old_doc_type(platform_version); @@ -338,7 +338,7 @@ mod tests { #[test] fn should_pass_when_indices_are_unchanged() { - let platform_version = PlatformVersion::latest(); + let platform_version = PlatformVersion::get(14).expect("protocol version 14 must exist"); let old = old_doc_type(platform_version); let new = old_doc_type(platform_version); @@ -426,7 +426,8 @@ mod tests { #[test] fn should_return_invalid_result_when_ranked_averageable_is_changed() { - let platform_version = PlatformVersion::latest(); + let platform_version = + PlatformVersion::get(14).expect("protocol version 14 must exist"); let old = document_type_with_ranked_index(false, platform_version); let new = document_type_with_ranked_index(true, platform_version); @@ -445,7 +446,8 @@ mod tests { #[test] fn should_pass_when_ranked_averageable_is_unchanged() { - let platform_version = PlatformVersion::latest(); + let platform_version = + PlatformVersion::get(14).expect("protocol version 14 must exist"); let old = document_type_with_ranked_index(true, platform_version); let new = document_type_with_ranked_index(true, platform_version); From da84076a2924aaa9e268a77af4eff12c323e9315 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 5 Aug 2026 12:16:48 +0700 Subject: [PATCH 5/6] Revert "test(dpp): pin validate_update v1 tests to protocol version 14" This reverts commit 5078c6ac0134cbef6d30aba31a693ed1e54aaf98. --- .../methods/validate_update/v1/mod.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs index 8980653135..bd950436ea 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v1/mod.rs @@ -172,7 +172,7 @@ mod tests { // same clean rejection naming the added index. #[test] fn should_reject_added_index_identically_regardless_of_name_sort_order() { - let platform_version = PlatformVersion::get(14).expect("protocol version 14 must exist"); + let platform_version = PlatformVersion::latest(); let old = old_doc_type(platform_version); @@ -226,7 +226,7 @@ mod tests { // clean, deterministic rejection. #[test] fn should_reject_renamed_index_with_clean_error() { - let platform_version = PlatformVersion::get(14).expect("protocol version 14 must exist"); + let platform_version = PlatformVersion::latest(); let old = old_doc_type(platform_version); @@ -255,7 +255,7 @@ mod tests { #[test] fn should_reject_removed_index() { - let platform_version = PlatformVersion::get(14).expect("protocol version 14 must exist"); + let platform_version = PlatformVersion::latest(); let old = old_doc_type(platform_version); @@ -281,7 +281,7 @@ mod tests { #[test] fn should_reject_index_with_added_property() { - let platform_version = PlatformVersion::get(14).expect("protocol version 14 must exist"); + let platform_version = PlatformVersion::latest(); let old = old_doc_type(platform_version); @@ -311,7 +311,7 @@ mod tests { // the schema-compatibility hard error. v1 rejects it cleanly. #[test] fn should_reject_index_with_changed_unique_flag() { - let platform_version = PlatformVersion::get(14).expect("protocol version 14 must exist"); + let platform_version = PlatformVersion::latest(); let old = old_doc_type(platform_version); @@ -338,7 +338,7 @@ mod tests { #[test] fn should_pass_when_indices_are_unchanged() { - let platform_version = PlatformVersion::get(14).expect("protocol version 14 must exist"); + let platform_version = PlatformVersion::latest(); let old = old_doc_type(platform_version); let new = old_doc_type(platform_version); @@ -426,8 +426,7 @@ mod tests { #[test] fn should_return_invalid_result_when_ranked_averageable_is_changed() { - let platform_version = - PlatformVersion::get(14).expect("protocol version 14 must exist"); + let platform_version = PlatformVersion::latest(); let old = document_type_with_ranked_index(false, platform_version); let new = document_type_with_ranked_index(true, platform_version); @@ -446,8 +445,7 @@ mod tests { #[test] fn should_pass_when_ranked_averageable_is_unchanged() { - let platform_version = - PlatformVersion::get(14).expect("protocol version 14 must exist"); + let platform_version = PlatformVersion::latest(); let old = document_type_with_ranked_index(true, platform_version); let new = document_type_with_ranked_index(true, platform_version); From ae98e86529edbf4d13468b60776ebe2ddac0f2d3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 5 Aug 2026 12:19:54 +0700 Subject: [PATCH 6/6] test(dpp): match the exact schema-compat error variant in v0 pin test Replaces the contains() check with an assert_matches on the concrete ProtocolError -> DataContractError::JsonSchema -> SchemaCompatibilityValidationError chain and the exact inner message, so the frozen v0 late-name behavior cannot drift to a different error without a signal. Co-Authored-By: Claude Fable 5 --- .../document_type/methods/validate_update/v0/mod.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs index 15f9c1ce8f..46a4de0142 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/v0/mod.rs @@ -45,6 +45,8 @@ mod tests { use crate::consensus::ConsensusError; use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::DocumentType; + use crate::data_contract::errors::{DataContractError, JsonSchemaError}; + use crate::ProtocolError; use assert_matches::assert_matches; use platform_value::{platform_value, Identifier, Value}; use platform_version::version::PlatformVersion; @@ -148,11 +150,11 @@ mod tests { .validate_update(new_late_name.as_ref(), platform_version) .expect_err("late-name addition should error in schema compatibility"); - assert!( - late_error - .to_string() - .contains("schema keyword 'indices' at path '/indices/2' is not supported"), - "expected the schema compatibility hard error for the `indices` keyword, got {late_error}" + assert_matches!( + late_error, + ProtocolError::DataContractError(DataContractError::JsonSchema( + JsonSchemaError::SchemaCompatibilityValidationError(message) + )) if message == "schema keyword 'indices' at path '/indices/2' is not supported" ); } }