From ec43ac110ef1d3a1b28188f5d571c9f878dfc5d4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 02:18:41 +0700 Subject: [PATCH 1/2] feat(dpp)!: typed scalar arrays in document schemas (PV14) An array property may be declared by an `items` schema instead of `byteArray: true`: a list of one scalar type (integer, number, string, boolean, byte array or identifier) stored inline in the document as a varint element count followed by the elements. `minItems` and `maxItems` count elements, `maxItems` is required and capped by the new `SystemLimits::max_typed_array_items` (1024), `uniqueItems` refuses a repeated element; the JSON schema validator enforces them on every write. The parsed form is the appended `DocumentPropertyType::TypedArray` variant (the enum is append-only, so the bounds could not go on the never-produced `Array` payload), gated by the new `parse_typed_array` schema version slot (`Some(0)` in CONTRACT_VERSIONS_V6). Arrays of objects, arrays of arrays, `refersTo` on the items and an index on an array property are refused at registration. `find_identifier_and_binary_paths` v1 registers identifier and byte array items as `path[]` conversion paths, and platform-value's path replacement now understands a trailing `list[]` (and no longer panics on an out-of-range `list[i]`). Meta-schema v3 is edited in place: the byte-array-only rule becomes a `oneOf` over the byte array and typed forms, with `items` bound to a scalar item definition. wasm-dpp2 exposes the parsed lists through `documentTypeArrayProperties` and `documentArrayProperties`. Swift and Kotlin parsers still read every `type: array` as bytes. Co-Authored-By: Claude Fable 5.1 --- book/src/data-model/data-contracts.md | 29 + .../serialization/document-serialization.md | 2 +- .../document/v3/document-meta.json | 186 ++++- .../try_from_schema/common/mod.rs | 5 +- .../class_methods/try_from_schema/mod.rs | 150 ++-- .../class_methods/try_from_schema/v3/mod.rs | 2 + .../try_from_schema/v3/typed_array_tests.rs | 790 ++++++++++++++++++ .../src/data_contract/document_type/mod.rs | 5 + .../document_type/property/array.rs | 500 ++++++++++- .../document_type/property/mod.rs | 500 +++++++++-- .../find_identifier_and_binary_paths/mod.rs | 6 +- .../v1/mod.rs | 91 ++ .../document_type/v0/random_document_type.rs | 30 + packages/rs-drive/src/query/conditions.rs | 7 +- .../btreemap_field_replacement.rs | 237 +++++- packages/rs-platform-value/src/replace.rs | 122 ++- .../dpp_versions/dpp_contract_versions/mod.rs | 5 + .../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 | 3 +- .../src/version/mocks/v2_test.rs | 1 + .../src/version/system_limits/mod.rs | 17 + .../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 | 1 + .../rs-platform-version/src/version/v14.rs | 15 + .../src/data_contract/document_type_array.rs | 181 ++++ packages/wasm-dpp2/src/data_contract/mod.rs | 2 + packages/wasm-dpp2/src/data_contract/model.rs | 53 ++ 33 files changed, 2733 insertions(+), 215 deletions(-) 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-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/v1/mod.rs create mode 100644 packages/wasm-dpp2/src/data_contract/document_type_array.rs diff --git a/book/src/data-model/data-contracts.md b/book/src/data-model/data-contracts.md index 21c4840a6c0..bedf01797bd 100644 --- a/book/src/data-model/data-contracts.md +++ b/book/src/data-model/data-contracts.md @@ -243,6 +243,35 @@ What happens to data: - **New writes are held to the new schema.** Creates must supply the property; replaces re-supply full content, so replacing a grandfathered document requires the new property and re-stamps the document at the current version — lazy migration, one document at a time. - **Indexes are unaffected** because index additions on update remain banned — a newly added required field cannot be indexed retroactively (there is no backfill). +## Typed Scalar Arrays + +Until protocol v14 the only `type: array` a document schema could declare was a byte array (`byteArray: true`). From v14 (meta-schema v3) an array property may instead declare an `items` schema, which makes it a **typed scalar array**: a list of one scalar type stored inline in the document, exactly like every other property. + +```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 +} +``` + +The rules, enforced by the meta-schema on the validating path and by the parser (`DocumentPropertyType::try_from_value_map`, `parse_typed_array` version 0) on both paths: + +- `items` is any scalar property schema the parser already reads at the top level: an integer, a number, a string with `minLength` / `maxLength`, a boolean, a byte array with `minItems` / `maxItems`, or an identifier. Arrays of objects, arrays of arrays, a `$ref`, `enum` / `const`, and `refersTo` on the items are refused (a reference on identifier items is a separate follow-up; the item schema is parsed whole so it can carry one later). +- `minItems` and `maxItems` on the array count elements, not bytes. `maxItems` is required and may not exceed `SystemLimits::max_typed_array_items` (1024), because the count-prefixed inline encoding is sized for fees by its bound. `uniqueItems: true` refuses a repeated element. +- An array is either a byte array or a typed array, never both: `byteArray` and `items` are mutually exclusive, and `contentMediaType` belongs on the items. +- An array property cannot be indexed. Drive's query conditions give the type no operator, so an index on one is refused at registration with `InvalidIndexPropertyTypeError`, and an indexOnly `entryPayload` cannot name one. + +The parsed form is `DocumentPropertyType::TypedArray(TypedArrayProperty { items, min_items, max_items, unique_items })`, with `items` an `ArrayItemType`. Documents carry the list as a `Value::Array` of the item type's values, validated by the JSON schema validator (`minItems`, `maxItems`, `uniqueItems`, and the item constraints all surface as the usual `JsonSchemaError`), and serialized as a varint element count followed by the elements (see [Document Serialization](../serialization/document-serialization.md)). Fee estimation sizes the property by `maxItems` times the item bound plus the count prefix. Identifier and byte array items are registered as `path[]` conversion paths, so a document built from JSON converts every element of the list. + +The Swift and Kotlin contract parsers still read every `type: array` as a byte array; typed arrays reach those SDKs in a follow-up. + ## Rules and Guidelines **Do:** diff --git a/book/src/serialization/document-serialization.md b/book/src/serialization/document-serialization.md index 327588762b2..5a54a071399 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 scalar array, protocol v14) | varint element count + each element in sequence: integer 8 bytes, number 8 bytes, boolean 1 byte, string / byte array / identifier varint length prefix + bytes (an identifier item 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 a59c93a4da6..a3956b53d03 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 scalar arrays (an array property declared by an items schema instead of byteArray, stored inline as a 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": { @@ -36,6 +36,155 @@ "unevaluatedProperties": false } }, + "typedArrayItemSchema": { + "$comment": "The items schema of a typed scalar array: one scalar type with its bounds, read by the property parser into the array's item type. Objects, arrays that are not byte arrays (arrays of arrays), $ref, enum, const, refersTo (a reference on identifier items is a separate follow-up) and position are not items", + "type": "object", + "properties": { + "type": { + "enum": [ + "string", + "integer", + "number", + "boolean", + "array" + ] + }, + "$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" + }, + "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" + }, + "format": { + "$ref": "https://json-schema.org/draft/2020-12/meta/format-annotation#/properties/format" + }, + "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" + }, + "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": { + "type": "string", + "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 item is a byte array: arrays of arrays are not supported", + "if": { + "properties": { + "type": { + "const": "array" + } + }, + "required": [ + "type" + ] + }, + "then": { + "required": [ + "byteArray" + ] + } + } + ] + }, "documentSchema": { "type": "object", "properties": { @@ -91,6 +240,10 @@ "uniqueItems": { "$ref": "https://json-schema.org/draft/2020-12/meta/validation#/properties/uniqueItems" }, + "items": { + "description": "The item schema of a typed scalar array (protocol version 14): one scalar type with its bounds. The array is stored inline in the document as a count followed by the elements; minItems and maxItems on the array count elements, uniqueItems refuses a repeated element. Only allowed on an array property that is not a byte array", + "$ref": "#/$defs/typedArrayItemSchema" + }, "refersTo": { "type": "object", "properties": { @@ -424,7 +577,7 @@ } }, { - "$comment": "allow only byte arrays", + "$comment": "an array property is either a byte array (byteArray: true, no items) or a typed scalar array (an items schema and maxItems, no byteArray). Typed scalar arrays exist from protocol version 14: minItems and maxItems count elements, uniqueItems refuses a repeated element, and contentMediaType belongs on the items", "if": { "properties": { "type": { @@ -436,11 +589,30 @@ ] }, "then": { - "properties": { - "byteArray": true - }, - "required": [ - "byteArray" + "oneOf": [ + { + "$comment": "byte array", + "properties": { + "byteArray": true, + "items": false + }, + "required": [ + "byteArray" + ] + }, + { + "$comment": "typed scalar array", + "properties": { + "items": true, + "maxItems": true, + "byteArray": false, + "contentMediaType": false + }, + "required": [ + "items", + "maxItems" + ] + } ] } }, 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..e110313788b 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,8 +1319,10 @@ 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: the query + // conditions give them no operator, so a typed array is refused too DocumentPropertyType::Array(_) + | DocumentPropertyType::TypedArray(_) | DocumentPropertyType::Object(_) | DocumentPropertyType::VariableTypeArray(_) => { Err(ProtocolError::ConsensusError(Box::new( @@ -2539,6 +2541,7 @@ pub(super) fn apply_index_only( property.property_type, DocumentPropertyType::Object(_) | DocumentPropertyType::Array(_) + | DocumentPropertyType::TypedArray(_) | DocumentPropertyType::VariableTypeArray(_) ) { return Err(structure_error(format!( 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 38c0c0e3b7c..ec7be76f6ad 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 @@ -153,7 +153,11 @@ fn insert_values( platform_version, )?; - match DocumentPropertyType::try_from_value_map(&inner_properties, &config.into())? { + match DocumentPropertyType::try_from_value_map( + &inner_properties, + &config.into(), + platform_version, + )? { DocumentPropertyType::Object(_) => { if let Some(properties_as_value) = inner_properties.get(property_names::PROPERTIES) { @@ -230,79 +234,81 @@ 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(); - - 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 DocumentPropertyType::try_from_value_map( + &inner_properties, + &config.into(), + platform_version, + )? { + 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..f72bdcfa817 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 @@ -460,6 +460,8 @@ mod keep_history_tests; mod meta_schema_v0_stray_keyword_tests; #[cfg(test)] mod moderators_delete_tests; +#[cfg(test)] +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..286504f410d --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_tests.rs @@ -0,0 +1,790 @@ +//! Typed scalar arrays: `type: array` declared by an `items` schema instead of +//! `byteArray: true`, generation 3 (protocol version 14). +//! +//! The array is one property stored inline in the document as a count +//! followed by its elements. These tests cover the parse (what is admitted, +//! what is refused and why), the document codec, the JSON schema validation +//! of the element count, uniqueness and item type, random document +//! generation within the bounds, the contract's platform serialization, and +//! the protocol version gate. + +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::conversion::json::DataContractJsonConversionMethodsV0; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::array::ArrayItemType; +use crate::data_contract::document_type::methods::DocumentTypeV0Methods; +use crate::data_contract::document_type::random_document::{ + CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, +}; +use crate::data_contract::document_type::{DocumentPropertyType, TypedArrayProperty}; +use crate::data_contract::methods::validate_document::DataContractDocumentValidationMethodsV0; +use crate::data_contract::DataContract; +use crate::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use crate::document::{Document, DocumentV0Getters}; +use crate::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, + PlatformSerializableWithPlatformVersion, +}; +use crate::ProtocolError; +use platform_value::{platform_value, Identifier, Value}; +use platform_version::version::PlatformVersion; +use rand::rngs::StdRng; +use rand::SeedableRng; +use serde_json::{json, Value as JsonValue}; + +const DOCUMENT_TYPE: &str = "charter"; + +/// A list of up to 64 distinct identifiers. +fn identifier_list_schema(position: u32) -> JsonValue { + json!({ + "type": "array", + "minItems": 0, + "maxItems": 64, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": position + }) +} + +/// One to four integers between 0 and 100. +fn bounded_integer_list_schema(position: u32) -> JsonValue { + json!({ + "type": "array", + "minItems": 1, + "maxItems": 4, + "items": { "type": "integer", "minimum": 0, "maximum": 100 }, + "position": position + }) +} + +fn contract_json(properties: JsonValue, required: JsonValue, indices: JsonValue) -> JsonValue { + let mut document_schema = json!({ + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": false + }); + // The meta-schema refuses an empty `indices` list: declare the key only with content + if indices + .as_array() + .is_some_and(|indices| !indices.is_empty()) + { + document_schema["indices"] = indices; + } + json!({ + "$formatVersion": "1", + "id": "9k3RE6kHNTsDmyXFwEPpiFQ3ipXfp5FuXGXpQ1rDHDJb", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + DOCUMENT_TYPE: document_schema + } + }) +} + +/// The contract the document tests share: a required identifier list and a +/// required bounded integer list. +fn lists_contract_json() -> JsonValue { + contract_json( + json!({ + "reasons": identifier_list_schema(0), + "counts": bounded_integer_list_schema(1), + }), + json!(["reasons", "counts"]), + json!([]), + ) +} + +fn parse(json: JsonValue) -> Result { + DataContract::from_json(json, true, PlatformVersion::latest()) +} + +fn parse_property(schema: JsonValue) -> Result { + let contract = parse(contract_json( + json!({ "list": schema }), + json!([]), + json!([]), + ))?; + Ok(contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("the document type parses") + .properties() + .get("list") + .expect("the property parses") + .property_type + .clone()) +} + +fn refusal(schema: JsonValue) -> String { + parse_property(schema) + .expect_err("the schema should be refused") + .to_string() +} + +fn identifier(seed: u8) -> Value { + Value::Identifier([seed; 32]) +} + +fn document_with(contract: &DataContract, properties: Value) -> Document { + contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("the document type parses") + .create_document_from_data( + properties, + Identifier::from([1u8; 32]), + 1, + 1, + [2u8; 32], + PlatformVersion::latest(), + ) + .expect("the document should build") +} + +fn validate(contract: &DataContract, properties: Value) -> Vec { + let document = document_with(contract, properties); + contract + .validate_document(DOCUMENT_TYPE, &document, PlatformVersion::latest()) + .expect("validation should run") + .errors +} + +fn assert_json_schema_refusal(errors: Vec, fragment: &str) { + let error = errors + .first() + .unwrap_or_else(|| panic!("expected a refusal mentioning {fragment:?}")); + assert!( + matches!( + error, + ConsensusError::BasicError(BasicError::JsonSchemaError(_)) + ), + "expected the JSON schema error, got {error:?}" + ); + assert!( + error.to_string().contains(fragment), + "expected a refusal mentioning {fragment:?}, got {error}" + ); +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +#[test] +fn should_parse_a_typed_identifier_array() { + assert_eq!( + parse_property(identifier_list_schema(0)).expect("should parse"), + DocumentPropertyType::TypedArray(TypedArrayProperty { + items: ArrayItemType::Identifier, + min_items: Some(0), + max_items: 64, + unique_items: true, + }) + ); +} + +#[test] +fn should_parse_a_typed_integer_array_with_bounds() { + assert_eq!( + parse_property(bounded_integer_list_schema(0)).expect("should parse"), + DocumentPropertyType::TypedArray(TypedArrayProperty { + items: ArrayItemType::Integer, + min_items: Some(1), + max_items: 4, + unique_items: false, + }) + ); +} + +#[test] +fn should_parse_every_scalar_item_type_with_its_bounds() { + for (items, expected) in [ + (json!({ "type": "number" }), ArrayItemType::Number), + (json!({ "type": "boolean" }), ArrayItemType::Boolean), + ( + json!({ "type": "string", "minLength": 1, "maxLength": 8 }), + ArrayItemType::String(Some(1), Some(8)), + ), + ( + json!({ "type": "array", "byteArray": true, "minItems": 2, "maxItems": 20 }), + ArrayItemType::ByteArray(Some(2), Some(20)), + ), + ] { + let parsed = parse_property(json!({ + "type": "array", + "maxItems": 3, + "items": items, + "position": 0 + })) + .unwrap_or_else(|e| panic!("{expected:?} items should parse: {e}")); + assert_eq!( + parsed, + DocumentPropertyType::TypedArray(TypedArrayProperty { + items: expected, + min_items: None, + max_items: 3, + unique_items: false, + }) + ); + } +} + +#[test] +fn should_keep_parsing_byte_arrays_exactly_as_before() { + assert_eq!( + parse_property(json!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + })) + .expect("should parse"), + DocumentPropertyType::Identifier + ); +} + +#[test] +fn should_refuse_an_array_of_objects() { + let error = refusal(json!({ + "type": "array", + "maxItems": 3, + "items": { + "type": "object", + "properties": { "a": { "type": "string", "position": 0 } }, + "additionalProperties": false + }, + "position": 0 + })); + assert!(error.contains("items"), "unexpected refusal: {error}"); +} + +#[test] +fn should_refuse_an_array_of_arrays() { + let error = refusal(json!({ + "type": "array", + "maxItems": 3, + "items": { "type": "array", "maxItems": 2, "items": { "type": "integer" } }, + "position": 0 + })); + assert!(error.contains("items"), "unexpected refusal: {error}"); +} + +#[test] +fn should_refuse_a_typed_array_without_max_items() { + // The meta-schema refuses it first, as the array form matching neither branch; the + // parser's own "must declare maxItems" message is pinned on the stored path below + let error = refusal(json!({ + "type": "array", + "items": { "type": "integer" }, + "position": 0 + })); + assert!(error.contains("oneOf"), "unexpected refusal: {error}"); +} + +#[test] +fn should_refuse_a_typed_array_that_is_also_a_byte_array() { + let error = refusal(json!({ + "type": "array", + "byteArray": true, + "maxItems": 3, + "items": { "type": "integer" }, + "position": 0 + })); + assert!( + error.contains("items") || error.contains("byteArray"), + "unexpected refusal: {error}" + ); +} + +#[test] +fn should_refuse_refers_to_on_the_items_of_a_typed_array() { + let mut schema = identifier_list_schema(0); + schema["items"]["refersTo"] = json!({ "type": "identity" }); + let error = refusal(schema); + assert!(error.contains("refersTo"), "unexpected refusal: {error}"); +} + +#[test] +fn should_refuse_an_index_on_a_typed_array_property() { + let error = parse(contract_json( + json!({ "reasons": identifier_list_schema(0) }), + json!([]), + json!([{ "name": "byReasons", "properties": [{ "reasons": "asc" }] }]), + )) + .expect_err("an index on an array property should be refused"); + assert!( + matches!( + error, + ProtocolError::ConsensusError(ref boxed) + if matches!(**boxed, ConsensusError::BasicError(BasicError::InvalidIndexPropertyTypeError(_))) + ), + "expected InvalidIndexPropertyTypeError, got {error}" + ); +} + +/// The stored path (`full_validation: false`) does not run the meta-schema, +/// so the parser itself must hold every rule. +#[test] +fn should_hold_the_parser_rules_without_the_meta_schema() { + for (schema, fragment) in [ + ( + json!({ "type": "array", "items": { "type": "integer" }, "position": 0 }), + "maxItems", + ), + ( + json!({ + "type": "array", + "maxItems": 2, + "items": { "type": "object", "properties": {}, "additionalProperties": false }, + "position": 0 + }), + "arrays of objects", + ), + ( + json!({ + "type": "array", + "maxItems": 2, + "items": { "type": "array", "maxItems": 2, "items": { "type": "integer" } }, + "position": 0 + }), + "arrays of arrays", + ), + ( + json!({ + "type": "array", + "maxItems": 2, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "identity" } + }, + "position": 0 + }), + "refersTo", + ), + ( + json!({ + "type": "array", + "maxItems": 2, + "minItems": 3, + "items": { "type": "integer" }, + "position": 0 + }), + "minItems", + ), + ( + json!({ + "type": "array", + "maxItems": 2, + "contentMediaType": "application/x.dash.dpp.identifier", + "items": { "type": "integer" }, + "position": 0 + }), + "contentMediaType", + ), + ] { + let error = DataContract::from_json( + contract_json(json!({ "list": schema }), json!([]), json!([])), + false, + PlatformVersion::latest(), + ) + .expect_err("the stored path should refuse it too") + .to_string(); + assert!( + error.contains(fragment), + "expected a refusal mentioning {fragment:?}, got {error}" + ); + } +} + +#[test] +fn should_refuse_a_typed_array_over_the_element_cap() { + let cap = PlatformVersion::latest() + .system_limits + .max_typed_array_items + .expect("protocol version 14 caps typed arrays"); + let error = refusal(json!({ + "type": "array", + "maxItems": cap + 1, + "items": { "type": "boolean" }, + "position": 0 + })); + assert!( + error.contains("exceeds the maximum"), + "unexpected refusal: {error}" + ); + + parse_property(json!({ + "type": "array", + "maxItems": cap, + "items": { "type": "boolean" }, + "position": 0 + })) + .expect("the cap itself is admitted"); +} + +// --------------------------------------------------------------------------- +// The document codec +// --------------------------------------------------------------------------- + +#[test] +fn should_round_trip_a_document_with_typed_arrays_through_the_codec() { + let contract = parse(lists_contract_json()).expect("should parse"); + let document_type = contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("the document type parses"); + let properties = platform_value!({ + "reasons": [identifier(3), identifier(4)], + "counts": [7i64, 0i64, 100i64], + }); + let document = document_with(&contract, properties); + + let bytes = document + .serialize(document_type, &contract, PlatformVersion::latest()) + .expect("should serialize"); + let restored = Document::from_bytes(&bytes, document_type, PlatformVersion::latest()) + .expect("should deserialize"); + + assert_eq!( + restored.properties().get("reasons"), + Some(&Value::Array(vec![identifier(3), identifier(4)])) + ); + // Integer items always decode as i64 + assert_eq!( + restored.properties().get("counts"), + Some(&Value::Array(vec![ + Value::I64(7), + Value::I64(0), + Value::I64(100) + ])) + ); + assert_eq!(restored.properties(), document.properties()); +} + +#[test] +fn should_round_trip_an_empty_typed_array_and_an_absent_optional_one() { + let contract = parse(contract_json( + json!({ + "reasons": identifier_list_schema(0), + "tags": { + "type": "array", + "maxItems": 3, + "items": { "type": "string", "maxLength": 8 }, + "position": 1 + }, + }), + json!(["reasons"]), + json!([]), + )) + .expect("should parse"); + let document_type = contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("the document type parses"); + let document = document_with(&contract, platform_value!({ "reasons": [] })); + + let bytes = document + .serialize(document_type, &contract, PlatformVersion::latest()) + .expect("should serialize"); + let restored = Document::from_bytes(&bytes, document_type, PlatformVersion::latest()) + .expect("should deserialize"); + + assert_eq!( + restored.properties().get("reasons"), + Some(&Value::Array(vec![])) + ); + assert_eq!(restored.properties().get("tags"), None); +} + +#[test] +fn should_convert_base58_identifier_items_when_creating_a_document_from_data() { + let contract = parse(lists_contract_json()).expect("should parse"); + let id = Identifier::from([9u8; 32]); + let document = document_with( + &contract, + platform_value!({ + "reasons": [id.to_string(platform_value::string_encoding::Encoding::Base58)], + "counts": [1u64], + }), + ); + assert_eq!( + document.properties().get("reasons"), + Some(&Value::Array(vec![identifier(9)])) + ); +} + +// --------------------------------------------------------------------------- +// Document validation +// --------------------------------------------------------------------------- + +#[test] +fn should_accept_a_document_within_the_bounds() { + let contract = parse(lists_contract_json()).expect("should parse"); + let errors = validate( + &contract, + platform_value!({ + "reasons": [identifier(1), identifier(2)], + "counts": [0u64, 50u64, 100u64, 100u64], + }), + ); + assert!(errors.is_empty(), "unexpected refusal: {errors:?}"); +} + +#[test] +fn should_refuse_a_document_over_max_items() { + let contract = parse(lists_contract_json()).expect("should parse"); + let errors = validate( + &contract, + platform_value!({ + "reasons": [], + "counts": [1u64, 2u64, 3u64, 4u64, 5u64], + }), + ); + assert_json_schema_refusal(errors, "counts"); +} + +#[test] +fn should_refuse_a_document_under_min_items() { + let contract = parse(lists_contract_json()).expect("should parse"); + let errors = validate( + &contract, + platform_value!({ + "reasons": [], + "counts": [], + }), + ); + assert_json_schema_refusal(errors, "counts"); +} + +#[test] +fn should_refuse_a_repeated_element_under_unique_items() { + let contract = parse(lists_contract_json()).expect("should parse"); + let errors = validate( + &contract, + platform_value!({ + "reasons": [identifier(1), identifier(1)], + "counts": [1u64], + }), + ); + assert_json_schema_refusal(errors, "reasons"); +} + +#[test] +fn should_refuse_a_wrong_typed_element() { + let contract = parse(lists_contract_json()).expect("should parse"); + let errors = validate( + &contract, + platform_value!({ + "reasons": [], + "counts": ["one"], + }), + ); + assert_json_schema_refusal(errors, "counts"); +} + +#[test] +fn should_refuse_an_element_outside_the_item_bounds() { + let contract = parse(lists_contract_json()).expect("should parse"); + let errors = validate( + &contract, + platform_value!({ + "reasons": [], + "counts": [101u64], + }), + ); + assert_json_schema_refusal(errors, "counts"); +} + +// --------------------------------------------------------------------------- +// Random documents +// --------------------------------------------------------------------------- + +/// A contract whose every typed array a random generator can satisfy: the +/// integer items carry no `minimum` / `maximum`, which the generator does +/// not read for any integer property. +fn random_lists_contract() -> DataContract { + parse(contract_json( + json!({ + "reasons": identifier_list_schema(0), + "counts": { + "type": "array", + "minItems": 1, + "maxItems": 4, + "items": { "type": "integer" }, + "position": 1 + }, + "tags": { + "type": "array", + "minItems": 2, + "maxItems": 5, + "uniqueItems": true, + "items": { "type": "string", "minLength": 3, "maxLength": 8 }, + "position": 2 + }, + "flags": { + "type": "array", + "maxItems": 2, + "uniqueItems": true, + "items": { "type": "boolean" }, + "position": 3 + }, + }), + json!(["reasons", "counts", "tags", "flags"]), + json!([]), + )) + .expect("should parse") +} + +#[test] +fn should_generate_random_documents_that_validate_against_their_own_schema() { + let contract = random_lists_contract(); + let document_type = contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("the document type parses"); + let platform_version = PlatformVersion::latest(); + + for seed in 0..16u64 { + let document = document_type + .random_document(Some(seed), platform_version) + .expect("a random document should build"); + let result = contract + .validate_document(DOCUMENT_TYPE, &document, platform_version) + .expect("validation should run"); + assert!( + result.is_valid(), + "seed {seed}: {:?} refused with {:?}", + document.properties(), + result.errors + ); + } + + for fill_size in [ + DocumentFieldFillSize::MinDocumentFillSize, + DocumentFieldFillSize::MaxDocumentFillSize, + ] { + let mut rng = StdRng::seed_from_u64(42); + let document = document_type + .random_document_with_params( + Identifier::random_with_rng(&mut rng), + platform_value::Bytes32::random_with_rng(&mut rng), + None, + None, + None, + DocumentFieldFillType::FillIfNotRequired, + fill_size, + &mut rng, + platform_version, + ) + .expect("a random document should build"); + let result = contract + .validate_document(DOCUMENT_TYPE, &document, platform_version) + .expect("validation should run"); + assert!( + result.is_valid(), + "{fill_size:?}: {:?} refused with {:?}", + document.properties(), + result.errors + ); + } +} + +// --------------------------------------------------------------------------- +// Fee sizing +// --------------------------------------------------------------------------- + +#[test] +fn should_size_a_typed_array_by_its_bounds() { + let platform_version = PlatformVersion::latest(); + let identifiers = parse_property(identifier_list_schema(0)).expect("should parse"); + // count prefix (1 byte for 0 and for 64) plus 33 bytes an identifier + assert_eq!( + identifiers.min_byte_size(platform_version).unwrap(), + Some(1) + ); + assert_eq!( + identifiers.max_byte_size(platform_version).unwrap(), + Some(1 + 64 * 33) + ); + + let integers = parse_property(bounded_integer_list_schema(0)).expect("should parse"); + assert_eq!( + integers.min_byte_size(platform_version).unwrap(), + Some(1 + 8) + ); + assert_eq!( + integers.max_byte_size(platform_version).unwrap(), + Some(1 + 4 * 8) + ); + + let unbounded_strings = parse_property(json!({ + "type": "array", + "maxItems": 2, + "items": { "type": "string" }, + "position": 0 + })) + .expect("should parse"); + assert_eq!( + unbounded_strings.max_byte_size(platform_version).unwrap(), + Some(u16::MAX) + ); +} + +// --------------------------------------------------------------------------- +// Contract serialization and the version gate +// --------------------------------------------------------------------------- + +#[test] +fn should_round_trip_the_contract_through_platform_serialization() { + let platform_version = PlatformVersion::latest(); + let contract = parse(lists_contract_json()).expect("should parse"); + let bytes = contract + .serialize_to_bytes_with_platform_version(platform_version) + .expect("should serialize"); + let restored = DataContract::versioned_deserialize_untrusted(&bytes, true, platform_version) + .expect("should deserialize"); + assert_eq!( + restored + .document_type_for_name(DOCUMENT_TYPE) + .expect("the document type parses") + .properties(), + contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("the document type parses") + .properties() + ); + assert_eq!(restored, contract); +} + +#[test] +fn should_refuse_a_typed_array_below_protocol_version_14_and_accept_it_at_14() { + let json = lists_contract_json(); + let v13 = PlatformVersion::get(13).expect("protocol version 13 exists"); + + let validating = DataContract::from_json(json.clone(), true, v13) + .expect_err("meta-schema v2 admits only byte arrays"); + assert!( + matches!(validating, ProtocolError::ConsensusError(_)), + "expected a consensus refusal at 13, got {validating}" + ); + let stored = DataContract::from_json(json.clone(), false, v13) + .expect_err("the generation 2 parser admits only byte arrays") + .to_string(); + assert!( + stored.contains("only byte arrays"), + "expected the historical refusal at 13, got {stored}" + ); + + DataContract::from_json(json, true, PlatformVersion::latest()) + .expect("protocol version 14 admits typed arrays"); +} 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 5d19f4f47f3..ed69e2c73cb 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -99,6 +99,11 @@ pub(crate) mod property_names { pub const MIN_LENGTH: &str = "minLength"; pub const MAX_LENGTH: &str = "maxLength"; pub const BYTE_ARRAY: &str = "byteArray"; + /// The item schema of a typed scalar array (`type: array` without + /// `byteArray`). Meta-schema v3+ (protocol version 14). + pub const ITEMS: &str = "items"; + /// Whether a typed scalar array refuses a repeated element. + pub const UNIQUE_ITEMS: &str = "uniqueItems"; pub const CONTENT_MEDIA_TYPE: &str = "contentMediaType"; pub const ENCRYPTION_KEY_REQUIREMENTS: &str = "encryptionKeyReqs"; pub const DECRYPTION_KEY_REQUIREMENTS: &str = "decryptionKeyReqs"; 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..1fcf4b0ab98 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,9 +1,25 @@ +use crate::data_contract::document_type::property::DocumentPropertyType; +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::distributions::{Alphanumeric, Standard}; +use rand::rngs::StdRng; +use rand::Rng; use serde::{Deserialize, Serialize}; - +use std::collections::BTreeMap; +use std::io::BufReader; + +/// The element type of an array property: what one element encodes to inside +/// the array's inline value (a count followed by the elements). Produced by the +/// parser of a typed scalar array (`type: array` with an `items` schema, +/// meta-schema v3) through [`ArrayItemType::try_from_item_schema`]; only +/// scalars are elements, an object or a typed array as an item is refused +/// there. `Date` has no schema spelling (a user property cannot be declared a +/// date) and is kept for the codec's completeness. #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] #[serde(into = "ArrayItemTypeRepr", from = "ArrayItemTypeRepr")] pub enum ArrayItemType { @@ -318,6 +334,296 @@ impl ArrayItemType { } } +/// The `contentMediaType` value that makes a byte array an identifier. +const IDENTIFIER_CONTENT_MEDIA_TYPE: &str = "application/x.dash.dpp.identifier"; + +/// The longest string an item without `maxLength` is sized and generated at, +/// the same default the parent string property type uses. +const DEFAULT_MAX_STRING_LENGTH: u16 = 16383; + +/// Bytes of the varint that prefixes a length or a count. +fn varint_len(value: u16) -> u16 { + value.required_space() as u16 +} + +impl ArrayItemType { + /// Parses the `items` schema of a typed scalar array: one scalar property + /// schema, read with the same keywords the parent parser reads for a + /// top-level property of that type (`minLength` / `maxLength` on a string, + /// `byteArray` with `minItems` / `maxItems` and the identifier + /// `contentMediaType` on a byte array). The bounds a keyword declares on + /// the elements (`minimum`, `pattern`, and so on) stay in the schema and + /// are enforced on the document by the JSON schema validator. + /// + /// Refused as items, with a clear error: an object, an array that is not + /// a byte array (arrays of arrays), a `$ref`, and `refersTo`. A reference + /// on the items of an identifier array is a separate follow-up; this + /// parser reads the whole item schema so it can carry one later. + pub fn try_from_item_schema( + item_schema: &BTreeMap, + ) -> Result { + if item_schema.contains_key(property_names::REFERS_TO) { + return Err(DataContractError::InvalidContractStructure( + "array items may not carry refersTo: references on array items are not \ + supported yet" + .to_string(), + )); + } + if item_schema.contains_key(property_names::REF) { + return Err(DataContractError::InvalidContractStructure( + "array items must be an inline scalar schema, not a $ref".to_string(), + )); + } + let type_name = item_schema.get_str(property_names::TYPE)?; + let item_type = match type_name { + "integer" => ArrayItemType::Integer, + "number" => ArrayItemType::Number, + "boolean" => ArrayItemType::Boolean, + "string" => ArrayItemType::String( + item_schema.get_optional_integer(property_names::MIN_LENGTH)?, + item_schema.get_optional_integer(property_names::MAX_LENGTH)?, + ), + "array" => { + match item_schema.get_optional_bool(property_names::BYTE_ARRAY)? { + Some(true) => {} + Some(false) => { + return Err(DataContractError::InvalidContractStructure( + "byteArray should always be true if defined".to_string(), + )); + } + None => { + return Err(DataContractError::InvalidContractStructure( + "arrays of arrays are not supported: an array item that is an \ + array must be a byte array (byteArray: true)" + .to_string(), + )); + } + } + match item_schema.get_optional_str(property_names::CONTENT_MEDIA_TYPE)? { + Some(IDENTIFIER_CONTENT_MEDIA_TYPE) => ArrayItemType::Identifier, + Some(_) | None => ArrayItemType::ByteArray( + item_schema.get_optional_integer(property_names::MIN_ITEMS)?, + item_schema.get_optional_integer(property_names::MAX_ITEMS)?, + ), + } + } + "object" => { + return Err(DataContractError::InvalidContractStructure( + "arrays of objects are not supported: array items must be a scalar (an \ + integer, a number, a string, a boolean, a byte array or an identifier)" + .to_string(), + )); + } + other => { + return Err(DataContractError::InvalidContractStructure(format!( + "unsupported array item type: {other}" + ))); + } + }; + Ok(item_type) + } +} + +impl TryFrom<&Value> for ArrayItemType { + type Error = DataContractError; + + /// The `items` schema as a value map, see [`ArrayItemType::try_from_item_schema`]. + fn try_from(item_schema: &Value) -> Result { + let item_schema = item_schema.to_btree_ref_string_map()?; + Self::try_from_item_schema(&item_schema) + } +} + +impl ArrayItemType { + /// The schema type name of the item, as the parent property types name + /// themselves. + pub fn name(&self) -> &'static str { + match self { + ArrayItemType::Integer => "integer", + ArrayItemType::Number => "number", + ArrayItemType::String(_, _) => "string", + ArrayItemType::ByteArray(_, _) => "byteArray", + ArrayItemType::Identifier => "identifier", + ArrayItemType::Boolean => "boolean", + ArrayItemType::Date => "date", + } + } + + /// Reads one element, the mirror of [`Self::encode_value_with_size`]. A + /// length the element claims for itself is never trusted to size an + /// allocation: the bytes are read as they arrive and a short input is an + /// error. + pub fn read_value_from(&self, buf: &mut BufReader<&[u8]>) -> Result { + match self { + ArrayItemType::String(_, _) => { + let bytes = DocumentPropertyType::read_varint_value(buf)?; + let string = String::from_utf8(bytes).map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading string array item from serialized document".to_string(), + ) + })?; + Ok(Value::Text(string)) + } + ArrayItemType::Date | ArrayItemType::Number => { + let value = buf.read_f64::().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading number array item from serialized document".to_string(), + ) + })?; + Ok(Value::Float(value)) + } + ArrayItemType::Integer => { + let value = buf.read_i64::().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading integer array item from serialized document".to_string(), + ) + })?; + Ok(Value::I64(value)) + } + ArrayItemType::ByteArray(_, _) => { + let bytes = DocumentPropertyType::read_varint_value(buf)?; + Ok(Value::Bytes(bytes)) + } + ArrayItemType::Identifier => { + let bytes = DocumentPropertyType::read_varint_value(buf)?; + let id: [u8; 32] = bytes.try_into().map_err(|bytes: Vec| { + DataContractError::CorruptedSerialization(format!( + "identifier array item is {} bytes long, expected 32", + bytes.len() + )) + })?; + Ok(Value::Identifier(id)) + } + ArrayItemType::Boolean => { + let value = buf.read_u8().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading boolean array item from serialized document".to_string(), + ) + })?; + Ok(Value::Bool(value != 0)) + } + } + } + + /// The fewest bytes one element encodes to, length prefix included: a + /// string or a byte array at its lower bound (one byte a character), + /// an identifier at its fixed 33. + pub fn min_byte_size(&self) -> u16 { + match self { + ArrayItemType::Integer | ArrayItemType::Number | ArrayItemType::Date => 8, + ArrayItemType::Boolean => 1, + ArrayItemType::String(min_length, _) | ArrayItemType::ByteArray(min_length, _) => { + let min = min_length.map_or(0, |min| u16::try_from(min).unwrap_or(u16::MAX)); + min.saturating_add(varint_len(min)) + } + ArrayItemType::Identifier => 32 + varint_len(32), + } + } + + /// The most bytes one element encodes to, length prefix included: a + /// string at four bytes a character (the factor the parent string type + /// sizes with), a byte array at its upper bound, `u16::MAX` when the + /// element is unbounded. + pub fn max_byte_size(&self) -> u16 { + match self { + ArrayItemType::Integer | ArrayItemType::Number | ArrayItemType::Date => 8, + ArrayItemType::Boolean => 1, + ArrayItemType::String(_, max_length) => match max_length { + None => u16::MAX, + Some(max) => { + let max = u16::try_from(*max).unwrap_or(u16::MAX).saturating_mul(4); + max.saturating_add(varint_len(max)) + } + }, + ArrayItemType::ByteArray(_, max_size) => match max_size { + None => u16::MAX, + Some(max) => { + let max = u16::try_from(*max).unwrap_or(u16::MAX); + max.saturating_add(varint_len(max)) + } + }, + ArrayItemType::Identifier => 32 + varint_len(32), + } + } + + /// The length a random string or byte array element is generated at: + /// anywhere within the bounds. + fn random_length( + rng: &mut StdRng, + min: Option, + max: Option, + default_max: u16, + ) -> usize { + let min = min.unwrap_or(0); + let max = max.unwrap_or(default_max as usize).max(min); + rng.gen_range(min..=max) + } + + /// A random element of any size within the item's bounds. + pub fn random_value(&self, rng: &mut StdRng) -> Value { + match self { + ArrayItemType::String(min, max) => { + let length = Self::random_length(rng, *min, *max, DEFAULT_MAX_STRING_LENGTH); + Self::random_string(rng, length) + } + ArrayItemType::ByteArray(min, max) => { + let length = Self::random_length(rng, *min, *max, u16::MAX); + Self::random_bytes(rng, length) + } + other => other.random_fixed_size_value(rng), + } + } + + /// A random element at the smallest size the item's bounds allow. + pub fn random_min_value(&self, rng: &mut StdRng) -> Value { + match self { + ArrayItemType::String(min, _) => Self::random_string(rng, min.unwrap_or(0)), + ArrayItemType::ByteArray(min, _) => Self::random_bytes(rng, min.unwrap_or(0)), + other => other.random_fixed_size_value(rng), + } + } + + /// A random element at the largest size the item's bounds allow. + pub fn random_max_value(&self, rng: &mut StdRng) -> Value { + match self { + ArrayItemType::String(_, max) => { + Self::random_string(rng, max.unwrap_or(DEFAULT_MAX_STRING_LENGTH as usize)) + } + ArrayItemType::ByteArray(_, max) => { + Self::random_bytes(rng, max.unwrap_or(u16::MAX as usize)) + } + other => other.random_fixed_size_value(rng), + } + } + + fn random_fixed_size_value(&self, rng: &mut StdRng) -> Value { + match self { + ArrayItemType::Integer => Value::I64(rng.gen::()), + ArrayItemType::Number => Value::Float(rng.gen::()), + ArrayItemType::Identifier => Value::Identifier(rng.gen()), + ArrayItemType::Boolean => Value::Bool(rng.gen::()), + ArrayItemType::Date => { + let f: f64 = rng.gen_range(1548910575000.0..1648910575000.0); + Value::Float(f.round() / 1000.0) + } + ArrayItemType::String(_, _) | ArrayItemType::ByteArray(_, _) => self.random_value(rng), + } + } + + fn random_string(rng: &mut StdRng, length: usize) -> Value { + Value::Text( + rng.sample_iter(Alphanumeric) + .take(length) + .map(char::from) + .collect(), + ) + } + + fn random_bytes(rng: &mut StdRng, length: usize) -> Value { + Value::Bytes(rng.sample_iter(Standard).take(length).collect()) + } +} + fn get_field_type_matching_error() -> ProtocolError { ProtocolError::DataContractError(DataContractError::ValueWrongType( "document field type doesn't match document value for array".to_string(), @@ -328,6 +634,198 @@ fn get_field_type_matching_error() -> ProtocolError { #[allow(clippy::approx_constant)] mod tests { use super::*; + use platform_value::platform_value; + use rand::SeedableRng; + + // ----------------------------------------------------------------------- + // try_from_item_schema() tests + // ----------------------------------------------------------------------- + + fn item(schema: Value) -> Result { + ArrayItemType::try_from(&schema) + } + + #[test] + fn should_parse_every_scalar_item_schema() { + assert_eq!( + item(platform_value!({ "type": "integer", "minimum": 0 })).unwrap(), + ArrayItemType::Integer + ); + assert_eq!( + item(platform_value!({ "type": "number" })).unwrap(), + ArrayItemType::Number + ); + assert_eq!( + item(platform_value!({ "type": "boolean" })).unwrap(), + ArrayItemType::Boolean + ); + assert_eq!( + item(platform_value!({ "type": "string", "minLength": 2, "maxLength": 9 })).unwrap(), + ArrayItemType::String(Some(2), Some(9)) + ); + assert_eq!( + item(platform_value!({ "type": "string" })).unwrap(), + ArrayItemType::String(None, None) + ); + assert_eq!( + item(platform_value!({ "type": "array", "byteArray": true, "maxItems": 20 })).unwrap(), + ArrayItemType::ByteArray(None, Some(20)) + ); + assert_eq!( + item(platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + })) + .unwrap(), + ArrayItemType::Identifier + ); + } + + #[test] + fn should_refuse_object_array_ref_and_reference_items() { + for (schema, fragment) in [ + (platform_value!({ "type": "object" }), "arrays of objects"), + ( + platform_value!({ "type": "array", "items": { "type": "integer" } }), + "arrays of arrays", + ), + ( + platform_value!({ "type": "array", "byteArray": false }), + "byteArray should always be true", + ), + (platform_value!({ "$ref": "#/$defs/x" }), "$ref"), + ( + platform_value!({ "type": "integer", "refersTo": { "type": "identity" } }), + "refersTo", + ), + ( + platform_value!({ "type": "date" }), + "unsupported array item type", + ), + (platform_value!({ "minimum": 1 }), "type"), + ] { + let error = item(schema.clone()) + .expect_err("should be refused") + .to_string(); + assert!( + error.contains(fragment), + "{schema:?}: expected {fragment:?}, got {error}" + ); + } + } + + // ----------------------------------------------------------------------- + // read_value_from() mirrors the encoding + // ----------------------------------------------------------------------- + + #[test] + fn should_read_back_every_item_type_it_encodes() { + for (item_type, value) in [ + (ArrayItemType::Integer, Value::I64(-42)), + (ArrayItemType::Number, Value::Float(2.5)), + (ArrayItemType::Date, Value::Float(1648910575.0)), + (ArrayItemType::Boolean, Value::Bool(true)), + (ArrayItemType::Boolean, Value::Bool(false)), + ( + ArrayItemType::String(None, None), + Value::Text("héllo".to_string()), + ), + ( + ArrayItemType::String(None, None), + Value::Text(String::new()), + ), + ( + ArrayItemType::ByteArray(None, None), + Value::Bytes(vec![1, 2, 3]), + ), + (ArrayItemType::ByteArray(None, None), Value::Bytes(vec![])), + (ArrayItemType::Identifier, Value::Identifier([7u8; 32])), + ] { + let bytes = item_type + .encode_value_ref_with_size(&value) + .expect("should encode"); + let mut reader = BufReader::new(bytes.as_slice()); + let read = item_type + .read_value_from(&mut reader) + .expect("should decode"); + assert_eq!(read, value, "{item_type:?}"); + } + } + + #[test] + fn should_refuse_a_short_or_wrong_sized_item() { + let mut short = BufReader::new(&[0u8, 1, 2][..]); + assert!(ArrayItemType::Integer.read_value_from(&mut short).is_err()); + + // a string declaring more bytes than remain + let mut claims_more = BufReader::new(&[5u8, b'a'][..]); + assert!(ArrayItemType::String(None, None) + .read_value_from(&mut claims_more) + .is_err()); + + // an identifier item that is not 32 bytes + let not_an_id = ArrayItemType::ByteArray(None, None) + .encode_value_ref_with_size(&Value::Bytes(vec![1u8; 31])) + .unwrap(); + let mut reader = BufReader::new(not_an_id.as_slice()); + assert!(ArrayItemType::Identifier + .read_value_from(&mut reader) + .is_err()); + } + + // ----------------------------------------------------------------------- + // byte sizes and random values + // ----------------------------------------------------------------------- + + #[test] + fn should_size_items_by_their_encoding() { + assert_eq!(ArrayItemType::Integer.min_byte_size(), 8); + assert_eq!(ArrayItemType::Integer.max_byte_size(), 8); + assert_eq!(ArrayItemType::Boolean.max_byte_size(), 1); + assert_eq!(ArrayItemType::Identifier.min_byte_size(), 33); + assert_eq!(ArrayItemType::Identifier.max_byte_size(), 33); + assert_eq!(ArrayItemType::String(Some(2), Some(9)).min_byte_size(), 3); + assert_eq!(ArrayItemType::String(Some(2), Some(9)).max_byte_size(), 37); + assert_eq!(ArrayItemType::String(None, None).max_byte_size(), u16::MAX); + assert_eq!( + ArrayItemType::ByteArray(None, Some(200)).max_byte_size(), + 202 + ); + assert_eq!( + ArrayItemType::ByteArray(None, None).max_byte_size(), + u16::MAX + ); + } + + #[test] + fn should_generate_random_items_within_their_bounds() { + let mut rng = StdRng::seed_from_u64(1); + for _ in 0..32 { + match ArrayItemType::String(Some(2), Some(5)).random_value(&mut rng) { + Value::Text(text) => assert!((2..=5).contains(&text.chars().count())), + other => panic!("expected text, got {other:?}"), + } + match ArrayItemType::ByteArray(Some(3), Some(3)).random_value(&mut rng) { + Value::Bytes(bytes) => assert_eq!(bytes.len(), 3), + other => panic!("expected bytes, got {other:?}"), + } + } + assert!(matches!( + ArrayItemType::String(Some(2), Some(5)).random_min_value(&mut rng), + Value::Text(text) if text.len() == 2 + )); + assert!(matches!( + ArrayItemType::String(Some(2), Some(5)).random_max_value(&mut rng), + Value::Text(text) if text.len() == 5 + )); + assert!(matches!( + ArrayItemType::Identifier.random_value(&mut rng), + Value::Identifier(_) + )); + } // ----------------------------------------------------------------------- // encode_value_with_size() tests 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 84dcfb4b84a..73e08a0f3d7 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 @@ -88,6 +88,139 @@ pub struct ByteArrayPropertySizes { pub max_size: Option, } +/// A typed scalar array: `type: array` with an `items` schema (meta-schema v3, +/// protocol version 14). Its value is a list of `items` elements stored inline +/// in the document as a varint element count followed by the elements, each +/// encoded by its [`ArrayItemType`]; no per-element index entry, subtree or +/// reference exists. `minItems` and `maxItems` count elements (`maxItems` is +/// required and capped by `SystemLimits::max_typed_array_items`) and +/// `uniqueItems` refuses a repeated element; the JSON schema validator enforces +/// all three on the document, and they are read here to size the property for +/// fee estimation and to generate random documents within the bounds. +#[derive(Debug, PartialEq, Clone, Serialize)] +pub struct TypedArrayProperty { + pub items: ArrayItemType, + pub min_items: Option, + pub max_items: u16, + pub unique_items: bool, +} + +impl TypedArrayProperty { + /// Parses the typed form of an array property, generation 0: the `items` + /// schema through [`ArrayItemType::try_from_item_schema`], `minItems` / + /// `maxItems` as element counts (`maxItems` required and at most + /// `max_items_cap`, `minItems` not above it) and `uniqueItems`. + /// `contentMediaType` belongs on the items and is refused on the array. + /// Every rule holds on the validating and the stored path alike. + pub fn try_from_value_map_v0( + value_map: &BTreeMap, + max_items_cap: u16, + ) -> Result { + let Some(items_value) = value_map.get(property_names::ITEMS) else { + return Err(DataContractError::InvalidContractStructure( + "an array property must be a byte array (byteArray: true) or declare an \ + items schema" + .to_string(), + )); + }; + if value_map.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 items = ArrayItemType::try_from(*items_value)?; + let min_items: Option = value_map.get_optional_integer(property_names::MIN_ITEMS)?; + let Some(max_items) = value_map.get_optional_integer::(property_names::MAX_ITEMS)? + else { + return Err(DataContractError::InvalidContractStructure( + "a typed array must declare maxItems: its inline encoding is sized for fees \ + by its bound" + .to_string(), + )); + }; + if max_items > max_items_cap { + return Err(DataContractError::InvalidContractStructure(format!( + "typed array maxItems {max_items} exceeds the maximum of {max_items_cap} elements" + ))); + } + if min_items.is_some_and(|min| min > max_items) { + return Err(DataContractError::InvalidContractStructure(format!( + "typed array minItems exceeds its maxItems {max_items}" + ))); + } + let unique_items = value_map + .get_optional_bool(property_names::UNIQUE_ITEMS)? + .unwrap_or(false); + Ok(Self { + items, + min_items, + max_items, + unique_items, + }) + } + + /// The fewest bytes the array encodes to: the count prefix plus + /// `minItems` elements at their smallest. + pub fn min_byte_size(&self) -> u16 { + let min_items = self.min_items.unwrap_or(0); + (min_items.required_space() as u16) + .saturating_add(min_items.saturating_mul(self.items.min_byte_size())) + } + + /// The most bytes the array encodes to: the count prefix plus `maxItems` + /// elements at their largest, `u16::MAX` once that saturates (an + /// unbounded item makes the array unbounded). + pub fn max_byte_size(&self) -> u16 { + (self.max_items.required_space() as u16) + .saturating_add(self.max_items.saturating_mul(self.items.max_byte_size())) + } + + /// A random list of any length within the bounds, elements of any size. + pub fn random_value(&self, rng: &mut StdRng) -> Value { + let count = rng.gen_range(self.min_items.unwrap_or(0)..=self.max_items); + self.random_list(rng, count, |items, rng| items.random_value(rng)) + } + + /// A random list of `minItems` elements at their smallest size. + pub fn random_min_value(&self, rng: &mut StdRng) -> Value { + self.random_list(rng, self.min_items.unwrap_or(0), |items, rng| { + items.random_min_value(rng) + }) + } + + /// A random list of `maxItems` elements at their largest size. + pub fn random_max_value(&self, rng: &mut StdRng) -> Value { + self.random_list(rng, self.max_items, |items, rng| { + items.random_max_value(rng) + }) + } + + /// Fills a list of `count` elements. Under `uniqueItems` a repeated + /// element is drawn again, a bounded number of times: a schema whose + /// items cannot supply enough distinct values (three unique booleans) + /// yields a shorter list rather than never returning. + fn random_list( + &self, + rng: &mut StdRng, + count: u16, + mut element: impl FnMut(&ArrayItemType, &mut StdRng) -> Value, + ) -> Value { + let count = usize::from(count); + let mut values: Vec = Vec::with_capacity(count); + let mut attempts_left = count.saturating_mul(8).max(8); + while values.len() < count && attempts_left > 0 { + attempts_left -= 1; + let value = element(&self.items, rng); + if self.unique_items && values.contains(&value) { + continue; + } + values.push(value); + } + Value::Array(values) + } +} + /// What a `contract` reference requires of the contract it points at, beyond its existence. /// /// Declared as `refersTo: { "type": "contract", "contractRequirements": { ... } }`: each key names an @@ -559,9 +692,17 @@ pub enum DocumentPropertyType { Boolean, Date, Object(IndexMap), + /// Never produced by the schema parser: it predates typed arrays and carries no + /// element-count bounds, and the enum is append-only. It shares the inline + /// count-then-elements codec of [`TypedArray`](Self::TypedArray), which is what + /// `type: array` with an `items` schema parses to. Array(ArrayItemType), VariableTypeArray(Vec), IdentifierWithReference(DocumentPropertyReferenceTarget), + /// A typed scalar array (`type: array` with an `items` schema, meta-schema v3, + /// protocol version 14), stored inline as a varint element count followed by + /// the elements. See [`TypedArrayProperty`]. + TypedArray(TypedArrayProperty), } impl DocumentPropertyType { @@ -623,7 +764,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(), } } @@ -655,7 +798,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) @@ -703,6 +846,7 @@ impl DocumentPropertyType { .map(|(_, sub_field)| sub_field.property_type.min_byte_size(platform_version)) .sum(), DocumentPropertyType::Array(_) => Ok(None), + DocumentPropertyType::TypedArray(array) => Ok(Some(array.min_byte_size())), DocumentPropertyType::VariableTypeArray(_) => Ok(None), DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Ok(Some(32)) @@ -750,6 +894,7 @@ impl DocumentPropertyType { .map(|(_, sub_field)| sub_field.property_type.max_byte_size(platform_version)) .sum(), DocumentPropertyType::Array(_) => Ok(None), + DocumentPropertyType::TypedArray(array) => Ok(Some(array.max_byte_size())), DocumentPropertyType::VariableTypeArray(_) => Ok(None), DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Ok(Some(32)) @@ -784,7 +929,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) @@ -821,6 +966,7 @@ impl DocumentPropertyType { DocumentPropertyType::String(_) | DocumentPropertyType::Object(_) | DocumentPropertyType::Array(_) + | DocumentPropertyType::TypedArray(_) | DocumentPropertyType::VariableTypeArray(_) => None, } } @@ -954,6 +1100,7 @@ impl DocumentPropertyType { Value::Map(value_vec) } DocumentPropertyType::Array(_) => Value::Null, + DocumentPropertyType::TypedArray(array) => array.random_value(rng), DocumentPropertyType::VariableTypeArray(_) => Value::Null, DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Value::Identifier(rng.gen()) @@ -1005,6 +1152,7 @@ impl DocumentPropertyType { Value::Map(value_vec) } DocumentPropertyType::Array(_) => Value::Null, + DocumentPropertyType::TypedArray(array) => array.random_min_value(rng), DocumentPropertyType::VariableTypeArray(_) => Value::Null, DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Value::Identifier(rng.gen()) @@ -1056,6 +1204,7 @@ impl DocumentPropertyType { Value::Map(value_vec) } DocumentPropertyType::Array(_) => Value::Null, + DocumentPropertyType::TypedArray(array) => array.random_max_value(rng), DocumentPropertyType::VariableTypeArray(_) => Value::Null, DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { Value::Identifier(rng.gen()) @@ -1312,15 +1461,53 @@ 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) => { + Ok((Some(Self::read_array_from(item_type, buf)?), false)) + } + DocumentPropertyType::TypedArray(array) => { + Ok((Some(Self::read_array_from(&array.items, buf)?), false)) + } DocumentPropertyType::VariableTypeArray(_) => Err(DataContractError::Unsupported( "serialization of variable type arrays not yet supported".to_string(), )), } } + /// Reads an inline array, the mirror of [`Self::encode_array_with_size`]: + /// a varint element count, then that many elements. The count comes from + /// the serialized document and never sizes an allocation: the list grows + /// as elements arrive, and a count the input cannot supply ends in the + /// element reader's short-input error. + fn read_array_from( + item_type: &ArrayItemType, + buf: &mut BufReader<&[u8]>, + ) -> Result { + let count: usize = buf.read_varint().map_err(|_| { + DataContractError::CorruptedSerialization( + "error reading varint of array element count".to_string(), + ) + })?; + let mut values = Vec::new(); + for _ in 0..count { + values.push(item_type.read_value_from(buf)?); + } + Ok(Value::Array(values)) + } + + /// Encodes an inline array: a varint element count, then each element by + /// its [`ArrayItemType`]. + fn encode_array_with_size( + item_type: &ArrayItemType, + array: &[Value], + ) -> Result, ProtocolError> { + let mut r_vec = array.len().encode_var_vec(); + for value in array { + let mut serialized_value = item_type.encode_value_ref_with_size(value)?; + r_vec.append(&mut serialized_value); + } + Ok(r_vec) + } + pub fn encode_value_with_size( &self, value: Value, @@ -1535,6 +1722,13 @@ impl DocumentPropertyType { Err(get_field_type_matching_error(&value).into()) } } + DocumentPropertyType::TypedArray(array) => { + if let Value::Array(values) = &value { + Self::encode_array_with_size(&array.items, values) + } else { + Err(get_field_type_matching_error(&value).into()) + } + } DocumentPropertyType::VariableTypeArray(_) => Err(ProtocolError::DataContractError( DataContractError::Unsupported( "serialization of variable type arrays not yet supported".to_string(), @@ -1677,15 +1871,14 @@ impl DocumentPropertyType { } DocumentPropertyType::Array(array_field_type) => { if let Value::Array(array) = value { - let mut r_vec = array.len().encode_var_vec(); - - array.iter().try_for_each(|value| { - let mut serialized_value = - array_field_type.encode_value_ref_with_size(value)?; - r_vec.append(&mut serialized_value); - Ok::<(), ProtocolError>(()) - })?; - Ok(r_vec) + Self::encode_array_with_size(array_field_type, array) + } else { + Err(get_field_type_matching_error(value).into()) + } + } + DocumentPropertyType::TypedArray(array) => { + if let Value::Array(values) = value { + Self::encode_array_with_size(&array.items, values) } else { Err(get_field_type_matching_error(value).into()) } @@ -1786,13 +1979,13 @@ 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(), - ), - )) - } + DocumentPropertyType::Array(_) + | DocumentPropertyType::TypedArray(_) + | DocumentPropertyType::VariableTypeArray(_) => Err(ProtocolError::DataContractError( + DataContractError::EncodingDataStructureNotSupported( + "we should never try encoding an array".to_string(), + ), + )), } } @@ -1909,13 +2102,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::TypedArray(_) + | DocumentPropertyType::VariableTypeArray(_) => Err(ProtocolError::DataContractError( + DataContractError::EncodingDataStructureNotSupported( + "we should never try decoding an array".to_string(), + ), + )), } } @@ -2039,7 +2232,9 @@ impl DocumentPropertyType { "we should never try encoding an object".to_string(), )) } - DocumentPropertyType::Array(_) | DocumentPropertyType::VariableTypeArray(_) => { + DocumentPropertyType::Array(_) + | DocumentPropertyType::TypedArray(_) + | DocumentPropertyType::VariableTypeArray(_) => { Err(DataContractError::EncodingDataStructureNotSupported( "we should never try encoding an array".to_string(), )) @@ -2908,6 +3103,15 @@ impl DocumentPropertyType { } } + // A typed array: every element is sanitized to its item type + (DocumentPropertyType::TypedArray(array), Value::Array(_)) => { + if let Value::Array(items) = value { + for item in items.iter_mut() { + array.items.sanitize_value_mut(item); + } + } + } + // Handle VariableTypeArray - each item can have a different type (DocumentPropertyType::VariableTypeArray(item_types), Value::Array(_)) => { if let Value::Array(items) = value { @@ -2922,9 +3126,15 @@ impl DocumentPropertyType { } } + /// Parses one property schema into its type. An array is a byte array + /// when it declares `byteArray: true`; otherwise, from the version that + /// parses typed arrays (`parse_typed_array`, protocol version 14), it is a + /// typed scalar array declared by its `items` schema, and before that + /// version it is refused exactly as it always was. pub fn try_from_value_map( value_map: &BTreeMap, options: &DocumentPropertyTypeParsingOptions, + platform_version: &PlatformVersion, ) -> Result { let type_value = value_map.get_str(property_names::TYPE)?; @@ -2941,14 +3151,39 @@ impl DocumentPropertyType { max_length: value_map.get_optional_integer(property_names::MAX_LENGTH)?, }), "array" => { - // Only handling bytearrays for v1 - // Return an error if it is not a byte array let Some(is_byte_array) = value_map.get_optional_bool(property_names::BYTE_ARRAY)? else { - return Err(DataContractError::InvalidContractStructure( - "only byte arrays are supported now".to_string(), - )); + // Not a byte array: a typed scalar array where the version + // parses one, the historical refusal before that. + return match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .parse_typed_array + { + None => Err(DataContractError::InvalidContractStructure( + "only byte arrays are supported now".to_string(), + )), + Some(0) => { + let max_items_cap = platform_version + .system_limits + .max_typed_array_items + .ok_or_else(|| { + DataContractError::Unsupported( + "typed arrays have no element cap at this protocol \ + version" + .to_string(), + ) + })?; + TypedArrayProperty::try_from_value_map_v0(value_map, max_items_cap) + .map(DocumentPropertyType::TypedArray) + } + Some(version) => Err(DataContractError::Unsupported(format!( + "parse_typed_array version {version} is not supported" + ))), + }; }; if !is_byte_array { @@ -4964,15 +5199,28 @@ mod tests { } #[test] - fn test_read_optionally_from_array_returns_error() { + fn test_read_optionally_from_array_short_element_returns_error() { use std::io::BufReader; let prop = DocumentPropertyType::Array(ArrayItemType::Integer); + // one element declared, 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()); } + #[test] + fn test_read_optionally_from_array_mirrors_its_encoding() { + use std::io::BufReader; + let prop = DocumentPropertyType::Array(ArrayItemType::Integer); + let value = Value::Array(vec![Value::I64(-1), Value::I64(7)]); + let bytes = prop.encode_value_ref_with_size(&value, true).unwrap(); + let mut reader = BufReader::new(bytes.as_slice()); + let (read, finished) = prop.read_optionally_from(&mut reader, true).unwrap(); + assert_eq!(read, Some(value)); + assert!(!finished); + } + #[test] fn test_read_optionally_from_variable_type_array_returns_error() { use std::io::BufReader; @@ -5133,7 +5381,9 @@ mod tests { map.insert("minLength".to_string(), &min_val); map.insert("maxLength".to_string(), &max_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); assert_eq!( result, DocumentPropertyType::String(StringPropertySizes { @@ -5149,7 +5399,9 @@ mod tests { let mut map = BTreeMap::new(); map.insert("type".to_string(), &type_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); assert_eq!(result, DocumentPropertyType::Boolean); } @@ -5159,7 +5411,9 @@ mod tests { let mut map = BTreeMap::new(); map.insert("type".to_string(), &type_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); assert_eq!(result, DocumentPropertyType::F64); } @@ -5175,7 +5429,9 @@ mod tests { let options = DocumentPropertyTypeParsingOptions { sized_integer_types: true, }; - let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); assert_eq!(result, DocumentPropertyType::U8); } @@ -5187,7 +5443,9 @@ mod tests { let options = DocumentPropertyTypeParsingOptions { sized_integer_types: false, }; - let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); assert_eq!(result, DocumentPropertyType::I64); } @@ -5197,10 +5455,118 @@ mod tests { let mut map = BTreeMap::new(); map.insert("type".to_string(), &type_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = DocumentPropertyType::try_from_value_map(&map, &options); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()); assert!(result.is_err()); } + #[test] + fn should_parse_a_typed_array_from_its_items_schema() { + let schema = platform_value::platform_value!({ + "type": "array", + "minItems": 1, + "maxItems": 8, + "uniqueItems": true, + "items": { "type": "string", "maxLength": 16 } + }); + let map = schema.to_btree_ref_string_map().unwrap(); + let options = DocumentPropertyTypeParsingOptions::default(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); + assert_eq!( + result, + DocumentPropertyType::TypedArray(TypedArrayProperty { + items: ArrayItemType::String(None, Some(16)), + min_items: Some(1), + max_items: 8, + unique_items: true, + }) + ); + } + + #[test] + fn should_refuse_a_typed_array_where_the_version_does_not_parse_one() { + let schema = platform_value::platform_value!({ + "type": "array", + "maxItems": 8, + "items": { "type": "string" } + }); + let map = schema.to_btree_ref_string_map().unwrap(); + let options = DocumentPropertyTypeParsingOptions::default(); + let v13 = PlatformVersion::get(13).unwrap(); + let error = DocumentPropertyType::try_from_value_map(&map, &options, v13) + .expect_err("protocol version 13 admits only byte arrays") + .to_string(); + assert!(error.contains("only byte arrays"), "{error}"); + } + + #[test] + fn should_round_trip_a_typed_array_value_through_the_codec() { + use std::io::BufReader; + let prop = DocumentPropertyType::TypedArray(TypedArrayProperty { + items: ArrayItemType::Identifier, + min_items: None, + max_items: 4, + unique_items: false, + }); + let value = Value::Array(vec![ + Value::Identifier([1u8; 32]), + Value::Identifier([2u8; 32]), + ]); + let bytes = prop.encode_value_ref_with_size(&value, true).unwrap(); + // count, then two length-prefixed identifiers + assert_eq!(bytes.len(), 1 + 2 * 33); + assert_eq!( + bytes, + prop.encode_value_with_size(value.clone(), true).unwrap() + ); + let mut reader = BufReader::new(bytes.as_slice()); + let (read, finished) = prop.read_optionally_from(&mut reader, true).unwrap(); + assert_eq!(read, Some(value)); + assert!(!finished); + + // the optional form carries the presence byte the caller writes, then the same bytes + let mut optional = vec![1u8]; + optional.extend_from_slice(&bytes); + let mut reader = BufReader::new(optional.as_slice()); + let (read, _) = prop.read_optionally_from(&mut reader, false).unwrap(); + assert!(read.is_some()); + + // a value that is not a list is refused + assert!(prop + .encode_value_ref_with_size(&Value::Text("no".to_string()), true) + .is_err()); + } + + #[test] + fn should_generate_typed_array_values_within_the_bounds() { + use rand::SeedableRng; + let prop = DocumentPropertyType::TypedArray(TypedArrayProperty { + items: ArrayItemType::Boolean, + min_items: Some(1), + max_items: 3, + unique_items: true, + }); + let mut rng = StdRng::seed_from_u64(7); + for _ in 0..16 { + let Value::Array(values) = prop.random_value(&mut rng) else { + panic!("expected a list"); + }; + assert!((1..=2).contains(&values.len()), "{values:?}"); + assert_ne!(values.first(), values.get(1)); + } + let Value::Array(min) = prop.random_sub_filled_value(&mut rng) else { + panic!("expected a list"); + }; + assert_eq!(min.len(), 1); + // three unique booleans cannot exist: the max fill stops at two + let Value::Array(max) = prop.random_filled_value(&mut rng) else { + panic!("expected a list"); + }; + assert_eq!(max.len(), 2); + } + #[test] fn test_try_from_value_map_array_byte_array_identifier() { let type_val = Value::Text("array".to_string()); @@ -5211,7 +5577,9 @@ mod tests { map.insert("byteArray".to_string(), &byte_array_val); map.insert("contentMediaType".to_string(), &media_type_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); assert_eq!(result, DocumentPropertyType::Identifier); } @@ -5227,7 +5595,9 @@ mod tests { map.insert("minItems".to_string(), &min_items_val); map.insert("maxItems".to_string(), &max_items_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); assert_eq!( result, DocumentPropertyType::ByteArray(ByteArrayPropertySizes { @@ -5245,7 +5615,8 @@ mod tests { map.insert("type".to_string(), &type_val); map.insert("byteArray".to_string(), &byte_array_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = DocumentPropertyType::try_from_value_map(&map, &options); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()); assert!(result.is_err()); } @@ -5255,7 +5626,8 @@ mod tests { let mut map = BTreeMap::new(); map.insert("type".to_string(), &type_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = DocumentPropertyType::try_from_value_map(&map, &options); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()); assert!(result.is_err()); } @@ -7213,7 +7585,9 @@ mod tests { let mut map = BTreeMap::new(); map.insert("type".to_string(), &type_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); assert_eq!( result, DocumentPropertyType::String(StringPropertySizes { @@ -7229,7 +7603,9 @@ mod tests { let mut map = BTreeMap::new(); map.insert("type".to_string(), &type_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); assert!(matches!(result, DocumentPropertyType::Object(_))); } @@ -7244,7 +7620,9 @@ mod tests { let options = DocumentPropertyTypeParsingOptions { sized_integer_types: true, }; - let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); assert_eq!(result, DocumentPropertyType::U64); } @@ -7259,7 +7637,9 @@ mod tests { let options = DocumentPropertyTypeParsingOptions { sized_integer_types: true, }; - let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); assert_eq!(result, DocumentPropertyType::I64); } @@ -7274,7 +7654,9 @@ mod tests { let options = DocumentPropertyTypeParsingOptions { sized_integer_types: true, }; - let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); assert_eq!(result, DocumentPropertyType::U8); } @@ -7287,7 +7669,9 @@ mod tests { let options = DocumentPropertyTypeParsingOptions { sized_integer_types: true, }; - let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); assert_eq!(result, DocumentPropertyType::I64); } @@ -7302,7 +7686,9 @@ mod tests { let options = DocumentPropertyTypeParsingOptions { sized_integer_types: true, }; - let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); // min=0, max=255 => U8 assert_eq!(result, DocumentPropertyType::U8); } @@ -7318,7 +7704,9 @@ mod tests { let options = DocumentPropertyTypeParsingOptions { sized_integer_types: true, }; - let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); // 300 => U16 assert_eq!(result, DocumentPropertyType::U16); } @@ -7334,7 +7722,9 @@ mod tests { map.insert("byteArray".to_string(), &byte_array_val); map.insert("contentMediaType".to_string(), &media_type_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); + let result = + DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) + .unwrap(); assert!(matches!(result, DocumentPropertyType::ByteArray(_))); } 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..7c8c4ba04f4 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/schema/find_identifier_and_binary_paths/v1/mod.rs @@ -0,0 +1,91 @@ +//! Generation 1: generation 0 plus the typed scalar array (protocol version +//! 14), whose identifier and byte array items are registered as `path[]` +//! conversion paths exactly as the never-produced `Array` variant's were. +use crate::data_contract::document_type::array::ArrayItemType; +use crate::data_contract::document_type::property::{ + DocumentProperty, DocumentPropertyType, TypedArrayProperty, +}; +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); + } + // A typed array of identifiers or byte arrays converts every + // element: `path[]` addresses all members of the list. + DocumentPropertyType::Array(item_type) + | DocumentPropertyType::TypedArray(TypedArrayProperty { + items: item_type, .. + }) => { + let new_path = format!("{}[]", new_path); + match item_type { + ArrayItemType::Identifier => { + identifier_paths.insert(new_path.clone()); + } + ArrayItemType::ByteArray(_, _) => { + binary_paths.insert(new_path.clone()); + } + _ => {} + } + } + 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 72164db4da9..955542977bb 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,36 @@ impl DocumentTypeV0 { "byteArray": true, }) }, + DocumentPropertyType::TypedArray(array) => { + let items_schema = match &array.items { + ArrayItemType::String(min, max) => json!({"type": "string", "minLength": min, "maxLength": max}), + ArrayItemType::Integer => json!({"type": "integer"}), + ArrayItemType::Number => json!({"type": "number"}), + ArrayItemType::ByteArray(min, max) => { + 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"}), + ArrayItemType::Date => json!({"type": "number"}), + }; + + let mut schema = json!({ + "type": "array", + "items": items_schema, + "maxItems": array.max_items, + "uniqueItems": array.unique_items, + }); + if let Some(min_items) = array.min_items { + schema["minItems"] = json!(min_items); + } + schema + }, DocumentPropertyType::VariableTypeArray(types) => { let types_schema = types.iter().map(|t| { match t { diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index 7d4dfd71591..760f3e785d7 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::TypedArray(_) | T::VariableTypeArray(_) => 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::TypedArray(_) | T::VariableTypeArray(_) => { + false + } }; if !ok { return QuerySyntaxSimpleValidationResult::new_with_error( @@ -1462,6 +1464,7 @@ pub fn allowed_ops_for_type(property_type: &DocumentPropertyType) -> &'static [W DocumentPropertyType::Boolean => &[Equal], DocumentPropertyType::Object(_) | DocumentPropertyType::Array(_) + | DocumentPropertyType::TypedArray(_) | DocumentPropertyType::VariableTypeArray(_) => &[], } } 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..3eaaac78521 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 @@ -105,6 +105,8 @@ impl ReplacementType { } } +use crate::inner_value_at_path::is_array_path; + pub trait BTreeValueMapReplacementPathHelper { fn replace_at_path( &mut self, @@ -118,43 +120,89 @@ 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 under `map`: every +/// element, or the one at the index. `None` when the map has no such list, +/// which is an absent optional property and nothing to replace. +fn array_members<'a>( + map: &'a mut Vec<(Value, Value)>, + list_name: &str, + index: Option, +) -> Result>, Error> { + let Some(array_value) = map.get_optional_key_mut(list_name) else { + return Ok(None); + }; + let array = array_value.to_array_mut()?; + match index { + Some(index) => match array.get_mut(index) { + Some(member) => Ok(Some(vec![member])), + None => Err(Error::StructureError(format!( + "element at position {index} in array does not exist" + ))), + }, + None => Ok(Some(array.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 + if let Some((list_name, index)) = is_array_path(path_component)? { + let Some(members) = array_members(map, list_name, index)? else { + return Ok(None); + }; + 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 +237,37 @@ impl BTreeValueMapReplacementPathHelper for BTreeMap { let Some(first_path_component) = first else { return Err(Error::PathError("path was empty".to_string())); }; + // `list[]` as the first component: the members of a top-level list + if let Some((list_name, index)) = is_array_path(first_path_component)? { + let Some(array_value) = self.get_mut(list_name) else { + return Ok(()); + }; + let array = array_value.to_array_mut()?; + let members: Vec<&mut Value> = match index { + Some(index) => match array.get_mut(index) { + Some(member) => vec![member], + None => { + return Err(Error::StructureError(format!( + "element at position {index} in array does not exist" + ))) + } + }, + None => array.iter_mut().collect(), + }; + 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 +728,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 replace_at_path_top_level_list_members() { + 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 replace_at_path_nested_list_members() { + 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])]) + ); + + // a list of maps: descend into the members + 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 replace_at_path_absent_list_is_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()); + 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..dcc863b8d16 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 the value 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,43 @@ 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()?; + .filter_map(|current_value| { + let map = match current_value.to_map_mut() { + Ok(map) => map, + Err(err) => return Some(Err(err)), + }; + // An absent array is an absent optional property: 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)), + }; 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!( + 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()) + 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 +124,10 @@ 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 match replace_value(new_value, replacement_type) { + Ok(()) => None, + Err(err) => Some(Err(err)), }; - return None; } Some(Ok(new_value)) }) @@ -645,6 +656,61 @@ mod tests { ); } + // =============================================================== + // replace_at_path: array path ending in [] (the members themselves) + // =============================================================== + + #[test] + fn replace_at_path_array_members_are_the_leaves() { + let b58 = base58_of_32_bytes(7); + let arr = Value::Array(vec![Value::Text(b58.clone()), Value::Text(b58)]); + let mut value = Value::Map(vec![(Value::Text("ids".into()), arr)]); + + 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([7u8; 32]) + ]) + ); + } + + #[test] + fn replace_at_path_array_member_by_index_is_the_leaf() { + let b58 = base58_of_32_bytes(9); + let arr = Value::Array(vec![Value::Text("keep".into()), Value::Text(b58)]); + let mut value = Value::Map(vec![(Value::Text("ids".into()), arr)]); + + 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 index past the end is an error, not a panic + assert!(value + .replace_at_path("ids[2]", ReplacementType::Identifier) + .is_err()); + } + + #[test] + fn replace_at_path_absent_array_returns_ok() { + 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/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs index ce45a9b8f5e..e42a4fe8135 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,11 @@ pub struct DocumentTypeSchemaVersions { /// keyword: they ignore it entirely, exactly as they parsed before it /// existed. pub apply_required_since: OptionalFeatureVersion, + /// Parses `type: array` with an `items` schema into a typed scalar array + /// (`DocumentPropertyType::TypedArray`, stored inline as a count followed by + /// its elements). `None` on versions that predate typed arrays: there an + /// array property must be a byte array, exactly as they parsed before. + 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..589308de7b1 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,9 +79,10 @@ 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: identifier and byte array items of a typed array 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: the meta-schema v3 typed scalar array (`type: array` with an `items` schema) is parsed into `DocumentPropertyType::TypedArray`; None before this version means an array property must be a byte array, as it always had to be 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..39cb3c35aeb 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -599,6 +599,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_shielded_transition_actions: 16, max_time_range_overlap_factor: None, max_time_range_ttl_seconds: None, + max_typed_array_items: None, min_time_range_ttl_drop_operations_per_write: None, minimum_grovedb_proof_envelope_version: 0, }, 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..0090b32dc92 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -177,6 +177,23 @@ pub struct SystemLimits { /// `None` preserves the behavior of protocol versions that predate the /// `ttl` key (nothing to bound: the key does not parse there). pub max_time_range_ttl_seconds: Option, + /// Maximum `maxItems` a typed scalar array property (`type: array` with an + /// `items` schema, protocol version 14) may declare, enforced at contract + /// registration by the property parser (`parse_typed_array` 0). + /// + /// A typed array is stored inline in the document as a count followed by + /// its elements, and every size-sensitive path (fee estimation, the + /// document size estimate) sizes it by `maxItems` times the item bound, so + /// the declaration has to be bounded and the bound has to stay small + /// enough for those estimates to mean something. 1024 in V4: far beyond + /// what a document of any real item type fits under + /// `max_state_transition_size`, while keeping the worst-case size of an + /// identifier list (33 bytes an element) inside the `u16` the estimates + /// are computed in. + /// + /// `None` preserves the behavior of protocol versions that predate typed + /// arrays (nothing to bound: the `items` keyword does not parse there). + pub max_typed_array_items: Option, /// Minimum per-write drainage budget for a TTL'd time-range grid. /// Drive raises this floor to twice the maximum trees one document /// can create in the grid's merged index structure, times its overlap 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..90de1be6ea6 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -67,6 +67,7 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_shielded_transition_actions: 16, max_time_range_overlap_factor: None, max_time_range_ttl_seconds: None, + max_typed_array_items: None, min_time_range_ttl_drop_operations_per_write: None, minimum_grovedb_proof_envelope_version: 0, // V0 envelopes stay accepted until v14 }; 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..2aac3dfc3b2 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -48,6 +48,7 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { max_shielded_transition_actions: 16, max_time_range_overlap_factor: None, max_time_range_ttl_seconds: None, + max_typed_array_items: None, min_time_range_ttl_drop_operations_per_write: None, minimum_grovedb_proof_envelope_version: 0, // V0 envelopes stay accepted until v14 }; 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..8f2087a2677 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -50,6 +50,7 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { max_shielded_transition_actions: 16, max_time_range_overlap_factor: None, max_time_range_ttl_seconds: None, + max_typed_array_items: None, min_time_range_ttl_drop_operations_per_write: None, minimum_grovedb_proof_envelope_version: 0, // V0 envelopes stay accepted until v14 }; 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..5032794f073 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -94,6 +94,7 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { max_shielded_transition_actions: 16, max_time_range_overlap_factor: Some(24), max_time_range_ttl_seconds: Some(604_800), // one week + max_typed_array_items: Some(1024), min_time_range_ttl_drop_operations_per_write: Some(32), minimum_grovedb_proof_envelope_version: 1, // clients reject legacy V0 GroveDB proof envelopes from v14 }; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 002ca42dd65..0e2a7f10510 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -547,6 +547,21 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `ReferencedContractRequirementNotMetError` (40135). A changed /// `contractRequirements` is an incompatible schema change on update. /// +/// 25. **Typed scalar arrays in document schemas**: an array property may be +/// declared by an `items` schema instead of `byteArray: true` (meta-schema +/// v3, `parse_typed_array` 0, `DocumentPropertyType::TypedArray`): a list +/// of one scalar type (integer, number, string, boolean, byte array or +/// identifier) stored inline in the document as a varint element count +/// followed by the elements, with no per-element index entry, subtree or +/// reference. `minItems` and `maxItems` count elements; `maxItems` is +/// required and capped by `SYSTEM_LIMITS_V4.max_typed_array_items` +/// (1024); `uniqueItems` refuses a repeated element. The JSON schema +/// validator enforces them and the item bounds on every document write. +/// Arrays of objects, arrays of arrays, `refersTo` on the items and an +/// index on an array property are refused at registration. +/// `find_identifier_and_binary_paths` 1 registers identifier and byte +/// array items as `path[]` conversion paths. +/// /// 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_array.rs b/packages/wasm-dpp2/src/data_contract/document_type_array.rs new file mode 100644 index 00000000000..4c40304a858 --- /dev/null +++ b/packages/wasm-dpp2/src/data_contract/document_type_array.rs @@ -0,0 +1,181 @@ +//! Typed scalar array properties: the `type: array` properties declared by an +//! `items` schema instead of `byteArray: true`, which a contract may carry +//! from protocol version 14 onward. +//! +//! Such a property is a list of one scalar type stored inline in the document +//! as a count followed by its elements; consensus enforces the schema's +//! `minItems` / `maxItems` / `uniqueItems` and the item bounds when the +//! document is written. 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; +use dpp::data_contract::document_type::{ + DocumentPropertyType, DocumentTypeRef, TypedArrayProperty, +}; +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#" +/** + * The item type of a typed scalar array property, as the contract parser + * reads the array's `items` schema. + * + * `type` is the parsed kind, not the raw schema `type`: a byte array item + * (`type: 'array'` with `byteArray: true` in the schema) whose + * `contentMediaType` is the identifier media type is an `identifier`, and + * any other byte array item is a `byteArray` with its `minItems` / + * `maxItems` byte bounds. A string item carries its `minLength` / + * `maxLength`. A bound is absent when the schema declares none. Every other + * bound the item schema may declare (`minimum`, `pattern`, ...) stays in the + * raw schema, which `contract.toJSON()` still shows. + */ +export type DocumentPropertyArrayItemType = + | { type: 'integer' } + | { type: 'number' } + | { type: 'string'; minLength?: number; maxLength?: number } + | { type: 'byteArray'; minItems?: number; maxItems?: number } + | { type: 'identifier' } + | { type: 'boolean' } + | { type: 'date' }; + +/** + * A typed scalar array property of a document type: `type: array` declared + * by an `items` schema (protocol version 14). Its value is a list of one + * scalar type, stored inline in the document as a count followed by the + * elements, and carried by a document as a plain array of the item type's + * values. The field names are the schema keywords' own, so what + * `contract.toJSON()` shows under the property 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 `"meta.tags"` for a nested one. + */ + path: string; + /** The parsed `items` schema. */ + items: DocumentPropertyArrayItemType; + /** + * Fewest elements a document may carry. Absent when the schema declares + * none, which consensus reads as zero. + */ + minItems?: number; + /** Most elements a document may carry. Always declared. */ + maxItems: number; + /** Whether consensus refuses a repeated element. */ + 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 property at '{path}'" + )) + })?; + Ok(()) +} + +/// Sets a size bound when the schema declares one; an undeclared bound is +/// absent, matching the schema's own omission. +fn set_optional_size( + target: &Object, + key: &str, + size: Option, + path: &str, +) -> WasmDppResult<()> { + if let Some(size) = size { + set_field(target, key, &JsValue::from_f64(size as f64), path)?; + } + Ok(()) +} + +/// The internally-tagged JS object for one item type. +fn item_type_to_js(item_type: &ArrayItemType, path: &str) -> WasmDppResult { + let object = Object::new(); + set_field(&object, "type", &JsValue::from_str(item_type.name()), path)?; + match item_type { + ArrayItemType::String(min_length, max_length) => { + set_optional_size(&object, "minLength", *min_length, path)?; + set_optional_size(&object, "maxLength", *max_length, path)?; + } + ArrayItemType::ByteArray(min_size, max_size) => { + set_optional_size(&object, "minItems", *min_size, path)?; + set_optional_size(&object, "maxItems", *max_size, path)?; + } + ArrayItemType::Integer + | ArrayItemType::Number + | ArrayItemType::Identifier + | ArrayItemType::Boolean + | ArrayItemType::Date => {} + } + Ok(object.into()) +} + +/// Build the flat JS object for one typed array property. +fn typed_array_to_js(path: &str, array: &TypedArrayProperty) -> WasmDppResult { + let object = Object::new(); + set_field(&object, "path", &JsValue::from_str(path), path)?; + set_field( + &object, + "items", + &item_type_to_js(&array.items, path)?, + path, + )?; + if let Some(min_items) = array.min_items { + set_field( + &object, + "minItems", + &JsValue::from_f64(f64::from(min_items)), + path, + )?; + } + set_field( + &object, + "maxItems", + &JsValue::from_f64(f64::from(array.max_items)), + path, + )?; + set_field( + &object, + "uniqueItems", + &JsValue::from_bool(array.unique_items), + path, + )?; + Ok(object.into()) +} + +/// Collect every typed array property of one document type, in schema +/// property order. +/// +/// Walks `flattened_properties`, as the reference accessor does, so a nested +/// array is reported under its dotted path. +pub(crate) fn typed_array_properties_for_document_type( + document_type: DocumentTypeRef<'_>, +) -> WasmDppResult { + let arrays = Array::new(); + + for (path, property) in document_type.flattened_properties() { + if let DocumentPropertyType::TypedArray(array) = &property.property_type { + arrays.push(&typed_array_to_js(path, array)?); + } + } + + Ok(arrays) +} diff --git a/packages/wasm-dpp2/src/data_contract/mod.rs b/packages/wasm-dpp2/src/data_contract/mod.rs index 57c8a39ce52..b4235e566b8 100644 --- a/packages/wasm-dpp2/src/data_contract/mod.rs +++ b/packages/wasm-dpp2/src/data_contract/mod.rs @@ -1,5 +1,6 @@ pub mod contract_bounds; pub mod document; +pub mod document_type_array; pub mod document_type_immutability; pub mod document_type_reference; pub mod model; @@ -7,6 +8,7 @@ pub mod transitions; pub use contract_bounds::ContractBoundsWasm; pub use document::DocumentWasm; +pub use document_type_array::{DocumentTypedArrayPropertyArrayJs, DocumentTypedArrayPropertyMapJs}; pub use document_type_immutability::{ DocumentTypeImmutablePropertiesJs, DocumentTypeImmutablePropertiesMapJs, }; diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index 7d01a65bd15..2ba8646d5cb 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -1,3 +1,7 @@ +use crate::data_contract::document_type_array::{ + DocumentTypedArrayPropertyArrayJs, DocumentTypedArrayPropertyMapJs, + typed_array_properties_for_document_type, +}; use crate::data_contract::document_type_immutability::{ DocumentTypeImmutablePropertiesJs, DocumentTypeImmutablePropertiesMapJs, immutable_properties_for_document_type, @@ -768,6 +772,55 @@ impl DataContractWasm { Ok(JsValue::from(properties).into()) } + /// The typed scalar array properties of one document type, in schema + /// property order: each with its dotted `path`, its parsed `items` type + /// and its `minItems` / `maxItems` / `uniqueItems` bounds. + /// + /// 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 lists" stay distinguishable. + /// + /// Typed arrays are only parsed from protocol version 14 onward. A + /// contract deserialized against an earlier platform version cannot + /// carry one: its parser refuses an array property that is not a byte + /// array, exactly as consensus did at that version. + #[wasm_bindgen(js_name = "documentTypeArrayProperties")] + pub fn document_type_array_properties( + &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 arrays = typed_array_properties_for_document_type(document_type)?; + Ok(JsValue::from(arrays).into()) + } + + /// Every document type that declares at least one typed scalar array + /// property, keyed by document type name. + /// + /// Document types without one are omitted, so an empty `Map` means + /// "this contract declares no typed arrays at all". + #[wasm_bindgen(getter = "documentArrayProperties")] + pub fn document_array_properties(&self) -> WasmDppResult { + let map = js_sys::Map::new(); + + for (name, document_type) in self.0.document_types() { + let arrays = typed_array_properties_for_document_type(document_type.as_ref())?; + if arrays.length() > 0 { + map.set(&JsValue::from_str(name), &arrays.into()); + } + } + + Ok(JsValue::from(map).into()) + } + /// Every document type that freezes at least one property, keyed by /// document type name. /// From 266ef6a14870cd693068307292bbf7432b9e3573 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 03:27:39 +0700 Subject: [PATCH 2/2] feat(dpp)!: typed arrays parse through a versioned method, cap at registration, agreement refused (#4922 port) Takes from the independent implementation in #4922 what it did better: - `class_methods/parse_typed_array` is its own versioned method run before the scalar parser, whose signature and "array" arm are restored byte-for-byte; below protocol version 14 the historical refusal is reached exactly as before. - The `maxItems` requirement and its `SystemLimits::max_typed_array_items` cap move to the generation 3 driver under full validation, like the other registration limits, so a later, lower cap can never make a stored contract unreadable; `TypedArrayProperty::max_items` is `Option`. - drive-abci refuses a `propertyAgreement` on a typed array on either side (fixture + test): the write-time check compares index key encodings, which a list does not have. - The ranked key-length check skips typed arrays so the type error is the one reported; a test pins it. - Byte array items whose bounds pin 20, 32 or 36 bytes read back as `Bytes20/32/36`, boolean items must be 0 or 1, item bounds are read at u16 like the scalar bounds. - Meta-schema v3: `items` is allowed on `type: array` only. - wasm-dpp2: `maxItems` is optional on the surface; a mocha spec covers the accessors. Merges v4.2-dev (#4915). Co-Authored-By: Claude Fable 5.1 --- book/src/data-model/data-contracts.md | 8 +- .../document/v3/document-meta.json | 12 + .../document_type/class_methods/mod.rs | 1 + .../class_methods/parse_typed_array/mod.rs | 80 ++++++ .../class_methods/parse_typed_array/v0/mod.rs | 142 ++++++++++ .../class_methods/try_from_schema/mod.rs | 25 +- .../class_methods/try_from_schema/v3/mod.rs | 56 ++++ .../try_from_schema/v3/typed_array_tests.rs | 89 +++++- .../document_type/property/array.rs | 85 +++++- .../document_type/property/mod.rs | 268 ++++-------------- .../document_type/v0/random_document_type.rs | 4 +- .../v0/mod.rs | 12 + .../data_contract_create/mod.rs | 22 ++ ...dation-contract-agreement-typed-array.json | 62 ++++ .../src/version/system_limits/mod.rs | 5 +- .../rs-platform-version/src/version/v14.rs | 14 +- .../src/data_contract/document_type_array.rs | 21 +- .../unit/DocumentTypeArrayProperties.spec.ts | 153 ++++++++++ 18 files changed, 793 insertions(+), 266 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-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-typed-array.json create mode 100644 packages/wasm-dpp2/tests/unit/DocumentTypeArrayProperties.spec.ts diff --git a/book/src/data-model/data-contracts.md b/book/src/data-model/data-contracts.md index bedf01797bd..4f0b533c9bc 100644 --- a/book/src/data-model/data-contracts.md +++ b/book/src/data-model/data-contracts.md @@ -261,14 +261,14 @@ Until protocol v14 the only `type: array` a document schema could declare was a } ``` -The rules, enforced by the meta-schema on the validating path and by the parser (`DocumentPropertyType::try_from_value_map`, `parse_typed_array` version 0) on both paths: +The rules, enforced by the meta-schema on the validating path and by the parser (`parse_typed_array` version 0) on both paths, except the bound, which is a registration limit: - `items` is any scalar property schema the parser already reads at the top level: an integer, a number, a string with `minLength` / `maxLength`, a boolean, a byte array with `minItems` / `maxItems`, or an identifier. Arrays of objects, arrays of arrays, a `$ref`, `enum` / `const`, and `refersTo` on the items are refused (a reference on identifier items is a separate follow-up; the item schema is parsed whole so it can carry one later). -- `minItems` and `maxItems` on the array count elements, not bytes. `maxItems` is required and may not exceed `SystemLimits::max_typed_array_items` (1024), because the count-prefixed inline encoding is sized for fees by its bound. `uniqueItems: true` refuses a repeated element. +- `minItems` and `maxItems` on the array count elements, not bytes. Registration (full validation, in the generation 3 driver) requires `maxItems` and refuses one above `SystemLimits::max_typed_array_items` (1024), because the count-prefixed inline encoding is sized for fees by its bound; like every registration limit it is not re-applied when a stored contract is read. `uniqueItems: true` refuses a repeated element. - An array is either a byte array or a typed array, never both: `byteArray` and `items` are mutually exclusive, and `contentMediaType` belongs on the items. -- An array property cannot be indexed. Drive's query conditions give the type no operator, so an index on one is refused at registration with `InvalidIndexPropertyTypeError`, and an indexOnly `entryPayload` cannot name one. +- An array property cannot be indexed. Drive's query conditions give the type no operator, so an index on one is refused at registration with `InvalidIndexPropertyTypeError`, and an indexOnly `entryPayload` cannot name one. A `propertyAgreement` cannot bind one either: the write-time check compares index key encodings, so drive-abci refuses the declaration at registration. -The parsed form is `DocumentPropertyType::TypedArray(TypedArrayProperty { items, min_items, max_items, unique_items })`, with `items` an `ArrayItemType`. Documents carry the list as a `Value::Array` of the item type's values, validated by the JSON schema validator (`minItems`, `maxItems`, `uniqueItems`, and the item constraints all surface as the usual `JsonSchemaError`), and serialized as a varint element count followed by the elements (see [Document Serialization](../serialization/document-serialization.md)). Fee estimation sizes the property by `maxItems` times the item bound plus the count prefix. Identifier and byte array items are registered as `path[]` conversion paths, so a document built from JSON converts every element of the list. +The parsed form is `DocumentPropertyType::TypedArray(TypedArrayProperty { items, min_items, max_items, unique_items })`, with `items` an `ArrayItemType`, produced by the versioned `parse_typed_array` method that runs before the scalar parser (`None` before protocol v14, so the scalar parser's byte-array-only refusal stays byte-identical there). Documents carry the list as a `Value::Array` of the item type's values, validated by the JSON schema validator (`minItems`, `maxItems`, `uniqueItems`, and the item constraints all surface as the usual `JsonSchemaError`), and serialized as a varint element count followed by the elements (see [Document Serialization](../serialization/document-serialization.md)). Fee estimation sizes the property by `maxItems` times the item bound plus the count prefix. Identifier and byte array items are registered as `path[]` conversion paths, so a document built from JSON converts every element of the list. The Swift and Kotlin contract parsers still read every `type: array` as a byte array; typed arrays reach those SDKs in a follow-up. 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 84b37e0fc53..210e5776798 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 @@ -480,6 +480,18 @@ } } }, + "items": { + "description": "should be used only with array type", + "properties": { + "type": { + "type": "string", + "const": "array" + } + }, + "required": [ + "type" + ] + }, "contentMediaType": { "if": { "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..63b410ed54b 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; +pub(crate) 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..146bfbff229 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs @@ -0,0 +1,80 @@ +use crate::data_contract::document_type::DocumentPropertyType; +use crate::data_contract::errors::DataContractError; +use platform_value::Value; +use platform_version::version::PlatformVersion; +use std::collections::BTreeMap; + +mod v0; + +/// Parses a typed scalar array property, `type: array` with an `items` schema +/// in place of `byteArray: true`, into [`DocumentPropertyType::TypedArray`]. +/// +/// Returns `None` for every other property, a byte array included, which the +/// caller leaves to `DocumentPropertyType::try_from_value_map` exactly as +/// before typed arrays existed. +/// +/// 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 the scalar parser refuses an array +/// that is not a byte array with the refusal it always had. +/// +/// # Parameters +/// +/// * `inner_properties`: the property schema as a value map +/// * `platform_version`: selects the generation +/// +/// # Returns +/// +/// The typed array property type, or `None` when the property is not a typed +/// array or the version does not parse one. +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" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data_contract::document_type::array::ArrayItemType; + use crate::data_contract::document_type::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().unwrap(); + + assert_eq!( + parse_typed_array(&map, PlatformVersion::latest()).unwrap(), + Some(DocumentPropertyType::TypedArray(TypedArrayProperty { + items: ArrayItemType::String(None, Some(16)), + min_items: Some(1), + max_items: Some(8), + unique_items: true, + })) + ); + // Protocol version 13 leaves the property to the scalar parser + let v13 = PlatformVersion::get(13).unwrap(); + assert_eq!(parse_typed_array(&map, v13).unwrap(), 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 new file mode 100644 index 00000000000..2795792855f --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs @@ -0,0 +1,142 @@ +use crate::data_contract::document_type::array::ArrayItemType; +use crate::data_contract::document_type::{ + property_names, DocumentPropertyType, TypedArrayProperty, +}; +use crate::data_contract::errors::DataContractError; +use platform_value::btreemap_extensions::BTreeValueMapHelper; +use platform_value::Value; +use std::collections::BTreeMap; + +/// Generation 0: an array property that declares no `byteArray` is a typed +/// scalar array. Its `items` schema is read through +/// [`ArrayItemType::try_from_item_schema`], `minItems` and `maxItems` count +/// elements, and `uniqueItems` defaults to false. `contentMediaType` belongs +/// on the items and is refused on the array, as is `minItems` above +/// `maxItems`. Whether `maxItems` is declared, and stays under the system +/// limit, is checked at registration by the generation 3 driver under full +/// validation, like the other registration limits, so a stored contract is +/// never re-judged by a later cap. +#[inline(always)] +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_value) = inner_properties.get(property_names::ITEMS) else { + return Err(DataContractError::InvalidContractStructure( + "an array property must be a byte array (byteArray: true) or declare an items schema" + .to_string(), + )); + }; + 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 items = ArrayItemType::try_from(*items_value)?; + let min_items: Option = + inner_properties.get_optional_integer(property_names::MIN_ITEMS)?; + let max_items: Option = + inner_properties.get_optional_integer(property_names::MAX_ITEMS)?; + if let (Some(min), Some(max)) = (min_items, max_items) { + if min > max { + return Err(DataContractError::InvalidContractStructure(format!( + "typed array minItems {min} exceeds its maxItems {max}" + ))); + } + } + let unique_items = inner_properties + .get_optional_bool(property_names::UNIQUE_ITEMS)? + .unwrap_or(false); + + Ok(Some(DocumentPropertyType::TypedArray(TypedArrayProperty { + items, + min_items, + max_items, + unique_items, + }))) +} + +#[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().unwrap(); + parse_typed_array_v0(&map) + } + + #[test] + fn should_leave_byte_arrays_and_scalars_to_the_scalar_parser() { + assert_eq!( + parse(platform_value!({ "type": "array", "byteArray": true, "maxItems": 32 })).unwrap(), + None + ); + assert_eq!( + parse(platform_value!({ "type": "array", "byteArray": false })).unwrap(), + None + ); + assert_eq!( + parse(platform_value!({ "type": "string", "maxLength": 3 })).unwrap(), + None + ); + } + + #[test] + fn should_parse_a_typed_array_with_optional_bounds() { + assert_eq!( + parse(platform_value!({ "type": "array", "items": { "type": "integer" } })).unwrap(), + Some(DocumentPropertyType::TypedArray(TypedArrayProperty { + items: ArrayItemType::Integer, + min_items: None, + max_items: None, + unique_items: false, + })) + ); + } + + #[test] + fn should_refuse_a_typed_array_without_items_with_content_media_type_or_with_min_over_max() { + for (schema, fragment) in [ + ( + platform_value!({ "type": "array", "maxItems": 2 }), + "items schema", + ), + ( + platform_value!({ + "type": "array", + "maxItems": 2, + "contentMediaType": "application/x.dash.dpp.identifier", + "items": { "type": "integer" } + }), + "contentMediaType", + ), + ( + platform_value!({ + "type": "array", + "minItems": 3, + "maxItems": 2, + "items": { "type": "integer" } + }), + "minItems", + ), + ] { + 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/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 5b4bcb7473b..de95188a4e1 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,11 +155,14 @@ fn insert_values( platform_version, )?; - match DocumentPropertyType::try_from_value_map( - &inner_properties, - &config.into(), - platform_version, - )? { + // A typed array parses through its own versioned method; everything + // else, byte arrays included, through the scalar parser as before + 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) { @@ -235,11 +239,12 @@ fn insert_values_nested( platform_version, )?; - let property_type = match DocumentPropertyType::try_from_value_map( - &inner_properties, - &config.into(), - platform_version, - )? { + 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())?, + }; + + 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) { 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 f72bdcfa817..8a5b7d43245 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. @@ -398,6 +405,11 @@ fn try_from_schema_generation_3( name, )?; + #[cfg(feature = "validation")] + if full_validation { + validate_typed_array_max_items(&v2, name, platform_version)?; + } + // The flags are read from the parsed result (not the raw schema) so // the check sees `canBeDeleted` resolved against the contract config // default (`true` when the key is omitted). @@ -416,6 +428,50 @@ fn try_from_schema_generation_3( Ok(v2) } +/// Every typed array property must declare `maxItems`, at most +/// `SystemLimits::max_typed_array_items`, so the worst-case encoded size fee +/// estimation charges by 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 Some(limit) = platform_version.system_limits.max_typed_array_items else { + return Err(ProtocolError::CorruptedCodeExecution( + "the generation 3 document type parser admits typed arrays, so its protocol \ + version must cap their maxItems" + .to_string(), + )); + }; + 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)] 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 286504f410d..561c865a3b9 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 @@ -184,7 +184,7 @@ fn should_parse_a_typed_identifier_array() { DocumentPropertyType::TypedArray(TypedArrayProperty { items: ArrayItemType::Identifier, min_items: Some(0), - max_items: 64, + max_items: Some(64), unique_items: true, }) ); @@ -197,7 +197,7 @@ fn should_parse_a_typed_integer_array_with_bounds() { DocumentPropertyType::TypedArray(TypedArrayProperty { items: ArrayItemType::Integer, min_items: Some(1), - max_items: 4, + max_items: Some(4), unique_items: false, }) ); @@ -229,7 +229,7 @@ fn should_parse_every_scalar_item_type_with_its_bounds() { DocumentPropertyType::TypedArray(TypedArrayProperty { items: expected, min_items: None, - max_items: 3, + max_items: Some(3), unique_items: false, }) ); @@ -279,15 +279,39 @@ fn should_refuse_an_array_of_arrays() { } #[test] -fn should_refuse_a_typed_array_without_max_items() { - // The meta-schema refuses it first, as the array form matching neither branch; the - // parser's own "must declare maxItems" message is pinned on the stored path below - let error = refusal(json!({ +fn should_refuse_a_typed_array_without_max_items_at_registration_only() { + let schema = json!({ "type": "array", "items": { "type": "integer" }, "position": 0 - })); + }); + // The meta-schema refuses it, as the array form matching neither branch + let error = refusal(schema.clone()); assert!(error.contains("oneOf"), "unexpected refusal: {error}"); + + // The stored path parses it: the bound is a registration limit, checked + // under full validation only, so a stored contract is never re-judged + let contract = DataContract::from_json( + contract_json(json!({ "list": schema }), json!([]), json!([])), + false, + PlatformVersion::latest(), + ) + .expect("the stored path admits a typed array without maxItems"); + assert_eq!( + contract + .document_type_for_name(DOCUMENT_TYPE) + .expect("the document type parses") + .properties() + .get("list") + .expect("the property parses") + .property_type, + DocumentPropertyType::TypedArray(TypedArrayProperty { + items: ArrayItemType::Integer, + min_items: None, + max_items: None, + unique_items: false, + }) + ); } #[test] @@ -336,10 +360,6 @@ fn should_refuse_an_index_on_a_typed_array_property() { #[test] fn should_hold_the_parser_rules_without_the_meta_schema() { for (schema, fragment) in [ - ( - json!({ "type": "array", "items": { "type": "integer" }, "position": 0 }), - "maxItems", - ), ( json!({ "type": "array", @@ -415,14 +435,20 @@ fn should_refuse_a_typed_array_over_the_element_cap() { .system_limits .max_typed_array_items .expect("protocol version 14 caps typed arrays"); - let error = refusal(json!({ + let over_the_cap = json!({ "type": "array", "maxItems": cap + 1, "items": { "type": "boolean" }, "position": 0 - })); + }); + let error = parse(contract_json( + json!({ "list": over_the_cap.clone() }), + json!([]), + json!([]), + )) + .expect_err("registration refuses a bound over the cap"); assert!( - error.contains("exceeds the maximum"), + matches!(error, ProtocolError::ConsensusError(_)) && error.to_string().contains("at most"), "unexpected refusal: {error}" ); @@ -433,6 +459,39 @@ fn should_refuse_a_typed_array_over_the_element_cap() { "position": 0 })) .expect("the cap itself is admitted"); + + // The stored path never applies the cap: a later, lower cap must not make + // a registered contract unreadable + DataContract::from_json( + contract_json(json!({ "list": over_the_cap }), json!([]), json!([])), + false, + PlatformVersion::latest(), + ) + .expect("the stored path admits a bound over the cap"); +} + +#[test] +fn should_refuse_a_typed_array_in_an_index_with_a_ranked_axis() { + let error = parse(contract_json( + json!({ "reasons": identifier_list_schema(0) }), + json!([]), + json!([{ + "name": "byReasons", + "properties": [{ "reasons": "asc" }], + "rangeCountable": true, + "rankedCountable": true + }]), + )) + .expect_err("a ranked index on an array property should be refused"); + // The type error, not the ranked key-length error, is the one reported + assert!( + matches!( + error, + ProtocolError::ConsensusError(ref boxed) + if matches!(**boxed, ConsensusError::BasicError(BasicError::InvalidIndexPropertyTypeError(_))) + ), + "expected InvalidIndexPropertyTypeError, got {error}" + ); } // --------------------------------------------------------------------------- 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 1fcf4b0ab98..65057231d9b 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 @@ -379,9 +379,14 @@ impl ArrayItemType { "integer" => ArrayItemType::Integer, "number" => ArrayItemType::Number, "boolean" => ArrayItemType::Boolean, + // Bounds are read at the width a scalar property's bounds have "string" => ArrayItemType::String( - item_schema.get_optional_integer(property_names::MIN_LENGTH)?, - item_schema.get_optional_integer(property_names::MAX_LENGTH)?, + item_schema + .get_optional_integer::(property_names::MIN_LENGTH)? + .map(usize::from), + item_schema + .get_optional_integer::(property_names::MAX_LENGTH)? + .map(usize::from), ), "array" => { match item_schema.get_optional_bool(property_names::BYTE_ARRAY)? { @@ -402,8 +407,12 @@ impl ArrayItemType { match item_schema.get_optional_str(property_names::CONTENT_MEDIA_TYPE)? { Some(IDENTIFIER_CONTENT_MEDIA_TYPE) => ArrayItemType::Identifier, Some(_) | None => ArrayItemType::ByteArray( - item_schema.get_optional_integer(property_names::MIN_ITEMS)?, - item_schema.get_optional_integer(property_names::MAX_ITEMS)?, + item_schema + .get_optional_integer::(property_names::MIN_ITEMS)? + .map(usize::from), + item_schema + .get_optional_integer::(property_names::MAX_ITEMS)? + .map(usize::from), ), } } @@ -480,9 +489,9 @@ impl ArrayItemType { })?; Ok(Value::I64(value)) } - ArrayItemType::ByteArray(_, _) => { + ArrayItemType::ByteArray(min_size, max_size) => { let bytes = DocumentPropertyType::read_varint_value(buf)?; - Ok(Value::Bytes(bytes)) + Ok(Self::fixed_size_bytes_value(*min_size, *max_size, bytes)) } ArrayItemType::Identifier => { let bytes = DocumentPropertyType::read_varint_value(buf)?; @@ -494,14 +503,39 @@ impl ArrayItemType { })?; Ok(Value::Identifier(id)) } - ArrayItemType::Boolean => { - let value = buf.read_u8().map_err(|_| { - DataContractError::CorruptedSerialization( - "error reading boolean array item from serialized document".to_string(), - ) - })?; - Ok(Value::Bool(value != 0)) - } + // The encoder writes 0 or 1; anything else is a corrupted element + 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 item from serialized document".to_string(), + )), + }, + } + } + + /// The value a byte array item reads back as: `Bytes20`, `Bytes32` or + /// `Bytes36` when the item's bounds pin one of those sizes, as a + /// fixed-size scalar byte array reads back, and `Bytes` otherwise. + fn fixed_size_bytes_value( + min_size: Option, + max_size: Option, + bytes: Vec, + ) -> Value { + 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), } } @@ -742,6 +776,23 @@ mod tests { Value::Bytes(vec![1, 2, 3]), ), (ArrayItemType::ByteArray(None, None), Value::Bytes(vec![])), + // an item whose bounds pin a size reads back in that size's value kind + ( + ArrayItemType::ByteArray(Some(20), Some(20)), + Value::Bytes20([4u8; 20]), + ), + ( + ArrayItemType::ByteArray(Some(32), Some(32)), + Value::Bytes32([5u8; 32]), + ), + ( + ArrayItemType::ByteArray(Some(36), Some(36)), + Value::Bytes36([6u8; 36]), + ), + ( + ArrayItemType::ByteArray(Some(3), Some(3)), + Value::Bytes(vec![1, 2, 3]), + ), (ArrayItemType::Identifier, Value::Identifier([7u8; 32])), ] { let bytes = item_type @@ -774,6 +825,12 @@ mod tests { assert!(ArrayItemType::Identifier .read_value_from(&mut reader) .is_err()); + + // a boolean item is 0 or 1 + let mut not_a_bool = BufReader::new(&[2u8][..]); + assert!(ArrayItemType::Boolean + .read_value_from(&mut not_a_bool) + .is_err()); } // ----------------------------------------------------------------------- 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 ba25e731820..76b4bade69d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs @@ -90,77 +90,25 @@ pub struct ByteArrayPropertySizes { } /// A typed scalar array: `type: array` with an `items` schema (meta-schema v3, -/// protocol version 14). Its value is a list of `items` elements stored inline -/// in the document as a varint element count followed by the elements, each -/// encoded by its [`ArrayItemType`]; no per-element index entry, subtree or -/// reference exists. `minItems` and `maxItems` count elements (`maxItems` is -/// required and capped by `SystemLimits::max_typed_array_items`) and -/// `uniqueItems` refuses a repeated element; the JSON schema validator enforces -/// all three on the document, and they are read here to size the property for -/// fee estimation and to generate random documents within the bounds. +/// protocol version 14), parsed by `parse_typed_array`. Its value is a list of +/// `items` elements stored inline in the document as a varint element count +/// followed by the elements, each encoded by its [`ArrayItemType`]; no +/// per-element index entry, subtree or reference exists. `minItems` and +/// `maxItems` count elements and `uniqueItems` refuses a repeated element; the +/// JSON schema validator enforces all three on the document, and they are +/// read here to size the property for fee estimation and to generate random +/// documents within the bounds. Registration requires `maxItems`, at most +/// `SystemLimits::max_typed_array_items`, under full validation, so it is +/// only absent on a contract parsed without validation. #[derive(Debug, PartialEq, Clone, Serialize)] pub struct TypedArrayProperty { pub items: ArrayItemType, pub min_items: Option, - pub max_items: u16, + pub max_items: Option, pub unique_items: bool, } impl TypedArrayProperty { - /// Parses the typed form of an array property, generation 0: the `items` - /// schema through [`ArrayItemType::try_from_item_schema`], `minItems` / - /// `maxItems` as element counts (`maxItems` required and at most - /// `max_items_cap`, `minItems` not above it) and `uniqueItems`. - /// `contentMediaType` belongs on the items and is refused on the array. - /// Every rule holds on the validating and the stored path alike. - pub fn try_from_value_map_v0( - value_map: &BTreeMap, - max_items_cap: u16, - ) -> Result { - let Some(items_value) = value_map.get(property_names::ITEMS) else { - return Err(DataContractError::InvalidContractStructure( - "an array property must be a byte array (byteArray: true) or declare an \ - items schema" - .to_string(), - )); - }; - if value_map.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 items = ArrayItemType::try_from(*items_value)?; - let min_items: Option = value_map.get_optional_integer(property_names::MIN_ITEMS)?; - let Some(max_items) = value_map.get_optional_integer::(property_names::MAX_ITEMS)? - else { - return Err(DataContractError::InvalidContractStructure( - "a typed array must declare maxItems: its inline encoding is sized for fees \ - by its bound" - .to_string(), - )); - }; - if max_items > max_items_cap { - return Err(DataContractError::InvalidContractStructure(format!( - "typed array maxItems {max_items} exceeds the maximum of {max_items_cap} elements" - ))); - } - if min_items.is_some_and(|min| min > max_items) { - return Err(DataContractError::InvalidContractStructure(format!( - "typed array minItems exceeds its maxItems {max_items}" - ))); - } - let unique_items = value_map - .get_optional_bool(property_names::UNIQUE_ITEMS)? - .unwrap_or(false); - Ok(Self { - items, - min_items, - max_items, - unique_items, - }) - } - /// The fewest bytes the array encodes to: the count prefix plus /// `minItems` elements at their smallest. pub fn min_byte_size(&self) -> u16 { @@ -170,31 +118,44 @@ impl TypedArrayProperty { } /// The most bytes the array encodes to: the count prefix plus `maxItems` - /// elements at their largest, `u16::MAX` once that saturates (an - /// unbounded item makes the array unbounded). + /// elements at their largest, `u16::MAX` once that saturates or when + /// `maxItems` or the item is unbounded. pub fn max_byte_size(&self) -> u16 { - (self.max_items.required_space() as u16) - .saturating_add(self.max_items.saturating_mul(self.items.max_byte_size())) + let Some(max_items) = self.max_items else { + return u16::MAX; + }; + (max_items.required_space() as u16) + .saturating_add(max_items.saturating_mul(self.items.max_byte_size())) + } + + /// How many elements a random list holds: between `minItems` and + /// `maxItems`, and eight more than `minItems` when `maxItems` is absent. + fn random_count_range(&self) -> (u16, u16) { + let min_items = self.min_items.unwrap_or(0); + let max_items = self + .max_items + .unwrap_or(min_items.saturating_add(8)) + .max(min_items); + (min_items, max_items) } /// A random list of any length within the bounds, elements of any size. pub fn random_value(&self, rng: &mut StdRng) -> Value { - let count = rng.gen_range(self.min_items.unwrap_or(0)..=self.max_items); + let (min_items, max_items) = self.random_count_range(); + let count = rng.gen_range(min_items..=max_items); self.random_list(rng, count, |items, rng| items.random_value(rng)) } /// A random list of `minItems` elements at their smallest size. pub fn random_min_value(&self, rng: &mut StdRng) -> Value { - self.random_list(rng, self.min_items.unwrap_or(0), |items, rng| { - items.random_min_value(rng) - }) + let (min_items, _) = self.random_count_range(); + self.random_list(rng, min_items, |items, rng| items.random_min_value(rng)) } /// A random list of `maxItems` elements at their largest size. pub fn random_max_value(&self, rng: &mut StdRng) -> Value { - self.random_list(rng, self.max_items, |items, rng| { - items.random_max_value(rng) - }) + let (_, max_items) = self.random_count_range(); + self.random_list(rng, max_items, |items, rng| items.random_max_value(rng)) } /// Fills a list of `count` elements. Under `uniqueItems` a repeated @@ -3269,15 +3230,9 @@ impl DocumentPropertyType { } } - /// Parses one property schema into its type. An array is a byte array - /// when it declares `byteArray: true`; otherwise, from the version that - /// parses typed arrays (`parse_typed_array`, protocol version 14), it is a - /// typed scalar array declared by its `items` schema, and before that - /// version it is refused exactly as it always was. pub fn try_from_value_map( value_map: &BTreeMap, options: &DocumentPropertyTypeParsingOptions, - platform_version: &PlatformVersion, ) -> Result { let type_value = value_map.get_str(property_names::TYPE)?; @@ -3294,39 +3249,14 @@ impl DocumentPropertyType { max_length: value_map.get_optional_integer(property_names::MAX_LENGTH)?, }), "array" => { + // Only handling bytearrays for v1 + // Return an error if it is not a byte array let Some(is_byte_array) = value_map.get_optional_bool(property_names::BYTE_ARRAY)? else { - // Not a byte array: a typed scalar array where the version - // parses one, the historical refusal before that. - return match platform_version - .dpp - .contract_versions - .document_type_versions - .schema - .parse_typed_array - { - None => Err(DataContractError::InvalidContractStructure( - "only byte arrays are supported now".to_string(), - )), - Some(0) => { - let max_items_cap = platform_version - .system_limits - .max_typed_array_items - .ok_or_else(|| { - DataContractError::Unsupported( - "typed arrays have no element cap at this protocol \ - version" - .to_string(), - ) - })?; - TypedArrayProperty::try_from_value_map_v0(value_map, max_items_cap) - .map(DocumentPropertyType::TypedArray) - } - Some(version) => Err(DataContractError::Unsupported(format!( - "parse_typed_array version {version} is not supported" - ))), - }; + return Err(DataContractError::InvalidContractStructure( + "only byte arrays are supported now".to_string(), + )); }; if !is_byte_array { @@ -5524,9 +5454,7 @@ mod tests { map.insert("minLength".to_string(), &min_val); map.insert("maxLength".to_string(), &max_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); + let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); assert_eq!( result, DocumentPropertyType::String(StringPropertySizes { @@ -5542,9 +5470,7 @@ mod tests { let mut map = BTreeMap::new(); map.insert("type".to_string(), &type_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); + let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); assert_eq!(result, DocumentPropertyType::Boolean); } @@ -5554,9 +5480,7 @@ mod tests { let mut map = BTreeMap::new(); map.insert("type".to_string(), &type_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); + let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); assert_eq!(result, DocumentPropertyType::F64); } @@ -5572,9 +5496,7 @@ mod tests { let options = DocumentPropertyTypeParsingOptions { sized_integer_types: true, }; - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); + let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); assert_eq!(result, DocumentPropertyType::U8); } @@ -5586,9 +5508,7 @@ mod tests { let options = DocumentPropertyTypeParsingOptions { sized_integer_types: false, }; - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); + let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); assert_eq!(result, DocumentPropertyType::I64); } @@ -5598,59 +5518,17 @@ mod tests { let mut map = BTreeMap::new(); map.insert("type".to_string(), &type_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()); + let result = DocumentPropertyType::try_from_value_map(&map, &options); assert!(result.is_err()); } - #[test] - fn should_parse_a_typed_array_from_its_items_schema() { - let schema = platform_value::platform_value!({ - "type": "array", - "minItems": 1, - "maxItems": 8, - "uniqueItems": true, - "items": { "type": "string", "maxLength": 16 } - }); - let map = schema.to_btree_ref_string_map().unwrap(); - let options = DocumentPropertyTypeParsingOptions::default(); - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); - assert_eq!( - result, - DocumentPropertyType::TypedArray(TypedArrayProperty { - items: ArrayItemType::String(None, Some(16)), - min_items: Some(1), - max_items: 8, - unique_items: true, - }) - ); - } - - #[test] - fn should_refuse_a_typed_array_where_the_version_does_not_parse_one() { - let schema = platform_value::platform_value!({ - "type": "array", - "maxItems": 8, - "items": { "type": "string" } - }); - let map = schema.to_btree_ref_string_map().unwrap(); - let options = DocumentPropertyTypeParsingOptions::default(); - let v13 = PlatformVersion::get(13).unwrap(); - let error = DocumentPropertyType::try_from_value_map(&map, &options, v13) - .expect_err("protocol version 13 admits only byte arrays") - .to_string(); - assert!(error.contains("only byte arrays"), "{error}"); - } - #[test] fn should_round_trip_a_typed_array_value_through_the_codec() { use std::io::BufReader; let prop = DocumentPropertyType::TypedArray(TypedArrayProperty { items: ArrayItemType::Identifier, min_items: None, - max_items: 4, + max_items: Some(4), unique_items: false, }); let value = Value::Array(vec![ @@ -5688,7 +5566,7 @@ mod tests { let prop = DocumentPropertyType::TypedArray(TypedArrayProperty { items: ArrayItemType::Boolean, min_items: Some(1), - max_items: 3, + max_items: Some(3), unique_items: true, }); let mut rng = StdRng::seed_from_u64(7); @@ -5720,9 +5598,7 @@ mod tests { map.insert("byteArray".to_string(), &byte_array_val); map.insert("contentMediaType".to_string(), &media_type_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); + let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); assert_eq!(result, DocumentPropertyType::Identifier); } @@ -5738,9 +5614,7 @@ mod tests { map.insert("minItems".to_string(), &min_items_val); map.insert("maxItems".to_string(), &max_items_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); + let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); assert_eq!( result, DocumentPropertyType::ByteArray(ByteArrayPropertySizes { @@ -5758,8 +5632,7 @@ mod tests { map.insert("type".to_string(), &type_val); map.insert("byteArray".to_string(), &byte_array_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()); + let result = DocumentPropertyType::try_from_value_map(&map, &options); assert!(result.is_err()); } @@ -5769,8 +5642,7 @@ mod tests { let mut map = BTreeMap::new(); map.insert("type".to_string(), &type_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()); + let result = DocumentPropertyType::try_from_value_map(&map, &options); assert!(result.is_err()); } @@ -7728,9 +7600,7 @@ mod tests { let mut map = BTreeMap::new(); map.insert("type".to_string(), &type_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); + let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); assert_eq!( result, DocumentPropertyType::String(StringPropertySizes { @@ -7746,9 +7616,7 @@ mod tests { let mut map = BTreeMap::new(); map.insert("type".to_string(), &type_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); + let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); assert!(matches!(result, DocumentPropertyType::Object(_))); } @@ -7763,9 +7631,7 @@ mod tests { let options = DocumentPropertyTypeParsingOptions { sized_integer_types: true, }; - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); + let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); assert_eq!(result, DocumentPropertyType::U64); } @@ -7780,9 +7646,7 @@ mod tests { let options = DocumentPropertyTypeParsingOptions { sized_integer_types: true, }; - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); + let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); assert_eq!(result, DocumentPropertyType::I64); } @@ -7797,9 +7661,7 @@ mod tests { let options = DocumentPropertyTypeParsingOptions { sized_integer_types: true, }; - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); + let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); assert_eq!(result, DocumentPropertyType::U8); } @@ -7812,9 +7674,7 @@ mod tests { let options = DocumentPropertyTypeParsingOptions { sized_integer_types: true, }; - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); + let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); assert_eq!(result, DocumentPropertyType::I64); } @@ -7829,9 +7689,7 @@ mod tests { let options = DocumentPropertyTypeParsingOptions { sized_integer_types: true, }; - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); + let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); // min=0, max=255 => U8 assert_eq!(result, DocumentPropertyType::U8); } @@ -7847,9 +7705,7 @@ mod tests { let options = DocumentPropertyTypeParsingOptions { sized_integer_types: true, }; - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); + let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); // 300 => U16 assert_eq!(result, DocumentPropertyType::U16); } @@ -7865,9 +7721,7 @@ mod tests { map.insert("byteArray".to_string(), &byte_array_val); map.insert("contentMediaType".to_string(), &media_type_val); let options = DocumentPropertyTypeParsingOptions::default(); - let result = - DocumentPropertyType::try_from_value_map(&map, &options, PlatformVersion::latest()) - .unwrap(); + let result = DocumentPropertyType::try_from_value_map(&map, &options).unwrap(); assert!(matches!(result, DocumentPropertyType::ByteArray(_))); } 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 955542977bb..8d3a1c09a3e 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 @@ -390,12 +390,14 @@ impl DocumentTypeV0 { let mut schema = json!({ "type": "array", "items": items_schema, - "maxItems": array.max_items, "uniqueItems": array.unique_items, }); if let Some(min_items) = array.min_items { schema["minItems"] = json!(min_items); } + if let Some(max_items) = array.max_items { + schema["maxItems"] = json!(max_items); + } schema }, DocumentPropertyType::VariableTypeArray(types) => { 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-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 0090b32dc92..537a726dc21 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -179,7 +179,10 @@ pub struct SystemLimits { pub max_time_range_ttl_seconds: Option, /// Maximum `maxItems` a typed scalar array property (`type: array` with an /// `items` schema, protocol version 14) may declare, enforced at contract - /// registration by the property parser (`parse_typed_array` 0). + /// registration under full validation by document type parser generation + /// 3, which also requires the bound to be declared. Full validation only, + /// like the other registration limits: a stored contract is never + /// re-judged by a later, lower cap. /// /// A typed array is stored inline in the document as a count followed by /// its elements, and every size-sensitive path (fee estimation, the diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 403438134ec..e392e6e47b4 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -562,12 +562,14 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// of one scalar type (integer, number, string, boolean, byte array or /// identifier) stored inline in the document as a varint element count /// followed by the elements, with no per-element index entry, subtree or -/// reference. `minItems` and `maxItems` count elements; `maxItems` is -/// required and capped by `SYSTEM_LIMITS_V4.max_typed_array_items` -/// (1024); `uniqueItems` refuses a repeated element. The JSON schema -/// validator enforces them and the item bounds on every document write. -/// Arrays of objects, arrays of arrays, `refersTo` on the items and an -/// index on an array property are refused at registration. +/// reference. `minItems` and `maxItems` count elements; registration +/// (full validation) requires `maxItems`, at most +/// `SYSTEM_LIMITS_V4.max_typed_array_items` (1024); `uniqueItems` refuses +/// a repeated element. The JSON schema validator enforces them and the +/// item bounds on every document write. Arrays of objects, arrays of +/// arrays, `refersTo` on the items, an index on an array property and a +/// `propertyAgreement` on one (its write-time check compares index key +/// encodings, which a list does not have) are refused at registration. /// `find_identifier_and_binary_paths` 1 registers identifier and byte /// array items as `path[]` conversion paths. /// diff --git a/packages/wasm-dpp2/src/data_contract/document_type_array.rs b/packages/wasm-dpp2/src/data_contract/document_type_array.rs index 4c40304a858..e7039d83b11 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_array.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_array.rs @@ -65,8 +65,11 @@ export type DocumentTypedArrayProperty = { * none, which consensus reads as zero. */ minItems?: number; - /** Most elements a document may carry. Always declared. */ - maxItems: number; + /** + * Most elements a document may carry. Contract registration requires it, + * so it is only absent on a contract parsed without validation. + */ + maxItems?: number; /** Whether consensus refuses a repeated element. */ uniqueItems: boolean; }; @@ -146,12 +149,14 @@ fn typed_array_to_js(path: &str, array: &TypedArrayProperty) -> WasmDppResult { + 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 array properties (v14)', () => { + describe('documentTypeArrayProperties()', () => { + it('should report every typed array with its element type and bounds', () => { + const contract = buildContract(schemas); + + expect(contract.documentTypeArrayProperties('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.documentTypeArrayProperties('plain')).to.deep.equal([]); + }); + + it('should throw for an unknown document type', () => { + const contract = buildContract(schemas); + + expect(() => contract.documentTypeArrayProperties('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('documentArrayProperties', () => { + it('should key typed arrays by document type and omit types declaring none', () => { + const contract = buildContract(schemas); + const map = contract.documentArrayProperties as Map; + + expect([...map.keys()]).to.deep.equal(['charter']); + expect(map.get('charter')).to.deep.equal(contract.documentTypeArrayProperties('charter')); + }); + + it('should be empty for a contract declaring no typed arrays at all', () => { + const contract = buildContract({ plain: schemas.plain }); + + expect((contract.documentArrayProperties as Map).size).to.equal(0); + }); + }); +});