diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 18d815cf5ce..221603c5b4b 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -680,7 +680,7 @@ The check runs where the JSON schema validation of a document's properties runs, ## Property Constraints (`propertyConstraints`) -Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named rules the integer properties of every created or replaced document must meet, where JSON Schema can only bound one property at a time. Each rule compares two integer expressions. +Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named rules the integer properties of every created or replaced document must meet, where JSON Schema can only bound one property at a time. Each rule is a condition: a comparison of two integer expressions, or `anyOf`, `allOf` or `not` over conditions. ```json "propertyConstraints": { @@ -693,11 +693,21 @@ Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named "wholeLots": { "equal": [{ "modulo": ["quantity", 10] }, 0] }, "minimumOrder": { "greaterThanOrEqual": [{ "multiply": ["price", { "ifAbsent": ["quantity", 1] }] }, 100] + }, + "feeWaivedOrAtLeastTen": { + "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] } } ``` -The first rule reads `((price + fee) * quantity) <= deposit`. A rule's name is 1 to 64 letters, digits or underscores, and the rule is an object with one key, its comparison: `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` or `greaterThanOrEqual`, listing the left and the right expression. An expression is one of: +The first rule reads `((price + fee) * quantity) <= deposit`, the last `fee == 0 || fee >= 10`. A rule's name is 1 to 64 letters, digits or underscores, and the rule is a condition, an object with one key: + +- a comparison, `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` or `greaterThanOrEqual`, listing the left and the right expression; +- `{ "anyOf": [...] }`, holding if at least one of two or more conditions holds; +- `{ "allOf": [...] }`, holding if every one of two or more conditions holds; +- `{ "not": condition }`, holding if its one condition does not. + +Conditions nest: `{ "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan": ["quantity", 10] }] } }` refuses a free order of more than 10. An `anyOf` or `allOf` may not list two alike conditions, nor hold one of its own kind directly (it says what one flat list says), and a `not` may not hold a `not` directly. An expression is one of: - an integer value (`100`; a float with no fractional part, `100.0`, reads as that integer, as the meta-schema's `integer` type admits it); - a string, the dotted path of an integer property of the document type (`"price"`, `"meta.total"`), whose value it takes, 0 when the document leaves the property out; @@ -709,11 +719,13 @@ A JSON number is always a value and a string always a path, so a property named The arithmetic is exact over `i128`. Operands are evaluated left to right, and every intermediate result must fit: an overflow, a divisor of 0, a negative exponent or a property value that is not an integer (a float with no fractional part passes the schema's `integer` type) breaks the rule instead of wrapping or truncating. `divide` and `modulo` are Euclidean, so the remainder is never negative and the quotient is the one that goes with it (`-7` by `2` is `-4` remainder `1`); for operands that are not negative this is ordinary integer division. `0` to the power `0` is `1`. There are no floats: a `number` property cannot be read, which keeps every node's result bit-identical. -The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path names an integer property of the type (a nested one by its dotted path) that is neither `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every rule reads at least one property, that no literal divisor is 0 and no literal exponent negative, and that no operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting the comparison, every operator and every operand. The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. +Conditions are checked in declared order and no further than the outcome needs: a comparison evaluates its left side, then its right; `anyOf` stops at the first condition that holds and `allOf` at the first that fails. A fault in a condition that is checked breaks the rule whatever the others would say, and `not` does not turn it into a pass. So an earlier condition guards a later one: `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` holds for a `b` of 0 without dividing by it, while the same two conditions the other way round divide by zero and break the rule. + +The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path names an integer property of the type (a nested one by its dotted path) that is neither `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every arithmetic operator and every operand, and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. -Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the comparison does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check reads no state and changes nothing stored, so it adds no fee; the limits bound its cost. Transfers, purchases and price updates change no property and are not judged. +Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check reads no state and changes nothing stored, so it adds no fee; the limits bound its cost. Transfers, purchases and price updates change no property and are not judged. -In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`, empty on types that predate the keyword), each rule's `violation` evaluates it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. +In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`, a comparison or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword), each rule's `holds` and `violation` evaluate it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. ## Rules and Guidelines diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 0d58f661fa0..85bc7e07571 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -399,7 +399,7 @@ try { ## Property constraints (`propertyConstraints`) -From protocol version 14 a document type can declare rules its documents' integer properties must meet, each a comparison of two integer expressions built from property paths and integer values: +From protocol version 14 a document type can declare rules its documents' integer properties must meet, each a comparison of two integer expressions built from property paths and integer values, or `anyOf`, `allOf` or `not` over such conditions: ```json "propertyConstraints": { @@ -411,11 +411,14 @@ From protocol version 14 a document type can declare rules its documents' intege }, "minimumOrder": { "greaterThanOrEqual": [{ "multiply": ["price", { "ifAbsent": ["quantity", 1] }] }, 100] + }, + "feeWaivedOrAtLeastTen": { + "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] } } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: 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 31807057c01..8fdc957f045 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), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named comparisons between integer expressions over the document's integer properties, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), 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.", + "$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), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's integer properties, comparisons between integer expressions combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), 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": { "referenceOperands": { @@ -40,7 +40,7 @@ } }, "propertyConstraint": { - "description": "One rule of propertyConstraints: an object with one key, the comparison, listing the two integer expressions it compares, left then right", + "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right, or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", "type": "object", "properties": { "equal": { @@ -60,12 +60,45 @@ }, "greaterThanOrEqual": { "$ref": "#/$defs/propertyConstraintOperandPair" + }, + "anyOf": { + "description": "Holds if at least one of its conditions holds, checked in declared order and stopping at the first that holds: two or more conditions, no two alike, none of them directly an anyOf (it says what one flat list says)", + "$ref": "#/$defs/propertyConstraintConditions", + "items": { + "properties": { + "anyOf": false + } + } + }, + "allOf": { + "description": "Holds if every one of its conditions holds, checked in declared order and stopping at the first that fails: two or more conditions, no two alike, none of them directly an allOf (it says what one flat list says)", + "$ref": "#/$defs/propertyConstraintConditions", + "items": { + "properties": { + "allOf": false + } + } + }, + "not": { + "description": "Holds if its one condition does not hold; a fault evaluating the condition still breaks the rule. The condition may not be directly another not", + "$ref": "#/$defs/propertyConstraint", + "properties": { + "not": false + } } }, "minProperties": 1, "maxProperties": 1, "additionalProperties": false }, + "propertyConstraintConditions": { + "type": "array", + "items": { + "$ref": "#/$defs/propertyConstraint" + }, + "minItems": 2, + "uniqueItems": true + }, "propertyConstraintExpression": { "description": "An integer expression of a propertyConstraints rule: an integer value; the dotted path of an integer property of the document type, whose value it takes, 0 when the document leaves it out; or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two", "type": [ @@ -1991,7 +2024,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is an object with one key, its comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual), listing the two integer expressions it compares, left then right. An expression is an integer value, the dotted path of an integer property of the document type, whose value it takes, 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Every property a rule reads must be an integer property that is neither transient nor inside a transient object, every rule must read at least one property, a literal 0 divisor or negative exponent is refused, and no operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting the comparison, every operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer property of the document type, whose value it takes, 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property a rule reads must be an integer property that is neither transient nor inside a transient object, every comparison must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" 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 e1230dc77eb..8c0d0383217 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 @@ -1877,7 +1877,8 @@ pub(super) fn validate_encrypted_for_declarations( /// declaration's shape ([`parse_property_constraints`]) and these reads are /// checked on every parse; under full validation, the limits too: at most /// `SystemLimits::max_property_constraints` rules, each of at most -/// `max_property_constraint_nodes` nodes. +/// `max_property_constraint_nodes` nodes, and no `anyOf` or `allOf` listing +/// the same condition twice. /// /// Only parser generation 3 calls it, once the core parse has run the /// meta-schema, so under full validation a malformed declaration is the @@ -1979,6 +1980,11 @@ fn apply_property_constraints_v0( "rule \"{name}\" has {nodes} nodes, above the maximum of {max_nodes}" ))); } + if let Some((repeat, earlier)) = constraint.repeated_condition() { + return Err(structure_error(format!( + "rule \"{name}\" at {repeat} repeats the condition at {earlier}" + ))); + } } } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index da201782106..0be5b4b9164 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -132,6 +132,63 @@ fn should_parse_the_rules_onto_the_document_type_on_both_paths() { assert!(document_type.property_constraints().is_empty()); } +/// `anyOf`, `allOf` and `not` register and parse on both paths, and every property +/// a condition reads, however deep, is held to the same checks as a comparison's. +#[test] +fn should_parse_combined_conditions_and_check_every_property_they_read() { + let rules = json!({ + "feeWaivedOrAtLeastTen": { + "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] + }, + "noFreeLargeOrder": { + "not": { "allOf": [{ "equal": ["price", 0] }, { "greaterThan": ["quantity", 10] }] } + }, + "depositOrSmallOrder": { + "allOf": [ + { + "anyOf": [ + { "greaterThan": ["deposit", 0] }, + { "not": { "greaterThan": ["quantity", 1] } } + ] + }, + { "lessThanOrEqual": [{ "ifAbsent": ["meta.total", 0] }, "deposit"] } + ] + } + }); + for full_validation in [true, false] { + let document_type = parse_order(rules.clone(), full_validation) + .unwrap_or_else(|e| panic!("full_validation {full_validation}: should parse: {e}")); + let constraints = document_type.property_constraints(); + assert_eq!( + constraints["feeWaivedOrAtLeastTen"].property_paths(), + ["fee", "fee"] + ); + assert_eq!( + constraints["noFreeLargeOrder"].property_paths(), + ["price", "quantity"] + ); + assert_eq!( + constraints["depositOrSmallOrder"].property_paths(), + ["deposit", "quantity", "meta.total", "deposit"] + ); + } + + let nested_string = json!({ + "rule": { + "anyOf": [ + { "equal": ["fee", 0] }, + { "not": { "lessThan": [{ "add": ["price", "note"] }, 10] } } + ] + } + }); + for full_validation in [true, false] { + expect_structure_error( + parse_order(nested_string.clone(), full_validation), + "rule \"rule\" reads \"note\", which has type string, not integer", + ); + } +} + /// Only an integer property's value is a number the rule can compute with: a /// string, a float, an array, an object and a system property are refused on /// both paths, as is a path naming nothing. @@ -217,11 +274,23 @@ fn should_refuse_a_rule_reading_anything_but_an_integer_property() { /// a transient object. #[test] fn should_refuse_a_rule_reading_a_transient_value() { - for (transient, operand) in [("code", "code"), ("meta", "meta.total")] { - let schema = order_schema( - Some(json!({ "rule": { "lessThan": [operand, "price"] } })), - Some(transient), - ); + for (transient, operand, nested) in [ + ("code", "code", false), + ("meta", "meta.total", false), + ("code", "code", true), + ] { + // Also when the property is read deep inside a condition + let rule = if nested { + json!({ + "anyOf": [ + { "equal": ["price", 1] }, + { "not": { "lessThan": [{ "add": ["fee", operand] }, "price"] } } + ] + }) + } else { + json!({ "lessThan": [operand, "price"] }) + }; + let schema = order_schema(Some(json!({ "rule": rule })), Some(transient)); for full_validation in [true, false] { expect_structure_error( parse_dispatched( @@ -291,6 +360,73 @@ fn should_hold_the_limits_under_full_validation_only() { ), ); parse_order(rule_of(max_nodes + 1), false).expect("a stored contract stays readable"); + + // Every logical operator and every comparison counts too: allOf, the equal with + // its add, "price", ones and 0, and not over an equal of "fee" and 0 + let logical_rule_of = |nodes: usize| { + let mut operands = vec![json!("price")]; + operands.resize(nodes - 8, json!(1)); + json!({ + "rule": { + "allOf": [ + { "equal": [{ "add": operands }, 0] }, + { "not": { "equal": ["fee", 0] } } + ] + } + }) + }; + let document_type = parse_order(logical_rule_of(max_nodes), true) + .expect("the most nodes a rule may have, logical ones included"); + assert_eq!( + document_type.property_constraints()["rule"].node_count(), + max_nodes + ); + expect_structure_error( + parse_order(logical_rule_of(max_nodes + 1), true), + &format!( + "rule \"rule\" has {} nodes, above the maximum of {max_nodes}", + max_nodes + 1 + ), + ); + parse_order(logical_rule_of(max_nodes + 1), false).expect("a stored contract stays readable"); +} + +/// No `anyOf` or `allOf` may list the same condition twice, checked when a contract +/// registers: the meta-schema refuses two identical JSON conditions, and the parser +/// two that parse alike. A stored contract stays readable. +#[test] +fn should_refuse_a_repeated_condition_under_full_validation_only() { + let identical = json!({ + "rule": { "anyOf": [{ "equal": ["price", 1] }, { "equal": ["price", 1] }] } + }); + let registered = parse_order(identical.clone(), true); + assert!( + registered.as_ref().is_err_and(is_json_schema_error), + "the meta-schema should refuse it, got {registered:?}" + ); + parse_order(identical, false).expect("a stored contract stays readable"); + + // A path on its own reads as ifAbsent 0, so these two are the same condition + let alike = json!({ + "rule": { + "allOf": [ + { "equal": ["fee", 1] }, + { + "not": { + "anyOf": [ + { "equal": ["price", 1] }, + { "equal": [{ "ifAbsent": ["price", 0] }, 1] } + ] + } + } + ] + } + }); + expect_structure_error( + parse_order(alike.clone(), true), + "rule \"rule\" at allOf[1].not.anyOf[1] repeats the condition at allOf[1].not.anyOf[0]", + ); + parse_order(alike, false).expect("a stored contract stays readable"); } /// When a contract registers, the meta-schema checks the grammar, the @@ -316,6 +452,29 @@ fn should_check_the_grammar_with_the_meta_schema_and_the_parser() { json!({ "rule": { "equal": [{ "ifAbsent": ["price", "fee"] }, 1] } }), json!({ "bad-name": { "equal": ["price", 1] } }), json!(["price"]), + json!({ "rule": { "or": [{ "equal": ["price", 1] }, { "equal": ["fee", 1] }] } }), + json!({ "rule": { "anyOf": [{ "equal": ["price", 1] }] } }), + json!({ "rule": { "allOf": { "equal": ["price", 1] } } }), + json!({ "rule": { "not": [{ "equal": ["price", 1] }] } }), + json!({ "rule": { "not": { "equal": ["price", 1] }, "equal": ["fee", 1] } }), + json!({ + "rule": { + "anyOf": [ + { "anyOf": [{ "equal": ["price", 1] }, { "equal": ["price", 2] }] }, + { "equal": ["fee", 1] } + ] + } + }), + json!({ + "rule": { + "allOf": [ + { "equal": ["fee", 1] }, + { "allOf": [{ "equal": ["price", 1] }, { "equal": ["price", 2] }] } + ] + } + }), + json!({ "rule": { "not": { "not": { "equal": ["price", 1] } } } }), + json!({ "rule": { "anyOf": [{ "equal": ["price", 1] }, { "equal": ["price"] }] } }), ] { let registered = parse_order(rules.clone(), true); assert!( @@ -343,6 +502,10 @@ fn should_check_the_grammar_with_the_meta_schema_and_the_parser() { json!({ "rule": { "equal": [{ "add": [1, 2] }, 3] } }), "rule \"rule\" reads no property", ), + ( + json!({ "rule": { "anyOf": [{ "equal": ["price", 1] }, { "equal": [1, 1] }] } }), + "rule \"rule\" at anyOf[1] reads no property", + ), ] { for full_validation in [true, false] { expect_structure_error(parse_order(rules.clone(), full_validation), needle); diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs index ff0288ddcf3..4a8adb7f858 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs @@ -683,9 +683,9 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe /// Judges a document's properties, `data` (a map), against every rule of the document /// type's `propertyConstraints`, in name order: the first rule it breaks fails with - /// `DocumentPropertyConstraintViolatedError` (10422), naming the rule and why (the - /// comparison does not hold, or evaluating it overflowed, divided by zero, raised to a - /// negative power or read a value that is not an integer). A property the document + /// `DocumentPropertyConstraintViolatedError` (10422), naming the rule and why (the rule + /// does not hold, or evaluating it overflowed, divided by zero, raised to a negative + /// power or read a value that is not an integer). A property the document /// leaves out counts as 0, or as its `ifAbsent` value. Reads the properties alone: /// `DataContract::validate_document_properties` runs it after the schema validation, /// so document create and replace, and every client validating a document, apply it. diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 719ece5d81d..08670000d26 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -116,9 +116,10 @@ pub(crate) mod property_names { /// transferable or tradeable one). Meta-schema v3+ (protocol version 14). /// See `parse_doctype_reference` in `try_from_schema`. pub const CREATOR_REFERS_TO: &str = "creatorRefersTo"; - /// Doctype-level object of named rules, each a comparison of two integer - /// expressions over the document's integer properties that every created or - /// replaced document must meet. Meta-schema v3+ (protocol version 14). See + /// Doctype-level object of named rules, each a condition on the document's + /// integer properties (a comparison of two integer expressions, or an + /// `anyOf`, `allOf` or `not` of conditions) that every created or replaced + /// document must meet. Meta-schema v3+ (protocol version 14). See /// `parse_property_constraints` in `property_constraints`. pub const PROPERTY_CONSTRAINTS: &str = "propertyConstraints"; pub const DISTINCT_FROM: &str = "distinctFrom"; diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index 83d8da79521..ceb489787dc 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -1,7 +1,7 @@ //! The doctype-level `propertyConstraints` keyword (meta-schema v3, protocol //! version 14): named rules every document of the type must meet, each a -//! comparison of two integer expressions over the document's integer -//! properties. +//! condition on the document's integer properties: a comparison of two integer +//! expressions, or `anyOf`, `allOf` or `not` over conditions. //! //! ```json //! "propertyConstraints": { @@ -14,6 +14,9 @@ //! "wholeLots": { "equal": [{ "modulo": ["quantity", 10] }, 0] }, //! "minimumOrder": { //! "greaterThanOrEqual": [{ "multiply": ["price", { "ifAbsent": ["quantity", 1] }] }, 100] +//! }, +//! "feeWaivedOrAtLeastTen": { +//! "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] //! } //! } //! ``` @@ -23,12 +26,14 @@ //! `ifAbsent`, a property with the value it takes when the document leaves it //! out. A property named on its own takes 0 when absent. How the arithmetic //! treats overflow, division and powers is set out on -//! [`ConstraintExpression::evaluate`]. +//! [`ConstraintExpression::evaluate`], and how conditions combine on +//! [`PropertyConstraint::holds`]. //! //! [`parse_property_constraints`] checks the declaration's shape on every //! parse, [`MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH`] included. Which properties a //! rule may read is checked against the parsed document type by parser -//! generation 3, and the limits on the rules under full validation only. +//! generation 3, and the limits on the rules, and that no `anyOf` or `allOf` +//! repeats a condition, under full validation only. //! Nothing here is serialized: a document type rebuilds its rules from its //! stored schema whenever the contract is loaded. @@ -51,22 +56,27 @@ const MULTIPLY: &str = "multiply"; const DIVIDE: &str = "divide"; const MODULO: &str = "modulo"; const POWER: &str = "power"; +const ANY_OF: &str = "anyOf"; +const ALL_OF: &str = "allOf"; +const NOT: &str = "not"; /// Every key an operand object may hold, for the errors. const OPERAND_KEYS: &str = "add, subtract, multiply, divide, modulo, power or ifAbsent"; -/// The deepest an operand may sit in its rule, the two sides of the comparison -/// at depth 1. Checked on every parse, stored contracts included, so that a -/// declaration handed to a parse without full validation cannot drive the -/// parser, or the evaluation of what it builds, into unbounded recursion. A -/// registrable rule stays far below it: it has at most +/// The deepest a condition or an operand may sit in its rule: the rule's own +/// condition at depth 0, and each operand of a comparison, and each condition +/// under `anyOf`, `allOf` or `not`, one level deeper than what holds it. +/// Checked on every parse, stored contracts included, so that a declaration +/// handed to a parse without full validation cannot drive the parser, or the +/// evaluation of what it builds, into unbounded recursion. A registrable rule +/// stays far below it: it has at most /// `SystemLimits::max_property_constraint_nodes` nodes, so it is never deeper /// than that (a test holds every protocol version's limit to it). A constant /// rather than a limit, like `MAX_REFERENCE_EXPRESSION_DECODE_DEPTH`, so that /// no change to a limit can make a stored contract unparseable. pub const MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH: usize = 64; -/// How the two sides of a rule must compare. +/// How the two sides of a comparison must compare. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConstraintComparison { /// `equal`: the two sides are the same number. @@ -234,6 +244,23 @@ impl ConstraintExpression { } } + /// Whether the expression reads at least one property. + fn reads_property(&self) -> bool { + match self { + ConstraintExpression::Value(_) => false, + ConstraintExpression::Property { .. } => true, + ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { + operands.iter().any(ConstraintExpression::reads_property) + } + ConstraintExpression::Subtract(left, right) + | ConstraintExpression::Divide(left, right) + | ConstraintExpression::Modulo(left, right) + | ConstraintExpression::Power(left, right) => { + left.reads_property() || right.reads_property() + } + } + } + /// Appends the dotted paths of the properties the expression reads to /// `paths`, in the order it reads them. fn collect_property_paths<'a>(&'a self, paths: &mut Vec<&'a str>) { @@ -256,47 +283,162 @@ impl ConstraintExpression { } } -/// One rule of `propertyConstraints`: its two sides must compare as -/// `comparison` says. +/// A rule of `propertyConstraints`, or a condition inside one: a comparison of +/// two integer expressions, or `anyOf`, `allOf` or `not` over conditions. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct PropertyConstraint { - pub comparison: ConstraintComparison, - pub left: ConstraintExpression, - pub right: ConstraintExpression, +pub enum PropertyConstraint { + /// A comparison: the two sides must compare as `comparison` says. + Compare { + comparison: ConstraintComparison, + left: ConstraintExpression, + right: ConstraintExpression, + }, + /// `anyOf`: at least one of two or more conditions holds. + AnyOf(Vec), + /// `allOf`: every one of two or more conditions holds. + AllOf(Vec), + /// `not`: the condition does not hold. + Not(Box), } impl PropertyConstraint { + /// Whether a document whose properties are `data` meets the condition. + /// + /// Evaluated left to right, and no further than the outcome needs: a + /// comparison evaluates its left side, then its right one; `anyOf` checks + /// its conditions in declared order and holds at the first that holds; + /// `allOf` fails at the first that fails; `not` inverts its condition. The + /// first fault an evaluated expression meets ([`ConstraintExpression::evaluate`]) + /// is returned whatever the conditions left unevaluated would say, and `not` + /// never turns a fault into a pass. So an earlier condition guards a later + /// one: `anyOf: [{ equal: ["b", 0] }, { equal: [{ divide: ["a", "b"] }, 2] }]` + /// holds for a `b` of 0 without dividing by it, while the same two + /// conditions the other way round divide by zero. + pub fn holds(&self, data: &Value) -> Result { + match self { + PropertyConstraint::Compare { + comparison, + left, + right, + } => { + let (left, right) = (left.evaluate(data)?, right.evaluate(data)?); + Ok(comparison.holds(left, right)) + } + PropertyConstraint::AnyOf(conditions) => { + for condition in conditions { + if condition.holds(data)? { + return Ok(true); + } + } + Ok(false) + } + PropertyConstraint::AllOf(conditions) => { + for condition in conditions { + if !condition.holds(data)? { + return Ok(false); + } + } + Ok(true) + } + PropertyConstraint::Not(condition) => Ok(!condition.holds(data)?), + } + } + /// Why a document whose properties are `data` breaks the rule, `None` when - /// it meets it. The left side is evaluated before the right one, so a fault - /// on both sides is reported from the left. + /// it meets it: the first fault met on the way ([`Self::holds`]), or + /// [`PropertyConstraintViolation::NotMet`] when the rule evaluates to false. pub fn violation(&self, data: &Value) -> Option { - let left = match self.left.evaluate(data) { - Ok(left) => left, - Err(violation) => return Some(violation), - }; - let right = match self.right.evaluate(data) { - Ok(right) => right, - Err(violation) => return Some(violation), - }; - (!self.comparison.holds(left, right)).then_some(PropertyConstraintViolation::NotMet) + match self.holds(data) { + Ok(true) => None, + Ok(false) => Some(PropertyConstraintViolation::NotMet), + Err(violation) => Some(violation), + } } /// The nodes of the rule, counted against - /// `SystemLimits::max_property_constraint_nodes`: its comparison, every - /// operator and every operand (an integer value, or a property with or - /// without `ifAbsent`). + /// `SystemLimits::max_property_constraint_nodes`: every comparison and + /// logical operator, every arithmetic operator and every operand (an + /// integer value, or a property with or without `ifAbsent`). pub fn node_count(&self) -> usize { - 1 + self.left.node_count() + self.right.node_count() + 1 + match self { + PropertyConstraint::Compare { left, right, .. } => { + left.node_count() + right.node_count() + } + PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { + conditions.iter().map(PropertyConstraint::node_count).sum() + } + PropertyConstraint::Not(condition) => condition.node_count(), + } } - /// The dotted paths of the properties the rule reads, in the order it reads - /// them, a path read twice listed twice. + /// The dotted paths of the properties the rule reads, in declared order, a + /// path read twice listed twice. pub fn property_paths(&self) -> Vec<&str> { let mut paths = Vec::new(); - self.left.collect_property_paths(&mut paths); - self.right.collect_property_paths(&mut paths); + self.collect_property_paths(&mut paths); paths } + + /// Where an `anyOf` or `allOf` of the rule lists the same condition twice: + /// the repeat's place and the earlier one's (`anyOf[2]` and `anyOf[0]`), the + /// first found in declared order, `None` when no list does. Conditions are + /// alike when they parse alike, so `1` and `1.0` are the same value, and so + /// are `"price"` and `{ "ifAbsent": ["price", 0] }`. Checked under full + /// validation with the limits, which bound the lists it compares; a stored + /// rule was checked when its contract registered. + pub fn repeated_condition(&self) -> Option<(String, String)> { + self.find_repeated_condition(&mut String::new()) + } + + /// [`Self::repeated_condition`] for the condition at `at` (empty for the + /// rule's own), which is extended as the walk descends and trimmed back + /// when it returns `None`. + fn find_repeated_condition(&self, at: &mut String) -> Option<(String, String)> { + let (key, conditions) = match self { + PropertyConstraint::Compare { .. } => return None, + PropertyConstraint::AnyOf(conditions) => (ANY_OF, conditions), + PropertyConstraint::AllOf(conditions) => (ALL_OF, conditions), + PropertyConstraint::Not(condition) => { + let parent = enter(at, NOT); + let found = condition.find_repeated_condition(at); + at.truncate(parent); + return found; + } + }; + let parent = enter(at, key); + let base = at.len(); + for (index, condition) in conditions.iter().enumerate() { + if let Some(earlier) = conditions[..index] + .iter() + .position(|earlier| earlier == condition) + { + return Some((format!("{at}[{index}]"), format!("{at}[{earlier}]"))); + } + // Writing to a `String` cannot fail + let _ = write!(at, "[{index}]"); + if let Some(found) = condition.find_repeated_condition(at) { + return Some(found); + } + at.truncate(base); + } + at.truncate(parent); + None + } + + fn collect_property_paths<'a>(&'a self, paths: &mut Vec<&'a str>) { + match self { + PropertyConstraint::Compare { left, right, .. } => { + left.collect_property_paths(paths); + right.collect_property_paths(paths); + } + PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { + for condition in conditions { + condition.collect_property_paths(paths); + } + } + PropertyConstraint::Not(condition) => condition.collect_property_paths(paths), + } + } } /// Reads the `propertyConstraints` keyword of a document type's `schema`: @@ -305,17 +447,21 @@ impl PropertyConstraint { /// /// The rules of the declaration's shape are checked here, on every parse: an /// object of one or more rules, each named with 1 to 64 letters, digits or -/// underscores and holding one comparison of exactly two operands. An operand -/// is an integer value, a property path, or an object with one key: `ifAbsent` -/// with a path and an integer value, `add` or `multiply` with two or more -/// operands, or `subtract`, `divide`, `modulo` or `power` with exactly two. -/// An integer value may be spelled as a float with no fractional part, as the -/// meta-schema's `integer` type admits one. A literal 0 divisor, a literal -/// negative exponent, a rule that reads no property, which would hold for every -/// document or for none, and an operand deeper than -/// [`MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH`] are refused. +/// underscores and holding one condition. A condition is an object with one +/// key: a comparison of exactly two operands, `anyOf` or `allOf` with two or +/// more conditions, none of them directly the same operator (it says what one +/// flat list says), or `not` with one condition that is not directly another +/// `not`. An operand is an integer value, a property path, or +/// an object with one key: `ifAbsent` with a path and an integer value, `add` +/// or `multiply` with two or more operands, or `subtract`, `divide`, `modulo` +/// or `power` with exactly two. An integer value may be spelled as a float with +/// no fractional part, as the meta-schema's `integer` type admits one. A +/// literal 0 divisor, a literal negative exponent, a comparison that reads no +/// property, which would hold for every document or for none, and a condition +/// or operand deeper than [`MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH`] are refused. /// What the paths name is checked against the parsed document type, and the -/// limits under full validation, by parser generation 3. +/// limits and that no list repeats a condition under full validation, by +/// parser generation 3. pub fn parse_property_constraints( schema: &Value, document_type_name: &str, @@ -353,14 +499,10 @@ pub fn parse_property_constraints( name.non_qualified_string_representation() ))); }; - let constraint = parse_rule(rule) + // Where a condition or an operand sits in the rule (`anyOf[1].lessThan[0]`), + // grown and trimmed in place as the parse descends and only read into an error + let constraint = parse_condition(rule, &mut String::new(), 0) .map_err(|message| structure_error(format!("rule \"{name}\" {message}")))?; - if constraint.property_paths().is_empty() { - return Err(structure_error(format!( - "rule \"{name}\" reads no property, so it would hold for every document or for \ - none" - ))); - } if constraints.insert(name.to_string(), constraint).is_some() { return Err(structure_error(format!("declares rule \"{name}\" twice"))); } @@ -389,38 +531,132 @@ fn single_entry(value: &Value) -> Option<(&str, &Value)> { Some((key.as_text()?, value)) } -/// One rule: an object whose one key names the comparison and lists its two -/// sides. The error is the rest of a message naming the rule. -fn parse_rule(rule: &Value) -> Result { - let comparison_names = || { +/// Every key a condition object may hold, for the errors. +fn condition_keys() -> String { + format!( + "a comparison ({}), anyOf, allOf or not", ConstraintComparison::ALL .map(ConstraintComparison::wire_name) .join(", ") - }; - let Some((key, sides)) = single_entry(rule) else { + ) +} + +/// `at ` followed by where something sits in its rule, nothing for the rule's +/// own condition, to open the rest of an error naming the rule. +fn located(at: &str) -> String { + if at.is_empty() { + String::new() + } else { + format!("at {at} ") + } +} + +/// Extends `at`, where a condition sits in its rule (empty for the rule's +/// own), to the place of its `key`, returning the length to trim it back to. +fn enter(at: &mut String, key: &str) -> usize { + let parent = at.len(); + if parent > 0 { + at.push('.'); + } + at.push_str(key); + parent +} + +/// A condition at `at` (`anyOf[1]`, empty for the rule's own), where the +/// errors place it, `depth` levels into its rule: an object whose one key is a +/// comparison listing its two sides, or `anyOf`, `allOf` or `not`. The error is +/// the rest of a message naming the rule. `at` is extended for what the +/// condition holds and trimmed back before a successful return. +fn parse_condition( + value: &Value, + at: &mut String, + depth: usize, +) -> Result { + if depth > MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH { return Err(format!( - "must be an object with one key, its comparison: {}", - comparison_names() + "{}nests deeper than {MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH} levels", + located(at) )); - }; - let Some(comparison) = ConstraintComparison::ALL - .into_iter() - .find(|comparison| comparison.wire_name() == key) - else { + } + let Some((key, body)) = single_entry(value) else { return Err(format!( - "compares with \"{key}\", which is not a comparison: {}", - comparison_names() + "{}must be an object with one key: {}", + located(at), + condition_keys() )); }; - // Where an operand sits in the rule (`lessThan[0].add[1]`), grown and trimmed - // in place as the parse descends and only read into an error - let mut at = key.to_string(); - let (left, right) = operand_pair(sides, &mut at, 1)?; - Ok(PropertyConstraint { - comparison, - left, - right, - }) + let parent = enter(at, key); + let condition = match key { + ANY_OF => PropertyConstraint::AnyOf(condition_list(body, key, at, depth + 1)?), + ALL_OF => PropertyConstraint::AllOf(condition_list(body, key, at, depth + 1)?), + NOT => { + if single_entry(body).is_some_and(|(inner, _)| inner == NOT) { + return Err(format!( + "at {at}.{NOT} is a not directly inside a not, which says what the \ + condition inside it says: declare that condition" + )); + } + PropertyConstraint::Not(Box::new(parse_condition(body, at, depth + 1)?)) + } + _ => { + let Some(comparison) = ConstraintComparison::ALL + .into_iter() + .find(|comparison| comparison.wire_name() == key) + else { + at.truncate(parent); + return Err(format!( + "{}names \"{key}\", which is not {}", + located(at), + condition_keys() + )); + }; + let (left, right) = operand_pair(body, at, depth + 1)?; + if !left.reads_property() && !right.reads_property() { + at.truncate(parent); + return Err(format!( + "{}reads no property, so it would hold for every document or for none", + located(at) + )); + } + PropertyConstraint::Compare { + comparison, + left, + right, + } + } + }; + at.truncate(parent); + Ok(condition) +} + +/// The two or more conditions the `anyOf` or `allOf` named `key` lists at +/// `at`, `depth` levels into their rule, none of them directly another `key`, +/// which says what one flat list says. That no two are alike is checked under +/// full validation ([`PropertyConstraint::repeated_condition`]). +fn condition_list( + conditions: &Value, + key: &str, + at: &mut String, + depth: usize, +) -> Result, String> { + let Some(values) = conditions.as_array().filter(|values| values.len() >= 2) else { + return Err(format!("at {at} must list two or more conditions")); + }; + let base = at.len(); + let mut parsed = Vec::with_capacity(values.len()); + for (index, value) in values.iter().enumerate() { + // Writing to a `String` cannot fail + let _ = write!(at, "[{index}]"); + if single_entry(value).is_some_and(|(inner, _)| inner == key) { + return Err(format!( + "at {at} is an {key} directly inside an {key}, which says what one flat list \ + says: list its conditions in the outer {key}" + )); + } + parsed.push(parse_condition(value, at, depth)?); + at.truncate(base); + } + Ok(parsed) } /// An operand at `at` (`lessThan[0].add[1]`), where the errors place it, diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index 09c63e5dff3..70fc920d0f3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -45,9 +45,18 @@ fn data(entries: &[(&str, Value)]) -> Value { /// The value of `expression`, parsed as the left side of an `equal`, for `data`. fn evaluate(expression: Value, data: &Value) -> Result { - parse_rule_value(platform_value!({ "equal": [expression, "anchor"] })) - .left - .evaluate(data) + match parse_rule_value(platform_value!({ "equal": [expression, "anchor"] })) { + PropertyConstraint::Compare { left, .. } => left.evaluate(data), + other => panic!("an equal parses to a comparison, got {other:?}"), + } +} + +/// The comparison `rule` is, which it must be. +fn comparison_of(rule: &PropertyConstraint) -> ConstraintComparison { + match rule { + PropertyConstraint::Compare { comparison, .. } => *comparison, + other => panic!("expected a comparison, got {other:?}"), + } } // ── parsing ───────────────────────────────────────────────────────────── @@ -93,7 +102,7 @@ fn should_parse_every_operator_comparison_and_the_if_absent_operand() { ); assert_eq!( rules["depositCoversOrder"], - PropertyConstraint { + PropertyConstraint::Compare { comparison: ConstraintComparison::LessThanOrEqual, left: ConstraintExpression::Multiply(vec![ ConstraintExpression::Add(vec![property("price"), property("fee")]), @@ -104,7 +113,7 @@ fn should_parse_every_operator_comparison_and_the_if_absent_operand() { ); assert_eq!( rules["wholeLots"], - PropertyConstraint { + PropertyConstraint::Compare { comparison: ConstraintComparison::Equal, left: ConstraintExpression::Modulo( Box::new(property("quantity")), @@ -115,7 +124,7 @@ fn should_parse_every_operator_comparison_and_the_if_absent_operand() { ); assert_eq!( rules["minimumOrder"], - PropertyConstraint { + PropertyConstraint::Compare { comparison: ConstraintComparison::GreaterThanOrEqual, left: ConstraintExpression::Multiply(vec![ property("price"), @@ -129,7 +138,7 @@ fn should_parse_every_operator_comparison_and_the_if_absent_operand() { ); assert_eq!( rules["rest"], - PropertyConstraint { + PropertyConstraint::Compare { comparison: ConstraintComparison::NotEqual, left: ConstraintExpression::Subtract( Box::new(ConstraintExpression::Divide( @@ -144,8 +153,14 @@ fn should_parse_every_operator_comparison_and_the_if_absent_operand() { right: ConstraintExpression::Value(-5), } ); - assert_eq!(rules["less"].comparison, ConstraintComparison::LessThan); - assert_eq!(rules["more"].comparison, ConstraintComparison::GreaterThan); + assert_eq!( + comparison_of(&rules["less"]), + ConstraintComparison::LessThan + ); + assert_eq!( + comparison_of(&rules["more"]), + ConstraintComparison::GreaterThan + ); } #[test] @@ -173,15 +188,15 @@ fn should_refuse_a_malformed_declaration() { ), ( platform_value!({ "rule": ["price", 1] }), - "rule \"rule\" must be an object with one key, its comparison", + "rule \"rule\" must be an object with one key: a comparison (equal, notEqual", ), ( platform_value!({ "rule": { "equal": ["price", 1], "lessThan": ["price", 1] } }), - "rule \"rule\" must be an object with one key, its comparison", + "rule \"rule\" must be an object with one key: a comparison (equal, notEqual", ), ( platform_value!({ "rule": { "atMost": ["price", 1] } }), - "compares with \"atMost\", which is not a comparison", + "rule \"rule\" names \"atMost\", which is not a comparison (equal", ), ( platform_value!({ "rule": { "equal": ["price"] } }), @@ -275,7 +290,7 @@ fn should_read_a_float_literal_without_a_fractional_part_as_an_integer() { })); assert_eq!( rule, - PropertyConstraint { + PropertyConstraint::Compare { comparison: ConstraintComparison::Equal, left: ConstraintExpression::Property { path: "price".to_string(), @@ -335,6 +350,261 @@ fn should_count_nodes_and_list_the_paths_a_rule_reads() { assert_eq!(rule.property_paths(), ["price", "fee", "price"]); } +// ── anyOf, allOf and not ──────────────────────────────────────────────── + +fn compare( + comparison: ConstraintComparison, + left: ConstraintExpression, + right: ConstraintExpression, +) -> PropertyConstraint { + PropertyConstraint::Compare { + comparison, + left, + right, + } +} + +#[test] +fn should_parse_any_of_all_of_and_not() { + let rules = parse(platform_value!({ + "aIsZeroOrBIsFour": { "anyOf": [{ "equal": ["a", 0] }, { "equal": ["b", 4] }] }, + "notBoth": { + "not": { "allOf": [{ "greaterThan": ["a", 0] }, { "greaterThan": ["b", 0] }] } + }, + "nested": { + "allOf": [ + { "anyOf": [{ "equal": ["a", 0] }, { "lessThan": ["a", "b"] }] }, + { "not": { "equal": [{ "ifAbsent": ["b", 1] }, 3] } } + ] + } + })) + .expect("the declaration parses"); + + let a_is = |value| { + compare( + ConstraintComparison::Equal, + property("a"), + ConstraintExpression::Value(value), + ) + }; + assert_eq!( + rules["aIsZeroOrBIsFour"], + PropertyConstraint::AnyOf(vec![ + a_is(0), + compare( + ConstraintComparison::Equal, + property("b"), + ConstraintExpression::Value(4) + ), + ]) + ); + assert_eq!( + rules["notBoth"], + PropertyConstraint::Not(Box::new(PropertyConstraint::AllOf(vec![ + compare( + ConstraintComparison::GreaterThan, + property("a"), + ConstraintExpression::Value(0) + ), + compare( + ConstraintComparison::GreaterThan, + property("b"), + ConstraintExpression::Value(0) + ), + ]))) + ); + assert_eq!( + rules["nested"], + PropertyConstraint::AllOf(vec![ + PropertyConstraint::AnyOf(vec![ + a_is(0), + compare(ConstraintComparison::LessThan, property("a"), property("b")), + ]), + PropertyConstraint::Not(Box::new(compare( + ConstraintComparison::Equal, + ConstraintExpression::Property { + path: "b".to_string(), + if_absent: 1 + }, + ConstraintExpression::Value(3) + ))), + ]) + ); +} + +/// The errors place a fault by its path through the conditions, then through the +/// operands of the comparison it sits in. +#[test] +fn should_refuse_a_malformed_condition() { + let one = platform_value!({ "equal": ["price", 1] }); + let two = platform_value!({ "equal": ["price", 2] }); + let fee = platform_value!({ "equal": ["fee", 1] }); + let cases = [ + ( + platform_value!({ "anyOf": [one.clone()] }), + "rule \"rule\" at anyOf must list two or more conditions", + ), + ( + platform_value!({ "allOf": one.clone() }), + "rule \"rule\" at allOf must list two or more conditions", + ), + ( + platform_value!({ "allOf": [] }), + "rule \"rule\" at allOf must list two or more conditions", + ), + ( + platform_value!({ "not": [one.clone()] }), + "rule \"rule\" at not must be an object with one key: a comparison", + ), + ( + platform_value!({ "anyOf": [one.clone(), two.clone()], "equal": ["price", 3] }), + "rule \"rule\" must be an object with one key: a comparison", + ), + ( + platform_value!({ "anyOf": [one.clone(), ["price", 1]] }), + "rule \"rule\" at anyOf[1] must be an object with one key: a comparison", + ), + ( + platform_value!({ "anyOf": [one.clone(), { "or": [one.clone(), two.clone()] }] }), + "rule \"rule\" at anyOf[1] names \"or\", which is not a comparison", + ), + // A flat list says the same + ( + platform_value!({ "anyOf": [{ "anyOf": [one.clone(), two.clone()] }, fee.clone()] }), + "rule \"rule\" at anyOf[0] is an anyOf directly inside an anyOf", + ), + ( + platform_value!({ "allOf": [fee.clone(), { "allOf": [one.clone(), two.clone()] }] }), + "rule \"rule\" at allOf[1] is an allOf directly inside an allOf", + ), + // A double negation says what the condition inside it says + ( + platform_value!({ "not": { "not": one.clone() } }), + "rule \"rule\" at not.not is a not directly inside a not", + ), + // Every comparison reads a property, not only the rule as a whole: a constant + // one would make the anyOf hold for every document + ( + platform_value!({ "anyOf": [one.clone(), { "equal": [1, 1] }] }), + "rule \"rule\" at anyOf[1] reads no property", + ), + ( + platform_value!({ "not": { "equal": [2, { "add": [1, 1] }] } }), + "rule \"rule\" at not reads no property", + ), + ( + platform_value!({ + "allOf": [one.clone(), { "not": { "lessThan": [{ "divide": ["price", 0] }, 1] } }] + }), + "rule \"rule\" at allOf[1].not.lessThan[0].divide divides by 0", + ), + ( + platform_value!({ "anyOf": [one.clone(), { "equal": ["price"] }] }), + "rule \"rule\" at anyOf[1].equal must list exactly two operands", + ), + ]; + for (condition, needle) in cases { + expect_refusal(platform_value!({ "rule": condition }), needle); + } +} + +/// Conditions nest within the same cap as operands: the rule's own condition is at +/// depth 0, and whatever a condition holds one level deeper. +#[test] +fn should_refuse_conditions_nested_deeper_than_the_parse_depth_cap() { + let nested = |levels: usize| { + let mut condition = platform_value!({ "equal": ["price", 0] }); + for level in 0..levels { + // Alternating, since an anyOf directly inside an anyOf is refused + let key = if level % 2 == 0 { ANY_OF } else { ALL_OF }; + let sibling = platform_value!({ "equal": ["price", level as u64 + 1] }); + condition = Value::Map(vec![( + Value::Text(key.to_string()), + Value::Array(vec![condition, sibling]), + )]); + } + platform_value!({ "rule": condition }) + }; + // The innermost comparison sits `levels` deep, its operands one deeper + parse(nested(MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH - 1)).expect("at the cap"); + expect_refusal( + nested(MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH), + &format!("equal[0] nests deeper than {MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH} levels"), + ); + // One level more puts the comparison itself past the cap, inside the anyOf of + // the first level, and the condition parse refuses it before its operands + expect_refusal( + nested(MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH + 1), + &format!("anyOf[0] nests deeper than {MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH} levels"), + ); +} + +/// A list that repeats a condition is found where it sits, the first in declared +/// order, comparing conditions as they parse. The parse itself accepts it: the check +/// runs under full validation. +#[test] +fn should_find_a_condition_an_any_of_or_all_of_repeats() { + let one = platform_value!({ "equal": ["price", 1] }); + let two = platform_value!({ "equal": ["price", 2] }); + let fee = platform_value!({ "equal": ["fee", 1] }); + for (condition, expected) in [ + ( + platform_value!({ "anyOf": [one.clone(), two.clone()] }), + None, + ), + // The same condition in two different lists is no repeat + ( + platform_value!({ + "allOf": [ + { "anyOf": [one.clone(), fee.clone()] }, + { "anyOf": [one.clone(), two.clone()] } + ] + }), + None, + ), + ( + platform_value!({ "anyOf": [one.clone(), two.clone(), one.clone()] }), + Some(("anyOf[2]", "anyOf[0]")), + ), + // Alike once parsed: JSON does not tell `1` from `1.0`, and a path on its own + // reads as `ifAbsent` 0 + ( + platform_value!({ "allOf": [one.clone(), { "equal": ["price", 1.0] }] }), + Some(("allOf[1]", "allOf[0]")), + ), + ( + platform_value!({ + "anyOf": [one.clone(), { "equal": [{ "ifAbsent": ["price", 0] }, 1] }] + }), + Some(("anyOf[1]", "anyOf[0]")), + ), + // Found through a not, inside a nested list + ( + platform_value!({ + "allOf": [ + fee.clone(), + { "not": { "anyOf": [two.clone(), one.clone(), two.clone()] } } + ] + }), + Some(("allOf[1].not.anyOf[2]", "allOf[1].not.anyOf[0]")), + ), + // The first repeat in declared order + ( + platform_value!({ + "anyOf": [{ "allOf": [fee.clone(), fee.clone()] }, one.clone(), one.clone()] + }), + Some(("anyOf[0].allOf[1]", "anyOf[0].allOf[0]")), + ), + ] { + let rule = parse_rule_value(condition.clone()); + assert_eq!( + rule.repeated_condition(), + expected.map(|(repeat, earlier)| (repeat.to_string(), earlier.to_string())), + "{condition:?}" + ); + } +} + // ── evaluation ────────────────────────────────────────────────────────── #[test] @@ -593,3 +863,108 @@ fn should_report_whether_a_rule_holds_and_the_left_fault_first() { ); } } + +/// `a == 0 || b == 4`, and `allOf` and `not` over the same comparisons. +#[test] +fn should_combine_conditions_with_any_of_all_of_and_not() { + let any_of = parse_rule_value(platform_value!({ + "anyOf": [{ "equal": ["a", 0] }, { "equal": ["b", 4] }] + })); + let all_of = parse_rule_value(platform_value!({ + "allOf": [{ "equal": ["a", 0] }, { "equal": ["b", 4] }] + })); + let not = parse_rule_value(platform_value!({ + "not": { "anyOf": [{ "equal": ["a", 0] }, { "equal": ["b", 4] }] } + })); + for (a, b, either, both) in [ + (0, 4, true, true), + (0, 5, true, false), + (1, 4, true, false), + (1, 5, false, false), + ] { + let values = data(&[("a", Value::U64(a)), ("b", Value::U64(b))]); + assert_eq!(any_of.holds(&values), Ok(either), "a {a}, b {b}: anyOf"); + assert_eq!(all_of.holds(&values), Ok(both), "a {a}, b {b}: allOf"); + assert_eq!(not.holds(&values), Ok(!either), "a {a}, b {b}: not"); + assert_eq!( + any_of.violation(&values), + (!either).then_some(PropertyConstraintViolation::NotMet), + "a {a}, b {b}" + ); + } + // An absent property still counts as 0 + assert_eq!(any_of.holds(&data(&[("b", Value::U64(5))])), Ok(true)); +} + +/// Conditions are checked in declared order, no further than the outcome needs, so an +/// earlier one guards a later one; a fault in a condition that is checked breaks the rule +/// whatever the others would say, and `not` does not turn it into a pass. +#[test] +fn should_stop_at_the_outcome_and_break_the_rule_on_the_first_fault() { + let quotient_is_two = platform_value!({ "equal": [{ "divide": ["a", "b"] }, 2] }); + let guarded_any_of = parse_rule_value(platform_value!({ + "anyOf": [{ "equal": ["b", 0] }, quotient_is_two.clone()] + })); + let unguarded_any_of = parse_rule_value(platform_value!({ + "anyOf": [quotient_is_two.clone(), { "equal": ["b", 0] }] + })); + let guarded_all_of = parse_rule_value(platform_value!({ + "allOf": [{ "notEqual": ["b", 0] }, quotient_is_two.clone()] + })); + let negated = parse_rule_value(platform_value!({ "not": quotient_is_two })); + + let values = |a: u64, b: u64| data(&[("a", Value::U64(a)), ("b", Value::U64(b))]); + let zero_divisor = values(6, 0); + assert_eq!(guarded_any_of.violation(&zero_divisor), None); + assert_eq!( + unguarded_any_of.violation(&zero_divisor), + Some(PropertyConstraintViolation::DivisionByZero) + ); + assert_eq!( + guarded_all_of.violation(&zero_divisor), + Some(PropertyConstraintViolation::NotMet) + ); + assert_eq!( + negated.violation(&zero_divisor), + Some(PropertyConstraintViolation::DivisionByZero) + ); + + // 4 / 2 = 2, 6 / 2 = 3 + for rule in [&guarded_any_of, &unguarded_any_of, &guarded_all_of] { + assert_eq!(rule.violation(&values(4, 2)), None, "{rule:?}"); + assert_eq!( + rule.violation(&values(6, 2)), + Some(PropertyConstraintViolation::NotMet), + "{rule:?}" + ); + } + assert_eq!( + negated.violation(&values(4, 2)), + Some(PropertyConstraintViolation::NotMet) + ); + assert_eq!(negated.violation(&values(6, 2)), None); + + // An allOf stops at the first condition that fails, before a later fault + let fails_before_the_fault = parse_rule_value(platform_value!({ + "allOf": [{ "equal": ["a", 1] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] + })); + assert_eq!( + fails_before_the_fault.violation(&zero_divisor), + Some(PropertyConstraintViolation::NotMet) + ); +} + +/// Every comparison and logical operator is a node, and the paths are listed in declared +/// order. +#[test] +fn should_count_the_nodes_and_list_the_paths_of_combined_conditions() { + let rule = parse_rule_value(platform_value!({ + "anyOf": [ + { "equal": ["a", 0] }, + { "not": { "equal": [{ "ifAbsent": ["b", 1] }, "a"] } } + ] + })); + // anyOf, equal, a, 0, not, equal, ifAbsent b, a + assert_eq!(rule.node_count(), 8); + assert_eq!(rule.property_paths(), ["a", "b", "a"]); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index 3e43678a3b2..db4f2dc5637 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -168,8 +168,9 @@ pub struct DocumentTypeV2 { pub(in crate::data_contract) creator_reference: Option, /// The rules every created or replaced document must meet, by name, in the /// order they are checked (`propertyConstraints` keyword, protocol version - /// 14): each a comparison of two integer expressions over the document's - /// integer properties. Empty on document types that declare none. The + /// 14): each a condition on the document's integer properties, a comparison + /// of two integer expressions or an `anyOf`, `allOf` or `not` of conditions. + /// Empty on document types that declare none. The /// parser (`apply_property_constraints`) holds every property a rule reads /// to be an integer that is neither transient nor inside a transient object. pub(in crate::data_contract) property_constraints: BTreeMap, diff --git a/packages/rs-dpp/src/errors/consensus/basic/document/document_property_constraint_violated_error.rs b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_constraint_violated_error.rs index f372c00e23f..87706335cce 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/document/document_property_constraint_violated_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/document/document_property_constraint_violated_error.rs @@ -13,7 +13,8 @@ use thiserror::Error; /// Encoded by position in consensus errors: a new reason goes at the end. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, DecodeUntrusted)] pub enum PropertyConstraintViolation { - /// Both sides of the rule evaluate, but they do not compare as it requires. + /// The rule evaluates without a fault but does not hold: its comparison + /// does not, or its `anyOf`, `allOf` or `not` comes out false. NotMet, /// A value the rule reads, or a result it computes on the way, does not fit /// a 128-bit signed integer. @@ -32,7 +33,7 @@ pub enum PropertyConstraintViolation { impl fmt::Display for PropertyConstraintViolation { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { - Self::NotMet => "its two sides do not compare as it requires", + Self::NotMet => "it does not hold", Self::Overflow => "a value it reads or computes does not fit a 128-bit signed integer", Self::DivisionByZero => "it divides by zero", Self::NegativeExponent => "it raises to a negative power", @@ -42,9 +43,9 @@ impl fmt::Display for PropertyConstraintViolation { } /// A created or replaced document breaks a rule of its document type's -/// `propertyConstraints`: the comparison does not hold, or evaluating it -/// overflowed, divided by zero, raised to a negative power or read a value that -/// is not an integer. +/// `propertyConstraints`: the rule does not hold, or evaluating it overflowed, +/// divided by zero, raised to a negative power or read a value that is not an +/// integer. /// /// A pure structure check on document create and replace (protocol version 14): /// it reads the transition alone, so it is a basic error, not a state one. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index 98af6a90cb9..602681ed2e2 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -1,6 +1,7 @@ //! End-to-end coverage for the `propertyConstraints` doctype keyword (protocol //! version 14): a document type names rules its documents' integer properties -//! must meet, each a comparison of two integer expressions. A create or replace +//! must meet, each a comparison of two integer expressions or an `anyOf`, +//! `allOf` or `not` of such conditions. A create or replace //! that breaks one is consensus-rejected with //! `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the rule //! and why, and leaves the stored document untouched. A property the document @@ -37,6 +38,8 @@ mod property_constraints_tests { /// * `boostPower`: `ifAbsent(boost, 1) ^ 20 >= 1`, which overflows for a large boost /// * `depositCoversOrder`: `(price + fee) * quantity <= deposit` /// * `discountBelowPrice`: `discount < price`, an absent discount counting as 0 + /// * `feeWaivedOnlyWithDiscount`: `!(fee == 0 && discount == 0)` + /// * `feeWaivedOrAtLeastTen`: `fee == 0 || fee >= 10` /// * `perUnitDeposit`: `deposit / quantity >= 1`, which divides by zero for no quantity fn offer_schema() -> Value { platform_value!({ @@ -68,6 +71,12 @@ mod property_constraints_tests { ] }, "discountBelowPrice": { "lessThan": ["discount", "price"] }, + "feeWaivedOnlyWithDiscount": { + "not": { "allOf": [{ "equal": ["fee", 0] }, { "equal": ["discount", 0] }] } + }, + "feeWaivedOrAtLeastTen": { + "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] + }, "perUnitDeposit": { "greaterThanOrEqual": [{ "divide": ["deposit", "quantity"] }, 1] } @@ -439,6 +448,44 @@ mod property_constraints_tests { assert!(fixture.stored_offers().is_empty()); } + /// A fee of 5 is neither waived nor at least 10, and a waived fee needs a + /// discount; a waived fee on a discounted offer meets both rules. + #[tokio::test] + async fn should_judge_any_of_all_of_and_not() { + let mut fixture = OfferFixture::new(); + + let result = fixture + .create(|document| document.set("fee", Value::U64(5))) + .await; + expect_violated( + result, + "feeWaivedOrAtLeastTen", + PropertyConstraintViolation::NotMet, + ); + + let result = fixture + .create(|document| document.set("fee", Value::U64(0))) + .await; + expect_violated( + result, + "feeWaivedOnlyWithDiscount", + PropertyConstraintViolation::NotMet, + ); + assert!(fixture.stored_offers().is_empty()); + + // (100 + 0) * 2 = 200 <= 220, and 10 < 100 + assert_matches!( + fixture + .create(|document| { + document.set("fee", Value::U64(0)); + document.set("discount", Value::U64(10)); + }) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 1); + } + #[tokio::test] async fn should_judge_a_replace_against_the_rules() { let mut fixture = OfferFixture::new(); 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 a2a3766c207..cba4b6a73e1 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -53,9 +53,9 @@ pub struct SystemLimits { /// version 14), the only generation that parses `propertyConstraints`, and never /// reached before. pub max_property_constraints: u16, - /// Maximum number of nodes in one `propertyConstraints` rule: its comparison, every - /// arithmetic operator and every operand, an integer value or a property. An `ifAbsent` - /// operand is one node, the default it gives included. Refused under full validation + /// Maximum number of nodes in one `propertyConstraints` rule: every comparison and every + /// `anyOf`, `allOf` or `not`, every arithmetic operator and every operand, an integer + /// value or a property. An `ifAbsent` operand is one node, the default it gives included. Refused under full validation /// only, like `max_property_constraints`. Read by document type parser generation 3 /// (protocol version 14) and never reached before. pub max_property_constraint_nodes: u16, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index fff97b8f6be..0cb85e20854 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1044,21 +1044,30 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// /// 39. **Property constraints**: the doctype-level `propertyConstraints` /// keyword (meta-schema v3, `parse_property_constraints` 0) names rules a -/// document's integer properties must meet, each a comparison (`equal`, -/// `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan`, +/// document's integer properties must meet, each a condition: a comparison +/// (`equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan`, /// `greaterThanOrEqual`) of two integer expressions built from integer /// literals, property paths and `add`, `subtract`, `multiply`, `divide`, -/// `modulo` and `power`. A property the document leaves out counts as 0, -/// or as the value of an `ifAbsent` operand naming it. Arithmetic is exact +/// `modulo` and `power`, or `anyOf` or `allOf` over two or more conditions, +/// or `not` over one. A property the document leaves out counts as 0, or +/// as the value of an `ifAbsent` operand naming it. Arithmetic is exact /// `i128`: `divide` and `modulo` are Euclidean (the remainder is never /// negative), and an overflow, a zero divisor, a negative exponent or a /// value that is not an integer refuses the document rather than wrapping. -/// The parser checks that every path names an integer property that is -/// neither transient nor inside a transient object, and that no operand -/// nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every -/// parse, and under full validation the limits -/// `SystemLimits::max_property_constraints` (16 rules) and -/// `max_property_constraint_nodes` (32 per rule). +/// Conditions are checked in declared order and no further than the +/// outcome needs (`anyOf` stops at the first that holds, `allOf` at the +/// first that fails), a fault in one that is checked refuses the document +/// whatever the others say, and `not` never turns a fault into a pass, so +/// an earlier condition guards a later one. The parser checks that every +/// path names an integer property that is neither transient nor inside a +/// transient object, that every comparison reads a property, that an +/// `anyOf` or `allOf` holds none directly of its own kind, that a `not` +/// holds no `not` directly, and that no condition or operand nests deeper +/// than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every parse, and +/// under full validation the limits `SystemLimits::max_property_constraints` +/// (16 rules) and `max_property_constraint_nodes` (32 per rule, every +/// comparison and logical operator counting as one) and that no `anyOf` or +/// `allOf` lists the same condition twice. /// `DataContract::validate_document_properties` 0 (extended in place, inert /// before this version) calls `validate_property_constraints` /// (`validate_property_constraints` 0) after the schema validation, so diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 6059dd2cda2..16e16f3bcc0 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -255,9 +255,9 @@ impl DocumentMaxBytesErrorCodeWasm { #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum DocumentPropertyConstraintErrorCodeWasm { /// The written document breaks a rule of its document type's - /// `propertyConstraints`: the comparison does not hold, or evaluating it - /// overflowed, divided by zero, raised to a negative power or read a value - /// that is not an integer. + /// `propertyConstraints`: the rule (a comparison, or an `anyOf`, `allOf` or + /// `not` of them) does not hold, or evaluating it overflowed, divided by + /// zero, raised to a negative power or read a value that is not an integer. DocumentPropertyConstraintViolated = 10422, }