diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index f5fb020a8b6..3d952b5663c 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL THE RELEASE CARRYING PROTOCOL V14 SHIPS — FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable) and the refersTo reference keyword on identifier properties, and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once the release carrying protocol v14 ships, mutating it would change historical validation results and break consensus replay. After release, any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL THE RELEASE CARRYING PROTOCOL V14 SHIPS — FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties, and the requiredSince property keyword (the contract version a property is required from), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once the release carrying protocol v14 ships, mutating it would change historical validation results and break consensus replay. After release, any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "documentProperties": { @@ -224,6 +224,11 @@ "position": { "type": "integer", "minimum": 0 + }, + "requiredSince": { + "type": "integer", + "minimum": 1, + "maximum": 4294967295 } }, "dependentSchemas": { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index d54aac7b187..dc9e05b06fe 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -722,6 +722,7 @@ fn parse_document_properties( &mut document_properties, &required_fields, &transient_fields, + true, property_key, property_value, root_schema, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index db391c3456e..ce6ba7e1755 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -128,6 +128,7 @@ fn insert_values( vec![(prefix, property_key, property_value)]; while let Some((prefix, property_key, property_value)) = to_visit.pop() { + let is_top_level = prefix.is_none(); let prefixed_property_key = match prefix { None => property_key, Some(prefix) => [prefix, property_key].join(".").to_owned(), @@ -143,6 +144,12 @@ fn insert_values( let is_required = known_required.contains(&prefixed_property_key); let is_transient = known_transient.contains(&prefixed_property_key); + let required_since = apply_required_since( + &inner_properties, + is_required, + is_top_level, + platform_version, + )?; match DocumentPropertyType::try_from_value_map(&inner_properties, &config.into())? { DocumentPropertyType::Object(_) => { @@ -179,6 +186,7 @@ fn insert_values( property_type, required: is_required, transient: is_transient, + required_since, }, ); } @@ -194,6 +202,7 @@ fn insert_values_nested( document_properties: &mut IndexMap, known_required: &BTreeSet, known_transient: &BTreeSet, + is_top_level: bool, property_key: String, property_value: &Value, root_schema: &Value, @@ -212,6 +221,13 @@ fn insert_values_nested( let is_transient = known_transient.contains(&property_key); + let required_since = apply_required_since( + &inner_properties, + is_required, + is_top_level, + platform_version, + )?; + let property_type = match DocumentPropertyType::try_from_value_map(&inner_properties, &config.into())? { DocumentPropertyType::Object(_) => { @@ -271,6 +287,7 @@ fn insert_values_nested( &mut nested_properties, &stripped_required, &stripped_transient, + false, object_property_string, object_property_value, root_schema, @@ -294,12 +311,79 @@ fn insert_values_nested( property_type, required: is_required, transient: is_transient, + required_since, }, ); Ok(()) } +/// Parses the `requiredSince` keyword: the contract version from which the +/// property is required. Only meaningful on top-level required properties — +/// the document wire format encodes a required property without a presence +/// flag, so requiredness that varies by contract version must be resolvable +/// per property from the current schema alone (see the per-document contract +/// version stamp in document serialization format 3). +/// +/// Versioned on `apply_required_since` in the platform version's document +/// type schema versions. `None` selects the behavior of the versions that +/// predate the keyword: it is ignored entirely, so their parses stay +/// byte-for-byte identical to what they always produced. +fn apply_required_since( + inner_properties: &BTreeMap, + is_required: bool, + is_top_level: bool, + platform_version: &PlatformVersion, +) -> Result, DataContractError> { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .apply_required_since + { + None => Ok(None), + Some(0) => apply_required_since_v0(inner_properties, is_required, is_top_level), + Some(version) => Err(DataContractError::Unsupported(format!( + "apply_required_since version {version} is not supported" + ))), + } +} + +fn apply_required_since_v0( + inner_properties: &BTreeMap, + is_required: bool, + is_top_level: bool, +) -> Result, DataContractError> { + let Some(required_since_value) = inner_properties.get(property_names::REQUIRED_SINCE) else { + return Ok(None); + }; + + if !is_top_level { + return Err(DataContractError::InvalidContractStructure( + "requiredSince is only allowed on top-level properties".to_string(), + )); + } + + if !is_required { + return Err(DataContractError::InvalidContractStructure( + "requiredSince is only allowed on properties listed in required".to_string(), + )); + } + + let required_since: u32 = required_since_value + .to_integer() + .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; + + if required_since == 0 { + return Err(DataContractError::InvalidContractStructure( + "requiredSince must be a contract version of at least 1".to_string(), + )); + } + + Ok(Some(required_since)) +} + /// Folds a `refersTo` declaration into the property type: an identifier property /// with `refersTo` becomes `IdentifierWithReference(target)`. Non-identifier /// properties cannot carry `refersTo`. @@ -804,4 +888,207 @@ mod tests { ) .expect("a parse predating refersTo should ignore the keyword entirely"); } + + // ================================================================ + // requiredSince + // ================================================================ + + #[test] + fn should_parse_required_since_on_top_level_required_property() { + let document_type = try_document_type_from_schema(json!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0, "maxLength": 60}, + "b": {"type": "string", "position": 1, "maxLength": 60, "requiredSince": 3}, + }, + "required": ["a", "b"], + "additionalProperties": false + })) + .expect("should parse"); + + let properties = document_type.as_ref().flattened_properties().clone(); + assert_eq!(properties.get("a").unwrap().required_since, None); + assert_eq!(properties.get("b").unwrap().required_since, Some(3)); + assert!(properties.get("b").unwrap().required); + } + + #[test] + fn should_reject_required_since_on_optional_property() { + let result = try_document_type_from_schema(json!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0, "maxLength": 60, "requiredSince": 2}, + }, + "required": [], + "additionalProperties": false + })); + + assert!( + result.is_err(), + "requiredSince on a property not listed in required must be rejected" + ); + } + + #[test] + fn should_reject_required_since_on_nested_property() { + let result = try_document_type_from_schema(json!({ + "type": "object", + "properties": { + "outer": { + "type": "object", + "position": 0, + "properties": { + "inner": {"type": "string", "position": 0, "maxLength": 60, "requiredSince": 2}, + }, + "required": ["inner"], + "additionalProperties": false + }, + }, + "required": [], + "additionalProperties": false + })); + + assert!( + result.is_err(), + "requiredSince on a nested property must be rejected" + ); + } + + #[test] + fn should_reject_required_since_above_u32_max() { + // The meta-schema caps the value at u32::MAX too; this pins the + // parser-side rejection so it does not depend on meta-schema + // coverage (parses without full validation skip the meta-schema) + let result = try_document_type_from_schema(json!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0, "maxLength": 60, "requiredSince": 4_294_967_296_u64}, + }, + "required": ["a"], + "additionalProperties": false + })); + + assert!( + result.is_err(), + "requiredSince above u32::MAX must be rejected" + ); + } + + #[test] + fn should_reject_required_since_of_zero() { + let result = try_document_type_from_schema(json!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0, "maxLength": 60, "requiredSince": 0}, + }, + "required": ["a"], + "additionalProperties": false + })); + + assert!( + result.is_err(), + "requiredSince of 0 must be rejected (contract versions start at 1)" + ); + } + + #[test] + fn should_parse_required_since_reached_through_a_ref() { + // A `$ref`'d property resolves to its `$defs` entry before keywords + // are read, so an annotation hidden behind a reference is parsed + // exactly like a direct one — any validation that only scans raw + // property JSON would miss it, which is why the + // `requiredSince <= contract version` invariant is enforced on + // parsed properties (validate_required_since_within_contract_version) + let platform_version = PlatformVersion::latest(); + let config = + DataContractConfig::default_for_version(platform_version).expect("config should build"); + + let schema_defs: BTreeMap = [( + "annotated".to_string(), + platform_value::to_value(json!({ + "type": "string", "maxLength": 60, "requiredSince": 2 + })) + .expect("defs should convert"), + )] + .into_iter() + .collect(); + + let schema = platform_value::to_value(json!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0, "maxLength": 60}, + "b": {"$ref": "#/$defs/annotated", "position": 1}, + }, + "required": ["a", "b"], + "additionalProperties": false + })) + .expect("schema should convert"); + + let document_type = DocumentType::try_from_schema( + Identifier::random(), + 0, + config.version(), + "msg", + schema, + Some(&schema_defs), + &BTreeMap::new(), + &config, + false, + &mut vec![], + platform_version, + ) + .expect("should parse"); + + let properties = document_type.as_ref().flattened_properties().clone(); + assert_eq!(properties.get("b").unwrap().required_since, Some(2)); + + // The parsed-property invariant check sees the annotation the raw + // JSON hides: version 1 (too old for requiredSince 2) rejects, + // version 2 accepts + let mut document_types = BTreeMap::new(); + document_types.insert("msg".to_string(), document_type); + + assert!( + crate::data_contract::document_type::validate_required_since_within_contract_version( + &document_types, + 1 + ) + .is_err(), + "requiredSince 2 must be rejected on a version 1 contract even through $ref" + ); + assert!( + crate::data_contract::document_type::validate_required_since_within_contract_version( + &document_types, + 2 + ) + .is_ok() + ); + } + + #[test] + fn should_ignore_required_since_on_platform_versions_predating_it() { + // Platform versions whose tables carry `apply_required_since: None` + // predate the keyword: even if it appears in a schema they parse + // (only possible without full validation — their meta-schemas reject + // it), they must ignore it and keep producing the plain required + // property they always produced. + let platform_version = PlatformVersion::get(13).expect("platform version 13 should exist"); + + let document_type = try_document_type_from_schema_on_version( + json!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0, "maxLength": 60, "requiredSince": 3}, + }, + "required": ["a"], + "additionalProperties": false + }), + platform_version, + ) + .expect("a parse predating requiredSince should ignore the keyword entirely"); + + let properties = document_type.as_ref().flattened_properties().clone(); + assert_eq!(properties.get("a").unwrap().required_since, None); + assert!(properties.get("a").unwrap().required); + } } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v0/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v0/mod.rs index 686b8a42e76..fc41f482429 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v0/mod.rs @@ -254,6 +254,7 @@ impl DocumentTypeV0 { &mut document_properties, &required_fields, &transient_fields, + true, property_key, property_value, &root_schema, diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs index 88ce7c0f39d..fd773b07c5f 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs @@ -164,9 +164,10 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe .estimated_size { 0 => self.estimated_size_v0(platform_version), + 1 => self.estimated_size_v1(platform_version), version => Err(ProtocolError::UnknownVersionMismatch { method: "estimated_size".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/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs index cae38edefce..ea05394a714 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 @@ -1712,7 +1712,7 @@ mod tests { 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) + .validate_update(new.as_ref(), 2, platform_version) .expect("validate_update should not error") } 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 a8fd6b443c5..6e4418eaeeb 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 @@ -9,10 +9,14 @@ mod v1; impl DocumentTypeRef<'_> { /// Verify that the update to the document type is valid. - /// We assume that new document type is valid + /// We assume that new document type is valid. + /// `new_contract_version` is the version the updated contract will have + /// (already validated to be the old version + 1): a newly added required + /// property must carry `requiredSince` equal to exactly that version. pub fn validate_update( &self, new_document_type: DocumentTypeRef, + new_contract_version: u32, platform_version: &PlatformVersion, ) -> Result { match platform_version @@ -22,7 +26,7 @@ impl DocumentTypeRef<'_> { .validate_update { 0 => self.validate_update_v0(new_document_type, platform_version), - 1 => self.validate_update_v1(new_document_type, platform_version), + 1 => self.validate_update_v1(new_document_type, new_contract_version, platform_version), version => Err(ProtocolError::UnknownVersionMismatch { method: "validate_update".to_string(), known_versions: vec![0, 1], 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 46a4de01420..3f7616e6e47 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 @@ -132,7 +132,7 @@ mod tests { let early_result = old .as_ref() - .validate_update(new_early_name.as_ref(), platform_version) + .validate_update(new_early_name.as_ref(), 2, platform_version) .expect("early-name addition should produce a validation result"); assert_matches!( @@ -147,7 +147,7 @@ mod tests { // check ("schema keyword 'indices' ... is not supported"). let late_error = old .as_ref() - .validate_update(new_late_name.as_ref(), platform_version) + .validate_update(new_late_name.as_ref(), 2, platform_version) .expect_err("late-name addition should error in schema compatibility"); assert_matches!( 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 8e872772233..76a6732fc20 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 @@ -20,7 +20,9 @@ //! 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::consensus::basic::data_contract::{ + DataContractInvalidIndexDefinitionUpdateError, DataContractInvalidRequiredFieldsUpdateError, +}; use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::DocumentTypeRef; use crate::validation::SimpleConsensusValidationResult; @@ -32,6 +34,7 @@ impl DocumentTypeRef<'_> { pub(super) fn validate_update_v1( &self, new_document_type: DocumentTypeRef, + new_contract_version: u32, platform_version: &PlatformVersion, ) -> Result { // Validate configuration @@ -55,10 +58,102 @@ impl DocumentTypeRef<'_> { return Ok(result); } + // Validate required-field changes (the schema compatibility differ + // has the top-level `required` key stripped, so this is the only + // place top-level requiredness changes are judged) + let result = self.validate_required_fields_update(new_document_type, new_contract_version); + + if !result.is_valid() { + return Ok(result); + } + // Validate schema compatibility self.validate_schema(new_document_type, platform_version) } + /// Top-level requiredness may only change in one way: a brand-new + /// property may be added as required when it is annotated with + /// `requiredSince` equal to the contract version this update creates. + /// Everything else is frozen: requiredness is baked into the document + /// wire format (required properties serialize without a presence flag), + /// and the per-document contract-version stamp resolves layouts from the + /// latest schema alone only if annotations never change retroactively. + /// + /// Nested (dotted) required paths and the `requiredSince` keyword on + /// existing properties stay frozen by the schema compatibility differ; + /// this check judges the top-level `required` key, which is stripped + /// from the diff exactly like `indices`. + fn validate_required_fields_update( + &self, + new_document_type: DocumentTypeRef, + new_contract_version: u32, + ) -> SimpleConsensusValidationResult { + let old_required = self.required_fields(); + let new_required = new_document_type.required_fields(); + + for name in old_required { + // Nested paths are governed by the schema compatibility differ + if name.contains('.') { + continue; + } + if !new_required.contains(name) { + return SimpleConsensusValidationResult::new_with_error( + DataContractInvalidRequiredFieldsUpdateError::new( + self.name().to_string(), + format!("removed required field '{name}'"), + ) + .into(), + ); + } + } + + for name in new_required { + if name.contains('.') || old_required.contains(name) { + continue; + } + if name.starts_with('$') { + return SimpleConsensusValidationResult::new_with_error( + DataContractInvalidRequiredFieldsUpdateError::new( + self.name().to_string(), + format!("system field '{name}' cannot become required"), + ) + .into(), + ); + } + if self.properties().contains_key(name) { + return SimpleConsensusValidationResult::new_with_error( + DataContractInvalidRequiredFieldsUpdateError::new( + self.name().to_string(), + format!("existing property '{name}' cannot become required"), + ) + .into(), + ); + } + let Some(new_property) = new_document_type.properties().get(name) else { + return SimpleConsensusValidationResult::new_with_error( + DataContractInvalidRequiredFieldsUpdateError::new( + self.name().to_string(), + format!("added required field '{name}' references an unknown property"), + ) + .into(), + ); + }; + if new_property.required_since != Some(new_contract_version) { + return SimpleConsensusValidationResult::new_with_error( + DataContractInvalidRequiredFieldsUpdateError::new( + self.name().to_string(), + format!( + "new required field '{name}' must carry requiredSince {new_contract_version}, the contract version this update creates" + ), + ) + .into(), + ); + } + } + + SimpleConsensusValidationResult::new() + } + /// 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 @@ -196,7 +291,7 @@ mod tests { let early_result = old .as_ref() - .validate_update(new_early_name.as_ref(), platform_version) + .validate_update(new_early_name.as_ref(), 2, platform_version) .expect("validate_update should not error"); assert_matches!( @@ -208,7 +303,7 @@ mod tests { let late_result = old .as_ref() - .validate_update(new_late_name.as_ref(), platform_version) + .validate_update(new_late_name.as_ref(), 2, platform_version) .expect("validate_update should not error"); assert_matches!( @@ -242,7 +337,7 @@ mod tests { let result = old .as_ref() - .validate_update(new.as_ref(), platform_version) + .validate_update(new.as_ref(), 2, platform_version) .expect("validate_update should not error"); assert_matches!( @@ -268,7 +363,7 @@ mod tests { let result = old .as_ref() - .validate_update(new.as_ref(), platform_version) + .validate_update(new.as_ref(), 2, platform_version) .expect("validate_update should not error"); assert_matches!( @@ -295,7 +390,7 @@ mod tests { let result = old .as_ref() - .validate_update(new.as_ref(), platform_version) + .validate_update(new.as_ref(), 2, platform_version) .expect("validate_update should not error"); assert_matches!( @@ -325,7 +420,7 @@ mod tests { let result = old .as_ref() - .validate_update(new.as_ref(), platform_version) + .validate_update(new.as_ref(), 2, platform_version) .expect("validate_update should not error"); assert_matches!( @@ -359,7 +454,7 @@ mod tests { let result = old .as_ref() - .validate_update(new.as_ref(), platform_version) + .validate_update(new.as_ref(), 2, platform_version) .expect("validate_update should not error"); assert!( @@ -378,7 +473,7 @@ mod tests { let result = old .as_ref() - .validate_update(new.as_ref(), platform_version) + .validate_update(new.as_ref(), 2, platform_version) .expect("validate_update should not error"); assert!( @@ -465,7 +560,7 @@ mod tests { let result = old .as_ref() - .validate_update(new.as_ref(), platform_version) + .validate_update(new.as_ref(), 2, platform_version) .expect("validate_update should not error"); assert_matches!( @@ -484,7 +579,7 @@ mod tests { let result = old .as_ref() - .validate_update(new.as_ref(), platform_version) + .validate_update(new.as_ref(), 2, platform_version) .expect("validate_update should not error"); assert!( @@ -494,4 +589,239 @@ mod tests { ); } } + + // ================================================================ + // Required-field updates (`requiredSince`) + // ================================================================ + + mod required_fields_update { + use super::*; + + fn doc_type_with( + properties: Value, + required: Value, + platform_version: &PlatformVersion, + ) -> DocumentType { + let schema = platform_value!({ + "type": "object", + "properties": properties, + "required": required, + "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( + platform_value!({ + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + }), + platform_value!(["a"]), + platform_version, + ) + } + + #[test] + fn should_allow_adding_new_required_property_with_correct_required_since() { + let platform_version = PlatformVersion::latest(); + + let old = old_doc_type(platform_version); + let new = doc_type_with( + platform_value!({ + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + "b": {"type": "string", "position": 1, "maxLength": 60_u32, "requiredSince": 2}, + }), + platform_value!(["a", "b"]), + platform_version, + ); + + let result = old + .as_ref() + .validate_update(new.as_ref(), 2, platform_version) + .expect("validate_update should not error"); + + assert!( + result.is_valid(), + "a new required property annotated with the version this \ + update creates must be accepted, got {:?}", + result.errors + ); + } + + #[test] + fn should_reject_new_required_property_with_retroactive_required_since() { + let platform_version = PlatformVersion::latest(); + + let old = old_doc_type(platform_version); + // Contract moving to version 3, but the annotation claims 2: + // documents stamped 2 would misparse + let new = doc_type_with( + platform_value!({ + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + "b": {"type": "string", "position": 1, "maxLength": 60_u32, "requiredSince": 2}, + }), + platform_value!(["a", "b"]), + platform_version, + ); + + let result = old + .as_ref() + .validate_update(new.as_ref(), 3, platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidRequiredFieldsUpdateError(e) + )] if e.details().contains("must carry requiredSince 3") + ); + } + + #[test] + fn should_reject_new_required_property_without_required_since() { + let platform_version = PlatformVersion::latest(); + + let old = old_doc_type(platform_version); + let new = doc_type_with( + platform_value!({ + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + "b": {"type": "string", "position": 1, "maxLength": 60_u32}, + }), + platform_value!(["a", "b"]), + platform_version, + ); + + let result = old + .as_ref() + .validate_update(new.as_ref(), 2, platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidRequiredFieldsUpdateError(e) + )] if e.details().contains("must carry requiredSince 2") + ); + } + + #[test] + fn should_reject_promoting_existing_property_to_required() { + let platform_version = PlatformVersion::latest(); + + let old = doc_type_with( + platform_value!({ + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + "b": {"type": "string", "position": 1, "maxLength": 60_u32}, + }), + platform_value!(["a"]), + platform_version, + ); + let new = doc_type_with( + platform_value!({ + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + "b": {"type": "string", "position": 1, "maxLength": 60_u32}, + }), + platform_value!(["a", "b"]), + platform_version, + ); + + let result = old + .as_ref() + .validate_update(new.as_ref(), 2, platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidRequiredFieldsUpdateError(e) + )] if e.details() == "existing property 'b' cannot become required" + ); + } + + #[test] + fn should_reject_removing_required_field() { + let platform_version = PlatformVersion::latest(); + + let old = doc_type_with( + platform_value!({ + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + "b": {"type": "string", "position": 1, "maxLength": 60_u32}, + }), + platform_value!(["a", "b"]), + platform_version, + ); + let new = doc_type_with( + platform_value!({ + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + "b": {"type": "string", "position": 1, "maxLength": 60_u32}, + }), + platform_value!(["a"]), + platform_version, + ); + + let result = old + .as_ref() + .validate_update(new.as_ref(), 2, platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidRequiredFieldsUpdateError(e) + )] if e.details() == "removed required field 'b'" + ); + } + + #[test] + fn should_reject_mutating_required_since_on_existing_property() { + let platform_version = PlatformVersion::latest(); + + // The property was added as required at version 2; a later + // update must not move the annotation. This is caught by the + // compatibility differ's frozen `requiredSince` rule. + let old = doc_type_with( + platform_value!({ + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + "b": {"type": "string", "position": 1, "maxLength": 60_u32, "requiredSince": 2}, + }), + platform_value!(["a", "b"]), + platform_version, + ); + let new = doc_type_with( + platform_value!({ + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + "b": {"type": "string", "position": 1, "maxLength": 60_u32, "requiredSince": 3}, + }), + platform_value!(["a", "b"]), + platform_version, + ); + + let result = old + .as_ref() + .validate_update(new.as_ref(), 3, platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.operation() == "replace" && e.property_path() == "/properties/b/requiredSince" + ); + } + } } diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs index 588c826c383..d2f18611bd1 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs @@ -166,6 +166,7 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa { 0 => { let mut document = DocumentV0 { + contract_version: None, id: document_id, owner_id, properties: data @@ -328,6 +329,7 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa .document_structure_version { 0 => Ok(DocumentV0 { + contract_version: None, id, owner_id, properties, @@ -440,6 +442,13 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa Ok(total_size) } + /// Generation 0 plus the document serialization format 3 + /// contract-version stamp varint (worst case 5 bytes for a u32). + /// Selected together with format 3 by the version table. + fn estimated_size_v1(&self, platform_version: &PlatformVersion) -> Result { + Ok(self.estimated_size_v0(platform_version)?.saturating_add(5)) + } + fn max_size_v0(&self, platform_version: &PlatformVersion) -> Result { let mut total_size = 0u16; diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 3492b399571..60bbbb9e53f 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -40,6 +40,37 @@ pub const EMPTY_TREE_STORAGE_SIZE: usize = 33; pub const MAX_INDEX_SIZE: usize = 255; pub const STORAGE_FLAGS_SIZE: usize = 2; +/// A `requiredSince` annotation may never exceed the version of the contract +/// carrying it — requiredness cannot be pre-scheduled at a future version. +/// Runs over the *parsed* properties, so annotations reached through `$ref` +/// are covered. Called wherever document types are built from a contract's +/// serialized form (creates, updates, and disk loads all pass through +/// there); a no-op for every contract predating the keyword, since their +/// properties carry no annotation. +pub(crate) fn validate_required_since_within_contract_version( + document_types: &std::collections::BTreeMap, + contract_version: u32, +) -> Result<(), crate::data_contract::errors::DataContractError> { + use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; + + for (document_type_name, document_type) in document_types { + for (property_name, property) in document_type.as_ref().properties() { + if let Some(required_since) = property.required_since { + if required_since > contract_version { + return Err( + crate::data_contract::errors::DataContractError::InvalidContractStructure( + format!( + "property '{property_name}' of document type '{document_type_name}' carries requiredSince {required_since} which exceeds the contract version {contract_version}" + ), + ), + ); + } + } + } + } + Ok(()) +} + pub(crate) mod property_names { pub const DOCUMENTS_KEEP_HISTORY: &str = "documentsKeepHistory"; pub const KEEPS_TRANSFER_HISTORY: &str = "keepsTransferHistory"; @@ -62,6 +93,7 @@ pub(crate) mod property_names { pub const PROPERTIES: &str = "properties"; pub const POSITION: &str = "position"; pub const REQUIRED: &str = "required"; + pub const REQUIRED_SINCE: &str = "requiredSince"; pub const TRANSIENT: &str = "transient"; pub const TYPE: &str = "type"; pub const REF: &str = "$ref"; diff --git a/packages/rs-dpp/src/data_contract/document_type/property/byte_array_encoding_flip_tests.rs b/packages/rs-dpp/src/data_contract/document_type/property/byte_array_encoding_flip_tests.rs index 832ba61c864..948b0401352 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/byte_array_encoding_flip_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/byte_array_encoding_flip_tests.rs @@ -105,6 +105,7 @@ fn build_document_with_ff_prefixed_bytes(_contract: &DataContract) -> Document { properties.insert(BYTE_ARRAY_FIELD.to_string(), Value::Bytes32(bytes)); DocumentV0 { + contract_version: None, id: Identifier::new([1; 32]), owner_id: Identifier::new([2; 32]), properties, diff --git a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs index 7b1dc041afa..18a81473b07 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs @@ -39,6 +39,25 @@ pub struct DocumentProperty { pub property_type: DocumentPropertyType, pub required: bool, pub transient: bool, + /// The contract version this property is required from (`requiredSince`). + /// `None` for plain-required properties (required at every version) and + /// for optional properties. Only ever `Some` when `required` is true. + #[serde(skip_serializing_if = "Option::is_none")] + pub required_since: Option, +} + +impl DocumentProperty { + /// Whether this property is required for a document whose bytes conform to + /// `contract_version` (the document's stamp). `None` means the document + /// was serialized before format 3, which predates every `requiredSince` + /// annotation, so only unconditionally required properties count. + pub fn required_at(&self, contract_version: Option) -> bool { + self.required + && match self.required_since { + None => true, + Some(since) => contract_version.is_some_and(|version| version >= since), + } + } } #[derive(Debug, PartialEq, Clone, Serialize)] @@ -2825,6 +2844,7 @@ mod tests { property_type: DocumentPropertyType::U32, required: true, transient: false, + required_since: None, }, ); sub_fields.insert( @@ -2833,6 +2853,7 @@ mod tests { property_type: DocumentPropertyType::U64, required: true, transient: false, + required_since: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -5121,6 +5142,7 @@ mod tests { }), required: true, transient: false, + required_since: None, }, ); inner_fields.insert( @@ -5129,6 +5151,7 @@ mod tests { property_type: DocumentPropertyType::U32, required: true, transient: false, + required_since: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -5179,6 +5202,7 @@ mod tests { }), required: true, transient: false, + required_since: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -5198,6 +5222,7 @@ mod tests { property_type: DocumentPropertyType::U32, required: true, transient: false, + required_since: None, }, ); inner_fields.insert( @@ -5206,6 +5231,7 @@ mod tests { property_type: DocumentPropertyType::U64, required: false, transient: false, + required_since: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -5638,6 +5664,7 @@ mod tests { property_type: DocumentPropertyType::U32, required: true, transient: false, + required_since: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -5672,6 +5699,7 @@ mod tests { property_type: DocumentPropertyType::U32, required: true, transient: false, + required_since: None, }, ); sub_fields.insert( @@ -5680,6 +5708,7 @@ mod tests { property_type: DocumentPropertyType::U64, required: true, transient: false, + required_since: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -5697,6 +5726,7 @@ mod tests { property_type: DocumentPropertyType::U16, required: true, transient: false, + required_since: None, }, ); sub_fields.insert( @@ -5705,6 +5735,7 @@ mod tests { property_type: DocumentPropertyType::Boolean, required: true, transient: false, + required_since: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -5996,6 +6027,7 @@ mod tests { property_type: DocumentPropertyType::U32, required: true, transient: false, + required_since: None, }, ); sub_fields.insert( @@ -6004,6 +6036,7 @@ mod tests { property_type: DocumentPropertyType::U64, required: false, transient: false, + required_since: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -6063,6 +6096,7 @@ mod tests { property_type: DocumentPropertyType::U32, required: true, transient: false, + required_since: None, }, ); sub_fields.insert( @@ -6071,6 +6105,7 @@ mod tests { property_type: DocumentPropertyType::U64, required: false, transient: false, + required_since: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -6156,6 +6191,7 @@ mod tests { property_type: DocumentPropertyType::U8, required: true, transient: false, + required_since: None, }, ); sub_fields.insert( @@ -6164,6 +6200,7 @@ mod tests { property_type: DocumentPropertyType::Boolean, required: false, transient: false, + required_since: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -6429,6 +6466,7 @@ mod tests { property_type: DocumentPropertyType::U32, required: true, transient: false, + required_since: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -6457,6 +6495,7 @@ mod tests { property_type: DocumentPropertyType::U32, required: false, transient: false, + required_since: None, }, ); // Second field is required @@ -6466,6 +6505,7 @@ mod tests { property_type: DocumentPropertyType::U32, required: true, transient: false, + required_since: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -6581,6 +6621,7 @@ mod tests { }), required: true, transient: false, + required_since: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -6598,6 +6639,7 @@ mod tests { property_type: DocumentPropertyType::U32, required: false, transient: false, + required_since: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -6903,6 +6945,7 @@ mod tests { property_type: DocumentPropertyType::U8, required: true, transient: false, + required_since: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -7165,6 +7208,7 @@ mod tests { ), required: false, transient: false, + required_since: None, }; let value = serde_json::to_value(&property).expect("serialization should succeed"); diff --git a/packages/rs-dpp/src/data_contract/document_type/random_document.rs b/packages/rs-dpp/src/data_contract/document_type/random_document.rs index 39394248c1a..7797534e8dd 100644 --- a/packages/rs-dpp/src/data_contract/document_type/random_document.rs +++ b/packages/rs-dpp/src/data_contract/document_type/random_document.rs @@ -376,6 +376,7 @@ pub trait CreateRandomDocument: DocumentTypeV0Getters + DocumentTypeV0Methods { .document_structure_version { 0 => Ok(DocumentV0 { + contract_version: None, id, properties, owner_id, diff --git a/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs index bd369d8ed2b..b1bbcbca3cb 100644 --- a/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs @@ -16,6 +16,14 @@ //! diffing, so index definitions are validated in exactly one place. Only //! the document type's own `indices` keyword is removed; a *property* named //! `indices` lives under `/properties/indices` and is still validated. +//! +//! The top-level `required` key is stripped for the same reason: top-level +//! requiredness changes are judged by `validate_update` v1's +//! `validate_required_fields_update`, which admits exactly one change the +//! differ's frozen `required` rule cannot express — a brand-new property +//! added as required with `requiredSince` equal to the version the update +//! creates. Nested `required` arrays (under `/properties//required`) +//! remain frozen by the differ. use crate::data_contract::document_type::schema::IncompatibleJsonSchemaOperation; use crate::data_contract::errors::{DataContractError, JsonSchemaError}; @@ -48,30 +56,41 @@ static OPTIONS: Lazy = Lazy::new(|| { } }); -fn without_indices(schema: &JsonValue) -> Cow<'_, JsonValue> { +/// Strips the two top-level keys whose changes are validated by dedicated +/// checks in `validate_update` v1 instead of the JSON diff: `indices` +/// (index definitions compared by name) and `required` +/// (`validate_required_fields_update`, which admits new-property additions +/// annotated with `requiredSince`). Only the document type's own top-level +/// keys are removed; a nested object property's `required` array lives under +/// `/properties//required` and stays governed by the differ's frozen +/// `required` rule, as do properties named `indices` or `required`. +fn without_top_level_validated_keys(schema: &JsonValue) -> Cow<'_, JsonValue> { match schema { - JsonValue::Object(map) if map.contains_key("indices") => { + JsonValue::Object(map) if map.contains_key("indices") || map.contains_key("required") => { let mut map = map.clone(); map.remove("indices"); + map.remove("required"); Cow::Owned(JsonValue::Object(map)) } _ => Cow::Borrowed(schema), } } -/// Pairing invariant: stripping `indices` unconditionally is only safe -/// because every `PlatformVersion` that selects this generation -/// (`validate_schema_compatibility: 1`) also selects a `validate_update` -/// generation of at least 1 (`dpp.validation.document_type.validate_update`), -/// which rejects every real index change before this check runs. A future -/// version table that bumps one without the other would let index changes -/// bypass compatibility validation entirely. +/// Pairing invariant: stripping `indices` and top-level `required` +/// unconditionally is only safe because every `PlatformVersion` that selects +/// this generation (`validate_schema_compatibility: 1`) also selects a +/// `validate_update` generation of at least 1 +/// (`dpp.validation.document_type.validate_update`), which rejects every +/// real index change and every disallowed required-set change before this +/// check runs. A future version table that bumps one without the other +/// would let index or required changes bypass compatibility validation +/// entirely. pub(super) fn validate_schema_compatibility_v1( original_schema: &JsonValue, new_schema: &JsonValue, ) -> Result, ProtocolError> { - let original_schema = without_indices(original_schema); - let new_schema = without_indices(new_schema); + let original_schema = without_top_level_validated_keys(original_schema); + let new_schema = without_top_level_validated_keys(new_schema); validate_schemas_compatibility(&original_schema, &new_schema, OPTIONS.deref()) .map(|result| { diff --git a/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs b/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs index 17fdc0fe3a4..26231060fd9 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs @@ -197,6 +197,7 @@ impl DocumentTypeV0 { property_type: document_type, required, transient: false, + required_since: None, } }; @@ -526,6 +527,7 @@ impl DocumentTypeV0 { property_type: document_type, required, transient: false, + required_since: None, } }; diff --git a/packages/rs-dpp/src/data_contract/methods/validate_update/v0/mod.rs b/packages/rs-dpp/src/data_contract/methods/validate_update/v0/mod.rs index b269970d6e0..c7a38bb18d5 100644 --- a/packages/rs-dpp/src/data_contract/methods/validate_update/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/validate_update/v0/mod.rs @@ -6,7 +6,8 @@ use crate::consensus::state::token::PreProgrammedDistributionTimestampInPastErro use crate::data_contract::accessors::v0::DataContractV0Getters; use crate::consensus::basic::data_contract::{ - DuplicateKeywordsError, IncompatibleDataContractSchemaError, InvalidDataContractVersionError, + DataContractInvalidRequiredFieldsUpdateError, DuplicateKeywordsError, + IncompatibleDataContractSchemaError, InvalidDataContractVersionError, InvalidDescriptionLengthError, InvalidKeywordCharacterError, InvalidKeywordLengthError, TooManyKeywordsError, }; @@ -17,6 +18,7 @@ use crate::data_contract::accessors::v1::DataContractV1Getters; use crate::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use crate::data_contract::associated_token::token_distribution_rules::accessors::v0::TokenDistributionRulesV0Getters; use crate::data_contract::associated_token::token_pre_programmed_distribution::accessors::v0::TokenPreProgrammedDistributionV0Methods; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::schema::validate_schema_compatibility; use crate::data_contract::schema::DataContractSchemaMethodsV0; use crate::data_contract::DataContract; @@ -107,9 +109,11 @@ impl DataContract { }; // Validate document type update rules - let validate_update_result = old_document_type - .as_ref() - .validate_update(new_document_type, platform_version)?; + let validate_update_result = old_document_type.as_ref().validate_update( + new_document_type, + new_data_contract.version(), + platform_version, + )?; if !validate_update_result.is_valid() { return Ok(SimpleConsensusValidationResult::new_with_errors( @@ -118,6 +122,40 @@ impl DataContract { } } + // Document types introduced by this update have no old counterpart, + // so the per-type update validation above never sees them. Their + // `requiredSince` annotations must name the version this update + // creates — anything else would pre-schedule (or backdate) a + // wire-layout change without validation. Replay safety: this loop is + // a no-op for every contract that predates the `requiredSince` + // keyword (protocol v14's meta-schema), because such contracts can + // carry no annotation — older meta-schemas rejected the keyword at + // write time and older parsers ignore it entirely. + for (document_type_name, new_document_type) in new_data_contract.document_types() { + if self + .document_type_optional_for_name(document_type_name) + .is_some() + { + continue; + } + for (property_name, property) in new_document_type.as_ref().properties() { + if let Some(required_since) = property.required_since { + if required_since != new_data_contract.version() { + return Ok(SimpleConsensusValidationResult::new_with_error( + DataContractInvalidRequiredFieldsUpdateError::new( + document_type_name.clone(), + format!( + "new document type property '{property_name}' must carry requiredSince {}, the contract version this update creates", + new_data_contract.version() + ), + ) + .into(), + )); + } + } + } + } + // Schema $defs should be compatible if let Some(old_defs_map) = self.schema_defs() { // If new contract doesn't have $defs, it means that it's $defs was removed and compatibility is broken @@ -364,6 +402,85 @@ mod tests { use crate::identity::accessors::IdentityGettersV0; use crate::prelude::Identity; + #[test] + fn should_validate_required_since_on_document_types_added_by_the_update() { + let platform_version = PlatformVersion::latest(); + + let old_data_contract = get_data_contract_fixture( + None, + IdentityNonce::default(), + platform_version.protocol_version, + ) + .data_contract_owned(); + + let new_type_schema = |required_since: u32| { + platform_value!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0, + "maxLength": 60_u32, + "requiredSince": required_since, + } + }, + "required": ["message"], + "additionalProperties": false + }) + }; + + // A new document type pre-scheduling requiredness at version 99 + // has no old counterpart, so the per-type update validation + // never runs on it — this pass must catch it + let mut new_data_contract = old_data_contract.clone(); + new_data_contract.set_version(old_data_contract.version() + 1); + new_data_contract + .set_document_schema( + "note", + new_type_schema(99), + false, + &mut Vec::new(), + platform_version, + ) + .expect("should add document type"); + + let result = old_data_contract + .validate_update(&new_data_contract, &BlockInfo::default(), platform_version) + .expect("failed validate update"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidRequiredFieldsUpdateError(e) + )] if e.details().contains("must carry requiredSince 2") + ); + + // The same new document type annotated with the version this + // update creates is accepted + let mut new_data_contract = old_data_contract.clone(); + new_data_contract.set_version(old_data_contract.version() + 1); + new_data_contract + .set_document_schema( + "note", + new_type_schema(old_data_contract.version() + 1), + false, + &mut Vec::new(), + platform_version, + ) + .expect("should add document type"); + + let result = old_data_contract + .validate_update(&new_data_contract, &BlockInfo::default(), platform_version) + .expect("failed validate update"); + + assert!( + result.is_valid(), + "a new document type annotated with the version this update \ + creates must be accepted, got {:?}", + result.errors + ); + } + #[test] fn should_return_invalid_result_if_owner_id_is_not_the_same() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-dpp/src/data_contract/v0/serialization/mod.rs b/packages/rs-dpp/src/data_contract/v0/serialization/mod.rs index 2fcee85088d..beb38d035b5 100644 --- a/packages/rs-dpp/src/data_contract/v0/serialization/mod.rs +++ b/packages/rs-dpp/src/data_contract/v0/serialization/mod.rs @@ -101,6 +101,12 @@ impl DataContractV0 { platform_version, )?; + crate::data_contract::document_type::validate_required_since_within_contract_version( + &document_types, + version, + ) + .map_err(ProtocolError::DataContractError)?; + let data_contract = DataContractV0 { id, version, @@ -144,6 +150,12 @@ impl DataContractV0 { platform_version, )?; + crate::data_contract::document_type::validate_required_since_within_contract_version( + &document_types, + version, + ) + .map_err(ProtocolError::DataContractError)?; + let data_contract = DataContractV0 { id, version, diff --git a/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs b/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs index c044aaf0833..b12357388bc 100644 --- a/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs +++ b/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs @@ -100,6 +100,12 @@ impl DataContractV1 { platform_version, )?; + crate::data_contract::document_type::validate_required_since_within_contract_version( + &document_types, + version, + ) + .map_err(ProtocolError::DataContractError)?; + let data_contract = DataContractV1 { id, version, @@ -161,6 +167,12 @@ impl DataContractV1 { platform_version, )?; + crate::data_contract::document_type::validate_required_since_within_contract_version( + &document_types, + version, + ) + .map_err(ProtocolError::DataContractError)?; + let data_contract = DataContractV1 { id, version, diff --git a/packages/rs-dpp/src/document/accessors/mod.rs b/packages/rs-dpp/src/document/accessors/mod.rs index ecae8be34d1..d629cbffcbd 100644 --- a/packages/rs-dpp/src/document/accessors/mod.rs +++ b/packages/rs-dpp/src/document/accessors/mod.rs @@ -116,6 +116,12 @@ impl DocumentV0Getters for Document { Document::V0(v0) => v0.creator_id, } } + + fn contract_version(&self) -> Option { + match self { + Document::V0(v0) => v0.contract_version, + } + } } impl DocumentV0Setters for Document { @@ -213,6 +219,12 @@ impl DocumentV0Setters for Document { Document::V0(v0) => v0.creator_id = creator_id, } } + + fn set_contract_version(&mut self, contract_version: Option) { + match self { + Document::V0(v0) => v0.contract_version = contract_version, + } + } } #[cfg(test)] @@ -223,6 +235,7 @@ mod tests { fn make_doc() -> Document { Document::V0(DocumentV0 { + contract_version: None, id: Identifier::new([1u8; 32]), owner_id: Identifier::new([2u8; 32]), properties: BTreeMap::new(), diff --git a/packages/rs-dpp/src/document/accessors/v0/mod.rs b/packages/rs-dpp/src/document/accessors/v0/mod.rs index b2de832bc36..b448e060016 100644 --- a/packages/rs-dpp/src/document/accessors/v0/mod.rs +++ b/packages/rs-dpp/src/document/accessors/v0/mod.rs @@ -48,6 +48,9 @@ pub trait DocumentV0Getters { fn updated_at_core_block_height(&self) -> Option; fn transferred_at_core_block_height(&self) -> Option; fn creator_id(&self) -> Option; + /// The data contract version this document's bytes conform to (the + /// serialization format 3 stamp); `None` for pre-stamp documents. + fn contract_version(&self) -> Option; } pub trait DocumentV0Setters: DocumentV0Getters { @@ -158,4 +161,7 @@ pub trait DocumentV0Setters: DocumentV0Getters { /// - `creator_id`: An `Option` to set as the document's creator ID. /// `None` indicates the creator ID is not available. fn set_creator_id(&mut self, creator_id: Option); + /// Sets the contract-version stamp: the data contract version this + /// document's bytes conform to. + fn set_contract_version(&mut self, contract_version: Option); } diff --git a/packages/rs-dpp/src/document/document_event.rs b/packages/rs-dpp/src/document/document_event.rs index b28086ce9a0..84477f13e93 100644 --- a/packages/rs-dpp/src/document/document_event.rs +++ b/packages/rs-dpp/src/document/document_event.rs @@ -103,6 +103,7 @@ impl DocumentEvent { } DocumentV0 { + contract_version: None, id: document_id, owner_id, properties, diff --git a/packages/rs-dpp/src/document/document_factory/v0/mod.rs b/packages/rs-dpp/src/document/document_factory/v0/mod.rs index 269dc16c092..55f82eedb26 100644 --- a/packages/rs-dpp/src/document/document_factory/v0/mod.rs +++ b/packages/rs-dpp/src/document/document_factory/v0/mod.rs @@ -574,6 +574,7 @@ mod test { platform_value::Value::Array(vec![]), ); let document_v0 = DocumentV0 { + contract_version: None, id: document_id, owner_id, properties, diff --git a/packages/rs-dpp/src/document/document_methods/get_raw_for_document_type/v0/mod.rs b/packages/rs-dpp/src/document/document_methods/get_raw_for_document_type/v0/mod.rs index d2e5464cf5a..e7c3eabb2e6 100644 --- a/packages/rs-dpp/src/document/document_methods/get_raw_for_document_type/v0/mod.rs +++ b/packages/rs-dpp/src/document/document_methods/get_raw_for_document_type/v0/mod.rs @@ -96,6 +96,7 @@ mod tests { fn make_document_with_known_ids() -> DocumentV0 { DocumentV0 { + contract_version: None, id: Identifier::new([0xAA; 32]), owner_id: Identifier::new([0xBB; 32]), properties: BTreeMap::new(), @@ -399,6 +400,7 @@ mod tests { fn minimal_doc() -> DocumentV0 { DocumentV0 { + contract_version: None, id: Identifier::new([1u8; 32]), owner_id: Identifier::new([2u8; 32]), properties: BTreeMap::new(), diff --git a/packages/rs-dpp/src/document/document_methods/is_equal_ignoring_timestamps/v0/mod.rs b/packages/rs-dpp/src/document/document_methods/is_equal_ignoring_timestamps/v0/mod.rs index a27dcb20c27..14c2a5ed610 100644 --- a/packages/rs-dpp/src/document/document_methods/is_equal_ignoring_timestamps/v0/mod.rs +++ b/packages/rs-dpp/src/document/document_methods/is_equal_ignoring_timestamps/v0/mod.rs @@ -56,6 +56,7 @@ mod tests { properties.insert("score".to_string(), Value::U64(100)); DocumentV0 { + contract_version: None, id: Identifier::new([1u8; 32]), owner_id: Identifier::new([2u8; 32]), properties, diff --git a/packages/rs-dpp/src/document/extended_document/mod.rs b/packages/rs-dpp/src/document/extended_document/mod.rs index 6b9fd509bde..19c2371a840 100644 --- a/packages/rs-dpp/src/document/extended_document/mod.rs +++ b/packages/rs-dpp/src/document/extended_document/mod.rs @@ -63,6 +63,7 @@ mod json_convertible_tests { let data_contract_id = data_contract.id(); let document = Document::V0(DocumentV0 { + contract_version: None, id: Identifier::new([0xa1; 32]), owner_id: Identifier::new([0xb2; 32]), properties: BTreeMap::new(), diff --git a/packages/rs-dpp/src/document/fields.rs b/packages/rs-dpp/src/document/fields.rs index 2d88483f034..93dbd0bc200 100644 --- a/packages/rs-dpp/src/document/fields.rs +++ b/packages/rs-dpp/src/document/fields.rs @@ -6,6 +6,7 @@ pub mod property_names { pub const REVISION: &str = "$revision"; pub const OWNER_ID: &str = "$ownerId"; pub const CREATOR_ID: &str = "$creatorId"; + pub const CONTRACT_VERSION: &str = "$contractVersion"; pub const PRICE: &str = "$price"; pub const CREATED_AT: &str = "$createdAt"; pub const UPDATED_AT: &str = "$updatedAt"; diff --git a/packages/rs-dpp/src/document/mod.rs b/packages/rs-dpp/src/document/mod.rs index dee2eef4bf3..ce4d97fba80 100644 --- a/packages/rs-dpp/src/document/mod.rs +++ b/packages/rs-dpp/src/document/mod.rs @@ -344,6 +344,7 @@ mod tests { #[test] fn display_document_with_no_properties() { let doc = Document::V0(DocumentV0 { + contract_version: None, id: platform_value::Identifier::new([0xAA; 32]), owner_id: platform_value::Identifier::new([0xBB; 32]), properties: Default::default(), @@ -371,6 +372,7 @@ mod tests { #[test] fn display_document_shows_transferred_at_fields() { let doc = Document::V0(DocumentV0 { + contract_version: None, id: platform_value::Identifier::new([1u8; 32]), owner_id: platform_value::Identifier::new([2u8; 32]), properties: Default::default(), @@ -409,6 +411,7 @@ mod tests { fn display_document_shows_creator_id() { let creator = platform_value::Identifier::new([0xCC; 32]); let doc = Document::V0(DocumentV0 { + contract_version: None, id: platform_value::Identifier::new([1u8; 32]), owner_id: platform_value::Identifier::new([2u8; 32]), properties: Default::default(), @@ -436,6 +439,7 @@ mod tests { #[test] fn display_document_shows_block_height_fields() { let doc = Document::V0(DocumentV0 { + contract_version: None, id: platform_value::Identifier::new([1u8; 32]), owner_id: platform_value::Identifier::new([2u8; 32]), properties: Default::default(), @@ -466,6 +470,7 @@ mod tests { #[test] fn increment_revision_works_on_mutable_document() { let mut doc = Document::V0(DocumentV0 { + contract_version: None, id: platform_value::Identifier::new([1u8; 32]), owner_id: platform_value::Identifier::new([2u8; 32]), properties: Default::default(), @@ -490,6 +495,7 @@ mod tests { #[test] fn increment_revision_fails_when_no_revision() { let mut doc = Document::V0(DocumentV0 { + contract_version: None, id: platform_value::Identifier::new([1u8; 32]), owner_id: platform_value::Identifier::new([2u8; 32]), properties: Default::default(), @@ -618,6 +624,7 @@ mod tests { #[test] fn increment_revision_errors_on_overflow() { let mut doc = Document::V0(DocumentV0 { + contract_version: None, id: platform_value::Identifier::new([1u8; 32]), owner_id: platform_value::Identifier::new([2u8; 32]), properties: Default::default(), @@ -647,6 +654,7 @@ mod tests { #[test] fn from_document_v0_produces_v0_variant() { let v0 = DocumentV0 { + contract_version: None, id: platform_value::Identifier::new([1u8; 32]), owner_id: platform_value::Identifier::new([2u8; 32]), properties: Default::default(), @@ -676,6 +684,7 @@ mod tests { #[test] fn document_display_has_version_prefix() { let doc = Document::V0(DocumentV0 { + contract_version: None, id: platform_value::Identifier::new([1u8; 32]), owner_id: platform_value::Identifier::new([2u8; 32]), properties: Default::default(), @@ -742,6 +751,7 @@ mod json_convertible_tests { fn fixture() -> Document { Document::V0(DocumentV0 { + contract_version: None, id: Identifier::new([0xa1; 32]), owner_id: Identifier::new([0xb2; 32]), properties: BTreeMap::new(), diff --git a/packages/rs-dpp/src/document/serialization_traits/platform_serialization_conversion/deserialize/v0/mod.rs b/packages/rs-dpp/src/document/serialization_traits/platform_serialization_conversion/deserialize/v0/mod.rs index 5db85a579fa..aa6686d6ccb 100644 --- a/packages/rs-dpp/src/document/serialization_traits/platform_serialization_conversion/deserialize/v0/mod.rs +++ b/packages/rs-dpp/src/document/serialization_traits/platform_serialization_conversion/deserialize/v0/mod.rs @@ -35,6 +35,18 @@ pub(in crate::document) trait DocumentPlatformDeserializationMethodsV0 { ) -> Result where Self: Sized; + + /// Reads a serialized document and creates a Document from it. + /// Version 3 has the contract version stamp, which selects each + /// `requiredSince` property's byte layout (raw when the stamp reaches the + /// property's `requiredSince`, presence-flagged otherwise). + fn from_bytes_v3( + serialized_document: &[u8], + document_type: DocumentTypeRef, + platform_version: &PlatformVersion, + ) -> Result + where + Self: Sized; } #[cfg(feature = "extended-document")] diff --git a/packages/rs-dpp/src/document/serialization_traits/platform_serialization_conversion/serialize/v0/mod.rs b/packages/rs-dpp/src/document/serialization_traits/platform_serialization_conversion/serialize/v0/mod.rs index 5f6238667b8..80700fd9a92 100644 --- a/packages/rs-dpp/src/document/serialization_traits/platform_serialization_conversion/serialize/v0/mod.rs +++ b/packages/rs-dpp/src/document/serialization_traits/platform_serialization_conversion/serialize/v0/mod.rs @@ -24,6 +24,15 @@ pub(in crate::document) trait DocumentPlatformSerializationMethodsV0 { /// id 32 bytes + owner_id 32 bytes + encoded values byte arrays /// Serialize v2 will serialize the creator id if the document can be transferred or sold fn serialize_v2(&self, document_type: DocumentTypeRef) -> Result, ProtocolError>; + + /// Serializes the document. + /// + /// The serialization of a document follows the pattern: + /// contract version stamp varint + id 32 bytes + owner_id 32 bytes + encoded values byte arrays + /// Serialize v3 stamps the document with the data contract version its + /// bytes conform to, and encodes a property whose `requiredSince` exceeds + /// the stamp with a presence flag instead of raw + fn serialize_v3(&self, document_type: DocumentTypeRef) -> Result, ProtocolError>; } #[cfg(feature = "extended-document")] diff --git a/packages/rs-dpp/src/document/serialization_traits/platform_value_conversion/mod.rs b/packages/rs-dpp/src/document/serialization_traits/platform_value_conversion/mod.rs index 5d451bce535..2e6c7ea9eb3 100644 --- a/packages/rs-dpp/src/document/serialization_traits/platform_value_conversion/mod.rs +++ b/packages/rs-dpp/src/document/serialization_traits/platform_value_conversion/mod.rs @@ -159,6 +159,7 @@ mod tests { let owner_id = Identifier::new([2u8; 32]); let doc_v0 = DocumentV0 { + contract_version: None, id, owner_id, properties: std::collections::BTreeMap::new(), diff --git a/packages/rs-dpp/src/document/v0/accessors.rs b/packages/rs-dpp/src/document/v0/accessors.rs index 7d0b47f0de3..7ce2269f81c 100644 --- a/packages/rs-dpp/src/document/v0/accessors.rs +++ b/packages/rs-dpp/src/document/v0/accessors.rs @@ -160,6 +160,10 @@ impl DocumentV0Getters for DocumentV0 { fn creator_id(&self) -> Option { self.creator_id } + + fn contract_version(&self) -> Option { + self.contract_version + } } impl DocumentV0Setters for DocumentV0 { @@ -290,4 +294,11 @@ impl DocumentV0Setters for DocumentV0 { fn set_creator_id(&mut self, creator_id: Option) { self.creator_id = creator_id; } + + /// Sets the contract-version stamp: the data contract version this + /// document's bytes conform to. Assigned by Drive when document content + /// is (re-)supplied; `None` for pre-stamp documents. + fn set_contract_version(&mut self, contract_version: Option) { + self.contract_version = contract_version; + } } diff --git a/packages/rs-dpp/src/document/v0/cbor_conversion.rs b/packages/rs-dpp/src/document/v0/cbor_conversion.rs index 190fa5ffa69..632d9c5ea68 100644 --- a/packages/rs-dpp/src/document/v0/cbor_conversion.rs +++ b/packages/rs-dpp/src/document/v0/cbor_conversion.rs @@ -58,6 +58,15 @@ pub struct DocumentForCbor { #[serde(rename = "$creatorId")] pub creator_id: Option, + + /// The contract-version stamp. Skipped when absent so pre-stamp CBOR + /// output stays byte-identical; `default` keeps old CBOR readable. + #[serde( + rename = "$contractVersion", + default, + skip_serializing_if = "Option::is_none" + )] + pub contract_version: Option, } #[cfg(feature = "cbor")] @@ -80,8 +89,10 @@ impl TryFrom for DocumentForCbor { updated_at_core_block_height, transferred_at_core_block_height, creator_id, + contract_version, } = value; Ok(DocumentForCbor { + contract_version, id: id.to_buffer(), properties: Value::convert_to_cbor_map(properties) .map_err(ProtocolError::ValueError)?, @@ -146,8 +157,12 @@ impl DocumentV0 { .remove_optional_identifier(property_names::CREATOR_ID) .map_err(ProtocolError::ValueError)?; + let contract_version = + document_map.remove_optional_integer(property_names::CONTRACT_VERSION)?; + // dev-note: properties is everything other than the id and owner id Ok(DocumentV0 { + contract_version, properties: document_map, owner_id: Identifier::new(owner_id), id: Identifier::new(id), @@ -229,6 +244,7 @@ mod tests { properties.insert("name".to_string(), Value::Text("Alice".to_string())); properties.insert("age".to_string(), Value::U64(30)); DocumentV0 { + contract_version: None, id, owner_id, properties, @@ -250,6 +266,39 @@ mod tests { // Round-trip: to_cbor -> from_cbor preserves document data // ================================================================ + #[test] + fn cbor_round_trip_preserves_contract_version_stamp() { + use crate::document::Document; + + let platform_version = PlatformVersion::latest(); + let mut document = make_document_v0_with_timestamps(); + document.contract_version = Some(7); + + let cbor = document.to_cbor().expect("expected to serialize to cbor"); + let restored = Document::from_cbor(&cbor, None, None, platform_version) + .expect("expected to deserialize from cbor"); + + let Document::V0(restored) = restored; + assert_eq!(restored.contract_version, Some(7)); + assert_eq!(restored.id, document.id); + assert_eq!(restored.revision, document.revision); + // (full property equality is not asserted: CBOR decodes integers as + // I128, a pre-existing normalization of this legacy path) + assert_eq!( + restored.properties.get("name"), + document.properties.get("name") + ); + + // An unstamped document round-trips to no stamp (the key is skipped + // entirely when absent, keeping pre-stamp CBOR byte-identical) + let unstamped = make_document_v0_with_timestamps(); + let unstamped_cbor = unstamped.to_cbor().expect("expected to serialize to cbor"); + let restored_unstamped = Document::from_cbor(&unstamped_cbor, None, None, platform_version) + .expect("expected to deserialize from cbor"); + let Document::V0(restored_unstamped) = restored_unstamped; + assert_eq!(restored_unstamped.contract_version, None); + } + #[test] fn cbor_round_trip_with_random_dashpay_profile() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-dpp/src/document/v0/mod.rs b/packages/rs-dpp/src/document/v0/mod.rs index 130908e1419..406d36023d8 100644 --- a/packages/rs-dpp/src/document/v0/mod.rs +++ b/packages/rs-dpp/src/document/v0/mod.rs @@ -98,6 +98,20 @@ pub struct DocumentV0 { /// The creator id. #[cfg_attr(feature = "serde-conversion", serde(rename = "$creatorId", default))] pub creator_id: Option, + /// The data contract version this document's bytes conform to — assigned + /// by Drive when document content is (re-)supplied (create/replace) and + /// preserved across server-side rewrites (transfer/purchase). Selects the + /// per-property byte layout when the document type carries `requiredSince` + /// annotations. `None` for documents serialized before format 3. + #[cfg_attr( + feature = "serde-conversion", + serde( + rename = "$contractVersion", + default, + skip_serializing_if = "Option::is_none" + ) + )] + pub contract_version: Option, } impl DocumentGetRawForContractV0 for DocumentV0 { @@ -197,6 +211,7 @@ mod tests { fn minimal_doc() -> DocumentV0 { DocumentV0 { + contract_version: None, id: Identifier::new([1u8; 32]), owner_id: Identifier::new([2u8; 32]), properties: BTreeMap::new(), diff --git a/packages/rs-dpp/src/document/v0/platform_value_conversion.rs b/packages/rs-dpp/src/document/v0/platform_value_conversion.rs index fa2902cd9eb..8811958b5d9 100644 --- a/packages/rs-dpp/src/document/v0/platform_value_conversion.rs +++ b/packages/rs-dpp/src/document/v0/platform_value_conversion.rs @@ -22,6 +22,7 @@ mod tests { fn minimal_doc() -> DocumentV0 { DocumentV0 { + contract_version: None, id: Identifier::new([1u8; 32]), owner_id: Identifier::new([2u8; 32]), properties: BTreeMap::new(), @@ -44,6 +45,7 @@ mod tests { props.insert("name".into(), Value::Text("Eve".into())); props.insert("score".into(), Value::U64(42)); DocumentV0 { + contract_version: None, id: Identifier::new([7u8; 32]), owner_id: Identifier::new([8u8; 32]), properties: props, diff --git a/packages/rs-dpp/src/document/v0/serialize.rs b/packages/rs-dpp/src/document/v0/serialize.rs index 14d0024b444..287d97b92df 100644 --- a/packages/rs-dpp/src/document/v0/serialize.rs +++ b/packages/rs-dpp/src/document/v0/serialize.rs @@ -216,7 +216,7 @@ impl DocumentPlatformSerializationMethodsV0 for DocumentV0 { .try_for_each(|(field_name, property)| { if let Some(value) = self.properties.get(field_name) { if value.is_null() { - if property.required && !property.transient { + if property.required_at(None) && !property.transient { Err(ProtocolError::DataContractError( DataContractError::MissingRequiredKey( "a required field is not present".to_string(), @@ -229,24 +229,24 @@ impl DocumentPlatformSerializationMethodsV0 for DocumentV0 { Ok(()) } } else { - if !property.required || property.transient { + if !property.required_at(None) || property.transient { // dbg!("we added 1", field_name); buffer.push(1); } let value = if property.property_type.is_integer() { DocumentPropertyType::I64 - .encode_value_ref_with_size(value, property.required) + .encode_value_ref_with_size(value, property.required_at(None)) } else { property .property_type - .encode_value_ref_with_size(value, property.required) + .encode_value_ref_with_size(value, property.required_at(None)) }?; // dbg!("we pushed {} with {}", field_name, hex::encode(&value)); buffer.extend(value.as_slice()); Ok(()) } - } else if property.required && !property.transient { + } else if property.required_at(None) && !property.transient { Err(ProtocolError::DataContractError( DataContractError::MissingRequiredKey(format!( "a required field {field_name} is not present" @@ -440,7 +440,7 @@ impl DocumentPlatformSerializationMethodsV0 for DocumentV0 { .try_for_each(|(field_name, property)| { if let Some(value) = self.properties.get(field_name) { if value.is_null() { - if property.required && !property.transient { + if property.required_at(None) && !property.transient { Err(ProtocolError::DataContractError( DataContractError::MissingRequiredKey( "a required field is not present".to_string(), @@ -453,18 +453,18 @@ impl DocumentPlatformSerializationMethodsV0 for DocumentV0 { Ok(()) } } else { - if !property.required || property.transient { + if !property.required_at(None) || property.transient { // dbg!("we added 1", field_name); buffer.push(1); } let value = property .property_type - .encode_value_ref_with_size(value, property.required)?; + .encode_value_ref_with_size(value, property.required_at(None))?; // dbg!("we pushed {} with {}", field_name, hex::encode(&value)); buffer.extend(value.as_slice()); Ok(()) } - } else if property.required && !property.transient { + } else if property.required_at(None) && !property.transient { Err(ProtocolError::DataContractError( DataContractError::MissingRequiredKey(format!( "a required field {field_name} is not present" @@ -668,7 +668,7 @@ impl DocumentPlatformSerializationMethodsV0 for DocumentV0 { .try_for_each(|(field_name, property)| { if let Some(value) = self.properties.get(field_name) { if value.is_null() { - if property.required && !property.transient { + if property.required_at(None) && !property.transient { Err(ProtocolError::DataContractError( DataContractError::MissingRequiredKey( "a required field is not present".to_string(), @@ -681,18 +681,18 @@ impl DocumentPlatformSerializationMethodsV0 for DocumentV0 { Ok(()) } } else { - if !property.required || property.transient { + if !property.required_at(None) || property.transient { // dbg!("we added 1", field_name); buffer.push(1); } let value = property .property_type - .encode_value_ref_with_size(value, property.required)?; + .encode_value_ref_with_size(value, property.required_at(None))?; // dbg!("we pushed {} with {}", field_name, hex::encode(&value)); buffer.extend(value.as_slice()); Ok(()) } - } else if property.required && !property.transient { + } else if property.required_at(None) && !property.transient { Err(ProtocolError::DataContractError( DataContractError::MissingRequiredKey(format!( "a required field {field_name} is not present" @@ -708,11 +708,469 @@ impl DocumentPlatformSerializationMethodsV0 for DocumentV0 { Ok(buffer) } + + /// Serializes the document. + /// + /// Serialize v3 is v2 plus the contract version stamp: a varint right + /// after the format prefix recording the data contract version the bytes + /// conform to (0 = unstamped, for pre-format-3 documents that are + /// re-serialized). A property whose `requiredSince` exceeds the stamp is + /// encoded with a presence flag exactly like an optional property, so + /// documents written before the property became required stay valid. + fn serialize_v3(&self, document_type: DocumentTypeRef) -> Result, ProtocolError> { + let mut buffer: Vec = 3u64.encode_var_vec(); //version 3 + + // the contract version stamp; 0 means unstamped + buffer.extend((self.contract_version.unwrap_or_default() as u64).encode_var_vec()); + + // $id + buffer.extend(self.id.as_slice()); + + // $ownerId + buffer.extend(self.owner_id.as_slice()); + + if document_type.trade_mode() != TradeMode::None + || document_type.documents_transferable().is_transferable() + { + if let Some(creator_id) = self.creator_id { + buffer.push(1); + buffer.extend(creator_id.as_slice()); + } else { + buffer.push(0); + } + } + + // $revision + if let Some(revision) = self.revision { + buffer.extend(revision.encode_var_vec()) + } else if document_type.requires_revision() { + buffer.extend((1 as Revision).encode_var_vec()) + } + + let mut bitwise_exists_flag: u16 = 0; + + let mut time_fields_data_buffer = vec![]; + + // $createdAt + if let Some(created_at) = &self.created_at { + bitwise_exists_flag |= 1; + time_fields_data_buffer.extend(created_at.to_be_bytes()); + } else if document_type.required_fields().contains(CREATED_AT) { + return Err(ProtocolError::DataContractError( + DataContractError::MissingRequiredKey( + "created at field is not present".to_string(), + ), + )); + } + + // $updatedAt + if let Some(updated_at) = &self.updated_at { + bitwise_exists_flag |= 2; + time_fields_data_buffer.extend(updated_at.to_be_bytes()); + } else if document_type.required_fields().contains(UPDATED_AT) { + return Err(ProtocolError::DataContractError( + DataContractError::MissingRequiredKey( + "updated at field is not present".to_string(), + ), + )); + } + + // $transferredAt + if let Some(transferred_at) = &self.transferred_at { + bitwise_exists_flag |= 4; + time_fields_data_buffer.extend(transferred_at.to_be_bytes()); + } else if document_type.required_fields().contains(TRANSFERRED_AT) { + return Err(ProtocolError::DataContractError( + DataContractError::MissingRequiredKey( + "transferred at field is not present".to_string(), + ), + )); + } + + // $createdAtBlockHeight + if let Some(created_at_block_height) = &self.created_at_block_height { + bitwise_exists_flag |= 8; + time_fields_data_buffer.extend(created_at_block_height.to_be_bytes()); + } else if document_type + .required_fields() + .contains(CREATED_AT_BLOCK_HEIGHT) + { + return Err(ProtocolError::DataContractError( + DataContractError::MissingRequiredKey( + "created_at_block_height field is not present".to_string(), + ), + )); + } + + // $updatedAtBlockHeight + if let Some(updated_at_block_height) = &self.updated_at_block_height { + bitwise_exists_flag |= 16; + time_fields_data_buffer.extend(updated_at_block_height.to_be_bytes()); + } else if document_type + .required_fields() + .contains(UPDATED_AT_BLOCK_HEIGHT) + { + return Err(ProtocolError::DataContractError( + DataContractError::MissingRequiredKey( + "updated_at_block_height field is not present".to_string(), + ), + )); + } + + // $transferredAtBlockHeight + if let Some(transferred_at_block_height) = &self.transferred_at_block_height { + bitwise_exists_flag |= 32; + time_fields_data_buffer.extend(transferred_at_block_height.to_be_bytes()); + } else if document_type + .required_fields() + .contains(TRANSFERRED_AT_BLOCK_HEIGHT) + { + return Err(ProtocolError::DataContractError( + DataContractError::MissingRequiredKey( + "transferred_at_block_height field is not present".to_string(), + ), + )); + } + + // $createdAtCoreBlockHeight + if let Some(created_at_core_block_height) = &self.created_at_core_block_height { + bitwise_exists_flag |= 64; + time_fields_data_buffer.extend(created_at_core_block_height.to_be_bytes()); + } else if document_type + .required_fields() + .contains(CREATED_AT_CORE_BLOCK_HEIGHT) + { + return Err(ProtocolError::DataContractError( + DataContractError::MissingRequiredKey( + "created_at_core_block_height field is not present".to_string(), + ), + )); + } + + // $updatedAtCoreBlockHeight + if let Some(updated_at_core_block_height) = &self.updated_at_core_block_height { + bitwise_exists_flag |= 128; + time_fields_data_buffer.extend(updated_at_core_block_height.to_be_bytes()); + } else if document_type + .required_fields() + .contains(UPDATED_AT_CORE_BLOCK_HEIGHT) + { + return Err(ProtocolError::DataContractError( + DataContractError::MissingRequiredKey( + "updated_at_core_block_height field is not present".to_string(), + ), + )); + } + + // $transferredAtCoreBlockHeight + if let Some(transferred_at_core_block_height) = &self.transferred_at_core_block_height { + bitwise_exists_flag |= 256; + time_fields_data_buffer.extend(transferred_at_core_block_height.to_be_bytes()); + } else if document_type + .required_fields() + .contains(TRANSFERRED_AT_CORE_BLOCK_HEIGHT) + { + return Err(ProtocolError::DataContractError( + DataContractError::MissingRequiredKey( + "transferred_at_core_block_height field is not present".to_string(), + ), + )); + } + + buffer.extend(bitwise_exists_flag.to_be_bytes().as_slice()); + buffer.append(&mut time_fields_data_buffer); + + // Now we serialize the price which might not be necessary unless called for by the document type + + if document_type.trade_mode().seller_sets_price() { + if let Some(price) = self.properties.get(PRICE) { + buffer.push(1); + let price_as_u64: u64 = price.to_integer().map_err(ProtocolError::ValueError)?; + buffer.append(&mut price_as_u64.to_be_bytes().to_vec()); + } else { + buffer.push(0); + } + } + + // User defined properties: requiredness is evaluated at this + // document's stamp, so a property that became required after the + // stamp keeps the presence-flagged layout it was written with + document_type + .properties() + .iter() + .try_for_each(|(field_name, property)| { + let required = property.required_at(self.contract_version); + if let Some(value) = self.properties.get(field_name) { + if value.is_null() { + if required && !property.transient { + Err(ProtocolError::DataContractError( + DataContractError::MissingRequiredKey( + "a required field is not present".to_string(), + ), + )) + } else { + // We don't have something that wasn't required + buffer.push(0); + Ok(()) + } + } else { + if !required || property.transient { + buffer.push(1); + } + let value = property + .property_type + .encode_value_ref_with_size(value, required)?; + buffer.extend(value.as_slice()); + Ok(()) + } + } else if required && !property.transient { + Err(ProtocolError::DataContractError( + DataContractError::MissingRequiredKey(format!( + "a required field {field_name} is not present" + )), + )) + } else { + // We don't have something that wasn't required + buffer.push(0); + Ok(()) + } + })?; + + Ok(buffer) + } } -impl DocumentPlatformDeserializationMethodsV0 for DocumentV0 { +impl DocumentPlatformDeserializationMethodsV0 for DocumentV0 { + /// Reads a serialized document and creates a Document from it. + fn from_bytes_v0( + serialized_document: &[u8], + document_type: DocumentTypeRef, + _platform_version: &PlatformVersion, + ) -> Result { + let mut buf = BufReader::new(serialized_document); + if serialized_document.len() < 64 { + return Err(DataContractError::DecodingDocumentError( + DecodingError::new( + "serialized document is too small, must have id and owner id".to_string(), + ), + )); + } + + // $id + let mut id = [0; 32]; + buf.read_exact(&mut id).map_err(|_| { + DataContractError::DecodingDocumentError(DecodingError::new( + "error reading from serialized document for id".to_string(), + )) + })?; + + // $ownerId + let mut owner_id = [0; 32]; + buf.read_exact(&mut owner_id).map_err(|_| { + DataContractError::DecodingDocumentError(DecodingError::new( + "error reading from serialized document for owner id".to_string(), + )) + })?; + + // $revision + // if the document type is mutable then we should deserialize the revision + let revision: Option = if document_type.requires_revision() { + let revision = buf.read_varint().map_err(|_| { + DataContractError::DecodingDocumentError(DecodingError::new( + "error reading revision from serialized document for revision".to_string(), + )) + })?; + Some(revision) + } else { + None + }; + + let timestamp_flags = buf.read_u16::().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading timestamp flags from serialized document".to_string(), + ) + })?; + + let created_at = if timestamp_flags & 1 > 0 { + Some(buf.read_u64::().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading created_at timestamp from serialized document".to_string(), + ) + })?) + } else { + None + }; + + let updated_at = if timestamp_flags & 2 > 0 { + Some(buf.read_u64::().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading updated_at timestamp from serialized document".to_string(), + ) + })?) + } else { + None + }; + + let transferred_at = if timestamp_flags & 4 > 0 { + Some(buf.read_u64::().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading transferred_at timestamp from serialized document".to_string(), + ) + })?) + } else { + None + }; + + let created_at_block_height = if timestamp_flags & 8 > 0 { + Some(buf.read_u64::().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading created_at_block_height from serialized document".to_string(), + ) + })?) + } else { + None + }; + + let updated_at_block_height = if timestamp_flags & 16 > 0 { + Some(buf.read_u64::().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading updated_at_block_height from serialized document".to_string(), + ) + })?) + } else { + None + }; + + let transferred_at_block_height = if timestamp_flags & 32 > 0 { + Some(buf.read_u64::().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading transferred_at_block_height from serialized document" + .to_string(), + ) + })?) + } else { + None + }; + + let created_at_core_block_height = if timestamp_flags & 64 > 0 { + Some(buf.read_u32::().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading created_at_core_block_height from serialized document" + .to_string(), + ) + })?) + } else { + None + }; + + let updated_at_core_block_height = if timestamp_flags & 128 > 0 { + Some(buf.read_u32::().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading updated_at_core_block_height from serialized document" + .to_string(), + ) + })?) + } else { + None + }; + + let transferred_at_core_block_height = if timestamp_flags & 256 > 0 { + Some(buf.read_u32::().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading updated_at_core_block_height from serialized document" + .to_string(), + ) + })?) + } else { + None + }; + + // Now we deserialize the price which might not be necessary unless called for by the document type + + let price = if document_type.trade_mode().seller_sets_price() { + let has_price = buf.read_u8().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading has price bool from serialized document".to_string(), + ) + })?; + if has_price > 0 { + let price = buf.read_u64::().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading price u64 from serialized document".to_string(), + ) + })?; + Some(price) + } else { + None + } + } else { + None + }; + + let mut finished_buffer = false; + + let mut properties = document_type + .properties() + .iter() + .filter_map(|(key, property)| { + if finished_buffer { + return if property.required_at(None) && !property.transient { + Some(Err(DataContractError::CorruptedSerialization( + "required field after finished buffer".to_string(), + ))) + } else { + None + }; + } + + // In version 0 all integers are encoded as I64 (in theory) + let read_value = if property.property_type.is_integer() { + DocumentPropertyType::I64.read_optionally_from( + &mut buf, + property.required_at(None) & !property.transient, + ) + } else { + property.property_type.read_optionally_from( + &mut buf, + property.required_at(None) & !property.transient, + ) + }; + + match read_value { + Ok(read_value) => { + finished_buffer |= read_value.1; + read_value.0.map(|read_value| Ok((key.clone(), read_value))) + } + Err(e) => Some(Err(e)), + } + }) + .collect::, DataContractError>>()?; + + if let Some(price) = price { + properties.insert(PRICE.to_string(), price.into()); + } + + Ok(DocumentV0 { + contract_version: None, + id: Identifier::new(id), + properties, + owner_id: Identifier::new(owner_id), + revision, + created_at, + updated_at, + transferred_at, + created_at_block_height, + updated_at_block_height, + transferred_at_block_height, + created_at_core_block_height, + updated_at_core_block_height, + transferred_at_core_block_height, + creator_id: None, + }) + } + /// Reads a serialized document and creates a Document from it. - fn from_bytes_v0( + fn from_bytes_v1( serialized_document: &[u8], document_type: DocumentTypeRef, _platform_version: &PlatformVersion, @@ -884,7 +1342,7 @@ impl DocumentPlatformDeserializationMethodsV0 for DocumentV0 { .iter() .filter_map(|(key, property)| { if finished_buffer { - return if property.required && !property.transient { + return if property.required_at(None) && !property.transient { Some(Err(DataContractError::CorruptedSerialization( "required field after finished buffer".to_string(), ))) @@ -892,16 +1350,10 @@ impl DocumentPlatformDeserializationMethodsV0 for DocumentV0 { None }; } - - // In version 0 all integers are encoded as I64 (in theory) - let read_value = if property.property_type.is_integer() { - DocumentPropertyType::I64 - .read_optionally_from(&mut buf, property.required & !property.transient) - } else { - property - .property_type - .read_optionally_from(&mut buf, property.required & !property.transient) - }; + let read_value = property.property_type.read_optionally_from( + &mut buf, + property.required_at(None) & !property.transient, + ); match read_value { Ok(read_value) => { @@ -918,6 +1370,7 @@ impl DocumentPlatformDeserializationMethodsV0 for DocumentV0 { } Ok(DocumentV0 { + contract_version: None, id: Identifier::new(id), properties, owner_id: Identifier::new(owner_id), @@ -936,7 +1389,7 @@ impl DocumentPlatformDeserializationMethodsV0 for DocumentV0 { } /// Reads a serialized document and creates a Document from it. - fn from_bytes_v1( + fn from_bytes_v2( serialized_document: &[u8], document_type: DocumentTypeRef, _platform_version: &PlatformVersion, @@ -966,6 +1419,31 @@ impl DocumentPlatformDeserializationMethodsV0 for DocumentV0 { )) })?; + // $creatorId + let creator_id: Option = if document_type.trade_mode() != TradeMode::None + || document_type.documents_transferable().is_transferable() + { + let has_creator_id = buf.read_u8().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading has creator id bool from serialized document".to_string(), + ) + })?; + if has_creator_id > 0 { + // $creatorId + let mut known_owner_id = [0; 32]; + buf.read_exact(&mut known_owner_id).map_err(|_| { + DataContractError::DecodingDocumentError(DecodingError::new( + "error reading from serialized document for creator id".to_string(), + )) + })?; + Some(known_owner_id.into()) + } else { + None + } + } else { + None + }; + // $revision // if the document type is mutable then we should deserialize the revision let revision: Option = if document_type.requires_revision() { @@ -1108,7 +1586,7 @@ impl DocumentPlatformDeserializationMethodsV0 for DocumentV0 { .iter() .filter_map(|(key, property)| { if finished_buffer { - return if property.required && !property.transient { + return if property.required_at(None) && !property.transient { Some(Err(DataContractError::CorruptedSerialization( "required field after finished buffer".to_string(), ))) @@ -1116,9 +1594,10 @@ impl DocumentPlatformDeserializationMethodsV0 for DocumentV0 { None }; } - let read_value = property - .property_type - .read_optionally_from(&mut buf, property.required & !property.transient); + let read_value = property.property_type.read_optionally_from( + &mut buf, + property.required_at(None) & !property.transient, + ); match read_value { Ok(read_value) => { @@ -1135,6 +1614,7 @@ impl DocumentPlatformDeserializationMethodsV0 for DocumentV0 { } Ok(DocumentV0 { + contract_version: None, id: Identifier::new(id), properties, owner_id: Identifier::new(owner_id), @@ -1148,25 +1628,42 @@ impl DocumentPlatformDeserializationMethodsV0 for DocumentV0 { created_at_core_block_height, updated_at_core_block_height, transferred_at_core_block_height, - creator_id: None, + creator_id, }) } /// Reads a serialized document and creates a Document from it. - fn from_bytes_v2( + /// Version 3 is version 2 plus the contract version stamp, which selects + /// each `requiredSince` property's byte layout: raw when the stamp has + /// reached the property's `requiredSince`, presence-flagged otherwise. + fn from_bytes_v3( serialized_document: &[u8], document_type: DocumentTypeRef, _platform_version: &PlatformVersion, ) -> Result { let mut buf = BufReader::new(serialized_document); - if serialized_document.len() < 64 { + if serialized_document.len() < 65 { return Err(DataContractError::DecodingDocumentError( DecodingError::new( - "serialized document is too small, must have id and owner id".to_string(), + "serialized document is too small, must have contract version, id and owner id" + .to_string(), ), )); } + // the contract version stamp; 0 means unstamped + let stamp: u64 = buf.read_varint().map_err(|_| { + DataContractError::DecodingDocumentError(DecodingError::new( + "error reading contract version stamp from serialized document".to_string(), + )) + })?; + if stamp > u32::MAX as u64 { + return Err(DataContractError::CorruptedSerialization( + "contract version stamp does not fit in a u32".to_string(), + )); + } + let contract_version = if stamp == 0 { None } else { Some(stamp as u32) }; + // $id let mut id = [0; 32]; buf.read_exact(&mut id).map_err(|_| { @@ -1349,8 +1846,9 @@ impl DocumentPlatformDeserializationMethodsV0 for DocumentV0 { .properties() .iter() .filter_map(|(key, property)| { + let required = property.required_at(contract_version); if finished_buffer { - return if property.required && !property.transient { + return if required && !property.transient { Some(Err(DataContractError::CorruptedSerialization( "required field after finished buffer".to_string(), ))) @@ -1360,7 +1858,7 @@ impl DocumentPlatformDeserializationMethodsV0 for DocumentV0 { } let read_value = property .property_type - .read_optionally_from(&mut buf, property.required & !property.transient); + .read_optionally_from(&mut buf, required & !property.transient); match read_value { Ok(read_value) => { @@ -1376,7 +1874,27 @@ impl DocumentPlatformDeserializationMethodsV0 for DocumentV0 { properties.insert(PRICE.to_string(), price.into()); } + // Every property the document was serialized with must have been + // consumed. Trailing bytes mean the document was written under a + // newer contract version than the document type used to read it — a + // stale reader would otherwise silently drop the fields it does not + // know about. The stamp makes this detectable: callers should + // refetch the contract and retry. + let mut trailing_probe = [0u8; 1]; + let trailing = buf.read(&mut trailing_probe).map_err(|_| { + DataContractError::CorruptedSerialization( + "error probing for trailing bytes in serialized document".to_string(), + ) + })?; + if trailing > 0 { + return Err(DataContractError::CorruptedSerialization(format!( + "serialized document has trailing bytes: it was serialized under contract version {} with properties this document type does not know; refetch the contract", + stamp + ))); + } + Ok(DocumentV0 { + contract_version, id: Identifier::new(id), properties, owner_id: Identifier::new(owner_id), @@ -1428,9 +1946,13 @@ impl DocumentPlatformConversionMethodsV0 for DocumentV0 { // Document types now have properties that are known to be things like u8, i32 etc. 1 => self.serialize_v1(document_type), 2 => self.serialize_v2(document_type), + // Version 3 coincides with protocol version 14: it stamps the + // document with the contract version its bytes conform to, + // enabling `requiredSince` properties. + 3 => self.serialize_v3(document_type), version => Err(ProtocolError::UnknownVersionMismatch { method: "DocumentV0::serialize".to_string(), - known_versions: vec![0, 1, 2], + known_versions: vec![0, 1, 2, 3], received: version, }), } @@ -1458,9 +1980,10 @@ impl DocumentPlatformConversionMethodsV0 for DocumentV0 { 0 => self.serialize_v0(document_type), 1 => self.serialize_v1(document_type), 2 => self.serialize_v2(document_type), + 3 => self.serialize_v3(document_type), version => Err(ProtocolError::UnknownVersionMismatch { method: "DocumentV0::serialize".to_string(), - known_versions: vec![0, 1, 2], + known_versions: vec![0, 1, 2, 3], received: version, }), } @@ -1510,9 +2033,11 @@ impl DocumentPlatformConversionMethodsV0 for DocumentV0 { .map_err(ProtocolError::DataContractError), 2 => DocumentV0::from_bytes_v2(serialized_document, document_type, platform_version) .map_err(ProtocolError::DataContractError), + 3 => DocumentV0::from_bytes_v3(serialized_document, document_type, platform_version) + .map_err(ProtocolError::DataContractError), version => Err(ProtocolError::UnknownVersionMismatch { method: "Document::from_bytes (deserialization)".to_string(), - known_versions: vec![0, 1, 2], + known_versions: vec![0, 1, 2, 3], received: version, }), } @@ -1585,9 +2110,21 @@ impl DocumentPlatformConversionMethodsV0 for DocumentV0 { )), } } + 3 => { + match DocumentV0::from_bytes_v3( + serialized_document, + document_type, + platform_version, + ) { + Ok(document) => Ok(ConsensusValidationResult::new_with_data(document)), + Err(err) => Ok(ConsensusValidationResult::new_with_error( + ConsensusError::BasicError(BasicError::ContractError(err)), + )), + } + } version => Err(ProtocolError::UnknownVersionMismatch { method: "Document::from_bytes (deserialization)".to_string(), - known_versions: vec![0, 1, 2], + known_versions: vec![0, 1, 2, 3], received: version, }), } @@ -2147,6 +2684,7 @@ mod tests { fn doc_with_ids() -> DocumentV0 { DocumentV0 { + contract_version: None, id: Identifier::new([1u8; 32]), owner_id: Identifier::new([2u8; 32]), properties: BTreeMap::new(), @@ -2606,4 +3144,486 @@ mod tests { assert_eq!(deserialized.created_at, Some(1750244879636)); assert_eq!(deserialized.updated_at, Some(1750244879636)); } + + // ================================================================ + // Format 3: the contract-version stamp and requiredSince layouts + // ================================================================ + + /// A document type with: + /// - `a`: required at every version + /// - `b`: required since contract version 2 + /// - `c`: plain optional + /// A document type exercising every schema-reachable property type in + /// both required and optional positions. Not represented because no + /// document schema can produce them (`try_from_value_map` dispatches on + /// `"type"` only): `Date` (no `"date"` arm; only array item types and + /// system fields use it via `try_from_name`) and u128/i128 (integer + /// bound inference is i64-limited). + fn kitchen_sink_document_type() -> crate::data_contract::document_type::DocumentType { + use crate::data_contract::config::DataContractConfig; + use crate::data_contract::document_type::DocumentType; + use platform_value::platform_value; + use std::collections::BTreeMap; + + let platform_version = PlatformVersion::latest(); + let schema = platform_value!({ + "type": "object", + "properties": { + "u8v": {"type": "integer", "position": 0, "minimum": 0, "maximum": 255}, + "u16v": {"type": "integer", "position": 1, "minimum": 0, "maximum": 65535}, + "u32v": {"type": "integer", "position": 2, "minimum": 0, "maximum": 4294967295_u64}, + "i8v": {"type": "integer", "position": 3, "minimum": -128, "maximum": 127}, + "i16v": {"type": "integer", "position": 4, "minimum": -32768, "maximum": 32767}, + "i32v": {"type": "integer", "position": 5, "minimum": -2147483648_i64, "maximum": 2147483647_i64}, + "i64v": {"type": "integer", "position": 6}, + "f64v": {"type": "number", "position": 7}, + "strv": {"type": "string", "position": 8, "maxLength": 60_u32}, + "bytv": {"type": "array", "position": 9, "byteArray": true, "minItems": 0, "maxItems": 32}, + "idv": {"type": "array", "position": 10, "byteArray": true, "minItems": 32, "maxItems": 32, "contentMediaType": "application/x.dash.dpp.identifier"}, + "boolv": {"type": "boolean", "position": 11}, + "u8o": {"type": "integer", "position": 12, "minimum": 0, "maximum": 255}, + "u16o": {"type": "integer", "position": 13, "minimum": 0, "maximum": 65535}, + "u32o": {"type": "integer", "position": 14, "minimum": 0, "maximum": 4294967295_u64}, + "i8o": {"type": "integer", "position": 15, "minimum": -128, "maximum": 127}, + "i16o": {"type": "integer", "position": 16, "minimum": -32768, "maximum": 32767}, + "i32o": {"type": "integer", "position": 17, "minimum": -2147483648_i64, "maximum": 2147483647_i64}, + "i64o": {"type": "integer", "position": 18}, + "f64o": {"type": "number", "position": 19}, + "stro": {"type": "string", "position": 20, "maxLength": 60_u32}, + "byto": {"type": "array", "position": 21, "byteArray": true, "minItems": 0, "maxItems": 32}, + "ido": {"type": "array", "position": 22, "byteArray": true, "minItems": 32, "maxItems": 32, "contentMediaType": "application/x.dash.dpp.identifier"}, + "boolo": {"type": "boolean", "position": 23}, + }, + "required": ["u8v", "u16v", "u32v", "i8v", "i16v", "i32v", "i64v", "f64v", "strv", "bytv", "idv", "boolv"], + "additionalProperties": false, + }); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + DocumentType::try_from_schema( + platform_value::Identifier::new([2; 32]), + 1, + config.version(), + "sink", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create kitchen-sink document type") + } + + fn kitchen_sink_required_properties() -> BTreeMap { + let mut properties = BTreeMap::new(); + properties.insert("u8v".to_string(), Value::U8(200)); + properties.insert("u16v".to_string(), Value::U16(60000)); + properties.insert("u32v".to_string(), Value::U32(4000000000)); + properties.insert("i8v".to_string(), Value::I8(-100)); + properties.insert("i16v".to_string(), Value::I16(-30000)); + properties.insert("i32v".to_string(), Value::I32(-2000000000)); + properties.insert("i64v".to_string(), Value::I64(-9000000000000000000)); + properties.insert("f64v".to_string(), Value::Float(1.5)); + properties.insert("strv".to_string(), Value::Text("hello".to_string())); + properties.insert("bytv".to_string(), Value::Bytes(vec![1, 2, 3])); + properties.insert("idv".to_string(), Value::Identifier([7; 32])); + properties.insert("boolv".to_string(), Value::Bool(true)); + properties + } + + #[test] + fn serialize_v3_round_trips_every_property_type() { + let platform_version = PlatformVersion::latest(); + let document_type = kitchen_sink_document_type(); + + // Every optional present alongside every required + let mut properties = kitchen_sink_required_properties(); + properties.insert("u8o".to_string(), Value::U8(1)); + properties.insert("u16o".to_string(), Value::U16(2)); + properties.insert("u32o".to_string(), Value::U32(3)); + properties.insert("i8o".to_string(), Value::I8(-1)); + properties.insert("i16o".to_string(), Value::I16(-2)); + properties.insert("i32o".to_string(), Value::I32(-3)); + properties.insert("i64o".to_string(), Value::I64(-4)); + properties.insert("f64o".to_string(), Value::Float(-2.75)); + properties.insert("stro".to_string(), Value::Text(String::new())); + properties.insert("byto".to_string(), Value::Bytes(Vec::new())); + properties.insert("ido".to_string(), Value::Identifier([9; 32])); + properties.insert("boolo".to_string(), Value::Bool(false)); + + let document = stamped_document(None, properties, document_type.as_ref()); + let serialized = document + .serialize_v3(document_type.as_ref()) + .expect("expected to serialize all property types"); + let deserialized = + DocumentV0::from_bytes(&serialized, document_type.as_ref(), platform_version) + .expect("expected to deserialize all property types"); + assert_eq!(document, deserialized); + + // Determinism: same document, same bytes + let serialized_again = document + .serialize_v3(document_type.as_ref()) + .expect("expected to serialize again"); + assert_eq!(serialized, serialized_again); + + // Every optional absent (the flag-0 arm of each type), stamped + let document = stamped_document( + Some(1), + kitchen_sink_required_properties(), + document_type.as_ref(), + ); + let serialized = document + .serialize_v3(document_type.as_ref()) + .expect("expected to serialize with absent optionals"); + let deserialized = + DocumentV0::from_bytes(&serialized, document_type.as_ref(), platform_version) + .expect("expected to deserialize with absent optionals"); + assert_eq!(document, deserialized); + } + + #[test] + fn serialize_v3_missing_plain_required_property_errors() { + let document_type = kitchen_sink_document_type(); + let mut properties = kitchen_sink_required_properties(); + properties.remove("u16v"); + + let document = stamped_document(None, properties, document_type.as_ref()); + assert!( + document.serialize_v3(document_type.as_ref()).is_err(), + "serializing without a required property must error" + ); + } + + #[test] + fn from_bytes_v3_never_panics_on_truncated_input() { + let platform_version = PlatformVersion::latest(); + let document_type = kitchen_sink_document_type(); + + let mut properties = kitchen_sink_required_properties(); + properties.insert("stro".to_string(), Value::Text("tail".to_string())); + let document = stamped_document(Some(1), properties, document_type.as_ref()); + let serialized = document + .serialize_v3(document_type.as_ref()) + .expect("expected to serialize"); + + // Every strict prefix must produce a Result, never a panic. (Some + // prefixes legitimately succeed: format 3 tolerates EOF at property + // boundaries so appended properties stay readable by old data.) + for length in 0..serialized.len() { + let _ = DocumentV0::from_bytes( + &serialized[..length], + document_type.as_ref(), + platform_version, + ); + } + } + + fn required_since_document_type() -> crate::data_contract::document_type::DocumentType { + use crate::data_contract::config::DataContractConfig; + use crate::data_contract::document_type::DocumentType; + use platform_value::platform_value; + use std::collections::BTreeMap; + + let platform_version = PlatformVersion::latest(); + let schema = platform_value!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + "b": {"type": "string", "position": 1, "maxLength": 60_u32, "requiredSince": 2}, + "c": {"type": "string", "position": 2, "maxLength": 60_u32}, + }, + "required": ["a", "b"], + "additionalProperties": false, + }); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + DocumentType::try_from_schema( + platform_value::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 stamped_document( + contract_version: Option, + properties: BTreeMap, + document_type: DocumentTypeRef, + ) -> DocumentV0 { + DocumentV0 { + contract_version, + id: Identifier::new([3; 32]), + owner_id: Identifier::new([4; 32]), + properties, + revision: document_type.initial_revision(), + ..Default::default() + } + } + + #[test] + fn serialize_v3_round_trips_document_stamped_at_required_since() { + let platform_version = PlatformVersion::latest(); + let document_type = required_since_document_type(); + let document_type_ref = document_type.as_ref(); + + let mut properties = BTreeMap::new(); + properties.insert("a".to_string(), Value::Text("alpha".to_string())); + properties.insert("b".to_string(), Value::Text("beta".to_string())); + + let document = stamped_document(Some(2), properties, document_type_ref); + + let serialized = document + .serialize_v3(document_type_ref) + .expect("stamped document with the required-since field should serialize"); + + let (version, _) = u64::decode_var(&serialized).expect("expected varint"); + assert_eq!(version, 3, "serialization version prefix should be 3"); + + let deserialized = DocumentV0::from_bytes(&serialized, document_type_ref, platform_version) + .expect("expected deserialization to succeed"); + + assert_eq!(deserialized.contract_version, Some(2)); + assert_eq!(deserialized, document); + } + + #[test] + fn serialize_v3_grandfathered_document_may_omit_required_since_field() { + let platform_version = PlatformVersion::latest(); + let document_type = required_since_document_type(); + let document_type_ref = document_type.as_ref(); + + let mut properties = BTreeMap::new(); + properties.insert("a".to_string(), Value::Text("alpha".to_string())); + + // Stamped at version 1, before `b` became required at version 2 + let document = stamped_document(Some(1), properties, document_type_ref); + + let serialized = document + .serialize_v3(document_type_ref) + .expect("grandfathered document without the required-since field should serialize"); + + let deserialized = DocumentV0::from_bytes(&serialized, document_type_ref, platform_version) + .expect("expected deserialization to succeed"); + + assert_eq!(deserialized.contract_version, Some(1)); + assert!(!deserialized.properties.contains_key("b")); + assert_eq!(deserialized, document); + } + + #[test] + fn serialize_v3_unstamped_document_treats_required_since_fields_as_optional() { + let platform_version = PlatformVersion::latest(); + let document_type = required_since_document_type(); + let document_type_ref = document_type.as_ref(); + + let mut properties = BTreeMap::new(); + properties.insert("a".to_string(), Value::Text("alpha".to_string())); + + // No stamp: a pre-format-3 document being re-serialized (e.g. on + // transfer). Every requiredSince annotation postdates its bytes. + let document = stamped_document(None, properties, document_type_ref); + + let serialized = document + .serialize_v3(document_type_ref) + .expect("unstamped document without the required-since field should serialize"); + + let deserialized = DocumentV0::from_bytes(&serialized, document_type_ref, platform_version) + .expect("expected deserialization to succeed"); + + assert_eq!(deserialized.contract_version, None); + assert_eq!(deserialized, document); + } + + #[test] + fn serialize_v3_stamped_at_required_since_missing_field_errors() { + let document_type = required_since_document_type(); + let document_type_ref = document_type.as_ref(); + + let mut properties = BTreeMap::new(); + properties.insert("a".to_string(), Value::Text("alpha".to_string())); + + // Stamped at version 2, where `b` is required — but `b` is absent + let document = stamped_document(Some(2), properties, document_type_ref); + + let result = document.serialize_v3(document_type_ref); + assert!( + matches!( + result, + Err(ProtocolError::DataContractError( + DataContractError::MissingRequiredKey(_) + )) + ), + "a document stamped at requiredSince must contain the field, got {result:?}" + ); + } + + #[test] + fn format_2_bytes_stay_readable_under_a_required_since_schema() { + use crate::data_contract::config::DataContractConfig; + use crate::data_contract::document_type::DocumentType; + use platform_value::platform_value; + + let platform_version = PlatformVersion::latest(); + + // The schema as it was at contract version 1, before `b` (required + // since version 2) and `c` (optional) were appended + let old_schema = platform_value!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + }, + "required": ["a"], + "additionalProperties": false, + }); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + let old_document_type = DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "test", + old_schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create old document type"); + + let mut properties = BTreeMap::new(); + properties.insert("a".to_string(), Value::Text("alpha".to_string())); + + // A pre-stamp document serialized in format 2 under the old schema + // (as every document written before protocol v14 was, at the + // latest): its buffer ends before `b` and `c`, which must read back + // as absent under the updated schema, not as errors + let document = stamped_document(None, properties, old_document_type.as_ref()); + + let serialized = document + .serialize_v2(old_document_type.as_ref()) + .expect("format 2 serialization should succeed"); + + let (version, _) = u64::decode_var(&serialized).expect("expected varint"); + assert_eq!(version, 2); + + let new_document_type = required_since_document_type(); + let deserialized = + DocumentV0::from_bytes(&serialized, new_document_type.as_ref(), platform_version) + .expect("format 2 bytes must stay readable under a requiredSince schema"); + + assert_eq!(deserialized.contract_version, None); + assert!(!deserialized.properties.contains_key("b")); + assert!(!deserialized.properties.contains_key("c")); + assert_eq!(deserialized, document); + } + + #[test] + fn stale_document_type_rejects_document_stamped_under_newer_contract() { + use crate::data_contract::config::DataContractConfig; + use crate::data_contract::document_type::DocumentType; + use platform_value::platform_value; + + let platform_version = PlatformVersion::latest(); + + // The reader's stale view: the schema as of contract version 1, + // before `b` and `c` were appended + let stale_schema = platform_value!({ + "type": "object", + "properties": { + "a": {"type": "string", "position": 0, "maxLength": 60_u32}, + }, + "required": ["a"], + "additionalProperties": false, + }); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + let stale_document_type = DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "test", + stale_schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create stale document type"); + + // A document written under contract version 2, where `b` exists and + // is required + let current_document_type = required_since_document_type(); + let mut properties = BTreeMap::new(); + properties.insert("a".to_string(), Value::Text("alpha".to_string())); + properties.insert("b".to_string(), Value::Text("beta".to_string())); + let document = stamped_document(Some(2), properties, current_document_type.as_ref()); + + let serialized = document + .serialize_v3(current_document_type.as_ref()) + .expect("expected serialization"); + + // A stale reader must hard-error on the trailing bytes instead of + // silently dropping the field it does not know about + let result = + DocumentV0::from_bytes(&serialized, stale_document_type.as_ref(), platform_version); + assert!( + matches!( + &result, + Err(ProtocolError::DataContractError( + DataContractError::CorruptedSerialization(message) + )) if message.contains("trailing bytes") + ), + "a stale document type must reject a newer-stamped document, got {result:?}" + ); + } + + #[test] + fn stamp_survives_the_wire_for_documents_stamped_past_required_since() { + let platform_version = PlatformVersion::latest(); + let document_type = required_since_document_type(); + let document_type_ref = document_type.as_ref(); + + // The same content stamped before and at the requiredSince boundary + // must produce different byte layouts (flagged vs raw), and each must + // round-trip through the layout its own stamp selects + let mut properties = BTreeMap::new(); + properties.insert("a".to_string(), Value::Text("alpha".to_string())); + properties.insert("b".to_string(), Value::Text("beta".to_string())); + + let stamped_before = stamped_document(Some(1), properties.clone(), document_type_ref); + let stamped_at = stamped_document(Some(2), properties, document_type_ref); + + let serialized_before = stamped_before + .serialize_v3(document_type_ref) + .expect("expected serialization"); + let serialized_at = stamped_at + .serialize_v3(document_type_ref) + .expect("expected serialization"); + + // The flagged layout carries one extra presence byte for `b`, and the + // two stamps differ in the prefix varint + assert_ne!(serialized_before, serialized_at); + + let before_back = + DocumentV0::from_bytes(&serialized_before, document_type_ref, platform_version) + .expect("expected deserialization"); + let at_back = DocumentV0::from_bytes(&serialized_at, document_type_ref, platform_version) + .expect("expected deserialization"); + + assert_eq!(before_back, stamped_before); + assert_eq!(at_back, stamped_at); + } } diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index 6791910923e..d1bed746659 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -7,15 +7,15 @@ use crate::consensus::basic::data_contract::data_contract_max_depth_exceed_error use crate::consensus::basic::data_contract::{ ContestedUniqueIndexOnMutableDocumentTypeError, ContestedUniqueIndexWithUniqueIndexError, DataContractHaveNewUniqueIndexError, DataContractImmutablePropertiesUpdateError, - DataContractInvalidIndexDefinitionUpdateError, DataContractTokenConfigurationUpdateError, - DataContractUniqueIndicesChangedError, DecimalsOverLimitError, DuplicateIndexError, - DuplicateIndexNameError, GroupExceedsMaxMembersError, GroupHasTooFewMembersError, - GroupMemberHasPowerOfZeroError, GroupMemberHasPowerOverLimitError, - GroupNonUnilateralMemberPowerHasLessThanRequiredPowerError, GroupPositionDoesNotExistError, - GroupRequiredPowerIsInvalidError, GroupTotalPowerLessThanRequiredError, - IncompatibleDataContractSchemaError, IncompatibleDocumentTypeSchemaError, - IncompatibleRe2PatternError, InvalidCompoundIndexError, InvalidDataContractIdError, - InvalidDataContractVersionError, InvalidDocumentTypeNameError, + DataContractInvalidIndexDefinitionUpdateError, DataContractInvalidRequiredFieldsUpdateError, + DataContractTokenConfigurationUpdateError, DataContractUniqueIndicesChangedError, + DecimalsOverLimitError, DuplicateIndexError, DuplicateIndexNameError, + GroupExceedsMaxMembersError, GroupHasTooFewMembersError, GroupMemberHasPowerOfZeroError, + GroupMemberHasPowerOverLimitError, GroupNonUnilateralMemberPowerHasLessThanRequiredPowerError, + GroupPositionDoesNotExistError, GroupRequiredPowerIsInvalidError, + GroupTotalPowerLessThanRequiredError, IncompatibleDataContractSchemaError, + IncompatibleDocumentTypeSchemaError, IncompatibleRe2PatternError, InvalidCompoundIndexError, + InvalidDataContractIdError, InvalidDataContractVersionError, InvalidDocumentTypeNameError, InvalidDocumentTypeRequiredSecurityLevelError, InvalidIndexPropertyTypeError, InvalidIndexedPropertyConstraintError, InvalidKeywordCharacterError, InvalidTokenBaseSupplyError, InvalidTokenDistributionFunctionDivideByZeroError, @@ -696,6 +696,9 @@ pub enum BasicError { #[error(transparent)] TokenPricingScheduleEmptyError(TokenPricingScheduleEmptyError), + + #[error(transparent)] + DataContractInvalidRequiredFieldsUpdateError(DataContractInvalidRequiredFieldsUpdateError), } impl From for ConsensusError { diff --git a/packages/rs-dpp/src/errors/consensus/basic/data_contract/data_contract_invalid_required_fields_update_error.rs b/packages/rs-dpp/src/errors/consensus/basic/data_contract/data_contract_invalid_required_fields_update_error.rs new file mode 100644 index 00000000000..db524161d38 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/data_contract/data_contract_invalid_required_fields_update_error.rs @@ -0,0 +1,46 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Document type {document_type} required fields update is not allowed: {details}")] +#[platform_serialize(unversioned)] +pub struct DataContractInvalidRequiredFieldsUpdateError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + document_type: String, + details: String, +} + +impl DataContractInvalidRequiredFieldsUpdateError { + pub fn new(document_type: String, details: String) -> Self { + Self { + document_type, + details, + } + } + + pub fn document_type(&self) -> &str { + &self.document_type + } + + pub fn details(&self) -> &str { + &self.details + } +} + +impl From for ConsensusError { + fn from(err: DataContractInvalidRequiredFieldsUpdateError) -> Self { + Self::BasicError(BasicError::DataContractInvalidRequiredFieldsUpdateError( + err, + )) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/data_contract/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/data_contract/mod.rs index a43ea4793a3..d15ccaca661 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/data_contract/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/data_contract/mod.rs @@ -3,6 +3,7 @@ mod contested_unique_index_with_unique_index_error; mod data_contract_have_new_unique_index_error; mod data_contract_immutable_properties_update_error; mod data_contract_invalid_index_definition_update_error; +mod data_contract_invalid_required_fields_update_error; pub mod data_contract_max_depth_exceed_error; mod data_contract_token_configuration_update_error; mod data_contract_unique_indices_changed_error; @@ -62,6 +63,7 @@ mod unknown_transferable_type_error; pub use data_contract_have_new_unique_index_error::*; pub use data_contract_immutable_properties_update_error::*; pub use data_contract_invalid_index_definition_update_error::*; +pub use data_contract_invalid_required_fields_update_error::*; pub use data_contract_token_configuration_update_error::*; pub use data_contract_unique_indices_changed_error::*; pub use document_types_are_missing_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 6fa1b64d6ca..cc42a8e4d53 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -120,6 +120,7 @@ impl ErrorWithCode for BasicError { Self::InvalidTokenDistributionTimeIntervalTooShortError(_) => 10273, Self::InvalidTokenDistributionTimeIntervalNotMinuteAlignedError(_) => 10274, Self::RedundantDocumentPaidForByTokenWithContractId(_) => 10275, + Self::DataContractInvalidRequiredFieldsUpdateError { .. } => 10276, // Group Errors: 10350-10399 Self::GroupPositionDoesNotExistError(_) => 10350, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs index 8b7a528b7d0..876fc29bc10 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs @@ -374,6 +374,7 @@ impl DocumentFromCreateTransitionV0 for Document { .document_structure_version { 0 => Ok(DocumentV0 { + contract_version: None, id: base.id(), owner_id, properties: data, @@ -484,6 +485,7 @@ impl DocumentFromCreateTransitionV0 for Document { .document_structure_version { 0 => Ok(DocumentV0 { + contract_version: None, id: base.id(), owner_id, properties, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/v0/mod.rs index 0c2d1f95054..b501324e98d 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_replace_transition/v0/mod.rs @@ -234,6 +234,7 @@ impl DocumentFromReplaceTransitionV0 for Document { .document_structure_version { 0 => Ok(DocumentV0 { + contract_version: None, id, owner_id, properties: data.clone(), @@ -315,6 +316,7 @@ impl DocumentFromReplaceTransitionV0 for Document { .document_structure_version { 0 => Ok(DocumentV0 { + contract_version: None, id, owner_id, properties: data, diff --git a/packages/rs-dpp/src/tests/json_document.rs b/packages/rs-dpp/src/tests/json_document.rs index 1f76d7431fd..feab00b5adc 100644 --- a/packages/rs-dpp/src/tests/json_document.rs +++ b/packages/rs-dpp/src/tests/json_document.rs @@ -154,6 +154,7 @@ pub fn json_document_to_document( } let mut document: DocumentV0 = DocumentV0 { + contract_version: None, id: data.remove_identifier("$id")?, owner_id: data.remove_identifier("$ownerId")?, properties: Default::default(), diff --git a/packages/rs-dpp/src/tokens/token_event.rs b/packages/rs-dpp/src/tokens/token_event.rs index 5982eb45520..5066b0f0479 100644 --- a/packages/rs-dpp/src/tokens/token_event.rs +++ b/packages/rs-dpp/src/tokens/token_event.rs @@ -860,6 +860,7 @@ impl TokenEvent { }; let document: Document = DocumentV0 { + contract_version: None, id: document_id, owner_id, properties, diff --git a/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/common.rs b/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/common.rs index a96ca521343..aa28c7fa3bf 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/common.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/initialization/create_genesis_state/common.rs @@ -66,6 +66,7 @@ impl Platform { .map_err(|e| Error::Protocol(ProtocolError::ValueError(e)))?; let document = DocumentV0 { + contract_version: None, id: DPNS_DASH_TLD_DOCUMENT_ID.into(), properties: document_stub_properties, owner_id: contract.owner_id(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs index f69dfbaf868..3272136bbd2 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs @@ -10,7 +10,9 @@ mod deletion_tests { async fn test_document_delete_on_document_type_that_is_mutable_and_can_be_deleted() { run_document_delete_on_document_type_that_is_mutable_and_can_be_deleted_at_protocol_version( PlatformVersion::latest().protocol_version, - 1699160, + // v14: the deleted document carries the contract-version stamp + // (one stored byte, five estimated), shifting processing costs + 1699620, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index 9cf7dacdab0..5b6161d1b2b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -333,7 +333,8 @@ mod replacement_tests { async fn test_document_replace_on_document_type_that_is_mutable() { run_document_replace_on_document_type_that_is_mutable_at_protocol_version( PlatformVersion::latest().protocol_version, - 1432760, + // v14: replaced documents carry the contract-version stamp + 1433220, ) .await; } @@ -1037,7 +1038,7 @@ mod replacement_tests { async fn test_document_replace_on_document_type_that_is_not_mutable() { run_document_replace_on_document_type_that_is_not_mutable_at_protocol_version( PlatformVersion::latest().protocol_version, - 460920, + 460940, // v14: stamped documents (see happy-path baseline note) ) .await; } @@ -1293,7 +1294,7 @@ mod replacement_tests { async fn test_document_replace_on_document_type_that_is_not_mutable_but_is_transferable() { run_document_replace_on_document_type_that_is_not_mutable_but_is_transferable_at_protocol_version( PlatformVersion::latest().protocol_version, - 457660, + 457680, // v14: stamped documents (see happy-path baseline note) ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs index cd03d32a7c5..cc761bad415 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs @@ -498,7 +498,8 @@ mod transfer_tests { async fn test_document_transfer_on_document_type_that_is_transferable() { run_document_transfer_on_document_type_that_is_transferable_at_protocol_version( PlatformVersion::latest().protocol_version, - 3643400, + // v14: transferred documents carry the contract-version stamp + 3643860, ) .await; } @@ -1478,7 +1479,7 @@ mod transfer_tests { async fn test_document_delete_after_transfer() { run_document_delete_after_transfer_at_protocol_version( PlatformVersion::latest().protocol_version, - 4004260, + 4004720, // v14: stamped documents (see transferable baseline note) ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs index 5fba3b44ec7..1068a7ad348 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs @@ -3958,7 +3958,9 @@ mod token_burn_tests { // sizes and therefore the byte-billed group-action contract reads. run_token_burn_group_action_confirmer_fee_includes_transformer_reads_at_protocol_version( PlatformVersion::latest().protocol_version, - 4_367_880, + // PROTOCOL_VERSION_14: +400 — genesis system documents now carry + // the contract-version stamp, shifting byte-billed subtree reads + 4_368_280, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/direct_selling/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/direct_selling/mod.rs index 2fe0cb1727c..3fdde12cd7c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/direct_selling/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/direct_selling/mod.rs @@ -24,7 +24,10 @@ mod token_selling_tests { // sizes and therefore the byte-billed contract reads. run_successful_direct_purchase_single_price_at_protocol_version( PlatformVersion::latest().protocol_version, - 699_868_073_580, + // PROTOCOL_VERSION_14: 27_400 credits more in fees — genesis system + // documents now carry the contract-version stamp, shifting + // byte-billed subtree reads + 699_868_046_180, ) .await; } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/mod.rs index 008be12cc67..4598692d1d6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/mod.rs @@ -1,2 +1,3 @@ pub(crate) mod v0; pub(crate) mod v1; +pub(crate) mod v2; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs new file mode 100644 index 00000000000..b8c12c7c0c2 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/basic_structure/v2/mod.rs @@ -0,0 +1,181 @@ +use crate::error::Error; +use dpp::consensus::basic::data_contract::DataContractInvalidRequiredFieldsUpdateError; +use dpp::dashcore::Network; +use dpp::state_transition::data_contract_create_transition::accessors::DataContractCreateTransitionAccessorsV0; +use dpp::state_transition::data_contract_create_transition::DataContractCreateTransition; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; + +use super::v1::DataContractCreateStateTransitionBasicStructureValidationV1; + +const PROPERTIES: &str = "properties"; +const REQUIRED_SINCE: &str = "requiredSince"; + +pub(in crate::execution::validation::state_transition::state_transitions::data_contract_create) trait DataContractCreateStateTransitionBasicStructureValidationV2 +{ + fn validate_basic_structure_v2( + &self, + network_type: Network, + platform_version: &PlatformVersion, + ) -> Result; +} + +impl DataContractCreateStateTransitionBasicStructureValidationV2 for DataContractCreateTransition { + fn validate_basic_structure_v2( + &self, + network_type: Network, + platform_version: &PlatformVersion, + ) -> Result { + // First run all v1 (and transitively v0) validations + let v1_result = self.validate_basic_structure_v1(network_type, platform_version)?; + if !v1_result.is_valid() { + return Ok(v1_result); + } + + // `requiredSince` names the contract version a property is required + // from. A freshly created contract is version 1, so the only value + // that names an existing version is 1 (which is equivalent to plain + // membership in `required`). Later values would pre-schedule + // requiredness at a future version — coherent for the wire format, + // but banned: requiredness changes must arrive with the update that + // creates the version they name. + // + // This raw-JSON scan is an early, cheap rejection only — it cannot + // see an annotation reached through a `$defs` `$ref`. The + // authoritative enforcement is + // `validate_required_since_within_contract_version` in dpp, which + // runs on the *parsed* properties (references resolved) whenever the + // contract is built from its serialized form, including this + // transition's transform into action. + for (document_type_name, schema) in self.data_contract().document_schemas() { + let Some(properties) = schema + .get_optional_value(PROPERTIES) + .ok() + .flatten() + .and_then(|properties| properties.as_map()) + else { + continue; + }; + + for (property_name, property_schema) in properties { + let Some(required_since) = property_schema + .as_map() + .and_then(|map| { + map.iter() + .find(|(key, _)| key.as_text() == Some(REQUIRED_SINCE)) + }) + .and_then(|(_, value)| value.as_integer::()) + else { + continue; + }; + + if required_since != 1 { + return Ok(SimpleConsensusValidationResult::new_with_error( + DataContractInvalidRequiredFieldsUpdateError::new( + document_type_name.clone(), + format!( + "property '{}' of a newly created contract cannot carry requiredSince {} — a fresh contract is version 1", + property_name.as_text().unwrap_or_default(), + required_since + ), + ) + .into(), + )); + } + } + } + + Ok(SimpleConsensusValidationResult::new()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use dpp::consensus::basic::BasicError; + use dpp::consensus::ConsensusError; + use dpp::platform_value::platform_value; + use dpp::prelude::IdentityNonce; + use dpp::state_transition::data_contract_create_transition::DataContractCreateTransitionV0; + use dpp::tests::fixtures::get_data_contract_fixture; + use platform_version::version::PlatformVersion; + use platform_version::TryIntoPlatformVersioned; + + fn create_transition_with_required_since( + required_since: u32, + ) -> (DataContractCreateTransition, &'static PlatformVersion) { + let platform_version = PlatformVersion::latest(); + let identity_nonce = IdentityNonce::default(); + + let data_contract = + get_data_contract_fixture(None, identity_nonce, platform_version.protocol_version) + .data_contract_owned(); + + let mut data_contract_for_serialization: dpp::data_contract::serialized_version::DataContractInSerializationFormat = data_contract + .try_into_platform_versioned(platform_version) + .expect("failed to convert data contract"); + + data_contract_for_serialization + .document_schemas_mut() + .insert( + "note".to_string(), + platform_value!({ + "type": "object", + "properties": { + "message": { + "type": "string", + "position": 0, + "maxLength": 60, + "requiredSince": required_since, + } + }, + "required": ["message"], + "additionalProperties": false + }), + ); + + let transition: DataContractCreateTransition = DataContractCreateTransitionV0 { + data_contract: data_contract_for_serialization, + identity_nonce, + user_fee_increase: 0, + signature_public_key_id: 0, + signature: Default::default(), + } + .into(); + + (transition, platform_version) + } + + #[test] + fn should_accept_required_since_of_one_on_a_new_contract() { + let (transition, platform_version) = create_transition_with_required_since(1); + + let result = transition + .validate_basic_structure_v2(Network::Testnet, platform_version) + .expect("failed to validate basic structure"); + + assert!( + result.is_valid(), + "requiredSince 1 on a fresh contract is equivalent to plain \ + required and must be accepted, got {:?}", + result.errors + ); + } + + #[test] + fn should_reject_required_since_above_one_on_a_new_contract() { + let (transition, platform_version) = create_transition_with_required_since(2); + + let result = transition + .validate_basic_structure_v2(Network::Testnet, platform_version) + .expect("failed to validate basic structure"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::DataContractInvalidRequiredFieldsUpdateError(e) + )] if e.details().contains("cannot carry requiredSince 2") + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs index 8983c91b396..df6c2b6e695 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_create/mod.rs @@ -6,6 +6,7 @@ mod state; use advanced_structure::v1::DataContractCreatedStateTransitionAdvancedStructureValidationV1; use basic_structure::v0::DataContractCreateStateTransitionBasicStructureValidationV0; use basic_structure::v1::DataContractCreateStateTransitionBasicStructureValidationV1; +use basic_structure::v2::DataContractCreateStateTransitionBasicStructureValidationV2; use dpp::address_funds::PlatformAddress; use dpp::block::block_info::BlockInfo; use dpp::dashcore::Network; @@ -99,14 +100,15 @@ impl StateTransitionBasicStructureValidationV0 for DataContractCreateTransition { Some(0) => self.validate_basic_structure_v0(network_type, platform_version), Some(1) => self.validate_basic_structure_v1(network_type, platform_version), + Some(2) => self.validate_basic_structure_v2(network_type, platform_version), Some(version) => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "data contract create transition: validate_basic_structure".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), None => Err(Error::Execution(ExecutionError::VersionNotActive { method: "data contract create transition: validate_basic_structure".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], })), } } diff --git a/packages/rs-drive-abci/src/query/document_query/v0/mod.rs b/packages/rs-drive-abci/src/query/document_query/v0/mod.rs index 2bd36c1fad1..ff5a9ab1686 100644 --- a/packages/rs-drive-abci/src/query/document_query/v0/mod.rs +++ b/packages/rs-drive-abci/src/query/document_query/v0/mod.rs @@ -886,6 +886,7 @@ mod tests { let created_at = base_time + i * 20000; // Create a Document with the desired properties let random_document: Document = DocumentV0 { + contract_version: None, id: Identifier::random_with_rng(&mut std_rng), owner_id: Identifier::random_with_rng(&mut std_rng), properties: { @@ -1052,6 +1053,7 @@ mod tests { let created_at = base_time + i * 20000; // Create a Document with the desired properties let random_document: Document = DocumentV0 { + contract_version: None, id: Identifier::random_with_rng(&mut std_rng), owner_id: Identifier::random_with_rng(&mut std_rng), properties: { @@ -1218,6 +1220,7 @@ mod tests { let created_at = base_time + i * 20000; // Create a Document with the desired properties let random_document: Document = DocumentV0 { + contract_version: None, id: Identifier::random_with_rng(&mut std_rng), owner_id: Identifier::random_with_rng(&mut std_rng), properties: { @@ -1377,6 +1380,7 @@ mod tests { let created_at = base_time + i * 20000; // Create a Document with the desired properties let random_document: Document = DocumentV0 { + contract_version: None, id: Identifier::random_with_rng(&mut std_rng), owner_id: Identifier::random_with_rng(&mut std_rng), properties: { @@ -1551,6 +1555,7 @@ mod tests { let created_at = base_time + i * 20000; // Create a Document with the desired properties let random_document: Document = DocumentV0 { + contract_version: None, id: Identifier::random_with_rng(&mut std_rng), owner_id: Identifier::random_with_rng(&mut std_rng), properties: { diff --git a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs index 860c394c18f..2241e2ba5f7 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs @@ -1128,6 +1128,7 @@ mod ported_v0_count_tests { properties.insert("age".to_string(), Value::U64(age)); let document: Document = DocumentV0 { + contract_version: None, id: Identifier::from(id), owner_id: Identifier::from([0u8; 32]), properties, diff --git a/packages/rs-drive-abci/src/test/helpers/fee_pools.rs b/packages/rs-drive-abci/src/test/helpers/fee_pools.rs index 5dbe0cb0957..2a6dcdcf5d5 100644 --- a/packages/rs-drive-abci/src/test/helpers/fee_pools.rs +++ b/packages/rs-drive-abci/src/test/helpers/fee_pools.rs @@ -78,6 +78,7 @@ fn create_test_mn_share_document( properties.insert(String::from("percentage"), percentage.into()); let document = DocumentV0 { + contract_version: None, id, properties, owner_id: identity_id, diff --git a/packages/rs-drive/benches/document_average_worst_case.rs b/packages/rs-drive/benches/document_average_worst_case.rs index 3ec864dc2b5..3ad031fab16 100644 --- a/packages/rs-drive/benches/document_average_worst_case.rs +++ b/packages/rs-drive/benches/document_average_worst_case.rs @@ -547,6 +547,7 @@ fn insert_grade_document( properties.insert("instructor".to_string(), Value::Bytes(instructor.to_vec())); let document: Document = DocumentV0 { + contract_version: None, id: Identifier::from(document_id(row)), owner_id: Identifier::from([7u8; 32]), properties, diff --git a/packages/rs-drive/benches/document_count_worst_case.rs b/packages/rs-drive/benches/document_count_worst_case.rs index 2a00dd884c4..40e8953bc81 100644 --- a/packages/rs-drive/benches/document_count_worst_case.rs +++ b/packages/rs-drive/benches/document_count_worst_case.rs @@ -260,6 +260,7 @@ fn insert_widget_document( properties.insert("serial".to_string(), Value::U64(row)); let document: Document = DocumentV0 { + contract_version: None, id: Identifier::from(document_id(row)), owner_id: Identifier::from([7u8; 32]), properties, diff --git a/packages/rs-drive/benches/document_sum_worst_case.rs b/packages/rs-drive/benches/document_sum_worst_case.rs index e1953d40b2e..6d42c21dbf8 100644 --- a/packages/rs-drive/benches/document_sum_worst_case.rs +++ b/packages/rs-drive/benches/document_sum_worst_case.rs @@ -312,6 +312,7 @@ fn insert_tip_document( properties.insert("sentAt".to_string(), Value::U64(sent_at)); let document: Document = DocumentV0 { + contract_version: None, id: Identifier::from(document_id(row)), owner_id: Identifier::from([7u8; 32]), properties, diff --git a/packages/rs-drive/src/drive/contract/insert/add_description/v0/mod.rs b/packages/rs-drive/src/drive/contract/insert/add_description/v0/mod.rs index bbd6671fc90..c8a21e091d6 100644 --- a/packages/rs-drive/src/drive/contract/insert/add_description/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/add_description/v0/mod.rs @@ -202,6 +202,7 @@ impl Drive { ]); let document: Document = DocumentV0 { + contract_version: None, id: document_id, owner_id, properties, diff --git a/packages/rs-drive/src/drive/contract/insert/add_new_keywords/v0/mod.rs b/packages/rs-drive/src/drive/contract/insert/add_new_keywords/v0/mod.rs index 89536fa16cb..950785dd05d 100644 --- a/packages/rs-drive/src/drive/contract/insert/add_new_keywords/v0/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/add_new_keywords/v0/mod.rs @@ -163,6 +163,7 @@ impl Drive { ]); let document: Document = DocumentV0 { + contract_version: None, id: document_id, owner_id, properties, diff --git a/packages/rs-drive/src/drive/document/delete/mod.rs b/packages/rs-drive/src/drive/document/delete/mod.rs index 9e074da93b2..717446bac3f 100644 --- a/packages/rs-drive/src/drive/document/delete/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/mod.rs @@ -1047,7 +1047,9 @@ mod tests { assert!(fee_result.fee_refunds.0.is_empty()); assert_eq!(fee_result.storage_fee, 0); - assert_eq!(fee_result.processing_fee, 71994700); + // estimated_size v1 adds the contract-version stamp varint to the + // worst-case document size + assert_eq!(fee_result.processing_fee, 72064200); } #[test] diff --git a/packages/rs-drive/src/drive/document/insert/mod.rs b/packages/rs-drive/src/drive/document/insert/mod.rs index b02044b4781..fbaa278f2e5 100644 --- a/packages/rs-drive/src/drive/document/insert/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/mod.rs @@ -455,7 +455,9 @@ mod tests { &EPOCH_CHANGE_FEE_VERSION_TEST, StorageDiskUsageCreditPerByte, ), - processing_fee: 73253660, + // estimated_size v1 adds the contract-version stamp varint to + // the worst-case document size + processing_fee: 73323060, ..Default::default() }; diff --git a/packages/rs-drive/src/drive/document/update/mod.rs b/packages/rs-drive/src/drive/document/update/mod.rs index 9b223c9bdf2..226abd4ad94 100644 --- a/packages/rs-drive/src/drive/document/update/mod.rs +++ b/packages/rs-drive/src/drive/document/update/mod.rs @@ -2747,6 +2747,7 @@ mod tests { properties_initial.insert("color".to_string(), Value::Text("red".to_string())); properties_initial.insert("amount".to_string(), Value::U64(5)); let document_initial: dpp::document::Document = DocumentV0 { + contract_version: None, id: doc_id, owner_id: Identifier::from([0u8; 32]), properties: properties_initial, @@ -2795,6 +2796,7 @@ mod tests { properties_updated.insert("color".to_string(), Value::Text("red".to_string())); properties_updated.insert("amount".to_string(), Value::U64(42)); let document_updated: dpp::document::Document = DocumentV0 { + contract_version: None, id: doc_id, owner_id: Identifier::from([0u8; 32]), properties: properties_updated, @@ -2998,6 +3000,7 @@ mod tests { properties_initial.insert("color".to_string(), Value::Text("red".to_string())); properties_initial.insert("amount".to_string(), Value::U64(11)); let document_initial: dpp::document::Document = DocumentV0 { + contract_version: None, id: doc_id, owner_id: Identifier::from([0u8; 32]), properties: properties_initial, @@ -3043,6 +3046,7 @@ mod tests { properties_updated.insert("color".to_string(), Value::Text("blue".to_string())); properties_updated.insert("amount".to_string(), Value::U64(17)); let document_updated: dpp::document::Document = DocumentV0 { + contract_version: None, id: doc_id, owner_id: Identifier::from([0u8; 32]), properties: properties_updated, diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index 7fb394f3ef0..7d4dfd71591 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -1538,6 +1538,7 @@ mod tests { fn cursor_document(field: &str, value: Value) -> dpp::document::Document { DocumentV0 { + contract_version: None, id: Identifier::from([3u8; 32]), owner_id: Identifier::from([4u8; 32]), properties: BTreeMap::from([(field.to_string(), value)]), diff --git a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs index 526a5c86b57..26bcd39f12e 100644 --- a/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs +++ b/packages/rs-drive/src/query/drive_document_average_query/drive_dispatcher.rs @@ -495,6 +495,7 @@ mod tests { properties.insert("color".to_string(), Value::Text(color.to_string())); properties.insert("amount".to_string(), Value::U64(amount)); let document: Document = DocumentV0 { + contract_version: None, id: Identifier::from([(i + 1) as u8; 32]), owner_id: Identifier::from([0u8; 32]), properties, @@ -2096,6 +2097,7 @@ mod tests { let mut properties = std::collections::BTreeMap::new(); properties.insert("amount".to_string(), Value::U64(*amount)); let document: Document = DocumentV0 { + contract_version: None, id: Identifier::from([(i + 1) as u8; 32]), owner_id: Identifier::from([0u8; 32]), properties, diff --git a/packages/rs-drive/src/query/drive_document_count_query/tests.rs b/packages/rs-drive/src/query/drive_document_count_query/tests.rs index da54e064fc9..116147110a5 100644 --- a/packages/rs-drive/src/query/drive_document_count_query/tests.rs +++ b/packages/rs-drive/src/query/drive_document_count_query/tests.rs @@ -69,6 +69,7 @@ fn insert_person_doc( properties.insert("age".to_string(), Value::U64(age)); let document: Document = DocumentV0 { + contract_version: None, id: Identifier::from(id), owner_id: Identifier::from([0u8; 32]), properties, @@ -1262,6 +1263,7 @@ fn test_compound_range_in_summed_no_proof_uses_per_in_aggregate_fanout() { properties.insert("brand".to_string(), Value::Text(brand.to_string())); properties.insert("color".to_string(), Value::Text(color.to_string())); let document: Document = DocumentV0 { + contract_version: None, id: Identifier::from([(i + 1) as u8; 32]), owner_id: Identifier::from([0u8; 32]), properties, @@ -1536,6 +1538,7 @@ fn test_range_distinct_proof_uses_compile_time_default_query_limit_not_operator_ let mut properties = StdBTreeMap::new(); properties.insert("color".to_string(), Value::Text(color.to_string())); let document: Document = DocumentV0 { + contract_version: None, id: Identifier::from([(i + 1) as u8; 32]), owner_id: Identifier::from([0u8; 32]), properties, @@ -2767,6 +2770,7 @@ mod range_countable_point_lookup_tests { properties.insert("color".to_string(), Value::Text(c.to_string())); } let document: Document = DocumentV0 { + contract_version: None, id: Identifier::from(id), owner_id: Identifier::from([0u8; 32]), properties, @@ -2815,6 +2819,7 @@ mod range_countable_point_lookup_tests { let mut properties = StdBTreeMap::new(); properties.insert("category".to_string(), Value::Text(category.to_string())); let document: Document = DocumentV0 { + contract_version: None, id: Identifier::from(id), owner_id: Identifier::from([0u8; 32]), properties, diff --git a/packages/rs-drive/src/query/drive_document_sum_query/tests.rs b/packages/rs-drive/src/query/drive_document_sum_query/tests.rs index 1688669039b..aa1d0566be1 100644 --- a/packages/rs-drive/src/query/drive_document_sum_query/tests.rs +++ b/packages/rs-drive/src/query/drive_document_sum_query/tests.rs @@ -334,6 +334,7 @@ mod limit_policy_regression { properties.insert("color".to_string(), Value::Text(color.to_string())); properties.insert("amount".to_string(), Value::U64(amount)); let document: Document = DocumentV0 { + contract_version: None, id: Identifier::from([(i + 1) as u8; 32]), owner_id: Identifier::from([0u8; 32]), properties, diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index 53fad48e77c..c7fb41382d7 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -2808,6 +2808,7 @@ mod tests { // We intentionally omit 'transactionIndex' to simulate missing field let starts_at_document = DocumentV0 { + contract_version: None, id: Identifier::from([3u8; 32]), // The same as start_at owner_id: Identifier::random(), properties, diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/address_funds/address_credit_withdrawal_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/address_funds/address_credit_withdrawal_transition.rs index b93406ec832..0b76c034f34 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/address_funds/address_credit_withdrawal_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/address_funds/address_credit_withdrawal_transition.rs @@ -96,6 +96,7 @@ mod tests { fn make_document() -> Document { Document::V0(DocumentV0 { + contract_version: None, id: Identifier::from([0x11; 32]), owner_id: Identifier::from([0x22; 32]), properties: Default::default(), diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/identity/identity_credit_withdrawal_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/identity/identity_credit_withdrawal_transition.rs index 520e54e765e..203b9d0ad10 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/identity/identity_credit_withdrawal_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/identity/identity_credit_withdrawal_transition.rs @@ -77,6 +77,7 @@ mod tests { fn make_document() -> Document { Document::V0(DocumentV0 { + contract_version: None, id: Identifier::from([0xDD; 32]), owner_id: Identifier::from([0xAA; 32]), properties: Default::default(), diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shielded_withdrawal_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shielded_withdrawal_transition.rs index 6df19016c54..458fb0cd2ec 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shielded_withdrawal_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/shielded_withdrawal_transition.rs @@ -119,6 +119,7 @@ mod tests { fn make_document() -> Document { Document::V0(DocumentV0 { + contract_version: None, id: Identifier::from([0x11; 32]), owner_id: Identifier::from([0x22; 32]), properties: Default::default(), diff --git a/packages/rs-drive/src/state_transition_action/address_funds/address_credit_withdrawal/mod.rs b/packages/rs-drive/src/state_transition_action/address_funds/address_credit_withdrawal/mod.rs index 28f9438d2ee..85d1fee5e0d 100644 --- a/packages/rs-drive/src/state_transition_action/address_funds/address_credit_withdrawal/mod.rs +++ b/packages/rs-drive/src/state_transition_action/address_funds/address_credit_withdrawal/mod.rs @@ -86,6 +86,7 @@ mod tests { fn make_document() -> Document { Document::V0(DocumentV0 { + contract_version: None, id: Identifier::from([0x11; 32]), owner_id: Identifier::from([0x22; 32]), properties: Default::default(), diff --git a/packages/rs-drive/src/state_transition_action/address_funds/address_credit_withdrawal/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/address_funds/address_credit_withdrawal/v0/transformer.rs index 862b2d052c9..0c44dcd19c5 100644 --- a/packages/rs-drive/src/state_transition_action/address_funds/address_credit_withdrawal/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/address_funds/address_credit_withdrawal/v0/transformer.rs @@ -93,6 +93,7 @@ impl AddressCreditWithdrawalTransitionActionV0 { }); let withdrawal_document = DocumentV0 { + contract_version: None, id: document_id, owner_id, properties: document_data.into_btree_string_map().unwrap(), diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/mod.rs index e529985db76..5584aec2faf 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/mod.rs @@ -1,6 +1,7 @@ /// transformer pub mod transformer; mod v0; +mod v1; use derive_more::From; @@ -14,6 +15,7 @@ use dpp::fee::Credits; use dpp::ProtocolError; pub use v0::*; +pub use v1::*; use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction}; use dpp::version::PlatformVersion; use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::ContestedDocumentVotePollStoredInfo; @@ -150,7 +152,21 @@ impl DocumentFromCreateTransitionAction for Document { ) -> Result { match document_create_transition_action { DocumentCreateTransitionAction::V0(v0) => { - Self::try_from_create_transition_action_v0(v0, owner_id, platform_version) + match platform_version + .drive + .methods + .state_transitions + .document_from_action + .document_from_create_transition_action + { + 0 => Self::try_from_create_transition_action_v0(v0, owner_id, platform_version), + 1 => Self::try_from_create_transition_action_v1(v0, owner_id, platform_version), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "Document::try_from_create_transition_action".to_string(), + known_versions: vec![0, 1], + received: version, + }), + } } } } @@ -162,7 +178,29 @@ impl DocumentFromCreateTransitionAction for Document { ) -> Result { match document_create_transition_action { DocumentCreateTransitionAction::V0(v0) => { - Self::try_from_owned_create_transition_action_v0(v0, owner_id, platform_version) + match platform_version + .drive + .methods + .state_transitions + .document_from_action + .document_from_create_transition_action + { + 0 => Self::try_from_owned_create_transition_action_v0( + v0, + owner_id, + platform_version, + ), + 1 => Self::try_from_owned_create_transition_action_v1( + v0, + owner_id, + platform_version, + ), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "Document::try_from_owned_create_transition_action".to_string(), + known_versions: vec![0, 1], + received: version, + }), + } } } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs index 8cb8c278692..73193343490 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v0/mod.rs @@ -188,6 +188,7 @@ impl DocumentFromCreateTransitionActionV0 for Document { .document_structure_version { 0 => Ok(DocumentV0 { + contract_version: None, id, owner_id, properties: data, @@ -320,6 +321,7 @@ impl DocumentFromCreateTransitionActionV0 for Document { .document_structure_version { 0 => Ok(DocumentV0 { + contract_version: None, id: *id, owner_id, properties: data, diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v1/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v1/mod.rs new file mode 100644 index 00000000000..d15e0792fad --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_create_transition_action/v1/mod.rs @@ -0,0 +1,70 @@ +//! Generation 1 of the create-action → `Document` conversion: generation 0 +//! plus the contract-version stamp. The built document records the version +//! of the contract it was validated against, which selects each +//! `requiredSince` property's byte layout in document serialization format +//! 3. This generation must only be selected by platform versions whose +//! document serialization format writes the stamp (format 3, protocol +//! v14+); the version table pairs the two. + +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::document::{Document, DocumentV0Setters}; +use dpp::platform_value::Identifier; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; + +use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionAccessorsV0}; +use super::{DocumentCreateTransitionActionV0, DocumentFromCreateTransitionActionV0}; + +/// documents from create transition v1 +pub trait DocumentFromCreateTransitionActionV1 { + /// Attempts to create a new `Document` from the given `DocumentCreateTransitionActionV0` + /// instance and `owner_id`, stamped with the contract version the + /// document was created against. + fn try_from_owned_create_transition_action_v1( + v0: DocumentCreateTransitionActionV0, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result + where + Self: Sized; + /// Attempts to create a new `Document` from the given `DocumentCreateTransitionActionV0` + /// reference and `owner_id`, stamped with the contract version the + /// document was created against. + fn try_from_create_transition_action_v1( + v0: &DocumentCreateTransitionActionV0, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result + where + Self: Sized; +} + +fn action_contract_version(base: &DocumentBaseTransitionAction) -> u32 { + base.data_contract_fetch_info_ref().contract.version() +} + +impl DocumentFromCreateTransitionActionV1 for Document { + fn try_from_owned_create_transition_action_v1( + v0: DocumentCreateTransitionActionV0, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result { + let contract_version = action_contract_version(&v0.base); + let mut document = + Self::try_from_owned_create_transition_action_v0(v0, owner_id, platform_version)?; + document.set_contract_version(Some(contract_version)); + Ok(document) + } + + fn try_from_create_transition_action_v1( + v0: &DocumentCreateTransitionActionV0, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result { + let contract_version = action_contract_version(&v0.base); + let mut document = + Self::try_from_create_transition_action_v0(v0, owner_id, platform_version)?; + document.set_contract_version(Some(contract_version)); + Ok(document) + } +} diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/mod.rs index 136c1f9e6dc..21836bef237 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use std::collections::{BTreeMap, BTreeSet}; @@ -10,6 +11,7 @@ use dpp::platform_value::{Identifier, Value}; use dpp::prelude::{BlockHeight, CoreBlockHeight, Revision}; use dpp::ProtocolError; pub use v0::*; +pub use v1::*; use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionAction; use dpp::version::PlatformVersion; @@ -172,7 +174,25 @@ impl DocumentFromReplaceTransitionAction for Document { ) -> Result { match document_replace_transition_action { DocumentReplaceTransitionAction::V0(v0) => { - Self::try_from_replace_transition_action_v0(v0, owner_id, platform_version) + match platform_version + .drive + .methods + .state_transitions + .document_from_action + .document_from_replace_transition_action + { + 0 => { + Self::try_from_replace_transition_action_v0(v0, owner_id, platform_version) + } + 1 => { + Self::try_from_replace_transition_action_v1(v0, owner_id, platform_version) + } + version => Err(ProtocolError::UnknownVersionMismatch { + method: "Document::try_from_replace_transition_action".to_string(), + known_versions: vec![0, 1], + received: version, + }), + } } } } @@ -184,7 +204,29 @@ impl DocumentFromReplaceTransitionAction for Document { ) -> Result { match document_replace_transition_action { DocumentReplaceTransitionAction::V0(v0) => { - Self::try_from_owned_replace_transition_action_v0(v0, owner_id, platform_version) + match platform_version + .drive + .methods + .state_transitions + .document_from_action + .document_from_replace_transition_action + { + 0 => Self::try_from_owned_replace_transition_action_v0( + v0, + owner_id, + platform_version, + ), + 1 => Self::try_from_owned_replace_transition_action_v1( + v0, + owner_id, + platform_version, + ), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "Document::try_from_owned_replace_transition_action".to_string(), + known_versions: vec![0, 1], + received: version, + }), + } } } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/mod.rs index 2a458a70f2e..ac82cd6d4f7 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/mod.rs @@ -157,6 +157,7 @@ impl DocumentFromReplaceTransitionActionV0 for Document { .document_structure_version { 0 => Ok(DocumentV0 { + contract_version: None, id, owner_id, properties: data.clone(), @@ -211,6 +212,7 @@ impl DocumentFromReplaceTransitionActionV0 for Document { .document_structure_version { 0 => Ok(DocumentV0 { + contract_version: None, id, owner_id, properties: data, diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v1/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v1/mod.rs new file mode 100644 index 00000000000..9b1b631e1fc --- /dev/null +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v1/mod.rs @@ -0,0 +1,63 @@ +//! Generation 1 of the replace-action → `Document` conversion: generation 0 +//! plus the contract-version stamp. A replace re-supplies the full document +//! contents, so the document is re-stamped with the current contract +//! version. This generation must only be selected by platform versions +//! whose document serialization format writes the stamp (format 3, protocol +//! v14+); the version table pairs the two. + +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::document::{Document, DocumentV0Setters}; +use dpp::platform_value::Identifier; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; + +use crate::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; +use super::{DocumentFromReplaceTransitionActionV0, DocumentReplaceTransitionActionV0}; + +/// document from replace transition v1 +pub trait DocumentFromReplaceTransitionActionV1 { + /// Attempts to create a new `Document` from the given `DocumentReplaceTransitionAction` + /// reference and `owner_id`, re-stamped with the current contract version. + fn try_from_replace_transition_action_v1( + value: &DocumentReplaceTransitionActionV0, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result + where + Self: Sized; + /// Attempts to create a new `Document` from the given `DocumentReplaceTransitionAction` + /// instance and `owner_id`, re-stamped with the current contract version. + fn try_from_owned_replace_transition_action_v1( + value: DocumentReplaceTransitionActionV0, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result + where + Self: Sized; +} + +impl DocumentFromReplaceTransitionActionV1 for Document { + fn try_from_replace_transition_action_v1( + value: &DocumentReplaceTransitionActionV0, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result { + let contract_version = value.base.data_contract_fetch_info_ref().contract.version(); + let mut document = + Self::try_from_replace_transition_action_v0(value, owner_id, platform_version)?; + document.set_contract_version(Some(contract_version)); + Ok(document) + } + + fn try_from_owned_replace_transition_action_v1( + value: DocumentReplaceTransitionActionV0, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> Result { + let contract_version = value.base.data_contract_fetch_info_ref().contract.version(); + let mut document = + Self::try_from_owned_replace_transition_action_v0(value, owner_id, platform_version)?; + document.set_contract_version(Some(contract_version)); + Ok(document) + } +} diff --git a/packages/rs-drive/src/state_transition_action/identity/identity_credit_withdrawal/mod.rs b/packages/rs-drive/src/state_transition_action/identity/identity_credit_withdrawal/mod.rs index d83af986f2d..9e508a14a06 100644 --- a/packages/rs-drive/src/state_transition_action/identity/identity_credit_withdrawal/mod.rs +++ b/packages/rs-drive/src/state_transition_action/identity/identity_credit_withdrawal/mod.rs @@ -75,6 +75,7 @@ mod tests { fn make_document() -> Document { Document::V0(DocumentV0 { + contract_version: None, id: Identifier::from([0xDD; 32]), owner_id: Identifier::from([0xAA; 32]), properties: Default::default(), diff --git a/packages/rs-drive/src/state_transition_action/identity/identity_credit_withdrawal/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/identity/identity_credit_withdrawal/v0/transformer.rs index e7f8d622d16..3f74eb99ba8 100644 --- a/packages/rs-drive/src/state_transition_action/identity/identity_credit_withdrawal/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/identity/identity_credit_withdrawal/v0/transformer.rs @@ -52,6 +52,7 @@ impl IdentityCreditWithdrawalTransitionActionV0 { }); let withdrawal_document = DocumentV0 { + contract_version: None, id: document_id, owner_id: identity_credit_withdrawal.identity_id, properties: document_data.into_btree_string_map().unwrap(), @@ -176,6 +177,7 @@ impl IdentityCreditWithdrawalTransitionActionV0 { }); let withdrawal_document = DocumentV0 { + contract_version: None, id: document_id, owner_id: identity_credit_withdrawal.identity_id, properties: document_data.into_btree_string_map()?, diff --git a/packages/rs-drive/src/state_transition_action/shielded/shielded_withdrawal/mod.rs b/packages/rs-drive/src/state_transition_action/shielded/shielded_withdrawal/mod.rs index ed54c01c824..11ab32792bf 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/shielded_withdrawal/mod.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/shielded_withdrawal/mod.rs @@ -96,6 +96,7 @@ mod tests { fn make_document() -> Document { Document::V0(DocumentV0 { + contract_version: None, id: Identifier::from([0x11; 32]), owner_id: Identifier::from([0x22; 32]), properties: Default::default(), diff --git a/packages/rs-drive/src/state_transition_action/shielded/shielded_withdrawal/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/shielded/shielded_withdrawal/v0/transformer.rs index d674e99c965..86166ffd29d 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/shielded_withdrawal/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/shielded_withdrawal/v0/transformer.rs @@ -90,6 +90,7 @@ impl ShieldedWithdrawalTransitionActionV0 { }); let withdrawal_document = DocumentV0 { + contract_version: None, id: document_id, owner_id, properties: document_data diff --git a/packages/rs-drive/src/util/object_size_info/document_info.rs b/packages/rs-drive/src/util/object_size_info/document_info.rs index 086d34ed46d..8fe04f45b03 100644 --- a/packages/rs-drive/src/util/object_size_info/document_info.rs +++ b/packages/rs-drive/src/util/object_size_info/document_info.rs @@ -304,6 +304,7 @@ mod tests { /// Helper: build a minimal Document (V0) with a given 32-byte id. fn make_document(id_bytes: [u8; 32]) -> Document { Document::V0(DocumentV0 { + contract_version: None, id: Identifier::new(id_bytes), owner_id: Identifier::new([0xAA; 32]), properties: BTreeMap::new(), diff --git a/packages/rs-drive/tests/drive_storage_ops_coverage.rs b/packages/rs-drive/tests/drive_storage_ops_coverage.rs index 36342c497ae..25ea15b592e 100644 --- a/packages/rs-drive/tests/drive_storage_ops_coverage.rs +++ b/packages/rs-drive/tests/drive_storage_ops_coverage.rs @@ -887,6 +887,7 @@ mod document_operation_tests { use dpp::document::Document; let doc = Document::V0(dpp::document::DocumentV0 { + contract_version: None, id: dpp::prelude::Identifier::new([1u8; 32]), owner_id: dpp::prelude::Identifier::new([2u8; 32]), properties: Default::default(), @@ -920,6 +921,7 @@ mod document_operation_tests { use dpp::document::Document; let doc = Document::V0(dpp::document::DocumentV0 { + contract_version: None, id: dpp::prelude::Identifier::new([1u8; 32]), owner_id: dpp::prelude::Identifier::new([2u8; 32]), properties: Default::default(), diff --git a/packages/rs-drive/tests/query_tests.rs b/packages/rs-drive/tests/query_tests.rs index bdcd8ab5576..5393c6e4f71 100644 --- a/packages/rs-drive/tests/query_tests.rs +++ b/packages/rs-drive/tests/query_tests.rs @@ -6812,8 +6812,8 @@ mod tests { .expect("there is always a root hash"); let expected_app_hash = vec![ - 237, 198, 157, 236, 20, 182, 87, 85, 216, 64, 84, 25, 163, 231, 107, 173, 155, 152, 34, - 64, 34, 142, 234, 16, 99, 134, 153, 156, 24, 208, 150, 115, + 224, 113, 139, 209, 16, 163, 116, 151, 213, 152, 169, 13, 158, 228, 31, 124, 88, 139, + 165, 2, 152, 27, 85, 54, 21, 40, 183, 80, 104, 140, 198, 119, ]; assert_eq!(root_hash.as_slice(), expected_app_hash); @@ -6893,8 +6893,8 @@ mod tests { .expect("there is always a root hash"); let expected_app_hash = vec![ - 237, 198, 157, 236, 20, 182, 87, 85, 216, 64, 84, 25, 163, 231, 107, 173, 155, 152, 34, - 64, 34, 142, 234, 16, 99, 134, 153, 156, 24, 208, 150, 115, + 224, 113, 139, 209, 16, 163, 116, 151, 213, 152, 169, 13, 158, 228, 31, 124, 88, 139, + 165, 2, 152, 27, 85, 54, 21, 40, 183, 80, 104, 140, 198, 119, ]; assert_eq!(root_hash.as_slice(), expected_app_hash); @@ -6995,8 +6995,8 @@ mod tests { .expect("there is always a root hash"); let expected_app_hash = vec![ - 237, 198, 157, 236, 20, 182, 87, 85, 216, 64, 84, 25, 163, 231, 107, 173, 155, 152, 34, - 64, 34, 142, 234, 16, 99, 134, 153, 156, 24, 208, 150, 115, + 224, 113, 139, 209, 16, 163, 116, 151, 213, 152, 169, 13, 158, 228, 31, 124, 88, 139, + 165, 2, 152, 27, 85, 54, 21, 40, 183, 80, 104, 140, 198, 119, ]; assert_eq!(root_hash.as_slice(), expected_app_hash); diff --git a/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs b/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs index 6718805e19f..3984cfb261e 100644 --- a/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs +++ b/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs @@ -1369,6 +1369,54 @@ pub static KEYWORD_COMPATIBILITY_RULES: Lazy = Laz ], }, ), + // `requiredSince` (the contract version a property is required from) + // is frozen on existing properties: a document's byte layout is + // resolved from the latest schema by comparing each property's + // `requiredSince` against the document's contract-version stamp, so + // changing the annotation retroactively would misparse stored + // documents. A brand-new property carrying the keyword arrives as a + // single Add of the whole property subschema and never resolves this + // rule; introducing it there is judged by the document type's + // required-fields update validation, not by this differ. + ( + "requiredSince", + CompatibilityRules { + allow_addition: false, + allow_removal: false, + allow_replacement_callback: FALSE_CALLBACK.clone(), + subschema_levels_depth: None, + inner: None, + #[cfg(any(test, feature = "examples"))] + examples: vec![ + ( + json!({}), + json!({ "requiredSince": 2 }), + Some(JsonSchemaChange::Add(AddOperation { + path: "/requiredSince".to_string(), + value: json!(2), + })), + ) + .into(), + ( + json!({ "requiredSince": 2 }), + json!({}), + Some(JsonSchemaChange::Remove(RemoveOperation { + path: "/requiredSince".to_string(), + })), + ) + .into(), + ( + json!({ "requiredSince": 2 }), + json!({ "requiredSince": 3 }), + Some(JsonSchemaChange::Replace(ReplaceOperation { + path: "/requiredSince".to_string(), + value: json!(3), + })), + ) + .into(), + ], + }, + ), ( "$defs", CompatibilityRules { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs index e66bb564083..ec80ba92ca8 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs @@ -82,6 +82,11 @@ pub struct DocumentTypeSchemaVersions { /// `None` on versions that predate the keyword: they ignore it entirely, /// exactly as they parsed before it existed. pub apply_property_reference: OptionalFeatureVersion, + /// Parses the `requiredSince` property keyword (the contract version from + /// which a property is required). `None` on versions that predate the + /// keyword: they ignore it entirely, exactly as they parsed before it + /// existed. + pub apply_required_since: OptionalFeatureVersion, pub validate_max_depth: FeatureVersion, pub max_depth: u16, pub recursive_schema_validator_versions: RecursiveSchemaValidatorVersions, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs index 156436fbd24..bd6eda1e693 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs @@ -43,6 +43,7 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { find_identifier_and_binary_paths: 0, // This version predates the `refersTo` reference keyword apply_property_reference: None, + apply_required_since: None, validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs index 45a29d6b353..1aec3222db0 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs @@ -43,6 +43,7 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { find_identifier_and_binary_paths: 0, // This version predates the `refersTo` reference keyword apply_property_reference: None, + apply_required_since: None, validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs index 143f2719c93..5d513cc8506 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs @@ -45,6 +45,7 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { find_identifier_and_binary_paths: 0, // This version predates the `refersTo` reference keyword apply_property_reference: None, + apply_required_since: None, validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs index fd12634279e..3c9f484e8c4 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs @@ -45,6 +45,7 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { find_identifier_and_binary_paths: 0, // This version predates the `refersTo` reference keyword apply_property_reference: None, + apply_required_since: None, validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs index 1ca91eedd68..674f8593997 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs @@ -47,6 +47,7 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { find_identifier_and_binary_paths: 0, // This version predates the `refersTo` reference keyword apply_property_reference: None, + apply_required_since: None, validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs index f61a7db2c4b..546494f144d 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs @@ -69,6 +69,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { enrich_with_base_schema: 1, find_identifier_and_binary_paths: 0, apply_property_reference: Some(0), // changed: the meta-schema v3 `refersTo` keyword is folded into the parsed property type; None before this version means the keyword is ignored, as it was before it existed + apply_required_since: Some(0), // changed: the meta-schema v3 `requiredSince` keyword (contract version a property is required from) is parsed onto the property; None before this version means the keyword is ignored, as it was before it existed validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { @@ -81,7 +82,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { create_document_with_prevalidated_properties: 0, prefunded_voting_balance_for_document: 0, contested_vote_poll_for_document: 0, - estimated_size: 0, + estimated_size: 1, // changed: adds the document serialization format 3 contract-version stamp varint (worst case 5 bytes) to the estimate index_for_types: 0, max_size: 0, serialize_value_for_key: 0, diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/mod.rs index 22148d7eeaf..bbb55fefa6d 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/mod.rs @@ -3,6 +3,7 @@ use versioned_feature_core::{FeatureVersion, FeatureVersionBounds}; pub mod v1; pub mod v2; pub mod v3; +pub mod v4; #[derive(Clone, Debug, Default)] pub struct DPPDocumentVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/v4.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/v4.rs new file mode 100644 index 00000000000..dbcc34f6117 --- /dev/null +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_document_versions/v4.rs @@ -0,0 +1,37 @@ +use crate::version::dpp_versions::dpp_document_versions::{ + DPPDocumentVersions, DocumentMethodVersions, +}; +use versioned_feature_core::FeatureVersionBounds; + +/// Document serialization moves to format 3: the document is stamped with the +/// data contract version its bytes conform to, right after the format prefix. +/// The stamp lets a property carry `requiredSince` (required from a given +/// contract version) while documents written before that version keep the +/// presence-flagged layout they were serialized with. Formats 0-2 predate the +/// stamp and deserialize with an unstamped (pre-annotation) layout. +pub const DOCUMENT_VERSIONS_V4: DPPDocumentVersions = DPPDocumentVersions { + document_structure_version: 0, + document_serialization_version: FeatureVersionBounds { + min_version: 0, + max_version: 3, + default_current_version: 3, + }, + document_cbor_serialization_version: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + extended_document_structure_version: 0, + extended_document_serialization_version: FeatureVersionBounds { + min_version: 0, + max_version: 0, + default_current_version: 0, + }, + document_method_versions: DocumentMethodVersions { + is_equal_ignoring_timestamps: 0, + hash: 0, + get_raw_for_contract: 0, + get_raw_for_document_type: 0, + try_into_asset_unlock_base_transaction_info: 0, + }, +}; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 9195558adb4..d0ccbccd3f6 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -95,7 +95,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = }, masternode_vote_state_transition_balance_pre_check: 0, contract_create_state_transition: DriveAbciStateTransitionValidationVersion { - basic_structure: Some(1), + basic_structure: Some(2), // changed: rejects `requiredSince` other than 1 on a newly created contract — the annotation must name the version the change arrives with, and a fresh contract is version 1 advanced_structure: Some(1), identity_signatures: None, nonce: Some(0), diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs index 31387c4a28d..b2b4f08c977 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/mod.rs @@ -1,6 +1,7 @@ pub mod v1; pub mod v2; pub mod v3; +pub mod v4; use crate::version::drive_versions::DriveDataContractOperationMethodVersions; use versioned_feature_core::FeatureVersion; @@ -10,6 +11,18 @@ pub struct DriveStateTransitionMethodVersions { pub operations: DriveStateTransitionOperationMethodVersions, pub convert_to_high_level_operations: DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions, + pub document_from_action: DriveDocumentFromActionVersions, +} + +/// Versions of the action → `Document` conversions. Generation 1 stamps the +/// built document with the data contract version its bytes conform to +/// (create assigns, replace re-assigns); it must only be selected by +/// platform versions whose document serialization format writes the stamp +/// (format 3, protocol v14+). +#[derive(Clone, Debug, Default)] +pub struct DriveDocumentFromActionVersions { + pub document_from_create_transition_action: FeatureVersion, + pub document_from_replace_transition_action: FeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs index 204d3058747..aaebe7aa3f7 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v1.rs @@ -1,4 +1,5 @@ use crate::version::drive_versions::drive_state_transition_method_versions::{ + DriveDocumentFromActionVersions, DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions, DriveStateTransitionMethodVersions, DriveStateTransitionOperationMethodVersions, }; @@ -56,4 +57,8 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V1: DriveStateTransitionMethodV shielded_withdrawal_transition: 0, identity_create_from_shielded_pool_transition: 0, }, + document_from_action: DriveDocumentFromActionVersions { + document_from_create_transition_action: 0, + document_from_replace_transition_action: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs index babf9bc6fd1..48e4c7d8f8e 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v2.rs @@ -1,4 +1,5 @@ use crate::version::drive_versions::drive_state_transition_method_versions::{ + DriveDocumentFromActionVersions, DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions, DriveStateTransitionMethodVersions, DriveStateTransitionOperationMethodVersions, }; @@ -57,4 +58,8 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V2: DriveStateTransitionMethodV shielded_withdrawal_transition: 0, identity_create_from_shielded_pool_transition: 0, }, + document_from_action: DriveDocumentFromActionVersions { + document_from_create_transition_action: 0, + document_from_replace_transition_action: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs index 5993c538df9..801fdaf5437 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v3.rs @@ -1,4 +1,5 @@ use crate::version::drive_versions::drive_state_transition_method_versions::{ + DriveDocumentFromActionVersions, DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions, DriveStateTransitionMethodVersions, DriveStateTransitionOperationMethodVersions, }; @@ -61,4 +62,8 @@ pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V3: DriveStateTransitionMethodV shielded_withdrawal_transition: 0, identity_create_from_shielded_pool_transition: 0, }, + document_from_action: DriveDocumentFromActionVersions { + document_from_create_transition_action: 0, + document_from_replace_transition_action: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs new file mode 100644 index 00000000000..466d006a06c --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_state_transition_method_versions/v4.rs @@ -0,0 +1,69 @@ +use crate::version::drive_versions::drive_state_transition_method_versions::{ + DriveDocumentFromActionVersions, + DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions, + DriveStateTransitionMethodVersions, DriveStateTransitionOperationMethodVersions, +}; +use crate::version::drive_versions::DriveDataContractOperationMethodVersions; + +// This started at protocol 14: document_from_action generation 1 stamps built documents with the contract version (paired with document serialization format 3) +pub const DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4: DriveStateTransitionMethodVersions = + DriveStateTransitionMethodVersions { + operations: DriveStateTransitionOperationMethodVersions { + finalization_tasks: 0, + contracts: DriveDataContractOperationMethodVersions { + finalization_tasks: 0, + }, + }, + convert_to_high_level_operations: + DriveStateTransitionActionConvertToHighLevelOperationsMethodVersions { + data_contract_create_transition: 0, + data_contract_update_transition: 0, + document_create_transition: 0, + document_delete_transition: 0, + // PROTOCOL_VERSION_13: v1 rewrites a transferred or purchased + // DPNS domain document's `records.identity` to the new owner + // so the username resolves to the buyer. v0 stays for + // PROTOCOL_VERSION_12 chain replay. + document_purchase_transition: 1, // changed + document_replace_transition: 0, + document_transfer_transition: 1, // changed + document_update_price_transition: 1, // changed + token_burn_transition: 0, + token_mint_transition: 0, + token_transfer_transition: 0, + documents_batch_transition: 0, + identity_create_transition: 0, + identity_create_from_addresses_transition: 0, + identity_credit_transfer_transition: 0, + identity_credit_withdrawal_transition: 0, + identity_top_up_transition: 0, + identity_top_up_from_addresses_transition: 0, + identity_update_transition: 1, + masternode_vote_transition: 0, + bump_identity_data_contract_nonce: 0, + bump_identity_nonce: 0, + partially_use_asset_lock: 0, + token_freeze_transition: 0, + token_unfreeze_transition: 0, + token_emergency_action_transition: 0, + token_destroy_frozen_funds_transition: 0, + token_config_update_transition: 0, + token_claim_transition: 0, + token_direct_purchase_transition: 0, + token_set_price_for_direct_purchase_transition: 0, + identity_credit_transfer_to_addresses_transition: 0, + address_funds_transfer_transition: 0, + address_credit_withdrawal_transition: 0, + address_funding_from_asset_lock_transition: 0, + shield_transition: 0, + shield_from_asset_lock_transition: 0, + shielded_transfer_transition: 0, + unshield_transition: 0, + shielded_withdrawal_transition: 0, + identity_create_from_shielded_pool_transition: 0, + }, + document_from_action: DriveDocumentFromActionVersions { + document_from_create_transition_action: 1, // changed + document_from_replace_transition_action: 1, // changed + }, + }; diff --git a/packages/rs-platform-version/src/version/drive_versions/v9.rs b/packages/rs-platform-version/src/version/drive_versions/v9.rs index 54f4e357c57..fade08c521d 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v9.rs @@ -6,7 +6,7 @@ use crate::version::drive_versions::drive_group_method_versions::v1::DRIVE_GROUP use crate::version::drive_versions::drive_group_method_versions::DriveShieldedMethodVersions; use crate::version::drive_versions::drive_grove_method_versions::v1::DRIVE_GROVE_METHOD_VERSIONS_V1; use crate::version::drive_versions::drive_identity_method_versions::v2::DRIVE_IDENTITY_METHOD_VERSIONS_V2; -use crate::version::drive_versions::drive_state_transition_method_versions::v3::DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V3; +use crate::version::drive_versions::drive_state_transition_method_versions::v4::DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4; use crate::version::drive_versions::drive_structure_version::v1::DRIVE_STRUCTURE_V1; use crate::version::drive_versions::drive_token_method_versions::v1::DRIVE_TOKEN_METHOD_VERSIONS_V1; use crate::version::drive_versions::drive_verify_method_versions::v2::DRIVE_VERIFY_METHOD_VERSIONS_V2; @@ -98,7 +98,7 @@ pub const DRIVE_VERSION_V9: DriveVersion = DriveVersion { apply_batch_low_level_drive_operations: 0, apply_batch_grovedb_operations: 0, }, - state_transitions: DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V3, // changed in v8: DPNS domain records.identity rewrite on transfer/purchase + state_transitions: DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4, // changed: document_from_action generation 1 stamps built documents with the contract version (create assigns, replace re-assigns; paired with document serialization format 3) batch_operations: DriveBatchOperationsMethodVersion { convert_drive_operations_to_grove_operations: 0, apply_drive_operations: 0, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 21000350ca7..e5f3e09c909 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -2,7 +2,7 @@ use crate::version::consensus_versions::ConsensusVersions; use crate::version::dpp_versions::dpp_asset_lock_versions::v1::DPP_ASSET_LOCK_VERSIONS_V1; use crate::version::dpp_versions::dpp_contract_versions::v6::CONTRACT_VERSIONS_V6; use crate::version::dpp_versions::dpp_costs_versions::v1::DPP_COSTS_VERSIONS_V1; -use crate::version::dpp_versions::dpp_document_versions::v3::DOCUMENT_VERSIONS_V3; +use crate::version::dpp_versions::dpp_document_versions::v4::DOCUMENT_VERSIONS_V4; use crate::version::dpp_versions::dpp_factory_versions::v1::DPP_FACTORY_VERSIONS_V1; use crate::version::dpp_versions::dpp_identity_versions::v1::IDENTITY_VERSIONS_V1; use crate::version::dpp_versions::dpp_method_versions::v2::DPP_METHOD_VERSIONS_V2; @@ -113,6 +113,17 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// reference property names an identity or contract that does not exist /// is rejected. v13 keeps the v9 table and therefore keeps /// accepting all of these, so replay of pre-upgrade blocks is unchanged. +/// * `DOCUMENT_VERSIONS_V4` bumps `document_serialization_version` to +/// default 3: documents are stamped with the contract version their bytes +/// conform to (a varint after the format prefix), enabling the +/// `requiredSince` property keyword — a contract update may add a new +/// required property annotated with the version that update creates. +/// Documents stamped below a property's `requiredSince` keep the +/// presence-flagged layout they were written with, so the latest contract +/// alone reconstructs every stamp's layout and no historical contract +/// lookups are ever needed. Reads dispatch on the byte prefix, so +/// formats 0–2 (all pre-v14 documents) deserialize exactly as before with +/// an unstamped (pre-annotation) layout. /// /// The wire surface is deliberately unchanged: `GetDocumentsRequestV1` /// already carries `selects` / `group_by` / `order_by` / `limit` / @@ -137,7 +148,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V1, state_transitions: STATE_TRANSITION_VERSIONS_V3, contract_versions: CONTRACT_VERSIONS_V6, // changed: v3 document meta-schema hosts the ranked index keywords - document_versions: DOCUMENT_VERSIONS_V3, + document_versions: DOCUMENT_VERSIONS_V4, // changed: document serialization format 3 — the contract version stamp that enables `requiredSince` properties identity_versions: IDENTITY_VERSIONS_V1, voting_versions: VOTING_VERSION_V2, token_versions: TOKEN_VERSIONS_V2, diff --git a/packages/rs-platform-wallet-ffi/src/document.rs b/packages/rs-platform-wallet-ffi/src/document.rs index 32509bc3356..79c18bc2e1f 100644 --- a/packages/rs-platform-wallet-ffi/src/document.rs +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -532,6 +532,7 @@ mod tests { ); let document = Document::V0(DocumentV0 { + contract_version: None, id: Identifier::from([1u8; 32]), owner_id: Identifier::from([2u8; 32]), properties, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs index 36e34dd3556..a0a7a919204 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs @@ -726,6 +726,7 @@ impl DashPayView<'_, B> { properties.insert("privateData".to_string(), Value::Bytes(private_data)); let document = Document::V0(DocumentV0 { + contract_version: None, id: doc_id.unwrap_or_else(|| Identifier::from([0u8; 32])), owner_id: *identity_id, properties, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index c99018792cf..46fa521e4bf 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -4287,6 +4287,7 @@ mod sweep_tests { ); let doc = Document::V0(DocumentV0 { + contract_version: None, id: Identifier::from([9u8; 32]), owner_id: sender, properties, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index b5582883845..9cc79a181db 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -197,6 +197,7 @@ impl DashPayView<'_, B> { }; let stub_document = Document::V0(DocumentV0 { + contract_version: None, id: Identifier::from([0u8; 32]), owner_id: *identity_id, properties, @@ -358,6 +359,7 @@ impl DashPayView<'_, B> { }; let updated_document = Document::V0(DocumentV0 { + contract_version: None, id: existing_doc_id, owner_id: *identity_id, properties, diff --git a/packages/rs-sdk-ffi/src/document/create.rs b/packages/rs-sdk-ffi/src/document/create.rs index 1412ea0d46e..6aa744398d6 100644 --- a/packages/rs-sdk-ffi/src/document/create.rs +++ b/packages/rs-sdk-ffi/src/document/create.rs @@ -341,6 +341,7 @@ pub unsafe extern "C" fn dash_sdk_document_make_handle( // Create the document let document = Document::V0(DocumentV0 { + contract_version: None, id: document_id, owner_id, properties, diff --git a/packages/rs-sdk-ffi/src/document/delete.rs b/packages/rs-sdk-ffi/src/document/delete.rs index b1dbb23d439..864bc8019bb 100644 --- a/packages/rs-sdk-ffi/src/document/delete.rs +++ b/packages/rs-sdk-ffi/src/document/delete.rs @@ -400,6 +400,7 @@ mod tests { properties.insert("name".to_string(), Value::Text("Test Document".to_string())); let document = Document::V0(DocumentV0 { + contract_version: None, id, owner_id, properties, diff --git a/packages/rs-sdk-ffi/src/document/price.rs b/packages/rs-sdk-ffi/src/document/price.rs index 18aea1485c7..10197aa6a2b 100644 --- a/packages/rs-sdk-ffi/src/document/price.rs +++ b/packages/rs-sdk-ffi/src/document/price.rs @@ -336,6 +336,7 @@ mod tests { properties.insert("price".to_string(), Value::U64(1000)); let document = Document::V0(DocumentV0 { + contract_version: None, id, owner_id, properties, diff --git a/packages/rs-sdk-ffi/src/document/purchase.rs b/packages/rs-sdk-ffi/src/document/purchase.rs index bf55bb6fc11..8d45477eb1f 100644 --- a/packages/rs-sdk-ffi/src/document/purchase.rs +++ b/packages/rs-sdk-ffi/src/document/purchase.rs @@ -382,6 +382,7 @@ mod tests { properties.insert("price".to_string(), Value::U64(1000)); let document = Document::V0(DocumentV0 { + contract_version: None, id, owner_id, properties, diff --git a/packages/rs-sdk-ffi/src/document/put.rs b/packages/rs-sdk-ffi/src/document/put.rs index fb2539df325..d492b6bb065 100644 --- a/packages/rs-sdk-ffi/src/document/put.rs +++ b/packages/rs-sdk-ffi/src/document/put.rs @@ -400,6 +400,7 @@ mod tests { properties.insert("name".to_string(), Value::Text("Test Document".to_string())); let document = Document::V0(DocumentV0 { + contract_version: None, id, owner_id, properties, diff --git a/packages/rs-sdk-ffi/src/document/replace.rs b/packages/rs-sdk-ffi/src/document/replace.rs index c552bd291b2..6be96648c02 100644 --- a/packages/rs-sdk-ffi/src/document/replace.rs +++ b/packages/rs-sdk-ffi/src/document/replace.rs @@ -413,6 +413,7 @@ mod tests { properties.insert("age".to_string(), Value::U64(25)); let document = Document::V0(DocumentV0 { + contract_version: None, id, owner_id, properties, diff --git a/packages/rs-sdk-ffi/src/document/transfer.rs b/packages/rs-sdk-ffi/src/document/transfer.rs index 9dfeeaab883..00523d400a1 100644 --- a/packages/rs-sdk-ffi/src/document/transfer.rs +++ b/packages/rs-sdk-ffi/src/document/transfer.rs @@ -391,6 +391,7 @@ mod tests { ); let document = Document::V0(DocumentV0 { + contract_version: None, id, owner_id, properties, diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request.rs b/packages/rs-sdk/src/platform/dashpay/contact_request.rs index d595faaaed7..adf48b41150 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request.rs @@ -524,6 +524,7 @@ impl Sdk { // Create the document from the result let document = Document::V0(DocumentV0 { + contract_version: None, id: result.id, owner_id: result.owner_id, properties: result.properties, diff --git a/packages/rs-sdk/src/platform/documents/transitions/delete.rs b/packages/rs-sdk/src/platform/documents/transitions/delete.rs index 2d44ec735a5..da43978f5bd 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/delete.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/delete.rs @@ -179,6 +179,7 @@ impl DocumentDeleteTransitionBuilder { // Create a minimal document for deletion let document = Document::V0(dpp::document::DocumentV0 { + contract_version: None, id: self.document_id, owner_id: self.owner_id, properties: Default::default(), diff --git a/packages/rs-sdk/src/platform/documents/transitions/purchase.rs b/packages/rs-sdk/src/platform/documents/transitions/purchase.rs index fa6be76a464..d9b245139e0 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/purchase.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/purchase.rs @@ -90,6 +90,7 @@ impl DocumentPurchaseTransitionBuilder { // Create a minimal document with just the required fields // The actual document will be fetched during the transition let document = Document::V0(dpp::document::DocumentV0 { + contract_version: None, id: document_id, owner_id: current_owner_id, properties: Default::default(), diff --git a/packages/rs-sdk/src/platform/documents/transitions/set_price.rs b/packages/rs-sdk/src/platform/documents/transitions/set_price.rs index 6750a6700cc..75f289c2fce 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/set_price.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/set_price.rs @@ -84,6 +84,7 @@ impl DocumentSetPriceTransitionBuilder { // Create a minimal document with just the required fields // The actual document will be fetched during the transition let document = Document::V0(dpp::document::DocumentV0 { + contract_version: None, id: document_id, owner_id, properties: Default::default(), diff --git a/packages/rs-sdk/src/platform/documents/transitions/transfer.rs b/packages/rs-sdk/src/platform/documents/transitions/transfer.rs index 490380cc3fa..8bd5d515921 100644 --- a/packages/rs-sdk/src/platform/documents/transitions/transfer.rs +++ b/packages/rs-sdk/src/platform/documents/transitions/transfer.rs @@ -83,6 +83,7 @@ impl DocumentTransferTransitionBuilder { // Create a minimal document with just the required fields // The actual document will be fetched during the transition let document = Document::V0(dpp::document::DocumentV0 { + contract_version: None, id: document_id, owner_id, properties: Default::default(), diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 4d6ba1f660f..0f6ddefa7cd 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -278,6 +278,7 @@ impl Sdk { // Create preorder document let preorder_document = Document::V0(DocumentV0 { + contract_version: None, id: preorder_id, owner_id: identity_id, properties: BTreeMap::from([( @@ -299,6 +300,7 @@ impl Sdk { // Create domain document let domain_document = Document::V0(DocumentV0 { + contract_version: None, id: domain_id, owner_id: identity_id, properties: BTreeMap::from([ diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 7a1b2245af5..33daad96a0e 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -62,7 +62,7 @@ use dpp::consensus::state::data_trigger::DataTriggerError::{ DataTriggerConditionError, DataTriggerExecutionError, DataTriggerInvalidResultError, }; use wasm_bindgen::{JsError, JsValue}; -use dpp::consensus::basic::data_contract::{ContestedUniqueIndexOnMutableDocumentTypeError, ContestedUniqueIndexWithUniqueIndexError, DataContractTokenConfigurationUpdateError, DecimalsOverLimitError, DuplicateKeywordsError, GroupExceedsMaxMembersError, GroupHasTooFewMembersError, GroupMemberHasPowerOfZeroError, GroupMemberHasPowerOverLimitError, GroupNonUnilateralMemberPowerHasLessThanRequiredPowerError, GroupPositionDoesNotExistError, GroupRequiredPowerIsInvalidError, GroupTotalPowerLessThanRequiredError, InvalidDescriptionLengthError, InvalidDocumentTypeRequiredSecurityLevelError, InvalidKeywordCharacterError, InvalidKeywordLengthError, InvalidTokenBaseSupplyError, InvalidTokenDistributionFunctionDivideByZeroError, InvalidTokenDistributionFunctionIncoherenceError, InvalidTokenDistributionFunctionInvalidParameterError, InvalidTokenDistributionFunctionInvalidParameterTupleError, InvalidTokenLanguageCodeError, InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, NonContiguousContractTokenPositionsError, RedundantDocumentPaidForByTokenWithContractId, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, TooManyKeywordsError, UnknownDocumentActionTokenEffectError, UnknownDocumentCreationRestrictionModeError, UnknownGasFeesPaidByError, UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, UnknownTradeModeError, UnknownTransferableTypeError}; +use dpp::consensus::basic::data_contract::{ContestedUniqueIndexOnMutableDocumentTypeError, DataContractInvalidRequiredFieldsUpdateError, ContestedUniqueIndexWithUniqueIndexError, DataContractTokenConfigurationUpdateError, DecimalsOverLimitError, DuplicateKeywordsError, GroupExceedsMaxMembersError, GroupHasTooFewMembersError, GroupMemberHasPowerOfZeroError, GroupMemberHasPowerOverLimitError, GroupNonUnilateralMemberPowerHasLessThanRequiredPowerError, GroupPositionDoesNotExistError, GroupRequiredPowerIsInvalidError, GroupTotalPowerLessThanRequiredError, InvalidDescriptionLengthError, InvalidDocumentTypeRequiredSecurityLevelError, InvalidKeywordCharacterError, InvalidKeywordLengthError, InvalidTokenBaseSupplyError, InvalidTokenDistributionFunctionDivideByZeroError, InvalidTokenDistributionFunctionIncoherenceError, InvalidTokenDistributionFunctionInvalidParameterError, InvalidTokenDistributionFunctionInvalidParameterTupleError, InvalidTokenLanguageCodeError, InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, NonContiguousContractTokenPositionsError, RedundantDocumentPaidForByTokenWithContractId, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, TooManyKeywordsError, UnknownDocumentActionTokenEffectError, UnknownDocumentCreationRestrictionModeError, UnknownGasFeesPaidByError, UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, UnknownTradeModeError, UnknownTransferableTypeError}; use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; use dpp::consensus::basic::group::GroupActionNotAllowedOnTransitionError; use dpp::consensus::basic::identity::{DataContractBoundsNotPresentError, DisablingKeyIdAlsoBeingAddedInSameTransitionError, InvalidIdentityCreditWithdrawalTransitionAmountError, InvalidIdentityUpdateTransitionDisableKeysError, InvalidIdentityUpdateTransitionEmptyError, InvalidKeyPurposeForContractBoundsError, TooManyMasterPublicKeyError, WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError}; @@ -1005,6 +1005,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::TokenPricingScheduleEmptyError(e) => { generic_consensus_error!(TokenPricingScheduleEmptyError, e).into() } + BasicError::DataContractInvalidRequiredFieldsUpdateError(e) => { + generic_consensus_error!(DataContractInvalidRequiredFieldsUpdateError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/data_contract/document/model.rs b/packages/wasm-dpp2/src/data_contract/document/model.rs index d447196c486..b49d54ff9a3 100644 --- a/packages/wasm-dpp2/src/data_contract/document/model.rs +++ b/packages/wasm-dpp2/src/data_contract/document/model.rs @@ -235,6 +235,7 @@ impl DocumentWasm { )?; let document = Document::V0(DocumentV0 { + contract_version: None, id: doc_id, owner_id, properties,