From 7e54306ddb2944fa389b72ca79d60f7f965c7695 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 02:18:53 +0700 Subject: [PATCH 1/5] feat(dpp)!: typed scalar arrays in document schemas (PV14) A document property may be `type: "array"` with an `items` element schema instead of `byteArray`: a list of integers, numbers, strings, booleans, byte arrays or identifiers. Objects and arrays of arrays stay refused. On the array minItems and maxItems count elements, maxItems is required and at most SystemLimits::max_document_array_items (1024), and uniqueItems refuses a repeated element when the document is validated. The array is stored inline, a varint element count followed by the elements, the encoding DocumentPropertyType::Array already had; read_optionally_from now mirrors it. DocumentPropertyType is append-only, so the bounded form is a new TypedArray variant rather than a changed Array payload. Meta-schema v3 gains `items` and the element schema, and a byte array now refuses `items` and `uniqueItems`. The parse is the new versioned `parse_typed_array` (None before protocol version 14, where an array that is not a byte array is refused as before). A typed array cannot be an index property, an indexOnly terminal or entry payload, or one side of a propertyAgreement. wasm-dpp2 exposes the declarations through documentTypeTypedArrays / documentTypedArrays. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/documents.md | 27 + .../document/v3/document-meta.json | 200 ++++- .../document_type/class_methods/mod.rs | 1 + .../class_methods/parse_typed_array/mod.rs | 38 + .../class_methods/parse_typed_array/v0/mod.rs | 44 ++ .../try_from_schema/common/mod.rs | 26 +- .../class_methods/try_from_schema/mod.rs | 151 ++-- .../class_methods/try_from_schema/v3/mod.rs | 52 ++ .../try_from_schema/v3/typed_array_tests.rs | 715 ++++++++++++++++++ .../src/data_contract/document_type/mod.rs | 2 + .../document_type/property/array.rs | 352 +++++++++ .../document_type/property/mod.rs | 265 ++++++- .../document_type/v0/random_document_type.rs | 41 + .../v0/mod.rs | 12 + .../data_contract_create/mod.rs | 22 + ...dation-contract-agreement-typed-array.json | 62 ++ packages/rs-drive/src/query/conditions.rs | 9 +- .../dpp_versions/dpp_contract_versions/mod.rs | 6 + .../dpp_versions/dpp_contract_versions/v1.rs | 1 + .../dpp_versions/dpp_contract_versions/v2.rs | 1 + .../dpp_versions/dpp_contract_versions/v3.rs | 1 + .../dpp_versions/dpp_contract_versions/v4.rs | 1 + .../dpp_versions/dpp_contract_versions/v5.rs | 1 + .../dpp_versions/dpp_contract_versions/v6.rs | 1 + .../src/version/mocks/v2_test.rs | 1 + .../src/version/system_limits/mod.rs | 7 + .../src/version/system_limits/v1.rs | 1 + .../src/version/system_limits/v2.rs | 1 + .../src/version/system_limits/v3.rs | 1 + .../src/version/system_limits/v4.rs | 4 + .../rs-platform-version/src/version/v14.rs | 13 + .../document_type_typed_arrays.rs | 177 +++++ packages/wasm-dpp2/src/data_contract/mod.rs | 4 + packages/wasm-dpp2/src/data_contract/model.rs | 53 ++ .../tests/unit/DocumentTypedArrays.spec.ts | 153 ++++ 35 files changed, 2324 insertions(+), 122 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-typed-array.json create mode 100644 packages/wasm-dpp2/src/data_contract/document_type_typed_arrays.rs create mode 100644 packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index c56d6a8cd78..92fc8e693e4 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -305,6 +305,33 @@ Enforcement lives in the replace action's state validation (generation 1). The a In Rust the lists are `DocumentTypeV2Getters::immutable_fields()` and `immutable_fields_allow_setting()`. Earlier document type generations return empty sets. +## Typed Arrays + +Up to protocol version 13 a `type: "array"` property had to be a byte array (`byteArray: true`). Protocol version 14 adds typed arrays: a list whose `items` schema says what every element is. + +```json +"reasons": { + "type": "array", + "minItems": 0, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 2 +} +``` + +- An element is a scalar: an integer, a number, a string (with `minLength` / `maxLength`), a boolean, a byte array (`byteArray: true`, whose `minItems` / `maxItems` count bytes) or an identifier. Objects and arrays of arrays are refused, and so is `refersTo` on an element for now. +- On the array itself `minItems` and `maxItems` count elements, not bytes. `maxItems` is required and at most `SystemLimits::max_document_array_items` (1024), so a typed array's worst-case size, which fee estimation charges by, stays finite. `uniqueItems: true` refuses a document that repeats an element. +- A byte array keeps its form exactly: it takes neither `items` nor `uniqueItems`. +- 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 (8 bytes for an integer or a number, 1 for a boolean, a varint length and the bytes for a string, a byte array or an 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. + ## Rules and Guidelines **Do:** 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 fefdea82fee..d8d2ba0b1ce 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 @@ -91,6 +91,10 @@ "uniqueItems": { "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/uniqueItems" }, + "items": { + "description": "typed arrays only: the schema every element of the array has", + "$ref": "#/$defs/documentArrayItem" + }, "refersTo": { "type": "object", "properties": { @@ -327,6 +331,18 @@ } } }, + "items": { + "description": "should be used only with array type", + "properties": { + "type": { + "type": "string", + "const": "array" + } + }, + "required": [ + "type" + ] + }, "contentMediaType": { "if": { "properties": { @@ -439,7 +455,7 @@ } }, { - "$comment": "allow only byte arrays", + "$comment": "an array is a byte array or a typed array. A byte array declares byteArray: true, its minItems and maxItems count bytes, and it takes no items and no uniqueItems. A typed array declares items, the schema of every element, instead; its minItems and maxItems count elements, and maxItems is required", "if": { "properties": { "type": { @@ -451,12 +467,26 @@ ] }, "then": { - "properties": { - "byteArray": true + "if": { + "required": [ + "byteArray" + ] }, - "required": [ - "byteArray" - ] + "then": { + "properties": { + "items": false, + "uniqueItems": false + } + }, + "else": { + "properties": { + "contentMediaType": false + }, + "required": [ + "items", + "maxItems" + ] + } } }, { @@ -493,6 +523,164 @@ } ] }, + "documentArrayItem": { + "$comment": "The element schema of a typed array: one scalar, an integer, a number, a string, a boolean, or a byte array (byteArray: true), which with the identifier contentMediaType is an identifier. Objects and arrays of arrays are refused. An element carries no position, requiredSince, refersTo or uniqueItems of its own", + "type": "object", + "properties": { + "$comment": { + "$ref": "https://json-schema.org/draft/2020-12/meta/core#/properties/$comment" + }, + "description": { + "$ref": "https://json-schema.org/draft/2020-12/meta/meta-data#/properties/description" + }, + "examples": { + "$ref": "https://json-schema.org/draft/2020-12/meta/meta-data#/properties/examples" + }, + "type": { + "enum": [ + "integer", + "number", + "string", + "boolean", + "array" + ] + }, + "multipleOf": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/multipleOf" + }, + "maximum": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/maximum" + }, + "exclusiveMaximum": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/exclusiveMaximum" + }, + "minimum": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/minimum" + }, + "exclusiveMinimum": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/exclusiveMinimum" + }, + "maxLength": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/maxLength" + }, + "minLength": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/minLength" + }, + "pattern": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/pattern" + }, + "maxItems": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/maxItems" + }, + "minItems": { + "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/minItems" + }, + "const": true, + "enum": { + "type": "array", + "items": true, + "minItems": 1, + "uniqueItems": true + }, + "format": { + "$ref": "https://json-schema.org/draft/2020-12/meta/format-annotation#/properties/format" + }, + "contentMediaType": { + "$ref": "https://json-schema.org/draft/2020-12/meta/content#/properties/contentMediaType" + }, + "byteArray": { + "type": "boolean", + "const": true + } + }, + "required": [ + "type" + ], + "additionalProperties": false, + "dependentSchemas": { + "byteArray": { + "description": "should be used only with array type", + "properties": { + "type": { + "const": "array" + } + } + }, + "contentMediaType": { + "if": { + "properties": { + "contentMediaType": { + "const": "application/x.dash.dpp.identifier" + } + } + }, + "then": { + "properties": { + "byteArray": { + "const": true + }, + "minItems": { + "const": 32 + }, + "maxItems": { + "const": 32 + } + }, + "required": [ + "byteArray", + "minItems", + "maxItems" + ] + } + }, + "pattern": { + "description": "prevent slow pattern matching of large strings", + "properties": { + "maxLength": { + "type": "integer", + "minimum": 0, + "maximum": 50000 + } + }, + "required": [ + "maxLength" + ] + }, + "format": { + "description": "prevent slow format validation of large strings", + "properties": { + "maxLength": { + "type": "integer", + "minimum": 0, + "maximum": 50000 + } + }, + "required": [ + "maxLength" + ] + } + }, + "allOf": [ + { + "$comment": "an array element is a byte array: a typed array cannot hold another typed array", + "if": { + "properties": { + "type": { + "const": "array" + } + }, + "required": [ + "type" + ] + }, + "then": { + "required": [ + "byteArray" + ] + } + } + ] + }, "documentActionTokenCost": { "type": "object", "properties": { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/mod.rs index 0fa5e3e27a7..b617c405a7c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/mod.rs @@ -8,6 +8,7 @@ use crate::ProtocolError; pub(crate) mod apply_required_since; mod create_document_types_from_document_schemas; +mod parse_typed_array; mod should_use_creator_id; mod system_properties; mod try_from_schema; 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 new file mode 100644 index 00000000000..30146733916 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs @@ -0,0 +1,38 @@ +use std::collections::BTreeMap; + +use platform_value::Value; +use platform_version::version::PlatformVersion; + +use crate::data_contract::document_type::DocumentPropertyType; +use crate::data_contract::errors::DataContractError; + +mod v0; + +/// Parses a typed array property: `type: "array"` with an `items` element +/// 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`. +/// +/// Versioned on `parse_typed_array` in the platform version's document type +/// schema versions. `None` selects the behavior of the versions that predate +/// typed arrays: nothing is parsed here, so `try_from_value_map` refuses an +/// array that is not a byte array, exactly as those versions always did. +pub(crate) fn parse_typed_array( + inner_properties: &BTreeMap, + platform_version: &PlatformVersion, +) -> Result, DataContractError> { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .parse_typed_array + { + None => Ok(None), + Some(0) => v0::parse_typed_array_v0(inner_properties), + Some(version) => Err(DataContractError::Unsupported(format!( + "parse_typed_array version {version} is not supported" + ))), + } +} 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 new file mode 100644 index 00000000000..1e2e0515996 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs @@ -0,0 +1,44 @@ +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::errors::DataContractError; + +/// Generation 0 parse rules: an array property that does not declare +/// `byteArray` is a typed array, whose `items` must be one scalar element +/// schema. `minItems` and `maxItems` count elements and fit a u16; +/// `uniqueItems` defaults to false. +pub(super) fn parse_typed_array_v0( + inner_properties: &BTreeMap, +) -> Result, DataContractError> { + let is_array = inner_properties + .get(property_names::TYPE) + .and_then(|type_value| type_value.as_text()) + == Some("array"); + + // A byte array keeps its scalar parse, and so does `byteArray: false`, + // which that parse refuses + if !is_array || inner_properties.contains_key(property_names::BYTE_ARRAY) { + return Ok(None); + } + + let Some(items) = inner_properties.get(property_names::ITEMS) else { + return Err(DataContractError::InvalidContractStructure( + "an array property must declare either byteArray: true or the items schema of its \ + elements" + .to_string(), + )); + }; + + Ok(Some(DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: ArrayItemType::try_from(*items)?, + min_items: inner_properties.get_optional_integer(property_names::MIN_ITEMS)?, + max_items: inner_properties.get_optional_integer(property_names::MAX_ITEMS)?, + unique_items: inner_properties + .get_optional_bool(property_names::UNIQUE_ITEMS)? + .unwrap_or_default(), + }))) +} 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 597adf4a2e2..145e3f62bce 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 @@ -1319,20 +1319,21 @@ fn check_indexable_property_shape( property_type: &DocumentPropertyType, ) -> Result<(), ProtocolError> { match property_type { - // Array and objects aren't supported for indexing yet + // Array and objects aren't supported for indexing yet. A typed array + // is stored inline in the document, with no index entry per element + // and no query operator (see Drive's `allowed_ops_for_type`). DocumentPropertyType::Array(_) | DocumentPropertyType::Object(_) - | DocumentPropertyType::VariableTypeArray(_) => { - Err(ProtocolError::ConsensusError(Box::new( - InvalidIndexPropertyTypeError::new( - document_type_name.to_owned(), - index_name.to_owned(), - property_name.to_owned(), - property_type.name(), - ) - .into(), - ))) - } + | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::TypedArray(_) => Err(ProtocolError::ConsensusError(Box::new( + InvalidIndexPropertyTypeError::new( + document_type_name.to_owned(), + index_name.to_owned(), + property_name.to_owned(), + property_type.name(), + ) + .into(), + ))), // Indexed byte array size must be limited DocumentPropertyType::ByteArray(sizes) if sizes @@ -2540,6 +2541,7 @@ pub(super) fn apply_index_only( DocumentPropertyType::Object(_) | DocumentPropertyType::Array(_) | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::TypedArray(_) ) { return Err(structure_error(format!( "entryPayload property \"{}\" of indexOnly document type \"{}\" must be a \ 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 da27d502beb..118f8c1961b 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 @@ -1,5 +1,6 @@ use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::class_methods::apply_required_since::apply_required_since; +use crate::data_contract::document_type::class_methods::parse_typed_array::parse_typed_array; use crate::data_contract::document_type::v0::DocumentTypeV0; use crate::data_contract::document_type::v1::DocumentTypeV1; use crate::data_contract::document_type::{ @@ -154,7 +155,12 @@ fn insert_values( platform_version, )?; - match DocumentPropertyType::try_from_value_map(&inner_properties, &config.into())? { + let property_type = match parse_typed_array(&inner_properties, platform_version)? { + Some(typed_array) => typed_array, + None => DocumentPropertyType::try_from_value_map(&inner_properties, &config.into())?, + }; + + match property_type { DocumentPropertyType::Object(_) => { if let Some(properties_as_value) = inner_properties.get(property_names::PROPERTIES) { @@ -231,79 +237,82 @@ fn insert_values_nested( platform_version, )?; - let property_type = - match DocumentPropertyType::try_from_value_map(&inner_properties, &config.into())? { - DocumentPropertyType::Object(_) => { - let mut nested_properties = IndexMap::new(); - if let Some(properties_as_value) = inner_properties.get(property_names::PROPERTIES) - { - let properties = - properties_as_value - .as_map() - .ok_or(DataContractError::ValueWrongType( - "properties must be a map".to_string(), - ))?; - - // Nested properties are emitted below in source-map order (the - // `properties.iter()` loop), and that `IndexMap` insertion order is - // consensus-observable: historical contracts were committed in source-map - // order, so re-sorting nested properties by `position` would soft-fork any - // contract whose source order differs from its position order. A previous - // `position`-based `sort_by` here was dead code (its sorted result was never - // read) and read `position` with `.expect()`, which could panic on adversarial - // schema input during block execution. Removed (ordering unchanged). Do NOT - // reintroduce a nested-property sort — even a correct one — nor a panicking - // `position` read here. - - // Create a new set with the prefix removed from the keys - let stripped_required: BTreeSet = known_required - .iter() - .filter_map(|key| { - if key.starts_with(&property_key) && key.len() > property_key.len() { - Some(key[property_key.len() + 1..].to_string()) - } else { - None - } - }) - .collect(); - - let stripped_transient: BTreeSet = known_transient - .iter() - .filter_map(|key| { - if key.starts_with(&property_key) && key.len() > property_key.len() { - Some(key[property_key.len() + 1..].to_string()) - } else { - None - } - }) - .collect(); - - for (object_property_key, object_property_value) in properties.iter() { - let object_property_string = object_property_key - .as_text() - .ok_or(DataContractError::KeyWrongType( - "property key must be a string".to_string(), - ))? - .to_string(); + let property_type = match parse_typed_array(&inner_properties, platform_version)? { + Some(typed_array) => typed_array, + None => DocumentPropertyType::try_from_value_map(&inner_properties, &config.into())?, + }; - insert_values_nested( - &mut nested_properties, - &stripped_required, - &stripped_transient, - false, - object_property_string, - object_property_value, - root_schema, - config, - platform_version, - )?; - } + let property_type = match property_type { + DocumentPropertyType::Object(_) => { + let mut nested_properties = IndexMap::new(); + if let Some(properties_as_value) = inner_properties.get(property_names::PROPERTIES) { + let properties = + properties_as_value + .as_map() + .ok_or(DataContractError::ValueWrongType( + "properties must be a map".to_string(), + ))?; + + // Nested properties are emitted below in source-map order (the + // `properties.iter()` loop), and that `IndexMap` insertion order is + // consensus-observable: historical contracts were committed in source-map + // order, so re-sorting nested properties by `position` would soft-fork any + // contract whose source order differs from its position order. A previous + // `position`-based `sort_by` here was dead code (its sorted result was never + // read) and read `position` with `.expect()`, which could panic on adversarial + // schema input during block execution. Removed (ordering unchanged). Do NOT + // reintroduce a nested-property sort — even a correct one — nor a panicking + // `position` read here. + + // Create a new set with the prefix removed from the keys + let stripped_required: BTreeSet = known_required + .iter() + .filter_map(|key| { + if key.starts_with(&property_key) && key.len() > property_key.len() { + Some(key[property_key.len() + 1..].to_string()) + } else { + None + } + }) + .collect(); + + let stripped_transient: BTreeSet = known_transient + .iter() + .filter_map(|key| { + if key.starts_with(&property_key) && key.len() > property_key.len() { + Some(key[property_key.len() + 1..].to_string()) + } else { + None + } + }) + .collect(); + + for (object_property_key, object_property_value) in properties.iter() { + let object_property_string = object_property_key + .as_text() + .ok_or(DataContractError::KeyWrongType( + "property key must be a string".to_string(), + ))? + .to_string(); + + insert_values_nested( + &mut nested_properties, + &stripped_required, + &stripped_transient, + false, + object_property_string, + object_property_value, + root_schema, + config, + platform_version, + )?; } - - DocumentPropertyType::Object(nested_properties) } - property_type => property_type, - }; + + DocumentPropertyType::Object(nested_properties) + } + property_type => property_type, + }; let property_type = apply_property_reference(&inner_properties, property_type, platform_version)?; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 79ef89772c6..474af1a73f4 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -145,6 +145,13 @@ fn validate_ranked_index_property_key_length( return Ok(()); }; + // A typed array is no index key at all, and its byte bound measures the + // whole list: the property-type check right after this one rejects it + // with the error that explains the problem. + if matches!(property_type, DocumentPropertyType::TypedArray(_)) { + return Ok(()); + } + // `None` is only produced by the array and object types, which the // property-type check right after this one rejects outright with the // error that actually explains the problem. @@ -413,9 +420,52 @@ fn try_from_schema_generation_3( )); } + #[cfg(feature = "validation")] + if full_validation { + validate_typed_array_max_items(&v2, name, platform_version)?; + } + Ok(v2) } +/// Every typed array property must declare `maxItems`, at most +/// `SystemLimits::max_document_array_items`, so its worst-case encoded size +/// stays finite. Read off the flattened properties, which reach a typed array +/// nested in an object too. +/// +/// Full validation only, like the other registration limits: a stored +/// contract was checked when it was registered, and a later protocol version +/// lowering the cap must not make it unreadable. +#[cfg(feature = "validation")] +fn validate_typed_array_max_items( + document_type: &DocumentTypeV2, + name: &str, + platform_version: &PlatformVersion, +) -> Result<(), ProtocolError> { + let limit = platform_version.system_limits.max_document_array_items; + for (path, property) in document_type.flattened_properties() { + let DocumentPropertyType::TypedArray(typed_array) = &property.property_type else { + continue; + }; + match typed_array.max_items { + Some(max_items) if max_items <= limit => {} + max_items => { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "typed array property \"{}\" of document type \"{}\" must declare \ + maxItems of at most {}, found {}", + path, + name, + limit, + max_items.map_or_else(|| "none".to_string(), |max| max.to_string()), + )), + )); + } + } + } + Ok(()) +} + impl DocumentType { /// Dispatches to this module's generation-3 parser and wraps the result. #[allow(clippy::too_many_arguments)] @@ -460,6 +510,8 @@ mod keep_history_tests; mod meta_schema_v0_stray_keyword_tests; #[cfg(test)] mod moderators_delete_tests; +#[cfg(all(test, feature = "validation", feature = "random-documents"))] +mod typed_array_tests; #[cfg(test)] mod tests { 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 new file mode 100644 index 00000000000..056162b4bb3 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs @@ -0,0 +1,715 @@ +//! Typed arrays: a property that is `type: "array"` with an `items` element +//! schema in place of `byteArray`. +//! +//! The grammar is the v3 document meta-schema's (protocol version 14) and the +//! parse is `parse_typed_array` 0, which the tables select from protocol +//! version 14 only; earlier versions keep refusing an array that is not a +//! byte array. The array is stored inline in the document, so these tests +//! also cover the codec, the document validation and the contract +//! serialization of a type that carries one. + +use super::*; +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::errors::DataContractError; +use crate::data_contract::serialized_version::v0::DataContractInSerializationFormatV0; +use crate::data_contract::DataContract; +use platform_value::platform_value; + +/// Parse through the real dispatcher, which picks the parser generation out +/// of the platform version's `try_from_schema` table value (generation 2 at +/// PV13, generation 3 at PV14). +fn parse_dispatched( + schema: Value, + platform_version: &PlatformVersion, + full_validation: bool, +) -> Result { + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available on this platform version"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "charter", + schema, + None, + &BTreeMap::new(), + &config, + full_validation, + &mut vec![], + platform_version, + ) +} + +/// A document type with one property, `list`, declared by `list_schema`. +fn schema_with_list(list_schema: Value) -> Value { + platform_value!({ + "type": "object", + "properties": { + "list": list_schema, + }, + "additionalProperties": false + }) +} + +/// The `reasons` declaration of the moderation charters contract: up to 64 +/// distinct identifiers. +fn reasons_list() -> Value { + platform_value!({ + "type": "array", + "minItems": 0, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 0 + }) +} + +fn list_property_type(document_type: &DocumentType) -> DocumentPropertyType { + document_type + .as_ref() + .flattened_properties() + .get("list") + .map(|property| property.property_type.clone()) + .expect("the list property is parsed") +} + +/// The error of a validating parse the meta-schema refused. +fn expect_json_schema_error( + result: Result, +) -> JsonSchemaError { + match result { + Err(ProtocolError::ConsensusError(boxed)) => match *boxed { + ConsensusError::BasicError(BasicError::JsonSchemaError(error)) => error, + other => panic!("expected a JSON schema error, got {other:?}"), + }, + other => panic!("expected a JSON schema error, got {other:?}"), + } +} + +/// The parser's structure errors surface as `InvalidContractStructure` +/// either directly or, with the `validation` feature on, wrapped as the basic +/// `ContractError`. +fn expect_structure_error(result: Result, needle: &str) { + let message = match result { + Err(ProtocolError::DataContractError(DataContractError::InvalidContractStructure( + message, + ))) => message, + Err(ProtocolError::ConsensusError(boxed)) => match *boxed { + ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(message), + )) => message, + other => panic!("expected InvalidContractStructure, got {other:?}"), + }, + other => panic!("expected InvalidContractStructure, got {other:?}"), + }; + assert!( + message.contains(needle), + "expected {needle:?} in the error, got: {message}" + ); +} + +#[test] +fn should_parse_a_typed_identifier_array() { + let document_type = parse_dispatched( + schema_with_list(reasons_list()), + PlatformVersion::latest(), + true, + ) + .expect("a typed identifier array parses"); + + assert_eq!( + list_property_type(&document_type), + DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: ArrayItemType::Identifier, + min_items: Some(0), + max_items: Some(64), + unique_items: true, + }) + ); +} + +#[test] +fn should_parse_a_typed_integer_array_with_bounds() { + let platform_version = PlatformVersion::latest(); + let document_type = parse_dispatched( + schema_with_list(platform_value!({ + "type": "array", + "minItems": 1, + "maxItems": 10, + "items": { "type": "integer", "minimum": 0, "maximum": 100 }, + "position": 0 + })), + platform_version, + true, + ) + .expect("a typed integer array parses"); + + let property_type = list_property_type(&document_type); + assert_eq!( + property_type, + DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: ArrayItemType::Integer, + min_items: Some(1), + max_items: Some(10), + unique_items: false, + }) + ); + // A one-byte element count, then 1 to 10 elements of 8 bytes each + assert_eq!( + property_type + .min_byte_size(platform_version) + .expect("sized"), + Some(9) + ); + assert_eq!( + property_type + .max_byte_size(platform_version) + .expect("sized"), + Some(81) + ); +} + +#[test] +fn should_parse_every_scalar_element_type() { + for (items, expected) in [ + (platform_value!({ "type": "number" }), ArrayItemType::Number), + ( + platform_value!({ "type": "boolean" }), + ArrayItemType::Boolean, + ), + ( + platform_value!({ "type": "string", "minLength": 1, "maxLength": 20 }), + ArrayItemType::String(Some(1), Some(20)), + ), + ( + platform_value!({ "type": "array", "byteArray": true, "minItems": 4, "maxItems": 8 }), + ArrayItemType::ByteArray(Some(4), Some(8)), + ), + ] { + let document_type = parse_dispatched( + schema_with_list(platform_value!({ + "type": "array", + "maxItems": 4, + "items": items.clone(), + "position": 0 + })), + PlatformVersion::latest(), + true, + ) + .unwrap_or_else(|e| panic!("{items:?} elements should parse: {e}")); + + let DocumentPropertyType::TypedArray(typed_array) = list_property_type(&document_type) + else { + panic!("{items:?} should parse to a typed array"); + }; + assert_eq!(typed_array.item_type, expected, "{items:?}"); + } +} + +#[test] +fn should_refuse_arrays_of_objects() { + let list = platform_value!({ + "type": "array", + "maxItems": 4, + "items": { + "type": "object", + "properties": { "name": { "type": "string", "maxLength": 10, "position": 0 } }, + "additionalProperties": false + }, + "position": 0 + }); + + let error = expect_json_schema_error(parse_dispatched( + schema_with_list(list.clone()), + PlatformVersion::latest(), + true, + )); + assert!( + error.instance_path().ends_with("/list/items/type") + || error.instance_path().contains("/list/items"), + "the meta-schema should point at the element schema, got {}", + error.instance_path() + ); + + // A parse that skips the meta-schema refuses it with the same rule + expect_structure_error( + parse_dispatched(schema_with_list(list), PlatformVersion::latest(), false), + "arrays of objects are not supported", + ); +} + +#[test] +fn should_refuse_arrays_of_arrays() { + for items in [ + platform_value!({ "type": "array" }), + platform_value!({ "type": "array", "items": { "type": "integer" }, "maxItems": 2 }), + ] { + let list = platform_value!({ + "type": "array", + "maxItems": 4, + "items": items.clone(), + "position": 0 + }); + + expect_json_schema_error(parse_dispatched( + schema_with_list(list.clone()), + PlatformVersion::latest(), + true, + )); + expect_structure_error( + parse_dispatched(schema_with_list(list), PlatformVersion::latest(), false), + "arrays of arrays are not supported", + ); + } +} + +#[test] +fn should_refuse_an_index_on_a_typed_array_property() { + let mut schema = schema_with_list(reasons_list()); + schema + .set_value( + "indices", + platform_value!([{ "name": "byList", "properties": [{ "list": "asc" }] }]), + ) + .expect("indices apply"); + + let result = parse_dispatched(schema, PlatformVersion::latest(), true); + + assert!( + matches!( + &result, + Err(ProtocolError::ConsensusError(boxed)) + if matches!( + **boxed, + ConsensusError::BasicError(BasicError::InvalidIndexPropertyTypeError(_)) + ) + ), + "an index on a typed array should be refused as an invalid index property type, got \ + {result:?}" + ); +} + +#[test] +fn should_refuse_a_typed_array_below_protocol_version_14_and_accept_it_at_14() { + let schema = schema_with_list(reasons_list()); + let platform_version_13 = PlatformVersion::get(13).expect("protocol version 13 should exist"); + + // Meta-schema v2 has no typed arrays + expect_json_schema_error(parse_dispatched(schema.clone(), platform_version_13, true)); + // and without it the shipped parse refuses an array that is not a byte + // array, exactly as it always did + expect_structure_error( + parse_dispatched(schema.clone(), platform_version_13, false), + "only byte arrays are supported now", + ); + + parse_dispatched(schema, PlatformVersion::latest(), true) + .expect("protocol version 14 parses typed arrays"); +} + +#[test] +fn should_require_max_items_on_a_typed_array_within_the_system_limit() { + let platform_version = PlatformVersion::latest(); + let limit = platform_version.system_limits.max_document_array_items; + + let without_max_items = schema_with_list(platform_value!({ + "type": "array", + "items": { "type": "boolean" }, + "position": 0 + })); + let error = expect_json_schema_error(parse_dispatched( + without_max_items.clone(), + platform_version, + true, + )); + assert_eq!(error.keyword(), "required"); + + let over_the_limit = schema_with_list(platform_value!({ + "type": "array", + "maxItems": u64::from(limit) + 1, + "items": { "type": "boolean" }, + "position": 0 + })); + expect_structure_error( + parse_dispatched(over_the_limit.clone(), platform_version, true), + &format!("must declare maxItems of at most {limit}"), + ); + + // The bound is a registration rule: a stored contract is read as declared + parse_dispatched(without_max_items, platform_version, false) + .expect("the non-validating parse reads the declaration as it is"); + parse_dispatched(over_the_limit, platform_version, false) + .expect("the non-validating parse reads the declaration as it is"); + + let at_the_limit = schema_with_list(platform_value!({ + "type": "array", + "maxItems": limit, + "items": { "type": "boolean" }, + "position": 0 + })); + parse_dispatched(at_the_limit, platform_version, true).expect("maxItems at the limit parses"); +} + +#[test] +fn should_keep_the_byte_array_form_free_of_items_and_unique_items() { + for (keyword, value) in [ + ("uniqueItems", Value::Bool(true)), + ("items", platform_value!({ "type": "integer" })), + ] { + let mut list = platform_value!({ + "type": "array", + "byteArray": true, + "maxItems": 16, + "position": 0 + }); + list.set_value(keyword, value).expect("keyword applies"); + + let error = expect_json_schema_error(parse_dispatched( + schema_with_list(list), + PlatformVersion::latest(), + true, + )); + assert!( + error.instance_path().ends_with(&format!("/list/{keyword}")), + "{keyword} on a byte array should be refused, got {}", + error.instance_path() + ); + } +} + +#[test] +fn should_refuse_refers_to_on_the_elements_of_a_typed_array() { + let list = platform_value!({ + "type": "array", + "maxItems": 4, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "identity" } + }, + "position": 0 + }); + + expect_json_schema_error(parse_dispatched( + schema_with_list(list.clone()), + PlatformVersion::latest(), + true, + )); + expect_structure_error( + parse_dispatched(schema_with_list(list), PlatformVersion::latest(), false), + "refersTo is not supported on the elements of a typed array", + ); +} + +/// A contract whose `charter` type carries a typed array of every element +/// type, `reasons` and `counts` required and the rest optional. +fn charter_contract(platform_version: &PlatformVersion) -> DataContract { + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available on this platform version"); + let charter = platform_value!({ + "type": "object", + "properties": { + "reasons": reasons_list(), + "counts": { + "type": "array", + "minItems": 1, + "maxItems": 5, + "items": { "type": "integer" }, + "position": 1 + }, + "labels": { + "type": "array", + "maxItems": 5, + "items": { "type": "string", "minLength": 1, "maxLength": 20 }, + "position": 2 + }, + "flags": { + "type": "array", + "maxItems": 2, + "uniqueItems": true, + "items": { "type": "boolean" }, + "position": 3 + }, + "digests": { + "type": "array", + "maxItems": 3, + "items": { "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32 }, + "position": 4 + }, + "weights": { + "type": "array", + "maxItems": 3, + "items": { "type": "number" }, + "position": 5 + } + }, + "required": ["reasons", "counts"], + "additionalProperties": false + }); + + DataContract::try_from_platform_versioned( + DataContractInSerializationFormatV0 { + id: Identifier::new([7; 32]), + config, + version: 1, + owner_id: Identifier::new([8; 32]), + schema_defs: None, + document_schemas: BTreeMap::from([("charter".to_string(), charter)]), + } + .into(), + true, + &mut vec![], + platform_version, + ) + .expect("the charter contract registers") +} + +/// Built by hand: `platform_value!` would store the identifiers as bytes, +/// not as the identifiers the decoder reads back. +fn charter_properties() -> Value { + Value::Map(vec![ + ( + Value::Text("reasons".to_string()), + Value::Array(vec![Value::Identifier([3; 32]), Value::Identifier([4; 32])]), + ), + ( + Value::Text("counts".to_string()), + Value::Array(vec![Value::I64(-5), Value::I64(0), Value::I64(i64::MAX)]), + ), + ( + Value::Text("labels".to_string()), + Value::Array(vec![ + Value::Text("spam".to_string()), + Value::Text("abuse".to_string()), + ]), + ), + ( + Value::Text("flags".to_string()), + Value::Array(vec![Value::Bool(true)]), + ), + ( + Value::Text("digests".to_string()), + Value::Array(vec![Value::Bytes32([9; 32])]), + ), + ( + Value::Text("weights".to_string()), + Value::Array(vec![Value::Float(0.5)]), + ), + ]) +} + +#[test] +fn should_round_trip_a_contract_with_typed_arrays_through_platform_serialization() { + use crate::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, + PlatformSerializableWithPlatformVersion, + }; + + let platform_version = PlatformVersion::latest(); + let contract = charter_contract(platform_version); + + let bytes = contract + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the contract serializes"); + let restored = DataContract::versioned_deserialize_untrusted(&bytes, true, platform_version) + .expect("the contract deserializes with full validation"); + + assert_eq!(restored, contract); + let reasons = restored + .document_type_for_name("charter") + .expect("charter type") + .flattened_properties() + .get("reasons") + .map(|property| property.property_type.clone()); + assert_eq!( + reasons, + Some(DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: ArrayItemType::Identifier, + min_items: Some(0), + max_items: Some(64), + unique_items: true, + })) + ); +} + +#[test] +fn should_round_trip_a_document_with_typed_arrays_through_serialization() { + use crate::document::serialization_traits::DocumentPlatformConversionMethodsV0; + use crate::document::{Document, DocumentV0, DocumentV0Getters}; + + let platform_version = PlatformVersion::latest(); + let contract = charter_contract(platform_version); + let document_type = contract + .document_type_for_name("charter") + .expect("charter type"); + + let full = charter_properties() + .into_btree_string_map() + .expect("properties are a map"); + // The required arrays alone, one of them empty + let required_only = BTreeMap::from([ + ("reasons".to_string(), Value::Array(vec![])), + ("counts".to_string(), Value::Array(vec![Value::I64(7)])), + ]); + + for properties in [full, required_only] { + let document: Document = DocumentV0 { + contract_version: None, + id: Identifier::new([1; 32]), + owner_id: Identifier::new([2; 32]), + properties, + revision: Some(1), + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + creator_id: None, + } + .into(); + + let bytes = document + .serialize(document_type, &contract, platform_version) + .expect("the document serializes"); + let restored = Document::from_bytes(&bytes, document_type, platform_version) + .expect("the document deserializes"); + + assert_eq!(restored.properties(), document.properties()); + } +} + +#[test] +fn should_refuse_a_document_whose_typed_array_breaks_its_schema() { + use crate::data_contract::methods::validate_document::DataContractDocumentValidationMethodsV0; + + let platform_version = PlatformVersion::latest(); + let contract = charter_contract(platform_version); + + contract + .validate_document_properties("charter", charter_properties(), platform_version) + .map(|result| assert!(result.is_valid(), "the base document is valid: {result:?}")) + .expect("validation runs"); + + let too_many_counts: Vec = (0..6).map(Value::I64).collect(); + for (property, value, keyword) in [ + ("counts", Value::Array(too_many_counts), "maxItems"), + ("counts", Value::Array(vec![]), "minItems"), + ( + "reasons", + Value::Array(vec![Value::Identifier([3; 32]), Value::Identifier([3; 32])]), + "uniqueItems", + ), + ("counts", platform_value!([Value::I64(1), "two"]), "type"), + ( + "reasons", + platform_value!([Value::Bytes(vec![1; 31])]), + "minItems", + ), + ] { + let mut properties = charter_properties(); + properties + .set_value(property, value.clone()) + .expect("property applies"); + + let result = contract + .validate_document_properties("charter", properties, platform_version) + .expect("validation returns a consensus result, never an error"); + + let Some(ConsensusError::BasicError(BasicError::JsonSchemaError(error))) = + result.first_error() + else { + panic!("{property} = {value:?} should be refused by the JSON schema, got {result:?}"); + }; + assert_eq!( + error.keyword(), + keyword, + "{property} = {value:?} should break {keyword}, got {error:?}" + ); + } +} + +#[test] +fn should_generate_random_documents_that_validate_against_their_own_schema() { + use crate::data_contract::document_type::random_document::{ + CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, + }; + use crate::data_contract::methods::validate_document::DataContractDocumentValidationMethodsV0; + use crate::document::serialization_traits::DocumentPlatformConversionMethodsV0; + use crate::document::{Document, DocumentV0Getters}; + use platform_value::Bytes32; + use rand::rngs::StdRng; + use rand::SeedableRng; + + let platform_version = PlatformVersion::latest(); + let contract = charter_contract(platform_version); + let document_type = contract + .document_type_for_name("charter") + .expect("charter type"); + let mut rng = StdRng::seed_from_u64(14); + + let mut documents: Vec<(String, Document)> = (0..8) + .map(|seed| { + let document = document_type + .random_document(Some(seed), platform_version) + .expect("a random document"); + ("random_document".to_string(), document) + }) + .collect(); + for fill_size in [ + DocumentFieldFillSize::MinDocumentFillSize, + DocumentFieldFillSize::MaxDocumentFillSize, + DocumentFieldFillSize::AnyDocumentFillSize, + ] { + for _ in 0..8 { + let owner_id = Identifier::random_with_rng(&mut rng); + let entropy = Bytes32::random_with_rng(&mut rng); + let document = document_type + .random_document_with_identifier_and_entropy( + &mut rng, + owner_id, + entropy, + DocumentFieldFillType::FillIfNotRequired, + fill_size, + platform_version, + ) + .expect("a random document"); + documents.push((format!("{fill_size:?}"), document)); + } + } + + for (generator, document) in documents { + let result = contract + .validate_document("charter", &document, platform_version) + .expect("validation runs"); + assert!( + result.is_valid(), + "a {generator} random document should satisfy its own schema: {result:?} for {:?}", + document.properties() + ); + + let bytes = document + .serialize(document_type, &contract, platform_version) + .expect("the random document serializes"); + let restored = Document::from_bytes(&bytes, document_type, platform_version) + .expect("the random document deserializes"); + assert_eq!(restored.properties(), document.properties()); + } +} 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 3e39ec42e9c..f7f8def6e26 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -96,6 +96,8 @@ pub(crate) mod property_names { pub const MAXIMUM: &str = "maximum"; pub const MIN_ITEMS: &str = "minItems"; pub const MAX_ITEMS: &str = "maxItems"; + pub const ITEMS: &str = "items"; + pub const UNIQUE_ITEMS: &str = "uniqueItems"; pub const MIN_LENGTH: &str = "minLength"; pub const MAX_LENGTH: &str = "maxLength"; pub const BYTE_ARRAY: &str = "byteArray"; 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 ee26594c276..076e911145b 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,8 +1,21 @@ +use crate::data_contract::document_type::property::{ + ByteArrayPropertySizes, DocumentPropertyType, StringPropertySizes, +}; +use crate::data_contract::document_type::property_names; use crate::data_contract::errors::DataContractError; use crate::ProtocolError; +use byteorder::{BigEndian, ReadBytesExt}; use integer_encoding::VarInt; +use platform_value::btreemap_extensions::BTreeValueMapHelper; use platform_value::Value; +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")] @@ -16,6 +29,118 @@ pub enum ArrayItemType { Date, } +/// A typed array property: `type: "array"` with an `items` schema naming the +/// type of every element, parsed from protocol version 14 +/// (`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 +/// indexed per element, so a typed array cannot be an index property. +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +pub struct TypedArrayProperty { + /// The type of every element, parsed from `items`. + pub item_type: ArrayItemType, + /// `minItems`: the fewest elements a document may hold. + pub min_items: Option, + /// `maxItems`: the most elements a document may hold. Full validation + /// requires it and caps it at `SystemLimits::max_document_array_items`. + pub max_items: Option, + /// `uniqueItems`: whether a document is refused for repeating an element. + pub unique_items: bool, +} + +impl TypedArrayProperty { + /// 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 { + 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) + } + + /// 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 `maxItems` or the element is unbounded. + pub fn max_encoded_size(&self) -> u16 { + let (Some(max_items), Some(item_max)) = (self.max_items, self.item_type.max_encoded_size()) + else { + return u16::MAX; + }; + let size = (max_items.required_space() as u64) + .saturating_add(u64::from(max_items).saturating_mul(item_max)); + u16::try_from(size).unwrap_or(u16::MAX) + } + + /// How many elements a random value holds: between `minItems` and + /// `maxItems`, and eight more than `minItems` when `maxItems` is absent. + fn random_items_range(&self) -> (usize, usize) { + let min_items = usize::from(self.min_items.unwrap_or(0)); + let max_items = self + .max_items + .map(usize::from) + .unwrap_or(min_items + 8) + .max(min_items); + (min_items, max_items) + } + + /// A random value holding between `minItems` and `maxItems` random + /// elements. + pub(super) fn random_value(&self, rng: &mut StdRng) -> Value { + let (min_items, max_items) = self.random_items_range(); + let count = rng.gen_range(min_items..=max_items); + self.random_items(count, rng, |element_type, rng| { + element_type.random_value(rng) + }) + } + + /// A random value holding `minItems` elements, each of its smallest size. + pub(super) fn random_sub_filled_value(&self, rng: &mut StdRng) -> Value { + let (min_items, _) = self.random_items_range(); + self.random_items(min_items, rng, |element_type, rng| { + element_type.random_sub_filled_value(rng) + }) + } + + /// A random value holding `maxItems` elements, each of its largest size. + pub(super) fn random_filled_value(&self, rng: &mut StdRng) -> Value { + let (_, max_items) = self.random_items_range(); + self.random_items(max_items, rng, |element_type, rng| { + element_type.random_filled_value(rng) + }) + } + + /// `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. + 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 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), + item => item, + }; + if self.unique_items && items.contains(&item) { + continue; + } + items.push(item); + } + Value::Array(items) + } +} + // 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 @@ -316,6 +441,233 @@ 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. + pub(super) fn read_from(&self, buf: &mut BufReader<&[u8]>) -> Result { + match self { + ArrayItemType::String(_, _) => { + let bytes = DocumentPropertyType::read_varint_value(buf)?; + String::from_utf8(bytes).map(Value::Text).map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading string array element from serialized document".to_string(), + ) + }) + } + ArrayItemType::Integer => buf.read_i64::().map(Value::I64).map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading integer array element from serialized document".to_string(), + ) + }), + ArrayItemType::Number | ArrayItemType::Date => { + buf.read_f64::().map(Value::Float).map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading number array element from serialized document".to_string(), + ) + }) + } + ArrayItemType::ByteArray(_, _) => { + let bytes = DocumentPropertyType::read_varint_value(buf)?; + Ok(self.byte_array_value(bytes)) + } + ArrayItemType::Identifier => { + let bytes = DocumentPropertyType::read_varint_value(buf)?; + <[u8; 32]>::try_from(bytes) + .map(Value::Identifier) + .map_err(|bytes| { + DataContractError::CorruptedSerialization(format!( + "identifier array element must be 32 bytes, found {}", + bytes.len() + )) + }) + } + ArrayItemType::Boolean => match buf.read_u8() { + Ok(0) => Ok(Value::Bool(false)), + Ok(1) => Ok(Value::Bool(true)), + _ => Err(DataContractError::CorruptedSerialization( + "error reading boolean array element from serialized document".to_string(), + )), + }, + } + } + + /// The value a byte array element reads back as: a fixed-size element + /// 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(_, max_size) => max_size.map(length_prefixed_size), + ArrayItemType::Identifier => Some(length_prefixed_size(32)), + } + } + + /// 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 d71648c21e2..3f830859cec 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 @@ -21,7 +21,7 @@ use crate::data_contract::DataContract; use crate::document::property_names::{CREATOR_ID, OWNER_ID}; use crate::prelude::TimestampMillis; use crate::ProtocolError; -use array::ArrayItemType; +use array::{ArrayItemType, TypedArrayProperty}; use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; use indexmap::IndexMap; use integer_encoding::{VarInt, VarIntReader}; @@ -702,9 +702,18 @@ pub enum DocumentPropertyType { Boolean, Date, Object(IndexMap), + /// A list of elements of one type with no element count bounds. The + /// schema parser never produces it: a typed array property parses to + /// [`DocumentPropertyType::TypedArray`], which shares its encoding. Array(ArrayItemType), VariableTypeArray(Vec), IdentifierWithReference(DocumentPropertyReferenceTarget), + /// A typed array property (`type: "array"` with an `items` element + /// schema), from protocol version 14: the element type with the + /// `minItems` / `maxItems` element count bounds and `uniqueItems`. Stored + /// inline like [`DocumentPropertyType::Array`], a varint element count + /// followed by the elements. + TypedArray(TypedArrayProperty), } impl DocumentPropertyType { @@ -766,7 +775,9 @@ impl DocumentPropertyType { DocumentPropertyType::Boolean => "boolean".to_string(), DocumentPropertyType::Date => "date".to_string(), DocumentPropertyType::Object(_) => "object".to_string(), - DocumentPropertyType::Array(_) => "array".to_string(), + DocumentPropertyType::Array(_) | DocumentPropertyType::TypedArray(_) => { + "array".to_string() + } DocumentPropertyType::VariableTypeArray(_) => "variableTypeArray".to_string(), } } @@ -798,7 +809,7 @@ impl DocumentPropertyType { .iter() .map(|(_, sub_field)| sub_field.property_type.min_size()) .sum(), - DocumentPropertyType::Array(_) => None, + DocumentPropertyType::Array(_) | DocumentPropertyType::TypedArray(_) => None, DocumentPropertyType::VariableTypeArray(_) => None, DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Some(32) @@ -847,6 +858,9 @@ impl DocumentPropertyType { .sum(), DocumentPropertyType::Array(_) => Ok(None), DocumentPropertyType::VariableTypeArray(_) => Ok(None), + DocumentPropertyType::TypedArray(typed_array) => { + Ok(Some(typed_array.min_encoded_size())) + } DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Ok(Some(32)) } @@ -894,6 +908,9 @@ impl DocumentPropertyType { .sum(), DocumentPropertyType::Array(_) => Ok(None), DocumentPropertyType::VariableTypeArray(_) => Ok(None), + DocumentPropertyType::TypedArray(typed_array) => { + Ok(Some(typed_array.max_encoded_size())) + } DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Ok(Some(32)) } @@ -927,7 +944,7 @@ impl DocumentPropertyType { .iter() .map(|(_, sub_field)| sub_field.property_type.max_size()) .sum(), - DocumentPropertyType::Array(_) => None, + DocumentPropertyType::Array(_) | DocumentPropertyType::TypedArray(_) => None, DocumentPropertyType::VariableTypeArray(_) => None, DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Some(32) @@ -964,7 +981,8 @@ impl DocumentPropertyType { DocumentPropertyType::String(_) | DocumentPropertyType::Object(_) | DocumentPropertyType::Array(_) - | DocumentPropertyType::VariableTypeArray(_) => None, + | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::TypedArray(_) => None, } } @@ -1101,6 +1119,7 @@ impl DocumentPropertyType { DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Value::Identifier(rng.gen()) } + DocumentPropertyType::TypedArray(typed_array) => typed_array.random_value(rng), } } @@ -1152,6 +1171,9 @@ impl DocumentPropertyType { DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Value::Identifier(rng.gen()) } + DocumentPropertyType::TypedArray(typed_array) => { + typed_array.random_sub_filled_value(rng) + } } } @@ -1203,6 +1225,7 @@ impl DocumentPropertyType { DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Value::Identifier(rng.gen()) } + DocumentPropertyType::TypedArray(typed_array) => typed_array.random_filled_value(rng), } } @@ -1455,9 +1478,24 @@ impl DocumentPropertyType { Ok((Some(Value::Map(values)), false)) } } - DocumentPropertyType::Array(_array_field_type) => Err(DataContractError::Unsupported( - "serialization of arrays not yet supported".to_string(), - )), + DocumentPropertyType::Array(item_type) + | DocumentPropertyType::TypedArray(TypedArrayProperty { 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 + // one byte, so a count the document cannot hold fails once + // the input runs out. + let count: usize = buf.read_varint().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading varint of array element count".to_string(), + ) + })?; + let mut items = Vec::new(); + for _ in 0..count { + items.push(item_type.read_from(buf)?); + } + Ok((Some(Value::Array(items)), false)) + } DocumentPropertyType::VariableTypeArray(_) => Err(DataContractError::Unsupported( "serialization of variable type arrays not yet supported".to_string(), )), @@ -1663,7 +1701,11 @@ impl DocumentPropertyType { Err(get_field_type_matching_error(&value).into()) } } - DocumentPropertyType::Array(array_field_type) => { + DocumentPropertyType::Array(array_field_type) + | DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: array_field_type, + .. + }) => { if let Value::Array(array) = value { let mut r_vec = array.len().encode_var_vec(); @@ -1818,7 +1860,11 @@ impl DocumentPropertyType { len_prepended_vec.append(&mut r_vec); Ok(len_prepended_vec) } - DocumentPropertyType::Array(array_field_type) => { + DocumentPropertyType::Array(array_field_type) + | DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: array_field_type, + .. + }) => { if let Value::Array(array) = value { let mut r_vec = array.len().encode_var_vec(); @@ -1929,13 +1975,14 @@ impl DocumentPropertyType { "we should never try encoding an object".to_string(), ), )), - DocumentPropertyType::Array(_) | DocumentPropertyType::VariableTypeArray(_) => { - Err(ProtocolError::DataContractError( - DataContractError::EncodingDataStructureNotSupported( - "we should never try encoding an array".to_string(), - ), - )) - } + // Arrays are never index keys: the parser refuses an index on one + DocumentPropertyType::Array(_) + | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::TypedArray(_) => Err(ProtocolError::DataContractError( + DataContractError::EncodingDataStructureNotSupported( + "we should never try encoding an array".to_string(), + ), + )), } } @@ -2052,13 +2099,13 @@ impl DocumentPropertyType { "we should never try decoding an object".to_string(), ), )), - DocumentPropertyType::Array(_) | DocumentPropertyType::VariableTypeArray(_) => { - Err(ProtocolError::DataContractError( - DataContractError::EncodingDataStructureNotSupported( - "we should never try decoding an array".to_string(), - ), - )) - } + DocumentPropertyType::Array(_) + | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::TypedArray(_) => Err(ProtocolError::DataContractError( + DataContractError::EncodingDataStructureNotSupported( + "we should never try decoding an array".to_string(), + ), + )), } } @@ -2182,7 +2229,10 @@ impl DocumentPropertyType { "we should never try encoding an object".to_string(), )) } - DocumentPropertyType::Array(_) | DocumentPropertyType::VariableTypeArray(_) => { + // A string names one value, never a list of them + DocumentPropertyType::Array(_) + | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::TypedArray(_) => { Err(DataContractError::EncodingDataStructureNotSupported( "we should never try encoding an array".to_string(), )) @@ -3043,7 +3093,11 @@ impl DocumentPropertyType { } // Handle Array type - sanitize all elements - (DocumentPropertyType::Array(item_type), Value::Array(_)) => { + (DocumentPropertyType::Array(item_type), Value::Array(_)) + | ( + DocumentPropertyType::TypedArray(TypedArrayProperty { item_type, .. }), + Value::Array(_), + ) => { if let Value::Array(items) = value { for item in items.iter_mut() { item_type.sanitize_value_mut(item); @@ -5106,14 +5160,165 @@ mod tests { assert_eq!(value, Some(Value::Bytes(vec![10, 20, 30]))); } + fn typed_array(item_type: ArrayItemType) -> DocumentPropertyType { + DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type, + min_items: None, + max_items: Some(8), + unique_items: false, + }) + } + #[test] - fn test_read_optionally_from_array_returns_error() { + fn should_round_trip_every_array_element_type_through_encode_and_read_optionally_from() { use std::io::BufReader; - let prop = DocumentPropertyType::Array(ArrayItemType::Integer); + for (item_type, items) in [ + ( + ArrayItemType::Integer, + vec![Value::I64(i64::MIN), Value::I64(-1), Value::I64(i64::MAX)], + ), + ( + ArrayItemType::Number, + vec![Value::Float(-0.5), Value::Float(1e300)], + ), + ( + ArrayItemType::String(None, Some(20)), + vec![Value::Text("".to_string()), Value::Text("über".to_string())], + ), + ( + ArrayItemType::ByteArray(Some(1), 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)), + vec![Value::Bytes32([0x80; 32])], + ), + ( + ArrayItemType::Identifier, + vec![Value::Identifier([1; 32]), Value::Identifier([2; 32])], + ), + ( + ArrayItemType::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"); + } + } + } + } + + #[test] + fn should_refuse_an_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 = prop.read_optionally_from(&mut reader, true); - assert!(result.is_err()); + let result = typed_array(ArrayItemType::Integer).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); + 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(_)) + )); + } + + #[test] + fn should_refuse_malformed_identifier_and_boolean_array_elements() { + 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(_)) + )); + + // A boolean element is written as 0 or 1 + let data: &[u8] = &[1, 2]; + let mut reader = BufReader::new(data); + assert!(matches!( + typed_array(ArrayItemType::Boolean).read_optionally_from(&mut reader, true), + Err(DataContractError::CorruptedSerialization(_)) + )); + } + + #[test] + fn should_bound_a_typed_array_by_its_item_counts_times_its_element_bounds() { + let pv = PlatformVersion::latest(); + let bounded = |item_type, min_items, max_items| { + DocumentPropertyType::TypedArray(TypedArrayProperty { + 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), Some(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)); + + // A string element's maxLength counts characters of up to four bytes + let strings = bounded(ArrayItemType::String(Some(3), Some(40)), None, Some(200)); + assert_eq!(strings.min_byte_size(pv).unwrap(), Some(1)); + assert_eq!( + strings.max_byte_size(pv).unwrap(), + Some(2 + 200 * (2 + 160)) + ); + + // Unbounded, or past what a u16 holds, reports u16::MAX + let unbounded_elements = bounded(ArrayItemType::String(None, None), None, Some(4)); + assert_eq!( + unbounded_elements.max_byte_size(pv).unwrap(), + Some(u16::MAX) + ); + let unbounded_count = bounded(ArrayItemType::Boolean, None, None); + assert_eq!(unbounded_count.max_byte_size(pv).unwrap(), Some(u16::MAX)); + let saturated = bounded( + ArrayItemType::String(None, Some(5000)), + Some(1024), + Some(1024), + ); + assert_eq!(saturated.max_byte_size(pv).unwrap(), Some(u16::MAX)); + assert_eq!(saturated.min_byte_size(pv).unwrap(), Some(2 + 1024)); } #[test] 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 72164db4da9..868fd438df7 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 @@ -368,6 +368,47 @@ impl DocumentTypeV0 { "byteArray": true, }) }, + DocumentPropertyType::TypedArray(typed_array) => { + // Bounds are only written when declared: a `null` bound is no schema + fn with_bounds(mut schema: serde_json::Value, bounds: [(&str, Option); 2]) -> serde_json::Value { + if let serde_json::Value::Object(ref mut map) = schema { + for (keyword, bound) in bounds { + if let Some(bound) = bound { + map.insert(keyword.to_string(), json!(bound)); + } + } + } + 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!({ + "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"}), + }; + + with_bounds( + json!({ + "type": "array", + "items": items_schema, + "uniqueItems": typed_array.unique_items, + }), + [ + ("minItems", typed_array.min_items.map(usize::from)), + ("maxItems", typed_array.max_items.map(usize::from)), + ], + ) + }, DocumentPropertyType::VariableTypeArray(types) => { let types_schema = types.iter().map(|t| { match t { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs index d64954e4e30..3ee33668bbc 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs @@ -325,6 +325,18 @@ pub(super) fn validate_data_contract_references_v0( "agreement properties must be plain values, not object containers", )); } + // The write-time check compares index key encodings, which a + // list does not have, so an agreement on one would never hold + if matches!(referring_type, DocumentPropertyType::TypedArray(_)) + || matches!( + referenced.property_type, + DocumentPropertyType::TypedArray(_) + ) + { + return Ok(invalid( + "agreement properties must be single values, not typed arrays", + )); + } if !same_value_kind(referring_type, &referenced.property_type) { return Ok(invalid( "the two properties must share one value kind: a cross-kind \ 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 3721ab3896c..2b380472d7e 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 @@ -5796,6 +5796,28 @@ mod tests { ); } + /// An agreement is checked at write time by comparing index key + /// encodings, which a typed array does not have, so one between two + /// typed arrays of the same element type is refused at registration + /// rather than refusing every write that carries them. + #[tokio::test] + async fn should_reject_agreement_on_typed_array_properties() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-typed-array.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyAgreementInvalidError(error) + ), + .. + } if error.reason().contains("not typed arrays") + ); + } + /// `$ownerId` and `$creatorId` may sit on the referenced side of an /// agreement when the referring side is an identifier and, for /// `$creatorId`, the referenced type records creator ids diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-typed-array.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-typed-array.json new file mode 100644 index 00000000000..d90607de8aa --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-typed-array.json @@ -0,0 +1,62 @@ +{ + "$formatVersion": "1", + "id": "8Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVg", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "note": { + "type": "object", + "canBeDeleted": false, + "properties": { + "content": { + "type": "string", + "position": 0, + "maxLength": 100 + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 20 + }, + "maxItems": 8, + "position": 1 + } + }, + "required": [], + "additionalProperties": false + }, + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "noteId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "note", + "propertyAgreement": { + "tags": "tags" + } + } + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 20 + }, + "maxItems": 8, + "position": 1 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index 7d4dfd71591..6fa93ff4090 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -1311,7 +1311,7 @@ impl<'a> WhereClause { Value::I8(_) | Value::I16(_) | Value::I32(_) | Value::I64(_) | Value::I128(_) ), // No validation for object/array types as operators are disallowed - T::Object(_) | T::Array(_) | T::VariableTypeArray(_) => false, + T::Object(_) | T::Array(_) | T::VariableTypeArray(_) | T::TypedArray(_) => false, } }; @@ -1364,7 +1364,9 @@ impl<'a> WhereClause { T::ByteArray(_) => matches!(self.value, Value::Bytes(_)), T::Boolean => matches!(self.value, Value::Bool(_)), // Not applicable for object/array/variable arrays - T::Object(_) | T::Array(_) | T::VariableTypeArray(_) => false, + T::Object(_) | T::Array(_) | T::VariableTypeArray(_) | T::TypedArray(_) => { + false + } }; if !ok { return QuerySyntaxSimpleValidationResult::new_with_error( @@ -1462,7 +1464,8 @@ pub fn allowed_ops_for_type(property_type: &DocumentPropertyType) -> &'static [W DocumentPropertyType::Boolean => &[Equal], DocumentPropertyType::Object(_) | DocumentPropertyType::Array(_) - | DocumentPropertyType::VariableTypeArray(_) => &[], + | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::TypedArray(_) => &[], } } 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 ce45a9b8f5e..33c9ebc890e 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 @@ -92,6 +92,12 @@ pub struct DocumentTypeSchemaVersions { /// keyword: they ignore it entirely, exactly as they parsed before it /// existed. pub apply_required_since: OptionalFeatureVersion, + /// Parses a typed array property (`type: "array"` with an `items` + /// element schema instead of `byteArray`). `None` on versions that + /// predate typed arrays: they leave such a property to the scalar + /// parser, which refuses an array that is not a byte array, exactly as + /// they parsed before typed arrays existed. + pub parse_typed_array: 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 5d8268e5c02..5063b165e5d 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 @@ -45,6 +45,7 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { // This version predates the `refersTo` reference keyword apply_property_reference: None, apply_required_since: None, + parse_typed_array: 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 0adcf1900fa..31cee6b3269 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 @@ -45,6 +45,7 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { // This version predates the `refersTo` reference keyword apply_property_reference: None, apply_required_since: None, + parse_typed_array: 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 8aa7bcfd4ff..e5564f333c7 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 @@ -47,6 +47,7 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { // This version predates the `refersTo` reference keyword apply_property_reference: None, apply_required_since: None, + parse_typed_array: 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 c7b75d7bd50..b162ccef519 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 @@ -47,6 +47,7 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { // This version predates the `refersTo` reference keyword apply_property_reference: None, apply_required_since: None, + parse_typed_array: 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 e192b402bdc..1b6d8af3412 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 @@ -49,6 +49,7 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { // This version predates the `refersTo` reference keyword apply_property_reference: None, apply_required_since: None, + parse_typed_array: 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 a5ec478500c..0f5ac405e01 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 @@ -82,6 +82,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { 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 + parse_typed_array: Some(0), // changed: a meta-schema v3 typed array (`type: "array"` with an `items` element schema) parses to `DocumentPropertyType::TypedArray`; None before this version leaves it to the scalar parser, which refuses an array that is not a byte array validate_max_depth: 0, max_depth: 256, recursive_schema_validator_versions: RecursiveSchemaValidatorVersions { diff --git a/packages/rs-platform-version/src/version/mocks/v2_test.rs b/packages/rs-platform-version/src/version/mocks/v2_test.rs index 6ffed9849cf..abf97b42440 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -567,6 +567,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5000, max_document_value_depth: None, + max_document_array_items: 1024, max_state_transition_size: 20000, // Is different in this test version, not sure if this was a mistake // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch. Raising it here diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index aa11841d772..41bbfe028d6 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -11,6 +11,13 @@ pub struct SystemLimits { /// /// `None` preserves the behavior of protocol versions that predate this limit. pub max_document_value_depth: Option, + /// Maximum `maxItems` a typed array document property (`type: "array"` with an `items` + /// element schema) may declare, enforced when a contract is registered or updated: full + /// validation requires every typed array to declare `maxItems` and refuses one above + /// this. The bound keeps an array's worst-case encoded size, which fee estimation charges + /// by, finite. Read by document type parser generation 3 (protocol version 14), the only + /// generation that parses typed arrays, and never reached before. + pub max_document_array_items: u16, /// Max size of a state transition in bytes. /// /// NOTE: This must be equal to the `max-tx-bytes` in the Tenderdash config diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index 510c1426a69..877c9fdf45a 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -4,6 +4,7 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5120, //5 KiB max_document_value_depth: None, + max_document_array_items: 1024, max_state_transition_size: 20480, //20 KiB // TODO: this is currently capped at 1 because the batch state-transition // pipeline has known correctness issues with multi-transition batches: diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index caa74744517..afd5f1758a9 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -10,6 +10,7 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_field_value_size: 5120, //5 KiB // v12 is already active on live networks; the depth limit activates in v13 (see v3). max_document_value_depth: None, + max_document_array_items: 1024, max_state_transition_size: 20480, //20 KiB // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch and SYSTEM_LIMITS_V1. diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index 19eb669d6c8..1ebc49b5064 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -12,6 +12,7 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { // Use the protocol's existing data-contract schema-depth ceiling as the conservative // instance budget, bounding pre-schema work well above known document requirements. max_document_value_depth: Some(256), + max_document_array_items: 1024, max_state_transition_size: 20480, //20 KiB // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch and SYSTEM_LIMITS_V1. diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index 117c466fe36..b893cdf4ba3 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -50,12 +50,16 @@ use crate::version::system_limits::SystemLimits; /// * Elected moderation teams (protocol version 14): a contract that declares an elected /// moderation team sets its join window and vote window between one day and four weeks, /// and its challenge cool-down between two weeks and three years, all in seconds. +/// * Typed array document properties (protocol version 14): a typed array property declares +/// `maxItems`, at most 1024 elements (`max_document_array_items`, backfilled into the +/// earlier tables, whose parsers never read it). pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5120, //5 KiB // Use the protocol's existing data-contract schema-depth ceiling as the conservative // instance budget, bounding pre-schema work well above known document requirements. max_document_value_depth: Some(256), + max_document_array_items: 1024, // typed array properties (new in v14): full validation requires maxItems and caps it here max_state_transition_size: 20480, //20 KiB // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch and SYSTEM_LIMITS_V1. diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 9c8c36a843d..26c5232e1f5 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -556,6 +556,19 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `ReferencedContractRequirementNotMetError` (40135). A changed /// `contractRequirements` is an incompatible schema change on update. /// +/// 25. **Typed arrays of scalars in document schemas**: a document property +/// may be `type: "array"` with an `items` element schema instead of +/// `byteArray` (meta-schema v3, `parse_typed_array` 0, +/// `DocumentPropertyType::TypedArray`). An element is an integer, a +/// number, a string, a boolean, a byte array or an identifier; objects +/// and arrays of arrays are refused. On the array `minItems` and +/// `maxItems` count elements, `maxItems` is required and at most +/// `SYSTEM_LIMITS_V4.max_document_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`. A byte array +/// now refuses `uniqueItems`, as it refuses `items`. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's diff --git a/packages/wasm-dpp2/src/data_contract/document_type_typed_arrays.rs b/packages/wasm-dpp2/src/data_contract/document_type_typed_arrays.rs new file mode 100644 index 00000000000..c40d043f173 --- /dev/null +++ b/packages/wasm-dpp2/src/data_contract/document_type_typed_arrays.rs @@ -0,0 +1,177 @@ +//! Typed array properties: the lists of scalars a document type may declare +//! from protocol version 14 onward. +//! +//! A typed array is `type: "array"` with an `items` schema naming what every +//! element is, where a byte array declares `byteArray: true` instead. It is +//! stored inline in the document as an element count followed by the +//! elements, and it cannot be indexed. What this module adds is the ability +//! to *discover* the declarations, "which properties of this document type +//! are lists, and of what?", without hand-parsing the contract's raw JSON +//! schema. + +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::{DocumentPropertyType, DocumentTypeRef}; +use js_sys::{Array, Object, Reflect}; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(typescript_custom_section)] +const DOCUMENT_TYPED_ARRAY_PROPERTY_TS: &'static str = r#" +/** + * What every element of a typed array is, parsed from its `items` schema. + * + * `type` names the element kind as DPP parses it: `byteArray` is an + * `items` schema with `byteArray: true`, and `identifier` one that also + * carries the identifier `contentMediaType`. The bound names are the schema + * keywords' own: `minLength` / `maxLength` count a string element's + * characters, `minItems` / `maxItems` a byte array element's bytes. A bound + * is absent when the schema omits it. + */ +export type DocumentTypedArrayItem = + | { type: 'integer' } + | { type: 'number' } + | { type: 'boolean' } + | { type: 'string'; minLength?: number; maxLength?: number } + | { type: 'byteArray'; minItems?: number; maxItems?: number } + | { type: 'identifier' }; + +/** + * A single typed array property of a document type. + * + * Mirrors the typed array form of the v3 document meta-schema, which is + * active from protocol version 14. The field names are the schema + * keywords' own, so what `contract.toJSON()` shows and what these accessors + * return line up key for key. + */ +export type DocumentTypedArrayProperty = { + /** + * Dotted path of the property within the document type, for example + * `"reasons"`, or `"team.members"` for one nested in an object. + */ + path: string; + /** What every element is. */ + items: DocumentTypedArrayItem; + /** The fewest elements a document may hold; absent when not declared. */ + minItems?: number; + /** + * The most elements a document may hold. Contract registration requires + * it, so it is only absent on a contract parsed without validation. + */ + maxItems?: number; + /** Whether a document repeating an element is refused. */ + uniqueItems: boolean; +}; +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "Array")] + pub type DocumentTypedArrayPropertyArrayJs; + + #[wasm_bindgen(typescript_type = "Map>")] + pub type DocumentTypedArrayPropertyMapJs; +} + +/// `Reflect::set` with the collection-getter error convention the `tokens` +/// and `groups` getters on `DataContract` already use. +fn set_field(target: &Object, key: &str, value: &JsValue, path: &str) -> WasmDppResult<()> { + Reflect::set(target, &JsValue::from_str(key), value).map_err(|_| { + WasmDppError::generic(format!( + "unable to serialize the `{key}` field of the typed array declared at '{path}'" + )) + })?; + Ok(()) +} + +/// Set a bound only when the schema declares it, matching the schema's own +/// omission. +fn set_bound>( + target: &Object, + key: &str, + bound: Option, + path: &str, +) -> WasmDppResult<()> { + match bound { + Some(bound) => set_field(target, key, &JsValue::from_f64(bound.into()), path), + None => Ok(()), + } +} + +/// Build the flat, internally-tagged JS object for one element type. +fn item_to_js(item_type: &ArrayItemType, 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", + // No items schema parses to a date; reported as the number it + // decodes to rather than failing the whole collection + ArrayItemType::Date => "number", + }; + 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)?; + } + ArrayItemType::ByteArray(min_size, max_size) => { + set_bound(&object, "minItems", as_f64(min_size), path)?; + set_bound(&object, "maxItems", as_f64(max_size), path)?; + } + ArrayItemType::Integer + | ArrayItemType::Number + | ArrayItemType::Boolean + | ArrayItemType::Identifier + | ArrayItemType::Date => {} + } + + Ok(object.into()) +} + +/// Build the JS object for one typed array property. +fn typed_array_to_js(path: &str, typed_array: &TypedArrayProperty) -> WasmDppResult { + let object = Object::new(); + set_field(&object, "path", &JsValue::from_str(path), path)?; + set_field( + &object, + "items", + &item_to_js(&typed_array.item_type, path)?, + path, + )?; + set_bound(&object, "minItems", typed_array.min_items, path)?; + set_bound(&object, "maxItems", typed_array.max_items, path)?; + set_field( + &object, + "uniqueItems", + &JsValue::from_bool(typed_array.unique_items), + path, + )?; + Ok(object.into()) +} + +/// Collect every typed array property of one document type, in schema +/// property order. +/// +/// Walks `flattened_properties`, which reaches a typed array nested in an +/// object property and names it by its dotted path. +pub(crate) fn typed_arrays_for_document_type( + document_type: DocumentTypeRef<'_>, +) -> WasmDppResult { + let typed_arrays = Array::new(); + + for (path, property) in document_type.flattened_properties() { + if let DocumentPropertyType::TypedArray(typed_array) = &property.property_type { + typed_arrays.push(&typed_array_to_js(path, typed_array)?); + } + } + + Ok(typed_arrays) +} diff --git a/packages/wasm-dpp2/src/data_contract/mod.rs b/packages/wasm-dpp2/src/data_contract/mod.rs index 57c8a39ce52..ab01c052f8c 100644 --- a/packages/wasm-dpp2/src/data_contract/mod.rs +++ b/packages/wasm-dpp2/src/data_contract/mod.rs @@ -2,6 +2,7 @@ pub mod contract_bounds; pub mod document; pub mod document_type_immutability; pub mod document_type_reference; +pub mod document_type_typed_arrays; pub mod model; pub mod transitions; @@ -13,6 +14,9 @@ pub use document_type_immutability::{ pub use document_type_reference::{ DocumentPropertyReferenceArrayJs, DocumentPropertyReferenceMapJs, }; +pub use document_type_typed_arrays::{ + DocumentTypedArrayPropertyArrayJs, DocumentTypedArrayPropertyMapJs, +}; pub use model::{ DataContractJSONJs, DataContractObjectJs, DataContractWasm, tokens_configuration_from_js_value, }; diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index 7d01a65bd15..65272df00e2 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -5,6 +5,10 @@ use crate::data_contract::document_type_immutability::{ use crate::data_contract::document_type_reference::{ DocumentPropertyReferenceArrayJs, DocumentPropertyReferenceMapJs, references_for_document_type, }; +use crate::data_contract::document_type_typed_arrays::{ + DocumentTypedArrayPropertyArrayJs, DocumentTypedArrayPropertyMapJs, + typed_arrays_for_document_type, +}; use crate::error::{WasmDppError, WasmDppResult}; use crate::identifier::{IdentifierLikeJs, IdentifierWasm}; use crate::impl_try_from_js_value; @@ -789,6 +793,55 @@ impl DataContractWasm { Ok(JsValue::from(map).into()) } + + /// Every typed array property of one document type (`type: "array"` + /// with an `items` element schema), in schema property order: `{ path, + /// items, minItems?, maxItems?, uniqueItems }`. + /// + /// Returns an empty array when the document type declares none. Throws + /// when the contract has no document type by that name, so "no such + /// type" and "no typed arrays" stay distinguishable. + /// + /// Typed arrays are only parsed from protocol version 14 onward. A + /// contract deserialized against an earlier platform version cannot + /// carry one: the parsers of those versions refuse an array that is not + /// a byte array. + #[wasm_bindgen(js_name = "documentTypeTypedArrays")] + pub fn document_type_typed_arrays( + &self, + #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: String, + ) -> WasmDppResult { + let document_type = self + .0 + .document_type_optional_for_name(document_type_name.as_str()) + .ok_or_else(|| { + WasmDppError::invalid_argument(format!( + "document type '{document_type_name}' not found in contract" + )) + })?; + + let typed_arrays = typed_arrays_for_document_type(document_type)?; + Ok(JsValue::from(typed_arrays).into()) + } + + /// Every document type that declares at least one typed array property, + /// keyed by document type name. + /// + /// Document types with none are omitted, so an empty `Map` means "this + /// contract declares no typed arrays at all". + #[wasm_bindgen(getter = "documentTypedArrays")] + pub fn document_typed_arrays(&self) -> WasmDppResult { + let map = js_sys::Map::new(); + + for (name, document_type) in self.0.document_types() { + let typed_arrays = typed_arrays_for_document_type(document_type.as_ref())?; + if typed_arrays.length() > 0 { + map.set(&JsValue::from_str(name), &typed_arrays.into()); + } + } + + Ok(JsValue::from(map).into()) + } } impl DataContractWasm { diff --git a/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts new file mode 100644 index 00000000000..42a35da3f52 --- /dev/null +++ b/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts @@ -0,0 +1,153 @@ +/** + * Verifies the typed array metadata surface introduced with protocol + * version 14. + * + * A typed array is `type: 'array'` with an `items` schema naming what every + * element is, where a byte array declares `byteArray: true` instead. It is + * stored inline in the document and validated against the JSON schema like + * any other property. What the JS layer offers is *discovery*: which + * properties of a document type are lists, and of what. + */ +import { expect } from './helpers/chai.ts'; +import { initWasm, wasm } from '../../dist/dpp.compressed.js'; + +let PlatformVersion: typeof wasm.PlatformVersion; + +before(async () => { + await initWasm(); + ({ PlatformVersion } = wasm); +}); + +const ownerId = '11111111111111111111111111111111'; + +/** + * A `charter` with typed arrays of identifiers, strings and byte arrays + * (one nested in an object), next to a `plain` type declaring none. + */ +const schemas = { + charter: { + type: 'object', + properties: { + reasons: { + type: 'array', + minItems: 0, + maxItems: 64, + uniqueItems: true, + items: { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', + }, + position: 0, + }, + labels: { + type: 'array', + maxItems: 5, + items: { type: 'string', minLength: 1, maxLength: 20 }, + position: 1, + }, + team: { + type: 'object', + position: 2, + properties: { + digests: { + type: 'array', + maxItems: 3, + items: { + type: 'array', byteArray: true, minItems: 4, maxItems: 8, + }, + position: 0, + }, + }, + additionalProperties: false, + }, + }, + required: ['reasons'], + additionalProperties: false, + }, + plain: { + type: 'object', + properties: { + message: { type: 'string', position: 0, maxLength: 64 }, + }, + additionalProperties: false, + }, +}; + +function buildContract(contractSchemas: Record, platformVersion = 14) { + return new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: contractSchemas, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(platformVersion), + }); +} + +describe('DataContract: typed arrays (v14)', () => { + describe('documentTypeTypedArrays()', () => { + it('should report every typed array with its element type and bounds', () => { + const contract = buildContract(schemas); + + expect(contract.documentTypeTypedArrays('charter')).to.deep.equal([ + { + path: 'reasons', + items: { type: 'identifier' }, + minItems: 0, + maxItems: 64, + uniqueItems: true, + }, + { + path: 'labels', + items: { type: 'string', minLength: 1, maxLength: 20 }, + maxItems: 5, + uniqueItems: false, + }, + { + path: 'team.digests', + items: { type: 'byteArray', minItems: 4, maxItems: 8 }, + maxItems: 3, + uniqueItems: false, + }, + ]); + }); + + it('should return an empty array for a document type declaring none', () => { + const contract = buildContract(schemas); + + expect(contract.documentTypeTypedArrays('plain')).to.deep.equal([]); + }); + + it('should throw for an unknown document type', () => { + const contract = buildContract(schemas); + + expect(() => contract.documentTypeTypedArrays('doesNotExist')).to.throw(/not found/); + }); + + /** + * The meta-schemas before protocol version 14 only have byte arrays. + */ + it('should refuse a typed array on a pre-v14 contract', () => { + expect(() => buildContract(schemas, 13)).to.throw(); + }); + }); + + describe('documentTypedArrays', () => { + it('should key typed arrays by document type and omit types declaring none', () => { + const contract = buildContract(schemas); + const map = contract.documentTypedArrays as Map; + + expect([...map.keys()]).to.deep.equal(['charter']); + expect(map.get('charter')).to.deep.equal(contract.documentTypeTypedArrays('charter')); + }); + + it('should be empty for a contract declaring no typed arrays at all', () => { + const contract = buildContract({ plain: schemas.plain }); + + expect((contract.documentTypedArrays as Map).size).to.equal(0); + }); + }); +}); From c0f4f496ce44dedb00a4db613e0a2a010363a446 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 03:08:34 +0700 Subject: [PATCH 2/5] feat(dpp)!: convert typed array elements and require maxItems on every parse Takes the parts #4920 did better: - find_identifier_and_binary_paths 1 (selected at protocol version 14) registers a typed array's identifier and byte array elements as `path[]` conversion paths, so a document built from JSON or a value map converts every element. platform-value's path helpers now make a trailing `list[]` or `list[i]` name the members to replace, treat an absent list as nothing to replace, and return an error instead of reaching the `unwrap` behind an inverted bounds check on `list[i]`; the BTreeMap helper learns the `list[]` syntax it never parsed. Drive and drive-abci never call these helpers. - TypedArrayProperty::max_items is a required u16: the parse refuses a typed array without maxItems, with minItems above it or with contentMediaType on the array, on every path. The 1024 cap stays a registration limit under full validation. - Element bounds are covered at document validation, and the meta-schema header and the serialization chapter mention typed arrays. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/documents.md | 4 +- .../serialization/document-serialization.md | 2 +- .../document/v3/document-meta.json | 2 +- .../class_methods/parse_typed_array/v0/mod.rs | 37 ++- .../class_methods/try_from_schema/v3/mod.rs | 30 +-- .../try_from_schema/v3/typed_array_tests.rs | 131 ++++++++++- .../document_type/property/array.rs | 29 ++- .../document_type/property/mod.rs | 16 +- .../find_identifier_and_binary_paths/mod.rs | 6 +- .../v1/mod.rs | 87 +++++++ .../document_type/v0/random_document_type.rs | 2 +- .../btreemap_field_replacement.rs | 219 ++++++++++++++---- packages/rs-platform-value/src/replace.rs | 128 +++++++--- .../dpp_versions/dpp_contract_versions/v6.rs | 2 +- .../rs-platform-version/src/version/v14.rs | 12 +- .../document_type_typed_arrays.rs | 14 +- 16 files changed, 567 insertions(+), 154 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/v1/mod.rs diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 92fc8e693e4..dfee26a0488 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -324,11 +324,11 @@ Up to protocol version 13 a `type: "array"` property had to be a byte array (`by ``` - An element is a scalar: an integer, a number, a string (with `minLength` / `maxLength`), a boolean, a byte array (`byteArray: true`, whose `minItems` / `maxItems` count bytes) or an identifier. Objects and arrays of arrays are refused, and so is `refersTo` on an element for now. -- On the array itself `minItems` and `maxItems` count elements, not bytes. `maxItems` is required and at most `SystemLimits::max_document_array_items` (1024), so a typed array's worst-case size, which fee estimation charges by, stays finite. `uniqueItems: true` refuses a document that repeats an element. +- On the array itself `minItems` and `maxItems` count elements, not bytes. `maxItems` is required, `minItems` may not exceed it and `contentMediaType` belongs on the items; these hold on every parse. Contract registration also caps `maxItems` at `SystemLimits::max_document_array_items` (1024), so a typed array's worst-case size, which fee estimation charges by, stays small. `uniqueItems: true` refuses a document that repeats an element. - A byte array keeps its form exactly: it takes neither `items` nor `uniqueItems`. - 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 (8 bytes for an integer or a number, 1 for a boolean, a varint length and the bytes for a string, a byte array or an 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 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`. 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. diff --git a/book/src/serialization/document-serialization.md b/book/src/serialization/document-serialization.md index 327588762b2..80c16034797 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` | varint element count + each element encoded in sequence | +| `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) | | `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. 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 d8d2ba0b1ce..e777000a516 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 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; 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, the requiredSince property keyword (the contract version a property is required from), and the timeRange index transform, 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 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and 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 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; 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, the requiredSince property keyword (the contract version a property is required from), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements), 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 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and 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": { 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 1e2e0515996..ed6f087cb14 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 @@ -9,8 +9,12 @@ use crate::data_contract::errors::DataContractError; /// Generation 0 parse rules: an array property that does not declare /// `byteArray` is a typed array, whose `items` must be one scalar element -/// schema. `minItems` and `maxItems` count elements and fit a u16; -/// `uniqueItems` defaults to false. +/// schema. `minItems` and `maxItems` count elements and fit a u16; `maxItems` +/// is required and `minItems` may not exceed it; `contentMediaType` belongs on +/// the items; `uniqueItems` defaults to false. These are the shape of the +/// declaration, so they hold on every parse; the cap on `maxItems` is a +/// registration limit, checked by the generation 3 driver under full +/// validation. pub(super) fn parse_typed_array_v0( inner_properties: &BTreeMap, ) -> Result, DataContractError> { @@ -33,10 +37,33 @@ pub(super) fn parse_typed_array_v0( )); }; + if inner_properties.contains_key(property_names::CONTENT_MEDIA_TYPE) { + return Err(DataContractError::InvalidContractStructure( + "contentMediaType belongs on the items of a typed array, not on the array".to_string(), + )); + } + + let item_type = ArrayItemType::try_from(*items)?; + + // Fee estimation sizes the inline list by its bound + let Some(max_items) = inner_properties.get_optional_integer(property_names::MAX_ITEMS)? else { + return Err(DataContractError::InvalidContractStructure( + "a typed array must declare maxItems: its inline encoding is sized by it".to_string(), + )); + }; + let min_items: Option = + inner_properties.get_optional_integer(property_names::MIN_ITEMS)?; + if min_items.is_some_and(|min_items| min_items > max_items) { + return Err(DataContractError::InvalidContractStructure(format!( + "a typed array's minItems may not exceed its maxItems of {max_items}: no document \ + could hold the list" + ))); + } + Ok(Some(DocumentPropertyType::TypedArray(TypedArrayProperty { - item_type: ArrayItemType::try_from(*items)?, - min_items: inner_properties.get_optional_integer(property_names::MIN_ITEMS)?, - max_items: inner_properties.get_optional_integer(property_names::MAX_ITEMS)?, + item_type, + min_items, + max_items, unique_items: inner_properties .get_optional_bool(property_names::UNIQUE_ITEMS)? .unwrap_or_default(), diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 474af1a73f4..6c544c4de88 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -428,10 +428,10 @@ fn try_from_schema_generation_3( Ok(v2) } -/// Every typed array property must declare `maxItems`, at most -/// `SystemLimits::max_document_array_items`, so its worst-case encoded size -/// stays finite. Read off the flattened properties, which reach a typed array -/// nested in an object too. +/// Every typed array property's `maxItems` (which the parse requires) is at +/// most `SystemLimits::max_document_array_items`, so its worst-case encoded +/// size stays small. Read off the flattened properties, which reach a typed +/// array nested in an object too. /// /// Full validation only, like the other registration limits: a stored /// contract was checked when it was registered, and a later protocol version @@ -447,20 +447,14 @@ fn validate_typed_array_max_items( let DocumentPropertyType::TypedArray(typed_array) = &property.property_type else { continue; }; - match typed_array.max_items { - Some(max_items) if max_items <= limit => {} - max_items => { - return Err(consensus_or_protocol_data_contract_error( - DataContractError::InvalidContractStructure(format!( - "typed array property \"{}\" of document type \"{}\" must declare \ - maxItems of at most {}, found {}", - path, - name, - limit, - max_items.map_or_else(|| "none".to_string(), |max| max.to_string()), - )), - )); - } + if typed_array.max_items > limit { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "typed array property \"{}\" of document type \"{}\" declares maxItems \ + {}, above the maximum of {}", + path, name, typed_array.max_items, limit, + )), + )); } } Ok(()) 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 056162b4bb3..6df9333eb57 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 @@ -133,7 +133,7 @@ fn should_parse_a_typed_identifier_array() { DocumentPropertyType::TypedArray(TypedArrayProperty { item_type: ArrayItemType::Identifier, min_items: Some(0), - max_items: Some(64), + max_items: 64, unique_items: true, }) ); @@ -161,7 +161,7 @@ fn should_parse_a_typed_integer_array_with_bounds() { DocumentPropertyType::TypedArray(TypedArrayProperty { item_type: ArrayItemType::Integer, min_items: Some(1), - max_items: Some(10), + max_items: 10, unique_items: false, }) ); @@ -323,6 +323,7 @@ fn should_require_max_items_on_a_typed_array_within_the_system_limit() { let platform_version = PlatformVersion::latest(); let limit = platform_version.system_limits.max_document_array_items; + // maxItems is the shape of the declaration: required on every parse let without_max_items = schema_with_list(platform_value!({ "type": "array", "items": { "type": "boolean" }, @@ -334,7 +335,12 @@ fn should_require_max_items_on_a_typed_array_within_the_system_limit() { true, )); assert_eq!(error.keyword(), "required"); + expect_structure_error( + parse_dispatched(without_max_items, platform_version, false), + "a typed array must declare maxItems", + ); + // The cap is a registration limit: a stored contract is read as declared let over_the_limit = schema_with_list(platform_value!({ "type": "array", "maxItems": u64::from(limit) + 1, @@ -343,12 +349,8 @@ fn should_require_max_items_on_a_typed_array_within_the_system_limit() { })); expect_structure_error( parse_dispatched(over_the_limit.clone(), platform_version, true), - &format!("must declare maxItems of at most {limit}"), + &format!("above the maximum of {limit}"), ); - - // The bound is a registration rule: a stored contract is read as declared - parse_dispatched(without_max_items, platform_version, false) - .expect("the non-validating parse reads the declaration as it is"); parse_dispatched(over_the_limit, platform_version, false) .expect("the non-validating parse reads the declaration as it is"); @@ -361,6 +363,62 @@ fn should_require_max_items_on_a_typed_array_within_the_system_limit() { parse_dispatched(at_the_limit, platform_version, true).expect("maxItems at the limit parses"); } +#[test] +fn should_hold_the_shape_rules_of_a_typed_array_without_the_meta_schema() { + for (list, needle) in [ + ( + platform_value!({ + "type": "array", + "minItems": 5, + "maxItems": 4, + "items": { "type": "integer" }, + "position": 0 + }), + "minItems may not exceed its maxItems", + ), + ( + platform_value!({ + "type": "array", + "maxItems": 4, + "contentMediaType": "application/x.dash.dpp.identifier", + "items": { "type": "integer" }, + "position": 0 + }), + "contentMediaType belongs on the items", + ), + ] { + for full_validation in [true, false] { + expect_structure_error_or_json_schema_error( + parse_dispatched( + schema_with_list(list.clone()), + PlatformVersion::latest(), + full_validation, + ), + full_validation, + needle, + ); + } + } +} + +/// On the validating path the meta-schema may refuse a declaration before the +/// parser sees it; without it the parser has to refuse it itself. +fn expect_structure_error_or_json_schema_error( + result: Result, + full_validation: bool, + needle: &str, +) { + match result { + Err(ProtocolError::ConsensusError(boxed)) + if full_validation + && matches!( + *boxed, + ConsensusError::BasicError(BasicError::JsonSchemaError(_)) + ) => {} + result => expect_structure_error(result, needle), + } +} + #[test] fn should_keep_the_byte_array_form_free_of_items_and_unique_items() { for (keyword, value) in [ @@ -540,7 +598,7 @@ fn should_round_trip_a_contract_with_typed_arrays_through_platform_serialization Some(DocumentPropertyType::TypedArray(TypedArrayProperty { item_type: ArrayItemType::Identifier, min_items: Some(0), - max_items: Some(64), + max_items: 64, unique_items: true, })) ); @@ -596,6 +654,52 @@ fn should_round_trip_a_document_with_typed_arrays_through_serialization() { } } +/// The identifier and byte array elements are conversion paths (`reasons[]`, +/// `digests[]`), so a document built from data carrying base58 strings and +/// plain bytes holds identifiers and bytes, as it does for scalar properties. +#[test] +fn should_convert_the_elements_of_typed_arrays_when_creating_a_document_from_data() { + use crate::data_contract::document_type::methods::DocumentTypeV0Methods; + use crate::document::DocumentV0Getters; + use platform_value::string_encoding::Encoding; + + let platform_version = PlatformVersion::latest(); + let contract = charter_contract(platform_version); + let document_type = contract + .document_type_for_name("charter") + .expect("charter type"); + + assert!(document_type.identifier_paths().contains("reasons[]")); + assert!(document_type.binary_paths().contains("digests[]")); + + let reason = Identifier::new([5; 32]); + let data = Value::Map(vec![ + ( + Value::Text("reasons".to_string()), + Value::Array(vec![Value::Text(reason.to_string(Encoding::Base58))]), + ), + ( + Value::Text("counts".to_string()), + Value::Array(vec![Value::I64(1)]), + ), + ]); + let document = document_type + .create_document_from_data( + data, + Identifier::new([2; 32]), + 1, + 1, + [3; 32], + platform_version, + ) + .expect("the document is created"); + + assert_eq!( + document.properties().get("reasons"), + Some(&Value::Array(vec![Value::Identifier([5; 32])])) + ); +} + #[test] fn should_refuse_a_document_whose_typed_array_breaks_its_schema() { use crate::data_contract::methods::validate_document::DataContractDocumentValidationMethodsV0; @@ -623,6 +727,17 @@ fn should_refuse_a_document_whose_typed_array_breaks_its_schema() { platform_value!([Value::Bytes(vec![1; 31])]), "minItems", ), + // The element schema's own bounds + ( + "labels", + Value::Array(vec![Value::Text("x".repeat(21))]), + "maxLength", + ), + ( + "labels", + Value::Array(vec![Value::Text(String::new())]), + "minLength", + ), ] { let mut properties = charter_properties(); properties 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 076e911145b..7ac50287357 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 @@ -41,11 +41,13 @@ pub enum ArrayItemType { pub struct TypedArrayProperty { /// The type of every element, parsed from `items`. pub item_type: ArrayItemType, - /// `minItems`: the fewest elements a document may hold. + /// `minItems`: the fewest elements a document may hold, never above + /// `max_items`. pub min_items: Option, - /// `maxItems`: the most elements a document may hold. Full validation - /// requires it and caps it at `SystemLimits::max_document_array_items`. - pub max_items: Option, + /// `maxItems`: the most elements a document may hold. Every parse + /// requires it; full validation also caps it at + /// `SystemLimits::max_document_array_items`. + pub max_items: u16, /// `uniqueItems`: whether a document is refused for repeating an element. pub unique_items: bool, } @@ -64,26 +66,21 @@ impl TypedArrayProperty { /// 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 `maxItems` or the element is unbounded. + /// `u16::MAX` when the element is unbounded. pub fn max_encoded_size(&self) -> u16 { - let (Some(max_items), Some(item_max)) = (self.max_items, self.item_type.max_encoded_size()) - else { + let Some(item_max) = self.item_type.max_encoded_size() else { return u16::MAX; }; - let size = (max_items.required_space() as u64) - .saturating_add(u64::from(max_items).saturating_mul(item_max)); + 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) } /// How many elements a random value holds: between `minItems` and - /// `maxItems`, and eight more than `minItems` when `maxItems` is absent. + /// `maxItems`. fn random_items_range(&self) -> (usize, usize) { - let min_items = usize::from(self.min_items.unwrap_or(0)); - let max_items = self - .max_items - .map(usize::from) - .unwrap_or(min_items + 8) - .max(min_items); + let max_items = usize::from(self.max_items); + let min_items = usize::from(self.min_items.unwrap_or(0)).min(max_items); (min_items, max_items) } 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 3f830859cec..b38ab45428c 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 @@ -5164,7 +5164,7 @@ mod tests { DocumentPropertyType::TypedArray(TypedArrayProperty { item_type, min_items: None, - max_items: Some(8), + max_items: 8, unique_items: false, }) } @@ -5292,12 +5292,12 @@ mod tests { }; // Identifiers carry a one-byte length prefix: 33 bytes each - let identifiers = bounded(ArrayItemType::Identifier, Some(2), Some(64)); + 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)); // A string element's maxLength counts characters of up to four bytes - let strings = bounded(ArrayItemType::String(Some(3), Some(40)), None, Some(200)); + let strings = bounded(ArrayItemType::String(Some(3), Some(40)), None, 200); assert_eq!(strings.min_byte_size(pv).unwrap(), Some(1)); assert_eq!( strings.max_byte_size(pv).unwrap(), @@ -5305,18 +5305,12 @@ mod tests { ); // Unbounded, or past what a u16 holds, reports u16::MAX - let unbounded_elements = bounded(ArrayItemType::String(None, None), None, Some(4)); + let unbounded_elements = bounded(ArrayItemType::String(None, None), None, 4); assert_eq!( unbounded_elements.max_byte_size(pv).unwrap(), Some(u16::MAX) ); - let unbounded_count = bounded(ArrayItemType::Boolean, None, None); - assert_eq!(unbounded_count.max_byte_size(pv).unwrap(), Some(u16::MAX)); - let saturated = bounded( - ArrayItemType::String(None, Some(5000)), - Some(1024), - Some(1024), - ); + let saturated = bounded(ArrayItemType::String(None, 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)); } diff --git a/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/mod.rs b/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/mod.rs index ccd1e64fedb..7ab44ba9518 100644 --- a/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/mod.rs @@ -7,6 +7,7 @@ use platform_version::version::dpp_versions::dpp_contract_versions::DocumentType use std::collections::BTreeSet; mod v0; +mod v1; impl DocumentType { pub(in crate::data_contract) fn find_identifier_and_binary_paths( @@ -20,9 +21,12 @@ impl DocumentType { 0 => Ok(DocumentTypeV0::find_identifier_and_binary_paths_v0( properties, )), + 1 => Ok(DocumentTypeV0::find_identifier_and_binary_paths_v1( + properties, + )), version => Err(ProtocolError::UnknownVersionMismatch { method: "find_identifier_and_binary_paths".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } 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 new file mode 100644 index 00000000000..093cf842dbb --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/v1/mod.rs @@ -0,0 +1,87 @@ +//! Generation 1: generation 0 plus the typed array (protocol version 14), +//! whose identifier and byte array elements are registered as `path[]` +//! conversion paths, as the never-produced `Array` variant's always were. + +use crate::data_contract::document_type::array::{ArrayItemType, TypedArrayProperty}; +use crate::data_contract::document_type::property::{DocumentProperty, DocumentPropertyType}; +use crate::data_contract::document_type::v0::DocumentTypeV0; + +use indexmap::IndexMap; +use std::collections::BTreeSet; + +impl DocumentTypeV0 { + #[inline(always)] + pub(super) fn find_identifier_and_binary_paths_v1( + properties: &IndexMap, + ) -> (BTreeSet, BTreeSet) { + Self::find_identifier_and_binary_paths_inner_v1(properties, "") + } + + #[inline(always)] + fn find_identifier_and_binary_paths_inner_v1( + properties: &IndexMap, + current_path: &str, + ) -> (BTreeSet, BTreeSet) { + let mut identifier_paths = BTreeSet::new(); + let mut binary_paths = BTreeSet::new(); + + for (key, value) in properties.iter() { + let new_path = if current_path.is_empty() { + key.clone() + } else { + format!("{}.{}", current_path, key) + }; + + match &value.property_type { + DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_) => { + identifier_paths.insert(new_path); + } + DocumentPropertyType::ByteArray(_) => { + binary_paths.insert(new_path); + } + DocumentPropertyType::Object(inner_properties) => { + let (inner_identifier_paths, inner_binary_paths) = + Self::find_identifier_and_binary_paths_inner_v1( + inner_properties, + &new_path, + ); + + identifier_paths.extend(inner_identifier_paths); + binary_paths.extend(inner_binary_paths); + } + // `path[]` addresses every element of the list + DocumentPropertyType::Array(item_type) + | DocumentPropertyType::TypedArray(TypedArrayProperty { item_type, .. }) => { + let new_path = format!("{}[]", new_path); + match item_type { + ArrayItemType::Identifier => { + identifier_paths.insert(new_path); + } + ArrayItemType::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); + match array_field_type { + ArrayItemType::Identifier => { + identifier_paths.insert(new_path.clone()); + } + ArrayItemType::ByteArray(_, _) => { + binary_paths.insert(new_path.clone()); + } + _ => {} + } + } + } + _ => {} + } + } + + (identifier_paths, binary_paths) + } +} 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 868fd438df7..89826cc211e 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 @@ -405,7 +405,7 @@ impl DocumentTypeV0 { }), [ ("minItems", typed_array.min_items.map(usize::from)), - ("maxItems", typed_array.max_items.map(usize::from)), + ("maxItems", Some(usize::from(typed_array.max_items))), ], ) }, diff --git a/packages/rs-platform-value/src/btreemap_extensions/btreemap_field_replacement.rs b/packages/rs-platform-value/src/btreemap_extensions/btreemap_field_replacement.rs index f93a1b79832..8117909cecd 100644 --- a/packages/rs-platform-value/src/btreemap_extensions/btreemap_field_replacement.rs +++ b/packages/rs-platform-value/src/btreemap_extensions/btreemap_field_replacement.rs @@ -1,3 +1,4 @@ +use crate::inner_value_at_path::is_array_path; use crate::value_map::ValueMapHelper; use crate::{Error, Value}; use std::collections::BTreeMap; @@ -118,43 +119,83 @@ pub trait BTreeValueMapReplacementPathHelper { ) -> Result<(), Error>; } +/// Replaces one value in place with its `replacement_type` form. The +/// fixed-size byte values keep their width; anything else is read as the +/// bytes it holds in whatever encoding it currently has. +fn replace_leaf(value: &mut Value, replacement_type: ReplacementType) -> Result<(), Error> { + match value { + Value::Bytes20(bytes) => { + *value = replacement_type.replace_for_bytes_20(*bytes)?; + } + Value::Bytes32(bytes) => { + *value = replacement_type.replace_for_bytes_32(*bytes)?; + } + Value::Bytes36(bytes) => { + *value = replacement_type.replace_for_bytes_36(*bytes)?; + } + _ => { + let bytes = match replacement_type { + ReplacementType::Identifier | ReplacementType::TextBase58 => { + value.to_identifier_bytes() + } + ReplacementType::BinaryBytes | ReplacementType::TextBase64 => { + value.to_binary_bytes() + } + }?; + *value = replacement_type.replace_for_bytes(bytes)?; + } + } + Ok(()) +} + +/// The members a `list[]` or `list[3]` component addresses in a list value: +/// every element, or the one at the index. +fn list_members(list: &mut Value, index: Option) -> Result, Error> { + let list = list.to_array_mut()?; + match index { + Some(index) => match list.get_mut(index) { + Some(member) => Ok(vec![member]), + None => Err(Error::StructureError(format!( + "element at position {index} in array does not exist" + ))), + }, + None => Ok(list.iter_mut().collect()), + } +} + fn replace_down( mut current_values: Vec<&mut Value>, mut split: Peekable>, replacement_type: ReplacementType, ) -> Result<(), Error> { if let Some(path_component) = split.next() { + let is_last_component = split.peek().is_none(); let next_values = current_values .iter_mut() .map(|current_value| { if current_value.is_map() { let map = current_value.as_map_mut_ref()?; + // `list[]` names the members of a list: the values to + // replace when it ends the path, the values to descend + // into otherwise. An absent list is nothing to replace. + if let Some((list_name, index)) = is_array_path(path_component)? { + let Some(list) = map.get_optional_key_mut(list_name) else { + return Ok(None); + }; + let members = list_members(list, index)?; + if is_last_component { + for member in members { + replace_leaf(member, replacement_type)?; + } + return Ok(None); + } + return Ok(Some(members)); + } let Some(new_value) = map.get_optional_key_mut(path_component) else { return Ok(None); }; - if split.peek().is_none() { - match new_value { - Value::Bytes20(bytes) => { - *new_value = replacement_type.replace_for_bytes_20(*bytes)?; - } - Value::Bytes32(bytes) => { - *new_value = replacement_type.replace_for_bytes_32(*bytes)?; - } - Value::Bytes36(bytes) => { - *new_value = replacement_type.replace_for_bytes_36(*bytes)?; - } - _ => { - let bytes = match replacement_type { - ReplacementType::Identifier | ReplacementType::TextBase58 => { - new_value.to_identifier_bytes() - } - ReplacementType::BinaryBytes | ReplacementType::TextBase64 => { - new_value.to_binary_bytes() - } - }?; - *new_value = replacement_type.replace_for_bytes(bytes)?; - } - } + if is_last_component { + replace_leaf(new_value, replacement_type)?; Ok(None) } else { Ok(Some(vec![new_value])) @@ -189,33 +230,26 @@ impl BTreeValueMapReplacementPathHelper for BTreeMap { let Some(first_path_component) = first else { return Err(Error::PathError("path was empty".to_string())); }; + // `list[]` first: the members of a top-level list + if let Some((list_name, index)) = is_array_path(first_path_component)? { + let Some(list) = self.get_mut(list_name) else { + return Ok(()); + }; + let members = list_members(list, index)?; + if split.len() == 1 { + for member in members { + replace_leaf(member, replacement_type)?; + } + return Ok(()); + } + split.remove(0); + return replace_down(members, split.into_iter().peekable(), replacement_type); + } let Some(current_value) = self.get_mut(first_path_component.to_owned()) else { return Ok(()); }; if split.len() == 1 { - match current_value { - Value::Bytes20(bytes) => { - *current_value = replacement_type.replace_for_bytes_20(*bytes)?; - } - Value::Bytes32(bytes) => { - *current_value = replacement_type.replace_for_bytes_32(*bytes)?; - } - Value::Bytes36(bytes) => { - *current_value = replacement_type.replace_for_bytes_36(*bytes)?; - } - _ => { - let bytes = match replacement_type { - ReplacementType::Identifier | ReplacementType::TextBase58 => { - current_value.to_identifier_bytes() - } - ReplacementType::BinaryBytes | ReplacementType::TextBase64 => { - current_value.to_binary_bytes() - } - }?; - *current_value = replacement_type.replace_for_bytes(bytes)?; - } - } - Ok(()) + replace_leaf(current_value, replacement_type) } else { split.remove(0); let current_values = vec![current_value]; @@ -676,6 +710,99 @@ mod tests { } } + // ----------------------------------------------------------------------- + // replace_at_path: `list[]` names the members of a list + // ----------------------------------------------------------------------- + + fn base58_id(seed: u8) -> Value { + Value::Text( + crate::Identifier::from([seed; 32]).to_string(crate::string_encoding::Encoding::Base58), + ) + } + + #[test] + fn should_replace_the_members_of_a_top_level_list() { + let mut map = BTreeMap::new(); + map.insert( + "ids".to_string(), + Value::Array(vec![base58_id(1), Value::Bytes32([2u8; 32])]), + ); + map.replace_at_path("ids[]", ReplacementType::Identifier) + .unwrap(); + assert_eq!( + map.get("ids").unwrap(), + &Value::Array(vec![ + Value::Identifier([1u8; 32]), + Value::Identifier([2u8; 32]) + ]) + ); + + // One member by index; an index past the end is an error + map.insert( + "more".to_string(), + Value::Array(vec![base58_id(3), Value::Text("keep".into())]), + ); + map.replace_at_path("more[0]", ReplacementType::Identifier) + .unwrap(); + assert_eq!( + map.get("more").unwrap(), + &Value::Array(vec![ + Value::Identifier([3u8; 32]), + Value::Text("keep".into()) + ]) + ); + assert!(map + .replace_at_path("more[2]", ReplacementType::Identifier) + .is_err()); + } + + #[test] + fn should_replace_the_members_of_a_nested_list_and_descend_into_a_list_of_maps() { + let inner = Value::Map(vec![( + Value::Text("ids".into()), + Value::Array(vec![base58_id(4)]), + )]); + let mut map = BTreeMap::new(); + map.insert("meta".to_string(), inner); + map.replace_at_path("meta.ids[]", ReplacementType::Identifier) + .unwrap(); + let Some(Value::Map(meta)) = map.get("meta") else { + panic!("expected the map"); + }; + assert_eq!( + meta.get_optional_key("ids").unwrap(), + &Value::Array(vec![Value::Identifier([4u8; 32])]) + ); + + let item = Value::Map(vec![(Value::Text("id".into()), base58_id(5))]); + map.insert("items".to_string(), Value::Array(vec![item])); + map.replace_at_path("items[].id", ReplacementType::Identifier) + .unwrap(); + let Some(Value::Array(items)) = map.get("items") else { + panic!("expected the list"); + }; + let Value::Map(item) = &items[0] else { + panic!("expected a map member"); + }; + assert_eq!( + item.get_optional_key("id").unwrap(), + &Value::Identifier([5u8; 32]) + ); + } + + #[test] + fn should_treat_an_absent_top_level_list_as_nothing_to_replace() { + let mut map = BTreeMap::new(); + map.insert("a".to_string(), Value::U32(1)); + assert!(map + .replace_at_path("ids[]", ReplacementType::Identifier) + .is_ok()); + // Below a scalar there is no list to look in + assert!(map + .replace_at_path("a.ids[]", ReplacementType::Identifier) + .is_err()); + } + // ----------------------------------------------------------------------- // Error paths // ----------------------------------------------------------------------- diff --git a/packages/rs-platform-value/src/replace.rs b/packages/rs-platform-value/src/replace.rs index c3088f1d470..0238a07bfc7 100644 --- a/packages/rs-platform-value/src/replace.rs +++ b/packages/rs-platform-value/src/replace.rs @@ -3,6 +3,17 @@ use crate::inner_value_at_path::is_array_path; use crate::{Error, ReplacementType, Value, ValueMapHelper}; use std::collections::HashSet; +/// Replaces one value in place with its `replacement_type` form, reading the +/// bytes it holds in whatever encoding it currently has. +fn replace_value(value: &mut Value, replacement_type: ReplacementType) -> Result<(), Error> { + let bytes = match replacement_type { + ReplacementType::Identifier | ReplacementType::TextBase58 => value.to_identifier_bytes()?, + ReplacementType::BinaryBytes | ReplacementType::TextBase64 => value.to_binary_bytes()?, + }; + *value = replacement_type.replace_for_bytes(bytes)?; + Ok(()) +} + impl Value { /// If the `Value` is a `Map`, replaces the value at the path inside the map. /// This is used to set inner values as Identifiers or BinaryData, or from Identifiers or @@ -64,30 +75,44 @@ impl Value { let mut current_values = vec![self]; while let Some(path_component) = split.next() { if let Some((string_part, number_part)) = is_array_path(path_component)? { + let is_last_component = split.peek().is_none(); current_values = current_values .into_iter() - .map(|current_value| { - let map = current_value.to_map_mut()?; - let array_value = map.get_key_mut(string_part)?; - let array = array_value.to_array_mut()?; - if let Some(number_part) = number_part { - if array.len() < number_part { - //this already exists - Ok(vec![array.get_mut(number_part).unwrap()]) - } else { - Err(Error::StructureError(format!( + .filter_map(|current_value| { + let map = match current_value.to_map_mut() { + Ok(map) => map, + Err(err) => return Some(Err(err)), + }; + // An absent list is an absent optional property, as an + // absent key is below: nothing to replace + let array_value = map.get_optional_key_mut(string_part)?; + let array = match array_value.to_array_mut() { + Ok(array) => array, + Err(err) => return Some(Err(err)), + }; + match number_part { + Some(number_part) => match array.get_mut(number_part) { + Some(member) => Some(Ok(vec![member])), + None => Some(Err(Error::StructureError(format!( "element at position {number_part} in array does not exist" - ))) - } - } else { + )))), + }, // we are replacing all members in array - Ok(array.iter_mut().collect()) + None => Some(Ok(array.iter_mut().collect())), } }) .collect::>, Error>>()? .into_iter() .flatten() - .collect() + .collect(); + if is_last_component { + // `list[]` or `list[3]` ends the path: the members are the + // values to replace + for member in current_values { + replace_value(member, replacement_type)?; + } + return Ok(()); + } } else { current_values = current_values .into_iter() @@ -100,23 +125,7 @@ impl Value { let new_value = map.get_optional_key_mut(path_component)?; if split.peek().is_none() { - let bytes_result = match replacement_type { - ReplacementType::Identifier | ReplacementType::TextBase58 => { - new_value.to_identifier_bytes() - } - ReplacementType::BinaryBytes | ReplacementType::TextBase64 => { - new_value.to_binary_bytes() - } - }; - let bytes = match bytes_result { - Ok(bytes) => bytes, - Err(err) => return Some(Err(err)), - }; - *new_value = match replacement_type.replace_for_bytes(bytes) { - Ok(value) => value, - Err(err) => return Some(Err(err)), - }; - return None; + return replace_value(new_value, replacement_type).err().map(Err); } Some(Ok(new_value)) }) @@ -645,6 +654,61 @@ mod tests { ); } + // =============================================================== + // replace_at_path: a path ending in a list names its members + // =============================================================== + + #[test] + fn should_replace_every_member_when_a_list_ends_the_path() { + let b58 = base58_of_32_bytes(7); + let list = Value::Array(vec![Value::Text(b58), Value::Bytes32([8u8; 32])]); + let mut value = Value::Map(vec![(Value::Text("ids".into()), list)]); + + value + .replace_at_path("ids[]", ReplacementType::Identifier) + .unwrap(); + assert_eq!( + value.get_value_at_path("ids").unwrap(), + &Value::Array(vec![ + Value::Identifier([7u8; 32]), + Value::Identifier([8u8; 32]) + ]) + ); + } + + #[test] + fn should_replace_one_member_by_index_and_refuse_an_index_past_the_end() { + let b58 = base58_of_32_bytes(9); + let list = Value::Array(vec![Value::Text("keep".into()), Value::Text(b58)]); + let mut value = Value::Map(vec![(Value::Text("ids".into()), list)]); + + value + .replace_at_path("ids[1]", ReplacementType::Identifier) + .unwrap(); + assert_eq!( + value.get_value_at_path("ids").unwrap(), + &Value::Array(vec![ + Value::Text("keep".into()), + Value::Identifier([9u8; 32]) + ]) + ); + // An error, not the panic the inverted bounds check used to reach + assert!(value + .replace_at_path("ids[2]", ReplacementType::Identifier) + .is_err()); + } + + #[test] + fn should_treat_an_absent_list_as_nothing_to_replace() { + let mut value = Value::Map(vec![(Value::Text("a".into()), Value::U32(42))]); + assert!(value + .replace_at_path("ids[]", ReplacementType::Identifier) + .is_ok()); + assert!(value + .replace_at_path("ids[].inner", ReplacementType::Identifier) + .is_ok()); + } + // =============================================================== // replace_at_path — optional key missing returns Ok // =============================================================== 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 0f5ac405e01..66af601406e 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 @@ -79,7 +79,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { document_type_schema: 3, // changed: v3 document meta-schema — v2 plus the ranked index keywords, and the gate the index parser reads should_add_creator_id: 1, enrich_with_base_schema: 1, - find_identifier_and_binary_paths: 0, + find_identifier_and_binary_paths: 1, // changed: a typed array's identifier and byte array elements are registered as `path[]` conversion paths 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 parse_typed_array: Some(0), // changed: a meta-schema v3 typed array (`type: "array"` with an `items` element schema) parses to `DocumentPropertyType::TypedArray`; None before this version leaves it to the scalar parser, which refuses an array that is not a byte array diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 26c5232e1f5..a9265dbb495 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -562,11 +562,13 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `DocumentPropertyType::TypedArray`). An element is an integer, a /// number, a string, a boolean, a byte array or an identifier; objects /// and arrays of arrays are refused. On the array `minItems` and -/// `maxItems` count elements, `maxItems` is required and at most -/// `SYSTEM_LIMITS_V4.max_document_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`. A byte array +/// `maxItems` count elements, `maxItems` is required (with `minItems` +/// not above it) and at most `SYSTEM_LIMITS_V4.max_document_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 /// now refuses `uniqueItems`, as it refuses `items`. /// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) 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 c40d043f173..5d7dd50eb74 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 @@ -55,11 +55,8 @@ export type DocumentTypedArrayProperty = { items: DocumentTypedArrayItem; /** The fewest elements a document may hold; absent when not declared. */ minItems?: number; - /** - * The most elements a document may hold. Contract registration requires - * it, so it is only absent on a contract parsed without validation. - */ - maxItems?: number; + /** The most elements a document may hold; every typed array declares it. */ + maxItems: number; /** Whether a document repeating an element is refused. */ uniqueItems: boolean; }; @@ -147,7 +144,12 @@ fn typed_array_to_js(path: &str, typed_array: &TypedArrayProperty) -> WasmDppRes path, )?; set_bound(&object, "minItems", typed_array.min_items, path)?; - set_bound(&object, "maxItems", typed_array.max_items, path)?; + set_field( + &object, + "maxItems", + &JsValue::from_f64(f64::from(typed_array.max_items)), + path, + )?; set_field( &object, "uniqueItems", From 7b35e3ceac5bf08eff078f146e9a7cff62bb0f2d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 03:48:01 +0700 Subject: [PATCH 3/5] feat(dpp)!: refuse uniqueItems on identifiers only, not on every byte array On a plain byte array uniqueItems keeps its meaning, no repeated byte, and stays allowed. An identifier (a byte array with the identifier contentMediaType) is one value, so "no repeated byte" would refuse about 87% of real identifiers; meta-schema v3 refuses the keyword there. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/documents.md | 2 +- .../document/v3/document-meta.json | 9 ++-- .../try_from_schema/v3/typed_array_tests.rs | 44 ++++++++++++++----- .../rs-platform-version/src/version/v14.rs | 4 +- 4 files changed, 42 insertions(+), 17 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index dfee26a0488..e8a621da6b9 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -325,7 +325,7 @@ Up to protocol version 13 a `type: "array"` property had to be a byte array (`by - An element is a scalar: an integer, a number, a string (with `minLength` / `maxLength`), a boolean, a byte array (`byteArray: true`, whose `minItems` / `maxItems` count bytes) or an identifier. Objects and arrays of arrays are refused, and so is `refersTo` on an element for now. - On the array itself `minItems` and `maxItems` count elements, not bytes. `maxItems` is required, `minItems` may not exceed it and `contentMediaType` belongs on the items; these hold on every parse. Contract registration also caps `maxItems` at `SystemLimits::max_document_array_items` (1024), so a typed array's worst-case size, which fee estimation charges by, stays small. `uniqueItems: true` refuses a document that repeats an element. -- A byte array keeps its form exactly: it takes neither `items` nor `uniqueItems`. +- 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`. 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 e777000a516..ff7c3d97528 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 @@ -352,6 +352,7 @@ } }, "then": { + "$comment": "an identifier is one value, not a list of bytes: uniqueItems would refuse every identifier that repeats a byte, most of them", "properties": { "byteArray": { "const": true @@ -361,7 +362,8 @@ }, "maxItems": { "const": 32 - } + }, + "uniqueItems": false }, "required": [ "byteArray", @@ -455,7 +457,7 @@ } }, { - "$comment": "an array is a byte array or a typed array. A byte array declares byteArray: true, its minItems and maxItems count bytes, and it takes no items and no uniqueItems. A typed array declares items, the schema of every element, instead; its minItems and maxItems count elements, and maxItems is required", + "$comment": "an array is a byte array or a typed array. A byte array declares byteArray: true, its minItems and maxItems count bytes, and it takes no items (an identifier byte array takes no uniqueItems either, see contentMediaType). A typed array declares items, the schema of every element, instead; its minItems and maxItems count elements, and maxItems is required", "if": { "properties": { "type": { @@ -474,8 +476,7 @@ }, "then": { "properties": { - "items": false, - "uniqueItems": false + "items": false } }, "else": { 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 6df9333eb57..d24ae4e90f8 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 @@ -420,17 +420,32 @@ fn expect_structure_error_or_json_schema_error( } #[test] -fn should_keep_the_byte_array_form_free_of_items_and_unique_items() { - for (keyword, value) in [ - ("uniqueItems", Value::Bool(true)), - ("items", platform_value!({ "type": "integer" })), +fn should_refuse_items_on_a_byte_array_and_unique_items_on_an_identifier() { + let byte_array = platform_value!({ + "type": "array", + "byteArray": true, + "maxItems": 16, + "position": 0 + }); + let identifier = platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }); + + for (mut list, keyword, value) in [ + ( + byte_array.clone(), + "items", + platform_value!({ "type": "integer" }), + ), + // An identifier is one value: "no repeated byte" would refuse most + // identifiers + (identifier.clone(), "uniqueItems", Value::Bool(true)), ] { - let mut list = platform_value!({ - "type": "array", - "byteArray": true, - "maxItems": 16, - "position": 0 - }); list.set_value(keyword, value).expect("keyword applies"); let error = expect_json_schema_error(parse_dispatched( @@ -440,10 +455,17 @@ fn should_keep_the_byte_array_form_free_of_items_and_unique_items() { )); assert!( error.instance_path().ends_with(&format!("/list/{keyword}")), - "{keyword} on a byte array should be refused, got {}", + "{keyword} should be refused, got {}", error.instance_path() ); } + + // On a plain byte array uniqueItems keeps its meaning, no repeated byte + let mut list = byte_array; + list.set_value("uniqueItems", Value::Bool(true)) + .expect("keyword applies"); + parse_dispatched(schema_with_list(list), PlatformVersion::latest(), true) + .expect("uniqueItems on a plain byte array parses"); } #[test] diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index a9265dbb495..f906b9082bf 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -569,7 +569,9 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// 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 -/// now refuses `uniqueItems`, as it refuses `items`. +/// refuses `items`, and an identifier (a byte array with the identifier +/// `contentMediaType`) now refuses `uniqueItems`, which would demand that +/// no byte repeat. /// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by From c506a035f28b0cc4ec07c09667cf1513fbb772da Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 04:03:27 +0700 Subject: [PATCH 4/5] feat(dpp)!: refuse const on typed array elements A list whose elements must all equal one value carries only its length, and a one-value enum restricts an element the same way while a contract update can still widen it (enum values may be added, a const can only be dropped). The element schema keeps enum and drops const; plain properties keep both. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/documents.md | 2 +- .../document/v3/document-meta.json | 3 +- .../try_from_schema/v3/typed_array_tests.rs | 37 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index e8a621da6b9..443ebff4333 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -323,7 +323,7 @@ Up to protocol version 13 a `type: "array"` property had to be a byte array (`by } ``` -- An element is a scalar: an integer, a number, a string (with `minLength` / `maxLength`), a boolean, a byte array (`byteArray: true`, whose `minItems` / `maxItems` count bytes) or an identifier. Objects and arrays of arrays are refused, and so is `refersTo` on an element for now. +- An element is a scalar: an integer, a number, a string (with `minLength` / `maxLength`), a boolean, a byte array (`byteArray: true`, whose `minItems` / `maxItems` count bytes) or an identifier. Objects and arrays of arrays are refused, and so is `refersTo` on an element for now. An element may be limited to allowed values with `enum`; `const` is refused on elements, since a one-value `enum` does the same and a contract update can still widen it. - On the array itself `minItems` and `maxItems` count elements, not bytes. `maxItems` is required, `minItems` may not exceed it and `contentMediaType` belongs on the items; these hold on every parse. Contract registration also caps `maxItems` at `SystemLimits::max_document_array_items` (1024), so a typed array's worst-case size, which fee estimation charges by, stays small. `uniqueItems: true` refuses a document that repeats an element. - 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`. 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 ff7c3d97528..7e9e23d1d3b 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 @@ -525,7 +525,7 @@ ] }, "documentArrayItem": { - "$comment": "The element schema of a typed array: one scalar, an integer, a number, a string, a boolean, or a byte array (byteArray: true), which with the identifier contentMediaType is an identifier. Objects and arrays of arrays are refused. An element carries no position, requiredSince, refersTo or uniqueItems of its own", + "$comment": "The element schema of a typed array: one scalar, an integer, a number, a string, a boolean, or a byte array (byteArray: true), which with the identifier contentMediaType is an identifier. Objects and arrays of arrays are refused. An element carries no position, requiredSince, refersTo, uniqueItems or const of its own (a one-value enum does what const would, and an update can widen it)", "type": "object", "properties": { "$comment": { @@ -576,7 +576,6 @@ "minItems": { "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/minItems" }, - "const": true, "enum": { "type": "array", "items": true, 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 d24ae4e90f8..303fe11aeb9 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 @@ -495,6 +495,43 @@ fn should_refuse_refers_to_on_the_elements_of_a_typed_array() { ); } +/// An element may be limited to allowed values with `enum`, but takes no +/// `const`: a list of one repeated value carries only its length, and a +/// one-value `enum` does the same while an update can still widen it. +#[test] +fn should_accept_enum_and_refuse_const_on_the_elements_of_a_typed_array() { + let list_with_items = |items: Value| { + schema_with_list(platform_value!({ + "type": "array", + "maxItems": 4, + "items": items, + "position": 0 + })) + }; + + parse_dispatched( + list_with_items(platform_value!({ + "type": "string", + "maxLength": 20, + "enum": ["spam", "abuse", "offTopic"] + })), + PlatformVersion::latest(), + true, + ) + .expect("enum on an element parses"); + + let error = expect_json_schema_error(parse_dispatched( + list_with_items(platform_value!({ "type": "integer", "const": 1 })), + PlatformVersion::latest(), + true, + )); + assert!( + error.instance_path().ends_with("/list/items"), + "const on an element should be refused, got {}", + error.instance_path() + ); +} + /// A contract whose `charter` type carries a typed array of every element /// type, `reasons` and `counts` required and the rest optional. fn charter_contract(platform_version: &PlatformVersion) -> DataContract { From e093c533049caa77dbf816427009617d3dc2ed1f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 04:11:33 +0700 Subject: [PATCH 5/5] feat(dpp)!: name the cap max_typed_array_items, refuse examples on elements, pin the parse and ranked rules - SystemLimits::max_document_array_items becomes max_typed_array_items: byte arrays are arrays too, and the cap only bounds typed arrays. - The element schema drops examples, which annotates nothing an element needs, as it dropped const. - Tests next to parse_typed_array (the protocol version 14 dispatch and the v0 shape rules) and one pinning that a ranked index on a typed array is refused as an invalid index property type rather than by the ranked key-length check, which skips it. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/documents.md | 2 +- .../document/v3/document-meta.json | 5 +- .../class_methods/parse_typed_array/mod.rs | 37 ++++++++ .../class_methods/parse_typed_array/v0/mod.rs | 84 +++++++++++++++++++ .../class_methods/try_from_schema/v3/mod.rs | 4 +- .../try_from_schema/v3/typed_array_tests.rs | 68 +++++++++++---- .../document_type/property/array.rs | 2 +- .../src/version/mocks/v2_test.rs | 2 +- .../src/version/system_limits/mod.rs | 12 +-- .../src/version/system_limits/v1.rs | 2 +- .../src/version/system_limits/v2.rs | 2 +- .../src/version/system_limits/v3.rs | 2 +- .../src/version/system_limits/v4.rs | 4 +- .../rs-platform-version/src/version/v14.rs | 2 +- 14 files changed, 193 insertions(+), 35 deletions(-) diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 443ebff4333..107acbf45f2 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -324,7 +324,7 @@ Up to protocol version 13 a `type: "array"` property had to be a byte array (`by ``` - An element is a scalar: an integer, a number, a string (with `minLength` / `maxLength`), a boolean, a byte array (`byteArray: true`, whose `minItems` / `maxItems` count bytes) or an identifier. Objects and arrays of arrays are refused, and so is `refersTo` on an element for now. An element may be limited to allowed values with `enum`; `const` is refused on elements, since a one-value `enum` does the same and a contract update can still widen it. -- On the array itself `minItems` and `maxItems` count elements, not bytes. `maxItems` is required, `minItems` may not exceed it and `contentMediaType` belongs on the items; these hold on every parse. Contract registration also caps `maxItems` at `SystemLimits::max_document_array_items` (1024), so a typed array's worst-case size, which fee estimation charges by, stays small. `uniqueItems: true` refuses a document that repeats an element. +- On the array itself `minItems` and `maxItems` count elements, not bytes. `maxItems` is required, `minItems` may not exceed it and `contentMediaType` belongs on the items; these hold on every parse. Contract registration also caps `maxItems` at `SystemLimits::max_typed_array_items` (1024), so a typed array's worst-case size, which fee estimation charges by, stays small. `uniqueItems: true` refuses a document that repeats an element. - 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`. 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 7e9e23d1d3b..a71689d814e 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 @@ -525,7 +525,7 @@ ] }, "documentArrayItem": { - "$comment": "The element schema of a typed array: one scalar, an integer, a number, a string, a boolean, or a byte array (byteArray: true), which with the identifier contentMediaType is an identifier. Objects and arrays of arrays are refused. An element carries no position, requiredSince, refersTo, uniqueItems or const of its own (a one-value enum does what const would, and an update can widen it)", + "$comment": "The element schema of a typed array: one scalar, an integer, a number, a string, a boolean, or a byte array (byteArray: true), which with the identifier contentMediaType is an identifier. Objects and arrays of arrays are refused. An element carries no position, requiredSince, refersTo, uniqueItems, const or examples of its own (a one-value enum does what const would, and an update can widen it; examples annotate nothing an element needs)", "type": "object", "properties": { "$comment": { @@ -534,9 +534,6 @@ "description": { "$ref": "https://json-schema.org/draft/2020-12/meta/meta-data#/properties/description" }, - "examples": { - "$ref": "https://json-schema.org/draft/2020-12/meta/meta-data#/properties/examples" - }, "type": { "enum": [ "integer", 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 30146733916..fb2e720c7a2 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 @@ -36,3 +36,40 @@ pub(crate) fn parse_typed_array( ))), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::data_contract::document_type::array::{ArrayItemType, TypedArrayProperty}; + use platform_value::platform_value; + + #[test] + fn should_parse_a_typed_array_from_protocol_version_14_and_leave_it_alone_before() { + let schema = platform_value!({ + "type": "array", + "minItems": 1, + "maxItems": 8, + "uniqueItems": true, + "items": { "type": "string", "maxLength": 16 } + }); + let map = schema + .to_btree_ref_string_map() + .expect("the schema is a map"); + + assert_eq!( + parse_typed_array(&map, PlatformVersion::latest()).expect("parses"), + Some(DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: ArrayItemType::String(None, Some(16)), + min_items: Some(1), + max_items: 8, + unique_items: true, + })) + ); + // 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"), + 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 ed6f087cb14..6189af95726 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 @@ -69,3 +69,87 @@ pub(super) fn parse_typed_array_v0( .unwrap_or_default(), }))) } + +#[cfg(test)] +mod tests { + use super::*; + use platform_value::platform_value; + + fn parse(schema: Value) -> Result, DataContractError> { + let map = schema + .to_btree_ref_string_map() + .expect("the schema is a map"); + parse_typed_array_v0(&map) + } + + #[test] + fn should_leave_byte_arrays_and_scalars_to_the_scalar_parser() { + for schema in [ + platform_value!({ "type": "array", "byteArray": true, "maxItems": 32 }), + // The scalar parse refuses it, as it always did + platform_value!({ "type": "array", "byteArray": false }), + platform_value!({ "type": "string", "maxLength": 3 }), + ] { + assert_eq!(parse(schema.clone()).expect("parses"), None, "{schema:?}"); + } + } + + #[test] + fn should_parse_a_typed_array_with_its_bounds() { + assert_eq!( + parse(platform_value!({ + "type": "array", + "minItems": 1, + "maxItems": 4, + "items": { "type": "integer" } + })) + .expect("parses"), + Some(DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: ArrayItemType::Integer, + min_items: Some(1), + max_items: 4, + unique_items: false, + })) + ); + } + + #[test] + fn should_refuse_a_typed_array_missing_items_or_max_items_or_with_a_misplaced_bound() { + for (schema, fragment) in [ + ( + platform_value!({ "type": "array", "maxItems": 2 }), + "items schema", + ), + ( + platform_value!({ "type": "array", "items": { "type": "integer" } }), + "must declare maxItems", + ), + ( + platform_value!({ + "type": "array", + "maxItems": 2, + "contentMediaType": "application/x.dash.dpp.identifier", + "items": { "type": "integer" } + }), + "contentMediaType belongs on the items", + ), + ( + platform_value!({ + "type": "array", + "minItems": 3, + "maxItems": 2, + "items": { "type": "integer" } + }), + "minItems may not exceed its maxItems", + ), + ] { + let error = parse(schema.clone()) + .expect_err("should be refused") + .to_string(); + assert!( + error.contains(fragment), + "{schema:?}: expected {fragment:?}, got {error}" + ); + } + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 6c544c4de88..4e90059f77d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -429,7 +429,7 @@ fn try_from_schema_generation_3( } /// Every typed array property's `maxItems` (which the parse requires) is at -/// most `SystemLimits::max_document_array_items`, so its worst-case encoded +/// most `SystemLimits::max_typed_array_items`, so its worst-case encoded /// size stays small. Read off the flattened properties, which reach a typed /// array nested in an object too. /// @@ -442,7 +442,7 @@ fn validate_typed_array_max_items( name: &str, platform_version: &PlatformVersion, ) -> Result<(), ProtocolError> { - let limit = platform_version.system_limits.max_document_array_items; + let limit = platform_version.system_limits.max_typed_array_items; for (path, property) in document_type.flattened_properties() { let DocumentPropertyType::TypedArray(typed_array) = &property.property_type else { continue; 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 303fe11aeb9..eab1a34f26d 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 @@ -300,6 +300,40 @@ fn should_refuse_an_index_on_a_typed_array_property() { ); } +/// The ranked key-length check runs before the index property-type check and +/// would otherwise size the whole list as a key, so it skips a typed array: +/// the type error is the one reported. +#[test] +fn should_refuse_a_typed_array_in_an_index_with_a_ranked_axis_as_an_invalid_index_type() { + let mut schema = schema_with_list(reasons_list()); + schema + .set_value( + "indices", + platform_value!([{ + "name": "byList", + "properties": [{ "list": "asc" }], + "rangeCountable": true, + "rankedCountable": true + }]), + ) + .expect("indices apply"); + + let result = parse_dispatched(schema, PlatformVersion::latest(), true); + + assert!( + matches!( + &result, + Err(ProtocolError::ConsensusError(boxed)) + if matches!( + **boxed, + ConsensusError::BasicError(BasicError::InvalidIndexPropertyTypeError(_)) + ) + ), + "a ranked index on a typed array should be refused as an invalid index property type, \ + got {result:?}" + ); +} + #[test] fn should_refuse_a_typed_array_below_protocol_version_14_and_accept_it_at_14() { let schema = schema_with_list(reasons_list()); @@ -321,7 +355,7 @@ fn should_refuse_a_typed_array_below_protocol_version_14_and_accept_it_at_14() { #[test] fn should_require_max_items_on_a_typed_array_within_the_system_limit() { let platform_version = PlatformVersion::latest(); - let limit = platform_version.system_limits.max_document_array_items; + let limit = platform_version.system_limits.max_typed_array_items; // maxItems is the shape of the declaration: required on every parse let without_max_items = schema_with_list(platform_value!({ @@ -496,10 +530,11 @@ fn should_refuse_refers_to_on_the_elements_of_a_typed_array() { } /// An element may be limited to allowed values with `enum`, but takes no -/// `const`: a list of one repeated value carries only its length, and a -/// one-value `enum` does the same while an update can still widen it. +/// `const` (a list of one repeated value carries only its length, and a +/// one-value `enum` does the same while an update can still widen it) and no +/// `examples`. #[test] -fn should_accept_enum_and_refuse_const_on_the_elements_of_a_typed_array() { +fn should_accept_enum_and_refuse_const_and_examples_on_the_elements_of_a_typed_array() { let list_with_items = |items: Value| { schema_with_list(platform_value!({ "type": "array", @@ -520,16 +555,21 @@ fn should_accept_enum_and_refuse_const_on_the_elements_of_a_typed_array() { ) .expect("enum on an element parses"); - let error = expect_json_schema_error(parse_dispatched( - list_with_items(platform_value!({ "type": "integer", "const": 1 })), - PlatformVersion::latest(), - true, - )); - assert!( - error.instance_path().ends_with("/list/items"), - "const on an element should be refused, got {}", - error.instance_path() - ); + for items in [ + platform_value!({ "type": "integer", "const": 1 }), + platform_value!({ "type": "integer", "examples": [1] }), + ] { + let error = expect_json_schema_error(parse_dispatched( + list_with_items(items.clone()), + PlatformVersion::latest(), + true, + )); + assert!( + error.instance_path().ends_with("/list/items"), + "{items:?} should be refused on an element, got {}", + error.instance_path() + ); + } } /// A contract whose `charter` type carries a typed array of every element 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 7ac50287357..7889eb085a4 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 @@ -46,7 +46,7 @@ pub struct TypedArrayProperty { pub min_items: Option, /// `maxItems`: the most elements a document may hold. Every parse /// requires it; full validation also caps it at - /// `SystemLimits::max_document_array_items`. + /// `SystemLimits::max_typed_array_items`. pub max_items: u16, /// `uniqueItems`: whether a document is refused for repeating an element. pub unique_items: bool, diff --git a/packages/rs-platform-version/src/version/mocks/v2_test.rs b/packages/rs-platform-version/src/version/mocks/v2_test.rs index abf97b42440..8de55eb3063 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -567,7 +567,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5000, max_document_value_depth: None, - max_document_array_items: 1024, + max_typed_array_items: 1024, max_state_transition_size: 20000, // Is different in this test version, not sure if this was a mistake // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch. Raising it here diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 41bbfe028d6..1d49f8ad883 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -12,12 +12,12 @@ pub struct SystemLimits { /// `None` preserves the behavior of protocol versions that predate this limit. pub max_document_value_depth: Option, /// Maximum `maxItems` a typed array document property (`type: "array"` with an `items` - /// element schema) may declare, enforced when a contract is registered or updated: full - /// validation requires every typed array to declare `maxItems` and refuses one above - /// this. The bound keeps an array's worst-case encoded size, which fee estimation charges - /// by, finite. Read by document type parser generation 3 (protocol version 14), the only - /// generation that parses typed arrays, and never reached before. - pub max_document_array_items: u16, + /// element schema) may declare, enforced when a contract is registered or updated (every + /// parse requires `maxItems`; full validation refuses one above this). The bound keeps an + /// array's worst-case encoded size, which fee estimation charges by, small. Read by + /// document type parser generation 3 (protocol version 14), the only generation that + /// parses typed arrays, and never reached before. + pub max_typed_array_items: u16, /// Max size of a state transition in bytes. /// /// NOTE: This must be equal to the `max-tx-bytes` in the Tenderdash config diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index 877c9fdf45a..d421df2ae63 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -4,7 +4,7 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5120, //5 KiB max_document_value_depth: None, - max_document_array_items: 1024, + max_typed_array_items: 1024, max_state_transition_size: 20480, //20 KiB // TODO: this is currently capped at 1 because the batch state-transition // pipeline has known correctness issues with multi-transition batches: diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index afd5f1758a9..bfc196d05ea 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -10,7 +10,7 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_field_value_size: 5120, //5 KiB // v12 is already active on live networks; the depth limit activates in v13 (see v3). max_document_value_depth: None, - max_document_array_items: 1024, + max_typed_array_items: 1024, max_state_transition_size: 20480, //20 KiB // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch and SYSTEM_LIMITS_V1. diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index 1ebc49b5064..bbbe304298b 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -12,7 +12,7 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { // Use the protocol's existing data-contract schema-depth ceiling as the conservative // instance budget, bounding pre-schema work well above known document requirements. max_document_value_depth: Some(256), - max_document_array_items: 1024, + max_typed_array_items: 1024, max_state_transition_size: 20480, //20 KiB // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch and SYSTEM_LIMITS_V1. diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index b893cdf4ba3..80d0298defe 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -51,7 +51,7 @@ use crate::version::system_limits::SystemLimits; /// moderation team sets its join window and vote window between one day and four weeks, /// and its challenge cool-down between two weeks and three years, all in seconds. /// * Typed array document properties (protocol version 14): a typed array property declares -/// `maxItems`, at most 1024 elements (`max_document_array_items`, backfilled into the +/// `maxItems`, at most 1024 elements (`max_typed_array_items`, backfilled into the /// earlier tables, whose parsers never read it). pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { estimated_contract_max_serialized_size: 16384, @@ -59,7 +59,7 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { // Use the protocol's existing data-contract schema-depth ceiling as the conservative // instance budget, bounding pre-schema work well above known document requirements. max_document_value_depth: Some(256), - max_document_array_items: 1024, // typed array properties (new in v14): full validation requires maxItems and caps it here + max_typed_array_items: 1024, // typed array properties (new in v14): contract registration caps their maxItems here max_state_transition_size: 20480, //20 KiB // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch and SYSTEM_LIMITS_V1. diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index f906b9082bf..0f0e41a107d 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -563,7 +563,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// number, a string, a boolean, a byte array or an identifier; objects /// and arrays of arrays are refused. On the array `minItems` and /// `maxItems` count elements, `maxItems` is required (with `minItems` -/// not above it) and at most `SYSTEM_LIMITS_V4.max_document_array_items` +/// 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