From 72597625878e7f896376a36c6bce82446440bbda Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 07:20:57 +0700 Subject: [PATCH 1/2] feat(platform)!: refersTo on typed array elements (PV14) An identifier element of a typed array may carry a refersTo on its items, which every element declares. The element parses to IdentifierWithReference(target) inside item_type through the same apply_property_reference a scalar identifier goes through; identityPublicKey is refused on an element. Registration checks the declaration as a single one, and document create and replace check every element as a single reference, refusing the first that fails with that reference's error named by the element's list path (reasons[2]). Registration caps the references one document can carry at SystemLimits::max_references_per_document (256) and refuses an immutable typed array of deletableDocument references. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/documents.md | 23 +- .../document/v3/document-meta.json | 42 +- .../v1/mod.rs | 4 +- .../class_methods/parse_typed_array/mod.rs | 6 +- .../class_methods/parse_typed_array/v0/mod.rs | 59 +- .../class_methods/try_from_schema/mod.rs | 14 +- .../class_methods/try_from_schema/v3/mod.rs | 90 ++ .../v3/typed_array_reference_tests.rs | 565 ++++++++++++ .../try_from_schema/v3/typed_array_tests.rs | 27 - .../document_type/index/preallocation.rs | 2 + .../methods/validate_update/common/mod.rs | 123 +++ .../document_type/property/mod.rs | 45 + .../document_reference_validation/mod.rs | 5 +- .../document_reference_validation/v0/mod.rs | 803 ++++++++++-------- .../batch/tests/document/mod.rs | 1 + .../tests/document/typed_array_references.rs | 607 +++++++++++++ .../v0/mod.rs | 26 +- .../data_contract_create/mod.rs | 61 ++ ...elements-agreement-missing-referenced.json | 54 ++ ...-elements-agreement-missing-referring.json | 54 ++ ...idation-contract-typed-array-elements.json | 194 +++++ .../src/query/chained_document_query/mod.rs | 2 + .../src/query/composite_document_query/mod.rs | 5 +- .../src/version/mocks/v2_test.rs | 1 + .../src/version/system_limits/mod.rs | 10 + .../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 | 6 + .../rs-platform-version/src/version/v14.rs | 31 + .../data_contract/document_type_reference.rs | 86 +- .../document_type_typed_arrays.rs | 46 +- packages/wasm-dpp2/src/data_contract/model.rs | 4 +- .../tests/unit/DocumentTypedArrays.spec.ts | 106 +++ 34 files changed, 2694 insertions(+), 411 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_reference_tests.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/typed_array_references.rs create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referenced.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referring.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements.json diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index bed07b9ae01..5833cb855a8 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -342,7 +342,7 @@ Up to protocol version 13 a `type: "array"` property had to be a byte array (`by } ``` -- An element is a scalar: an integer, a number, a string (with `minLength` / `maxLength`), a boolean, a byte array (`byteArray: true`, whose `minItems` / `maxItems` count bytes) or an identifier. Objects and arrays of arrays are refused, and so is `refersTo` on an element for now. An element may be limited to allowed values with `enum`; `const` is refused on elements, since a one-value `enum` does the same and a contract update can still widen it. The parser reads an element's `enum`, `minimum` and `maximum` onto the typed array (`ArrayItemConstraints`), refusing on both parse paths an `enum` with no member or a member of another type, an `enum` on a byte array or identifier element, and a `minimum` above the `maximum`, so random document generation stays inside them; the JSON schema validator enforces them on every document. +- An element is a scalar: an integer, a number, a string (with `minLength` / `maxLength`), a boolean, a byte array (`byteArray: true`, whose `minItems` / `maxItems` count bytes) or an identifier. Objects and arrays of arrays are refused. An identifier element may carry `refersTo` (see [References on the Elements](#references-on-the-elements)). An element may be limited to allowed values with `enum`; `const` is refused on elements, since a one-value `enum` does the same and a contract update can still widen it. The parser reads an element's `enum`, `minimum` and `maximum` onto the typed array (`ArrayItemConstraints`), refusing on both parse paths an `enum` with no member or a member of another type, an `enum` on a byte array or identifier element, and a `minimum` above the `maximum`, so random document generation stays inside them; the JSON schema validator enforces them on every document. - On the array itself `minItems` and `maxItems` count elements, not bytes. `maxItems` is required, `minItems` may not exceed it and `contentMediaType` belongs on the items; these hold on every parse. Contract registration also caps `maxItems` at `SystemLimits::max_typed_array_items` (1024), so a typed array's worst-case size, which fee estimation charges by, stays small. `uniqueItems: true` refuses a document that repeats an element. - A byte array keeps its form and takes no `items`. On a plain byte array `uniqueItems` keeps its old meaning, no repeated byte, but an identifier (a byte array with the identifier `contentMediaType`) refuses it: an identifier is one value, and "no repeated byte" would refuse most of them. - The document is validated against the JSON schema as always, so a list that is too long, too short, repeats an element under `uniqueItems` or holds a wrong-typed element fails with the usual `JsonSchemaError`. @@ -351,6 +351,27 @@ The array is stored inline in the document, like any other property: a varint el In Rust a typed array parses to `DocumentPropertyType::TypedArray(TypedArrayProperty)`, whose `item_type` is the `DocumentPropertyType` the `items` schema parses to as a property schema (`try_from_value_map` with the contract's parsing options). The parse is the versioned `parse_typed_array` (`None` before protocol version 14, where an array that is not a byte array is refused as it always was). The older `DocumentPropertyType::Array` variant, whose elements are an `ArrayItemType` in their own length-prefixed encoding, is never produced by the parser. +### References on the Elements + +An identifier element may carry a `refersTo` declaration, which every element of the list then declares. The moderation charters' `reasons` refers to `reason` documents this way: + +```json +"items": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "permanentDocument", "documentType": "reason" } +} +``` + +- The declaration takes every target and key a single identifier property's `refersTo` takes (`identity`, `contract` with `contractRequirements`, `token`, and `permanentDocument` and `deletableDocument` with `contractId`, `documentType` and `propertyAgreement`), with the same checks at contract registration, except `identityPublicKey` in either form: its `keyIdProperty` names one sibling key id, and the `identityProperty` form sits on the key id itself, neither of which can pair with many elements, so an element may not declare it. The declaration belongs on the `items`; on the array itself it is refused. +- When a document is created or replaced each element is checked as a single reference is, in list order: the target must exist, a referenced contract must meet the `contractRequirements`, a referenced document's type must be deletable or not as declared, and each `propertyAgreement` pair must hold. The referring side of a pair is still a property of the referring document or its `$ownerId`, the same for every element; the referenced side is a property of that element's referenced document. The first element that fails refuses the write with the error a single reference gives (`ReferencedEntityNotFoundError` 40120, `ReferencedDocumentPropertyMismatchError` 40127, `ReferencedContractRequirementNotMetError` 40135 and so on), whose `path` names the element by its list path: `reasons[2]` for the third. An empty or absent list checks nothing. Registration errors name the declaration `submittedCharter.reasons[]`. +- A replace re-validates the whole list when it changed or a property bound by a `propertyAgreement` changed, and on every replace when an agreement is keyed by `$ownerId` or the elements are `deletableDocument` references: the rules of a single reference, with the list as one property. The transition does not say which elements are new, so a changed list re-validates all of them. +- Every read is billed as a single reference's is. A foreign contract holding the referenced document type is fetched once per list, not once per element. Contract registration caps the references one document of a type can carry at `SystemLimits::max_references_per_document` (256), counting one per property with `refersTo` (an identifier, or a key id carrying a key reference) and `maxItems` per typed array of referencing elements: `maxItems` alone would let a type declare many lists of up to 1024 references each, and each one is a read when a document is written. +- An `immutable` property may not hold a typed array of `deletableDocument` references, at the top level or inside an immutable object. Every replace re-validates them, so once one target is deleted the list would have to change, which an immutable property cannot. A single `deletableDocument` reference has a way out, a replace may clear it once its target is gone, but that exception reads the one identifier the removed property held. +- A changed element `refersTo` is an incompatible schema change on contract update, as a changed `refersTo` on a scalar is. + +In Rust the element parses to `DocumentPropertyType::IdentifierWithReference(target)` inside `item_type`, through the same versioned `apply_property_reference` a scalar identifier goes through. `DocumentPropertyType::reference()` reports a property's declaration as `PropertyReference::Value(target)` for a scalar and `PropertyReference::Elements(target)` for a typed array; the registration check (`validate_data_contract_references`) and the write-time check (`validate_document_references`) both enumerate references through it. + ## Distinct Identifier Properties Protocol version 14 adds the property-level `distinctFrom` keyword, a pure structure rule on identifier properties: the property's value must differ from the value of a named property of the same document, or from the document's `$ownerId`. It sits next to the reference keywords (`refersTo` and its `propertyAgreement`, which bind a property to another document's values) but reads nothing beyond the transition being written. 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 caa195b5938..5485483dae3 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 (and, for an identityPublicKey reference with identityProperty, on the key id integer property), the requiredSince property keyword (the contract version a property is required from), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), 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 (and, for an identityPublicKey reference with identityProperty, on the key id integer property), the requiredSince property keyword (the contract version a property is required from), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), 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": { @@ -691,7 +691,7 @@ ] }, "documentArrayItem": { - "$comment": "The element schema of a typed array: one scalar, an integer, a number, a string, a boolean, or a byte array (byteArray: true), which with the identifier contentMediaType is an identifier. Objects and arrays of arrays are refused. An element carries no position, requiredSince, refersTo, uniqueItems, const or examples of its own (a one-value enum does what const would, and an update can widen it; examples annotate nothing an element needs). An enum's members are values of the element type (a byte array or identifier element takes none), and an integer element's minimum and maximum are integers; the parser reads them so random documents stay inside them", + "$comment": "The element schema of a typed array: one scalar, an integer, a number, a string, a boolean, or a byte array (byteArray: true), which with the identifier contentMediaType is an identifier. Objects and arrays of arrays are refused. An element carries no position, requiredSince, uniqueItems, const or examples of its own (a one-value enum does what const would, and an update can widen it; examples annotate nothing an element needs). An identifier element may carry a refersTo, which every element then declares. An enum's members are values of the element type (a byte array or identifier element takes none), and an integer element's minimum and maximum are integers; the parser reads them so random documents stay inside them", "type": "object", "properties": { "$comment": { @@ -760,6 +760,17 @@ "type": "string", "minLength": 1, "maxLength": 256 + }, + "refersTo": { + "description": "Only on identifier elements: what every element refers to, the refersTo declaration of an identifier property with the same keys and the same checks, except that identityPublicKey is refused: its keyIdProperty names one sibling key id, which cannot pair with many elements. When a document is created or replaced each element is checked as a single reference is, and the first one that fails refuses the write, its error naming the element by its list path (reasons[2] for the third). A propertyAgreement's referring side is still a property of the referring document or its $ownerId, the same for every element, and its referenced side a property of that element's referenced document. The declaration belongs on the items, not on the array. Available from protocol version 14.", + "$ref": "#/$defs/documentSchema/properties/refersTo", + "properties": { + "type": { + "not": { + "const": "identityPublicKey" + } + } + } } }, "required": [ @@ -767,6 +778,33 @@ ], "additionalProperties": false, "dependentSchemas": { + "refersTo": { + "description": "refersTo is only allowed on identifier elements", + "properties": { + "type": { + "const": "array" + }, + "byteArray": { + "const": true + }, + "contentMediaType": { + "const": "application/x.dash.dpp.identifier" + }, + "minItems": { + "const": 32 + }, + "maxItems": { + "const": 32 + } + }, + "required": [ + "type", + "byteArray", + "contentMediaType", + "minItems", + "maxItems" + ] + }, "distinctFrom": { "description": "distinctFrom is only allowed on identifier elements", "properties": { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs index d475b4ddf1f..ed5c0eb45f5 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs @@ -90,7 +90,9 @@ impl DocumentType { for (name, document_type) in &contract_document_types { for (path, property) in document_type.as_ref().flattened_properties() { - // Both forms of the key reference carry the same requirements + // Both forms of the key reference carry the same requirements. + // Only a scalar property can hold either: the typed array + // parser refuses identityPublicKey on an element let key_requirements = match &property.property_type { DocumentPropertyType::IdentifierWithReference( DocumentPropertyReferenceTarget::IdentityPublicKey { diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs index d26a1fc9053..89eac48eb62 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/mod.rs @@ -16,7 +16,9 @@ mod v0; /// Returns `None` for every other property, a byte array included, which the /// caller leaves to `DocumentPropertyType::try_from_value_map`. The element /// schema is parsed by that same scalar parser with the property's `options`, -/// so an element has the type a scalar property of its schema would have. +/// so an element has the type a scalar property of its schema would have, +/// and a `refersTo` on identifier elements is folded in by the same +/// `apply_property_reference` a scalar identifier goes through. /// /// Versioned on `parse_typed_array` in the platform version's document type /// schema versions. `None` selects the behavior of the versions that predate @@ -35,7 +37,7 @@ pub(crate) fn parse_typed_array( .parse_typed_array { None => Ok(None), - Some(0) => v0::parse_typed_array_v0(inner_properties, options), + Some(0) => v0::parse_typed_array_v0(inner_properties, options, platform_version), Some(version) => Err(DataContractError::Unsupported(format!( "parse_typed_array version {version} is not supported" ))), diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs index 898f7fc510e..341ae9a49eb 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs @@ -2,8 +2,10 @@ use std::collections::BTreeMap; use platform_value::btreemap_extensions::BTreeValueMapHelper; use platform_value::Value; +use platform_version::version::PlatformVersion; use crate::data_contract::document_type::array::{ArrayItemConstraints, TypedArrayProperty}; +use crate::data_contract::document_type::class_methods::try_from_schema::apply_property_reference; use crate::data_contract::document_type::{ property_names, DocumentPropertyType, DocumentPropertyTypeParsingOptions, }; @@ -20,6 +22,7 @@ use crate::data_contract::errors::DataContractError; pub(super) fn parse_typed_array_v0( inner_properties: &BTreeMap, options: &DocumentPropertyTypeParsingOptions, + platform_version: &PlatformVersion, ) -> Result, DataContractError> { let is_array = inner_properties .get(property_names::TYPE) @@ -46,7 +49,7 @@ pub(super) fn parse_typed_array_v0( )); } - let item_type = parse_element_type(items, options)?; + let item_type = parse_element_type(items, options, platform_version)?; let item_constraints = parse_item_constraints(items, &item_type)?; // Fee estimation sizes the inline list by its bound @@ -80,12 +83,21 @@ pub(super) fn parse_typed_array_v0( /// bounds give it and a byte array element with the identifier media type is /// an identifier. Objects and arrays of arrays are refused. /// -/// `refersTo` is refused for now. A reference on identifier elements would be -/// read from this same map and folded into the element type, as -/// `apply_property_reference` folds one into a scalar identifier. +/// A `refersTo` on identifier elements is folded into the element type by +/// `apply_property_reference`, the function (and the version of it) that +/// folds one into a scalar identifier, so an element reference has the +/// scalar's target types, keys and checks: the element becomes +/// `IdentifierWithReference(target)`. The one target refused is +/// `identityPublicKey`, in either form: its `keyIdProperty` names a single +/// sibling key id, and an `identityProperty` declaration sits on the key id +/// itself, neither of which can pair with many elements. The contract-level checks of the +/// declaration (the referenced document type, the `propertyAgreement` sides +/// and value kinds) need other contracts and run at registration in +/// drive-abci, which visits element references too. fn parse_element_type( items: &Value, options: &DocumentPropertyTypeParsingOptions, + platform_version: &PlatformVersion, ) -> Result { // The tuple form (`items: [..]`) and boolean schemas are not one element // schema @@ -99,11 +111,6 @@ fn parse_element_type( "the items of a typed array must be an inline element schema, not a $ref".to_string(), )); } - if items_map.contains_key(property_names::REFERS_TO) { - return Err(DataContractError::InvalidContractStructure( - "refersTo is not supported on the elements of a typed array".to_string(), - )); - } match items_map .get(property_names::TYPE) .and_then(|type_value| type_value.as_text()) @@ -126,6 +133,32 @@ fn parse_element_type( } let element_type = DocumentPropertyType::try_from_value_map(&items_map, options)?; + let element_type = match items_map.get(property_names::REFERS_TO) { + None => element_type, + Some(refers_to) => { + if !matches!(element_type, DocumentPropertyType::Identifier) { + return Err(DataContractError::InvalidContractStructure( + "refersTo is only allowed on identifier elements of a typed array".to_string(), + )); + } + // Either identityPublicKey form pairs one key id with the + // reference, a sibling property (keyIdProperty) or the property + // itself (identityProperty), which cannot pair with many elements + let reference_type = refers_to + .to_btree_ref_string_map()? + .get(property_names::TYPE) + .and_then(|reference_type| reference_type.as_text()); + if reference_type == Some("identityPublicKey") { + return Err(DataContractError::InvalidContractStructure( + "identityPublicKey refersTo is not allowed on the elements of a typed array: \ + it pairs one key id with the reference, which cannot pair with many \ + elements" + .to_string(), + )); + } + apply_property_reference(&items_map, element_type, platform_version)? + } + }; match element_type { DocumentPropertyType::U128 | DocumentPropertyType::I128 @@ -141,6 +174,7 @@ fn parse_element_type( | DocumentPropertyType::String(_) | DocumentPropertyType::ByteArray(_) | DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_) | DocumentPropertyType::Boolean => Ok(element_type), other => Err(DataContractError::InvalidContractStructure(format!( "unsupported typed array element type: {}", @@ -263,7 +297,11 @@ mod tests { let map = schema .to_btree_ref_string_map() .expect("the schema is a map"); - parse_typed_array_v0(&map, &DocumentPropertyTypeParsingOptions::default()) + parse_typed_array_v0( + &map, + &DocumentPropertyTypeParsingOptions::default(), + PlatformVersion::latest(), + ) } #[test] @@ -418,6 +456,7 @@ mod tests { &DocumentPropertyTypeParsingOptions { sized_integer_types, }, + PlatformVersion::latest(), ) .expect("parses"); let Some(DocumentPropertyType::TypedArray(typed_array)) = parsed else { 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 b393086a8c0..faf99d5cf1f 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 @@ -531,12 +531,15 @@ fn validate_distinct_from_targets_v0( /// property with an `identityPublicKey` declaration naming `identityProperty` /// becomes `KeyIdWithReference(identity property)`. No other property can carry /// `refersTo`. +/// The typed array parser calls it on an identifier element's `items` +/// schema, so an element reference is read by exactly this code; on the +/// array itself the declaration is refused, it belongs on the items. /// /// Versioned on `apply_property_reference` in the platform version's document /// type schema versions. `None` selects the behavior of the versions that /// predate the keyword: it is ignored entirely, so their parses stay /// byte-for-byte identical to what they always produced. -fn apply_property_reference( +pub(in crate::data_contract::document_type::class_methods) fn apply_property_reference( inner_properties: &BTreeMap, property_type: DocumentPropertyType, platform_version: &PlatformVersion, @@ -564,6 +567,15 @@ fn apply_property_reference_v0( return Ok(property_type); }; + // A typed array only exists from protocol version 14, where its element + // reference is read off the items by the typed array parser + if matches!(property_type, DocumentPropertyType::TypedArray(_)) { + return Err(DataContractError::InvalidContractStructure( + "refersTo on a typed array belongs on its items, where it applies to every element" + .to_string(), + )); + } + let refers_to_map = refers_to_value.to_btree_ref_string_map()?; let reference_type = refers_to_map 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 d7271f95148..668efd871af 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 @@ -25,6 +25,10 @@ use crate::data_contract::document_type::index::Index; use crate::data_contract::document_type::index::IndexGrammarAdmissions; #[cfg(feature = "validation")] use crate::data_contract::document_type::property::DocumentPropertyType; +#[cfg(feature = "validation")] +use crate::data_contract::document_type::property::{ + DocumentPropertyReferenceTarget, PropertyReference, +}; use crate::data_contract::document_type::property_names; use crate::data_contract::document_type::v2::DocumentTypeV2; use crate::data_contract::document_type::DocumentType; @@ -446,6 +450,8 @@ fn try_from_schema_generation_3( #[cfg(feature = "validation")] if full_validation { validate_typed_array_max_items(&v2, name, platform_version)?; + validate_reference_count(&v2, name, platform_version)?; + validate_no_immutable_deletable_element_references(&v2, name)?; } Ok(v2) @@ -483,6 +489,88 @@ fn validate_typed_array_max_items( Ok(()) } +/// The references one document of the type can carry, one for each +/// property declaring `refersTo` (an identifier, or a key id with a key +/// reference) and `maxItems` for each typed array whose elements declare it, +/// are at most +/// `SystemLimits::max_references_per_document`. Every reference is a billed +/// state read when the document is created or replaced, so the sum bounds +/// the reads one write can cause; `max_typed_array_items` alone would let a +/// type declare many arrays of that many references each. +/// +/// Full validation only, like the typed array cap: a stored contract was +/// checked when it was registered. +#[cfg(feature = "validation")] +fn validate_reference_count( + document_type: &DocumentTypeV2, + name: &str, + platform_version: &PlatformVersion, +) -> Result<(), ProtocolError> { + let limit = platform_version.system_limits.max_references_per_document; + let references: u32 = document_type + .flattened_properties() + .values() + .map( + |property| match (&property.property_type, property.property_type.reference()) { + ( + DocumentPropertyType::TypedArray(typed_array), + Some(PropertyReference::Elements(_)), + ) => u32::from(typed_array.max_items), + // A key reference declared on the key id itself is one key + // read, as an identifier's is + (_, Some(_)) | (DocumentPropertyType::KeyIdWithReference(_), None) => 1, + (_, None) => 0, + }, + ) + .sum(); + if references > u32::from(limit) { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "document type \"{name}\" declares references for up to {references} values per \ + document (one per property with refersTo, maxItems per typed array of \ + referencing elements), above the maximum of {limit}", + )), + )); + } + Ok(()) +} + +/// An `immutable` property may not hold a typed array of `deletableDocument` +/// references, directly or inside an immutable object. Every replace +/// re-validates such a reference, so once one element's target is deleted +/// the array would have to change, which an immutable property cannot: the +/// document could never be replaced again. A single `deletableDocument` +/// reference has a way out, the replace state validation lets a dead one be +/// cleared, and that exception reads the one identifier the removed +/// property held, which a list does not give it. +#[cfg(feature = "validation")] +fn validate_no_immutable_deletable_element_references( + document_type: &DocumentTypeV2, + name: &str, +) -> Result<(), ProtocolError> { + for (path, property) in document_type.flattened_properties() { + let Some(PropertyReference::Elements(DocumentPropertyReferenceTarget::DeletableDocument { + .. + })) = property.property_type.reference() + else { + continue; + }; + let top_level = path.split('.').next().unwrap_or(path); + if document_type.immutable_fields.contains(top_level) { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "document type \"{name}\" lists \"{top_level}\" as immutable, but \"{path}\" is \ + a typed array of deletableDocument references: every replace re-validates \ + them, so once one target is deleted the array would have to change and the \ + document could never be replaced again. Use permanentDocument references or \ + leave the array mutable", + )), + )); + } + } + Ok(()) +} + impl DocumentType { /// Dispatches to this module's generation-3 parser and wraps the result. #[allow(clippy::too_many_arguments)] @@ -529,6 +617,8 @@ mod meta_schema_v0_stray_keyword_tests; mod moderators_delete_tests; #[cfg(all(test, feature = "validation"))] mod name_rules_tests; +#[cfg(all(test, feature = "validation"))] +mod typed_array_reference_tests; #[cfg(all(test, feature = "validation", feature = "random-documents"))] mod typed_array_tests; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_reference_tests.rs new file mode 100644 index 00000000000..e0cae9fb9f6 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_reference_tests.rs @@ -0,0 +1,565 @@ +//! `refersTo` on the elements of a typed array: an identifier element's +//! `items` schema may carry the reference declaration a single identifier +//! property takes, and the element then parses to +//! `IdentifierWithReference(target)` inside the array's `item_type`. +//! +//! The declaration is read by `apply_property_reference`, the function a +//! scalar identifier goes through, from `parse_typed_array` 0 (protocol +//! version 14). The checks that need other contracts (the referenced +//! document type, the `propertyAgreement` sides and value kinds) run at +//! registration in drive-abci and are tested there. + +use super::*; +use crate::consensus::basic::json_schema_error::JsonSchemaError; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::array::TypedArrayProperty; +use crate::data_contract::document_type::{ + ContractReferenceModeration, ContractReferenceRequirements, DocumentPropertyReferenceTarget, + DocumentPropertyType, PropertyReference, +}; +use crate::data_contract::errors::DataContractError; +use crate::data_contract::serialized_version::v0::DataContractInSerializationFormatV0; +use crate::data_contract::DataContract; +use platform_value::platform_value; +use platform_value::string_encoding::Encoding; + +fn parse_dispatched( + schema: Value, + platform_version: &PlatformVersion, + full_validation: bool, +) -> Result { + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available on this platform version"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "submittedCharter", + schema, + None, + &BTreeMap::new(), + &config, + full_validation, + &mut vec![], + platform_version, + ) +} + +/// An identifier element schema, carrying `refers_to` when given. +fn identifier_items(refers_to: Option) -> Value { + let mut items = platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }); + if let Some(refers_to) = refers_to { + items + .set_value("refersTo", refers_to) + .expect("refersTo applies"); + } + items +} + +/// The `reasons` declaration of the moderation charters contract with the +/// given `items`: up to 64 distinct elements. +fn reasons_with_items(items: Value) -> Value { + platform_value!({ + "type": "array", + "minItems": 0, + "maxItems": 64, + "uniqueItems": true, + "items": items, + "position": 0 + }) +} + +/// A document type with `reasons` and a string `topic` a propertyAgreement +/// can name. +fn schema_with_reasons(reasons: Value) -> Value { + platform_value!({ + "type": "object", + "properties": { + "reasons": reasons, + "topic": { "type": "string", "maxLength": 32, "position": 1 } + }, + "additionalProperties": false + }) +} + +fn reasons_type(document_type: &DocumentType) -> DocumentPropertyType { + document_type + .as_ref() + .flattened_properties() + .get("reasons") + .map(|property| property.property_type.clone()) + .expect("the reasons property is parsed") +} + +fn expect_json_schema_error( + result: Result, +) -> JsonSchemaError { + match result { + Err(ProtocolError::ConsensusError(boxed)) => match *boxed { + ConsensusError::BasicError(BasicError::JsonSchemaError(error)) => error, + other => panic!("expected a JSON schema error, got {other:?}"), + }, + other => panic!("expected a JSON schema error, got {other:?}"), + } +} + +fn expect_structure_error(result: Result, needle: &str) { + let message = match result { + Err(ProtocolError::DataContractError(DataContractError::InvalidContractStructure( + message, + ))) => message, + Err(ProtocolError::ConsensusError(boxed)) => match *boxed { + ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(message), + )) => message, + other => panic!("expected InvalidContractStructure, got {other:?}"), + }, + other => panic!("expected InvalidContractStructure, got {other:?}"), + }; + assert!( + message.contains(needle), + "expected {needle:?} in the error, got: {message}" + ); +} + +/// Every target type a single identifier property takes, `identityPublicKey` +/// aside, with the same keys, folded into the element exactly as a scalar +/// reference is folded into its property. +#[test] +fn should_parse_an_element_reference_of_each_target_type() { + let foreign_contract = Identifier::new([9; 32]); + for (refers_to, expected) in [ + ( + platform_value!({ "type": "identity" }), + DocumentPropertyReferenceTarget::Identity, + ), + ( + platform_value!({ + "type": "contract", + "contractRequirements": { "moderation": "elected", "minimumAgeSeconds": 60 } + }), + DocumentPropertyReferenceTarget::Contract { + contract_requirements: ContractReferenceRequirements { + moderation: Some(ContractReferenceModeration::Elected), + minimum_age_seconds: Some(60), + ..Default::default() + }, + }, + ), + ( + platform_value!({ "type": "token" }), + DocumentPropertyReferenceTarget::Token, + ), + ( + platform_value!({ + "type": "permanentDocument", + "documentType": "reason", + "propertyAgreement": { "topic": "topic", "$ownerId": "$ownerId" } + }), + DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: None, + document_type_name: "reason".to_string(), + property_agreement: BTreeMap::from([ + ("$ownerId".to_string(), "$ownerId".to_string()), + ("topic".to_string(), "topic".to_string()), + ]), + }, + ), + ( + platform_value!({ + "type": "deletableDocument", + "contractId": foreign_contract.to_string(Encoding::Base58), + "documentType": "draft" + }), + DocumentPropertyReferenceTarget::DeletableDocument { + contract_id: Some(foreign_contract), + document_type_name: "draft".to_string(), + property_agreement: BTreeMap::new(), + }, + ), + ] { + let schema = schema_with_reasons(reasons_with_items(identifier_items(Some( + refers_to.clone(), + )))); + let expected_type = DocumentPropertyType::TypedArray(TypedArrayProperty { + item_type: Box::new(DocumentPropertyType::IdentifierWithReference( + expected.clone(), + )), + item_constraints: Default::default(), + min_items: Some(0), + max_items: 64, + unique_items: true, + }); + // The meta-schema admits it, and the stored path reads it the same + for full_validation in [true, false] { + let document_type = + parse_dispatched(schema.clone(), PlatformVersion::latest(), full_validation) + .unwrap_or_else(|error| { + panic!("{refers_to:?} (full validation {full_validation}): {error}") + }); + let parsed = reasons_type(&document_type); + assert_eq!(parsed, expected_type, "{refers_to:?}"); + assert_eq!( + parsed.reference(), + Some(PropertyReference::Elements(&expected)), + "{refers_to:?}" + ); + } + } +} + +/// `keyIdProperty` names one sibling key id, which cannot pair with many +/// elements. +#[test] +fn should_refuse_an_identity_public_key_reference_on_an_element() { + let schema = platform_value!({ + "type": "object", + "properties": { + "reasons": reasons_with_items(identifier_items(Some(platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "keyId" + })))), + "keyId": { "type": "integer", "minimum": 0, "maximum": 4294967295u64, "position": 1 } + }, + "additionalProperties": false + }); + + let error = expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + assert!( + error.instance_path().contains("/reasons/items/refersTo"), + "the meta-schema refuses the element declaration, got {}", + error.instance_path() + ); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "identityPublicKey refersTo is not allowed on the elements of a typed array", + ); + + // Nor the form declared on the key id itself, which names whose key it is + let schema = schema_with_reasons(reasons_with_items(identifier_items(Some( + platform_value!({ "type": "identityPublicKey", "identityProperty": "$ownerId" }), + )))); + expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "identityPublicKey refersTo is not allowed on the elements of a typed array", + ); +} + +#[test] +fn should_refuse_refers_to_on_a_non_identifier_element() { + for items in [ + platform_value!({ "type": "integer", "refersTo": { "type": "identity" } }), + platform_value!({ "type": "string", "maxLength": 8, "refersTo": { "type": "identity" } }), + // A byte array that is not an identifier + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "refersTo": { "type": "identity" } + }), + ] { + let schema = schema_with_reasons(reasons_with_items(items.clone())); + let error = expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + // The identifier-only rule names the element keyword it fails on + assert!( + error.instance_path().contains("/reasons/items"), + "{items:?}: the meta-schema refuses it on the element, got {}", + error.instance_path() + ); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "refersTo is only allowed on identifier elements of a typed array", + ); + } +} + +/// The declaration applies to every element, so it goes on the items. +#[test] +fn should_refuse_refers_to_on_the_typed_array_itself() { + let mut reasons = reasons_with_items(identifier_items(None)); + reasons + .set_value("refersTo", platform_value!({ "type": "identity" })) + .expect("refersTo applies"); + let schema = schema_with_reasons(reasons); + + expect_json_schema_error(parse_dispatched( + schema.clone(), + PlatformVersion::latest(), + true, + )); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + "refersTo on a typed array belongs on its items", + ); +} + +/// The rules the parse itself holds for a `propertyAgreement`, identical for +/// an element declaration: only `$ownerId` among the referring document's +/// system properties, only `$ownerId` and `$creatorId` among the referenced +/// document's. Whether a named schema property exists on either side is +/// checked at registration against the referenced contract (drive-abci). +#[test] +fn should_refuse_an_element_property_agreement_naming_an_unusable_system_property() { + for (agreement, fragment) in [ + ( + platform_value!({ "$createdAt": "topic" }), + "propertyAgreement keys must name a schema property", + ), + ( + platform_value!({ "topic": "$id" }), + "propertyAgreement values must name a schema property", + ), + ] { + let schema = schema_with_reasons(reasons_with_items(identifier_items(Some( + platform_value!({ + "type": "permanentDocument", + "documentType": "reason", + "propertyAgreement": agreement.clone() + }), + )))); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), false), + fragment, + ); + } +} + +/// Typed arrays, and so their element references, are protocol version 14 +/// grammar: meta-schema v2 refuses an array that is not a byte array. +#[test] +fn should_refuse_an_element_reference_before_protocol_version_14_and_accept_it_at_14() { + let schema = schema_with_reasons(reasons_with_items(identifier_items(Some( + platform_value!({ "type": "permanentDocument", "documentType": "reason" }), + )))); + + let platform_version_13 = PlatformVersion::get(13).expect("protocol version 13 exists"); + expect_json_schema_error(parse_dispatched(schema.clone(), platform_version_13, true)); + + let document_type = parse_dispatched(schema, PlatformVersion::latest(), true) + .expect("protocol version 14 admits an element reference"); + assert!(matches!( + reasons_type(&document_type).reference(), + Some(PropertyReference::Elements( + DocumentPropertyReferenceTarget::PermanentDocument { .. } + )) + )); +} + +/// A document type whose `reasons` carries `max_items` references, next to +/// `scalar_references` identifier properties with their own `refersTo`. +fn schema_with_references(max_items: u16, scalar_references: u32) -> Value { + let mut properties = BTreeMap::from([( + "reasons".to_string(), + platform_value!({ + "type": "array", + "maxItems": max_items, + "items": identifier_items(Some(platform_value!({ "type": "identity" }))), + "position": 0 + }), + )]); + for position in 1..=scalar_references { + let mut property = identifier_items(Some(platform_value!({ "type": "identity" }))); + property + .set_value("position", Value::U32(position)) + .expect("position applies"); + properties.insert(format!("ref{position}"), property); + } + platform_value!({ + "type": "object", + "properties": Value::from(properties), + "additionalProperties": false + }) +} + +/// Each element is a billed read when a document is written, so the +/// references one document can carry are bounded at registration: a typed +/// array counts its `maxItems`, a single reference one. +#[test] +fn should_bound_the_references_one_document_can_carry() { + let platform_version = PlatformVersion::latest(); + let limit = platform_version.system_limits.max_references_per_document; + assert!( + limit < platform_version.system_limits.max_typed_array_items, + "the bound is below one maximal typed array" + ); + + parse_dispatched(schema_with_references(limit - 2, 2), platform_version, true) + .expect("a type carrying exactly the maximum registers"); + + expect_structure_error( + parse_dispatched(schema_with_references(limit - 1, 2), platform_version, true), + &format!( + "declares references for up to {} values", + u32::from(limit) + 1 + ), + ); + expect_structure_error( + parse_dispatched(schema_with_references(limit + 1, 0), platform_version, true), + &format!("above the maximum of {limit}"), + ); + + // A stored contract was checked when it was registered + parse_dispatched( + schema_with_references(limit + 1, 0), + platform_version, + false, + ) + .expect("the stored path does not re-apply a registration limit"); +} + +/// Every replace re-validates a `deletableDocument` element, and an +/// immutable array could not drop a dead one, so the pair is refused. +#[test] +fn should_refuse_an_immutable_typed_array_of_deletable_document_references() { + let schema_with = |refers_to: Value, immutable: Value| { + let mut schema = schema_with_reasons(reasons_with_items(identifier_items(Some(refers_to)))); + schema + .set_value("documentsMutable", Value::Bool(true)) + .expect("documentsMutable applies"); + schema + .set_value("immutable", immutable) + .expect("immutable applies"); + schema + }; + + expect_structure_error( + parse_dispatched( + schema_with( + platform_value!({ "type": "deletableDocument", "documentType": "draft" }), + platform_value!(["reasons"]), + ), + PlatformVersion::latest(), + true, + ), + "is a typed array of deletableDocument references", + ); + + // Permanent targets never die, and a mutable array can drop a dead one + parse_dispatched( + schema_with( + platform_value!({ "type": "permanentDocument", "documentType": "reason" }), + platform_value!(["reasons"]), + ), + PlatformVersion::latest(), + true, + ) + .expect("an immutable array of permanentDocument references registers"); + parse_dispatched( + schema_with( + platform_value!({ "type": "deletableDocument", "documentType": "draft" }), + platform_value!(["topic"]), + ), + PlatformVersion::latest(), + true, + ) + .expect("a mutable array of deletableDocument references registers"); +} + +/// A contract with `reason` (never deleted) and `submittedCharter`, whose +/// `reasons` elements refer to reasons of the same contract. +fn charter_contract(platform_version: &PlatformVersion) -> DataContract { + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available on this platform version"); + let reason = platform_value!({ + "type": "object", + "canBeDeleted": false, + "properties": { + "topic": { "type": "string", "maxLength": 32, "position": 0 } + }, + "additionalProperties": false + }); + let submitted_charter = schema_with_reasons(reasons_with_items(identifier_items(Some( + platform_value!({ + "type": "permanentDocument", + "documentType": "reason", + "propertyAgreement": { "topic": "topic" } + }), + )))); + + DataContract::try_from_platform_versioned( + DataContractInSerializationFormatV0 { + id: Identifier::new([7; 32]), + config, + version: 1, + owner_id: Identifier::new([8; 32]), + schema_defs: None, + document_schemas: BTreeMap::from([ + ("reason".to_string(), reason), + ("submittedCharter".to_string(), submitted_charter), + ]), + } + .into(), + true, + &mut vec![], + platform_version, + ) + .expect("the charter contract registers") +} + +#[test] +fn should_round_trip_a_contract_with_an_element_reference_through_platform_serialization() { + use crate::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, + PlatformSerializableWithPlatformVersion, + }; + + let platform_version = PlatformVersion::latest(); + let contract = charter_contract(platform_version); + + let bytes = contract + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the contract serializes"); + for full_validation in [true, false] { + let restored = DataContract::versioned_deserialize_untrusted( + &bytes, + full_validation, + platform_version, + ) + .expect("the contract deserializes"); + + assert_eq!(restored, contract); + let charter = restored + .document_type_for_name("submittedCharter") + .expect("the charter type exists"); + let reasons = charter + .flattened_properties() + .get("reasons") + .expect("reasons is parsed"); + assert_eq!( + reasons.property_type.reference(), + Some(PropertyReference::Elements( + &DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: None, + document_type_name: "reason".to_string(), + property_agreement: BTreeMap::from([( + "topic".to_string(), + "topic".to_string() + )]), + } + )) + ); + } +} 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 01337439357..af33373096a 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 @@ -536,33 +536,6 @@ fn should_refuse_items_on_a_byte_array_and_unique_items_on_an_identifier() { .expect("uniqueItems on a plain byte array parses"); } -#[test] -fn should_refuse_refers_to_on_the_elements_of_a_typed_array() { - let list = platform_value!({ - "type": "array", - "maxItems": 4, - "items": { - "type": "array", - "byteArray": true, - "minItems": 32, - "maxItems": 32, - "contentMediaType": "application/x.dash.dpp.identifier", - "refersTo": { "type": "identity" } - }, - "position": 0 - }); - - expect_json_schema_error(parse_dispatched( - schema_with_list(list.clone()), - PlatformVersion::latest(), - true, - )); - expect_structure_error( - parse_dispatched(schema_with_list(list), PlatformVersion::latest(), false), - "refersTo is not supported on the elements of a typed array", - ); -} - /// An element may be limited to allowed values with `enum`, but takes no /// `const` (a list of one repeated value carries only its length, and a /// one-value `enum` does the same while an update can still widen it) and no diff --git a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs index 3186f70dcc8..b96f40a863b 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs @@ -106,6 +106,8 @@ impl Index { let Some(property) = flattened_properties.get(&candidate.name) else { continue; }; + // Only a scalar reference can bind: an index property is never a + // typed array, so element references never reach an index let DocumentPropertyType::IdentifierWithReference( DocumentPropertyReferenceTarget::PermanentDocument { contract_id, diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs index 132d9a0764b..30228187644 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs @@ -2112,6 +2112,129 @@ mod tests { } } + /// `reasons`, a typed array of identifiers, with `refersTo` on its items as given, + /// next to a `topic` an agreement can name. + fn element_reference_document_type( + refers_to: Option, + platform_version: &PlatformVersion, + ) -> DocumentType { + let mut items = platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }); + if let Some(refers_to) = refers_to { + items + .insert("refersTo".to_string(), refers_to) + .expect("should insert refersTo"); + } + + let schema = platform_value!({ + "type": "object", + "properties": { + "reasons": { + "type": "array", + "maxItems": 64, + "items": items, + "position": 0 + }, + "topic": { "type": "string", "maxLength": 32, "position": 1 } + }, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + "submittedCharter", + schema, + None, + &BTreeMap::new(), + &config, + true, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + /// An element reference is frozen like a scalar one: stored documents were + /// checked against the declaration they were written under, so adding, + /// removing or changing it is an incompatible schema change. + #[test] + fn should_refuse_a_contract_update_that_changes_an_element_refers_to() { + let platform_version = PlatformVersion::latest(); + let permanent = + platform_value!({ "type": "permanentDocument", "documentType": "reason" }); + + for (old_refers_to, new_refers_to, changed_path) in [ + ( + None, + Some(permanent.clone()), + "/properties/reasons/items/refersTo", + ), + ( + Some(permanent.clone()), + None, + "/properties/reasons/items/refersTo", + ), + ( + Some(permanent.clone()), + Some( + platform_value!({ "type": "deletableDocument", "documentType": "reason" }), + ), + "/properties/reasons/items/refersTo/type", + ), + ( + Some(permanent.clone()), + Some(platform_value!({ "type": "permanentDocument", "documentType": "rule" })), + "/properties/reasons/items/refersTo/documentType", + ), + ( + Some(permanent.clone()), + Some(platform_value!({ + "type": "permanentDocument", + "documentType": "reason", + "propertyAgreement": { "topic": "topic" } + })), + "/properties/reasons/items/refersTo/propertyAgreement", + ), + ] { + let old_document_type = + element_reference_document_type(old_refers_to.clone(), platform_version); + let new_document_type = + element_reference_document_type(new_refers_to.clone(), platform_version); + + let result = old_document_type + .as_ref() + .validate_update(new_document_type.as_ref(), 2, platform_version) + .expect("validate_update should not error"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.property_path() == changed_path, + "{old_refers_to:?} -> {new_refers_to:?}: {:?}", + result.errors + ); + } + + // An unchanged declaration is no change + let document_type = element_reference_document_type(Some(permanent), platform_version); + let result = document_type + .as_ref() + .validate_update(document_type.as_ref(), 2, platform_version) + .expect("validate_update should not error"); + assert!(result.is_valid(), "{:?}", result.errors); + } + /// `toUserId` and `delegateId`, two identifier properties, with `distinctFrom` on /// `delegateId` as given. fn distinct_from_document_type( 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 3ff74331974..5afe31ad220 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 @@ -860,6 +860,33 @@ impl DocumentPropertyReferenceTarget { } } +/// A property's `refersTo` declaration and what holds the reference: the +/// property's own value, or every element of a typed array of identifiers. +/// Returned by [`DocumentPropertyType::reference`], which is how the +/// registration and write-time validators enumerate a document type's +/// references, so neither kind can be skipped by a caller matching only +/// [`DocumentPropertyType::IdentifierWithReference`]. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum PropertyReference<'a> { + /// An identifier property: its value is the referenced id. + Value(&'a DocumentPropertyReferenceTarget), + /// A typed array whose elements are identifiers carrying `refersTo` + /// (declared on its `items`): each element is a referenced id, all of + /// them to this one target. Never an + /// [`DocumentPropertyReferenceTarget::IdentityPublicKey`], which the + /// parser refuses on an element. + Elements(&'a DocumentPropertyReferenceTarget), +} + +impl<'a> PropertyReference<'a> { + /// The declaration, whatever holds the reference. + pub fn target(&self) -> &'a DocumentPropertyReferenceTarget { + match self { + PropertyReference::Value(target) | PropertyReference::Elements(target) => target, + } + } +} + /// The system properties of a referenced document that the referenced side /// of a `propertyAgreement` pair may name, next to the referenced document /// type's schema properties: `$ownerId`, the current owner (which follows @@ -1180,6 +1207,24 @@ impl DocumentPropertyType { } } + /// The `refersTo` declaration this property carries, on its own value + /// (an identifier property) or on every element (a typed array whose + /// `items` declare it); `None` for a property without one. + pub fn reference(&self) -> Option> { + match self { + DocumentPropertyType::IdentifierWithReference(target) => { + Some(PropertyReference::Value(target)) + } + DocumentPropertyType::TypedArray(typed_array) => match typed_array.item_type.as_ref() { + DocumentPropertyType::IdentifierWithReference(target) => { + Some(PropertyReference::Elements(target)) + } + _ => None, + }, + _ => None, + } + } + /// How a value of this scalar type is laid out in a stored document, in /// the words a contract update error uses. The layout of two scalar types /// is the same exactly when they give the same answer. The schema chooses diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs index 7f462b08cbd..edf4ee7dc03 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs @@ -37,7 +37,10 @@ pub(crate) trait DocumentReferenceValidation { platform_version: &PlatformVersion, ) -> Result; - /// Validates the document's `refersTo` references against platform state. + /// Validates the document's `refersTo` references against platform state: + /// an identifier property's value, and each element of a typed array whose + /// `items` declare one, which is refused with the error a single reference + /// would give and named by its list path (`reasons[2]`). /// /// When `changed_fields` is provided (replace transitions), only references on /// those fields are validated. A reference also counts as changed when a diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs index c5705e76d33..1b8c07d228a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs @@ -13,7 +13,7 @@ use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::{ is_referring_system_agreement_property, DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentTypeRef, IdentityKeyReferenceRequirements, - KeyReferenceIdentityProperty, ReferringWrite, + KeyReferenceIdentityProperty, PropertyReference, ReferringWrite, }; use dpp::data_contract::DataContract; use dpp::document::property_names::{CREATOR_ID, OWNER_ID}; @@ -36,6 +36,8 @@ use dpp::platform_value::Value; use dpp::validation::SimpleConsensusValidationResult; use dpp::version::PlatformVersion; use std::borrow::Cow; +use std::sync::Arc; +use drive::drive::contract::DataContractFetchInfo; use drive::drive::identity::key::fetch::{ IdentityKeysRequest, OptionalSingleIdentityPublicKeyOutcome, }; @@ -115,7 +117,10 @@ impl DocumentReferenceValidationV0 for DocumentBaseTransitionAction { .get(property) .map(|property| &property.property_type) else { - // Not a deletableDocument reference: nothing can be "gone" + // Not a deletableDocument reference: nothing can be "gone". A + // typed array of deletableDocument references never gets here + // either: the parser refuses one on an immutable property, the + // only kind this exception serves return Ok(false); }; @@ -215,8 +220,12 @@ fn validate_document_type_references_v0( platform_version: &PlatformVersion, ) -> Result { for (path, property) in document_type.flattened_properties() { - let reference_target = match &property.property_type { - DocumentPropertyType::IdentifierWithReference(reference_target) => reference_target, + // A reference is an identifier property's value, or each element of + // a typed array of identifiers whose `items` declare it (protocol + // version 14, the version whose document create and replace state + // validation call this; no document type of an earlier version can + // hold a typed array, so the element arm is never reached there) + let reference = match &property.property_type { // A key reference on the key id property itself: the value is the // key id and the declaration names whose key it is. A transfer // itself is not checked, so the reference governs writing, not @@ -263,8 +272,12 @@ fn validate_document_type_references_v0( } continue; } - _ => continue, + property_type => match property_type.reference() { + Some(reference) => reference, + None => continue, + }, }; + let reference_target = reference.target(); if let Some(changed) = changed_fields { // Some targets bind a sibling property of the same document to @@ -279,6 +292,12 @@ fn validate_document_type_references_v0( // appears among the changed fields, and either document may have // been transferred since the last write, so a replace of an // unrelated field by a now-unauthorized owner must still fail. + // The same rules hold for the elements of a typed array, which + // share one declaration: the array is one field, so a replace + // that changes it, or a property bound to it, re-validates every + // element (the transition does not say which ones are new), and + // a writer gate or a deletableDocument target re-validates them + // all on every replace. let bound_property_changed = match reference_target { DocumentPropertyReferenceTarget::PermanentDocument { property_agreement, .. @@ -309,151 +328,262 @@ fn validate_document_type_references_v0( } } - let referenced_id = match document_data.get_optional_identifier_at_path(path) { - Ok(Some(referenced_id)) => referenced_id, - // A reference property that is not set is not validated; whether it may be - // absent at all is enforced by the document type's required fields - Ok(None) => continue, - Err(err) => { - return Ok(SimpleConsensusValidationResult::new_with_error( - InvalidIdentifierError::new(path.to_string(), err.to_string()).into(), - )) - } - }; - - let exists = match reference_target { - DocumentPropertyReferenceTarget::Identity => { - execution_context.add_operation(ValidationOperation::RetrieveIdentity( - RetrieveIdentityInfo::only_revision(), - )); - - platform - .drive - .fetch_identity_revision(referenced_id, true, transaction, platform_version)? - .is_some() - } - DocumentPropertyReferenceTarget::Contract { - contract_requirements, - } => { - let (fee, referenced_contract) = - platform.drive.get_contract_with_fetch_info_and_fee( - referenced_id, - Some(&block_info.epoch), - false, - transaction, - platform_version, - )?; - - let fee = fee.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( - "fee must exist when fetching a referenced contract with an epoch", - )))?; - - // The cost is added even if the referenced contract does not exist or was cached - execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); - - match referenced_contract { - None => false, - Some(fetch_info) => { - // The declaration's requirements are checked against the contract - // just fetched and the write itself (its owner and block time), so - // they cost no further read; the first unmet one refuses the write - let write = ReferringWrite { - owner_id, - block_time_ms: block_info.time_ms, - }; - if let Some(requirement) = - contract_requirements.first_unmet_by(&fetch_info.contract, write) - { - return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedContractRequirementNotMetError::new( - Identifier::from(referenced_id), - requirement.field().to_string(), - requirement.required(), - path.to_string(), - ) - .into(), - )); - } - true + // The referenced contracts this declaration resolved, so the + // elements of a typed array fetch a foreign contract once + let mut referenced_contracts = BTreeMap::new(); + + match reference { + PropertyReference::Value(_) => { + let referenced_id = match document_data.get_optional_identifier_at_path(path) { + Ok(Some(referenced_id)) => referenced_id, + // A reference property that is not set is not validated; whether it may be + // absent at all is enforced by the document type's required fields + Ok(None) => continue, + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidIdentifierError::new(path.to_string(), err.to_string()).into(), + )) } + }; + let result = validate_reference_v0( + contract, + document_type, + document_data, + owner_id, + reference_target, + referenced_id, + path, + &mut referenced_contracts, + platform, + block_info, + transaction, + execution_context, + platform_version, + )?; + if !result.is_valid() { + return Ok(result); } } - DocumentPropertyReferenceTarget::Token => { - // Token contract info is written for every token when its contract is - // inserted and is never deleted, so it serves as the existence record - let (referenced_token_info, fee) = - platform.drive.fetch_token_contract_info_with_costs( + PropertyReference::Elements(_) => { + let elements = match document_data.get_optional_at_path(path) { + Ok(Some(Value::Array(elements))) => elements, + // An absent list, like an absent reference, is not + // validated; an empty one has nothing to validate + Ok(None) => continue, + Ok(Some(_)) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidIdentifierError::new( + path.to_string(), + "a typed array of identifiers must be a list".to_string(), + ) + .into(), + )) + } + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidIdentifierError::new(path.to_string(), err.to_string()).into(), + )) + } + }; + // Each element is checked as a single reference is, in list + // order, and the first that fails refuses the write with the + // error a single reference would give, naming the element by + // its list path (`reasons[2]` for the third). The count is + // bounded by `maxItems`, which registration counts against + // `SystemLimits::max_references_per_document` with the + // type's other references, and every fetch is billed as a + // single reference's is + for (index, element) in elements.iter().enumerate() { + let element_path = format!("{path}[{index}]"); + let referenced_id = match element.to_hash256() { + Ok(referenced_id) => referenced_id, + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidIdentifierError::new(element_path, err.to_string()).into(), + )) + } + }; + let result = validate_reference_v0( + contract, + document_type, + document_data, + owner_id, + reference_target, referenced_id, + &element_path, + &mut referenced_contracts, + platform, block_info, - true, transaction, + execution_context, platform_version, )?; + if !result.is_valid() { + return Ok(result); + } + } + } + } + } - execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + Ok(SimpleConsensusValidationResult::new()) +} - referenced_token_info.is_some() - } - DocumentPropertyReferenceTarget::PermanentDocument { - contract_id: referenced_contract_id, - document_type_name, - property_agreement, - } - | DocumentPropertyReferenceTarget::DeletableDocument { - contract_id: referenced_contract_id, - document_type_name, - property_agreement, - } => { - let permanent = matches!( - reference_target, - DocumentPropertyReferenceTarget::PermanentDocument { .. } - ); - // An absent contract id targets the declaring contract itself; the - // declaring contract may also name its own id explicitly. Either - // way it is already loaded for this transition, so no fetch is - // billed for it - let effective_contract_id = referenced_contract_id.unwrap_or(contract.id()); - let referenced_contract_fetch_info; - let referenced_contract = if effective_contract_id == contract.id() { - contract - } else { - let (fee, fetch_info) = platform.drive.get_contract_with_fetch_info_and_fee( - effective_contract_id.to_buffer(), - Some(&block_info.epoch), - false, - transaction, - platform_version, - )?; +/// Checks one reference against platform state: the referenced id +/// `referenced_id`, declared by `reference_target`, is an identifier +/// property's value or one element of a typed array of them, and `path` is +/// how the errors name it (the property path, or the element's list path). +/// The target must exist and meet the declaration's contract requirements, +/// a referenced document's type must be deletable or not as declared, and +/// each `propertyAgreement` pair must hold between `document_data` (or the +/// writer `owner_id`) and the referenced document. Every read is billed to +/// `execution_context`; a foreign contract holding a referenced document +/// type is resolved through `referenced_contracts`, which the caller shares +/// among the elements of one array. +#[allow(clippy::too_many_arguments)] +fn validate_reference_v0( + contract: &DataContract, + document_type: DocumentTypeRef<'_>, + document_data: &BTreeMap, + owner_id: Identifier, + reference_target: &DocumentPropertyReferenceTarget, + referenced_id: [u8; 32], + path: &str, + referenced_contracts: &mut BTreeMap>>, + platform: &PlatformStateRef, + block_info: &BlockInfo, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, +) -> Result { + let exists = match reference_target { + DocumentPropertyReferenceTarget::Identity => { + execution_context.add_operation(ValidationOperation::RetrieveIdentity( + RetrieveIdentityInfo::only_revision(), + )); - let fee = - fee.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( - "fee must exist when fetching a referenced contract with an epoch", - )))?; + platform + .drive + .fetch_identity_revision(referenced_id, true, transaction, platform_version)? + .is_some() + } + DocumentPropertyReferenceTarget::Contract { + contract_requirements, + } => { + let (fee, referenced_contract) = platform.drive.get_contract_with_fetch_info_and_fee( + referenced_id, + Some(&block_info.epoch), + false, + transaction, + platform_version, + )?; + + let fee = fee.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "fee must exist when fetching a referenced contract with an epoch", + )))?; - // The cost is added even if the referenced contract does not exist or was cached - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee)); + // The cost is added even if the referenced contract does not exist or was cached + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); - let Some(fetch_info) = fetch_info else { - // A missing contract and a missing document type resolve to the - // same failure: the declared document type could not be found + match referenced_contract { + None => false, + Some(fetch_info) => { + // The declaration's requirements are checked against the contract + // just fetched and the write itself (its owner and block time), so + // they cost no further read; the first unmet one refuses the write + let write = ReferringWrite { + owner_id, + block_time_ms: block_info.time_ms, + }; + if let Some(requirement) = + contract_requirements.first_unmet_by(&fetch_info.contract, write) + { return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedDocumentTypeNotFoundError::new( - effective_contract_id, - document_type_name.clone(), + ReferencedContractRequirementNotMetError::new( + Identifier::from(referenced_id), + requirement.field().to_string(), + requirement.required(), path.to_string(), ) .into(), )); - }; + } + true + } + } + } + DocumentPropertyReferenceTarget::Token => { + // Token contract info is written for every token when its contract is + // inserted and is never deleted, so it serves as the existence record + let (referenced_token_info, fee) = + platform.drive.fetch_token_contract_info_with_costs( + referenced_id, + block_info, + true, + transaction, + platform_version, + )?; - referenced_contract_fetch_info = fetch_info; - &referenced_contract_fetch_info.contract + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(fee)); + + referenced_token_info.is_some() + } + DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: referenced_contract_id, + document_type_name, + property_agreement, + } + | DocumentPropertyReferenceTarget::DeletableDocument { + contract_id: referenced_contract_id, + document_type_name, + property_agreement, + } => { + let permanent = matches!( + reference_target, + DocumentPropertyReferenceTarget::PermanentDocument { .. } + ); + // An absent contract id targets the declaring contract itself; the + // declaring contract may also name its own id explicitly. Either + // way it is already loaded for this transition, so no fetch is + // billed for it + let effective_contract_id = referenced_contract_id.unwrap_or(contract.id()); + let referenced_contract_fetch_info; + let referenced_contract = if effective_contract_id == contract.id() { + contract + } else { + // The elements of one typed array share their declaration, + // so they resolve its contract once: the first element's + // fetch is billed and the rest reuse it. A single + // reference comes with a map of its own, one fetch + let fetch_info = match referenced_contracts.get(&effective_contract_id) { + Some(resolved) => resolved.clone(), + None => { + let (fee, fetch_info) = + platform.drive.get_contract_with_fetch_info_and_fee( + effective_contract_id.to_buffer(), + Some(&block_info.epoch), + false, + transaction, + platform_version, + )?; + + let fee = + fee.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "fee must exist when fetching a referenced contract with an epoch", + )))?; + + // The cost is added even if the referenced contract does not exist or was cached + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee)); + + referenced_contracts.insert(effective_contract_id, fetch_info.clone()); + fetch_info + } }; - let Some(referenced_document_type) = - referenced_contract.document_type_optional_for_name(document_type_name) - else { + let Some(fetch_info) = fetch_info else { + // A missing contract and a missing document type resolve to the + // same failure: the declared document type could not be found return Ok(SimpleConsensusValidationResult::new_with_error( ReferencedDocumentTypeNotFoundError::new( effective_contract_id, @@ -464,225 +594,236 @@ fn validate_document_type_references_v0( )); }; - // A `permanentDocument` reference admits only document types - // whose documents can never be deleted: `canBeDeleted` is - // immutable on contract updates and document types can not be - // removed, so a reference validated here can never dangle. A - // `deletableDocument` reference makes no such promise, and - // admits only document types whose documents CAN be deleted: - // the referenced document must exist now, and may be deleted - // later. Deletable means by anyone, the contract's moderators - // included (`canBeDeletedByModerators`), as at contract - // registration - let target_is_deletable = referenced_document_type.documents_can_be_deleted() - || referenced_document_type.documents_can_be_deleted_by_moderators(); - if permanent && target_is_deletable { - return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedDocumentTypeDeletableError::new( - effective_contract_id, - document_type_name.clone(), - path.to_string(), - ) - .into(), - )); - } - if !permanent && !target_is_deletable { - return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedDocumentTypeNotDeletableError::new( - effective_contract_id, - document_type_name.clone(), - path.to_string(), - ) - .into(), - )); - } + referenced_contract_fetch_info = fetch_info; + &referenced_contract_fetch_info.contract + }; - let referenced_document = fetch_document_with_id( - platform.drive, - referenced_contract, - referenced_document_type, - Identifier::from(referenced_id), - &block_info.epoch, - execution_context, - transaction, - platform_version, - )?; + let Some(referenced_document_type) = + referenced_contract.document_type_optional_for_name(document_type_name) + else { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentTypeNotFoundError::new( + effective_contract_id, + document_type_name.clone(), + path.to_string(), + ) + .into(), + )); + }; + + // A `permanentDocument` reference admits only document types + // whose documents can never be deleted: `canBeDeleted` is + // immutable on contract updates and document types can not be + // removed, so a reference validated here can never dangle. A + // `deletableDocument` reference makes no such promise, and + // admits only document types whose documents CAN be deleted: + // the referenced document must exist now, and may be deleted + // later. Deletable means by anyone, the contract's moderators + // included (`canBeDeletedByModerators`), as at contract + // registration + let target_is_deletable = referenced_document_type.documents_can_be_deleted() + || referenced_document_type.documents_can_be_deleted_by_moderators(); + if permanent && target_is_deletable { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentTypeDeletableError::new( + effective_contract_id, + document_type_name.clone(), + path.to_string(), + ) + .into(), + )); + } + if !permanent && !target_is_deletable { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentTypeNotDeletableError::new( + effective_contract_id, + document_type_name.clone(), + path.to_string(), + ) + .into(), + )); + } - // Property agreement: the referenced document is already in - // hand for the existence check, so comparing the declared - // pairs adds no reads. Each side is normalized through its - // OWN document type's key encoding — one deterministic - // normal form per value kind, so an identifier stored as - // bytes and one carried as an identifier compare equal. - // - // Absence is part of the agreement, strictly: both sides - // absent agree, one side absent is a mismatch. Anything - // laxer breaks the properties agreements exist for — with - // referring-absent-always-ok, a document could opt out of - // echoing a value its referenced document carries (e.g. a - // like on a TAGGED post omitting the tag, silently - // deflating every per-tag aggregate), and the referenced - // side's absence is what lets a referring doctype whose - // agreement key triggers a skipIfAbsent index stay - // consistently absent for untagged targets. - if let Some(referenced_document) = &referenced_document { - for (referring_property, referenced_property) in property_agreement { - let mismatch = || { - SimpleConsensusValidationResult::new_with_error( - ReferencedDocumentPropertyMismatchError::new( - path.to_string(), - referring_property.clone(), - referenced_property.clone(), - ) - .into(), + let referenced_document = fetch_document_with_id( + platform.drive, + referenced_contract, + referenced_document_type, + Identifier::from(referenced_id), + &block_info.epoch, + execution_context, + transaction, + platform_version, + )?; + + // Property agreement: the referenced document is already in + // hand for the existence check, so comparing the declared + // pairs adds no reads. Each side is normalized through its + // OWN document type's key encoding — one deterministic + // normal form per value kind, so an identifier stored as + // bytes and one carried as an identifier compare equal. + // + // Absence is part of the agreement, strictly: both sides + // absent agree, one side absent is a mismatch. Anything + // laxer breaks the properties agreements exist for — with + // referring-absent-always-ok, a document could opt out of + // echoing a value its referenced document carries (e.g. a + // like on a TAGGED post omitting the tag, silently + // deflating every per-tag aggregate), and the referenced + // side's absence is what lets a referring doctype whose + // agreement key triggers a skipIfAbsent index stay + // consistently absent for untagged targets. + if let Some(referenced_document) = &referenced_document { + for (referring_property, referenced_property) in property_agreement { + let mismatch = || { + SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentPropertyMismatchError::new( + path.to_string(), + referring_property.clone(), + referenced_property.clone(), ) + .into(), + ) + }; + // A lookup ERROR (a non-map value where the dotted + // path expects an intermediate object) is a + // mismatch, never absence — folding it into `None` + // would let two malformed sides "agree" as + // both-absent. + // The referring side is a schema property of the document + // being written, or the writer's own `$ownerId`, which + // lives on the transition rather than in its data: that + // pair is a write gate, and the writer is `owner_id`. + let referring_value: Option> = if referring_property == OWNER_ID { + Some(Cow::Owned(Value::Identifier(owner_id.to_buffer()))) + } else { + let Ok(referring_value) = + document_data.get_optional_at_path(referring_property) + else { + return Ok(mismatch()); }; - // A lookup ERROR (a non-map value where the dotted - // path expects an intermediate object) is a - // mismatch, never absence — folding it into `None` - // would let two malformed sides "agree" as - // both-absent. - // The referring side is a schema property of the document - // being written, or the writer's own `$ownerId`, which - // lives on the transition rather than in its data: that - // pair is a write gate, and the writer is `owner_id`. - let referring_value: Option> = if referring_property == OWNER_ID - { - Some(Cow::Owned(Value::Identifier(owner_id.to_buffer()))) - } else { - let Ok(referring_value) = - document_data.get_optional_at_path(referring_property) + referring_value.map(Cow::Borrowed) + }; + // The referenced side may name one of the two system + // identifiers a document carries outside its data: + // `$ownerId`, which follows the document through + // transfers, and `$creatorId`, set once at creation + // and absent on document types that do not record + // it. Contract registration validated that either + // faces an identifier property on the referring + // side, and the key serializer below already encodes + // both names as 32-byte identifiers. + let referenced_value: Option> = match referenced_property.as_str() { + OWNER_ID => Some(Cow::Owned(Value::Identifier( + referenced_document.owner_id().to_buffer(), + ))), + CREATOR_ID => referenced_document.creator_id().map(|creator_id| { + Cow::Owned(Value::Identifier(creator_id.to_buffer())) + }), + _ => { + let Ok(referenced_value) = referenced_document + .properties() + .get_optional_at_path(referenced_property) else { return Ok(mismatch()); }; - referring_value.map(Cow::Borrowed) - }; - // The referenced side may name one of the two system - // identifiers a document carries outside its data: - // `$ownerId`, which follows the document through - // transfers, and `$creatorId`, set once at creation - // and absent on document types that do not record - // it. Contract registration validated that either - // faces an identifier property on the referring - // side, and the key serializer below already encodes - // both names as 32-byte identifiers. - let referenced_value: Option> = - match referenced_property.as_str() { - OWNER_ID => Some(Cow::Owned(Value::Identifier( - referenced_document.owner_id().to_buffer(), - ))), - CREATOR_ID => referenced_document.creator_id().map(|creator_id| { - Cow::Owned(Value::Identifier(creator_id.to_buffer())) - }), - _ => { - let Ok(referenced_value) = referenced_document - .properties() - .get_optional_at_path(referenced_property) - else { - return Ok(mismatch()); - }; - referenced_value.map(Cow::Borrowed) - } - }; - let (referring_value, referenced_value) = - match (referring_value, referenced_value) { - (Some(referring_value), Some(referenced_value)) => { - (referring_value, referenced_value) - } - // Both absent: the sides agree. - (None, None) => continue, - // One side absent: a mismatch, exactly as a - // differing value would be. - (Some(_), None) | (None, Some(_)) => return Ok(mismatch()), - }; - let Ok(referring_encoded) = document_type.serialize_value_for_key( - referring_property, - &referring_value, - platform_version, - ) else { - return Ok(mismatch()); - }; - let Ok(referenced_encoded) = referenced_document_type - .serialize_value_for_key( - referenced_property, - &referenced_value, - platform_version, - ) - else { - return Ok(mismatch()); - }; - if referring_encoded != referenced_encoded { - return Ok(mismatch()); + referenced_value.map(Cow::Borrowed) } + }; + let (referring_value, referenced_value) = + match (referring_value, referenced_value) { + (Some(referring_value), Some(referenced_value)) => { + (referring_value, referenced_value) + } + // Both absent: the sides agree. + (None, None) => continue, + // One side absent: a mismatch, exactly as a + // differing value would be. + (Some(_), None) | (None, Some(_)) => return Ok(mismatch()), + }; + let Ok(referring_encoded) = document_type.serialize_value_for_key( + referring_property, + &referring_value, + platform_version, + ) else { + return Ok(mismatch()); + }; + let Ok(referenced_encoded) = referenced_document_type.serialize_value_for_key( + referenced_property, + &referenced_value, + platform_version, + ) else { + return Ok(mismatch()); + }; + if referring_encoded != referenced_encoded { + return Ok(mismatch()); } } - - referenced_document.is_some() } - DocumentPropertyReferenceTarget::IdentityPublicKey { - key_id_property, - key_requirements, - } => { - // The referenced key id is carried by the named sibling property - let key_id: KeyID = - match document_data.get_optional_integer_at_path(key_id_property) { - Ok(Some(key_id)) => key_id, - Ok(None) => { - return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedKeyIdPropertyInvalidError::new( - key_id_property.clone(), - path.to_string(), - "the key id property is not set".to_string(), - ) - .into(), - )) - } - Err(err) => { - return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedKeyIdPropertyInvalidError::new( - key_id_property.clone(), - path.to_string(), - err.to_string(), - ) - .into(), - )) - } - }; - let result = validate_referenced_identity_key_v0( - Identifier::from(referenced_id), - key_id, - path, - key_requirements, - document_type.name(), - contract.id(), - platform, - transaction, - execution_context, - platform_version, - )?; - if !result.is_valid() { - return Ok(result); + referenced_document.is_some() + } + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property, + key_requirements, + } => { + // The referenced key id is carried by the named sibling property + let key_id: KeyID = match document_data.get_optional_integer_at_path(key_id_property) { + Ok(Some(key_id)) => key_id, + Ok(None) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedKeyIdPropertyInvalidError::new( + key_id_property.clone(), + path.to_string(), + "the key id property is not set".to_string(), + ) + .into(), + )) + } + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedKeyIdPropertyInvalidError::new( + key_id_property.clone(), + path.to_string(), + err.to_string(), + ) + .into(), + )) } + }; - true + let result = validate_referenced_identity_key_v0( + Identifier::from(referenced_id), + key_id, + path, + key_requirements, + document_type.name(), + contract.id(), + platform, + transaction, + execution_context, + platform_version, + )?; + if !result.is_valid() { + return Ok(result); } - }; - - if !exists { - let missing_id = - Identifier::from_bytes(&referenced_id).map_err(|e| Error::Protocol(e.into()))?; - return Ok(SimpleConsensusValidationResult::new_with_error( - ConsensusError::StateError(StateError::ReferencedEntityNotFoundError( - ReferencedEntityNotFoundError::new( - missing_id, - reference_target.clone(), - path.to_string(), - ), - )), - )); + true } + }; + + if !exists { + let missing_id = + Identifier::from_bytes(&referenced_id).map_err(|e| Error::Protocol(e.into()))?; + + return Ok(SimpleConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::ReferencedEntityNotFoundError( + ReferencedEntityNotFoundError::new( + missing_id, + reference_target.clone(), + path.to_string(), + ), + )), + )); } Ok(SimpleConsensusValidationResult::new()) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index febb3f31f52..285d2bc580b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -17,6 +17,7 @@ mod replacement; mod required_since; mod system_agreement; mod transfer; +mod typed_array_references; use super::*; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/typed_array_references.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/typed_array_references.rs new file mode 100644 index 00000000000..5dfa3639f72 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/typed_array_references.rs @@ -0,0 +1,607 @@ +//! `refersTo` on the elements of a typed array through the full ABCI +//! pipeline. The fixture's `submittedCharter.reasons` is a list of +//! `permanentDocument` references to `reason`, `gatedCharter.reasons` binds +//! the WRITER to every referenced reason's owner, `topicCharter.reasons` +//! binds the charter's `topic` to every reason's, and `draftList.drafts` is +//! a list of `deletableDocument` references to `draft`. `plainCharter` has +//! the `reasons` shape without `refersTo`, as the fee baseline. +//! +//! Each element is checked as a single reference is, in list order, and the +//! first one that fails refuses the write with the single reference's +//! error, naming the element by its list path. + +use super::*; + +mod typed_array_reference_tests { + use super::*; + use crate::platform_types::platform_state::PlatformState; + use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::consensus::codes::ErrorWithCode; + use dpp::data_contract::accessors::v0::DataContractV0Setters; + use dpp::document::Document; + use dpp::identifier::Identifier; + use dpp::identity::signer::Signer; + use dpp::identity::IdentityPublicKey; + use dpp::prelude::DataContract; + use dpp::state_transition::StateTransition; + use simple_signer::signer::SimpleSigner; + use std::sync::Arc; + + /// Shared with the contract-create registration test, which pins that + /// the declarations themselves register. + const CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements.json"; + + fn register_contract( + platform: &TempPlatform, + owner_id: Identifier, + platform_version: &PlatformVersion, + ) -> DataContract { + let mut contract = json_document_to_contract(CONTRACT_PATH, true, platform_version) + .expect("expected to parse the typed array reference contract"); + contract.set_owner_id(owner_id); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the typed array reference contract"); + contract + } + + fn process_and_commit( + platform: &TempPlatform, + platform_state: &PlatformState, + transition: &StateTransition, + platform_version: &PlatformVersion, + ) -> StateTransitionsProcessingResult { + let serialized = transition + .serialize_to_bytes() + .expect("expected the batch transition to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + processing_result + } + + fn assert_successful(result: &StateTransitionsProcessingResult, because: &str) { + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }], + "{because}" + ); + } + + /// The write was refused because the referenced entity `missing` named at + /// `path` does not exist (code 40120). + fn assert_element_not_found( + result: &StateTransitionsProcessingResult, + path: &str, + missing: Identifier, + because: &str, + ) { + let [StateTransitionExecutionResult::PaidConsensusError { error, .. }] = + result.execution_results().as_slice() + else { + panic!("{because}: expected one paid consensus error, got {result:?}"); + }; + assert_eq!(error.code(), 40120, "{because}: {error}"); + assert_matches!( + error, + ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(not_found)) + if not_found.path() == path && *not_found.entity_id() == missing, + "{because}" + ); + } + + /// A list of identifiers as a document stores it. + fn identifier_list(ids: &[Identifier]) -> Value { + Value::Array( + ids.iter() + .map(|id| Value::Identifier(id.to_buffer())) + .collect(), + ) + } + + /// Creates a document of `type_name` with exactly `properties` set and + /// returns it with the processing result. + #[allow(clippy::too_many_arguments)] + async fn create_document>( + platform: &TempPlatform, + platform_state: &PlatformState, + contract: &DataContract, + type_name: &str, + properties: &[(&str, Value)], + owner: Identifier, + key: &IdentityPublicKey, + nonce: u64, + signer: &S, + rng: &mut StdRng, + platform_version: &PlatformVersion, + ) -> (Document, StateTransitionsProcessingResult) { + let document_type = contract + .document_type_for_name(type_name) + .expect("doctype exists"); + let entropy = Bytes32::random_with_rng(rng); + let mut document = document_type + .random_document_with_identifier_and_entropy( + rng, + owner, + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + for (property, value) in properties { + document.set(property, value.clone()); + } + // The id commits to the create transition's nonce: give the local + // copy the id the transition will carry, since the tests reference + // and act on it afterwards. + document + .set_id_for_creation(document_type, &entropy.0, nonce, platform_version) + .expect("expected the creation id"); + let create = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + document_type, + entropy.0, + key, + nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + let result = process_and_commit(platform, platform_state, &create, platform_version); + (document, result) + } + + struct Setup { + platform: TempPlatform, + platform_state: Arc, + contract: DataContract, + owner: Identifier, + signer: SimpleSigner, + key: IdentityPublicKey, + rng: StdRng, + nonce: u64, + } + + impl Setup { + fn new(seed: u64) -> Self { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load_full(); + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(1.0)); + let contract = register_contract(&platform, identity.id(), platform_version); + Self { + platform, + platform_state, + contract, + owner: identity.id(), + signer, + key, + rng: StdRng::seed_from_u64(seed), + nonce: 1, + } + } + + fn next_nonce(&mut self) -> u64 { + self.nonce += 1; + self.nonce + } + + async fn create( + &mut self, + type_name: &str, + properties: &[(&str, Value)], + ) -> (Document, StateTransitionsProcessingResult) { + let nonce = self.next_nonce(); + create_document( + &self.platform, + &self.platform_state, + &self.contract, + type_name, + properties, + self.owner, + &self.key, + nonce, + &self.signer, + &mut self.rng, + PlatformVersion::latest(), + ) + .await + } + + /// Bumps the revision and replaces `document` as it now stands. + async fn replace( + &mut self, + type_name: &str, + document: &mut Document, + ) -> StateTransitionsProcessingResult { + let platform_version = PlatformVersion::latest(); + document.increment_revision().expect("revision increments"); + let nonce = self.next_nonce(); + let document_type = self + .contract + .document_type_for_name(type_name) + .expect("doctype exists"); + let replace = BatchTransition::new_document_replacement_transition_from_document( + document.clone(), + document_type, + &self.key, + nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition"); + let result = process_and_commit( + &self.platform, + &self.platform_state, + &replace, + platform_version, + ); + // A refused replace leaves the stored revision where it was. + if result.valid_count() == 0 { + let revision = document.revision().expect("revision set"); + document.set_revision(Some(revision - 1)); + } + result + } + + async fn delete( + &mut self, + type_name: &str, + document: &Document, + ) -> StateTransitionsProcessingResult { + let platform_version = PlatformVersion::latest(); + let nonce = self.next_nonce(); + let document_type = self + .contract + .document_type_for_name(type_name) + .expect("doctype exists"); + let delete = BatchTransition::new_document_deletion_transition_from_document( + document.clone(), + document_type, + &self.key, + nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the delete transition"); + process_and_commit( + &self.platform, + &self.platform_state, + &delete, + platform_version, + ) + } + + /// A reason owned by the setup's identity. + async fn reason(&mut self, topic: &str) -> Identifier { + let (reason, result) = self.create("reason", &[("topic", topic.into())]).await; + assert_successful(&result, "the reason is created"); + reason.id() + } + + async fn reasons(&mut self, count: usize) -> Vec { + let mut reasons = Vec::with_capacity(count); + for _ in 0..count { + reasons.push(self.reason("dash").await); + } + reasons + } + } + + #[tokio::test] + async fn should_accept_a_list_whose_every_element_exists() { + let mut setup = Setup::new(8101); + let reasons = setup.reasons(3).await; + + let (_, result) = setup + .create( + "submittedCharter", + &[ + ("reasons", identifier_list(&reasons)), + ("title", "a charter".into()), + ], + ) + .await; + assert_successful(&result, "every referenced reason exists"); + } + + #[tokio::test] + async fn should_refuse_a_list_whose_third_element_is_missing_naming_that_element() { + let mut setup = Setup::new(8102); + let reasons = setup.reasons(2).await; + let missing = Identifier::random_with_rng(&mut setup.rng); + + let (_, result) = setup + .create( + "submittedCharter", + &[( + "reasons", + identifier_list(&[reasons[0], reasons[1], missing]), + )], + ) + .await; + assert_element_not_found( + &result, + "reasons[2]", + missing, + "the third reason does not exist", + ); + } + + #[tokio::test] + async fn should_accept_an_empty_list() { + let mut setup = Setup::new(8103); + + let (_, result) = setup + .create("submittedCharter", &[("reasons", identifier_list(&[]))]) + .await; + assert_successful(&result, "an empty list checks nothing"); + } + + /// A writer gate on the elements holds for every element: the writer + /// must own each referenced reason. + #[tokio::test] + async fn should_refuse_an_element_writer_gate_when_the_writer_does_not_own_a_referenced_document( + ) { + let platform_version = PlatformVersion::latest(); + let mut setup = Setup::new(8104); + let own_reason = setup.reason("dash").await; + let (bob, bob_signer, bob_key) = + setup_identity(&mut setup.platform, 450, dash_to_credits!(1.0)); + let (bobs_reason, result) = create_document( + &setup.platform, + &setup.platform_state, + &setup.contract, + "reason", + &[("topic", "dash".into())], + bob.id(), + &bob_key, + 2, + &bob_signer, + &mut setup.rng, + platform_version, + ) + .await; + assert_successful(&result, "bob's reason is created"); + + let (_, result) = setup + .create( + "gatedCharter", + &[("reasons", identifier_list(&[own_reason]))], + ) + .await; + assert_successful(&result, "the writer owns every referenced reason"); + + let (_, result) = setup + .create( + "gatedCharter", + &[("reasons", identifier_list(&[own_reason, bobs_reason.id()]))], + ) + .await; + let [StateTransitionExecutionResult::PaidConsensusError { error, .. }] = + result.execution_results().as_slice() + else { + panic!("expected one paid consensus error, got {result:?}"); + }; + assert_eq!(error.code(), 40127, "{error}"); + assert_matches!( + error, + ConsensusError::StateError(StateError::ReferencedDocumentPropertyMismatchError( + mismatch + )) if mismatch.path() == "reasons[1]" + && mismatch.referring_property() == "$ownerId" + && mismatch.referenced_property() == "$ownerId", + "the second reason is bob's" + ); + } + + /// The referring side of an element agreement is a property of the + /// charter, the same for every element; the referenced side is that + /// element's reason's property. + #[tokio::test] + async fn should_hold_an_element_agreement_between_the_document_and_every_referenced_document() { + let mut setup = Setup::new(8105); + let dash = setup.reason("dash").await; + let other_dash = setup.reason("dash").await; + let btc = setup.reason("btc").await; + + let (mut charter, result) = setup + .create( + "topicCharter", + &[ + ("reasons", identifier_list(&[dash, other_dash])), + ("topic", "dash".into()), + ], + ) + .await; + assert_successful(&result, "every reason is about dash"); + + let (_, result) = setup + .create( + "topicCharter", + &[ + ("reasons", identifier_list(&[dash, btc])), + ("topic", "dash".into()), + ], + ) + .await; + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyMismatchError(mismatch) + ), + .. + }] if mismatch.path() == "reasons[1]", + "the second reason is about btc" + ); + + // Changing only the bound property re-validates every element + charter.set("topic", "btc".into()); + let result = setup.replace("topicCharter", &mut charter).await; + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyMismatchError(mismatch) + ), + .. + }] if mismatch.path() == "reasons[0]", + "the reasons stay about dash" + ); + } + + #[tokio::test] + async fn should_refuse_a_replace_that_adds_a_missing_element() { + let mut setup = Setup::new(8106); + let reasons = setup.reasons(2).await; + let missing = Identifier::random_with_rng(&mut setup.rng); + + let (mut charter, result) = setup + .create( + "submittedCharter", + &[("reasons", identifier_list(&reasons))], + ) + .await; + assert_successful(&result, "the charter is created"); + + charter.set( + "reasons", + identifier_list(&[reasons[0], reasons[1], missing]), + ); + let result = setup.replace("submittedCharter", &mut charter).await; + assert_element_not_found( + &result, + "reasons[2]", + missing, + "the added reason does not exist", + ); + + let another = setup.reason("dash").await; + charter.set( + "reasons", + identifier_list(&[reasons[0], reasons[1], another]), + ); + let result = setup.replace("submittedCharter", &mut charter).await; + assert_successful(&result, "adding a reason that exists is fine"); + } + + /// Every replace re-validates a list of deletableDocument references, + /// touched or not, as it does a single one: a dead element has to be + /// dropped or repointed before the document can be replaced. + #[tokio::test] + async fn should_refuse_an_unrelated_replace_while_a_deletable_element_is_dead() { + let mut setup = Setup::new(8107); + let (first, result) = setup.create("draft", &[("topic", "dash".into())]).await; + assert_successful(&result, "the first draft is created"); + let (second, result) = setup.create("draft", &[("topic", "dash".into())]).await; + assert_successful(&result, "the second draft is created"); + + let (mut list, result) = setup + .create( + "draftList", + &[ + ("drafts", identifier_list(&[first.id(), second.id()])), + ("body", "first".into()), + ], + ) + .await; + assert_successful(&result, "the list is created"); + + list.set("body", "second".into()); + let result = setup.replace("draftList", &mut list).await; + assert_successful(&result, "a replace passes while both drafts exist"); + + let result = setup.delete("draft", &second).await; + assert_successful(&result, "a referenced draft can still be deleted"); + + // Only `body` changes, but every element is re-validated anyway + list.set("body", "third".into()); + let result = setup.replace("draftList", &mut list).await; + assert_element_not_found( + &result, + "drafts[1]", + second.id(), + "a replace may not leave an element on the deleted draft", + ); + + list.set("drafts", identifier_list(&[first.id()])); + let result = setup.replace("draftList", &mut list).await; + assert_successful(&result, "dropping the dead element repairs the list"); + } + + /// The processing fee of creating a `type_name` document whose list holds + /// the first `count` of four reasons, on a fresh platform with the same + /// history every time, so that only the list differs between two runs. + async fn creation_fee(type_name: &str, count: usize) -> u64 { + let mut setup = Setup::new(8108); + let reasons = setup.reasons(4).await; + let (_, result) = setup + .create( + type_name, + &[("reasons", identifier_list(&reasons[..count]))], + ) + .await; + assert_successful(&result, "the list is created"); + result.aggregated_fees().processing_fee + } + + /// Each element is a billed read: what a list of references costs over + /// the same list without `refersTo` grows with the element count. + #[tokio::test] + async fn should_charge_more_for_more_referenced_elements() { + let mut surcharges = Vec::new(); + for count in [0, 1, 2, 4] { + let referenced = creation_fee("submittedCharter", count).await; + let plain = creation_fee("plainCharter", count).await; + surcharges.push(i128::from(referenced) - i128::from(plain)); + } + // With no element there is nothing to read: what is left is the + // two document types' own difference + assert!( + surcharges.windows(2).all(|pair| pair[0] < pair[1]), + "the surcharge grows with every element: {surcharges:?}" + ); + } +} 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 7663242bc71..616f0a924ae 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 @@ -4,7 +4,7 @@ use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, Docume use dpp::data_contract::document_type::{ is_referenced_system_agreement_property, is_referring_system_agreement_property, DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, - DocumentReferenceDeclaration, KeyReferenceIdentityProperty, + DocumentReferenceDeclaration, KeyReferenceIdentityProperty, PropertyReference, }; use dpp::data_contract::DataContract; use dpp::document::property_names::CREATOR_ID; @@ -62,8 +62,16 @@ fn same_value_kind(a: &DocumentPropertyType, b: &DocumentPropertyType) -> bool { /// `identityPublicKey`: the declared key id property must exist in the same /// document type and be an integer. /// +/// A declaration on the `items` of a typed array of identifiers holds for +/// every element and is checked once, exactly as a single reference's: the +/// referring side of an agreement is still a property of the declaring +/// document type (or the writer), the referenced side a property of the +/// referenced document type. `identityPublicKey` never reaches here on +/// elements: the parser refuses it there. +/// /// The error paths name the failing declaration as -/// `documentTypeName.propertyPath`. Validation stops at the first invalid +/// `documentTypeName.propertyPath`, and an element declaration by its list +/// path, `documentTypeName.propertyPath[]`. Validation stops at the first invalid /// declaration: this bounds the billed work an invalid contract can cause and /// matches document write-time reference validation. Foreign contract /// resolutions are memoized per contract id, so a contract declaring many @@ -85,8 +93,7 @@ pub(super) fn validate_data_contract_references_v0( for (path, property) in document_type.as_ref().flattened_properties() { let declaration_path = format!("{declaring_type_name}.{path}"); - let reference_target = match &property.property_type { - DocumentPropertyType::IdentifierWithReference(reference_target) => reference_target, + let (reference_target, declaration_path) = match &property.property_type { // A key reference on the key id property: what `identityProperty` // names must fit the document type; nothing else about the // declaration is state-dependent @@ -165,7 +172,16 @@ pub(super) fn validate_data_contract_references_v0( } continue; } - _ => continue, + property_type => match property_type.reference() { + Some(PropertyReference::Value(target)) => (target, declaration_path), + // A typed array only parses from protocol version 14, + // whose contract create and update state validation are + // the only callers, so this arm is never reached before it + Some(PropertyReference::Elements(target)) => { + (target, format!("{declaring_type_name}.{path}[]")) + } + None => continue, + }, }; // The key id property must exist in the same document type and be 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 7948f04ed93..b0b68fd8460 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 @@ -5818,6 +5818,67 @@ mod tests { ); } + /// `refersTo` on the items of a typed array registers with every target + /// the fixture uses: permanent and deletable document elements, an + /// agreement keyed by the writer and one on a schema property. + #[tokio::test] + async fn should_register_contract_with_typed_array_element_references() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + /// An element declaration is checked at registration as a single + /// reference is: the referring side of an agreement is a property of + /// the declaring document type, and the error names the declaration + /// by its list path. + #[tokio::test] + async fn should_reject_an_element_agreement_on_a_missing_referring_property() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referring.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyAgreementInvalidError(error) + ), + .. + } if error.path() == "topicCharter.reasons[]" + && error.referring_property() == "subject" + && error.reason().contains("does not define the referring property") + ); + } + + /// The referenced side is a property of the referenced document type. + #[tokio::test] + async fn should_reject_an_element_agreement_on_a_missing_referenced_property() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referenced.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyAgreementInvalidError(error) + ), + .. + } if error.path() == "topicCharter.reasons[]" + && error.referenced_property() == "subject" + && error.reason().contains("does not define the referenced property") + ); + } + /// `$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-typed-array-elements-agreement-missing-referenced.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referenced.json new file mode 100644 index 00000000000..34d495e8228 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referenced.json @@ -0,0 +1,54 @@ +{ + "$formatVersion": "1", + "id": "9ynYc5JBxVLyJ5G8PzGDcKWz2Pwu5s5CQVb4Kt3rRnTz", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "reason": { + "type": "object", + "canBeDeleted": false, + "properties": { + "topic": { + "type": "string", + "position": 0, + "maxLength": 63 + } + }, + "required": [], + "additionalProperties": false + }, + "topicCharter": { + "type": "object", + "documentsMutable": true, + "properties": { + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "reason", + "propertyAgreement": { + "topic": "subject" + } + } + }, + "position": 0 + }, + "topic": { + "type": "string", + "position": 1, + "maxLength": 63 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referring.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referring.json new file mode 100644 index 00000000000..9839aad3418 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements-agreement-missing-referring.json @@ -0,0 +1,54 @@ +{ + "$formatVersion": "1", + "id": "8oxw8UU9C9b3UdPW3ygAeNXdcQ3S7WJwFe8GB4k6DfH4", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "reason": { + "type": "object", + "canBeDeleted": false, + "properties": { + "topic": { + "type": "string", + "position": 0, + "maxLength": 63 + } + }, + "required": [], + "additionalProperties": false + }, + "topicCharter": { + "type": "object", + "documentsMutable": true, + "properties": { + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "reason", + "propertyAgreement": { + "subject": "topic" + } + } + }, + "position": 0 + }, + "topic": { + "type": "string", + "position": 1, + "maxLength": 63 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements.json new file mode 100644 index 00000000000..091a2bb3642 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements.json @@ -0,0 +1,194 @@ +{ + "$formatVersion": "1", + "id": "5v2TWJ1Mv6hQrN8mDRpJvSkqAqh3FzsoD1qXQwuYAbNk", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "reason": { + "type": "object", + "canBeDeleted": false, + "properties": { + "topic": { + "type": "string", + "position": 0, + "maxLength": 63 + } + }, + "required": [], + "additionalProperties": false + }, + "draft": { + "type": "object", + "canBeDeleted": true, + "documentsMutable": true, + "properties": { + "topic": { + "type": "string", + "position": 0, + "maxLength": 63 + } + }, + "required": [], + "additionalProperties": false + }, + "submittedCharter": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "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", + "refersTo": { + "type": "permanentDocument", + "documentType": "reason" + } + }, + "position": 0 + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + }, + "plainCharter": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "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 + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + }, + "gatedCharter": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "reason", + "propertyAgreement": { + "$ownerId": "$ownerId" + } + } + }, + "position": 0 + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + }, + "topicCharter": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "reason", + "propertyAgreement": { + "topic": "topic" + } + } + }, + "position": 0 + }, + "topic": { + "type": "string", + "position": 1, + "maxLength": 63 + } + }, + "required": [], + "additionalProperties": false + }, + "draftList": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "drafts": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "deletableDocument", + "documentType": "draft" + } + }, + "position": 0 + }, + "body": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive/src/query/chained_document_query/mod.rs b/packages/rs-drive/src/query/chained_document_query/mod.rs index 42080512394..9aae4fb56e4 100644 --- a/packages/rs-drive/src/query/chained_document_query/mod.rs +++ b/packages/rs-drive/src/query/chained_document_query/mod.rs @@ -222,6 +222,8 @@ impl<'a> DriveDocumentQuery<'a> { self.document_type.name(), ))); }; + // A typed array of references is no join property: it is not + // indexable, and a join value is one identifier let document_reference = match &join_document_property.property_type { DocumentPropertyType::IdentifierWithReference(reference_target) => { reference_target.as_document_reference() diff --git a/packages/rs-drive/src/query/composite_document_query/mod.rs b/packages/rs-drive/src/query/composite_document_query/mod.rs index c3ee3fabdff..ca15550f37f 100644 --- a/packages/rs-drive/src/query/composite_document_query/mod.rs +++ b/packages/rs-drive/src/query/composite_document_query/mod.rs @@ -305,7 +305,10 @@ fn sorted_values(values: &[Identifier]) -> Vec { sorted } -/// The document reference a property type declares, of either kind. +/// The document reference a property type declares, of either kind. Only +/// a scalar reference counts: a binding reads one identifier out of the +/// property, and a typed array whose elements are references holds many, +/// is no index property and so is never a join field. fn document_reference_of( property_type: &DocumentPropertyType, ) -> Option> { 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 8de55eb3063..1105d50cd62 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -568,6 +568,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { max_field_value_size: 5000, max_document_value_depth: None, max_typed_array_items: 1024, + max_references_per_document: 256, max_state_transition_size: 20000, // Is different in this test version, not sure if this was a mistake // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch. Raising it here diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 1d49f8ad883..3bd698ed411 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -18,6 +18,16 @@ pub struct SystemLimits { /// document type parser generation 3 (protocol version 14), the only generation that /// parses typed arrays, and never reached before. pub max_typed_array_items: u16, + /// Maximum number of references one document of a document type may carry, counted at + /// contract registration or update from the type's `refersTo` declarations: one for each + /// property that declares one (an identifier, or a key id carrying a key reference), and + /// `maxItems` for each typed array whose identifier elements declare one. Every reference is checked against state when the + /// document is created or replaced, each check a billed read, so this bounds the reads one + /// document write can cause; without it a type could declare many typed arrays of + /// `max_typed_array_items` references each. Refused under full validation only, like + /// `max_typed_array_items`. Read by document type parser generation 3 (protocol version + /// 14), the only generation that parses `refersTo`, and never reached before. + pub max_references_per_document: u16, /// Max size of a state transition in bytes. /// /// NOTE: This must be equal to the `max-tx-bytes` in the Tenderdash config diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index d421df2ae63..09ae4b30517 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -5,6 +5,7 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { max_field_value_size: 5120, //5 KiB max_document_value_depth: None, max_typed_array_items: 1024, + max_references_per_document: 256, max_state_transition_size: 20480, //20 KiB // TODO: this is currently capped at 1 because the batch state-transition // pipeline has known correctness issues with multi-transition batches: diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index bfc196d05ea..a0a40ae65be 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -11,6 +11,7 @@ pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { // v12 is already active on live networks; the depth limit activates in v13 (see v3). max_document_value_depth: None, max_typed_array_items: 1024, + max_references_per_document: 256, max_state_transition_size: 20480, //20 KiB // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch and SYSTEM_LIMITS_V1. diff --git a/packages/rs-platform-version/src/version/system_limits/v3.rs b/packages/rs-platform-version/src/version/system_limits/v3.rs index bbbe304298b..b14cac1f1d1 100644 --- a/packages/rs-platform-version/src/version/system_limits/v3.rs +++ b/packages/rs-platform-version/src/version/system_limits/v3.rs @@ -13,6 +13,7 @@ pub const SYSTEM_LIMITS_V3: SystemLimits = SystemLimits { // instance budget, bounding pre-schema work well above known document requirements. max_document_value_depth: Some(256), max_typed_array_items: 1024, + max_references_per_document: 256, max_state_transition_size: 20480, //20 KiB // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch and SYSTEM_LIMITS_V1. diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index 80d0298defe..d9269889647 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -53,6 +53,11 @@ use crate::version::system_limits::SystemLimits; /// * Typed array document properties (protocol version 14): a typed array property declares /// `maxItems`, at most 1024 elements (`max_typed_array_items`, backfilled into the /// earlier tables, whose parsers never read it). +/// * References (protocol version 14): one document of a document type carries at most 256 +/// references, counted at registration as one per property with `refersTo` and +/// `maxItems` per typed array whose elements declare one (`max_references_per_document`, +/// backfilled into the earlier tables, whose parsers never read it). Each reference is a +/// billed state read when the document is written. pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5120, //5 KiB @@ -60,6 +65,7 @@ pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { // instance budget, bounding pre-schema work well above known document requirements. max_document_value_depth: Some(256), max_typed_array_items: 1024, // typed array properties (new in v14): contract registration caps their maxItems here + max_references_per_document: 256, // refersTo (new in v14): contract registration caps the references one document carries, a typed array of references counting its maxItems max_state_transition_size: 20480, //20 KiB // Load-bearing for state correctness, not just for throughput — see // SystemLimits::max_transitions_in_documents_batch and SYSTEM_LIMITS_V1. diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 4e0fe186b24..2f10cc0ae37 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -603,6 +603,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// passes. The parser checks the target at contract /// registration and update (it must exist, be an identifier and not be /// the declaring property), and a changed `distinctFrom` is an +/// incompatible schema change on update. /// /// 27. **`encryptedFor` on byte array properties**: a byte array property may /// declare how its ciphertext was produced, so wallets read the recipe @@ -686,6 +687,36 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// an incompatible schema change on update, like the rest of a /// `refersTo`. /// +/// 31. **`refersTo` on the elements of a typed array**: an identifier element +/// of a typed array may carry a `refersTo` declaration on its `items`, +/// which every element then declares (meta-schema v3 `documentArrayItem` +/// reuses the property `refersTo` definition by `$ref` and refuses +/// `identityPublicKey` in both forms, which pair one key id with the +/// reference). +/// `parse_typed_array` 0 folds it into the element through the same +/// `apply_property_reference` 0 a scalar identifier goes through, so the +/// element is `IdentifierWithReference(target)` inside `item_type`, and +/// `DocumentPropertyType::reference` reports either kind. Contract +/// registration (`data_contract_reference_validation` 0) checks the +/// declaration as a single one, and document create state validation 2 +/// and replace state validation 1 (`document_reference_validation` 0, +/// extended in place: both are only reached from protocol version 14, +/// where the element arm is the only new path) check every element as a +/// single reference, refusing the first that fails with that +/// reference's error (40120, 40127, 40135 and the rest), its path the +/// element's list path (`reasons[2]`). A replace re-validates the list +/// when it changed, when a property bound by a `propertyAgreement` +/// changed, and always for a `$ownerId` agreement or `deletableDocument` +/// elements. A foreign contract holding the referenced document type is +/// fetched once per list. Registration caps the references one document +/// can carry at `SYSTEM_LIMITS_V4.max_references_per_document` (256; one +/// per property declaring a reference, key id references of item 30 +/// included, `maxItems` per typed array of referencing elements; +/// backfilled into the earlier tables), and +/// refuses an `immutable` typed array of `deletableDocument` references, +/// which could never be replaced once one target is deleted. A changed +/// element `refersTo` is an incompatible schema change on update. +/// /// 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_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index 316a5921e6f..b6994e96be1 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -16,7 +16,7 @@ use crate::identifier::IdentifierWasm; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::{ DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentTypeRef, - IdentityKeyReferenceRequirements, KeyIdReference, + IdentityKeyReferenceRequirements, KeyIdReference, PropertyReference, }; use dpp::prelude::Identifier; use js_sys::{Array, Object, Reflect}; @@ -193,13 +193,19 @@ export type DocumentPropertyReferenceTarget = export type DocumentPropertyReference = { /** * Dotted path of the declaring property within the document type — for - * example `"author"`, or `"meta.parentId"` for a nested one. + * example `"author"`, or `"meta.parentId"` for a nested one. A + * declaration on the `items` of a typed array of identifiers, which every + * element carries, is listed with the list path of its elements, for + * example `"reasons[]"`; its `type` is never `identityPublicKey`, which + * an element cannot declare. * * This is the same string consensus reports in the `path` field of the * document-write reference errors (codes 40120-40125, 40131, 40135 and - * 40136). Note that contract - * *registration* errors prefix it with the document type name - * (`"."`) while document *write* errors do not. + * 40136), except that a write error names the failing element by its + * index (`"reasons[2]"` for + * the third). Note that contract *registration* errors prefix it with the + * document type name (`"."`, `".reasons[]"`) + * while document *write* errors do not. */ path: string; } & DocumentPropertyReferenceTarget; @@ -279,11 +285,6 @@ fn set_key_requirements_field( } /// Build the flat, internally-tagged JS object for one declaration. -/// -/// `declaring_contract_id` resolves the document variants' -/// absent `contract_id`, which consensus reads as "the declaring contract" -/// — it computes `contract_id.unwrap_or(contract.id())` and treats an -/// explicit self-id identically, so collapsing the two here loses nothing. fn reference_to_js( path: &str, target: &DocumentPropertyReferenceTarget, @@ -291,7 +292,35 @@ fn reference_to_js( ) -> WasmDppResult { let object = Object::new(); set_field(&object, "path", &JsValue::from_str(path), path)?; + set_reference_target_fields(&object, target, declaring_contract_id, path)?; + Ok(object.into()) +} + +/// The `DocumentPropertyReferenceTarget` of a declaration as its own JS +/// object, as a typed array element's `refersTo` reports it. `path` only +/// names the declaration in an error. +pub(crate) fn reference_target_to_js( + target: &DocumentPropertyReferenceTarget, + declaring_contract_id: Identifier, + path: &str, +) -> WasmDppResult { + let object = Object::new(); + set_reference_target_fields(&object, target, declaring_contract_id, path)?; + Ok(object.into()) +} +/// Set the fields of one `DocumentPropertyReferenceTarget` on `object`. +/// +/// `declaring_contract_id` resolves the document variants' +/// absent `contract_id`, which consensus reads as "the declaring contract" +/// — it computes `contract_id.unwrap_or(contract.id())` and treats an +/// explicit self-id identically, so collapsing the two here loses nothing. +fn set_reference_target_fields( + object: &Object, + target: &DocumentPropertyReferenceTarget, + declaring_contract_id: Identifier, + path: &str, +) -> WasmDppResult<()> { let kind = match target { DocumentPropertyReferenceTarget::Identity => "identity", DocumentPropertyReferenceTarget::Contract { .. } => "contract", @@ -300,7 +329,7 @@ fn reference_to_js( DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => "identityPublicKey", DocumentPropertyReferenceTarget::DeletableDocument { .. } => "deletableDocument", }; - set_field(&object, "type", &JsValue::from_str(kind), path)?; + set_field(object, "type", &JsValue::from_str(kind), path)?; match target { DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::Token => {} @@ -347,7 +376,7 @@ fn reference_to_js( set_field(&fields, name, &JsValue::from_bool(flag), path)?; } } - set_field(&object, "contractRequirements", &fields, path)?; + set_field(object, "contractRequirements", &fields, path)?; } } DocumentPropertyReferenceTarget::PermanentDocument { @@ -362,13 +391,13 @@ fn reference_to_js( } => { let effective = contract_id.unwrap_or(declaring_contract_id); set_field( - &object, + object, "contractId", &JsValue::from(IdentifierWasm::from(effective)), path, )?; set_field( - &object, + object, "documentType", &JsValue::from_str(document_type_name), path, @@ -383,7 +412,7 @@ fn reference_to_js( for (referring, referenced) in property_agreement { set_field(&agreement, referring, &JsValue::from_str(referenced), path)?; } - set_field(&object, "propertyAgreement", &agreement, path)?; + set_field(object, "propertyAgreement", &agreement, path)?; } } DocumentPropertyReferenceTarget::IdentityPublicKey { @@ -391,20 +420,21 @@ fn reference_to_js( key_requirements, } => { set_field( - &object, + object, "keyIdProperty", &JsValue::from_str(key_id_property), path, )?; - set_key_requirements_field(&object, key_requirements, path)?; + set_key_requirements_field(object, key_requirements, path)?; } } - Ok(object.into()) + Ok(()) } /// Collect every reference declaration of one document type, in schema -/// property order. +/// property order: an identifier property's own, and the one the elements +/// of a typed array of identifiers carry, listed at `path[]`. /// /// Walks `flattened_properties` rather than `properties` because that is /// what both consensus validators walk, and because their error `path` is @@ -418,13 +448,23 @@ pub(crate) fn references_for_document_type( for (path, property) in document_type.flattened_properties() { match &property.property_type { - DocumentPropertyType::IdentifierWithReference(target) => { - references.push(&reference_to_js(path, target, declaring_contract_id)?); - } DocumentPropertyType::KeyIdWithReference(reference) => { references.push(&key_id_reference_to_js(path, reference)?); } - _ => {} + property_type => match property_type.reference() { + Some(PropertyReference::Value(target)) => { + references.push(&reference_to_js(path, target, declaring_contract_id)?); + } + Some(PropertyReference::Elements(target)) => { + let element_path = format!("{path}[]"); + references.push(&reference_to_js( + &element_path, + target, + declaring_contract_id, + )?); + } + None => {} + }, } } diff --git a/packages/wasm-dpp2/src/data_contract/document_type_typed_arrays.rs b/packages/wasm-dpp2/src/data_contract/document_type_typed_arrays.rs index 13fdee452db..a73ddda879f 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_typed_arrays.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_typed_arrays.rs @@ -9,11 +9,13 @@ //! are lists, and of what?", without hand-parsing the contract's raw JSON //! schema. +use crate::data_contract::document_type_reference::reference_target_to_js; use crate::error::{WasmDppError, WasmDppResult}; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::array::{ArrayItemConstraints, TypedArrayProperty}; use dpp::data_contract::document_type::{DocumentPropertyType, DocumentTypeRef}; use dpp::platform_value::Value; +use dpp::prelude::Identifier; use js_sys::{Array, Object, Reflect}; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::wasm_bindgen; @@ -38,7 +40,18 @@ export type DocumentTypedArrayItem = | { type: 'boolean'; enum?: boolean[] } | { type: 'string'; minLength?: number; maxLength?: number; enum?: string[] } | { type: 'byteArray'; minItems?: number; maxItems?: number } - | { type: 'identifier' }; + | { + type: 'identifier'; + /** + * The `refersTo` declaration every element carries, when the `items` + * schema declares one: consensus checks each element as a single + * reference when a document is created or replaced, and a write error + * names the failing element by its list path (`"reasons[2]"`). Never + * `identityPublicKey`. The same declaration is listed by + * `documentTypeReferences` at the path `"[]"`. + */ + refersTo?: DocumentPropertyReferenceTarget; + }; /** * A single typed array property of a document type. @@ -112,9 +125,12 @@ fn scalar_to_js(value: &Value) -> Option { } /// Build the flat, internally-tagged JS object for one element type. +/// `declaring_contract_id` resolves an element reference's absent +/// `contractId`, as `documentTypeReferences` does. fn item_to_js( item_type: &DocumentPropertyType, constraints: &ArrayItemConstraints, + declaring_contract_id: Identifier, path: &str, ) -> WasmDppResult { let object = Object::new(); @@ -156,6 +172,14 @@ fn item_to_js( set_bound(&object, "minItems", sizes.min_size, path)?; set_bound(&object, "maxItems", sizes.max_size, path)?; } + DocumentPropertyType::IdentifierWithReference(target) => { + set_field( + &object, + "refersTo", + &reference_target_to_js(target, declaring_contract_id, path)?, + path, + )?; + } _ => {} } @@ -178,13 +202,22 @@ fn item_to_js( } /// Build the JS object for one typed array property. -fn typed_array_to_js(path: &str, typed_array: &TypedArrayProperty) -> WasmDppResult { +fn typed_array_to_js( + path: &str, + typed_array: &TypedArrayProperty, + declaring_contract_id: Identifier, +) -> WasmDppResult { let object = Object::new(); set_field(&object, "path", &JsValue::from_str(path), path)?; set_field( &object, "items", - &item_to_js(&typed_array.item_type, &typed_array.item_constraints, path)?, + &item_to_js( + &typed_array.item_type, + &typed_array.item_constraints, + declaring_contract_id, + path, + )?, path, )?; set_bound(&object, "minItems", typed_array.min_items, path)?; @@ -210,12 +243,17 @@ fn typed_array_to_js(path: &str, typed_array: &TypedArrayProperty) -> WasmDppRes /// object property and names it by its dotted path. pub(crate) fn typed_arrays_for_document_type( document_type: DocumentTypeRef<'_>, + declaring_contract_id: Identifier, ) -> WasmDppResult { let typed_arrays = Array::new(); for (path, property) in document_type.flattened_properties() { if let DocumentPropertyType::TypedArray(typed_array) = &property.property_type { - typed_arrays.push(&typed_array_to_js(path, typed_array)?); + typed_arrays.push(&typed_array_to_js( + path, + typed_array, + declaring_contract_id, + )?); } } diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index c9879728340..b84fbeb5938 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -927,7 +927,7 @@ impl DataContractWasm { )) })?; - let typed_arrays = typed_arrays_for_document_type(document_type)?; + let typed_arrays = typed_arrays_for_document_type(document_type, self.0.id())?; Ok(JsValue::from(typed_arrays).into()) } @@ -941,7 +941,7 @@ impl DataContractWasm { let map = js_sys::Map::new(); for (name, document_type) in self.0.document_types() { - let typed_arrays = typed_arrays_for_document_type(document_type.as_ref())?; + let typed_arrays = typed_arrays_for_document_type(document_type.as_ref(), self.0.id())?; if typed_arrays.length() > 0 { map.set(&JsValue::from_str(name), &typed_arrays.into()); } diff --git a/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts index 50f59b9877f..33f21aaf094 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts @@ -151,6 +151,112 @@ describe('DataContract: typed arrays (v14)', () => { }); }); + /** + * An identifier element may carry `refersTo`, which every element then + * declares and consensus checks element by element. + */ + describe('element references', () => { + const referencingSchemas = { + reason: { + type: 'object', + canBeDeleted: false, + properties: { + topic: { type: 'string', position: 0, maxLength: 63 }, + }, + additionalProperties: false, + }, + submittedCharter: { + 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', + refersTo: { + type: 'permanentDocument', + documentType: 'reason', + propertyAgreement: { topic: 'topic' }, + }, + }, + position: 0, + }, + topic: { type: 'string', position: 1, maxLength: 63 }, + }, + additionalProperties: false, + }, + }; + + type ElementReference = { + type: string; + contractId: { toBase58(): string }; + documentType: string; + propertyAgreement?: Record; + }; + + it('should report the element reference on the typed array items', () => { + const contract = buildContract(referencingSchemas); + const [reasons] = contract.documentTypeTypedArrays('submittedCharter') as { + path: string; + items: { type: string; refersTo: ElementReference }; + }[]; + + expect(reasons.path).to.equal('reasons'); + expect(reasons.items.type).to.equal('identifier'); + expect(reasons.items.refersTo.type).to.equal('permanentDocument'); + expect(reasons.items.refersTo.contractId.toBase58()).to.equal(contract.id.toBase58()); + expect(reasons.items.refersTo.documentType).to.equal('reason'); + expect(reasons.items.refersTo.propertyAgreement).to.deep.equal({ topic: 'topic' }); + }); + + it('should list the element reference among the references at its list path', () => { + const contract = buildContract(referencingSchemas); + const references = contract.documentTypeReferences('submittedCharter') as { + path: string; + type: string; + }[]; + + expect(references.map((reference) => [reference.path, reference.type])).to.deep.equal([ + ['reasons[]', 'permanentDocument'], + ]); + }); + + it('should refuse an identityPublicKey reference on the elements', () => { + const schemasWithKeyReference = { + submittedCharter: { + type: 'object', + properties: { + reasons: { + type: 'array', + maxItems: 4, + items: { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', + refersTo: { type: 'identityPublicKey', keyIdProperty: 'keyId' }, + }, + position: 0, + }, + keyId: { + type: 'integer', minimum: 0, maximum: 4294967295, position: 1, + }, + }, + additionalProperties: false, + }, + }; + + expect(() => buildContract(schemasWithKeyReference)).to.throw(); + }); + }); + describe('documentTypedArrays', () => { it('should key typed arrays by document type and omit types declaring none', () => { const contract = buildContract(schemas); From 89033c8d72e6452790560c93c49edc23d92c8b26 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 08:20:58 +0700 Subject: [PATCH 2/2] fix(platform)!: element references keep unchanged elements alone on replace (PV14) Code review fixes for refersTo on typed array elements: - a replace re-validates only the elements of a changed list the stored list did not hold (the replace action carries stored_changed_values), unless a bound property changed, a writer gate applies or the target is deletable; a repeated element is fetched once - registration also refuses a single deletableDocument reference inside an immutable object, which no replace could clear - PropertyReference gains KeyId and carries max_items on Elements, so the validators, the reference bound and wasm-dpp2 enumerate through one match - the element checks move into apply_element_reference, versioned with apply_property_reference; an array-level identityPublicKey refersTo gets its own error - shared test harnesses, new tests (nested list path, contract requirements, one foreign fetch per list, repeated elements, key id references in the bound), js-evo-sdk README Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/documents.md | 4 +- packages/js-evo-sdk/README.md | 2 + .../class_methods/parse_typed_array/v0/mod.rs | 47 +- .../class_methods/try_from_schema/mod.rs | 71 ++- .../class_methods/try_from_schema/v3/mod.rs | 63 ++- .../v3/typed_array_reference_tests.rs | 192 ++++--- .../v3/typed_array_test_helpers.rs | 72 +++ .../try_from_schema/v3/typed_array_tests.rs | 65 +-- .../document_type/property/mod.rs | 53 +- .../state_v2/mod.rs | 1 + .../document_reference_validation/mod.rs | 7 + .../document_reference_validation/v0/mod.rs | 186 ++++--- .../state_v1/mod.rs | 1 + .../document/deletable_document_reference.rs | 308 +--------- .../batch/tests/document/distinct_from.rs | 1 + .../batch/tests/document/encrypted_for.rs | 1 + .../batch/tests/document/mod.rs | 1 + .../tests/document/reference_test_setup.rs | 319 +++++++++++ .../tests/document/typed_array_references.rs | 527 ++++++++---------- .../v0/mod.rs | 22 +- ...idation-contract-typed-array-elements.json | 162 ++++++ .../document_replace_transition_action/mod.rs | 6 + .../v0/mod.rs | 9 + .../v0/transformer.rs | 16 +- .../state_transition_action/batch/tests.rs | 2 + .../rs-platform-version/src/version/v14.rs | 18 +- .../data_contract/document_type_reference.rs | 34 +- .../tests/unit/DocumentTypedArrays.spec.ts | 4 +- 28 files changed, 1275 insertions(+), 919 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_test_helpers.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/reference_test_setup.rs diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 5833cb855a8..9dc555a5f26 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -365,9 +365,9 @@ An identifier element may carry a `refersTo` declaration, which every element of - The declaration takes every target and key a single identifier property's `refersTo` takes (`identity`, `contract` with `contractRequirements`, `token`, and `permanentDocument` and `deletableDocument` with `contractId`, `documentType` and `propertyAgreement`), with the same checks at contract registration, except `identityPublicKey` in either form: its `keyIdProperty` names one sibling key id, and the `identityProperty` form sits on the key id itself, neither of which can pair with many elements, so an element may not declare it. The declaration belongs on the `items`; on the array itself it is refused. - When a document is created or replaced each element is checked as a single reference is, in list order: the target must exist, a referenced contract must meet the `contractRequirements`, a referenced document's type must be deletable or not as declared, and each `propertyAgreement` pair must hold. The referring side of a pair is still a property of the referring document or its `$ownerId`, the same for every element; the referenced side is a property of that element's referenced document. The first element that fails refuses the write with the error a single reference gives (`ReferencedEntityNotFoundError` 40120, `ReferencedDocumentPropertyMismatchError` 40127, `ReferencedContractRequirementNotMetError` 40135 and so on), whose `path` names the element by its list path: `reasons[2]` for the third. An empty or absent list checks nothing. Registration errors name the declaration `submittedCharter.reasons[]`. -- A replace re-validates the whole list when it changed or a property bound by a `propertyAgreement` changed, and on every replace when an agreement is keyed by `$ownerId` or the elements are `deletableDocument` references: the rules of a single reference, with the list as one property. The transition does not say which elements are new, so a changed list re-validates all of them. +- A replace follows the rules of a single reference, element by element. A changed list re-validates the elements the stored list did not hold (the replace action carries the stored value of each changed property, `stored_changed_values`); the ones it held are unchanged references and are left alone, as an unchanged single reference is. Every element is re-validated when a property bound by a `propertyAgreement` changed, and on every replace when an agreement is keyed by `$ownerId` or the elements are `deletableDocument` references. An element repeating an earlier one of the same list is not fetched again. - Every read is billed as a single reference's is. A foreign contract holding the referenced document type is fetched once per list, not once per element. Contract registration caps the references one document of a type can carry at `SystemLimits::max_references_per_document` (256), counting one per property with `refersTo` (an identifier, or a key id carrying a key reference) and `maxItems` per typed array of referencing elements: `maxItems` alone would let a type declare many lists of up to 1024 references each, and each one is a read when a document is written. -- An `immutable` property may not hold a typed array of `deletableDocument` references, at the top level or inside an immutable object. Every replace re-validates them, so once one target is deleted the list would have to change, which an immutable property cannot. A single `deletableDocument` reference has a way out, a replace may clear it once its target is gone, but that exception reads the one identifier the removed property held. +- An `immutable` property may not hold a `deletableDocument` reference a replace could not clear: a typed array of them, at the top level or inside an immutable object, or a single one inside an immutable object. Every replace re-validates them, so once a target is deleted the property would have to change, which an immutable property cannot. A single `deletableDocument` reference that is itself the immutable top-level property has a way out, a replace may remove it once its target is gone, and that exception reads the one identifier the removed top-level property held. - A changed element `refersTo` is an incompatible schema change on contract update, as a changed `refersTo` on a scalar is. In Rust the element parses to `DocumentPropertyType::IdentifierWithReference(target)` inside `item_type`, through the same versioned `apply_property_reference` a scalar identifier goes through. `DocumentPropertyType::reference()` reports a property's declaration as `PropertyReference::Value(target)` for a scalar and `PropertyReference::Elements(target)` for a typed array; the registration check (`validate_data_contract_references`) and the write-time check (`validate_document_references`) both enumerate references through it. diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index f7a7e25b3cf..4d9d3a4040c 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -207,6 +207,8 @@ for (const ref of contract.documentTypeReferences('note')) { contract.documentReferences; ``` +A typed array of identifiers may declare `refersTo` on its `items`, which every element then carries. Such a declaration is listed at the list path of its elements, `path: 'reasons[]'`, which is not a property path: read the list at `reasons` and treat each element as a reference. The same declaration is on the typed array's item, `contract.documentTypeTypedArrays('charter')[0].items.refersTo`. Consensus checks every element when the document is written, and a rejection names the failing element by its index, as in `reasons[2]` for the third. + A document reference comes in two strengths. `permanentDocument` requires the referenced document type to declare `canBeDeleted: false`, so a reference that was accepted keeps resolving. `deletableDocument` takes the same declaration (`contractId`, `documentType`, `propertyAgreement`) and is its disjoint counterpart: the referenced type must allow deletion (`ReferencedDocumentTypeNotDeletable`, 40131, otherwise). The referenced document must exist, and the agreement must hold, when the referring document is written, but it may be deleted afterwards. Nothing blocks that deletion and nothing cleans up after it, so a reader must expect such a reference to resolve to nothing. It can never start resolving to different content: a document id commits to the nonce of its create transition, so a deleted id can not be created again. A writer may not leave it that way: every replace of the referring document re-validates the reference, touched or not, so once the target is gone the replace has to repoint it at a document that exists or clear it (`ReferencedEntityNotFound` otherwise). A writer gate is then checked against the new target, never against a missing one. On an `immutable` property clearing is the only move, and the immutable check lets that one change through. The referring document can always be deleted. A property cannot switch between the two on a contract update, and `preallocated` indexes are only available through `permanentDocument`. Declarations are only parsed from protocol version 14 onward; a contract deserialized against an earlier version reports none even when its raw schema carries the keyword. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs index 341ae9a49eb..4fdbe30dff0 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/parse_typed_array/v0/mod.rs @@ -5,7 +5,7 @@ use platform_value::Value; use platform_version::version::PlatformVersion; use crate::data_contract::document_type::array::{ArrayItemConstraints, TypedArrayProperty}; -use crate::data_contract::document_type::class_methods::try_from_schema::apply_property_reference; +use crate::data_contract::document_type::class_methods::try_from_schema::apply_element_reference; use crate::data_contract::document_type::{ property_names, DocumentPropertyType, DocumentPropertyTypeParsingOptions, }; @@ -84,16 +84,16 @@ pub(super) fn parse_typed_array_v0( /// an identifier. Objects and arrays of arrays are refused. /// /// A `refersTo` on identifier elements is folded into the element type by -/// `apply_property_reference`, the function (and the version of it) that -/// folds one into a scalar identifier, so an element reference has the -/// scalar's target types, keys and checks: the element becomes -/// `IdentifierWithReference(target)`. The one target refused is -/// `identityPublicKey`, in either form: its `keyIdProperty` names a single +/// `apply_element_reference`, versioned with and calling the rules of +/// `apply_property_reference` that fold one into a scalar identifier, so an +/// element reference has the scalar's target types, keys and checks: the +/// element becomes `IdentifierWithReference(target)`. The one target refused +/// is `identityPublicKey`, in either form: its `keyIdProperty` names a single /// sibling key id, and an `identityProperty` declaration sits on the key id -/// itself, neither of which can pair with many elements. The contract-level checks of the -/// declaration (the referenced document type, the `propertyAgreement` sides -/// and value kinds) need other contracts and run at registration in -/// drive-abci, which visits element references too. +/// itself, neither of which can pair with many elements. The contract-level +/// checks of the declaration (the referenced document type, the +/// `propertyAgreement` sides and value kinds) need other contracts and run at +/// registration in drive-abci, which visits element references too. fn parse_element_type( items: &Value, options: &DocumentPropertyTypeParsingOptions, @@ -133,32 +133,7 @@ fn parse_element_type( } let element_type = DocumentPropertyType::try_from_value_map(&items_map, options)?; - let element_type = match items_map.get(property_names::REFERS_TO) { - None => element_type, - Some(refers_to) => { - if !matches!(element_type, DocumentPropertyType::Identifier) { - return Err(DataContractError::InvalidContractStructure( - "refersTo is only allowed on identifier elements of a typed array".to_string(), - )); - } - // Either identityPublicKey form pairs one key id with the - // reference, a sibling property (keyIdProperty) or the property - // itself (identityProperty), which cannot pair with many elements - let reference_type = refers_to - .to_btree_ref_string_map()? - .get(property_names::TYPE) - .and_then(|reference_type| reference_type.as_text()); - if reference_type == Some("identityPublicKey") { - return Err(DataContractError::InvalidContractStructure( - "identityPublicKey refersTo is not allowed on the elements of a typed array: \ - it pairs one key id with the reference, which cannot pair with many \ - elements" - .to_string(), - )); - } - apply_property_reference(&items_map, element_type, platform_version)? - } - }; + let element_type = apply_element_reference(&items_map, element_type, platform_version)?; match element_type { DocumentPropertyType::U128 | DocumentPropertyType::I128 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 faf99d5cf1f..bae56371b33 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 @@ -570,9 +570,14 @@ fn apply_property_reference_v0( // A typed array only exists from protocol version 14, where its element // reference is read off the items by the typed array parser if matches!(property_type, DocumentPropertyType::TypedArray(_)) { - return Err(DataContractError::InvalidContractStructure( + let message = if is_identity_public_key_reference(refers_to_value)? { + "identityPublicKey refersTo is not allowed on a typed array or on its elements: it \ + pairs one key id with the reference, which cannot pair with many elements" + } else { "refersTo on a typed array belongs on its items, where it applies to every element" - .to_string(), + }; + return Err(DataContractError::InvalidContractStructure( + message.to_string(), )); } @@ -828,6 +833,68 @@ fn apply_key_id_reference_v0( })) } +/// Whether a `refersTo` declaration names the `identityPublicKey` target. +fn is_identity_public_key_reference(refers_to: &Value) -> Result { + Ok(refers_to + .to_btree_ref_string_map()? + .get(property_names::TYPE) + .and_then(|reference_type| reference_type.as_text()) + == Some("identityPublicKey")) +} + +/// Folds a `refersTo` declared on the `items` of a typed array into the +/// element type, as [`apply_property_reference`] folds one into a scalar +/// identifier, which the version 0 rules call for the declaration itself: +/// only an identifier element may carry one, and never an +/// `identityPublicKey` one, in either form, since that pairs one key id with +/// the reference (a sibling `keyIdProperty`, or the key id itself through +/// `identityProperty`), which cannot pair with many elements. +/// +/// Versioned on `apply_property_reference`, the gate of the declarations it +/// reads: `None` ignores the keyword on an element exactly as it does on a +/// property. +pub(in crate::data_contract::document_type::class_methods) fn apply_element_reference( + items: &BTreeMap, + element_type: DocumentPropertyType, + platform_version: &PlatformVersion, +) -> Result { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .apply_property_reference + { + None => Ok(element_type), + Some(0) => apply_element_reference_v0(items, element_type), + Some(version) => Err(DataContractError::Unsupported(format!( + "apply_element_reference version {version} is not supported" + ))), + } +} + +fn apply_element_reference_v0( + items: &BTreeMap, + element_type: DocumentPropertyType, +) -> Result { + let Some(refers_to) = items.get(property_names::REFERS_TO) else { + return Ok(element_type); + }; + if !matches!(element_type, DocumentPropertyType::Identifier) { + return Err(DataContractError::InvalidContractStructure( + "refersTo is only allowed on identifier elements of a typed array".to_string(), + )); + } + if is_identity_public_key_reference(refers_to)? { + return Err(DataContractError::InvalidContractStructure( + "identityPublicKey refersTo is not allowed on the elements of a typed array: it pairs \ + one key id with the reference, which cannot pair with many elements" + .to_string(), + )); + } + apply_property_reference_v0(items, element_type) +} + /// Reads a property's `encryptedFor` declaration: how the bytes of a byte /// array property were encrypted. Non-byte-array properties, identifiers /// among them, cannot carry it. 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 668efd871af..9bdf90a7ea4 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 @@ -510,18 +510,8 @@ fn validate_reference_count( let references: u32 = document_type .flattened_properties() .values() - .map( - |property| match (&property.property_type, property.property_type.reference()) { - ( - DocumentPropertyType::TypedArray(typed_array), - Some(PropertyReference::Elements(_)), - ) => u32::from(typed_array.max_items), - // A key reference declared on the key id itself is one key - // read, as an identifier's is - (_, Some(_)) | (DocumentPropertyType::KeyIdWithReference(_), None) => 1, - (_, None) => 0, - }, - ) + .filter_map(|property| property.property_type.reference()) + .map(|reference| reference.max_references()) .sum(); if references > u32::from(limit) { return Err(consensus_or_protocol_data_contract_error( @@ -535,35 +525,50 @@ fn validate_reference_count( Ok(()) } -/// An `immutable` property may not hold a typed array of `deletableDocument` -/// references, directly or inside an immutable object. Every replace -/// re-validates such a reference, so once one element's target is deleted -/// the array would have to change, which an immutable property cannot: the -/// document could never be replaced again. A single `deletableDocument` -/// reference has a way out, the replace state validation lets a dead one be -/// cleared, and that exception reads the one identifier the removed -/// property held, which a list does not give it. +/// An `immutable` property may not hold a `deletableDocument` reference the +/// replace state validation could not clear: a typed array of them, at the +/// top level or inside an immutable object, or a single one inside an +/// immutable object. Every replace re-validates such a reference, so once a +/// target is deleted the property would have to change, which an immutable +/// property cannot: the document could never be replaced again. The one +/// such reference that has a way out is a single one held by an immutable +/// top-level property: a replace may remove it once its target is gone, an +/// exception that reads the one identifier the removed top-level property +/// held, which neither a list nor an object gives it. #[cfg(feature = "validation")] fn validate_no_immutable_deletable_element_references( document_type: &DocumentTypeV2, name: &str, ) -> Result<(), ProtocolError> { for (path, property) in document_type.flattened_properties() { - let Some(PropertyReference::Elements(DocumentPropertyReferenceTarget::DeletableDocument { - .. - })) = property.property_type.reference() - else { + let Some(reference) = property.property_type.reference() else { continue; }; + if !matches!( + reference.target(), + Some(DocumentPropertyReferenceTarget::DeletableDocument { .. }) + ) { + continue; + } let top_level = path.split('.').next().unwrap_or(path); + let is_list = matches!(reference, PropertyReference::Elements { .. }); + // A single reference that is itself the immutable property can be + // cleared once its target is gone + if !is_list && top_level == path { + continue; + } if document_type.immutable_fields.contains(top_level) { + let held_as = if is_list { + "a typed array of deletableDocument references" + } else { + "a deletableDocument reference inside an object" + }; return Err(consensus_or_protocol_data_contract_error( DataContractError::InvalidContractStructure(format!( "document type \"{name}\" lists \"{top_level}\" as immutable, but \"{path}\" is \ - a typed array of deletableDocument references: every replace re-validates \ - them, so once one target is deleted the array would have to change and the \ - document could never be replaced again. Use permanentDocument references or \ - leave the array mutable", + {held_as}: every replace re-validates it, so once a target is deleted the \ + property would have to change and the document could never be replaced \ + again. Use permanentDocument references, or leave the property mutable", )), )); } @@ -619,6 +624,8 @@ mod moderators_delete_tests; mod name_rules_tests; #[cfg(all(test, feature = "validation"))] mod typed_array_reference_tests; +#[cfg(all(test, feature = "validation"))] +mod typed_array_test_helpers; #[cfg(all(test, feature = "validation", feature = "random-documents"))] mod typed_array_tests; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_reference_tests.rs index e0cae9fb9f6..e8321024ed5 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_reference_tests.rs @@ -9,10 +9,10 @@ //! document type, the `propertyAgreement` sides and value kinds) run at //! registration in drive-abci and are tested there. +use super::typed_array_test_helpers::{ + expect_json_schema_error, expect_structure_error, parse_dispatched, +}; use super::*; -use crate::consensus::basic::json_schema_error::JsonSchemaError; -use crate::consensus::basic::BasicError; -use crate::consensus::ConsensusError; use crate::data_contract::accessors::v0::DataContractV0Getters; use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::array::TypedArrayProperty; @@ -20,34 +20,15 @@ use crate::data_contract::document_type::{ ContractReferenceModeration, ContractReferenceRequirements, DocumentPropertyReferenceTarget, DocumentPropertyType, PropertyReference, }; -use crate::data_contract::errors::DataContractError; use crate::data_contract::serialized_version::v0::DataContractInSerializationFormatV0; use crate::data_contract::DataContract; +use crate::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, + PlatformSerializableWithPlatformVersion, +}; use platform_value::platform_value; use platform_value::string_encoding::Encoding; -fn parse_dispatched( - schema: Value, - platform_version: &PlatformVersion, - full_validation: bool, -) -> Result { - let config = DataContractConfig::default_for_version(platform_version) - .expect("default config available on this platform version"); - DocumentType::try_from_schema( - Identifier::new([1; 32]), - 1, - config.version(), - "submittedCharter", - schema, - None, - &BTreeMap::new(), - &config, - full_validation, - &mut vec![], - platform_version, - ) -} - /// An identifier element schema, carrying `refers_to` when given. fn identifier_items(refers_to: Option) -> Value { let mut items = platform_value!({ @@ -100,37 +81,6 @@ fn reasons_type(document_type: &DocumentType) -> DocumentPropertyType { .expect("the reasons property is parsed") } -fn expect_json_schema_error( - result: Result, -) -> JsonSchemaError { - match result { - Err(ProtocolError::ConsensusError(boxed)) => match *boxed { - ConsensusError::BasicError(BasicError::JsonSchemaError(error)) => error, - other => panic!("expected a JSON schema error, got {other:?}"), - }, - other => panic!("expected a JSON schema error, got {other:?}"), - } -} - -fn expect_structure_error(result: Result, needle: &str) { - let message = match result { - Err(ProtocolError::DataContractError(DataContractError::InvalidContractStructure( - message, - ))) => message, - Err(ProtocolError::ConsensusError(boxed)) => match *boxed { - ConsensusError::BasicError(BasicError::ContractError( - DataContractError::InvalidContractStructure(message), - )) => message, - other => panic!("expected InvalidContractStructure, got {other:?}"), - }, - other => panic!("expected InvalidContractStructure, got {other:?}"), - }; - assert!( - message.contains(needle), - "expected {needle:?} in the error, got: {message}" - ); -} - /// Every target type a single identifier property takes, `identityPublicKey` /// aside, with the same keys, folded into the element exactly as a scalar /// reference is folded into its property. @@ -210,7 +160,10 @@ fn should_parse_an_element_reference_of_each_target_type() { assert_eq!(parsed, expected_type, "{refers_to:?}"); assert_eq!( parsed.reference(), - Some(PropertyReference::Elements(&expected)), + Some(PropertyReference::Elements { + target: &expected, + max_items: 64 + }), "{refers_to:?}" ); } @@ -314,6 +267,24 @@ fn should_refuse_refers_to_on_the_typed_array_itself() { parse_dispatched(schema, PlatformVersion::latest(), false), "refersTo on a typed array belongs on its items", ); + + // An identityPublicKey declaration is refused on the elements as well, + // so the error does not send the author there + let mut reasons = reasons_with_items(identifier_items(None)); + reasons + .set_value( + "refersTo", + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "topic" }), + ) + .expect("refersTo applies"); + expect_structure_error( + parse_dispatched( + schema_with_reasons(reasons), + PlatformVersion::latest(), + false, + ), + "identityPublicKey refersTo is not allowed on a typed array or on its elements", + ); } /// The rules the parse itself holds for a `propertyAgreement`, identical for @@ -362,9 +333,10 @@ fn should_refuse_an_element_reference_before_protocol_version_14_and_accept_it_a .expect("protocol version 14 admits an element reference"); assert!(matches!( reasons_type(&document_type).reference(), - Some(PropertyReference::Elements( - DocumentPropertyReferenceTarget::PermanentDocument { .. } - )) + Some(PropertyReference::Elements { + target: DocumentPropertyReferenceTarget::PermanentDocument { .. }, + .. + }) )); } @@ -421,6 +393,28 @@ fn should_bound_the_references_one_document_can_carry() { &format!("above the maximum of {limit}"), ); + // A key reference declared on the key id itself is one read too + let mut schema = schema_with_references(limit, 0); + schema + .set_value_at_full_path( + "properties.senderKeyId", + platform_value!({ + "type": "integer", + "minimum": 0, + "maximum": 4294967295u64, + "position": 1, + "refersTo": { "type": "identityPublicKey", "identityProperty": "$ownerId" } + }), + ) + .expect("the key id property applies"); + expect_structure_error( + parse_dispatched(schema, platform_version, true), + &format!( + "declares references for up to {} values", + u32::from(limit) + 1 + ), + ); + // A stored contract was checked when it was registered parse_dispatched( schema_with_references(limit + 1, 0), @@ -476,6 +470,68 @@ fn should_refuse_an_immutable_typed_array_of_deletable_document_references() { true, ) .expect("a mutable array of deletableDocument references registers"); + + // Inside an immutable object, a list and a single reference alike: the + // replace could only clear either by changing the object + let deletable = platform_value!({ "type": "deletableDocument", "documentType": "draft" }); + let mut single_reference = identifier_items(Some(deletable.clone())); + single_reference + .set_value("position", Value::U32(1)) + .expect("position applies"); + let mut drafts = reasons_with_items(identifier_items(Some(deletable.clone()))); + drafts + .set_value("position", Value::U32(0)) + .expect("position applies"); + for (member, member_schema, held_as) in [ + ( + "drafts", + drafts, + "a typed array of deletableDocument references", + ), + ( + "lead", + single_reference, + "a deletableDocument reference inside an object", + ), + ] { + let schema = platform_value!({ + "type": "object", + "documentsMutable": true, + "immutable": ["team"], + "properties": { + "team": { + "type": "object", + "position": 0, + "properties": { member: member_schema }, + "additionalProperties": false + } + }, + "additionalProperties": false + }); + expect_structure_error( + parse_dispatched(schema, PlatformVersion::latest(), true), + &format!("\"team.{member}\" is {held_as}"), + ); + } + + // A single reference that is itself the immutable property can be + // cleared once its target is gone, so it registers + let mut single_reference = identifier_items(Some(deletable)); + single_reference + .set_value("position", Value::U32(0)) + .expect("position applies"); + parse_dispatched( + platform_value!({ + "type": "object", + "documentsMutable": true, + "immutable": ["draftId"], + "properties": { "draftId": single_reference }, + "additionalProperties": false + }), + PlatformVersion::latest(), + true, + ) + .expect("an immutable top-level deletableDocument reference registers"); } /// A contract with `reason` (never deleted) and `submittedCharter`, whose @@ -521,11 +577,6 @@ fn charter_contract(platform_version: &PlatformVersion) -> DataContract { #[test] fn should_round_trip_a_contract_with_an_element_reference_through_platform_serialization() { - use crate::serialization::{ - PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, - PlatformSerializableWithPlatformVersion, - }; - let platform_version = PlatformVersion::latest(); let contract = charter_contract(platform_version); @@ -550,16 +601,17 @@ fn should_round_trip_a_contract_with_an_element_reference_through_platform_seria .expect("reasons is parsed"); assert_eq!( reasons.property_type.reference(), - Some(PropertyReference::Elements( - &DocumentPropertyReferenceTarget::PermanentDocument { + Some(PropertyReference::Elements { + target: &DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, document_type_name: "reason".to_string(), property_agreement: BTreeMap::from([( "topic".to_string(), "topic".to_string() )]), - } - )) + }, + max_items: 64, + }) ); } } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_test_helpers.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_test_helpers.rs new file mode 100644 index 00000000000..5b3bf7a49bf --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/typed_array_test_helpers.rs @@ -0,0 +1,72 @@ +//! Test helpers the typed array suites share: a parse through the real +//! `try_from_schema` dispatcher and the two error shapes a refused parse +//! comes back as. + +use super::*; +use crate::consensus::basic::json_schema_error::JsonSchemaError; +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::data_contract::errors::DataContractError; + +/// Parse through the real dispatcher, which picks the parser generation out +/// of the platform version's `try_from_schema` table value (generation 2 at +/// PV13, generation 3 at PV14). +pub(super) fn parse_dispatched( + schema: Value, + platform_version: &PlatformVersion, + full_validation: bool, +) -> Result { + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available on this platform version"); + DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "charter", + schema, + None, + &BTreeMap::new(), + &config, + full_validation, + &mut vec![], + platform_version, + ) +} + +/// The error of a validating parse the meta-schema refused. +pub(super) fn expect_json_schema_error( + result: Result, +) -> JsonSchemaError { + match result { + Err(ProtocolError::ConsensusError(boxed)) => match *boxed { + ConsensusError::BasicError(BasicError::JsonSchemaError(error)) => error, + other => panic!("expected a JSON schema error, got {other:?}"), + }, + other => panic!("expected a JSON schema error, got {other:?}"), + } +} + +/// The parser's structure errors surface as `InvalidContractStructure` +/// either directly or, with the `validation` feature on, wrapped as the basic +/// `ContractError`. +pub(super) fn expect_structure_error( + result: Result, + needle: &str, +) { + let message = match result { + Err(ProtocolError::DataContractError(DataContractError::InvalidContractStructure( + message, + ))) => message, + Err(ProtocolError::ConsensusError(boxed)) => match *boxed { + ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(message), + )) => message, + other => panic!("expected InvalidContractStructure, got {other:?}"), + }, + other => panic!("expected InvalidContractStructure, got {other:?}"), + }; + assert!( + message.contains(needle), + "expected {needle:?} in the error, got: {message}" + ); +} 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 af33373096a..b5ee8a75e41 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 @@ -8,8 +8,10 @@ //! also cover the codec, the document validation and the contract //! serialization of a type that carries one. +use super::typed_array_test_helpers::{ + expect_json_schema_error, expect_structure_error, parse_dispatched, +}; use super::*; -use crate::consensus::basic::json_schema_error::JsonSchemaError; use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::data_contract::accessors::v0::DataContractV0Getters; @@ -17,36 +19,10 @@ use crate::data_contract::document_type::array::{ArrayItemConstraints, TypedArra use crate::data_contract::document_type::{ ByteArrayPropertySizes, DocumentPropertyType, StringPropertySizes, }; -use crate::data_contract::errors::DataContractError; use crate::data_contract::serialized_version::v0::DataContractInSerializationFormatV0; use crate::data_contract::DataContract; use platform_value::platform_value; -/// Parse through the real dispatcher, which picks the parser generation out -/// of the platform version's `try_from_schema` table value (generation 2 at -/// PV13, generation 3 at PV14). -fn parse_dispatched( - schema: Value, - platform_version: &PlatformVersion, - full_validation: bool, -) -> Result { - let config = DataContractConfig::default_for_version(platform_version) - .expect("default config available on this platform version"); - DocumentType::try_from_schema( - Identifier::new([1; 32]), - 1, - config.version(), - "charter", - schema, - None, - &BTreeMap::new(), - &config, - full_validation, - &mut vec![], - platform_version, - ) -} - /// A document type with one property, `list`, declared by `list_schema`. fn schema_with_list(list_schema: Value) -> Value { platform_value!({ @@ -86,41 +62,6 @@ fn list_property_type(document_type: &DocumentType) -> DocumentPropertyType { .expect("the list property is parsed") } -/// The error of a validating parse the meta-schema refused. -fn expect_json_schema_error( - result: Result, -) -> JsonSchemaError { - match result { - Err(ProtocolError::ConsensusError(boxed)) => match *boxed { - ConsensusError::BasicError(BasicError::JsonSchemaError(error)) => error, - other => panic!("expected a JSON schema error, got {other:?}"), - }, - other => panic!("expected a JSON schema error, got {other:?}"), - } -} - -/// The parser's structure errors surface as `InvalidContractStructure` -/// either directly or, with the `validation` feature on, wrapped as the basic -/// `ContractError`. -fn expect_structure_error(result: Result, needle: &str) { - let message = match result { - Err(ProtocolError::DataContractError(DataContractError::InvalidContractStructure( - message, - ))) => message, - Err(ProtocolError::ConsensusError(boxed)) => match *boxed { - ConsensusError::BasicError(BasicError::ContractError( - DataContractError::InvalidContractStructure(message), - )) => message, - other => panic!("expected InvalidContractStructure, got {other:?}"), - }, - other => panic!("expected InvalidContractStructure, got {other:?}"), - }; - assert!( - message.contains(needle), - "expected {needle:?} in the error, got: {message}" - ); -} - #[test] fn should_parse_a_typed_identifier_array() { let document_type = parse_dispatched( 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 5afe31ad220..8f2eb424585 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 @@ -861,28 +861,48 @@ impl DocumentPropertyReferenceTarget { } /// A property's `refersTo` declaration and what holds the reference: the -/// property's own value, or every element of a typed array of identifiers. -/// Returned by [`DocumentPropertyType::reference`], which is how the -/// registration and write-time validators enumerate a document type's -/// references, so neither kind can be skipped by a caller matching only -/// [`DocumentPropertyType::IdentifierWithReference`]. +/// property's own value, every element of a typed array of identifiers, or, +/// for a key reference declared on the key id itself, the key id. Returned +/// by [`DocumentPropertyType::reference`], which is how the registration and +/// write-time validators, the per-document reference bound and the client +/// bindings enumerate a document type's references, so no kind can be +/// skipped by a caller matching one property type. #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum PropertyReference<'a> { /// An identifier property: its value is the referenced id. Value(&'a DocumentPropertyReferenceTarget), /// A typed array whose elements are identifiers carrying `refersTo` /// (declared on its `items`): each element is a referenced id, all of - /// them to this one target. Never an + /// them to `target`, at most `max_items` of them. Never an /// [`DocumentPropertyReferenceTarget::IdentityPublicKey`], which the /// parser refuses on an element. - Elements(&'a DocumentPropertyReferenceTarget), + Elements { + target: &'a DocumentPropertyReferenceTarget, + max_items: u16, + }, + /// A key id property carrying an `identityPublicKey` declaration that + /// names whose key it is ([`DocumentPropertyType::KeyIdWithReference`]). + KeyId(&'a KeyIdReference), } impl<'a> PropertyReference<'a> { - /// The declaration, whatever holds the reference. - pub fn target(&self) -> &'a DocumentPropertyReferenceTarget { + /// The declaration of an identifier or element reference; `None` for a + /// key reference on the key id, which has no identifier target. + pub fn target(&self) -> Option<&'a DocumentPropertyReferenceTarget> { + match self { + PropertyReference::Value(target) | PropertyReference::Elements { target, .. } => { + Some(target) + } + PropertyReference::KeyId(_) => None, + } + } + + /// How many referenced values one document can carry through this + /// declaration: `max_items` for a typed array, one otherwise. + pub fn max_references(&self) -> u32 { match self { - PropertyReference::Value(target) | PropertyReference::Elements(target) => target, + PropertyReference::Elements { max_items, .. } => u32::from(*max_items), + PropertyReference::Value(_) | PropertyReference::KeyId(_) => 1, } } } @@ -1208,8 +1228,9 @@ impl DocumentPropertyType { } /// The `refersTo` declaration this property carries, on its own value - /// (an identifier property) or on every element (a typed array whose - /// `items` declare it); `None` for a property without one. + /// (an identifier property), on every element (a typed array whose + /// `items` declare it) or on the key id (a key reference naming whose + /// key it is); `None` for a property without one. pub fn reference(&self) -> Option> { match self { DocumentPropertyType::IdentifierWithReference(target) => { @@ -1217,10 +1238,16 @@ impl DocumentPropertyType { } DocumentPropertyType::TypedArray(typed_array) => match typed_array.item_type.as_ref() { DocumentPropertyType::IdentifierWithReference(target) => { - Some(PropertyReference::Elements(target)) + Some(PropertyReference::Elements { + target, + max_items: typed_array.max_items, + }) } _ => None, }, + DocumentPropertyType::KeyIdWithReference(reference) => { + Some(PropertyReference::KeyId(reference)) + } _ => None, } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs index e2140c5d065..2898dec7ea3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs @@ -63,6 +63,7 @@ impl DocumentCreateTransitionActionStateValidationV2 for DocumentCreateTransitio owner_id, Some(owner_id), None, + None, platform, block_info, transaction, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs index edf4ee7dc03..d350462403b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs @@ -47,6 +47,10 @@ pub(crate) trait DocumentReferenceValidation { /// property bound to it changed: a `propertyAgreement` referring property /// or an `identityPublicKey` key id property. A writer gate, an agreement /// keyed by `$ownerId`, is validated on every replace regardless. + /// `stored_values` (replace transitions) holds the stored value of each + /// changed property: a changed typed array of references re-validates + /// only the elements the stored list did not hold, unless a bound + /// property changed, a writer gate applies or its target is deletable. /// /// `owner_id` is the writer, the transition's owner: a `propertyAgreement` /// whose referring side is `$ownerId` compares it, and an `identityPublicKey` @@ -62,6 +66,7 @@ pub(crate) trait DocumentReferenceValidation { owner_id: Identifier, creator_id: Option, changed_fields: Option<&BTreeSet>, + stored_values: Option<&BTreeMap>, platform: &PlatformStateRef, block_info: &BlockInfo, transaction: TransactionArg, @@ -112,6 +117,7 @@ impl DocumentReferenceValidation for DocumentBaseTransitionAction { owner_id: Identifier, creator_id: Option, changed_fields: Option<&BTreeSet>, + stored_values: Option<&BTreeMap>, platform: &PlatformStateRef, block_info: &BlockInfo, transaction: TransactionArg, @@ -130,6 +136,7 @@ impl DocumentReferenceValidation for DocumentBaseTransitionAction { owner_id, creator_id, changed_fields, + stored_values, platform, block_info, transaction, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs index 1b8c07d228a..2cca54ad70d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs @@ -80,6 +80,7 @@ pub(crate) trait DocumentReferenceValidationV0 { owner_id: Identifier, creator_id: Option, changed_fields: Option<&BTreeSet>, + stored_values: Option<&BTreeMap>, platform: &PlatformStateRef, block_info: &BlockInfo, transaction: TransactionArg, @@ -172,6 +173,7 @@ impl DocumentReferenceValidationV0 for DocumentBaseTransitionAction { owner_id: Identifier, creator_id: Option, changed_fields: Option<&BTreeSet>, + stored_values: Option<&BTreeMap>, platform: &PlatformStateRef, block_info: &BlockInfo, transaction: TransactionArg, @@ -196,6 +198,7 @@ impl DocumentReferenceValidationV0 for DocumentBaseTransitionAction { owner_id, creator_id, changed_fields, + stored_values, platform, block_info, transaction, @@ -213,6 +216,7 @@ fn validate_document_type_references_v0( owner_id: Identifier, creator_id: Option, changed_fields: Option<&BTreeSet>, + stored_values: Option<&BTreeMap>, platform: &PlatformStateRef, block_info: &BlockInfo, transaction: TransactionArg, @@ -225,13 +229,16 @@ fn validate_document_type_references_v0( // version 14, the version whose document create and replace state // validation call this; no document type of an earlier version can // hold a typed array, so the element arm is never reached there) - let reference = match &property.property_type { + let Some(reference) = property.property_type.reference() else { + continue; + }; + let (reference_target, holds_elements) = match reference { // A key reference on the key id property itself: the value is the // key id and the declaration names whose key it is. A transfer // itself is not checked, so the reference governs writing, not // holding; which replaces re-validate it depends on where the // identity comes from. - DocumentPropertyType::KeyIdWithReference(reference) => { + PropertyReference::KeyId(reference) => { let identity_property = &reference.identity_property; if let Some(changed) = changed_fields { let must_revalidate = match identity_property { @@ -272,14 +279,11 @@ fn validate_document_type_references_v0( } continue; } - property_type => match property_type.reference() { - Some(reference) => reference, - None => continue, - }, + PropertyReference::Value(target) => (target, false), + PropertyReference::Elements { target, .. } => (target, true), }; - let reference_target = reference.target(); - if let Some(changed) = changed_fields { + let bound_property_changed = if let Some(changed) = changed_fields { // Some targets bind a sibling property of the same document to // the reference; replacing that sibling must re-validate the // reference even when the reference property itself is untouched: @@ -294,10 +298,9 @@ fn validate_document_type_references_v0( // unrelated field by a now-unauthorized owner must still fail. // The same rules hold for the elements of a typed array, which // share one declaration: the array is one field, so a replace - // that changes it, or a property bound to it, re-validates every - // element (the transition does not say which ones are new), and - // a writer gate or a deletableDocument target re-validates them - // all on every replace. + // that changes it re-validates the elements the stored list did + // not hold, and a changed bound property, a writer gate or a + // deletableDocument target re-validates them all. let bound_property_changed = match reference_target { DocumentPropertyReferenceTarget::PermanentDocument { property_agreement, .. @@ -326,25 +329,105 @@ fn validate_document_type_references_v0( if !is_changed_field(changed, path) && !bound_property_changed { continue; } - } + bound_property_changed + } else { + false + }; // The referenced contracts this declaration resolved, so the // elements of a typed array fetch a foreign contract once let mut referenced_contracts = BTreeMap::new(); - match reference { - PropertyReference::Value(_) => { - let referenced_id = match document_data.get_optional_identifier_at_path(path) { - Ok(Some(referenced_id)) => referenced_id, - // A reference property that is not set is not validated; whether it may be - // absent at all is enforced by the document type's required fields - Ok(None) => continue, + if !holds_elements { + let referenced_id = match document_data.get_optional_identifier_at_path(path) { + Ok(Some(referenced_id)) => referenced_id, + // A reference property that is not set is not validated; whether it may be + // absent at all is enforced by the document type's required fields + Ok(None) => continue, + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidIdentifierError::new(path.to_string(), err.to_string()).into(), + )) + } + }; + let result = validate_reference_v0( + contract, + document_type, + document_data, + owner_id, + reference_target, + referenced_id, + path, + &mut referenced_contracts, + platform, + block_info, + transaction, + execution_context, + platform_version, + )?; + if !result.is_valid() { + return Ok(result); + } + } else { + let elements = match document_data.get_optional_at_path(path) { + Ok(Some(Value::Array(elements))) => elements, + // An absent list, like an absent reference, is not + // validated; an empty one has nothing to validate + Ok(None) => continue, + Ok(Some(_)) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidIdentifierError::new( + path.to_string(), + "a typed array of identifiers must be a list".to_string(), + ) + .into(), + )) + } + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidIdentifierError::new(path.to_string(), err.to_string()).into(), + )) + } + }; + // Each element is checked as a single reference is, in list + // order, and the first that fails refuses the write with the + // error a single reference would give, naming the element by + // its list path (`reasons[2]` for the third). The count is + // bounded by `maxItems`, which registration counts against + // `SystemLimits::max_references_per_document` with the + // type's other references, and every fetch is billed as a + // single reference's is. A replace re-validating the list + // only because it changed leaves out the elements the stored + // list already held, unchanged references, as an unchanged + // single reference is left alone; a changed bound property, + // a writer gate or a deletableDocument target re-validates + // them all. An element repeating an earlier one has its + // outcome already, so it is not fetched again + let mut checked: BTreeSet<[u8; 32]> = BTreeSet::new(); + if !bound_property_changed { + if let Some(Ok(Some(Value::Array(stored_elements)))) = + stored_values.map(|stored| stored.get_optional_at_path(path)) + { + checked.extend( + stored_elements + .iter() + .filter_map(|element| element.to_hash256().ok()), + ); + } + } + for (index, element) in elements.iter().enumerate() { + let element_path = format!("{path}[{index}]"); + let referenced_id = match element.to_hash256() { + Ok(referenced_id) => referenced_id, Err(err) => { return Ok(SimpleConsensusValidationResult::new_with_error( - InvalidIdentifierError::new(path.to_string(), err.to_string()).into(), + InvalidIdentifierError::new(element_path, err.to_string()).into(), )) } }; + if !checked.insert(referenced_id) { + continue; + } let result = validate_reference_v0( contract, document_type, @@ -352,7 +435,7 @@ fn validate_document_type_references_v0( owner_id, reference_target, referenced_id, - path, + &element_path, &mut referenced_contracts, platform, block_info, @@ -364,65 +447,6 @@ fn validate_document_type_references_v0( return Ok(result); } } - PropertyReference::Elements(_) => { - let elements = match document_data.get_optional_at_path(path) { - Ok(Some(Value::Array(elements))) => elements, - // An absent list, like an absent reference, is not - // validated; an empty one has nothing to validate - Ok(None) => continue, - Ok(Some(_)) => { - return Ok(SimpleConsensusValidationResult::new_with_error( - InvalidIdentifierError::new( - path.to_string(), - "a typed array of identifiers must be a list".to_string(), - ) - .into(), - )) - } - Err(err) => { - return Ok(SimpleConsensusValidationResult::new_with_error( - InvalidIdentifierError::new(path.to_string(), err.to_string()).into(), - )) - } - }; - // Each element is checked as a single reference is, in list - // order, and the first that fails refuses the write with the - // error a single reference would give, naming the element by - // its list path (`reasons[2]` for the third). The count is - // bounded by `maxItems`, which registration counts against - // `SystemLimits::max_references_per_document` with the - // type's other references, and every fetch is billed as a - // single reference's is - for (index, element) in elements.iter().enumerate() { - let element_path = format!("{path}[{index}]"); - let referenced_id = match element.to_hash256() { - Ok(referenced_id) => referenced_id, - Err(err) => { - return Ok(SimpleConsensusValidationResult::new_with_error( - InvalidIdentifierError::new(element_path, err.to_string()).into(), - )) - } - }; - let result = validate_reference_v0( - contract, - document_type, - document_data, - owner_id, - reference_target, - referenced_id, - &element_path, - &mut referenced_contracts, - platform, - block_info, - transaction, - execution_context, - platform_version, - )?; - if !result.is_valid() { - return Ok(result); - } - } - } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/state_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/state_v1/mod.rs index ea54c765f13..24ecb044015 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/state_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/state_v1/mod.rs @@ -115,6 +115,7 @@ impl DocumentReplaceTransitionActionStateValidationV1 for DocumentReplaceTransit owner_id, self.creator_id(), Some(self.changed_data_fields()), + Some(self.stored_changed_values()), platform, block_info, transaction, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletable_document_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletable_document_reference.rs index aaf8b6ec106..56fd98ae3f6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletable_document_reference.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletable_document_reference.rs @@ -13,19 +13,13 @@ use super::*; mod deletable_document_reference_tests { + use super::super::reference_test_setup::{ + assert_successful, create_document, ReferenceTestSetup as Setup, + }; use super::*; - use crate::platform_types::platform_state::PlatformState; use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; - use crate::rpc::core::MockCoreRPCLike; - use crate::test::helpers::setup::TempPlatform; - use dpp::data_contract::accessors::v0::DataContractV0Setters; use dpp::document::Document; use dpp::identifier::Identifier; - use dpp::identity::signer::Signer; - use dpp::identity::IdentityPublicKey; - use dpp::prelude::DataContract; - use dpp::state_transition::StateTransition; - use simple_signer::signer::SimpleSigner; /// Shared with the contract-create registration tests, which pin that /// the declarations themselves are accepted. @@ -36,76 +30,6 @@ mod deletable_document_reference_tests { /// exercises the write-time half of the same rule. const NOT_DELETABLE_CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-deletable-doc-registration-not-deletable.json"; - fn register_contract( - platform: &TempPlatform, - owner_id: Identifier, - platform_version: &PlatformVersion, - ) -> DataContract { - register_contract_at(platform, CONTRACT_PATH, owner_id, platform_version) - } - - fn register_contract_at( - platform: &TempPlatform, - path: &str, - owner_id: Identifier, - platform_version: &PlatformVersion, - ) -> DataContract { - let mut contract = json_document_to_contract(path, true, platform_version) - .expect("expected to parse the deletable document contract"); - contract.set_owner_id(owner_id); - platform - .drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("expected to apply the deletable document contract"); - contract - } - - fn process_and_commit( - platform: &TempPlatform, - platform_state: &PlatformState, - transition: &StateTransition, - platform_version: &PlatformVersion, - ) -> StateTransitionsProcessingResult { - let serialized = transition - .serialize_to_bytes() - .expect("expected the batch transition to serialize"); - let transaction = platform.drive.grove.start_transaction(); - let processing_result = platform - .platform - .process_raw_state_transitions( - &[serialized], - platform_state, - &BlockInfo::default(), - &transaction, - platform_version, - false, - None, - ) - .expect("expected to process state transition"); - platform - .drive - .grove - .commit_transaction(transaction) - .unwrap() - .expect("expected to commit transaction"); - processing_result - } - - fn assert_successful(result: &StateTransitionsProcessingResult, because: &str) { - assert_matches!( - result.execution_results().as_slice(), - [StateTransitionExecutionResult::SuccessfulExecution { .. }], - "{because}" - ); - } - fn assert_referenced_entity_not_found( result: &StateTransitionsProcessingResult, because: &str, @@ -120,237 +44,11 @@ mod deletable_document_reference_tests { ); } - /// Creates a document of `type_name` with exactly `properties` set and - /// returns it with the processing result. - #[allow(clippy::too_many_arguments)] - async fn create_document>( - platform: &TempPlatform, - platform_state: &PlatformState, - contract: &DataContract, - type_name: &str, - properties: &[(&str, Value)], - owner: Identifier, - key: &IdentityPublicKey, - nonce: u64, - signer: &S, - rng: &mut StdRng, - platform_version: &PlatformVersion, - ) -> (Document, StateTransitionsProcessingResult) { - let document_type = contract - .document_type_for_name(type_name) - .expect("doctype exists"); - let entropy = Bytes32::random_with_rng(rng); - let mut document = document_type - .random_document_with_identifier_and_entropy( - rng, - owner, - entropy, - DocumentFieldFillType::DoNotFillIfNotRequired, - DocumentFieldFillSize::AnyDocumentFillSize, - platform_version, - ) - .expect("expected a random document"); - for (property, value) in properties { - document.set(property, value.clone()); - } - // The id commits to the create transition's nonce: give the local - // copy the id the transition will carry, since the tests reference - // and act on it afterwards. - document - .set_id_for_creation(document_type, &entropy.0, nonce, platform_version) - .expect("expected the creation id"); - let create = BatchTransition::new_document_creation_transition_from_document( - document.clone(), - document_type, - entropy.0, - key, - nonce, - 0, - None, - signer, - platform_version, - None, - ) - .await - .expect("expected the create transition"); - let result = process_and_commit(platform, platform_state, &create, platform_version); - (document, result) - } - - #[allow(clippy::too_many_arguments)] - async fn replace_document>( - platform: &TempPlatform, - platform_state: &PlatformState, - contract: &DataContract, - type_name: &str, - document: &Document, - key: &IdentityPublicKey, - nonce: u64, - signer: &S, - platform_version: &PlatformVersion, - ) -> StateTransitionsProcessingResult { - let document_type = contract - .document_type_for_name(type_name) - .expect("doctype exists"); - let replace = BatchTransition::new_document_replacement_transition_from_document( - document.clone(), - document_type, - key, - nonce, - 0, - None, - signer, - platform_version, - None, - ) - .await - .expect("expected the replace transition"); - process_and_commit(platform, platform_state, &replace, platform_version) - } - - #[allow(clippy::too_many_arguments)] - async fn delete_document>( - platform: &TempPlatform, - platform_state: &PlatformState, - contract: &DataContract, - type_name: &str, - document: &Document, - key: &IdentityPublicKey, - nonce: u64, - signer: &S, - platform_version: &PlatformVersion, - ) -> StateTransitionsProcessingResult { - let document_type = contract - .document_type_for_name(type_name) - .expect("doctype exists"); - let delete = BatchTransition::new_document_deletion_transition_from_document( - document.clone(), - document_type, - key, - nonce, - 0, - None, - signer, - platform_version, - None, - ) - .await - .expect("expected the delete transition"); - process_and_commit(platform, platform_state, &delete, platform_version) - } - fn identifier_value(id: Identifier) -> Value { Value::Identifier(id.to_buffer()) } - struct Setup { - platform: TempPlatform, - platform_state: std::sync::Arc, - contract: DataContract, - owner: Identifier, - signer: SimpleSigner, - key: IdentityPublicKey, - rng: StdRng, - nonce: u64, - } - impl Setup { - fn new(contract_path: &str, seed: u64) -> Self { - let platform_version = PlatformVersion::latest(); - let mut platform = TestPlatformBuilder::new() - .build_with_mock_rpc() - .set_genesis_state(); - let platform_state = platform.state.load_full(); - let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(1.0)); - let contract = - register_contract_at(&platform, contract_path, identity.id(), platform_version); - Self { - platform, - platform_state, - contract, - owner: identity.id(), - signer, - key, - rng: StdRng::seed_from_u64(seed), - nonce: 1, - } - } - - fn next_nonce(&mut self) -> u64 { - self.nonce += 1; - self.nonce - } - - async fn create( - &mut self, - type_name: &str, - properties: &[(&str, Value)], - ) -> (Document, StateTransitionsProcessingResult) { - let nonce = self.next_nonce(); - create_document( - &self.platform, - &self.platform_state, - &self.contract, - type_name, - properties, - self.owner, - &self.key, - nonce, - &self.signer, - &mut self.rng, - PlatformVersion::latest(), - ) - .await - } - - /// Bumps the revision and replaces `document` as it now stands. - async fn replace( - &mut self, - type_name: &str, - document: &mut Document, - ) -> StateTransitionsProcessingResult { - document.increment_revision().expect("revision increments"); - let nonce = self.next_nonce(); - let result = replace_document( - &self.platform, - &self.platform_state, - &self.contract, - type_name, - document, - &self.key, - nonce, - &self.signer, - PlatformVersion::latest(), - ) - .await; - // A refused replace leaves the stored revision where it was. - if result.valid_count() == 0 { - let revision = document.revision().expect("revision set"); - document.set_revision(Some(revision - 1)); - } - result - } - - async fn delete( - &mut self, - type_name: &str, - document: &Document, - ) -> StateTransitionsProcessingResult { - let nonce = self.next_nonce(); - delete_document( - &self.platform, - &self.platform_state, - &self.contract, - type_name, - document, - &self.key, - nonce, - &self.signer, - PlatformVersion::latest(), - ) - .await - } - async fn draft(&mut self, topic: &str) -> Document { let (draft, result) = self.create("draft", &[("topic", topic.into())]).await; assert_successful(&result, "the draft is created"); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/distinct_from.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/distinct_from.rs index f78a34e9d97..47076f2a490 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/distinct_from.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/distinct_from.rs @@ -528,6 +528,7 @@ mod distinct_from_tests { changed_data_fields: BTreeSet::new(), added_data_fields: BTreeSet::new(), removed_identifier_fields: BTreeMap::new(), + stored_changed_values: BTreeMap::new(), creator_id: None, }); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/encrypted_for.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/encrypted_for.rs index eb633bde47a..a4cd5c079e2 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/encrypted_for.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/encrypted_for.rs @@ -457,6 +457,7 @@ mod encrypted_for_tests { changed_data_fields: BTreeSet::new(), added_data_fields: BTreeSet::new(), removed_identifier_fields: BTreeMap::new(), + stored_changed_values: BTreeMap::new(), creator_id: None, }); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index 285d2bc580b..7bd70f0fd93 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -13,6 +13,7 @@ mod keep_history; mod nft; mod owner_balance_proof; mod ranked_group_drain; +mod reference_test_setup; mod replacement; mod required_since; mod system_agreement; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/reference_test_setup.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/reference_test_setup.rs new file mode 100644 index 00000000000..58f8d43fd23 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/reference_test_setup.rs @@ -0,0 +1,319 @@ +//! The ABCI harness the `refersTo` suites share: a platform at the latest +//! protocol version with one funded identity and a fixture contract applied +//! directly, and create, replace and delete transitions processed and +//! committed one at a time. + +use super::*; +use crate::platform_types::platform_state::PlatformState; +use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::TempPlatform; +use dpp::data_contract::accessors::v0::DataContractV0Setters; +use dpp::document::Document; +use dpp::identifier::Identifier; +use dpp::identity::signer::Signer; +use dpp::identity::IdentityPublicKey; +use dpp::prelude::DataContract; +use dpp::state_transition::StateTransition; +use simple_signer::signer::SimpleSigner; +use std::sync::Arc; + +/// Applies the fixture contract at `path` owned by `owner_id`, parsed with +/// full validation when `validate` is set. +pub(super) fn register_contract_at( + platform: &TempPlatform, + path: &str, + owner_id: Identifier, + validate: bool, + platform_version: &PlatformVersion, +) -> DataContract { + let mut contract = json_document_to_contract(path, validate, platform_version) + .expect("expected to parse the fixture contract"); + contract.set_owner_id(owner_id); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the fixture contract"); + contract +} + +pub(super) fn process_and_commit( + platform: &TempPlatform, + platform_state: &PlatformState, + transition: &StateTransition, + platform_version: &PlatformVersion, +) -> StateTransitionsProcessingResult { + let serialized = transition + .serialize_to_bytes() + .expect("expected the batch transition to serialize"); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[serialized], + platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + processing_result +} + +pub(super) fn assert_successful(result: &StateTransitionsProcessingResult, because: &str) { + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }], + "{because}" + ); +} + +/// Creates a document of `type_name` with exactly `properties` set and +/// returns it with the processing result. +#[allow(clippy::too_many_arguments)] +pub(super) async fn create_document>( + platform: &TempPlatform, + platform_state: &PlatformState, + contract: &DataContract, + type_name: &str, + properties: &[(&str, Value)], + owner: Identifier, + key: &IdentityPublicKey, + nonce: u64, + signer: &S, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> (Document, StateTransitionsProcessingResult) { + let document_type = contract + .document_type_for_name(type_name) + .expect("doctype exists"); + let entropy = Bytes32::random_with_rng(rng); + let mut document = document_type + .random_document_with_identifier_and_entropy( + rng, + owner, + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + for (property, value) in properties { + document.set(property, value.clone()); + } + // The id commits to the create transition's nonce: give the local + // copy the id the transition will carry, since the tests reference + // and act on it afterwards. + document + .set_id_for_creation(document_type, &entropy.0, nonce, platform_version) + .expect("expected the creation id"); + let create = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + document_type, + entropy.0, + key, + nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + let result = process_and_commit(platform, platform_state, &create, platform_version); + (document, result) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn replace_document>( + platform: &TempPlatform, + platform_state: &PlatformState, + contract: &DataContract, + type_name: &str, + document: &Document, + key: &IdentityPublicKey, + nonce: u64, + signer: &S, + platform_version: &PlatformVersion, +) -> StateTransitionsProcessingResult { + let document_type = contract + .document_type_for_name(type_name) + .expect("doctype exists"); + let replace = BatchTransition::new_document_replacement_transition_from_document( + document.clone(), + document_type, + key, + nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition"); + process_and_commit(platform, platform_state, &replace, platform_version) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn delete_document>( + platform: &TempPlatform, + platform_state: &PlatformState, + contract: &DataContract, + type_name: &str, + document: &Document, + key: &IdentityPublicKey, + nonce: u64, + signer: &S, + platform_version: &PlatformVersion, +) -> StateTransitionsProcessingResult { + let document_type = contract + .document_type_for_name(type_name) + .expect("doctype exists"); + let delete = BatchTransition::new_document_deletion_transition_from_document( + document.clone(), + document_type, + key, + nonce, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expected the delete transition"); + process_and_commit(platform, platform_state, &delete, platform_version) +} + +/// A fresh platform with one funded identity owning the fixture contract at +/// `contract_path`, and the nonce and randomness its transitions use. +pub(super) struct ReferenceTestSetup { + pub(super) platform: TempPlatform, + pub(super) platform_state: Arc, + pub(super) contract: DataContract, + pub(super) owner: Identifier, + pub(super) signer: SimpleSigner, + pub(super) key: IdentityPublicKey, + pub(super) rng: StdRng, + pub(super) nonce: u64, +} + +impl ReferenceTestSetup { + pub(super) fn new(contract_path: &str, seed: u64) -> Self { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load_full(); + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(1.0)); + let contract = register_contract_at( + &platform, + contract_path, + identity.id(), + true, + platform_version, + ); + Self { + platform, + platform_state, + contract, + owner: identity.id(), + signer, + key, + rng: StdRng::seed_from_u64(seed), + nonce: 1, + } + } + + pub(super) fn next_nonce(&mut self) -> u64 { + self.nonce += 1; + self.nonce + } + + pub(super) async fn create( + &mut self, + type_name: &str, + properties: &[(&str, Value)], + ) -> (Document, StateTransitionsProcessingResult) { + let nonce = self.next_nonce(); + create_document( + &self.platform, + &self.platform_state, + &self.contract, + type_name, + properties, + self.owner, + &self.key, + nonce, + &self.signer, + &mut self.rng, + PlatformVersion::latest(), + ) + .await + } + + /// Bumps the revision and replaces `document` as it now stands. + pub(super) async fn replace( + &mut self, + type_name: &str, + document: &mut Document, + ) -> StateTransitionsProcessingResult { + document.increment_revision().expect("revision increments"); + let nonce = self.next_nonce(); + let result = replace_document( + &self.platform, + &self.platform_state, + &self.contract, + type_name, + document, + &self.key, + nonce, + &self.signer, + PlatformVersion::latest(), + ) + .await; + // A refused replace leaves the stored revision where it was. + if result.valid_count() == 0 { + let revision = document.revision().expect("revision set"); + document.set_revision(Some(revision - 1)); + } + result + } + + pub(super) async fn delete( + &mut self, + type_name: &str, + document: &Document, + ) -> StateTransitionsProcessingResult { + let nonce = self.next_nonce(); + delete_document( + &self.platform, + &self.platform_state, + &self.contract, + type_name, + document, + &self.key, + nonce, + &self.signer, + PlatformVersion::latest(), + ) + .await + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/typed_array_references.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/typed_array_references.rs index 5dfa3639f72..87df7652ed8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/typed_array_references.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/typed_array_references.rs @@ -2,9 +2,14 @@ //! pipeline. The fixture's `submittedCharter.reasons` is a list of //! `permanentDocument` references to `reason`, `gatedCharter.reasons` binds //! the WRITER to every referenced reason's owner, `topicCharter.reasons` -//! binds the charter's `topic` to every reason's, and `draftList.drafts` is -//! a list of `deletableDocument` references to `draft`. `plainCharter` has -//! the `reasons` shape without `refersTo`, as the fee baseline. +//! binds the charter's `topic` to every reason's, `draftList.drafts` is a +//! list of `deletableDocument` references to `draft`, `nestedCharter` holds +//! its list inside the `team` object, `foreignCharter.reasons` refers to the +//! `note` documents of the permanent-document foreign fixture contract, +//! `contractList.contracts` to contracts the writer owns, and +//! `repeatCharter.reasons` may repeat an element. `plainCharter` and +//! `repeatPlainCh` have the shapes of `submittedCharter` and +//! `repeatCharter` without `refersTo`, as fee baselines. //! //! Each element is checked as a single reference is, in list order, and the //! first one that fails refuses the write with the single reference's @@ -13,86 +18,25 @@ use super::*; mod typed_array_reference_tests { + use super::super::reference_test_setup::{ + assert_successful, create_document, register_contract_at, ReferenceTestSetup as Setup, + }; use super::*; - use crate::platform_types::platform_state::PlatformState; use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; - use crate::rpc::core::MockCoreRPCLike; - use crate::test::helpers::setup::TempPlatform; use dpp::consensus::codes::ErrorWithCode; - use dpp::data_contract::accessors::v0::DataContractV0Setters; - use dpp::document::Document; + use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::identifier::Identifier; - use dpp::identity::signer::Signer; - use dpp::identity::IdentityPublicKey; use dpp::prelude::DataContract; - use dpp::state_transition::StateTransition; - use simple_signer::signer::SimpleSigner; - use std::sync::Arc; /// Shared with the contract-create registration test, which pins that /// the declarations themselves register. const CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements.json"; - fn register_contract( - platform: &TempPlatform, - owner_id: Identifier, - platform_version: &PlatformVersion, - ) -> DataContract { - let mut contract = json_document_to_contract(CONTRACT_PATH, true, platform_version) - .expect("expected to parse the typed array reference contract"); - contract.set_owner_id(owner_id); - platform - .drive - .apply_contract( - &contract, - BlockInfo::default(), - true, - StorageFlags::optional_default_as_cow(), - None, - platform_version, - ) - .expect("expected to apply the typed array reference contract"); - contract - } - - fn process_and_commit( - platform: &TempPlatform, - platform_state: &PlatformState, - transition: &StateTransition, - platform_version: &PlatformVersion, - ) -> StateTransitionsProcessingResult { - let serialized = transition - .serialize_to_bytes() - .expect("expected the batch transition to serialize"); - let transaction = platform.drive.grove.start_transaction(); - let processing_result = platform - .platform - .process_raw_state_transitions( - &[serialized], - platform_state, - &BlockInfo::default(), - &transaction, - platform_version, - false, - None, - ) - .expect("expected to process state transition"); - platform - .drive - .grove - .commit_transaction(transaction) - .unwrap() - .expect("expected to commit transaction"); - processing_result - } - - fn assert_successful(result: &StateTransitionsProcessingResult, because: &str) { - assert_matches!( - result.execution_results().as_slice(), - [StateTransitionExecutionResult::SuccessfulExecution { .. }], - "{because}" - ); - } + /// The contract `foreignCharter` refers into, applied with an owner + /// other than the writer's. It declares an empty `indices` list, which + /// only a non-validating load admits, as its own registration test loads + /// it. + const FOREIGN_CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-permanent-doc-foreign.json"; /// The write was refused because the referenced entity `missing` named at /// `path` does not exist (code 40120). @@ -125,194 +69,7 @@ mod typed_array_reference_tests { ) } - /// Creates a document of `type_name` with exactly `properties` set and - /// returns it with the processing result. - #[allow(clippy::too_many_arguments)] - async fn create_document>( - platform: &TempPlatform, - platform_state: &PlatformState, - contract: &DataContract, - type_name: &str, - properties: &[(&str, Value)], - owner: Identifier, - key: &IdentityPublicKey, - nonce: u64, - signer: &S, - rng: &mut StdRng, - platform_version: &PlatformVersion, - ) -> (Document, StateTransitionsProcessingResult) { - let document_type = contract - .document_type_for_name(type_name) - .expect("doctype exists"); - let entropy = Bytes32::random_with_rng(rng); - let mut document = document_type - .random_document_with_identifier_and_entropy( - rng, - owner, - entropy, - DocumentFieldFillType::DoNotFillIfNotRequired, - DocumentFieldFillSize::AnyDocumentFillSize, - platform_version, - ) - .expect("expected a random document"); - for (property, value) in properties { - document.set(property, value.clone()); - } - // The id commits to the create transition's nonce: give the local - // copy the id the transition will carry, since the tests reference - // and act on it afterwards. - document - .set_id_for_creation(document_type, &entropy.0, nonce, platform_version) - .expect("expected the creation id"); - let create = BatchTransition::new_document_creation_transition_from_document( - document.clone(), - document_type, - entropy.0, - key, - nonce, - 0, - None, - signer, - platform_version, - None, - ) - .await - .expect("expected the create transition"); - let result = process_and_commit(platform, platform_state, &create, platform_version); - (document, result) - } - - struct Setup { - platform: TempPlatform, - platform_state: Arc, - contract: DataContract, - owner: Identifier, - signer: SimpleSigner, - key: IdentityPublicKey, - rng: StdRng, - nonce: u64, - } - impl Setup { - fn new(seed: u64) -> Self { - let platform_version = PlatformVersion::latest(); - let mut platform = TestPlatformBuilder::new() - .build_with_mock_rpc() - .set_genesis_state(); - let platform_state = platform.state.load_full(); - let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(1.0)); - let contract = register_contract(&platform, identity.id(), platform_version); - Self { - platform, - platform_state, - contract, - owner: identity.id(), - signer, - key, - rng: StdRng::seed_from_u64(seed), - nonce: 1, - } - } - - fn next_nonce(&mut self) -> u64 { - self.nonce += 1; - self.nonce - } - - async fn create( - &mut self, - type_name: &str, - properties: &[(&str, Value)], - ) -> (Document, StateTransitionsProcessingResult) { - let nonce = self.next_nonce(); - create_document( - &self.platform, - &self.platform_state, - &self.contract, - type_name, - properties, - self.owner, - &self.key, - nonce, - &self.signer, - &mut self.rng, - PlatformVersion::latest(), - ) - .await - } - - /// Bumps the revision and replaces `document` as it now stands. - async fn replace( - &mut self, - type_name: &str, - document: &mut Document, - ) -> StateTransitionsProcessingResult { - let platform_version = PlatformVersion::latest(); - document.increment_revision().expect("revision increments"); - let nonce = self.next_nonce(); - let document_type = self - .contract - .document_type_for_name(type_name) - .expect("doctype exists"); - let replace = BatchTransition::new_document_replacement_transition_from_document( - document.clone(), - document_type, - &self.key, - nonce, - 0, - None, - &self.signer, - platform_version, - None, - ) - .await - .expect("expected the replace transition"); - let result = process_and_commit( - &self.platform, - &self.platform_state, - &replace, - platform_version, - ); - // A refused replace leaves the stored revision where it was. - if result.valid_count() == 0 { - let revision = document.revision().expect("revision set"); - document.set_revision(Some(revision - 1)); - } - result - } - - async fn delete( - &mut self, - type_name: &str, - document: &Document, - ) -> StateTransitionsProcessingResult { - let platform_version = PlatformVersion::latest(); - let nonce = self.next_nonce(); - let document_type = self - .contract - .document_type_for_name(type_name) - .expect("doctype exists"); - let delete = BatchTransition::new_document_deletion_transition_from_document( - document.clone(), - document_type, - &self.key, - nonce, - 0, - None, - &self.signer, - platform_version, - None, - ) - .await - .expect("expected the delete transition"); - process_and_commit( - &self.platform, - &self.platform_state, - &delete, - platform_version, - ) - } - /// A reason owned by the setup's identity. async fn reason(&mut self, topic: &str) -> Identifier { let (reason, result) = self.create("reason", &[("topic", topic.into())]).await; @@ -327,11 +84,45 @@ mod typed_array_reference_tests { } reasons } + + /// The foreign fixture contract, owned by an identity other than + /// the writer, with `count` notes the writer creates in it. + async fn foreign_notes(&mut self, count: usize) -> (DataContract, Vec) { + let platform_version = PlatformVersion::latest(); + let foreign = register_contract_at( + &self.platform, + FOREIGN_CONTRACT_PATH, + Identifier::new([7; 32]), + false, + platform_version, + ); + let mut notes = Vec::with_capacity(count); + // Identity contract nonces count per contract + for nonce in 2..2 + count as u64 { + let (note, result) = create_document( + &self.platform, + &self.platform_state, + &foreign, + "note", + &[("content", "dash".into())], + self.owner, + &self.key, + nonce, + &self.signer, + &mut self.rng, + platform_version, + ) + .await; + assert_successful(&result, "the foreign note is created"); + notes.push(note.id()); + } + (foreign, notes) + } } #[tokio::test] async fn should_accept_a_list_whose_every_element_exists() { - let mut setup = Setup::new(8101); + let mut setup = Setup::new(CONTRACT_PATH, 8101); let reasons = setup.reasons(3).await; let (_, result) = setup @@ -348,7 +139,7 @@ mod typed_array_reference_tests { #[tokio::test] async fn should_refuse_a_list_whose_third_element_is_missing_naming_that_element() { - let mut setup = Setup::new(8102); + let mut setup = Setup::new(CONTRACT_PATH, 8102); let reasons = setup.reasons(2).await; let missing = Identifier::random_with_rng(&mut setup.rng); @@ -371,7 +162,7 @@ mod typed_array_reference_tests { #[tokio::test] async fn should_accept_an_empty_list() { - let mut setup = Setup::new(8103); + let mut setup = Setup::new(CONTRACT_PATH, 8103); let (_, result) = setup .create("submittedCharter", &[("reasons", identifier_list(&[]))]) @@ -385,7 +176,7 @@ mod typed_array_reference_tests { async fn should_refuse_an_element_writer_gate_when_the_writer_does_not_own_a_referenced_document( ) { let platform_version = PlatformVersion::latest(); - let mut setup = Setup::new(8104); + let mut setup = Setup::new(CONTRACT_PATH, 8104); let own_reason = setup.reason("dash").await; let (bob, bob_signer, bob_key) = setup_identity(&mut setup.platform, 450, dash_to_credits!(1.0)); @@ -441,7 +232,7 @@ mod typed_array_reference_tests { /// element's reason's property. #[tokio::test] async fn should_hold_an_element_agreement_between_the_document_and_every_referenced_document() { - let mut setup = Setup::new(8105); + let mut setup = Setup::new(CONTRACT_PATH, 8105); let dash = setup.reason("dash").await; let other_dash = setup.reason("dash").await; let btc = setup.reason("btc").await; @@ -494,7 +285,7 @@ mod typed_array_reference_tests { #[tokio::test] async fn should_refuse_a_replace_that_adds_a_missing_element() { - let mut setup = Setup::new(8106); + let mut setup = Setup::new(CONTRACT_PATH, 8106); let reasons = setup.reasons(2).await; let missing = Identifier::random_with_rng(&mut setup.rng); @@ -532,7 +323,7 @@ mod typed_array_reference_tests { /// dropped or repointed before the document can be replaced. #[tokio::test] async fn should_refuse_an_unrelated_replace_while_a_deletable_element_is_dead() { - let mut setup = Setup::new(8107); + let mut setup = Setup::new(CONTRACT_PATH, 8107); let (first, result) = setup.create("draft", &[("topic", "dash".into())]).await; assert_successful(&result, "the first draft is created"); let (second, result) = setup.create("draft", &[("topic", "dash".into())]).await; @@ -571,20 +362,21 @@ mod typed_array_reference_tests { assert_successful(&result, "dropping the dead element repairs the list"); } - /// The processing fee of creating a `type_name` document whose list holds - /// the first `count` of four reasons, on a fresh platform with the same - /// history every time, so that only the list differs between two runs. - async fn creation_fee(type_name: &str, count: usize) -> u64 { - let mut setup = Setup::new(8108); + /// The processing fee of creating a `type_name` document whose `reasons` + /// list holds `pick` of four own reasons, or of four foreign notes, on a + /// fresh platform with the same history every time (both sets are always + /// created), so that only the list differs between two runs. + async fn creation_fee(type_name: &str, pick: &[usize], foreign: bool) -> i128 { + let mut setup = Setup::new(CONTRACT_PATH, 8108); let reasons = setup.reasons(4).await; + let (_, notes) = setup.foreign_notes(4).await; + let source = if foreign { ¬es } else { &reasons }; + let list: Vec = pick.iter().map(|index| source[*index]).collect(); let (_, result) = setup - .create( - type_name, - &[("reasons", identifier_list(&reasons[..count]))], - ) + .create(type_name, &[("reasons", identifier_list(&list))]) .await; assert_successful(&result, "the list is created"); - result.aggregated_fees().processing_fee + i128::from(result.aggregated_fees().processing_fee) } /// Each element is a billed read: what a list of references costs over @@ -592,10 +384,11 @@ mod typed_array_reference_tests { #[tokio::test] async fn should_charge_more_for_more_referenced_elements() { let mut surcharges = Vec::new(); - for count in [0, 1, 2, 4] { - let referenced = creation_fee("submittedCharter", count).await; - let plain = creation_fee("plainCharter", count).await; - surcharges.push(i128::from(referenced) - i128::from(plain)); + let picks: [&[usize]; 4] = [&[], &[0], &[0, 1], &[0, 1, 2, 3]]; + for pick in picks { + let referenced = creation_fee("submittedCharter", pick, false).await; + let plain = creation_fee("plainCharter", pick, false).await; + surcharges.push(referenced - plain); } // With no element there is nothing to read: what is left is the // two document types' own difference @@ -604,4 +397,176 @@ mod typed_array_reference_tests { "the surcharge grows with every element: {surcharges:?}" ); } + + /// The elements of one list share their declaration, so a foreign + /// contract holding the referenced document type is fetched, and billed, + /// once per list: three foreign elements cost one contract fetch over + /// three own ones, as one does over one. + #[tokio::test] + async fn should_fetch_a_foreign_contract_once_per_list() { + let one = creation_fee("foreignCharter", &[0], true).await + - creation_fee("submittedCharter", &[0], false).await; + let three = creation_fee("foreignCharter", &[0, 1, 2], true).await + - creation_fee("submittedCharter", &[0, 1, 2], false).await; + + let platform_version = PlatformVersion::latest(); + let setup = Setup::new(CONTRACT_PATH, 8109); + let foreign = register_contract_at( + &setup.platform, + FOREIGN_CONTRACT_PATH, + Identifier::new([7; 32]), + false, + platform_version, + ); + let (fee, _) = setup + .platform + .drive + .get_contract_with_fetch_info_and_fee( + foreign.id().to_buffer(), + Some(&BlockInfo::default().epoch), + false, + None, + platform_version, + ) + .expect("expected to fetch the foreign contract"); + let contract_fetch = i128::from( + fee.expect("a fetch with an epoch carries its fee") + .processing_fee, + ); + + assert!( + (three - one).abs() < contract_fetch, + "two more foreign elements cost {} over their own counterparts, less than one \ + contract fetch ({contract_fetch})", + three - one + ); + } + + /// An element repeating an earlier one of the same list has that one's + /// outcome, so it is not fetched, or billed, again. + #[tokio::test] + async fn should_fetch_a_repeated_element_once() { + let mut surcharges = Vec::new(); + let picks: [&[usize]; 3] = [&[], &[0], &[0, 0]]; + for pick in picks { + surcharges.push( + creation_fee("repeatCharter", pick, false).await + - creation_fee("repeatPlainCh", pick, false).await, + ); + } + let one_fetch = surcharges[1] - surcharges[0]; + let second_copy = surcharges[2] - surcharges[1]; + assert!( + second_copy < one_fetch / 2, + "the repeated element costs {second_copy}, a fetch costs {one_fetch}" + ); + } + + /// A replace that only adds an element checks the new one: the elements + /// the stored list already held are unchanged references, and an + /// unchanged single reference is not re-validated either, so a reason + /// that stopped agreeing since it was written does not block the list. + #[tokio::test] + async fn should_leave_the_elements_the_stored_list_held_alone_when_a_replace_adds_one() { + let mut setup = Setup::new(CONTRACT_PATH, 8110); + let (mut drifting, result) = setup.create("reason", &[("topic", "dash".into())]).await; + assert_successful(&result, "the first reason is created"); + let (mut charter, result) = setup + .create( + "topicCharter", + &[ + ("reasons", identifier_list(&[drifting.id()])), + ("topic", "dash".into()), + ], + ) + .await; + assert_successful(&result, "the charter is created"); + + drifting.set("topic", "btc".into()); + let result = setup.replace("reason", &mut drifting).await; + assert_successful(&result, "the reason's owner changes its topic"); + + let added = setup.reason("dash").await; + charter.set("reasons", identifier_list(&[drifting.id(), added])); + let result = setup.replace("topicCharter", &mut charter).await; + assert_successful(&result, "only the added reason is checked, and it agrees"); + + // A missing addition is still refused, named at its new position + let missing = Identifier::random_with_rng(&mut setup.rng); + charter.set("reasons", identifier_list(&[missing, drifting.id(), added])); + let result = setup.replace("topicCharter", &mut charter).await; + assert_element_not_found(&result, "reasons[0]", missing, "the new element is checked"); + } + + /// A list inside an object is named by its dotted path, and replacing + /// the object re-validates it. + #[tokio::test] + async fn should_name_an_element_of_a_list_nested_in_an_object() { + let mut setup = Setup::new(CONTRACT_PATH, 8111); + let reasons = setup.reasons(2).await; + let missing = Identifier::random_with_rng(&mut setup.rng); + let team = |ids: &[Identifier]| { + Value::Map(vec![( + Value::Text("members".to_string()), + identifier_list(ids), + )]) + }; + + let (_, result) = setup + .create("nestedCharter", &[("team", team(&[reasons[0], missing]))]) + .await; + assert_element_not_found( + &result, + "team.members[1]", + missing, + "the nested list's second member does not exist", + ); + + let (mut charter, result) = setup + .create("nestedCharter", &[("team", team(&reasons))]) + .await; + assert_successful(&result, "every member exists"); + charter.set("team", team(&[reasons[0], reasons[1], missing])); + let result = setup.replace("nestedCharter", &mut charter).await; + assert_element_not_found( + &result, + "team.members[2]", + missing, + "replacing the object re-validates the list inside it", + ); + } + + /// A contract element's `contractRequirements` hold for every element + /// (40135), named at the element. + #[tokio::test] + async fn should_check_contract_requirements_on_every_element() { + let mut setup = Setup::new(CONTRACT_PATH, 8112); + let (foreign, _) = setup.foreign_notes(0).await; + let own = setup.contract.id(); + + let (_, result) = setup + .create("contractList", &[("contracts", identifier_list(&[own]))]) + .await; + assert_successful(&result, "the writer owns the contract"); + + let (_, result) = setup + .create( + "contractList", + &[("contracts", identifier_list(&[own, foreign.id()]))], + ) + .await; + let [StateTransitionExecutionResult::PaidConsensusError { error, .. }] = + result.execution_results().as_slice() + else { + panic!("expected one paid consensus error, got {result:?}"); + }; + assert_eq!(error.code(), 40135, "{error}"); + assert_matches!( + error, + ConsensusError::StateError(StateError::ReferencedContractRequirementNotMetError( + unmet + )) if unmet.path() == "contracts[1]", + "the second contract is owned by someone else" + ); + } } 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 616f0a924ae..a7a441902c8 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 @@ -93,11 +93,11 @@ pub(super) fn validate_data_contract_references_v0( for (path, property) in document_type.as_ref().flattened_properties() { let declaration_path = format!("{declaring_type_name}.{path}"); - let (reference_target, declaration_path) = match &property.property_type { + let (reference_target, declaration_path) = match property.property_type.reference() { // A key reference on the key id property: what `identityProperty` // names must fit the document type; nothing else about the // declaration is state-dependent - DocumentPropertyType::KeyIdWithReference(reference) => { + Some(PropertyReference::KeyId(reference)) => { let invalid = |message: &str| { SimpleConsensusValidationResult::new_with_error( ReferencedKeyIdPropertyInvalidError::new( @@ -172,16 +172,14 @@ pub(super) fn validate_data_contract_references_v0( } continue; } - property_type => match property_type.reference() { - Some(PropertyReference::Value(target)) => (target, declaration_path), - // A typed array only parses from protocol version 14, - // whose contract create and update state validation are - // the only callers, so this arm is never reached before it - Some(PropertyReference::Elements(target)) => { - (target, format!("{declaring_type_name}.{path}[]")) - } - None => continue, - }, + Some(PropertyReference::Value(target)) => (target, declaration_path), + // A typed array only parses from protocol version 14, whose + // contract create and update state validation are the only + // callers, so this arm is never reached before it + Some(PropertyReference::Elements { target, .. }) => { + (target, format!("{declaring_type_name}.{path}[]")) + } + None => continue, }; // The key id property must exist in the same document type and be diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements.json index 091a2bb3642..069808bf43d 100644 --- a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements.json +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-typed-array-elements.json @@ -7,6 +7,7 @@ "reason": { "type": "object", "canBeDeleted": false, + "documentsMutable": true, "properties": { "topic": { "type": "string", @@ -189,6 +190,167 @@ }, "required": [], "additionalProperties": false + }, + "nestedCharter": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "team": { + "type": "object", + "position": 0, + "properties": { + "members": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "reason" + } + }, + "position": 0 + } + }, + "additionalProperties": false + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + }, + "foreignCharter": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "contractId": "YMN9Qj5jPNp7j14VPcML1B6xGgcPWVZUGLFU3Mnyfaf", + "documentType": "note" + } + }, + "position": 0 + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + }, + "contractList": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "contracts": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "contract", + "contractRequirements": { + "owner": "self" + } + } + }, + "position": 0 + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + }, + "repeatCharter": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "reason" + } + }, + "position": 0 + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + }, + "repeatPlainCh": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "reasons": { + "type": "array", + "minItems": 0, + "maxItems": 16, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 0 + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false } } } diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/mod.rs index 893c5cc21ab..d3f8c122502 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/mod.rs @@ -127,6 +127,12 @@ impl DocumentReplaceTransitionActionAccessorsV0 for DocumentReplaceTransitionAct } } + fn stored_changed_values(&self) -> &BTreeMap { + match self { + DocumentReplaceTransitionAction::V0(v0) => &v0.stored_changed_values, + } + } + fn data_owned(self) -> BTreeMap { match self { DocumentReplaceTransitionAction::V0(v0) => v0.data, diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/mod.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/mod.rs index 1391beb7aba..92e2f917f54 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/mod.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/mod.rs @@ -52,6 +52,13 @@ pub struct DocumentReplaceTransitionActionV0 { /// pointed at is gone: the stored value is the only record of what /// that was. pub removed_identifier_fields: BTreeMap, + /// The stored value of each `changed_data_fields` property the stored + /// document held (the changed and the removed ones, not the added ones). + /// Read by the reference validation, which re-validates only the + /// elements of a changed typed array of references that the stored list + /// did not already hold, as an unchanged single reference is not + /// re-validated either. + pub stored_changed_values: BTreeMap, /// Creator id pub creator_id: Option, } @@ -99,6 +106,8 @@ pub trait DocumentReplaceTransitionActionAccessorsV0 { /// The identifier each removed top-level property held in the stored /// document fn removed_identifier_fields(&self) -> &BTreeMap; + /// The stored value of each changed property the stored document held + fn stored_changed_values(&self) -> &BTreeMap; /// data owned fn data_owned(self) -> BTreeMap; diff --git a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs index 5a5a361fd9a..89e6b4f31c5 100644 --- a/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/batch/batched_transition/document_transition/document_replace_transition_action/v0/transformer.rs @@ -1,6 +1,6 @@ use dpp::block::block_info::BlockInfo; use dpp::document::{property_names, Document, DocumentV0Getters}; -use dpp::platform_value::Identifier; +use dpp::platform_value::{Identifier, Value}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use dpp::data_contract::document_type::accessors::DocumentTypeV1Getters; @@ -163,6 +163,19 @@ impl DocumentReplaceTransitionActionV0 { ) .collect(); + // What the stored document held for each changed property, so the + // reference validation can tell a list's new elements from the ones + // it already held + let stored_changed_values: BTreeMap = changed_fields + .iter() + .filter_map(|key| { + original_document + .properties() + .get(key) + .map(|value| (key.clone(), value.clone())) + }) + .collect(); + Ok(( BatchedTransitionAction::DocumentAction(DocumentTransitionAction::ReplaceAction( DocumentReplaceTransitionActionV0 { @@ -182,6 +195,7 @@ impl DocumentReplaceTransitionActionV0 { changed_data_fields: changed_fields, added_data_fields: added_fields, removed_identifier_fields, + stored_changed_values, creator_id: original_creator_id, } .into(), diff --git a/packages/rs-drive/src/state_transition_action/batch/tests.rs b/packages/rs-drive/src/state_transition_action/batch/tests.rs index cf44f276ab7..a4ec5149de0 100644 --- a/packages/rs-drive/src/state_transition_action/batch/tests.rs +++ b/packages/rs-drive/src/state_transition_action/batch/tests.rs @@ -402,6 +402,7 @@ fn make_replace_v0() -> DocumentReplaceTransitionActionV0 { changed_data_fields: BTreeSet::from(["field".to_string()]), added_data_fields: BTreeSet::new(), removed_identifier_fields: BTreeMap::new(), + stored_changed_values: BTreeMap::new(), creator_id: Some(Identifier::from([0xCC; 32])), } } @@ -2977,6 +2978,7 @@ fn stamp_test_replace_action(protocol_version: u32) -> DocumentReplaceTransition changed_data_fields: BTreeSet::from(["field".to_string()]), added_data_fields: BTreeSet::new(), removed_identifier_fields: BTreeMap::new(), + stored_changed_values: BTreeMap::new(), creator_id: Some(Identifier::from([0xCC; 32])), }) } diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 2f10cc0ae37..26d0903bf78 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -704,17 +704,21 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// where the element arm is the only new path) check every element as a /// single reference, refusing the first that fails with that /// reference's error (40120, 40127, 40135 and the rest), its path the -/// element's list path (`reasons[2]`). A replace re-validates the list -/// when it changed, when a property bound by a `propertyAgreement` -/// changed, and always for a `$ownerId` agreement or `deletableDocument` -/// elements. A foreign contract holding the referenced document type is -/// fetched once per list. Registration caps the references one document +/// element's list path (`reasons[2]`). A replace re-validates the +/// elements of a changed list the stored list did not hold (the replace +/// action carries `stored_changed_values`), and all of them when a +/// property bound by a `propertyAgreement` changed, for a `$ownerId` +/// agreement or for `deletableDocument` elements. A repeated element and +/// a foreign contract holding the referenced document type are fetched +/// once per list. Registration caps the references one document /// can carry at `SYSTEM_LIMITS_V4.max_references_per_document` (256; one /// per property declaring a reference, key id references of item 30 /// included, `maxItems` per typed array of referencing elements; /// backfilled into the earlier tables), and -/// refuses an `immutable` typed array of `deletableDocument` references, -/// which could never be replaced once one target is deleted. A changed +/// refuses an `immutable` property holding a `deletableDocument` +/// reference no replace could clear (a typed array of them, or a single +/// one inside an immutable object), which could never be replaced once +/// a target is deleted. A changed /// element `refersTo` is an incompatible schema change on update. /// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) diff --git a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index b6994e96be1..587f22b9d8b 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -15,8 +15,8 @@ use crate::error::{WasmDppError, WasmDppResult}; use crate::identifier::IdentifierWasm; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::{ - DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentTypeRef, - IdentityKeyReferenceRequirements, KeyIdReference, PropertyReference, + DocumentPropertyReferenceTarget, DocumentTypeRef, IdentityKeyReferenceRequirements, + KeyIdReference, PropertyReference, }; use dpp::prelude::Identifier; use js_sys::{Array, Object, Reflect}; @@ -447,24 +447,22 @@ pub(crate) fn references_for_document_type( let references = Array::new(); for (path, property) in document_type.flattened_properties() { - match &property.property_type { - DocumentPropertyType::KeyIdWithReference(reference) => { + match property.property_type.reference() { + Some(PropertyReference::KeyId(reference)) => { references.push(&key_id_reference_to_js(path, reference)?); } - property_type => match property_type.reference() { - Some(PropertyReference::Value(target)) => { - references.push(&reference_to_js(path, target, declaring_contract_id)?); - } - Some(PropertyReference::Elements(target)) => { - let element_path = format!("{path}[]"); - references.push(&reference_to_js( - &element_path, - target, - declaring_contract_id, - )?); - } - None => {} - }, + Some(PropertyReference::Value(target)) => { + references.push(&reference_to_js(path, target, declaring_contract_id)?); + } + Some(PropertyReference::Elements { target, .. }) => { + let element_path = format!("{path}[]"); + references.push(&reference_to_js( + &element_path, + target, + declaring_contract_id, + )?); + } + None => {} } } diff --git a/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts index 33f21aaf094..3e0b24929fe 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentTypedArrays.spec.ts @@ -253,7 +253,9 @@ describe('DataContract: typed arrays (v14)', () => { }, }; - expect(() => buildContract(schemasWithKeyReference)).to.throw(); + expect(() => buildContract(schemasWithKeyReference)).to.throw( + /identityPublicKey refersTo is not allowed on the elements of a typed array/, + ); }); });