From 9f2497d39527a3bf88b515f40ae9db5238ab2494 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 04:59:55 +0700 Subject: [PATCH] feat(dpp)!: encode typed array elements as their scalar property type (PV14) A typed array element is now parsed from its items schema exactly as a scalar property schema is (DocumentPropertyType::try_from_value_map with the contract's parsing options) and encoded exactly as a required scalar property of that type. An identifier element is 32 raw bytes instead of a 0x20 length byte and 32 bytes, an integer element takes the width its bounds give it instead of always 8 bytes, and a fixed-size byte array element is raw. Strings and variable-size byte arrays keep their varint length. TypedArrayProperty::item_type becomes Box. The decoder refuses a serialized count above maxItems before reading any element, and the encoder refuses a raw identifier or fixed-size byte element of the wrong length. The schema compatibility rules allow raising maximum, adding enum values and unpinning a byte array's size, which would change an element's width, so validate_update v1 refuses a change in how an element encodes. Typed arrays have never shipped (#4922 is unreleased), so no stored document uses the old element encoding. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/documents.md | 4 +- .../serialization/document-serialization.md | 6 +- .../class_methods/parse_typed_array/mod.rs | 24 +- .../class_methods/parse_typed_array/v0/mod.rs | 143 ++++++- .../class_methods/try_from_schema/mod.rs | 13 +- .../try_from_schema/v3/typed_array_tests.rs | 46 +- .../methods/validate_update/v1/mod.rs | 228 +++++++++- .../document_type/property/array.rs | 394 ++++++++---------- .../document_type/property/mod.rs | 377 ++++++++++++----- .../v1/mod.rs | 16 +- .../document_type/v0/random_document_type.rs | 39 +- .../rs-platform-version/src/version/v14.rs | 10 +- .../document_type_typed_arrays.rs | 54 ++- 13 files changed, 979 insertions(+), 375 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 107acbf45f2..d2909cb6bfb 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -328,9 +328,9 @@ Up to protocol version 13 a `type: "array"` property had to be a byte array (`by - A byte array keeps its form and takes no `items`. On a plain byte array `uniqueItems` keeps its old meaning, no repeated byte, but an identifier (a byte array with the identifier `contentMediaType`) refuses it: an identifier is one value, and "no repeated byte" would refuse most of them. - The document is validated against the JSON schema as always, so a list that is too long, too short, repeats an element under `uniqueItems` or holds a wrong-typed element fails with the usual `JsonSchemaError`. -The array is stored inline in the document, like any other property: a varint element count followed by each element in its own encoding (see [Document Serialization](../serialization/document-serialization.md)). Identifier and byte array elements are conversion paths (`reasons[]`, `find_identifier_and_binary_paths` 1), so a document built from JSON or a value map converts every element, as it converts a scalar identifier. Nothing is written per element, so a typed array cannot be an index property (`InvalidIndexPropertyTypeError`), an indexOnly terminal or entry payload property, or one side of a `propertyAgreement`. +The array is stored inline in the document, like any other property: a varint element count followed by the elements, each encoded exactly as a required property of the element's type (see [Document Serialization](../serialization/document-serialization.md)). The `reasons` list above is therefore one count byte and 32 raw bytes per identifier, and an integer element bounded `0`..`100` takes one byte. Since the stored bytes depend on the element's type, a contract update may not change how an element encodes: raising an integer element's `maximum` (or adding an `enum` value) past its width, or unpinning a fixed-size byte array element, is refused with `DocumentTypeUpdateError`. A longer `maxLength`, a larger `maxItems` or a raised `maximum` that keeps the width are accepted. Identifier and byte array elements are conversion paths (`reasons[]`, `find_identifier_and_binary_paths` 1), so a document built from JSON or a value map converts every element, as it converts a scalar identifier. Nothing is written per element, so a typed array cannot be an index property (`InvalidIndexPropertyTypeError`), an indexOnly terminal or entry payload property, or one side of a `propertyAgreement`. -In Rust a typed array parses to `DocumentPropertyType::TypedArray(TypedArrayProperty)`, with the element type as an `ArrayItemType`. The parse is the versioned `parse_typed_array` (`None` before protocol version 14, where an array that is not a byte array is refused as it always was). The older `DocumentPropertyType::Array` variant has the same encoding without the count bounds, and the parser never produces it. +In Rust a typed array parses to `DocumentPropertyType::TypedArray(TypedArrayProperty)`, whose `item_type` is the `DocumentPropertyType` the `items` schema parses to as a property schema (`try_from_value_map` with the contract's parsing options). The parse is the versioned `parse_typed_array` (`None` before protocol version 14, where an array that is not a byte array is refused as it always was). The older `DocumentPropertyType::Array` variant, whose elements are an `ArrayItemType` in their own length-prefixed encoding, is never produced by the parser. ## Rules and Guidelines diff --git a/book/src/serialization/document-serialization.md b/book/src/serialization/document-serialization.md index 80c16034797..5f6b52230a3 100644 --- a/book/src/serialization/document-serialization.md +++ b/book/src/serialization/document-serialization.md @@ -184,7 +184,7 @@ All numeric values use **big-endian** byte order. | `byteArray` (variable size) | varint length prefix + raw bytes | | `identifier` | 32 bytes raw | | `date` | 8 bytes big-endian f64 (when optional: `0xff` prefix + 8 bytes) | -| `array` (typed array, protocol v14) | varint element count + each element in sequence: integer or number 8 bytes, boolean 1 byte, string, byte array or identifier a varint length prefix + the bytes (an identifier element is always 33 bytes) | +| `array` (typed array, protocol v14) | varint element count + each element encoded exactly as a required property of the element's type (rows above): an identifier element is 32 raw bytes, an integer element takes the width its bounds give it, a fixed-size byte array element is raw, a string or variable-size byte array element has a varint length prefix. Elements never carry a presence byte | | `object` | Nested fields serialized recursively in their schema position order | **Note on date types**: User-property `date` fields are encoded as **f64** (8 bytes). System timestamps (`$createdAt`, `$updatedAt`, `$transferredAt`) are **u64** milliseconds. Both are 8 bytes big-endian but use different numeric representations. @@ -284,4 +284,6 @@ See `packages/rs-scripts/README.md` for full usage details. 6. **ByteArray encoding depends on size constraints.** Fixed-size byte arrays (where `minItems == maxItems` in the schema) have no length prefix. Variable-size byte arrays have a varint length prefix. Check the schema to know which encoding is used. -7. **In version 3, the same document type can produce different property layouts.** A property annotated with `requiredSince` is presence-flagged in documents stamped below the annotation and raw in documents stamped at or above it. Two version-3 documents of the same type may therefore differ in layout — always read the stamp varint and resolve each property's requiredness against it before decoding the properties section. +7. **A typed array's element width comes from its `items` schema.** Each element is laid out as a required property of the element's type, so an integer element bounded `0`..`100` is 1 byte and an unbounded one 8, and a fixed-size byte array or identifier element has no length prefix. Parse the `items` schema exactly as a property schema to know the width, including the contract's `sizedIntegerTypes` setting. + +8. **In version 3, the same document type can produce different property layouts.** A property annotated with `requiredSince` is presence-flagged in documents stamped below the annotation and raw in documents stamped at or above it. Two version-3 documents of the same type may therefore differ in layout — always read the stamp varint and resolve each property's requiredness against it before decoding the properties section. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs index fb2e720c7a2..9b079eaa676 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs @@ -3,7 +3,9 @@ use std::collections::BTreeMap; use platform_value::Value; use platform_version::version::PlatformVersion; -use crate::data_contract::document_type::DocumentPropertyType; +use crate::data_contract::document_type::{ + DocumentPropertyType, DocumentPropertyTypeParsingOptions, +}; use crate::data_contract::errors::DataContractError; mod v0; @@ -12,7 +14,9 @@ mod v0; /// schema in place of `byteArray`, into [`DocumentPropertyType::TypedArray`]. /// /// Returns `None` for every other property, a byte array included, which the -/// caller leaves to `DocumentPropertyType::try_from_value_map`. +/// caller leaves to `DocumentPropertyType::try_from_value_map`. The element +/// schema is parsed by that same scalar parser with the property's `options`, +/// so an element has the type a scalar property of its schema would have. /// /// Versioned on `parse_typed_array` in the platform version's document type /// schema versions. `None` selects the behavior of the versions that predate @@ -20,6 +24,7 @@ mod v0; /// array that is not a byte array, exactly as those versions always did. pub(crate) fn parse_typed_array( inner_properties: &BTreeMap, + options: &DocumentPropertyTypeParsingOptions, platform_version: &PlatformVersion, ) -> Result, DataContractError> { match platform_version @@ -30,7 +35,7 @@ pub(crate) fn parse_typed_array( .parse_typed_array { None => Ok(None), - Some(0) => v0::parse_typed_array_v0(inner_properties), + Some(0) => v0::parse_typed_array_v0(inner_properties, options), Some(version) => Err(DataContractError::Unsupported(format!( "parse_typed_array version {version} is not supported" ))), @@ -40,7 +45,8 @@ pub(crate) fn parse_typed_array( #[cfg(test)] mod tests { use super::*; - use crate::data_contract::document_type::array::{ArrayItemType, TypedArrayProperty}; + use crate::data_contract::document_type::array::TypedArrayProperty; + use crate::data_contract::document_type::StringPropertySizes; use platform_value::platform_value; #[test] @@ -55,11 +61,15 @@ mod tests { let map = schema .to_btree_ref_string_map() .expect("the schema is a map"); + let options = DocumentPropertyTypeParsingOptions::default(); assert_eq!( - parse_typed_array(&map, PlatformVersion::latest()).expect("parses"), + parse_typed_array(&map, &options, PlatformVersion::latest()).expect("parses"), Some(DocumentPropertyType::TypedArray(TypedArrayProperty { - item_type: ArrayItemType::String(None, Some(16)), + item_type: Box::new(DocumentPropertyType::String(StringPropertySizes { + min_length: None, + max_length: Some(16), + })), min_items: Some(1), max_items: 8, unique_items: true, @@ -68,7 +78,7 @@ mod tests { // Protocol version 13 leaves the property to the scalar parser let platform_version_13 = PlatformVersion::get(13).expect("protocol version 13 exists"); assert_eq!( - parse_typed_array(&map, platform_version_13).expect("parses"), + parse_typed_array(&map, &options, platform_version_13).expect("parses"), None ); } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs index 6189af95726..bbf9255db5d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs @@ -3,8 +3,10 @@ use std::collections::BTreeMap; use platform_value::btreemap_extensions::BTreeValueMapHelper; use platform_value::Value; -use crate::data_contract::document_type::array::{ArrayItemType, TypedArrayProperty}; -use crate::data_contract::document_type::{property_names, DocumentPropertyType}; +use crate::data_contract::document_type::array::TypedArrayProperty; +use crate::data_contract::document_type::{ + property_names, DocumentPropertyType, DocumentPropertyTypeParsingOptions, +}; use crate::data_contract::errors::DataContractError; /// Generation 0 parse rules: an array property that does not declare @@ -17,6 +19,7 @@ use crate::data_contract::errors::DataContractError; /// validation. pub(super) fn parse_typed_array_v0( inner_properties: &BTreeMap, + options: &DocumentPropertyTypeParsingOptions, ) -> Result, DataContractError> { let is_array = inner_properties .get(property_names::TYPE) @@ -43,7 +46,7 @@ pub(super) fn parse_typed_array_v0( )); } - let item_type = ArrayItemType::try_from(*items)?; + let item_type = parse_element_type(items, options)?; // Fee estimation sizes the inline list by its bound let Some(max_items) = inner_properties.get_optional_integer(property_names::MAX_ITEMS)? else { @@ -61,7 +64,7 @@ pub(super) fn parse_typed_array_v0( } Ok(Some(DocumentPropertyType::TypedArray(TypedArrayProperty { - item_type, + item_type: Box::new(item_type), min_items, max_items, unique_items: inner_properties @@ -70,6 +73,80 @@ pub(super) fn parse_typed_array_v0( }))) } +/// The element type of a typed array: its `items` schema parsed exactly as a +/// scalar property schema is, so an integer element takes the width its +/// bounds give it and a byte array element with the identifier media type is +/// an identifier. Objects and arrays of arrays are refused. +/// +/// `refersTo` is refused for now. A reference on identifier elements would be +/// read from this same map and folded into the element type, as +/// `apply_property_reference` folds one into a scalar identifier. +fn parse_element_type( + items: &Value, + options: &DocumentPropertyTypeParsingOptions, +) -> Result { + // The tuple form (`items: [..]`) and boolean schemas are not one element + // schema + let items_map = items.to_btree_ref_string_map().map_err(|_| { + DataContractError::InvalidContractStructure( + "the items of a typed array must be one element schema (an object)".to_string(), + ) + })?; + if items_map.contains_key(property_names::REF) { + return Err(DataContractError::InvalidContractStructure( + "the items of a typed array must be an inline element schema, not a $ref".to_string(), + )); + } + if items_map.contains_key(property_names::REFERS_TO) { + return Err(DataContractError::InvalidContractStructure( + "refersTo is not supported on the elements of a typed array".to_string(), + )); + } + match items_map + .get(property_names::TYPE) + .and_then(|type_value| type_value.as_text()) + { + Some("object") => { + return Err(DataContractError::InvalidContractStructure( + "arrays of objects are not supported: the elements of a typed array must be \ + scalars (integer, number, string, boolean, byte array or identifier)" + .to_string(), + )) + } + Some("array") if !items_map.contains_key(property_names::BYTE_ARRAY) => { + return Err(DataContractError::InvalidContractStructure( + "arrays of arrays are not supported: an element of a typed array may be a byte \ + array (byteArray: true) or an identifier, but not another array" + .to_string(), + )) + } + _ => {} + } + + let element_type = DocumentPropertyType::try_from_value_map(&items_map, options)?; + match element_type { + DocumentPropertyType::U128 + | DocumentPropertyType::I128 + | DocumentPropertyType::U64 + | DocumentPropertyType::I64 + | DocumentPropertyType::U32 + | DocumentPropertyType::I32 + | DocumentPropertyType::U16 + | DocumentPropertyType::I16 + | DocumentPropertyType::U8 + | DocumentPropertyType::I8 + | DocumentPropertyType::F64 + | DocumentPropertyType::String(_) + | DocumentPropertyType::ByteArray(_) + | DocumentPropertyType::Identifier + | DocumentPropertyType::Boolean => Ok(element_type), + other => Err(DataContractError::InvalidContractStructure(format!( + "unsupported typed array element type: {}", + other.name() + ))), + } +} + #[cfg(test)] mod tests { use super::*; @@ -79,7 +156,7 @@ mod tests { let map = schema .to_btree_ref_string_map() .expect("the schema is a map"); - parse_typed_array_v0(&map) + parse_typed_array_v0(&map, &DocumentPropertyTypeParsingOptions::default()) } #[test] @@ -105,7 +182,7 @@ mod tests { })) .expect("parses"), Some(DocumentPropertyType::TypedArray(TypedArrayProperty { - item_type: ArrayItemType::Integer, + item_type: Box::new(DocumentPropertyType::I64), min_items: Some(1), max_items: 4, unique_items: false, @@ -113,6 +190,60 @@ mod tests { ); } + /// An element is parsed by the scalar parser, so it takes the type a + /// scalar property of the same schema takes: an integer sized by its + /// bounds (when the contract sizes integers), an identifier from the + /// identifier media type. + #[test] + fn should_type_an_element_as_a_scalar_property_of_its_schema() { + for (items, sized_integer_types, expected) in [ + ( + platform_value!({ "type": "integer", "minimum": 0, "maximum": 100 }), + true, + DocumentPropertyType::U8, + ), + ( + platform_value!({ "type": "integer", "minimum": -1000, "maximum": 1000 }), + true, + DocumentPropertyType::I16, + ), + // A contract that does not size integers keeps them at 64 bits + ( + platform_value!({ "type": "integer", "minimum": 0, "maximum": 100 }), + false, + DocumentPropertyType::I64, + ), + ( + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }), + true, + DocumentPropertyType::Identifier, + ), + ] { + let schema = + platform_value!({ "type": "array", "maxItems": 4, "items": items.clone() }); + let map = schema + .to_btree_ref_string_map() + .expect("the schema is a map"); + let parsed = parse_typed_array_v0( + &map, + &DocumentPropertyTypeParsingOptions { + sized_integer_types, + }, + ) + .expect("parses"); + let Some(DocumentPropertyType::TypedArray(typed_array)) = parsed else { + panic!("{items:?} should parse to a typed array"); + }; + assert_eq!(*typed_array.item_type, expected, "{items:?}"); + } + } + #[test] fn should_refuse_a_typed_array_missing_items_or_max_items_or_with_a_misplaced_bound() { for (schema, fragment) in [ 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 118f8c1961b..25226361b1a 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 @@ -7,7 +7,7 @@ use crate::data_contract::document_type::{ is_referenced_system_agreement_property, is_referring_system_agreement_property, property_names, ContractReferenceModeration, ContractReferenceOwner, ContractReferenceRequirements, DocumentProperty, DocumentPropertyReferenceTarget, - DocumentPropertyType, DocumentType, + DocumentPropertyType, DocumentPropertyTypeParsingOptions, DocumentType, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; @@ -155,9 +155,11 @@ fn insert_values( platform_version, )?; - let property_type = match parse_typed_array(&inner_properties, platform_version)? { + let options: DocumentPropertyTypeParsingOptions = config.into(); + let property_type = match parse_typed_array(&inner_properties, &options, platform_version)? + { Some(typed_array) => typed_array, - None => DocumentPropertyType::try_from_value_map(&inner_properties, &config.into())?, + None => DocumentPropertyType::try_from_value_map(&inner_properties, &options)?, }; match property_type { @@ -237,9 +239,10 @@ fn insert_values_nested( platform_version, )?; - let property_type = match parse_typed_array(&inner_properties, platform_version)? { + let options: DocumentPropertyTypeParsingOptions = config.into(); + let property_type = match parse_typed_array(&inner_properties, &options, platform_version)? { Some(typed_array) => typed_array, - None => DocumentPropertyType::try_from_value_map(&inner_properties, &config.into())?, + None => DocumentPropertyType::try_from_value_map(&inner_properties, &options)?, }; let property_type = match property_type { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs index eab1a34f26d..ccde1bc53bd 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs @@ -13,8 +13,10 @@ use crate::consensus::basic::json_schema_error::JsonSchemaError; use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::data_contract::accessors::v0::DataContractV0Getters; -use crate::data_contract::document_type::array::{ArrayItemType, TypedArrayProperty}; -use crate::data_contract::document_type::DocumentPropertyType; +use crate::data_contract::document_type::array::TypedArrayProperty; +use crate::data_contract::document_type::{ + ByteArrayPropertySizes, DocumentPropertyType, StringPropertySizes, +}; use crate::data_contract::errors::DataContractError; use crate::data_contract::serialized_version::v0::DataContractInSerializationFormatV0; use crate::data_contract::DataContract; @@ -131,7 +133,7 @@ fn should_parse_a_typed_identifier_array() { assert_eq!( list_property_type(&document_type), DocumentPropertyType::TypedArray(TypedArrayProperty { - item_type: ArrayItemType::Identifier, + item_type: Box::new(DocumentPropertyType::Identifier), min_items: Some(0), max_items: 64, unique_items: true, @@ -159,42 +161,60 @@ fn should_parse_a_typed_integer_array_with_bounds() { assert_eq!( property_type, DocumentPropertyType::TypedArray(TypedArrayProperty { - item_type: ArrayItemType::Integer, + item_type: Box::new(DocumentPropertyType::U8), min_items: Some(1), max_items: 10, unique_items: false, }) ); - // A one-byte element count, then 1 to 10 elements of 8 bytes each + // A one-byte element count, then 1 to 10 elements of one byte each: the + // element takes the width a scalar property bounded 0 to 100 takes assert_eq!( property_type .min_byte_size(platform_version) .expect("sized"), - Some(9) + Some(2) ); assert_eq!( property_type .max_byte_size(platform_version) .expect("sized"), - Some(81) + Some(11) ); } #[test] fn should_parse_every_scalar_element_type() { for (items, expected) in [ - (platform_value!({ "type": "number" }), ArrayItemType::Number), + ( + platform_value!({ "type": "number" }), + DocumentPropertyType::F64, + ), ( platform_value!({ "type": "boolean" }), - ArrayItemType::Boolean, + DocumentPropertyType::Boolean, ), ( platform_value!({ "type": "string", "minLength": 1, "maxLength": 20 }), - ArrayItemType::String(Some(1), Some(20)), + DocumentPropertyType::String(StringPropertySizes { + min_length: Some(1), + max_length: Some(20), + }), ), ( platform_value!({ "type": "array", "byteArray": true, "minItems": 4, "maxItems": 8 }), - ArrayItemType::ByteArray(Some(4), Some(8)), + DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(4), + max_size: Some(8), + }), + ), + ( + platform_value!({ "type": "integer", "minimum": -5, "maximum": 5 }), + DocumentPropertyType::I8, + ), + ( + platform_value!({ "type": "integer", "minimum": 0, "maximum": 70000 }), + DocumentPropertyType::U32, ), ] { let document_type = parse_dispatched( @@ -213,7 +233,7 @@ fn should_parse_every_scalar_element_type() { else { panic!("{items:?} should parse to a typed array"); }; - assert_eq!(typed_array.item_type, expected, "{items:?}"); + assert_eq!(*typed_array.item_type, expected, "{items:?}"); } } @@ -695,7 +715,7 @@ fn should_round_trip_a_contract_with_typed_arrays_through_platform_serialization assert_eq!( reasons, Some(DocumentPropertyType::TypedArray(TypedArrayProperty { - item_type: ArrayItemType::Identifier, + item_type: Box::new(DocumentPropertyType::Identifier), min_items: Some(0), max_items: 64, unique_items: true, 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 99c0c95ee2d..0511acf0953 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 @@ -27,7 +27,7 @@ use crate::consensus::state::data_contract::document_type_update_error::Document use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; -use crate::data_contract::document_type::DocumentTypeRef; +use crate::data_contract::document_type::{DocumentPropertyType, DocumentTypeRef}; use crate::validation::SimpleConsensusValidationResult; use crate::ProtocolError; use platform_version::version::PlatformVersion; @@ -80,6 +80,13 @@ impl DocumentTypeRef<'_> { return Ok(result); } + // Validate that no typed array changes how its elements are encoded + let result = self.validate_typed_array_element_encoding_stability(new_document_type); + + if !result.is_valid() { + 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) @@ -109,6 +116,67 @@ impl DocumentTypeRef<'_> { self.validate_schema_with_options(new_document_type, platform_version, &options) } + /// A typed array stores each element exactly as a required scalar property + /// of its element type is stored, so an update that changes how an + /// element encodes would misread every element already stored: an + /// integer element whose width changes (its bounds or `enum` choose it), + /// or a byte array element that turns from fixed-size (raw) to variable + /// (length-prefixed) or to another fixed size. The schema compatibility + /// rules allow the changes that do this (raising `maximum`, widening + /// `maxItems`), so the element encoding is held here, as + /// `validate_byte_array_encoding_stability` holds a byte array + /// property's. Every other element change, a longer `maxLength` included, + /// leaves the encoding as it is. + fn validate_typed_array_element_encoding_stability( + &self, + new_document_type: DocumentTypeRef, + ) -> SimpleConsensusValidationResult { + /// How an element of this type is laid out, in the words the error uses + fn element_encoding(element_type: &DocumentPropertyType) -> String { + match element_type { + DocumentPropertyType::ByteArray(sizes) => match (sizes.min_size, sizes.max_size) { + (Some(min), Some(max)) if min == max => format!("a fixed {min}-byte array"), + _ => "a length-prefixed byte array".to_string(), + }, + other => other.name(), + } + } + + let new_properties = new_document_type.flattened_properties(); + + for (path, old_property) in self.flattened_properties() { + let DocumentPropertyType::TypedArray(old_array) = &old_property.property_type else { + continue; + }; + let Some(new_property) = new_properties.get(path) else { + continue; + }; + let DocumentPropertyType::TypedArray(new_array) = &new_property.property_type else { + continue; + }; + + let old_encoding = element_encoding(&old_array.item_type); + let new_encoding = element_encoding(&new_array.item_type); + if old_encoding != new_encoding { + return SimpleConsensusValidationResult::new_with_error( + DocumentTypeUpdateError::new( + self.data_contract_id(), + self.name(), + format!( + "document type can not change the element encoding of typed array \ + property '{}': its elements are stored as {} and would be read as \ + {}", + path, old_encoding, new_encoding, + ), + ) + .into(), + ); + } + } + + SimpleConsensusValidationResult::new() + } + /// The action fees of a document type are fixed when it is published: an /// update may not add, change or remove them, nor switch their pricing. /// A document type added by the update is not judged here and may declare @@ -1473,4 +1541,162 @@ mod tests { ); } } + + // ================================================================ + // Typed array element encoding + // ================================================================ + + mod typed_array_element_encoding { + use super::*; + + /// A document type whose one property is a typed array with the + /// given `items` and `maxItems`. + fn doc_type_with_list( + items: Value, + max_items: u16, + platform_version: &PlatformVersion, + ) -> DocumentType { + let schema = platform_value!({ + "type": "object", + "properties": { + "list": { + "type": "array", + "maxItems": max_items, + "items": items, + "position": 0 + }, + }, + "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, + true, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + /// The schema compatibility rules allow each of these changes, but + /// each changes how an element is written, so the elements already + /// stored would be misread. + #[test] + fn should_reject_an_update_that_changes_how_typed_array_elements_are_encoded() { + let platform_version = PlatformVersion::latest(); + + for (old_items, new_items, old_encoding, new_encoding) in [ + // Raising the maximum across a width boundary widens the + // element from one byte to two + ( + platform_value!({ "type": "integer", "minimum": 0, "maximum": 100 }), + platform_value!({ "type": "integer", "minimum": 0, "maximum": 1000 }), + "u8", + "u16", + ), + // So does adding an enum value past what a byte holds + ( + platform_value!({ "type": "integer", "enum": [1, 2, 3] }), + platform_value!({ "type": "integer", "enum": [1, 2, 3, 300] }), + "u8", + "u16", + ), + // A byte array element whose size stops being pinned gains a + // length prefix + ( + platform_value!({ + "type": "array", "byteArray": true, "minItems": 20, "maxItems": 20 + }), + platform_value!({ + "type": "array", "byteArray": true, "minItems": 20, "maxItems": 32 + }), + "a fixed 20-byte array", + "a length-prefixed byte array", + ), + ] { + let old = doc_type_with_list(old_items.clone(), 8, platform_version); + let new = doc_type_with_list(new_items.clone(), 8, platform_version); + + let result = old + .as_ref() + .validate_update(new.as_ref(), 2, platform_version) + .expect("validate_update should not error"); + + let expected = format!( + "document type can not change the element encoding of typed array property \ + 'list': its elements are stored as {old_encoding} and would be read as \ + {new_encoding}" + ); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError(StateError::DocumentTypeUpdateError(e))] + if e.additional_message() == expected, + "{old_items:?} -> {new_items:?}: {:?}", + result.errors + ); + } + } + + /// Longer strings, more elements, and a raised maximum that stays + /// within the element's width leave every stored element readable. + #[test] + fn should_accept_an_update_that_keeps_how_typed_array_elements_are_encoded() { + let platform_version = PlatformVersion::latest(); + + for (old_items, old_max_items, new_items, new_max_items) in [ + ( + platform_value!({ "type": "string", "maxLength": 20 }), + 8, + platform_value!({ "type": "string", "maxLength": 40 }), + 8, + ), + ( + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }), + 8, + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }), + 64, + ), + ( + platform_value!({ "type": "integer", "minimum": 0, "maximum": 100 }), + 8, + platform_value!({ "type": "integer", "minimum": 0, "maximum": 200 }), + 8, + ), + ] { + let old = doc_type_with_list(old_items.clone(), old_max_items, platform_version); + let new = doc_type_with_list(new_items.clone(), new_max_items, 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(), + "{old_items:?} ({old_max_items}) -> {new_items:?} ({new_max_items}): {:?}", + result.errors + ); + } + } + } } diff --git a/packages/rs-dpp/src/data_contract/document_type/property/array.rs b/packages/rs-dpp/src/data_contract/document_type/property/array.rs index 7889eb085a4..a1a5619a362 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/array.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/array.rs @@ -1,22 +1,15 @@ -use crate::data_contract::document_type::property::{ - ByteArrayPropertySizes, DocumentPropertyType, StringPropertySizes, -}; -use crate::data_contract::document_type::property_names; +use crate::data_contract::document_type::property::DocumentPropertyType; use crate::data_contract::errors::DataContractError; use crate::ProtocolError; use byteorder::{BigEndian, ReadBytesExt}; -use integer_encoding::VarInt; -use platform_value::btreemap_extensions::BTreeValueMapHelper; +use integer_encoding::{VarInt, VarIntReader}; use platform_value::Value; +use platform_version::version::PlatformVersion; use rand::rngs::StdRng; use rand::Rng; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; use std::io::BufReader; -/// The media type that makes a byte array an identifier. -const IDENTIFIER_CONTENT_MEDIA_TYPE: &str = "application/x.dash.dpp.identifier"; - #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] #[serde(into = "ArrayItemTypeRepr", from = "ArrayItemTypeRepr")] pub enum ArrayItemType { @@ -34,13 +27,18 @@ pub enum ArrayItemType { /// (`parse_typed_array` 0). /// /// It is stored inline in the document like any other property: a varint -/// element count followed by each element in its [`ArrayItemType`] encoding -/// (the encoding [`DocumentPropertyType::Array`] always had). Nothing is +/// element count followed by the elements, each encoded exactly as a required +/// scalar property of `item_type` is. So an identifier element is 32 raw +/// bytes, a byte array element whose bounds pin one size is raw, an integer +/// element takes the width its schema's bounds give it, and a string or a +/// variable-size byte array element carries a varint length. Nothing is /// indexed per element, so a typed array cannot be an index property. -#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +#[derive(Debug, PartialEq, Clone, Serialize)] pub struct TypedArrayProperty { - /// The type of every element, parsed from `items`. - pub item_type: ArrayItemType, + /// The scalar type of every element, parsed from `items` exactly as a + /// scalar property schema is: an integer, a number, a string, a boolean, + /// a byte array or an identifier, never an object or an array. + pub item_type: Box, /// `minItems`: the fewest elements a document may hold, never above /// `max_items`. pub min_items: Option, @@ -53,27 +51,143 @@ pub struct TypedArrayProperty { } impl TypedArrayProperty { + /// Whether an element's encoding is a varint length followed by the bytes: + /// a string, and a byte array whose bounds do not pin one size. Every + /// other scalar has a fixed width. + fn element_is_length_prefixed(&self) -> bool { + match self.item_type.as_ref() { + DocumentPropertyType::String(_) => true, + DocumentPropertyType::ByteArray(sizes) => { + !(sizes.min_size.is_some() && sizes.min_size == sizes.max_size) + } + _ => false, + } + } + + /// The width of an element written raw from bytes: 32 for an identifier, + /// the size of a byte array whose bounds pin one. The scalar encoder + /// writes those bytes as given, so the list checks their length: a short + /// element would shift every element after it. + fn element_raw_width(&self) -> Option { + match self.item_type.as_ref() { + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { + Some(32) + } + DocumentPropertyType::ByteArray(sizes) + if sizes.min_size.is_some() && sizes.min_size == sizes.max_size => + { + sizes.min_size.map(usize::from) + } + _ => None, + } + } + + /// An element's encoded size for its scalar byte bound: the bound, plus + /// the varint length in front of a length-prefixed element. + fn element_encoded_size(&self, element_bytes: u16) -> u64 { + let prefix = if self.element_is_length_prefixed() { + usize::from(element_bytes).required_space() as u64 + } else { + 0 + }; + u64::from(element_bytes).saturating_add(prefix) + } + /// The fewest bytes the array encodes to: the varint count of `minItems` - /// elements and that many of the smallest element, saturating at - /// `u16::MAX`. - pub fn min_encoded_size(&self) -> u16 { + /// elements and that many of the smallest element, sized as its scalar + /// type is sized, saturating at `u16::MAX`. + pub fn min_encoded_size( + &self, + platform_version: &PlatformVersion, + ) -> Result { let min_items = self.min_items.unwrap_or(0); - let size = (min_items.required_space() as u64) - .saturating_add(u64::from(min_items).saturating_mul(self.item_type.min_encoded_size())); - u16::try_from(size).unwrap_or(u16::MAX) + let element_bytes = self.item_type.min_byte_size(platform_version)?.unwrap_or(0); + let size = (min_items.required_space() as u64).saturating_add( + u64::from(min_items).saturating_mul(self.element_encoded_size(element_bytes)), + ); + Ok(u16::try_from(size).unwrap_or(u16::MAX)) } /// The most bytes the array encodes to: the varint count of `maxItems` - /// elements and that many of the largest element, saturating at - /// `u16::MAX`, the size an unbounded string or byte array reports. Also - /// `u16::MAX` when the element is unbounded. - pub fn max_encoded_size(&self) -> u16 { - let Some(item_max) = self.item_type.max_encoded_size() else { - return u16::MAX; + /// elements and that many of the largest element, sized as its scalar + /// type is sized, saturating at `u16::MAX`, the size an unbounded string + /// or byte array reports. Also `u16::MAX` when the element is unbounded. + pub fn max_encoded_size( + &self, + platform_version: &PlatformVersion, + ) -> Result { + let element_bytes = match self.item_type.max_byte_size(platform_version)? { + Some(element_bytes) if element_bytes < u16::MAX => element_bytes, + _ => return Ok(u16::MAX), + }; + let size = (self.max_items.required_space() as u64).saturating_add( + u64::from(self.max_items).saturating_mul(self.element_encoded_size(element_bytes)), + ); + Ok(u16::try_from(size).unwrap_or(u16::MAX)) + } + + /// Encodes a list: the varint element count, then each element exactly as + /// a required scalar property of `item_type` is encoded. + pub(super) fn encode_value_ref(&self, value: &Value) -> Result, ProtocolError> { + let Value::Array(elements) = value else { + return Err(DataContractError::ValueWrongType(format!( + "a typed array value must be a list, got {value}" + )) + .into()); }; - let size = (self.max_items.required_space() as u64) - .saturating_add(u64::from(self.max_items).saturating_mul(item_max)); - u16::try_from(size).unwrap_or(u16::MAX) + let mut bytes = elements.len().encode_var_vec(); + for element in elements { + // A null encodes to no bytes at all, which would drop the element + if element.is_null() { + return Err(DataContractError::ValueWrongType( + "a typed array element can not be null".to_string(), + ) + .into()); + } + let element_bytes = self.item_type.encode_value_ref_with_size(element, true)?; + if let Some(width) = self.element_raw_width() { + if element_bytes.len() != width { + return Err(DataContractError::ValueWrongType(format!( + "a typed array element must be {width} bytes, got {}", + element_bytes.len() + )) + .into()); + } + } + bytes.extend(element_bytes); + } + Ok(bytes) + } + + /// Reads a list, the mirror of [`Self::encode_value_ref`]: a varint + /// element count, then each element as a required scalar property of + /// `item_type` is read. The count comes from the serialized document, so + /// a count above `maxItems` is refused before anything is read: that + /// bounds the loop even for elements of zero width (a byte array pinned + /// to zero bytes). + pub(super) fn read_from(&self, buf: &mut BufReader<&[u8]>) -> Result { + let count: usize = buf.read_varint().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading varint of typed array element count".to_string(), + ) + })?; + if count > usize::from(self.max_items) { + return Err(DataContractError::CorruptedSerialization(format!( + "a serialized typed array claims {count} elements, more than its maxItems of {}", + self.max_items + ))); + } + let mut elements = Vec::new(); + for _ in 0..count { + let (element, _) = self.item_type.read_optionally_from(buf, true)?; + let Some(element) = element else { + return Err(DataContractError::CorruptedSerialization( + "a typed array element read back as absent".to_string(), + )); + }; + elements.push(element); + } + Ok(Value::Array(elements)) } /// How many elements a random value holds: between `minItems` and @@ -110,23 +224,27 @@ impl TypedArrayProperty { }) } - /// `count` elements from `random_element`. Under `uniqueItems` a repeat is - /// drawn again, a bounded number of times, so an element type with fewer - /// distinct values than `count` (a boolean) yields fewer elements rather - /// than looping forever. + /// `count` elements from `random_element`, each in the value kind it reads + /// back as. Under `uniqueItems` a repeat is drawn again, a bounded number + /// of times, so an element type with fewer distinct values than `count` + /// (a boolean) yields fewer elements rather than looping forever. fn random_items( &self, count: usize, rng: &mut StdRng, random_element: impl Fn(&DocumentPropertyType, &mut StdRng) -> Value, ) -> Value { - let element_type = self.item_type.scalar_property_type(); + let fixed_size_bytes = matches!( + self.item_type.as_ref(), + DocumentPropertyType::ByteArray(sizes) + if sizes.min_size.is_some() && sizes.min_size == sizes.max_size + ); let mut items: Vec = Vec::with_capacity(count); let mut draws_left = count.saturating_mul(8).saturating_add(16); while items.len() < count && draws_left > 0 { draws_left -= 1; - let item = match random_element(&element_type, rng) { - Value::Bytes(bytes) => self.item_type.byte_array_value(bytes), + let item = match random_element(&self.item_type, rng) { + Value::Bytes(bytes) if fixed_size_bytes => fixed_size_bytes_value(bytes), item => item, }; if self.unique_items && items.contains(&item) { @@ -138,12 +256,30 @@ impl TypedArrayProperty { } } +/// The value a fixed-size byte array reads back as: 20, 32 or 36 bytes as +/// `Bytes20`, `Bytes32` or `Bytes36`, any other size as `Bytes`. +fn fixed_size_bytes_value(bytes: Vec) -> Value { + let bytes = match <[u8; 20]>::try_from(bytes) { + Ok(bytes) => return Value::Bytes20(bytes), + Err(bytes) => bytes, + }; + let bytes = match <[u8; 32]>::try_from(bytes) { + Ok(bytes) => return Value::Bytes32(bytes), + Err(bytes) => bytes, + }; + match <[u8; 36]>::try_from(bytes) { + Ok(bytes) => Value::Bytes36(bytes), + Err(bytes) => Value::Bytes(bytes), + } +} + // Internal-`$type` serde shape. Mixed unit + 2-tuple variants, so a // struct-variant Repr (serde can't auto-internal-tag tuple variants). Unit // variants -> `{"$type":"integer"}`; the tuple variants get named size bounds // (`#[serde(default)]` so an omitted bound deserializes as `None`). Serde-only -// type (no bincode); its on-wire form is exercised solely by these tests — -// document-schema parsing goes through `TryFrom<&Value>`, not serde. +// type (no bincode); its on-wire form is exercised solely by these tests. No +// document schema parses into an `ArrayItemType`: a typed array's elements are +// `DocumentPropertyType`s. #[derive(Serialize, Deserialize)] #[serde(tag = "$type", rename_all = "camelCase")] enum ArrayItemTypeRepr { @@ -439,86 +575,10 @@ impl ArrayItemType { } } - /// Parses the `items` schema of a typed array: one scalar element schema, - /// read the way a scalar property schema is read. An element is an - /// integer, a number, a string (with `minLength` / `maxLength`), a boolean, - /// a byte array (`byteArray: true`, with `minItems` / `maxItems` counting - /// bytes) or an identifier. Objects and arrays of arrays are refused. - /// - /// No document-schema type parses to [`ArrayItemType::Date`], so no - /// element does either, exactly as no scalar property parses to - /// `DocumentPropertyType::Date`. - /// - /// `refersTo` is refused for now. A reference on identifier elements would - /// be read from this same map and folded into the element type, as - /// `apply_property_reference` folds one into a scalar identifier. - pub fn try_from_value_map( - value_map: &BTreeMap, - ) -> Result { - if value_map.contains_key(property_names::REF) { - return Err(DataContractError::InvalidContractStructure( - "the items of a typed array must be an inline element schema, not a $ref" - .to_string(), - )); - } - if value_map.contains_key(property_names::REFERS_TO) { - return Err(DataContractError::InvalidContractStructure( - "refersTo is not supported on the elements of a typed array".to_string(), - )); - } - - let type_value = value_map.get_str(property_names::TYPE)?; - - match type_value { - "integer" => Ok(ArrayItemType::Integer), - "number" => Ok(ArrayItemType::Number), - "boolean" => Ok(ArrayItemType::Boolean), - // Bounds read as u16, the width a scalar string's bounds have - "string" => Ok(ArrayItemType::String( - value_map - .get_optional_integer::(property_names::MIN_LENGTH)? - .map(usize::from), - value_map - .get_optional_integer::(property_names::MAX_LENGTH)? - .map(usize::from), - )), - "array" => match value_map.get_optional_bool(property_names::BYTE_ARRAY)? { - Some(true) => { - match value_map.get_optional_str(property_names::CONTENT_MEDIA_TYPE)? { - Some(IDENTIFIER_CONTENT_MEDIA_TYPE) => Ok(ArrayItemType::Identifier), - Some(_) | None => Ok(ArrayItemType::ByteArray( - value_map - .get_optional_integer::(property_names::MIN_ITEMS)? - .map(usize::from), - value_map - .get_optional_integer::(property_names::MAX_ITEMS)? - .map(usize::from), - )), - } - } - Some(false) => Err(DataContractError::InvalidContractStructure( - "byteArray should always be true if defined".to_string(), - )), - None => Err(DataContractError::InvalidContractStructure( - "arrays of arrays are not supported: an element of a typed array may be a \ - byte array (byteArray: true) or an identifier, but not another array" - .to_string(), - )), - }, - "object" => Err(DataContractError::InvalidContractStructure( - "arrays of objects are not supported: the elements of a typed array must be \ - scalars (integer, number, string, boolean, byte array or identifier)" - .to_string(), - )), - other => Err(DataContractError::InvalidContractStructure(format!( - "unsupported typed array element type: {other}" - ))), - } - } - - /// Reads one element, mirroring [`Self::encode_value_ref_with_size`]. - /// Every element takes at least one byte, so a reader looping over a - /// claimed element count stops when the serialized document runs out. + /// Reads one element of the never-produced [`DocumentPropertyType::Array`], + /// mirroring [`Self::encode_value_ref_with_size`]. Every element takes at + /// least one byte, so a reader looping over a claimed element count stops + /// when the serialized document runs out. pub(super) fn read_from(&self, buf: &mut BufReader<&[u8]>) -> Result { match self { ArrayItemType::String(_, _) => { @@ -570,101 +630,15 @@ impl ArrayItemType { /// of 20, 32 or 36 bytes as `Bytes20`, `Bytes32` or `Bytes36`, the kinds a /// fixed-size scalar byte array reads back as, and `Bytes` otherwise. fn byte_array_value(&self, bytes: Vec) -> Value { - let ArrayItemType::ByteArray(min_size, max_size) = self else { - return Value::Bytes(bytes); - }; - if min_size.is_none() || min_size != max_size { - return Value::Bytes(bytes); - } - let bytes = match <[u8; 20]>::try_from(bytes) { - Ok(bytes) => return Value::Bytes20(bytes), - Err(bytes) => bytes, - }; - let bytes = match <[u8; 32]>::try_from(bytes) { - Ok(bytes) => return Value::Bytes32(bytes), - Err(bytes) => bytes, - }; - match <[u8; 36]>::try_from(bytes) { - Ok(bytes) => Value::Bytes36(bytes), - Err(bytes) => Value::Bytes(bytes), - } - } - - /// The fewest bytes one element encodes to, its own length prefix - /// included. A string's `minLength` counts characters, each at least one - /// byte. - pub fn min_encoded_size(&self) -> u64 { - match self { - ArrayItemType::Integer | ArrayItemType::Number | ArrayItemType::Date => 8, - ArrayItemType::Boolean => 1, - ArrayItemType::String(min_length, _) => length_prefixed_size(min_length.unwrap_or(0)), - ArrayItemType::ByteArray(min_size, _) => length_prefixed_size(min_size.unwrap_or(0)), - ArrayItemType::Identifier => length_prefixed_size(32), - } - } - - /// The most bytes one element encodes to, its own length prefix - /// included, or `None` when the element is unbounded. A string's - /// `maxLength` counts characters, each at most four bytes. - pub fn max_encoded_size(&self) -> Option { match self { - ArrayItemType::Integer | ArrayItemType::Number | ArrayItemType::Date => Some(8), - ArrayItemType::Boolean => Some(1), - ArrayItemType::String(_, max_length) => { - max_length.map(|max_length| length_prefixed_size(max_length.saturating_mul(4))) + ArrayItemType::ByteArray(min_size, max_size) + if min_size.is_some() && min_size == max_size => + { + fixed_size_bytes_value(bytes) } - ArrayItemType::ByteArray(_, max_size) => max_size.map(length_prefixed_size), - ArrayItemType::Identifier => Some(length_prefixed_size(32)), + _ => Value::Bytes(bytes), } } - - /// The scalar property type an element has in its own right, which - /// generates its random values. - pub(super) fn scalar_property_type(&self) -> DocumentPropertyType { - // Parsed bounds come from u16 schema values; saturate the rest - fn bound(size: &Option) -> Option { - size.map(|size| u16::try_from(size).unwrap_or(u16::MAX)) - } - match self { - ArrayItemType::Integer => DocumentPropertyType::I64, - ArrayItemType::Number => DocumentPropertyType::F64, - ArrayItemType::String(min_length, max_length) => { - DocumentPropertyType::String(StringPropertySizes { - min_length: bound(min_length), - max_length: bound(max_length), - }) - } - ArrayItemType::ByteArray(min_size, max_size) => { - DocumentPropertyType::ByteArray(ByteArrayPropertySizes { - min_size: bound(min_size), - max_size: bound(max_size), - }) - } - ArrayItemType::Identifier => DocumentPropertyType::Identifier, - ArrayItemType::Boolean => DocumentPropertyType::Boolean, - ArrayItemType::Date => DocumentPropertyType::Date, - } - } -} - -impl TryFrom<&Value> for ArrayItemType { - type Error = DataContractError; - - /// Parses a typed array's `items` value, which must be one element - /// schema: the tuple form (`items: [..]`) and boolean schemas are refused. - fn try_from(items: &Value) -> Result { - let value_map = items.to_btree_ref_string_map().map_err(|_| { - DataContractError::InvalidContractStructure( - "the items of a typed array must be one element schema (an object)".to_string(), - ) - })?; - Self::try_from_value_map(&value_map) - } -} - -/// The encoded size of a `len`-byte value behind its varint length prefix. -fn length_prefixed_size(len: usize) -> u64 { - (len.required_space() as u64).saturating_add(len as u64) } fn get_field_type_matching_error() -> ProtocolError { 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 b38ab45428c..63a11b62669 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 @@ -859,7 +859,7 @@ impl DocumentPropertyType { DocumentPropertyType::Array(_) => Ok(None), DocumentPropertyType::VariableTypeArray(_) => Ok(None), DocumentPropertyType::TypedArray(typed_array) => { - Ok(Some(typed_array.min_encoded_size())) + typed_array.min_encoded_size(platform_version).map(Some) } DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Ok(Some(32)) @@ -909,7 +909,7 @@ impl DocumentPropertyType { DocumentPropertyType::Array(_) => Ok(None), DocumentPropertyType::VariableTypeArray(_) => Ok(None), DocumentPropertyType::TypedArray(typed_array) => { - Ok(Some(typed_array.max_encoded_size())) + typed_array.max_encoded_size(platform_version).map(Some) } DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Ok(Some(32)) @@ -1478,8 +1478,10 @@ impl DocumentPropertyType { Ok((Some(Value::Map(values)), false)) } } - DocumentPropertyType::Array(item_type) - | DocumentPropertyType::TypedArray(TypedArrayProperty { item_type, .. }) => { + DocumentPropertyType::TypedArray(typed_array) => { + Ok((Some(typed_array.read_from(buf)?), false)) + } + DocumentPropertyType::Array(item_type) => { // Mirrors the encoding: a varint element count, then the // elements. The count comes from the serialized document, so // it never sizes an allocation; every element takes at least @@ -1701,11 +1703,8 @@ impl DocumentPropertyType { Err(get_field_type_matching_error(&value).into()) } } - DocumentPropertyType::Array(array_field_type) - | DocumentPropertyType::TypedArray(TypedArrayProperty { - item_type: array_field_type, - .. - }) => { + DocumentPropertyType::TypedArray(typed_array) => typed_array.encode_value_ref(&value), + DocumentPropertyType::Array(array_field_type) => { if let Value::Array(array) = value { let mut r_vec = array.len().encode_var_vec(); @@ -1860,11 +1859,8 @@ impl DocumentPropertyType { len_prepended_vec.append(&mut r_vec); Ok(len_prepended_vec) } - DocumentPropertyType::Array(array_field_type) - | DocumentPropertyType::TypedArray(TypedArrayProperty { - item_type: array_field_type, - .. - }) => { + DocumentPropertyType::TypedArray(typed_array) => typed_array.encode_value_ref(value), + DocumentPropertyType::Array(array_field_type) => { if let Value::Array(array) = value { let mut r_vec = array.len().encode_var_vec(); @@ -3093,11 +3089,7 @@ impl DocumentPropertyType { } // Handle Array type - sanitize all elements - (DocumentPropertyType::Array(item_type), Value::Array(_)) - | ( - DocumentPropertyType::TypedArray(TypedArrayProperty { item_type, .. }), - Value::Array(_), - ) => { + (DocumentPropertyType::Array(item_type), Value::Array(_)) => { if let Value::Array(items) = value { for item in items.iter_mut() { item_type.sanitize_value_mut(item); @@ -3105,6 +3097,15 @@ impl DocumentPropertyType { } } + // A typed array's elements sanitize as scalars of its element type + (DocumentPropertyType::TypedArray(typed_array), Value::Array(_)) => { + if let Value::Array(items) = value { + for item in items.iter_mut() { + typed_array.item_type.sanitize_value_mut(item); + } + } + } + // Handle VariableTypeArray - each item can have a different type (DocumentPropertyType::VariableTypeArray(item_types), Value::Array(_)) => { if let Value::Array(items) = value { @@ -5160,123 +5161,263 @@ mod tests { assert_eq!(value, Some(Value::Bytes(vec![10, 20, 30]))); } - fn typed_array(item_type: ArrayItemType) -> DocumentPropertyType { + fn typed_array(item_type: DocumentPropertyType) -> DocumentPropertyType { DocumentPropertyType::TypedArray(TypedArrayProperty { - item_type, + item_type: Box::new(item_type), min_items: None, max_items: 8, unique_items: false, }) } - #[test] - fn should_round_trip_every_array_element_type_through_encode_and_read_optionally_from() { + fn encode_and_read_back(property_type: &DocumentPropertyType, value: &Value) -> Vec { use std::io::BufReader; + // The document serializer writes the presence flag of a property that + // is not required itself + let encoded = property_type + .encode_value_ref_with_size(value, true) + .expect("encodes"); + let mut reader = BufReader::new(encoded.as_slice()); + let (decoded, finished) = property_type + .read_optionally_from(&mut reader, true) + .expect("decodes"); + assert_eq!(decoded.as_ref(), Some(value), "{property_type:?}"); + assert!(!finished); + assert!(reader.buffer().is_empty(), "{property_type:?} left bytes"); + + let mut with_marker = vec![1]; + with_marker.extend(&encoded); + let mut reader = BufReader::new(with_marker.as_slice()); + let (decoded, _) = property_type + .read_optionally_from(&mut reader, false) + .expect("decodes behind a presence flag"); + assert_eq!( + decoded.as_ref(), + Some(value), + "{property_type:?} behind a presence flag" + ); + encoded + } + + #[test] + fn should_round_trip_every_typed_array_element_type_through_encode_and_read_optionally_from() { for (item_type, items) in [ ( - ArrayItemType::Integer, + DocumentPropertyType::I64, vec![Value::I64(i64::MIN), Value::I64(-1), Value::I64(i64::MAX)], ), ( - ArrayItemType::Number, + DocumentPropertyType::U8, + vec![Value::U8(0), Value::U8(u8::MAX)], + ), + ( + DocumentPropertyType::I16, + vec![Value::I16(i16::MIN), Value::I16(1000)], + ), + (DocumentPropertyType::U32, vec![Value::U32(u32::MAX)]), + (DocumentPropertyType::U128, vec![Value::U128(u128::MAX)]), + ( + DocumentPropertyType::F64, vec![Value::Float(-0.5), Value::Float(1e300)], ), ( - ArrayItemType::String(None, Some(20)), + DocumentPropertyType::String(StringPropertySizes { + min_length: None, + max_length: Some(20), + }), vec![Value::Text("".to_string()), Value::Text("über".to_string())], ), ( - ArrayItemType::ByteArray(Some(1), Some(40)), + DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(1), + max_size: Some(40), + }), vec![Value::Bytes(vec![0xFF]), Value::Bytes(vec![7; 40])], ), // Fixed-size elements read back as the fixed-size value kinds ( - ArrayItemType::ByteArray(Some(32), Some(32)), + DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(32), + max_size: Some(32), + }), vec![Value::Bytes32([0x80; 32])], ), ( - ArrayItemType::Identifier, + DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(3), + max_size: Some(3), + }), + vec![Value::Bytes(vec![1, 2, 3]), Value::Bytes(vec![4, 5, 6])], + ), + ( + DocumentPropertyType::Identifier, vec![Value::Identifier([1; 32]), Value::Identifier([2; 32])], ), ( - ArrayItemType::Boolean, + DocumentPropertyType::Boolean, vec![Value::Bool(true), Value::Bool(false)], ), ] { - for property_type in [ - typed_array(item_type.clone()), - DocumentPropertyType::Array(item_type.clone()), - ] { - for items in [items.clone(), vec![]] { - let value = Value::Array(items); - // The document serializer writes the presence flag of a - // property that is not required itself - let encoded = property_type - .encode_value_ref_with_size(&value, true) - .expect("encodes"); - let mut reader = BufReader::new(encoded.as_slice()); - let (decoded, finished) = property_type - .read_optionally_from(&mut reader, true) - .expect("decodes"); - assert_eq!(decoded, Some(value.clone()), "{item_type:?}"); - assert!(!finished); - - let mut with_marker = vec![1]; - with_marker.extend(&encoded); - let mut reader = BufReader::new(with_marker.as_slice()); - let (decoded, _) = property_type - .read_optionally_from(&mut reader, false) - .expect("decodes behind a presence flag"); - assert_eq!(decoded, Some(value), "{item_type:?} behind a presence flag"); - } + let property_type = typed_array(item_type); + for items in [items.clone(), vec![]] { + encode_and_read_back(&property_type, &Value::Array(items)); } } } + /// Each element is written exactly as a required scalar property of its + /// type is written: after the varint count, an identifier is its 32 raw + /// bytes, an integer takes its width, a fixed-size byte array is raw, and + /// only strings and variable-size byte arrays carry a length. #[test] - fn should_refuse_an_array_whose_elements_run_past_the_serialized_document() { + fn should_encode_each_typed_array_element_as_a_required_scalar_property_of_its_type() { + let identifiers = encode_and_read_back( + &typed_array(DocumentPropertyType::Identifier), + &Value::Array(vec![ + Value::Identifier([0xAA; 32]), + Value::Identifier([0xBB; 32]), + ]), + ); + let mut expected = vec![2]; + expected.extend([0xAA; 32]); + expected.extend([0xBB; 32]); + assert_eq!(identifiers, expected); + + let small_integers = encode_and_read_back( + &typed_array(DocumentPropertyType::U8), + &Value::Array(vec![Value::U8(7), Value::U8(200)]), + ); + assert_eq!(small_integers, vec![2, 7, 200]); + + let wide_integers = encode_and_read_back( + &typed_array(DocumentPropertyType::I64), + &Value::Array(vec![Value::I64(1)]), + ); + assert_eq!(wide_integers, vec![1, 0, 0, 0, 0, 0, 0, 0, 1]); + + let hashes = encode_and_read_back( + &typed_array(DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(3), + max_size: Some(3), + })), + &Value::Array(vec![Value::Bytes(vec![1, 2, 3])]), + ); + assert_eq!(hashes, vec![1, 1, 2, 3]); + + let blobs = encode_and_read_back( + &typed_array(DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: None, + max_size: Some(3), + })), + &Value::Array(vec![Value::Bytes(vec![1, 2])]), + ); + assert_eq!(blobs, vec![1, 2, 1, 2]); + + let strings = encode_and_read_back( + &typed_array(DocumentPropertyType::String(StringPropertySizes { + min_length: None, + max_length: Some(8), + })), + &Value::Array(vec![Value::Text("ab".to_string())]), + ); + assert_eq!(strings, vec![1, 2, b'a', b'b']); + + let flags = encode_and_read_back( + &typed_array(DocumentPropertyType::Boolean), + &Value::Array(vec![Value::Bool(true), Value::Bool(false)]), + ); + assert_eq!(flags, vec![2, 1, 0]); + } + + #[test] + fn should_refuse_to_encode_a_typed_array_value_that_is_not_a_list_of_its_elements() { + let identifiers = typed_array(DocumentPropertyType::Identifier); + for value in [ + Value::Identifier([1; 32]), + Value::Array(vec![Value::Null]), + Value::Array(vec![Value::Text("not an identifier".to_string())]), + Value::Array(vec![Value::Bytes(vec![1; 31])]), + ] { + assert!( + identifiers + .encode_value_ref_with_size(&value, true) + .is_err(), + "{value:?}" + ); + } + // An element out of a fixed size's bounds is refused, not written raw + let hashes = typed_array(DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(3), + max_size: Some(3), + })); + assert!(hashes + .encode_value_ref_with_size(&Value::Array(vec![Value::Bytes(vec![1, 2])]), true) + .is_err()); + } + + #[test] + fn should_refuse_a_typed_array_whose_elements_run_past_the_serialized_document() { use std::io::BufReader; // One element claimed, two of its eight bytes present let data: &[u8] = &[1, 2, 3]; let mut reader = BufReader::new(data); - let result = typed_array(ArrayItemType::Integer).read_optionally_from(&mut reader, true); + let result = typed_array(DocumentPropertyType::I64).read_optionally_from(&mut reader, true); assert!(matches!( result, Err(DataContractError::CorruptedSerialization(_)) )); - // A count no document could hold fails when the input runs out, - // without sizing anything by it - let mut data = u64::MAX.encode_var_vec(); - data.push(1); + // One identifier claimed, 31 of its 32 bytes present: refused as a + // scalar identifier cut short is + let mut data = vec![1]; + data.extend([5; 31]); let mut reader = BufReader::new(data.as_slice()); - let result = typed_array(ArrayItemType::Boolean).read_optionally_from(&mut reader, true); - assert!(matches!( - result, - Err(DataContractError::CorruptedSerialization(_)) - )); + assert!(typed_array(DocumentPropertyType::Identifier) + .read_optionally_from(&mut reader, true) + .is_err()); } + /// The count comes from the serialized document, so one above `maxItems` + /// is refused before any element is read. Without that, elements of zero + /// width (a byte array pinned to zero bytes) would let a few bytes claim + /// a list of any length. #[test] - fn should_refuse_malformed_identifier_and_boolean_array_elements() { + fn should_refuse_a_serialized_typed_array_counting_more_elements_than_its_max_items() { use std::io::BufReader; - // An identifier element carries its length, which must be 32 - let mut data = vec![1]; - data.extend(31usize.encode_var_vec()); - data.extend([5; 31]); - let mut reader = BufReader::new(data.as_slice()); - assert!(matches!( - typed_array(ArrayItemType::Identifier).read_optionally_from(&mut reader, true), - Err(DataContractError::CorruptedSerialization(_)) - )); + for item_type in [ + DocumentPropertyType::Boolean, + DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(0), + max_size: Some(0), + }), + ] { + let property_type = typed_array(item_type); + for count in [9u64, u64::MAX] { + let mut data = count.encode_var_vec(); + data.extend([1; 16]); + let mut reader = BufReader::new(data.as_slice()); + let error = property_type + .read_optionally_from(&mut reader, true) + .expect_err("more elements than maxItems"); + assert!( + matches!(error, DataContractError::CorruptedSerialization(ref message) + if message.contains("more than its maxItems of 8")), + "{error}" + ); + } + } - // A boolean element is written as 0 or 1 - let data: &[u8] = &[1, 2]; + // maxItems zero-width elements read back as that many empty byte arrays + let empties = typed_array(DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(0), + max_size: Some(0), + })); + let data: &[u8] = &[8]; let mut reader = BufReader::new(data); - assert!(matches!( - typed_array(ArrayItemType::Boolean).read_optionally_from(&mut reader, true), - Err(DataContractError::CorruptedSerialization(_)) - )); + let (value, _) = empties + .read_optionally_from(&mut reader, true) + .expect("maxItems elements decode"); + assert_eq!(value, Some(Value::Array(vec![Value::Bytes(vec![]); 8]))); } #[test] @@ -5284,20 +5425,53 @@ mod tests { let pv = PlatformVersion::latest(); let bounded = |item_type, min_items, max_items| { DocumentPropertyType::TypedArray(TypedArrayProperty { - item_type, + item_type: Box::new(item_type), min_items, max_items, unique_items: true, }) }; - // Identifiers carry a one-byte length prefix: 33 bytes each - let identifiers = bounded(ArrayItemType::Identifier, Some(2), 64); - assert_eq!(identifiers.min_byte_size(pv).unwrap(), Some(1 + 2 * 33)); - assert_eq!(identifiers.max_byte_size(pv).unwrap(), Some(1 + 64 * 33)); + // Identifiers are 32 raw bytes each + let identifiers = bounded(DocumentPropertyType::Identifier, Some(2), 64); + assert_eq!(identifiers.min_byte_size(pv).unwrap(), Some(1 + 2 * 32)); + assert_eq!(identifiers.max_byte_size(pv).unwrap(), Some(1 + 64 * 32)); + + // An integer element takes the width its bounds give it + let small_integers = bounded(DocumentPropertyType::U8, Some(1), 10); + assert_eq!(small_integers.min_byte_size(pv).unwrap(), Some(2)); + assert_eq!(small_integers.max_byte_size(pv).unwrap(), Some(11)); + + // A fixed-size byte array carries no length; a variable one does + let hashes = bounded( + DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: Some(20), + max_size: Some(20), + }), + None, + 4, + ); + assert_eq!(hashes.max_byte_size(pv).unwrap(), Some(1 + 4 * 20)); + let blobs = bounded( + DocumentPropertyType::ByteArray(ByteArrayPropertySizes { + min_size: None, + max_size: Some(200), + }), + None, + 4, + ); + assert_eq!(blobs.max_byte_size(pv).unwrap(), Some(1 + 4 * (2 + 200))); - // A string element's maxLength counts characters of up to four bytes - let strings = bounded(ArrayItemType::String(Some(3), Some(40)), None, 200); + // A string element is sized as a string property is: four bytes per + // character of its length bounds, plus its varint length + let strings = bounded( + DocumentPropertyType::String(StringPropertySizes { + min_length: Some(3), + max_length: Some(40), + }), + None, + 200, + ); assert_eq!(strings.min_byte_size(pv).unwrap(), Some(1)); assert_eq!( strings.max_byte_size(pv).unwrap(), @@ -5305,14 +5479,31 @@ mod tests { ); // Unbounded, or past what a u16 holds, reports u16::MAX - let unbounded_elements = bounded(ArrayItemType::String(None, None), None, 4); + let unbounded_elements = bounded( + DocumentPropertyType::String(StringPropertySizes { + min_length: None, + max_length: None, + }), + None, + 4, + ); assert_eq!( unbounded_elements.max_byte_size(pv).unwrap(), Some(u16::MAX) ); - let saturated = bounded(ArrayItemType::String(None, Some(5000)), Some(1024), 1024); + let saturated = bounded( + DocumentPropertyType::String(StringPropertySizes { + min_length: Some(1), + max_length: Some(5000), + }), + Some(1024), + 1024, + ); assert_eq!(saturated.max_byte_size(pv).unwrap(), Some(u16::MAX)); - assert_eq!(saturated.min_byte_size(pv).unwrap(), Some(2 + 1024)); + assert_eq!( + saturated.min_byte_size(pv).unwrap(), + Some(2 + 1024 * (1 + 4)) + ); } #[test] diff --git a/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/v1/mod.rs index 093cf842dbb..407606ae69c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/v1/mod.rs @@ -51,8 +51,7 @@ impl DocumentTypeV0 { binary_paths.extend(inner_binary_paths); } // `path[]` addresses every element of the list - DocumentPropertyType::Array(item_type) - | DocumentPropertyType::TypedArray(TypedArrayProperty { item_type, .. }) => { + DocumentPropertyType::Array(item_type) => { let new_path = format!("{}[]", new_path); match item_type { ArrayItemType::Identifier => { @@ -64,6 +63,19 @@ impl DocumentTypeV0 { _ => {} } } + DocumentPropertyType::TypedArray(TypedArrayProperty { item_type, .. }) => { + let new_path = format!("{}[]", new_path); + match item_type.as_ref() { + DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_) => { + identifier_paths.insert(new_path); + } + DocumentPropertyType::ByteArray(_) => { + binary_paths.insert(new_path); + } + _ => {} + } + } DocumentPropertyType::VariableTypeArray(item_types) => { for (i, array_field_type) in item_types.iter().enumerate() { let new_path = format!("{}[{}]", new_path, i); 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 89826cc211e..e36163d2979 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 @@ -380,21 +380,42 @@ impl DocumentTypeV0 { } schema } - let items_schema = match &typed_array.item_type { - ArrayItemType::String(min, max) => with_bounds(json!({"type": "string"}), [("minLength", *min), ("maxLength", *max)]), - ArrayItemType::Integer => json!({"type": "integer"}), - ArrayItemType::Number => json!({"type": "number"}), - ArrayItemType::ByteArray(min, max) => with_bounds(json!({"type": "array", "byteArray": true}), [("minItems", *min), ("maxItems", *max)]), - ArrayItemType::Identifier => json!({ + // The element schema that parses back to the element type + let integer = |minimum: Option, maximum: Option| { + let mut schema = json!({"type": "integer"}); + if let serde_json::Value::Object(ref mut map) = schema { + if let Some(minimum) = minimum { + map.insert("minimum".to_string(), json!(minimum as i64)); + } + if let Some(maximum) = maximum { + map.insert("maximum".to_string(), json!(maximum as i64)); + } + } + schema + }; + let bound = |size: Option| size.map(usize::from); + let items_schema = match typed_array.item_type.as_ref() { + DocumentPropertyType::U8 => integer(Some(0), Some(u8::MAX.into())), + DocumentPropertyType::I8 => integer(Some(i8::MIN.into()), Some(i8::MAX.into())), + DocumentPropertyType::U16 => integer(Some(0), Some(u16::MAX.into())), + DocumentPropertyType::I16 => integer(Some(i16::MIN.into()), Some(i16::MAX.into())), + DocumentPropertyType::U32 => integer(Some(0), Some(u32::MAX.into())), + DocumentPropertyType::I32 => integer(Some(i32::MIN.into()), Some(i32::MAX.into())), + DocumentPropertyType::U64 => integer(Some(0), None), + DocumentPropertyType::I64 | DocumentPropertyType::U128 | DocumentPropertyType::I128 => integer(None, None), + DocumentPropertyType::F64 | DocumentPropertyType::Date => json!({"type": "number"}), + DocumentPropertyType::String(sizes) => with_bounds(json!({"type": "string"}), [("minLength", bound(sizes.min_length)), ("maxLength", bound(sizes.max_length))]), + DocumentPropertyType::ByteArray(sizes) => with_bounds(json!({"type": "array", "byteArray": true}), [("minItems", bound(sizes.min_size)), ("maxItems", bound(sizes.max_size))]), + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => json!({ "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, "contentMediaType": "application/x.dash.dpp.identifier", }), - ArrayItemType::Boolean => json!({"type": "boolean"}), - // No element schema parses to a date - ArrayItemType::Date => json!({"type": "number"}), + DocumentPropertyType::Boolean => json!({"type": "boolean"}), + // The parser admits no other element type + _ => json!({}), }; with_bounds( diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 0f0e41a107d..ce2b15cbcee 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -566,9 +566,13 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// not above it) and at most `SYSTEM_LIMITS_V4.max_typed_array_items` /// (1024), and `uniqueItems` refuses a document repeating an element. /// The array is stored inline, a varint element count followed by the -/// elements, and cannot be an index property or one side of a -/// `propertyAgreement`. Its identifier and byte array elements are -/// conversion paths (`find_identifier_and_binary_paths` 1). A byte array +/// elements, each encoded exactly as a required scalar property of its +/// type: an identifier element is 32 raw bytes, an integer element takes +/// the width its bounds give it, a fixed-size byte array element is raw. +/// A contract update may not change how an element encodes +/// (`validate_update` 1). The array cannot be an index property or one +/// side of a `propertyAgreement`. Its identifier and byte array elements +/// are conversion paths (`find_identifier_and_binary_paths` 1). A byte array /// refuses `items`, and an identifier (a byte array with the identifier /// `contentMediaType`) now refuses `uniqueItems`, which would demand that /// no byte repeat. diff --git a/packages/wasm-dpp2/src/data_contract/document_type_typed_arrays.rs b/packages/wasm-dpp2/src/data_contract/document_type_typed_arrays.rs index 5d7dd50eb74..c30c61278f7 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_typed_arrays.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_typed_arrays.rs @@ -11,7 +11,7 @@ use crate::error::{WasmDppError, WasmDppResult}; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::data_contract::document_type::array::{ArrayItemType, TypedArrayProperty}; +use dpp::data_contract::document_type::array::TypedArrayProperty; use dpp::data_contract::document_type::{DocumentPropertyType, DocumentTypeRef}; use js_sys::{Array, Object, Reflect}; use wasm_bindgen::JsValue; @@ -97,37 +97,47 @@ fn set_bound>( } /// Build the flat, internally-tagged JS object for one element type. -fn item_to_js(item_type: &ArrayItemType, path: &str) -> WasmDppResult { +fn item_to_js(item_type: &DocumentPropertyType, path: &str) -> WasmDppResult { let object = Object::new(); let kind = match item_type { - ArrayItemType::Integer => "integer", - ArrayItemType::Number => "number", - ArrayItemType::Boolean => "boolean", - ArrayItemType::String(..) => "string", - ArrayItemType::ByteArray(..) => "byteArray", - ArrayItemType::Identifier => "identifier", + DocumentPropertyType::U8 + | DocumentPropertyType::I8 + | DocumentPropertyType::U16 + | DocumentPropertyType::I16 + | DocumentPropertyType::U32 + | DocumentPropertyType::I32 + | DocumentPropertyType::U64 + | DocumentPropertyType::I64 + | DocumentPropertyType::U128 + | DocumentPropertyType::I128 => "integer", // No items schema parses to a date; reported as the number it // decodes to rather than failing the whole collection - ArrayItemType::Date => "number", + DocumentPropertyType::F64 | DocumentPropertyType::Date => "number", + DocumentPropertyType::Boolean => "boolean", + DocumentPropertyType::String(_) => "string", + DocumentPropertyType::ByteArray(_) => "byteArray", + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { + "identifier" + } + other => { + return Err(WasmDppError::generic(format!( + "the typed array declared at '{path}' has a {} element, which is not a scalar", + other.name() + ))); + } }; set_field(&object, "type", &JsValue::from_str(kind), path)?; - // Parsed from u16 schema values, so exact as JS numbers - let as_f64 = |bound: &Option| bound.map(|bound| bound as f64); match item_type { - ArrayItemType::String(min_length, max_length) => { - set_bound(&object, "minLength", as_f64(min_length), path)?; - set_bound(&object, "maxLength", as_f64(max_length), path)?; + DocumentPropertyType::String(sizes) => { + set_bound(&object, "minLength", sizes.min_length, path)?; + set_bound(&object, "maxLength", sizes.max_length, path)?; } - ArrayItemType::ByteArray(min_size, max_size) => { - set_bound(&object, "minItems", as_f64(min_size), path)?; - set_bound(&object, "maxItems", as_f64(max_size), path)?; + DocumentPropertyType::ByteArray(sizes) => { + set_bound(&object, "minItems", sizes.min_size, path)?; + set_bound(&object, "maxItems", sizes.max_size, path)?; } - ArrayItemType::Integer - | ArrayItemType::Number - | ArrayItemType::Boolean - | ArrayItemType::Identifier - | ArrayItemType::Date => {} + _ => {} } Ok(object.into())