From 7aeaa1d7752098f4c35a3ef2c55f3fd8b1e197f3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 11 Oct 2026 13:27:10 +0300 Subject: [PATCH 1/8] feat(platform)!: rules that read the referenced document (PV14) A propertyConstraints rule may read a value of the document a reference of its type points at, { "referenced": [reference, field] }, wherever a path stands. Consensus judges such a rule after the references hold, with the documents their checks read, so a create adds no read; a reference a rule reads through is checked again on every replace, with the same read. A refersTo where entry may hold a condition besides an equality: a const literal, notEqual, in, or an ordering bounded by a referring property, an integer const or a time or height of the referring document. Registration refuses a rule reading what the referenced type can not give (10231 for the declaring contract's types, the new ReferencedDocumentRuleReadInvalidError 40148 for another contract's) and a condition that can not hold (40126). SDK rule listings gain readsReferenced; pre-checks leave such rules unjudged. Book and PV14 notes 39 and 63 updated. Co-Authored-By: Claude Opus 5.5 --- .../contract-keywords/property-constraints.md | 92 +- book/src/contract-keywords/refers-to.md | 66 +- book/src/data-model/documents.md | 5 + book/src/error-handling/error-codes.md | 2 +- packages/js-evo-sdk/README.md | 4 +- .../ui/contracts/DocumentTypeDetailsScreen.kt | 18 +- .../ui/contracts/PropertyConstraints.kt | 23 + .../ui/contracts/PropertyConstraintsTest.kt | 34 + .../dashsdk/ffi/QueriesNative.kt | 17 +- .../queries/DocumentPropertyConstraints.kt | 71 +- .../dashsdk/queries/PlatformQueries.kt | 15 +- .../DocumentPropertyConstraintsTest.kt | 185 +++ .../document/v3/document-meta.json | 151 +- .../v1/mod.rs | 16 +- .../document_type/class_methods/mod.rs | 1 + .../try_from_schema/common/mod.rs | 73 +- .../class_methods/try_from_schema/mod.rs | 1300 ++++++++++++----- .../v3/commit_reveal_lookup_tests.rs | 2 + .../v3/list_element_reference_tests.rs | 2 + .../class_methods/try_from_schema/v3/mod.rs | 10 +- .../v3/owner_reference_tests.rs | 3 + .../v3/reference_expression_tests.rs | 5 + .../v3/reference_lookup_tests.rs | 4 + .../v3/referenced_rule_tests.rs | 816 +++++++++++ .../v3/typed_array_reference_tests.rs | 4 + .../document_type/index/preallocation.rs | 6 + .../document_type/methods/mod.rs | 39 + .../methods/versioned_methods.rs | 33 + .../property/list_element_reference.rs | 5 +- .../document_type/property/mod.rs | 88 ++ .../property/reference_expression.rs | 1 + .../document_type/property/reference_where.rs | 622 ++++++++ .../document_type/property_constraints/mod.rs | 456 ++++-- .../property_constraints/tests.rs | 6 +- packages/rs-dpp/src/errors/consensus/codes.rs | 1 + .../errors/consensus/state/document/mod.rs | 1 + ...renced_document_rule_read_invalid_error.rs | 88 ++ .../src/errors/consensus/state/state_error.rs | 43 +- .../state_v2/mod.rs | 41 +- .../document_reference_validation/mod.rs | 16 + .../document_reference_validation/v0/mod.rs | 298 +++- .../advanced_structure_v0/mod.rs | 1 + .../state_v1/mod.rs | 37 +- .../tests/document/commit_reveal_lookup.rs | 3 + .../document/contract_owner_requirement.rs | 3 + .../tests/document/derived_index_values.rs | 3 + .../tests/document/list_element_reference.rs | 3 + .../batch/tests/document/lookup_reference.rs | 3 + .../batch/tests/document/mod.rs | 1 + .../batch/tests/document/owner_reference.rs | 3 + .../tests/document/reference_expression.rs | 5 + .../batch/tests/document/referenced_rules.rs | 311 ++++ .../contract_user_moderation/state/v0/mod.rs | 1 + .../contract_user_moderation/tests.rs | 1 + .../tests/referenced_rules.rs | 296 ++++ .../v0/mod.rs | 222 ++- .../data_contract_create/mod.rs | 105 ++ ...enced-rules-registration-foreign-enum.json | 54 + ...nced-rules-registration-foreign-valid.json | 63 + ...rules-registration-foreign-wrong-kind.json | 52 + ...rules-registration-where-literal-kind.json | 44 + ...ules-registration-where-ordering-kind.json | 44 + ...-validation-contract-referenced-rules.json | 220 +++ .../rs-platform-version/src/version/v14.rs | 50 + .../src/data_contract/property_constraints.rs | 46 +- .../Utils/DocumentPropertyConstraints.swift | 103 +- .../Models/PersistentDocumentType.swift | 4 +- .../Views/DocumentTypeDetailsView.swift | 31 +- .../SwiftExampleApp/Views/DocumentsView.swift | 4 +- .../DocumentPropertyConstraintsTests.swift | 209 +++ .../src/errors/consensus/consensus_error.rs | 4 + packages/wasm-dpp2/src/consensus_error.rs | 27 +- .../document_type_property_constraints.rs | 116 +- .../data_contract/document_type_reference.rs | 122 +- .../unit/DocumentPropertyConstraints.spec.ts | 94 ++ .../unit/DocumentPropertyReference.spec.ts | 17 +- 76 files changed, 6324 insertions(+), 641 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs create mode 100644 packages/rs-dpp/src/data_contract/document_type/property/reference_where.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/document/referenced_document_rule_read_invalid_error.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/referenced_rules.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/referenced_rules.rs create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-foreign-enum.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-foreign-valid.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-foreign-wrong-kind.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-literal-kind.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-ordering-kind.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules.json diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index c9a2ef89dbf..f0b3b56acdf 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -1,6 +1,6 @@ # propertyConstraints -`propertyConstraints` holds named rules that every created or replaced document of a type must meet. JSON Schema bounds one property at a time; these rules relate properties to each other: a deposit that covers price times quantity, percentages that add up to 100, a closed order that carries its closing time, a second party who is not the owner, a payment address whose first byte names a known type. Each rule is a small tree of comparisons, arithmetic and logic that consensus evaluates against the document. A rule can also read a total of other documents, how many there are or what an integer property adds up to, from the count and sum trees their indexes keep (see [Totals of other documents](#totals-of-other-documents)). +`propertyConstraints` holds named rules that every created or replaced document of a type must meet. JSON Schema bounds one property at a time; these rules relate properties to each other: a deposit that covers price times quantity, percentages that add up to 100, a closed order that carries its closing time, a second party who is not the owner, a payment address whose first byte names a known type. Each rule is a small tree of comparisons, arithmetic and logic that consensus evaluates against the document. A rule can also read a total of other documents, how many there are or what an integer property adds up to, from the count and sum trees their indexes keep (see [Totals of other documents](#totals-of-other-documents)), and a value of the document one of its references points at, which the reference's check reads anyway (see [Values of a referenced document](#values-of-a-referenced-document)). | | | |---|---| @@ -49,12 +49,12 @@ - **Create and replace.** The rules run after the JSON schema validation of the document's properties (and after [maxBytes](max-bytes.md)), so every value a rule reads has passed its property's schema. A replace is judged on the whole new document, not only on what changed. - **Name order, first failure.** Rules are checked in the order of their names, and the first rule the document breaks refuses the transition with `DocumentPropertyConstraintViolatedError` (10422). The error names the document type, the rule, and why it failed (below). -- **Transfer and purchase.** These change only the owner and the transfer's time and heights. Rules that read `$ownerId`, `$transferredAt…` or a total that depends on the owner are judged again, against the stored document with its new owner and transfer values; other rules are not, since nothing they read changed. A transfer or purchase that would break such a rule is refused with 10422. +- **Transfer and purchase.** These change only the owner and the transfer's time and heights. Rules that read `$ownerId`, `$transferredAt…` or a total that depends on the owner are judged again, against the stored document with its new owner and transfer values; other rules are not, since nothing they read changed. A transfer or purchase that would break such a rule is refused with 10422. A rule reading a referenced document is never among them (see [Values of a referenced document](#values-of-a-referenced-document)). - **Price updates** change only the update's time and heights, so the rules that read `$updatedAt…` are judged again the same way; other rules are not. -- **Moderator restores.** Every rule of the restored type is judged on the retained document, with its original owner, times and heights. Totals include the restored document as an insertion into the live trees; its removal record is not counted. Another write while it was deleted may make a cap, lower bound or cross-type prerequisite fail. Uniqueness is checked first. A refusal in a block charges the moderator and consumes its nonce, while the document stays deleted and its removal record stays unrestored. Mempool admission refuses the same invalid restore without persisting fees or a nonce change. Restore does not revalidate the full property schema or judge `deleteConstraints`. +- **Moderator restores.** Every rule of the restored type is judged on the retained document, with its original owner, times and heights, except a rule reading a referenced document: a restore checks no reference. Totals include the restored document as an insertion into the live trees; its removal record is not counted. Another write while it was deleted may make a cap, lower bound or cross-type prerequisite fail. Uniqueness is checked first. A refusal in a block charges the moderator and consumes its nonce, while the document stays deleted and its removal record stays unrestored. Mempool admission refuses the same invalid restore without persisting fees or a nonce change. Restore does not revalidate the full property schema or judge `deleteConstraints`. - **Immutable properties.** The same grammar is the condition of an [`immutable`](mutability.md#immutable) entry, which freezes a property while it holds. Only such a condition may read the stored document, through `$old.`; a rule judges creates too, which have none. - **Deletes** are not judged, with one exception: a delete of an [index-only](index-only.md) document carries the row's values, which are validated like a create's, rules included. The delete carries neither the owner nor any time or height, which is why an index-only type may not have a rule reading `$ownerId` or a system time or height. What may be deleted is the job of [`deleteConstraints`](deletion.md#deleteconstraints), rules in this grammar the stored document must meet for its owner to delete it. -- **State and fees.** A rule reads the document, its owner and its times and heights, and a `countOf` or `sumOf` reads a total from state. Each such total is a state read billed with the write; nothing else a rule does adds a fee, and it changes nothing stored. The limits below bound its cost. SDKs that validate a document before sending it apply the same rules, except those reading a total, which they cannot read. +- **State and fees.** A rule reads the document, its owner and its times and heights, and a `countOf` or `sumOf` reads a total from state. Each such total is a state read billed with the write; nothing else a rule does adds a fee, and it changes nothing stored. A value of a referenced document comes from the read the reference's check makes. The limits below bound its cost. SDKs that validate a document before sending it apply the same rules, except those reading a total or a referenced document, which they cannot read. Why a rule fails, as the error reports it: @@ -122,6 +122,7 @@ An integer expression is one of: | `byteAt` | `{ "byteAt": ["address", 0] }` | The byte, 0 to 255, at an index (counted from 0) of a byte array property, 0 when the array does not hold it or the document leaves it out (see [Byte arrays](#byte-arrays)) | | system time or height | `"$createdAt"`, `"$updatedAtBlockHeight"` | A time or height the document records (see [Times and heights](#times-and-heights)) | | `countOf`, `sumOf` | `{ "countOf": ["listing", { "$ownerId": "$ownerId" }] }` | A total of documents of a type of the same contract, read from state (see [Totals of other documents](#totals-of-other-documents)) | +| `referenced` | `{ "referenced": ["pollId", "endsAt"] }` | A value of the document a reference points at, read as a path is (see [Values of a referenced document](#values-of-a-referenced-document)) | Where `maxLength`, `maxBytes` and `maxItems` bound one property by a fixed number, a size can be compared with another property or bounded only under a condition: `{ "lessThanOrEqual": [{ "count": "tags" }, "maxTags"] }` holds a list to its own limit. A size never breaks a rule by itself: a property left out or null has size 0, and so would a value of another type, which the schema validation refuses first. @@ -132,7 +133,7 @@ Two more forms appear only in string and identifier comparisons, never inside ar | `{ "const": "closed" }` | A string constant, or, compared with an identifier property or `$ownerId`, a base58 identifier, or, tested against a byte array property, hex digits | | `{ "ifAbsent": ["status", "open"] }` | A string property, read as the given string when the document leaves it out | -A bare JSON string is always a path and a bare JSON number always a value, so a constant string needs `{ "const": ... }`. The values an `in` lists are literals and need no wrapper. A path is a property name, or names joined by dots for a nested property (`"rewardSplit.leader"`); the only `$` names a rule accepts are `$ownerId`, the times and heights below, and, as the value a [total's filter](#totals-of-other-documents) matches by, `$id`. +A bare JSON string is always a path and a bare JSON number always a value, so a constant string needs `{ "const": ... }`. The values an `in` lists are literals and need no wrapper. A path is a property name, or names joined by dots for a nested property (`"rewardSplit.leader"`); the only `$` names a rule accepts are `$ownerId`, the times and heights below, and, as the value a [total's filter](#totals-of-other-documents) matches by, `$id`. A `{ "referenced": [reference, field] }` stands wherever a path does. A `number` property (a float) cannot be read by a rule, which keeps every result exact. @@ -232,6 +233,38 @@ A filter maps each key, a property of the counted type or `$ownerId`, to the val - **Every earlier write counts.** A document batch carries one transition, and each state transition of a block is applied before the next is validated, so a total includes every write before it. - **SDK pre-checks** cannot read state, so they do not judge a rule reading a total. +## Values of a referenced document + +`{ "referenced": [reference, field] }` reads a value of the document a reference of the type points at: `reference` names a top-level property of the type carrying a [`refersTo`](refers-to.md), and `field` a value of the document it finds. It stands wherever a path stands, and reads that value as a path reads a property of the type: as an integer operand, a side of a string or identifier comparison, the operand of an `in`, the path of an `ifAbsent`, `present` or `absent`, a size, a `byteAt`, a `startsWith` or `endsWith`, or either side of a `contains`. It is one node. + +```json +"propertyConstraints": { + "atLeastReserve": { + "greaterThanOrEqual": ["amount", { "referenced": ["auctionId", "reservePrice"] }] + }, + "deliveredOrder": { + "equal": [{ "referenced": ["orderId", "status"] }, { "const": "delivered" }] + }, + "memberWrites": { "contains": [{ "referenced": ["groupId", "members"] }, "$ownerId"] } +} +``` + +A bid meets its auction's reserve price, a review names an order that was delivered, and only a member of a group writes to it. + +- **The field** is a property of the referenced type by its dotted path, its `$id`, `$ownerId` or `$creatorId` (compared as identifiers, `$creatorId` on a type that records it), or a time or height it lists in `required` (`"$createdAt"`, read as an integer). +- **Absence** reads as a property of the type left out does: an integer reads 0 (or an `ifAbsent` default), a string or an identifier equals nothing, `present` does not hold. A reference the document leaves out leaves every value read through it absent. +- **Which comparison.** A rule names another type's property, maybe another contract's, so how it compares follows the rule: as a string beside a `const` or a string property, or in an `in` listing strings; as an identifier beside an identifier property or `$ownerId`; as an integer otherwise. Registration checks the property is of that kind. So a referenced identifier property is compared with an identifier property of the type or `$ownerId`; a literal identifier is a `where` [`const`](refers-to.md#conditions) instead. + +**Which references.** The reference holds one document: a `permanentDocument` or `deletableDocument` reference found by id or by a [`findBy`](refers-to-lookup.md) without a function, or a `moderatedDocument` one, into this contract or another. Not the elements of a typed array, an `anyOf` or `allOf`, an `inList`, or a `findBy` function, which is judged on the create alone. Through a `moderatedDocument` reference a rule reads only `$id`, `$ownerId` and the fields the referenced type keeps under [`deleteKeepsFields`](deletion.md#moderatorabilitiesdeletekeepsfields): once a moderator removes the document its [removal record](../data-model/contract-moderation.md#deleting-documents) is all there is to read, and a rule is judged on every replace. + +**When it is judged.** A rule reading a referenced document is judged once the type's references hold, with the documents their checks read, after the type's other rules; a missing target refuses the write with `ReferencedEntityNotFoundError` (40120) before it. A broken rule is `DocumentPropertyConstraintViolatedError` (10422), as any rule. Every replace judges every rule, so a reference a rule reads through is [checked again on every replace](refers-to.md#on-replace), its `where` included, and the rule reads the document as it is at the replace: a `permanentDocument` target replaced since is read with its new values. Each such check costs the read the reference's first check made, billed the same way. A kept `moderatedDocument` reference whose document a moderator removed is read from the removal record. + +Transfers, purchases, price updates, a moderator's change of fields and a moderator's restore check no reference, and do not judge such a rule. Registration keeps that sound: on a type whose documents can be transferred or traded, a rule reading a referenced document may not read what those writes change (`$ownerId`, `$transferredAt` and its heights, a total depending on the owner, and on a tradeable type `$updatedAt` and its heights), and it may not read a field the type keeps for its [moderators to change](moderator-abilities.md#changefields). + +**Time against a referenced value.** A create records its block for every time and height; a replace keeps the stored creation and transfer and records its block as the update. So `$createdAt` below a referenced `endsAt` holds the create to the deadline and lets a later replace through, while `$updatedAt` holds every write to it: a vote that may be changed only while its poll is open reads `$updatedAt`. On a type whose documents are never replaced the two are the same. + +**SDK pre-checks** cannot read the referenced document, so they do not judge such a rule, and list what it reads in `readsReferenced`. + ## How many of a group `countPresent` counts the properties of a group that the document holds, each as `present` tests it, so a rule compares the count with a number. A seller is reached by exactly one of three contacts: @@ -281,7 +314,7 @@ The meta-schema checks the shape (`JsonSchemaError`, 10101): - every condition and every operator object has exactly one key; - a comparison, `subtract`, `divide`, `modulo` and `power` take exactly two operands; `add` and `multiply` two or more; `anyOf` and `allOf` two or more conditions, no two alike; an `in` two or more distinct values, all integers or all strings; a `countPresent` two or more distinct paths; - no `anyOf` or `allOf` holds its own kind directly, and no `not` holds a `not` or a `notIn`; -- a path matches `$ownerId`, one of the nine [times and heights](#times-and-heights), or dotted names of 1 to 64 letters, digits or underscores, so `$revision` and other system properties are refused; +- a path matches `$ownerId`, one of the nine [times and heights](#times-and-heights), or dotted names of 1 to 64 letters, digits or underscores, so `$revision` and other system properties are refused; a `referenced` lists a property name, then a dotted path, `$id`, `$ownerId`, `$creatorId` or a time or height; - a `byteAt` lists a path and an integer index from 0 to 65535; - a `countOf` lists a type name and optionally a filter, and a `sumOf` a type name, a property and optionally a filter; a filter has one or more keys, each `$ownerId` or a dotted path, and each value is a path, `$ownerId`, `$id`, an integer or a `{ "const": ... }` string. @@ -299,7 +332,9 @@ The parser then checks the rules against the document type (`InvalidContractStru - no condition or operand nests more than 64 levels deep; - once every document type of the contract is parsed, every `countOf` and `sumOf` counts a type of the contract that is not index-only, and not its own type when that has a contested index, with a tree that keeps the total as set out in [Totals of other documents](#totals-of-other-documents). A unique, contested, ranked, time-range, integer-range or index-only-terminal index keeps no such total, nor does one with more properties than the filter has keys; - every key of a filter is `$ownerId` or an integer, string or identifier property of the counted type, and its value is of the same kind (`$ownerId` and `$id` are identifiers); a string constant is in the key's `enum` when it has one, and an identifier constant is base58; -- every property a filter value reads is listed in `required`, with every object around it, so a write always has the value; an index-only type has no rule reading a total. +- every property a filter value reads is listed in `required`, with every object around it, so a write always has the value; an index-only type has no rule reading a total; +- a `referenced` reads through a top-level property of the type holding one reference to one document, as set out in [Values of a referenced document](#values-of-a-referenced-document), only in `propertyConstraints` (not `deleteConstraints`, an `immutable` condition or `retractedWhen`, whose writes check no reference), not on an index-only type, not beside what a transfer, purchase or price update of the type changes, and not beside a field the type keeps for its moderators to change; +- once every document type of the contract is parsed, what a rule reads of a referenced type of the same contract is a field that type gives it: one it has, stored, of the kind the rule reads, a time or height it records, a `$creatorId` it records, a constant its `enum` lists, and through a `moderatedDocument` reference a field its removal record keeps. A referenced type of another contract is checked at registration against the stored contract, refused with `ReferencedDocumentRuleReadInvalidError` (40148). Three limits come from the protocol version 14 `SystemLimits`, and a rule over one is refused the same way: @@ -325,6 +360,7 @@ A rule within 32 nodes is never deep enough to reach the 64-level bound. Nodes a | `countPresent` | 1, plus 1 per path | | `add`, `multiply`, `subtract`, `divide`, `modulo`, `power`, `min`, `max`, `abs` | 1, plus their operands | | `countOf`, `sumOf` | 1, plus 1 per filter key | +| `referenced` | 1, as a path | `depositCoversOrder` above is 7 nodes (the comparison, `multiply`, `add` and four paths), and `closedNeedsClosedAt` is 5. An `in` fits up to 30 values in 32 nodes. @@ -453,6 +489,47 @@ Four nodes: the `in`, the `byteAt` and two values. An address starting with `0x0 `startsWith` is the one to reach for when a run of bytes is checked: `{ "startsWith": ["content", { "const": "1220" }] }` holds a multihash of a SHA-256 digest in 3 nodes, where two `byteAt` comparisons in an `allOf` take 7. +**A vote on a poll.** A single-choice ballot names its poll and an option by index. The poll holds two to three options, may close at `endsAt`, and may take several choices from one voter (`multiChoice`): + +```json +"vote": { + "type": "object", + "documentsMutable": false, + "properties": { + "pollId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "deletableDocument", "documentType": "poll" }, + "position": 0 + }, + "choice": { "type": "integer", "minimum": 0, "maximum": 9, "position": 1 } + }, + "required": ["pollId", "choice", "$createdAt"], + "propertyConstraints": { + "castBeforeTheEnd": { + "lessThan": [ + "$createdAt", + { "ifAbsent": [{ "referenced": ["pollId", "endsAt"] }, 9007199254740991] } + ] + }, + "singleChoicePoll": { "equal": [{ "referenced": ["pollId", "multiChoice"] }, 0] }, + "choiceIsAnOption": { + "lessThan": [ + "choice", + { "countPresent": [ + { "referenced": ["pollId", "option0"] }, + { "referenced": ["pollId", "option1"] }, + { "referenced": ["pollId", "option2"] } + ] } + ] + } + }, + "additionalProperties": false +} +``` + +A vote lands before the poll closes (one without `endsAt` stays open), only on a poll taking one choice (`multiChoice` left out reads as 0), and for an option the poll holds, its options filled from the first. The three rules read the poll the reference check fetched, so they add no read. A vote is never replaced, so `$createdAt` and `$updatedAt` are the same here. + **A flag in arithmetic.** A boolean reads as 1 or 0, so a waived fee must be 0: ```json @@ -465,6 +542,7 @@ Four nodes: the `in`, the `byteAt` and two values. An address starting with `0x0 - [Property Constraints](../data-model/documents.md#property-constraints-propertyconstraints), the deep dive - [deleteConstraints](deletion.md#deleteconstraints), rules in this grammar judged on the owner's delete +- [References (refersTo)](refers-to.md), whose `where` checks the referenced document with an equality, a literal, an inequality, a list or an ordering, each when the reference is checked - [distinctFrom](distinct-from.md), a single-keyword way to keep two identifiers apart - [Property Schemas](property-schemas.md), for the one-property bounds JSON Schema gives - [transient](transient.md), [Index-Only Types](index-only.md) diff --git a/book/src/contract-keywords/refers-to.md b/book/src/contract-keywords/refers-to.md index 7fd0f14d469..531eb07db9f 100644 --- a/book/src/contract-keywords/refers-to.md +++ b/book/src/contract-keywords/refers-to.md @@ -104,7 +104,7 @@ The same for a type whose documents disappear only when the contract's moderator A reply to a `post` its author can not delete, which the moderators can take down. The post must exist when the reply is written. Once a moderator removes it, the reply keeps pointing at it, and a replace of the reply is checked as if the reference were permanent: an edit of the reply's text passes, and the reference now resolves to the removal record. What a replace can not do is point a reply at a removed post, or write a new reply to one: a value the write sets must name a document in state. -A removed document has no values left but those its record keeps: its id, its owner, and the fields its type lists under [`moderatorAbilities.deleteKeepsFields`](deletion.md#moderatorabilitiesdeletekeepsfields). A `where` entry the replace checks again (its referring property changed, or it is a writer gate valued `"$ownerId"`) is checked against the record when it compares the referenced `$ownerId`, `$id` or a property the type keeps (a kept path, or one inside a kept object), with the same absence rule as against a document in state, and refused with `ReferencedDocumentRemovedError` (40145) when it compares any other property; the replace can repoint the reference at a document in state, or leave that property unchanged until the post is restored. A writer gate (a `where` entry valued `"$ownerId"`) is checked on every replace, so on a `moderatedDocument` reference it may compare only the referenced `$ownerId` or `$id` (`InvalidContractStructure`, 10231, otherwise), the only values sure to be in every record: a gate on anything else could refuse every replace once the post is removed. The value is kept as long as it is the one the stored document held, whatever else the replace changed in the same object or list. The value is always the document's id: `findBy`, `inList` and operands of an [expression](refers-to-expressions.md) are refused, since a removal frees the unique index keys a `findBy` finds by. +A removed document has no values left but those its record keeps: its id, its owner, and the fields its type lists under [`moderatorAbilities.deleteKeepsFields`](deletion.md#moderatorabilitiesdeletekeepsfields). A `where` entry the replace checks again (its referring property changed, or it is a writer gate valued `"$ownerId"`) is checked against the record when it compares the referenced `$ownerId`, `$id` or a property the type keeps (a kept path, or one inside a kept object), with the same absence rule as against a document in state, and refused with `ReferencedDocumentRemovedError` (40145) when it compares any other property; the replace can repoint the reference at a document in state, or leave that property unchanged until the post is restored. A writer gate (a `where` entry valued `"$ownerId"`, or a `notEqual` bounded by it) is checked on every replace, so on a `moderatedDocument` reference it may compare only the referenced `$ownerId` or `$id` (`InvalidContractStructure`, 10231, otherwise), the only values sure to be in every record: a gate on anything else could refuse every replace once the post is removed. An ordering bounded by `$updatedAt` or `$transferredAt` (or one of their heights) is checked on every replace too, and compares only a property the type keeps (40126 otherwise). A [rule reading the post](property-constraints.md#values-of-a-referenced-document) reads only its `$ownerId`, its `$id` and the properties its type keeps, for the same reason. The value is kept as long as it is the one the stored document held, whatever else the replace changed in the same object or list. The value is always the document's id: `findBy`, `inList` and operands of an [expression](refers-to-expressions.md) are refused, since a removal frees the unique index keys a `findBy` finds by. A chained or composite query that joins through a moderated reference proves each removed document's record beside the documents it joins, and reports the removal with it. @@ -163,10 +163,10 @@ The contract holding `documentType`, as a base58 string or an array of 32 bytes. ### `where` -States what the referenced document must hold once found: each entry `{ "": "" }` must hold as an equality when the referring document is written. It takes 1 to 10 entries, on `permanentDocument`, `moderatedDocument` and `deletableDocument` references. `where` never finds the document: the value does, as its id, or [`findBy`](refers-to-lookup.md) does. `where` is checked against the document found. +States what the referenced document must hold once found. It takes 1 to 10 entries, on `permanentDocument`, `moderatedDocument` and `deletableDocument` references, each keyed by a property of the referenced document. An entry valued with a referring value, `{ "": "" }`, must hold as an equality when the referring document is written; an entry valued with an object holds a literal, an inequality, a list or an ordering instead (see [Conditions](#conditions)). `where` never finds the document: the value does, as its id, or [`findBy`](refers-to-lookup.md) does. `where` is checked against the document found. - **The key** is the referenced side: a property of the referenced document type, a dotted path for a nested one, or one of the referenced document's own identifiers: `$ownerId` (its current owner, which follows it through transfers), `$creatorId` (its creator, which never changes, on types that record it, see [System Properties](system-properties.md)) or `$id` (its id). A system name needs an identifier on the referring side. -- **The value** is the referring side: a property of the declaring document type, a dotted path for a nested one, or `"$ownerId"`, the writer. An entry whose value is `"$ownerId"` is a write gate: only an identity whose id equals the referenced side may create or replace the document. A referring value may appear once in `where`. +- **The value** is the referring side: a property of the declaring document type, a dotted path for a nested one, or `"$ownerId"`, the writer. An entry whose value is `"$ownerId"` is a write gate: only an identity whose id equals the referenced side may create or replace the document. A referring value may appear once among the equalities. - **Absence counts.** Both sides absent agree; one side absent is a mismatch, as a different value is. - **Values, not keys.** The two sides compare as values of their type: strings as text (so `""` is not `"\0"`), byte arrays and identifiers as bytes (an identifier equals the same 32 bytes, however it is carried), integers as numbers whatever width they are carried at, floats exactly, bit for bit (so `-0.0` is not `0.0`). A value of any length compares, past the 255 bytes of an index key too. - The comparison reads the document already fetched for the existence check, so it costs nothing more. An entry that does not hold refuses the write with `ReferencedDocumentPropertyMismatchError` (40127); no document found is still `ReferencedEntityNotFoundError` (40120). @@ -199,7 +199,42 @@ WHERE $id = :submittedCharterId -- the value, an id reference AND targetContractId = :targetContractId -- where ``` -At registration both sides must exist and hold the same type of value (for integers, the same stored integer type). Neither side may be an object or a typed array, the referring side may not be the reference property itself, and the referenced side may not be `transient` (no stored document carries it; the referring side may be). An entry through which a [`preallocated`](index-only.md#preallocated) index is keyed needs a referenced property whose every value fits an index key, at most 255 bytes: a string of at most 63 characters or with `maxBytes` at most 255, or a byte array of at most 255 bytes. An entry breaking one of these is refused with `ReferencedDocumentPropertyAgreementInvalidError` (40126). +At registration both sides must exist and hold the same type of value (for integers, the same stored integer type). Neither side may be an object or a typed array, the referring side may not be the reference property itself, and the referenced side may not be `transient` (no stored document carries it; the referring side may be). An entry through which a [`preallocated`](index-only.md#preallocated) index is keyed needs a referenced property whose every value fits an index key, at most 255 bytes: a string of at most 63 characters or with `maxBytes` at most 255, or a byte array of at most 255 bytes. A condition's referenced side must hold a single value a literal is compared with (an integer, a boolean, a string, an identifier or a byte array, not a float), every literal must read as its kind, a `notEqual` compares two values of one kind (the writer an identifier), and an ordering compares an integer with an integer bound, a time or height the declaring type records. An entry breaking one of these is refused with `ReferencedDocumentPropertyAgreementInvalidError` (40126), whose referring property names the condition as written. + +#### Conditions + +An entry may hold an object with one key instead of a referring value: + +| Entry | Holds when the referenced side | +|---|---| +| `{ "const": v }` | equals the literal `v`. A side the document leaves out equals none | +| `{ "notEqual": b }` | differs from `b`: a property of the declaring type, `"$ownerId"` (the writer) or a `{ "const": v }`. It is the negation of an equality, so two absent sides are equal and one absent side differs | +| `{ "in": [v1, v2, ...] }` | equals one of two to thirty distinct literals of one kind. A side left out is in none | +| `{ "greaterThan": b }`, `{ "greaterThanOrEqual": b }`, `{ "lessThan": b }`, `{ "lessThanOrEqual": b }` | is an integer that compares with `b` this way: an integer property of the declaring type, an integer `{ "const": n }`, or a time or height the declaring type lists in `required` (`"$createdAt"`, `"$updatedAtBlockHeight"`, ...). It holds only when both sides have a value | + +A literal is an integer, `true` or `false`, or a string, and it reads as the referenced property it is compared with: a string for a string property, a base58 identifier for an identifier (or `$ownerId`, `$creatorId`, `$id`), hex digits for a byte array (two a byte). The rule that a referring value appears once applies to the equalities only, so `"minPrice": { "lessThanOrEqual": "amount" }, "maxPrice": { "greaterThanOrEqual": "amount" }` bounds a bid by both ends of a range. + +```json +"auctionId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "auction", + "where": { + "status": { "in": ["open", "extended"] }, + "reservePrice": { "lessThanOrEqual": "amount" }, + "endsAt": { "greaterThan": "$updatedAt" }, + "$ownerId": { "notEqual": "$ownerId" } + } + }, + "position": 0 +} +``` + +A bid names an auction that is open or extended, whose reserve its `amount` meets, that has not ended at the block of the write (the bid type lists `$updatedAt` in `required`), and that its writer does not own. A time or height reads as the referring document records it: a create's block for all of them, and on a replace the stored creation and transfer and the replace's block as the update. `$createdAt` and its heights never change, while `$updatedAt` and `$transferredAt` and their heights do without the referring document changing (a replace, a transfer), so an entry bounded by one of them is checked again on every replace, as a writer gate is, and so is a `notEqual` bounded by `"$ownerId"`. + +Conditions are checked when the reference is, against the same document, with the same refusal, 40127, whose referring property names the condition as written (`{ "in": ["extended", "open"] }`). They work on every form of document reference: the elements of a typed array, a leaf of an [expression](refers-to-expressions.md), [`findBy`](refers-to-lookup.md) and [`inList`](refers-to-list-element.md). They never key a [`preallocated`](index-only.md#preallocated) index. Beside a `findBy` function, the property a condition is bounded by must be fixed once written or transient, as an equality's referring property must. The keyword `propertyAgreement`, whose pairs were keyed the other way (`{ "": "" }`), was replaced by `where` before protocol version 14 shipped. A declaration still carrying it is refused on every parse, with a message saying to key `where` by the referenced document's property. `"propertyAgreement": { "recipientId": "$ownerId" }` is now `"where": { "$ownerId": "recipientId" }`, and a symmetric pair such as `"$ownerId": "$ownerId"` reads the same. @@ -274,8 +309,9 @@ A replace checks a reference again only when its outcome could have changed: |---|---| | `identity`, `token`, `contract` | the value changed | | `contract` with an `owner` requirement, on a type whose documents can be transferred or traded | every replace | -| `permanentDocument`, `moderatedDocument` | the value changed, or the referring value of a `where` entry changed | -| any document reference with a `where` entry whose value is `"$ownerId"` | every replace | +| `permanentDocument`, `moderatedDocument` | the value changed, or a referring property a `where` entry reads changed (an equality's referring side, a condition's bound) | +| any document reference with a `where` entry whose value is `"$ownerId"`, a `notEqual` bounded by `"$ownerId"`, or an ordering bounded by `$updatedAt`, `$transferredAt` or one of their heights | every replace | +| a document reference a `propertyConstraints` rule reads through ([`referenced`](property-constraints.md#values-of-a-referenced-document)) | every replace: every replace judges every rule, and the rule reads the document as it is at the replace | | `permanentDocument` with `findBy`, `inList` included | also when a property `findBy` reads changed | | `deletableDocument`, by id or with `findBy` | every replace | | a document reference with a `findBy` function | never: the whole declaration, its `where` included, is judged on the create alone (see [Commit and reveal](refers-to-lookup.md#commit-and-reveal)); a type whose documents are never replaced has no replace at all | @@ -285,15 +321,15 @@ A replace checks a reference again only when its outcome could have changed: | key id with an identity property path | the key id or that property changed | | `anyOf` or `allOf` | one of its operands would be checked again; the whole expression is then checked | -A value changed when the replace set it differently, added it or removed it. Changes are tracked per top-level property, so a change anywhere in an object checks again every reference inside that object. When a typed array changed, only the elements the stored list did not hold are checked, unless a rule above that applies to every element does (the referring value of a `where` entry changed, a `where` entry valued `"$ownerId"`, an `owner` requirement on such a type, or a `deletableDocument` target); then every element is checked. The rules for `ownerRefersTo` and `creatorRefersTo` are in [Writer and Creator References](owner-refers-to.md). +A value changed when the replace set it differently, added it or removed it. Changes are tracked per top-level property, so a change anywhere in an object checks again every reference inside that object. When a typed array changed, only the elements the stored list did not hold are checked, unless a rule above that applies to every element does (a referring property a `where` entry reads changed, a `where` entry checked on every replace, an `owner` requirement on such a type, or a `deletableDocument` target); then every element is checked. A reference checked again is checked whole: its existence and every `where` entry, against the document as it is at the replace. The rules for `ownerRefersTo` and `creatorRefersTo` are in [Writer and Creator References](owner-refers-to.md). -The "every replace" rows exist because something the reference depends on can change without a write to the referring document: the writer after a transfer or purchase, or the target's existence for a deletable one. A moderated target that a moderator removed is not one of them: the reference resolves to its removal record, as described [above](#moderateddocument). +The "every replace" rows exist because something the reference depends on can change without a write to the referring document: the writer after a transfer or purchase, the time of the write, the target's existence for a deletable one, or, for a reference a rule reads through, the target's own values (a `permanentDocument` target may be replaced since). Checking such a reference again costs the read its first check made, billed the same way: by id, or through the `findBy` index. A moderated target that a moderator removed is not one of them: the reference resolves to its removal record, as described [above](#moderateddocument). ### Transfers, purchases, deletes and restores -- A transfer or a purchase checks no reference. A reference governs writing, not holding: a new owner meets the writer gates (a `where` entry valued `"$ownerId"`, `identityProperty: "$ownerId"`, an `owner` requirement) on their first replace. +- A transfer or a purchase checks no reference. A reference governs writing, not holding: a new owner meets the writer gates (a `where` entry valued `"$ownerId"`, `identityProperty: "$ownerId"`, an `owner` requirement) on their first replace. Nor does it judge a rule reading a referenced document; registration keeps such a rule, on a type whose documents can be transferred or traded, from reading what a transfer, a purchase or a price update changes. - Deleting a referring document checks nothing. Deleting a referenced document does not look for documents referring to it: a `permanentDocument` target cannot be deleted at all, a `moderatedDocument` target is removed by a moderator on the record the reference then resolves to, and a `deletableDocument` reference meets its missing target on the referring document's next replace. A `deletableDocument` target can still refuse its owner's delete while documents refer to it: a [`deleteConstraints`](deletion.md#deleteconstraints) rule counting them by the target's own id, `{ "equal": [{ "countOf": ["vote", { "pollId": "$id" }] }, 0] }`, needs a countable index on the referring property, and holds the target in place until the last of them is gone. -- A document a moderator removed and later restores comes back as it was, without its references being checked again (see [Restoring Documents](../data-model/contract-moderation.md#restoring-documents)). +- A document a moderator removed and later restores comes back as it was, without its references being checked again (see [Restoring Documents](../data-model/contract-moderation.md#restoring-documents)), and without the rules reading a referenced document judged. A moderator's change of the fields a type keeps for its moderators judges none of them either, and a rule reading a referenced document may not read such a field. ## The reference budget @@ -314,7 +350,7 @@ A contract's declarations are checked when it is registered, and again for the w - A declaration holds `type` and the keys its target takes, or a single `anyOf` or `allOf`. - A referenced `documentType` must exist (`ReferencedDocumentTypeNotFoundError`, 40121). The three document references are disjoint, each type admitting exactly one: its documents must never disappear for `permanentDocument` (`ReferencedDocumentTypeDeletableError`, 40122), disappear only through a moderator's recorded removal for `moderatedDocument` (`ReferencedDocumentTypeNotModeratedError`, 40143), and be able to disappear otherwise for `deletableDocument` (`ReferencedDocumentTypeNotDeletableError`, 40131, for a type whose documents never disappear; `ReferencedDocumentTypeModeratedError`, 40144, for one whose documents only moderators remove on the record). The last is refused at registration only: a `deletableDocument` reference promises less than such a type keeps, and a contract registered on a network before `moderatedDocument` existed may hold one, which stays writable. A `permanentDocument` with `inList` whose list is in the declaring contract is the exception: the parser checks its type and reports a deletable one as `InvalidContractStructure` (10231). - A reference that finds its document by the document's id (`permanentDocument`, `moderatedDocument` or `deletableDocument` without `findBy`, or with `inList`, whose list's document is read by its id) may not name an [`indexOnly`](index-only.md) type (`ReferencedDocumentTypeIndexOnlyError`, 40146): its documents exist only as index entries and can not be fetched by `$id`, so no write could check the reference. This holds for a type of the declaring contract and of another one alike. A `findBy` into such a type is refused by its own rules instead, since the type has no unique index. -- Every `where` entry must be one that can hold (40126), every key reference must fit the document type (40125), every `boundTo` must name a type a key can be bound to (10231), and every [`findBy`](refers-to-lookup.md#rules-at-registration) and [list](refers-to-list-element.md#rules-at-registration) must resolve. +- Every `where` entry must be one that can hold (40126), every key reference must fit the document type (40125), every `boundTo` must name a type a key can be bound to (10231), and every [`findBy`](refers-to-lookup.md#rules-at-registration) and [list](refers-to-list-element.md#rules-at-registration) must resolve. A rule reading a document of another contract's type through a reference must read what that type gives (40148). - An `immutable` property may not hold a `deletableDocument` reference that a replace could not remove: one inside an object, a typed array of them, or any `deletableDocument` found by `findBy`, except one whose key a `findBy` function computes, which is checked on the create alone. A single `deletableDocument` reference by id that is itself an immutable top-level property is allowed, but only listed without a condition: a replace the condition left free could set it to another document once it was cleared. A `contract` reference with an `owner` requirement may not sit under an immutable property of a type whose documents can be transferred or traded. All refused with `InvalidContractStructure` (10231); see [Mutability](mutability.md). - The type stays within the [reference budget](#the-reference-budget). - The earlier spellings `lookup`, `propertyAgreement` and `type: "listElement"`, replaced by `findBy`, `where` and `inList` before protocol version 14 shipped, are refused: by the meta-schema on registration and update (10101), and by the parser on every parse (10231), with a message naming what replaced each. @@ -330,8 +366,8 @@ A contract's declarations are checked when it is registered, and again for the w | `ReferencedIdentityKeyNotFoundError` | 40123 | Write: the identity has no key with that id, or the identity does not exist. | | `ReferencedIdentityKeyDisabledError` | 40124 | Write: the key is disabled. | | `ReferencedKeyIdPropertyInvalidError` | 40125 | Registration: `keyIdProperty` or `identityProperty` names a property that does not fit, `$creatorId` on a type that records no creator ids, or a stored key id paired with a transient identity. Write: a key id without its identity, or an identity without its key id. | -| `ReferencedDocumentPropertyAgreementInvalidError` | 40126 | Registration: a `where` entry names a missing, transient, object or typed array property, or two properties of different types, or `$creatorId` on a type that does not record it, or keys a preallocated index by a property that can hold more than 255 bytes. | -| `ReferencedDocumentPropertyMismatchError` | 40127 | Write: the document found does not meet a `where` entry. | +| `ReferencedDocumentPropertyAgreementInvalidError` | 40126 | Registration: a `where` entry names a missing, transient, object or typed array property, or two properties of different types, or `$creatorId` on a type that does not record it, or keys a preallocated index by a property that can hold more than 255 bytes; or a [condition](#conditions) holds a literal of another kind than its property, compares two values of different kinds, orders something that is no integer, or is bounded by a time or height the declaring type does not record. | +| `ReferencedDocumentPropertyMismatchError` | 40127 | Write: the document found does not meet a `where` entry; for a condition, the referring property is the condition as written. | | `ReferencedDocumentTypeNotDeletableError` | 40131 | Registration: a `deletableDocument` reference names a type whose documents can never disappear. | | `ReferencedContractRequirementNotMetError` | 40135 | Write: the referenced contract exists but does not meet a `contractRequirements` entry. | | `ReferencedIdentityKeyRequirementNotMetError` | 40136 | Write: the key exists and is enabled but does not meet a `keyRequirements` entry. | @@ -341,8 +377,9 @@ A contract's declarations are checked when it is registered, and again for the w | `ReferencedDocumentTypeModeratedError` | 40144 | Registration: a `deletableDocument` reference names a type whose documents disappear only through a moderator's recorded removal. | | `ReferencedDocumentRemovedError` | 40145 | Write: a replace kept a `moderatedDocument` reference whose document a moderator removed, and a `where` entry checked again compares a property the removal record does not keep. | | `ReferencedDocumentTypeIndexOnlyError` | 40146 | Registration: a reference that finds its document by id (no `findBy`, or with `inList`) names an `indexOnly` type, whose documents can not be fetched by id. | +| `ReferencedDocumentRuleReadInvalidError` | 40148 | Registration: a [`propertyConstraints` rule](property-constraints.md#values-of-a-referenced-document) reads, through a reference into another contract's document type, a field that type can not give it. A rule reading a type of the declaring contract is refused by the contract parse instead (10231). | -The registration errors name the declaration as `.`, `.[]` for typed array elements, `.$ownerId` or `.$creatorId` for the writer and creator references, and add the operand for a leaf of an expression (`resignation.memberId.anyOf[1]`). When a document is written, the referenced document type is looked up and its kind checked again as a safeguard (40121, 40122, 40131, 40143), but a registered contract cannot fail those checks later: contracts and document types are never removed, and neither the deletion flags nor `ttl` nor `moderatorAbilities` can change. Nor can `indexOnly`, so the indexOnly rule (40146) is checked at registration only. The other codes in the range (40128 to 40130, 40132 to 40134, 40139 to 40142) belong to other keywords. See [Error Codes](../error-handling/error-codes.md). +The registration errors name the declaration as `.`, `.[]` for typed array elements, `.$ownerId` or `.$creatorId` for the writer and creator references, and add the operand for a leaf of an expression (`resignation.memberId.anyOf[1]`). When a document is written, the referenced document type is looked up and its kind checked again as a safeguard (40121, 40122, 40131, 40143), but a registered contract cannot fail those checks later: contracts and document types are never removed, and neither the deletion flags nor `ttl` nor `moderatorAbilities` can change. Nor can `indexOnly`, so the indexOnly rule (40146) is checked at registration only. The other codes in the range (40128 to 40130, 40132 to 40134, 40139 to 40142, 40147) belong to other keywords. See [Error Codes](../error-handling/error-codes.md). ## More forms of reference @@ -359,4 +396,5 @@ The registration errors name the declaration as `.`, `.` (`REFERENCED_DOCUMENT_PREFIX`), so the evaluation reads it as any path; `DocumentSystemValues::referenced_documents` says whether the data holds those values, and `PropertyConstraint::violation` leaves a rule reading one unjudged without them, which is how the structure validation, transfers, purchases, price updates, moderator restores and changes of fields, and every SDK pre-check pass over it. The reference validation (`validate_document_references`, generation 0, extended in place) checks every reference a rule reads through on every replace (`referenced_fields` lists them), and, once each holds, records the values the rules read of the document it found, or of the removal record a kept `moderatedDocument` reference resolves to (`referenced_document_values`); the state validation then judges those rules alone (`DocumentTypeV0Methods::validate_referencing_property_constraints`) against the document's data with the values under `$referenced` (`data_with_referenced_documents`). No read is added: a create reads each referenced document once, as the existence check always did, and a replace makes the check's read again, billed the same way. Registration checks the declaring side when the type is parsed (`validate_referencing_rules`) and what a rule reads of a referenced type with that type in hand (`referenced_reads_error`): in the contract parse for a type of the same contract (10231), in contract create and update state validation for another contract's (`ReferencedDocumentRuleReadInvalidError`, 40148). + In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, an `in`, a string comparison (`TextCompare`, `TextIn`), a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and how: by value, by presence, by size (`Length`, `Count`) or in a comparison of strings or identifiers, `system_reads` the system times and heights it reads, and `aggregate_reads` the totals, each an `AggregateRead`), each rule's `holds` and `violation` evaluate it against a document's data and `DocumentSystemValues`, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. ### Delete rules (`deleteConstraints`) diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index 34e37fa2160..9039f07ba60 100644 --- a/book/src/error-handling/error-codes.md +++ b/book/src/error-handling/error-codes.md @@ -107,7 +107,7 @@ The fee category currently has a single code. The 30000 range is reserved for fu | Range | Category | Examples | |-------|----------|----------| | 40000-40009 | Data Contract | `DataContractAlreadyPresentError` (40000), `DataContractIsReadonlyError` (40001), `DataContractNotFoundError` (40008) | -| 40100-40147 | Documents | `DocumentAlreadyPresentError` (40100), `DocumentNotFoundError` (40101), `DuplicateUniqueIndexError` (40105), `DocumentActionFeeAgreementNotSetError` (40132), `DocumentActionFeeAgreementMismatchError` (40133), `DocumentActionFeeMultiplierNotToleratedError` (40134), `DocumentActionFeeModeratorsShareMismatchError` (40139), `DocumentExpiredError` (40140), `DocumentContestMaximumContendersReachedError` (40141), `ReferencedDocumentRequirementNotMetError` (40142), `ReferencedDocumentTypeNotModeratedError` (40143), `ReferencedDocumentTypeModeratedError` (40144), `ReferencedDocumentRemovedError` (40145), `ReferencedDocumentTypeIndexOnlyError` (40146), `DocumentDeleteConstraintViolatedError` (40147) | +| 40100-40148 | Documents | `DocumentAlreadyPresentError` (40100), `DocumentNotFoundError` (40101), `DuplicateUniqueIndexError` (40105), `DocumentActionFeeAgreementNotSetError` (40132), `DocumentActionFeeAgreementMismatchError` (40133), `DocumentActionFeeMultiplierNotToleratedError` (40134), `DocumentActionFeeModeratorsShareMismatchError` (40139), `DocumentExpiredError` (40140), `DocumentContestMaximumContendersReachedError` (40141), `ReferencedDocumentRequirementNotMetError` (40142), `ReferencedDocumentTypeNotModeratedError` (40143), `ReferencedDocumentTypeModeratedError` (40144), `ReferencedDocumentRemovedError` (40145), `ReferencedDocumentTypeIndexOnlyError` (40146), `DocumentDeleteConstraintViolatedError` (40147), `ReferencedDocumentRuleReadInvalidError` (40148) | | 40200-40217 | Identity | `IdentityAlreadyExistsError` (40200), `InvalidIdentityRevisionError` (40203), `IdentityInsufficientBalanceError` (40210) | | 40300-40307 | Voting | `MasternodeNotFoundError` (40300), `MasternodeVoteAlreadyPresentError` (40304), `VoteChoiceNotAllowedForVotePollError` (40307) | | 40400-40401 | Prefunded Balances | `PrefundedSpecializedBalanceInsufficientError` (40400) | diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index b7a2274c142..7144511adc3 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -467,13 +467,13 @@ try { } ``` -To find a broken rule before paying for a refused transition, a contract lists a document type's rules and checks a document against them with the code consensus runs. The check covers the rules alone, not the JSON schema. It reads the document's owner for `$ownerId`, and the device clock for the times the write will record (`readsSystem` lists the ones a rule reads); a rule reading a block height is not checked, since the height is unknown until the block, and neither is a rule reading a `countOf` or `sumOf` total, which only the platform reads from state (`readsTotals` lists the ones a rule reads, each with its `kind`, `documentType`, the summed `property` of a `sumOf` and the `filter` keys): +To find a broken rule before paying for a refused transition, a contract lists a document type's rules and checks a document against them with the code consensus runs. The check covers the rules alone, not the JSON schema. It reads the document's owner for `$ownerId`, and the device clock for the times the write will record (`readsSystem` lists the ones a rule reads); a rule reading a block height is not checked, since the height is unknown until the block, and neither is a rule reading a `countOf` or `sumOf` total, which only the platform reads from state (`readsTotals` lists the ones a rule reads, each with its `kind`, `documentType`, the summed `property` of a `sumOf` and the `filter` keys), nor one reading a value of the document a reference points at, `{ referenced: [reference, field] }`, which the platform reads when it checks the reference (`readsReferenced` lists them, each with its `reference`, `path` and `kind`): ```ts contract.documentTypePropertyConstraints('offer'); // [{ name: 'discountBelowPrice', rule: { lessThan: ['discount', 'price'] }, // reads: [{ path: 'discount', kind: 'value' }, { path: 'price', kind: 'value' }], -// readsOwner: false, readsSystem: [], readsTotals: [] }, ...] +// readsOwner: false, readsSystem: [], readsTotals: [], readsReferenced: [] }, ...] const broken = contract.checkDocumentPropertyConstraints(document); if (broken) { diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt index e76d0dc928d..b247093fb9c 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/DocumentTypeDetailsScreen.kt @@ -404,8 +404,8 @@ private fun PropertyConstraintsFormSection(section: PropertyConstraintsSection) /** * One `propertyConstraints` rule: its name, the rule as declared, what it - * reads, the system times and heights and the totals it reads, and whether an owner change - * is judged against it too + * reads, the system times and heights, the totals and the values of referenced documents it + * reads, and whether an owner change is judged against it too * (← `PropertyConstraintRowView` in DocumentTypeDetailsView.swift). */ @Composable @@ -414,6 +414,7 @@ private fun PropertyConstraintRow(rule: DocumentPropertyConstraint) { val reads = remember(rule) { propertyConstraintReadsText(rule) } val systemReads = remember(rule) { propertyConstraintSystemReadsText(rule) } val totals = remember(rule) { propertyConstraintTotalsText(rule) } + val referenced = remember(rule) { propertyConstraintReferencedReadsText(rule) } Column( modifier = Modifier .fillMaxWidth() @@ -475,6 +476,19 @@ private fun PropertyConstraintRow(rule: DocumentPropertyConstraint) { color = MaterialTheme.colorScheme.tertiary, ) } + if (referenced != null) { + Text( + referenced, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.testTag("documentType.propertyConstraint.${rule.name}.readsReferenced"), + ) + Text( + PROPERTY_CONSTRAINT_REFERENCED_NOTE, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } if (rule.readsOwner) { Text( "Reads \$ownerId, the document's owner: transfers and purchases are judged " + diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/PropertyConstraints.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/PropertyConstraints.kt index afca1f27267..504ee8c283e 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/PropertyConstraints.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/PropertyConstraints.kt @@ -182,6 +182,29 @@ internal const val PROPERTY_CONSTRAINT_TOTALS_NOTE = "The platform reads these totals when the document is sent; the check before sending " + "does not, so it cannot catch this rule." +/** + * The line naming the values [rule] reads of referenced documents, each once, + * as `reference.path (kind)`: + * `Reads referenced documents: pollId.status (text), pollId.$ownerId (identifier)`. + * `null` for a rule reading none. The SwiftExampleApp builds the same text. + */ +internal fun propertyConstraintReferencedReadsText(rule: DocumentPropertyConstraint): String? = + rule.readsReferenced + .map { "${it.reference}.${it.path} (${it.kind.name})" } + .distinct() + .takeIf { it.isNotEmpty() } + ?.joinToString(", ", prefix = "Reads referenced documents: ") + +/** + * The note under a rule reading a referenced document + * ([propertyConstraintReferencedReadsText] not `null`): the check before + * sending has no referenced document, so it cannot catch the rule. The same + * text as the SwiftExampleApp's, for cross-platform UAT. + */ +internal const val PROPERTY_CONSTRAINT_REFERENCED_NOTE = + "The platform reads the referenced documents when it checks the references; the check " + + "before sending does not, so it cannot catch this rule." + /** Title of the alert a broken rule raises instead of a broadcast. */ internal const val PROPERTY_CONSTRAINT_BROKEN_TITLE = "Not sent: a property constraint is broken" diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/PropertyConstraintsTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/PropertyConstraintsTest.kt index 192178a948d..ed023935875 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/PropertyConstraintsTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/contracts/PropertyConstraintsTest.kt @@ -8,6 +8,7 @@ import kotlinx.serialization.json.putJsonObject import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.queries.DocumentPropertyConstraint import org.dashfoundation.dashsdk.queries.PropertyConstraintRead +import org.dashfoundation.dashsdk.queries.PropertyConstraintReferencedRead import org.dashfoundation.dashsdk.queries.PropertyConstraintTotalRead import org.dashfoundation.dashsdk.queries.PropertyConstraintViolation import org.junit.Assert.assertArrayEquals @@ -304,6 +305,39 @@ class PropertyConstraintsTest { ) } + /** A rule reading the status of the poll `pollId` refers to twice, and its owner. */ + private val referencingRule = DocumentPropertyConstraint( + name = "openPollOfItsOwner", + ruleJson = "{}", + reads = listOf(PropertyConstraintRead("pollOwnerId", PropertyConstraintRead.Kind.Identifier)), + readsOwner = false, + readsReferenced = listOf( + PropertyConstraintReferencedRead("pollId", "status", PropertyConstraintRead.Kind.Text), + PropertyConstraintReferencedRead("pollId", "${'$'}ownerId", PropertyConstraintRead.Kind.Identifier), + PropertyConstraintReferencedRead("pollId", "status", PropertyConstraintRead.Kind.Text), + ), + ) + + /** Each referenced read once, as `reference.path (kind)`; the SwiftExampleApp builds the same text. */ + @Test + fun `should list each value a rule reads of a referenced document once`() { + assertEquals( + "Reads referenced documents: pollId.status (text), pollId.${'$'}ownerId (identifier)", + propertyConstraintReferencedReadsText(referencingRule), + ) + assertNull(propertyConstraintReferencedReadsText(totalledRule)) + } + + /** The note under a rule reading a referenced document; the SwiftExampleApp shows the same text. */ + @Test + fun `should say the check before sending cannot catch a rule reading a referenced document`() { + assertEquals( + "The platform reads the referenced documents when it checks the references; the " + + "check before sending does not, so it cannot catch this rule.", + PROPERTY_CONSTRAINT_REFERENCED_NOTE, + ) + } + /** The note under a rule reading a system value; the SwiftExampleApp shows the same text. */ @Test fun `should say which writes the update and transfer times answer to`() { diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt index c7939030eeb..56f81d1231c 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/QueriesNative.kt @@ -75,12 +75,15 @@ internal object QueriesNative { /** * The `propertyConstraints` rules (protocol version 14) of [documentType] * as a JSON array in name order, each - * `{"name", "rule", "reads": [{"path", "kind"}], "readsOwner", "readsSystem", "readsTotals"}`: - * `kind` is `value`, `presence`, `text`, `identifier`, `length`, `count` - * or `elements`, `readsSystem` names the system times and heights the - * rule reads (`$createdAt`, `$updatedAtBlockHeight`, ...), and + * `{"name", "rule", "reads": [{"path", "kind"}], "readsOwner", "readsSystem", "readsTotals", "readsReferenced"}`: + * `kind` is `value`, `presence`, `text`, `identifier`, `length`, `count`, + * `elements` or `bytes`, `readsSystem` names the system times and heights + * the rule reads (`$createdAt`, `$updatedAtBlockHeight`, ...), * `readsTotals` lists its `countOf` and `sumOf` totals as - * `{"kind", "documentType", "property" (a sumOf only), "filter"}`. + * `{"kind", "documentType", "property" (a sumOf only), "filter"}`, and + * `readsReferenced` the values it reads of the documents the type's + * references point at as `{"reference", "path", "kind"}` (`kind` as in + * `reads`, which leaves them out). * [serializedContract] is the contract's platform serialization (what * [dataContractFetchWithSerialization] returns), read by Rust at the SDK's * protocol version; no network call. Throws on error (unknown document @@ -101,7 +104,9 @@ internal object QueriesNative { * the block time the create records (`$createdAt`, `$updatedAt`, * `$transferredAt`), and a rule reading a block height is not judged, the * height being unknown until the block, nor is one reading a `countOf` or - * `sumOf` total, which only the platform reads from state. No network call. Throws on + * `sumOf` total, which only the platform reads from state, or a value of a + * referenced document, which the platform reads when it checks the + * reference. No network call. Throws on * error (as above, plus an owner id that is not 32 bytes or properties * that are not a JSON object). */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt index 517a349a535..d163e628fa5 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt @@ -48,7 +48,8 @@ data class DocumentPropertyConstraint( * listed twice, every branch of an `ifThen` or `ifThenElse` included, * whichever one a document takes. `$ownerId` and the system times and * heights are no properties and are not listed: see [readsOwner] and - * [readsSystem], which cover every branch too. + * [readsSystem], which cover every branch too. Nor is a value of a + * document a reference points at: see [readsReferenced]. */ val reads: List, /** @@ -88,6 +89,18 @@ data class DocumentPropertyConstraint( * library predates the field. */ val readsTotals: List = emptyList(), + /** + * The values the rule reads of the documents the type's references point + * at (`{ "referenced": [reference, path] }`), in declared order, one read + * twice listed twice, every branch included as in [reads], which leaves + * them out. The platform reads the referenced document when it checks the + * reference; the pre-check ([Contracts.checkPropertyConstraints]) has no + * referenced document and does not judge a rule reading one. + * + * Empty for a rule reading none, and for every rule when the native + * library predates the field. + */ + val readsReferenced: List = emptyList(), ) { /** [ruleJson] indented for display, or [ruleJson] itself should it not parse back. */ val prettyRuleJson: String @@ -101,8 +114,8 @@ data class DocumentPropertyConstraint( /** * Decode the JSON array `dash_sdk_data_contract_get_property_constraints` * returns, keeping its order (name order). A rule without - * `readsSystem`, from a native library built before it, reads as - * reading no system value. + * `readsSystem`, `readsTotals` or `readsReferenced`, from a native + * library built before the key, reads as reading none of those. * * @throws DashSdkError.SerializationError for text that is not such an array. */ @@ -119,8 +132,9 @@ data class DocumentPropertyConstraint( val readsOwner = rule?.get("readsOwner")?.jsonBooleanOrNull() val readsSystem = rule?.let(::readsSystemOf) val readsTotals = rule?.let(::readsTotalsOf) + val readsReferenced = rule?.let(::readsReferencedOf) if (name == null || declaration == null || reads == null || readsOwner == null || - readsSystem == null || readsTotals == null + readsSystem == null || readsTotals == null || readsReferenced == null ) { throw DashSdkError.SerializationError("Malformed propertyConstraints rule: $entry") } @@ -131,6 +145,7 @@ data class DocumentPropertyConstraint( readsOwner = readsOwner, readsSystem = readsSystem, readsTotals = readsTotals, + readsReferenced = readsReferenced, ) } } @@ -154,6 +169,16 @@ data class DocumentPropertyConstraint( val entries = value as? JsonArray ?: return null return entries.map { PropertyConstraintTotalRead.fromJsonOrNull(it) ?: return null } } + + /** + * The reads [rule]'s `readsReferenced` lists: empty when the key is + * missing, `null` when it or an entry is malformed. + */ + private fun readsReferencedOf(rule: JsonObject): List? { + val value = rule["readsReferenced"] ?: return emptyList() + val entries = value as? JsonArray ?: return null + return entries.map { PropertyConstraintReferencedRead.fromJsonOrNull(it) ?: return null } + } } } @@ -221,6 +246,40 @@ data class PropertyConstraintTotalRead( } } +/** + * A value a `propertyConstraints` rule reads of the document a reference of the + * type points at, `{ "referenced": [reference, path] }`. The fields mirror + * wasm-dpp2's `PropertyConstraintReferencedRead` and the Swift SDK's. + */ +data class PropertyConstraintReferencedRead( + /** The top-level property of the document type holding the reference. */ + val reference: String, + /** + * The field of the referenced document: a dotted property path of its + * type, `$id`, `$ownerId`, `$creatorId`, or a time or height it records + * (`$createdAt`, ...). + */ + val path: String, + /** + * How the rule reads the field, by the kinds [PropertyConstraintRead] uses: + * an identifier (`$id`, `$ownerId`, `$creatorId`) as + * [PropertyConstraintRead.Kind.Identifier], a time or height as + * [PropertyConstraintRead.Kind.Value]. + */ + val kind: PropertyConstraintRead.Kind, +) { + internal companion object { + /** The read [entry] describes, or `null` when it is malformed. */ + fun fromJsonOrNull(entry: JsonElement): PropertyConstraintReferencedRead? { + val read = entry as? JsonObject ?: return null + val reference = read["reference"]?.jsonStringOrNull() ?: return null + val path = read["path"]?.jsonStringOrNull() ?: return null + val kind = read["kind"]?.jsonStringOrNull() ?: return null + return PropertyConstraintReferencedRead(reference, path, PropertyConstraintRead.Kind.fromName(kind)) + } + } +} + /** A property a `propertyConstraints` rule reads, and how it reads it. */ data class PropertyConstraintRead( /** The property's dotted path. */ @@ -323,8 +382,8 @@ data class PropertyConstraintRead( * Rust judges the document (`dash_sdk_data_contract_check_property_constraints`, * through [Contracts.checkPropertyConstraints]) with the check consensus runs, * the device clock standing in for the times the create records, and a rule - * reading a block height or a `countOf` or `sumOf` total left unjudged; this - * type only carries the verdict. + * reading a block height, a `countOf` or `sumOf` total or a value of a + * referenced document left unjudged; this type only carries the verdict. * The fields mirror wasm-dpp2's `DocumentPropertyConstraintViolation` and the * Swift SDK's `PropertyConstraintViolation`. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt index 0734eece7b6..583fee334bd 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/PlatformQueries.kt @@ -670,8 +670,10 @@ class Contracts internal constructor(private val sdk: Sdk) { * Each rule lists the properties it reads and how * ([DocumentPropertyConstraint.reads]), whether it reads the owner * ([DocumentPropertyConstraint.readsOwner]), the system times and - * heights it reads ([DocumentPropertyConstraint.readsSystem]) and the - * `countOf` and `sumOf` totals it reads ([DocumentPropertyConstraint.readsTotals]). + * heights it reads ([DocumentPropertyConstraint.readsSystem]), the + * `countOf` and `sumOf` totals it reads ([DocumentPropertyConstraint.readsTotals]) + * and the values it reads of referenced documents + * ([DocumentPropertyConstraint.readsReferenced]). * * [serializedContract] is the contract's platform serialization, the bytes * kept beside a fetched contract ([ContractWithSerialization.binarySerialization], @@ -712,9 +714,12 @@ class Contracts internal constructor(private val sdk: Sdk) { * reading a block height (`$createdAtBlockHeight`, ...) is not judged, * since the height is unknown until the block, nor is a rule reading a * `countOf` or `sumOf` total ([DocumentPropertyConstraint.readsTotals]), - * which the platform reads from state when the document is sent; consensus - * may still refuse the document for either. [serializedContract] is as for - * [propertyConstraints]; no network call. + * which the platform reads from state when the document is sent, nor one + * reading a value of a referenced document + * ([DocumentPropertyConstraint.readsReferenced]), which the platform reads + * when it checks the reference; consensus may still refuse the document + * for any of these. [serializedContract] is as for [propertyConstraints]; + * no network call. * * @throws DashSdkError.InvalidParameter for empty contract bytes, an owner * id that is not 32 bytes, or properties that are not a JSON object. diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt index 7a44e204e06..271e4eaf7c8 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraintsTest.kt @@ -610,6 +610,191 @@ class DocumentPropertyConstraintsTest { } } + /** + * Rules reading values of the document a reference points at, as the FFI + * reports them: a `vote` type whose `pollId` refers to a `poll`, reading + * which options the poll has, its owner and its status, and a rule, + * `smallChoice`, reading only the vote's choice. + */ + private val referencedRulesJson = """ + [ + { + "name": "choiceIsAnOption", + "readsOwner": false, + "reads": [{ "kind": "value", "path": "choice" }], + "readsSystem": [], + "readsTotals": [], + "readsReferenced": [ + { "reference": "pollId", "path": "option0", "kind": "presence" }, + { "reference": "pollId", "path": "option1", "kind": "presence" }, + { "reference": "pollId", "path": "option2", "kind": "presence" } + ], + "rule": { + "lessThan": [ + "choice", + { + "countPresent": [ + { "referenced": ["pollId", "option0"] }, + { "referenced": ["pollId", "option1"] }, + { "referenced": ["pollId", "option2"] } + ] + } + ] + } + }, + { + "name": "ownerIsTheDenormalizedOne", + "readsOwner": false, + "reads": [{ "kind": "identifier", "path": "pollOwnerId" }], + "readsSystem": [], + "readsTotals": [], + "readsReferenced": [ + { "reference": "pollId", "path": "${'$'}ownerId", "kind": "identifier" } + ], + "rule": { "equal": [{ "referenced": ["pollId", "${'$'}ownerId"] }, "pollOwnerId"] } + }, + { + "name": "pollIsOpen", + "readsOwner": false, + "reads": [], + "readsSystem": [], + "readsTotals": [], + "readsReferenced": [{ "reference": "pollId", "path": "status", "kind": "text" }], + "rule": { "notEqual": [{ "referenced": ["pollId", "status"] }, { "const": "closed" }] } + }, + { + "name": "smallChoice", + "readsOwner": false, + "reads": [{ "kind": "value", "path": "choice" }], + "readsSystem": [], + "readsTotals": [], + "readsReferenced": [], + "rule": { "lessThan": ["choice", 10] } + } + ] + """.trimIndent() + + // Referenced reads + + @Test + fun `should decode the values each rule reads of referenced documents`() { + val rules = DocumentPropertyConstraint.listFromJson(referencedRulesJson) + + assertEquals( + listOf("choiceIsAnOption", "ownerIsTheDenormalizedOne", "pollIsOpen", "smallChoice"), + rules.map { it.name }, + ) + assertEquals( + listOf( + listOf("option0", "option1", "option2").map { + PropertyConstraintReferencedRead("pollId", it, PropertyConstraintRead.Kind.Presence) + }, + listOf( + PropertyConstraintReferencedRead( + "pollId", + "${'$'}ownerId", + PropertyConstraintRead.Kind.Identifier, + ), + ), + listOf(PropertyConstraintReferencedRead("pollId", "status", PropertyConstraintRead.Kind.Text)), + emptyList(), + ), + rules.map { it.readsReferenced }, + ) + // A value of the referenced document is no property of this one; the + // properties the rule compares it with are + assertEquals( + listOf( + listOf(PropertyConstraintRead("choice", PropertyConstraintRead.Kind.Value)), + listOf(PropertyConstraintRead("pollOwnerId", PropertyConstraintRead.Kind.Identifier)), + emptyList(), + listOf(PropertyConstraintRead("choice", PropertyConstraintRead.Kind.Value)), + ), + rules.map { it.reads }, + ) + // The poll's owner is not the vote's + assertEquals(listOf(false, false, false, false), rules.map { it.readsOwner }) + assertEquals(List(4) { emptyList() }, rules.map { it.readsTotals }) + assertEquals( + """{"equal":[{"referenced":["pollId","${'$'}ownerId"]},"pollOwnerId"]}""", + rules[1].ruleJson, + ) + } + + /** A native library built before `readsReferenced` leaves the key out. */ + @Test + fun `should read a rule without readsReferenced as reading no referenced document`() { + val rules = DocumentPropertyConstraint.listFromJson( + """[{"name":"r","readsOwner":false,"reads":[],"readsSystem":[],"readsTotals":[],"rule":{"present":"a"}}]""", + ) + + assertEquals(emptyList(), rules.single().readsReferenced) + assertEquals( + DocumentPropertyConstraint("r", """{"present":"a"}""", emptyList(), readsOwner = false), + rules.single(), + ) + } + + /** + * Referenced reads are kept in declared order, one read twice listed + * twice, and a kind added by a later protocol version is kept by name + * rather than failing the whole list. + */ + @Test + fun `should keep repeated referenced reads and an unknown kind`() { + val rules = DocumentPropertyConstraint.listFromJson( + """ + [{ "name": "r", "readsOwner": false, "reads": [], "readsSystem": [], "readsTotals": [], + "readsReferenced": [ + { "reference": "pollId", "path": "endsAt", "kind": "value" }, + { "reference": "postId", "path": "meta.tags", "kind": "somethingNew" }, + { "reference": "pollId", "path": "endsAt", "kind": "value" } + ], + "rule": { "lessThan": [{ "referenced": ["pollId", "endsAt"] }, 10] } }] + """.trimIndent(), + ) + + val endsAt = PropertyConstraintReferencedRead("pollId", "endsAt", PropertyConstraintRead.Kind.Value) + assertEquals( + listOf( + endsAt, + PropertyConstraintReferencedRead( + "postId", + "meta.tags", + PropertyConstraintRead.Kind.Other("somethingNew"), + ), + endsAt, + ), + rules.single().readsReferenced, + ) + } + + @Test + fun `should refuse a malformed readsReferenced`() { + val malformed = listOf( + "null", + "{}", + "\"pollId\"", + "[1]", + "[null]", + """[["pollId", "status", "text"]]""", + // Every read names its reference, path and kind, each a string + """[{"path":"status","kind":"text"}]""", + """[{"reference":"pollId","kind":"text"}]""", + """[{"reference":"pollId","path":"status"}]""", + """[{"reference":7,"path":"status","kind":"text"}]""", + """[{"reference":"pollId","path":["status"],"kind":"text"}]""", + """[{"reference":"pollId","path":"status","kind":null}]""", + ) + for (readsReferenced in malformed) { + val json = + """[{"name":"r","readsOwner":false,"reads":[],"readsSystem":[],"readsTotals":[],"readsReferenced":$readsReferenced,"rule":{"present":"a"}}]""" + assertThrows(json, DashSdkError.SerializationError::class.java) { + DocumentPropertyConstraint.listFromJson(json) + } + } + } + // Violations @Test 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 6c28467357c..0df523d6892 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 @@ -280,7 +280,7 @@ "uniqueItems": true }, "propertyConstraintExpression": { - "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const or another string property; a system time or height the document type records ($createdAt, $updatedAtBlockHeight, ...); or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out, an integer, or a string for a string property compared with strings; add, multiply, min or max, two or more operands; subtract, divide, modulo or power, exactly two; abs, one; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out; byteAt, the byte (0 to 255) at an index of a byte array property, 0 when the array does not hold it; countPresent, how many of two or more properties the document holds, each as present tests it; countOf or sumOf, how many documents of a document type of this contract match a filter or the total of an integer property over them, as its count or sum trees keep it; const, a string constant compared with a string property, or a hex constant a byte array starts or ends with", + "description": "An expression of a propertyConstraints rule: an integer value; the dotted path of a property of the document type, whose value it takes: an integer or boolean one (1 for true, 0 for false), 0 when the document leaves it out, or a string one compared with a const or another string property; a system time or height the document type records ($createdAt, $updatedAtBlockHeight, ...); or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out, an integer, or a string for a string property compared with strings; add, multiply, min or max, two or more operands; subtract, divide, modulo or power, exactly two; abs, one; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out; byteAt, the byte (0 to 255) at an index of a byte array property, 0 when the array does not hold it; countPresent, how many of two or more properties the document holds, each as present tests it; countOf or sumOf, how many documents of a document type of this contract match a filter or the total of an integer property over them, as its count or sum trees keep it; referenced, a value of the document a reference of the type points at; const, a string constant compared with a string property, or a hex constant a byte array starts or ends with", "type": [ "integer", "string", @@ -298,6 +298,9 @@ }, "then": { "properties": { + "referenced": { + "$ref": "#/$defs/propertyConstraintReferenced" + }, "ifAbsent": { "description": "A property path and the value it takes when the document leaves the property out: an integer for an integer or boolean property, read as an integer operand, or a string for a string property, read as one side of a comparison of strings", "type": "array", @@ -470,9 +473,144 @@ } }, "propertyConstraintPath": { - "description": "The dotted path of a property of the document type, a nested one through the objects around it: an integer or boolean property when an operand reads its value, a string property when length or byteLength measures it, an array or byte array property when count counts its items, a byte array property when byteAt reads one of its bytes or startsWith or endsWith tests it, any property when present, absent or countPresent tests it. Or $ownerId, the document's owner, which only a comparison of identifiers reads, or a system time or height an integer operand reads ($createdAt, $updatedAt, $transferredAt, and each with BlockHeight or CoreBlockHeight appended), one the document type lists in required", - "type": "string", - "pattern": "^(\\$ownerId|\\$(created|updated|transferred)At(BlockHeight|CoreBlockHeight)?|[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*)$" + "description": "The dotted path of a property of the document type, a nested one through the objects around it: an integer or boolean property when an operand reads its value, a string property when length or byteLength measures it, an array or byte array property when count counts its items, a byte array property when byteAt reads one of its bytes or startsWith or endsWith tests it, any property when present, absent or countPresent tests it. Or $ownerId, the document's owner, which only a comparison of identifiers reads, or a system time or height an integer operand reads ($createdAt, $updatedAt, $transferredAt, and each with BlockHeight or CoreBlockHeight appended), one the document type lists in required. Or { \"referenced\": [reference, field] }, a value of the document a reference of the type points at, read as a property of that type is", + "anyOf": [ + { + "type": "string", + "pattern": "^(\\$ownerId|\\$(created|updated|transferred)At(BlockHeight|CoreBlockHeight)?|[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*)$" + }, + { + "type": "object", + "properties": { + "referenced": { + "$ref": "#/$defs/propertyConstraintReferenced" + } + }, + "required": [ + "referenced" + ], + "additionalProperties": false + } + ] + }, + "propertyConstraintReferenced": { + "description": "A value of the document a reference of the document type points at, standing wherever a property path does in a propertyConstraints rule: the name of a top-level property of the type whose refersTo finds one document, by id or by a findBy without a function (a permanentDocument, moderatedDocument or deletableDocument reference, not on the elements of a typed array, nor an anyOf, allOf or inList), then a field of that document: a property path of its type, read as that property's type says, its $id, $ownerId or $creatorId, compared as identifiers, or a time or height its type lists in required. A rule reading one is judged once the references are checked, with the document their check read, so it costs no read; its reference is checked again on every replace. Through a moderatedDocument reference only $id, $ownerId and the fields the type keeps under deleteKeepsFields are read. Available from protocol version 14", + "type": "array", + "prefixItems": [ + { + "type": "string", + "pattern": "^[a-zA-Z0-9_]{1,64}$" + }, + { + "type": "string", + "pattern": "^(\\$id|\\$ownerId|\\$creatorId|\\$(created|updated|transferred)At(BlockHeight|CoreBlockHeight)?|[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*)$" + } + ], + "items": false, + "minItems": 2 + }, + "referenceWhereCondition": { + "description": "A refersTo where entry that is not an equality with a referring value: an object with one key. const, a literal the referenced side must equal (an integer or true or false for such a property, a string for a string property, a base58 identifier for an identifier, $ownerId, $creatorId or $id, hex for a byte array); a referenced side the document leaves out equals none. notEqual, what it must differ from: a property of the referring document type, \"$ownerId\" (the writer, checked on every replace) or a { const }; the negation of an equality, so two absent sides are equal. in, two to thirty distinct literals of one kind, one of which it must equal; an absent side is in none. greaterThan, greaterThanOrEqual, lessThan or lessThanOrEqual, an integer property the referenced side must compare with this way: bounded by an integer property of the referring document type, an integer { const }, or a time or height the referring type lists in required ($createdAt as written by the create; $updatedAt and $transferredAt, and their heights, change without a write to the referring document, so an entry bounded by one is checked on every replace); it holds only when both sides have a value. A document found that fails an entry refuses the write with ReferencedDocumentPropertyMismatchError (40127). Available from protocol version 14", + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "properties": { + "const": { + "type": [ + "integer", + "boolean", + "string" + ] + }, + "notEqual": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^(\\$ownerId|[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*)$" + }, + { + "$ref": "#/$defs/referenceWhereConst" + } + ] + }, + "in": { + "type": "array", + "anyOf": [ + { + "items": { + "type": "integer" + } + }, + { + "items": { + "type": "boolean" + } + }, + { + "items": { + "type": "string" + } + } + ], + "minItems": 2, + "maxItems": 30, + "uniqueItems": true + }, + "greaterThan": { + "$ref": "#/$defs/referenceWhereOrderingBound" + }, + "greaterThanOrEqual": { + "$ref": "#/$defs/referenceWhereOrderingBound" + }, + "lessThan": { + "$ref": "#/$defs/referenceWhereOrderingBound" + }, + "lessThanOrEqual": { + "$ref": "#/$defs/referenceWhereOrderingBound" + } + }, + "additionalProperties": false + }, + "referenceWhereConst": { + "type": "object", + "properties": { + "const": { + "type": [ + "integer", + "boolean", + "string" + ] + } + }, + "required": [ + "const" + ], + "additionalProperties": false + }, + "referenceWhereOrderingBound": { + "description": "The integer bound of a where ordering: the path of an integer property of the referring document type, one of its times or heights its type lists in required, or { \"const\": integer }", + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^(\\$(created|updated|transferred)At(BlockHeight|CoreBlockHeight)?|[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*)$" + }, + { + "type": "object", + "properties": { + "const": { + "type": "integer" + } + }, + "required": [ + "const" + ], + "additionalProperties": false + } + ] }, "propertyConstraintOperands": { "type": "array", @@ -791,7 +929,7 @@ } }, "where": { - "description": "permanentDocument, moderatedDocument and deletableDocument references only: what the referenced document must hold once found, checked by consensus when the referring document is written. Each entry maps a property of the referenced document type, or one of its $ownerId, $creatorId and $id system identifiers, to the referring document's value it must equal: a schema property of the declaring document type, or \"$ownerId\", the writer, which turns the entry into a write gate: only an identity whose id equals the referenced side may create or replace the document. A referring value may be compared once. $creatorId needs a referenced document type that records creator ids (transferable or tradeable types of a format-1 contract), and $ownerId, $creatorId or $id an identifier on the referring side. Both sides must exist and share one value kind, validated at contract registration. $ownerId follows the referenced document through transfers while $creatorId and $id never change; each is checked when the referring document is written, not when the referenced document later moves. A document found that fails an entry refuses the write with ReferencedDocumentPropertyMismatchError (40127). Beside a findBy function the declaration is judged when the document is created only, so its entries gate the create alone, and every referring value other than $ownerId must be transient or fixed once written (not listed under immutable with a condition). where never finds the document: \".\" and functions belong to findBy. Available from protocol version 14.", + "description": "permanentDocument, moderatedDocument and deletableDocument references only: what the referenced document must hold once found, checked by consensus when the referring document is written. Each entry maps a property of the referenced document type, or one of its $ownerId, $creatorId and $id system identifiers, to the referring document's value it must equal: a schema property of the declaring document type, or \"$ownerId\", the writer, which turns the entry into a write gate: only an identity whose id equals the referenced side may create or replace the document. A referring value may be compared once. $creatorId needs a referenced document type that records creator ids (transferable or tradeable types of a format-1 contract), and $ownerId, $creatorId or $id an identifier on the referring side. Both sides must exist and share one value kind, validated at contract registration. $ownerId follows the referenced document through transfers while $creatorId and $id never change; each is checked when the referring document is written, not when the referenced document later moves. A document found that fails an entry refuses the write with ReferencedDocumentPropertyMismatchError (40127). An entry may instead hold an object with one key (see referenceWhereCondition): const, a literal the referenced side must equal; notEqual, a referring property, \"$ownerId\" or a const it must differ from; in, two or more literals it must be one of; or greaterThan, greaterThanOrEqual, lessThan or lessThanOrEqual, an integer bound it must compare with. The one-comparison-per-referring-value rule holds for the equalities only. Beside a findBy function the declaration is judged when the document is created only, so its entries gate the create alone, and every referring value other than $ownerId must be transient or fixed once written (not listed under immutable with a condition). where never finds the document: \".\" and functions belong to findBy. Available from protocol version 14.", "type": "object", "minProperties": 1, "maxProperties": 10, @@ -816,6 +954,9 @@ }, { "const": "$ownerId" + }, + { + "$ref": "#/$defs/referenceWhereCondition" } ] } 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 6d98f0910cd..105c315cc5e 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 @@ -6,7 +6,8 @@ use crate::data_contract::document_type::accessors::{ use crate::data_contract::document_type::class_methods::consensus_or_protocol_data_contract_error; use crate::data_contract::document_type::class_methods::try_from_schema::{ resolve_derived_index_properties, validate_preallocated_indexes_kept_on_removal, - validate_property_constraint_aggregates, validate_summable_off_count_indexes_lossless, + validate_property_constraint_aggregates, validate_property_constraint_referenced_reads, + validate_summable_off_count_indexes_lossless, }; use crate::data_contract::document_type::{ DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceDeclaration, @@ -327,6 +328,19 @@ impl DocumentType { validate_property_constraint_aggregates(&contract_document_types, schema_defs) .map_err(consensus_or_protocol_data_contract_error)?; + // What a rule reads of a document a reference points at, when that document's type + // is another of this contract; the referenced types of other contracts are read at + // registration. Inert before protocol version 14 for the same reason: no rule parses + // there. + validate_property_constraint_referenced_reads( + &contract_document_types, + data_contract_id, + data_contract_system_version, + contract_config_version, + schema_defs, + platform_version, + )?; + Ok(contract_document_types) } } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/mod.rs index f8d5b7ddbd5..f451831b780 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/mod.rs @@ -13,6 +13,7 @@ mod should_use_creator_id; mod system_properties; mod try_from_schema; +pub use try_from_schema::referenced_reads_error; pub(in crate::data_contract) use try_from_schema::{ resolve_derived_index_properties, validate_preallocated_indexes_kept_on_removal, validate_summable_off_count_indexes_lossless, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index e4ee3532e20..4e685b57e75 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -2960,57 +2960,31 @@ fn properties_read_by_references(document_type: DocumentTypeRef<'_>) -> BTreeSet continue; }; for leaf in target.leaves() { - let agreement = match leaf { - DocumentPropertyReferenceTarget::PermanentDocument { - property_agreement, .. - } - | DocumentPropertyReferenceTarget::DeletableDocument { - property_agreement, .. - } - | DocumentPropertyReferenceTarget::ModeratedDocument { - property_agreement, .. - } => Some(property_agreement), - DocumentPropertyReferenceTarget::PermanentDocumentLookup { - property_agreement, - lookup, - .. - } - | DocumentPropertyReferenceTarget::DeletableDocumentLookup { - property_agreement, - lookup, - .. - } => { - read.extend(lookup.referring_properties().map(top_level_property)); - // A computed key's params: the key is judged on the create only, so a - // change would leave the document no longer hashing to what it revealed - if let Some((_, key)) = lookup.hash_key() { - read.extend(key.properties_read().map(top_level_property)); - } - Some(property_agreement) - } - DocumentPropertyReferenceTarget::ListElement(list_element) => { - Some(&list_element.property_agreement) - } - DocumentPropertyReferenceTarget::IdentityPublicKey { - key_id_property, .. - } => { - read.insert(top_level_property(key_id_property)); - None - } - DocumentPropertyReferenceTarget::Identity - | DocumentPropertyReferenceTarget::Contract { .. } - | DocumentPropertyReferenceTarget::Token - | DocumentPropertyReferenceTarget::AnyOf(_) - | DocumentPropertyReferenceTarget::AllOf(_) => None, + if let DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property, .. + } = leaf + { + read.insert(top_level_property(key_id_property)); + } + let Some(declaration) = leaf.as_any_document_reference() else { + continue; }; - if let Some(agreement) = agreement { - read.extend( - agreement - .keys() - .filter(|property| !property.starts_with('$')) - .map(|property| top_level_property(property)), - ); + if let Some(lookup) = declaration.lookup { + read.extend(lookup.referring_properties().map(top_level_property)); + // A computed key's params: the key is judged on the create only, so a + // change would leave the document no longer hashing to what it revealed + if let Some((_, key)) = lookup.hash_key() { + read.extend(key.properties_read().map(top_level_property)); + } } + // The `where` of a list element names the property holding its + // document's `$id` too + read.extend( + declaration + .where_referring_properties() + .filter(|property| !property.starts_with('$')) + .map(top_level_property), + ); } } read @@ -3594,6 +3568,7 @@ fn parse_replace_condition( subject, &condition, true, + false, &condition_error, ) .map_err(consensus_or_protocol_data_contract_error)?; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 804e7733635..203d3167bd8 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 @@ -3,33 +3,42 @@ use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; use crate::data_contract::document_type::class_methods::apply_required_since::apply_required_since; +use crate::data_contract::document_type::class_methods::consensus_or_protocol_data_contract_error; use crate::data_contract::document_type::class_methods::parse_typed_array::parse_typed_array; +use crate::data_contract::document_type::property::reference_where::{ + WHERE_CONST, WHERE_IN, WHERE_NOT_EQUAL, +}; use crate::data_contract::document_type::property_constraints::{ - parse_delete_constraints, parse_property_constraints, AffixPosition, AggregateBinding, - AggregateKind, ElementKind, EqualityKind, PropertyConstraint, PropertyRead, + parse_delete_constraints, parse_property_constraints, referenced_read, AffixPosition, + AggregateBinding, AggregateKind, ElementKind, EqualityKind, PropertyConstraint, PropertyRead, + SystemChange, SystemProperty, REFERENCED_DOCUMENT_PREFIX, REFERENCED_IDENTIFIER_FIELDS, STORED_DOCUMENT_PREFIX, }; use crate::data_contract::document_type::reference_lookup::{ - clearable_once_its_document_is_deleted, schema_property_is_fixed_once_written, - LOOKUP_REFERENCE_VALUE, MAX_LOOKUP_KEYS, MAX_LOOKUP_PATH_LENGTH, + clearable_once_its_document_is_deleted, owner_can_change, + schema_property_is_fixed_once_written, LOOKUP_REFERENCE_VALUE, MAX_LOOKUP_KEYS, + MAX_LOOKUP_PATH_LENGTH, }; use crate::data_contract::document_type::v0::DocumentTypeV0; use crate::data_contract::document_type::v1::DocumentTypeV1; use crate::data_contract::document_type::v2::DocumentTypeV2; use crate::data_contract::document_type::{ - is_referenced_system_agreement_property, is_referring_system_agreement_property, is_transient, - property_at_path, property_names, ContractReferenceModeration, ContractReferenceOwner, - ContractReferenceRequirements, DistinctFrom, DocumentProperty, DocumentPropertyReferenceTarget, - DocumentPropertyType, DocumentPropertyTypeParsingOptions, DocumentReferenceLookup, - DocumentType, DocumentTypeRef, EncryptedFor, EncryptedForRecipient, EncryptionScheme, - GeneratedFrom, GenerationParam, IdentityKeyReferenceRequirements, KeyIdReference, - KeyReferenceIdentityProperty, ListElementReference, LookupHashKey, LookupKeySource, - ReferenceCombinator, ReferenceOperands, SystemFunction, COMBINABLE_REFERENCE_TARGET_TYPES, + is_path_listed, is_referenced_system_agreement_property, + is_referring_system_agreement_property, is_transient, property_at_path, property_names, + ContractReferenceModeration, ContractReferenceOwner, ContractReferenceRequirements, + DistinctFrom, DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, + DocumentPropertyTypeParsingOptions, DocumentReferenceDeclaration, DocumentReferenceKind, + DocumentReferenceLookup, DocumentType, DocumentTypeRef, EncryptedFor, EncryptedForRecipient, + EncryptionScheme, GeneratedFrom, GenerationParam, IdentityKeyReferenceRequirements, + KeyIdReference, KeyReferenceIdentityProperty, ListElementReference, LookupHashKey, + LookupKeySource, PropertyReference, ReferenceCombinator, ReferenceOperands, SystemFunction, + WhereBound, WhereCondition, WhereLiteral, WhereOrdering, COMBINABLE_REFERENCE_TARGET_TYPES, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; -use crate::document::property_names::{ID, OWNER_ID}; +use crate::document::property_names::{CREATOR_ID, ID, OWNER_ID}; use crate::identity::Purpose; +use crate::nft::TradeMode; use crate::util::json_schema::resolve_uri; use crate::validation::operations::ProtocolValidationOperation; use crate::ProtocolError; @@ -1270,15 +1279,25 @@ fn parse_reference_target( "moderatedDocument" => { let (contract_id, document_type_name) = parse_document_reference_type(refers_to_map, reference_type)?; - let property_agreement = parse_where(refers_to_map, reference_type)?; - if let Some(referenced_property) = - property_agreement - .iter() - .find_map(|(referring_property, referenced_property)| { - (is_referring_system_agreement_property(referring_property) - && !matches!(referenced_property.as_str(), OWNER_ID | ID)) - .then_some(referenced_property) - }) + let (property_agreement, where_conditions) = + parse_where(refers_to_map, reference_type)?; + let gates = property_agreement + .iter() + .filter(|(referring_property, _)| { + is_referring_system_agreement_property(referring_property) + }) + .map(|(_, referenced_property)| referenced_property) + .chain( + where_conditions + .iter() + .filter(|(_, condition)| { + matches!(condition, WhereCondition::NotEqual(WhereBound::Writer)) + }) + .map(|(referenced_property, _)| referenced_property), + ); + if let Some(referenced_property) = gates + .into_iter() + .find(|referenced_property| !matches!(referenced_property.as_str(), OWNER_ID | ID)) { return Err(DataContractError::InvalidContractStructure(format!( "moderatedDocument refersTo where compares the writer with \ @@ -1292,6 +1311,7 @@ fn parse_reference_target( contract_id, document_type_name, property_agreement, + where_conditions, } } // The two document targets share one declaration shape; they differ in @@ -1304,7 +1324,8 @@ fn parse_reference_target( let (contract_id, document_type_name) = parse_document_reference_type(refers_to_map, document_target)?; - let property_agreement = parse_where(refers_to_map, document_target)?; + let (property_agreement, where_conditions) = + parse_where(refers_to_map, document_target)?; let find_by = refers_to_map.get(property_names::FIND_BY); if refers_to_map.contains_key(property_names::IN_LIST) { @@ -1323,6 +1344,7 @@ fn parse_reference_target( contract_id, document_type_name, property_agreement, + where_conditions, )?, )); } @@ -1335,11 +1357,13 @@ fn parse_reference_target( contract_id, document_type_name, property_agreement, + where_conditions, }, _ => DocumentPropertyReferenceTarget::DeletableDocument { contract_id, document_type_name, property_agreement, + where_conditions, }, }); }; @@ -1360,6 +1384,7 @@ fn parse_reference_target( contract_id, document_type_name, property_agreement, + where_conditions, lookup, } } @@ -1381,6 +1406,7 @@ fn parse_reference_target( contract_id, document_type_name, property_agreement, + where_conditions, lookup, } } @@ -1413,21 +1439,33 @@ fn parse_reference_target( Ok(target) } +/// A parsed `where`: the equalities, `{referring property: referenced +/// property}`, and the other entries, `{referenced property: condition}`. +type ParsedWhere = (BTreeMap, BTreeMap); + /// The `where` of a document reference of `document_target`: what the /// referenced document must hold once found, each entry keyed by a property of /// the referenced document type (a property path, or `$ownerId`, `$creatorId` -/// or `$id`) and mapped to the referring-side value it must equal (a property -/// path of the declaring type, or `$ownerId`, the writer). Returned the way -/// the parsed model holds it, `{referring property: referenced property}`, so -/// a referring value may be compared once. `where` never finds the document -/// and reads neither `"."` nor a function, which belong to `findBy`. What the -/// names resolve to is checked at registration. +/// or `$id`). An entry valued with a referring-side value it must equal (a +/// property path of the declaring type, or `$ownerId`, the writer) is an +/// equality, returned the way the parsed model holds it, `{referring property: +/// referenced property}`, so a referring value may be compared once. Every +/// other entry is an object with one key, a [`WhereCondition`]: `const`, a +/// literal it must equal; `notEqual`, a referring property, `"$ownerId"` or a +/// `const` it must differ from; `in`, two or more distinct literals of one +/// kind it must be one of; or an ordering (`greaterThan`, `greaterThanOrEqual`, +/// `lessThan`, `lessThanOrEqual`) with a referring property, an integer +/// `const` or a time or height of the referring document as its bound, never +/// of an identifier. `where` never finds the document and reads neither `"."` +/// nor a function, which belong to `findBy`. What the names resolve to, and +/// whether a literal fits the property it is compared with, is checked at +/// registration. fn parse_where( refers_to_map: &BTreeMap, document_target: &str, -) -> Result, DataContractError> { +) -> Result { let Some(where_value) = refers_to_map.get(property_names::WHERE) else { - return Ok(BTreeMap::new()); + return Ok((BTreeMap::new(), BTreeMap::new())); }; let where_map = where_value.to_btree_ref_string_map()?; if where_map.is_empty() || where_map.len() > 10 { @@ -1436,6 +1474,7 @@ fn parse_where( ))); } let mut pairs = BTreeMap::new(); + let mut conditions = BTreeMap::new(); for (referenced_property, value) in where_map { if referenced_property.is_empty() || referenced_property.len() > MAX_PROPERTY_PATH_LENGTH { return Err(DataContractError::InvalidContractStructure(format!( @@ -1443,31 +1482,6 @@ fn parse_where( characters" ))); } - if matches!(value, Value::Map(_)) { - return Err(DataContractError::InvalidContractStructure(format!( - "refersTo where \"{referenced_property}\" holds a function, which computes a \ - key that finds the document: declare it in findBy" - ))); - } - let referring_property = value.as_text().ok_or_else(|| { - DataContractError::InvalidContractStructure( - "refersTo where values must name a property of the referring document type or \ - \"$ownerId\", the writer" - .to_string(), - ) - })?; - if referring_property.is_empty() || referring_property.len() > MAX_PROPERTY_PATH_LENGTH { - return Err(DataContractError::InvalidContractStructure(format!( - "refersTo where property paths must be between 1 and {MAX_PROPERTY_PATH_LENGTH} \ - characters" - ))); - } - if referring_property == LOOKUP_REFERENCE_VALUE { - return Err(DataContractError::InvalidContractStructure(format!( - "refersTo where \"{referenced_property}\" reads \".\", the reference's own \ - value, which only findBy reads" - ))); - } // Either side may name a system property, but only one the // comparison can read back as an identifier: `$ownerId`, `$creatorId` // or `$id` on the referenced side; the writer's `$ownerId` on the @@ -1481,15 +1495,21 @@ fn parse_where( .to_string(), )); } - if referring_property.starts_with('$') - && !is_referring_system_agreement_property(referring_property) - { - return Err(DataContractError::InvalidContractStructure( + if let Value::Map(_) = value { + let condition = parse_where_condition(&referenced_property, value)?; + conditions.insert(referenced_property, condition); + continue; + } + let referring_property = value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( "refersTo where values must name a property of the referring document type or \ - its $ownerId" + \"$ownerId\", the writer, or be an object holding const, notEqual, in or an \ + ordering" .to_string(), - )); - } + ) + })?; + let referring_property = + parse_where_referring_property(&referenced_property, referring_property)?; if pairs .insert(referring_property.to_string(), referenced_property.clone()) .is_some() @@ -1500,7 +1520,173 @@ fn parse_where( ))); } } - Ok(pairs) + Ok((pairs, conditions)) +} + +/// The referring-side value a `where` entry keyed by `referenced_property` +/// names, `referring_property`: a property path of the declaring type, or +/// `$ownerId`, the writer. Neither `"."`, which only `findBy` reads, nor any +/// other system property. +fn parse_where_referring_property<'a>( + referenced_property: &str, + referring_property: &'a str, +) -> Result<&'a str, DataContractError> { + if referring_property.is_empty() || referring_property.len() > MAX_PROPERTY_PATH_LENGTH { + return Err(DataContractError::InvalidContractStructure(format!( + "refersTo where property paths must be between 1 and {MAX_PROPERTY_PATH_LENGTH} \ + characters" + ))); + } + if referring_property == LOOKUP_REFERENCE_VALUE { + return Err(DataContractError::InvalidContractStructure(format!( + "refersTo where \"{referenced_property}\" reads \".\", the reference's own \ + value, which only findBy reads" + ))); + } + if referring_property.starts_with('$') + && !is_referring_system_agreement_property(referring_property) + { + return Err(DataContractError::InvalidContractStructure( + "refersTo where values must name a property of the referring document type or \ + its $ownerId" + .to_string(), + )); + } + Ok(referring_property) +} + +/// The [`WhereCondition`] a `where` entry keyed by `referenced_property` +/// declares with `value`, an object with one key: `const`, `notEqual`, `in` or +/// an ordering. Any other key is refused: a function computes a key that +/// finds the document, which belongs to `findBy`. +fn parse_where_condition( + referenced_property: &str, + value: &Value, +) -> Result { + let invalid = |message: String| { + DataContractError::InvalidContractStructure(format!( + "refersTo where \"{referenced_property}\" {message}" + )) + }; + let Value::Map(entries) = value else { + return Err(invalid("must be an object".to_string())); + }; + if entries + .iter() + .any(|(key, _)| key.as_text() == Some(property_names::FUNCTION)) + { + return Err(invalid( + "holds a function, which computes a key that finds the document: declare it in \ + findBy" + .to_string(), + )); + } + let [(Value::Text(key), body)] = entries.as_slice() else { + return Err(invalid( + "must be an object with one key: const, notEqual, in, greaterThan, \ + greaterThanOrEqual, lessThan or lessThanOrEqual" + .to_string(), + )); + }; + let literal = |value: &Value, at: &str| { + WhereLiteral::from_value(value).ok_or_else(|| { + invalid(format!( + "{at} must be an integer, true, false or a string (a base58 identifier for an \ + identifier, hex for a byte array)" + )) + }) + }; + // `{ "const": literal }`, the one object a bound may be + let constant = |value: &Value, at: &str| match value { + Value::Map(entries) => match entries.as_slice() { + [(Value::Text(key), literal_value)] if key == WHERE_CONST => { + literal(literal_value, &format!("{at}.const")) + } + _ => Err(invalid(format!( + "{at} must be a property path, or {{ \"const\": ... }}" + ))), + }, + _ => Err(invalid(format!( + "{at} must be a property path, or {{ \"const\": ... }}" + ))), + }; + let identifier_side = referenced_property.starts_with('$'); + match key.as_str() { + WHERE_CONST => Ok(WhereCondition::Constant(literal(body, WHERE_CONST)?)), + WHERE_NOT_EQUAL => { + let bound = match body { + Value::Text(path) if path == OWNER_ID => WhereBound::Writer, + Value::Text(path) => WhereBound::Property( + parse_where_referring_property(referenced_property, path)?.to_string(), + ), + body => WhereBound::Constant(constant(body, WHERE_NOT_EQUAL)?), + }; + Ok(WhereCondition::NotEqual(bound)) + } + WHERE_IN => { + let Some(values) = body.as_array().filter(|values| values.len() >= 2) else { + return Err(invalid("in must list two or more values".to_string())); + }; + let mut literals = BTreeSet::new(); + let mut first_kind = None; + for (index, value) in values.iter().enumerate() { + let parsed = literal(value, &format!("in[{index}]"))?; + let kind = std::mem::discriminant(&parsed); + if *first_kind.get_or_insert(kind) != kind { + return Err(invalid(format!( + "in[{index}] is of another kind than in[0]: the values an in lists are \ + all integers, all booleans or all strings" + ))); + } + if !literals.insert(parsed) { + return Err(invalid(format!("in[{index}] repeats an earlier value"))); + } + } + Ok(WhereCondition::In(literals)) + } + ordering_key => { + let Some(ordering) = WhereOrdering::ALL + .into_iter() + .find(|ordering| ordering.wire_name() == ordering_key) + else { + return Err(invalid(format!( + "names \"{ordering_key}\", which is not const, notEqual, in, greaterThan, \ + greaterThanOrEqual, lessThan or lessThanOrEqual: a function computes a key \ + that finds the document, which belongs in findBy" + ))); + }; + if identifier_side { + return Err(invalid(format!( + "orders an identifier, which {ordering_key} does not: an ordering compares \ + integers" + ))); + } + let bound = match body { + Value::Text(path) if path == OWNER_ID => { + return Err(invalid(format!( + "orders by $ownerId, an identifier, which {ordering_key} does not: an \ + ordering compares integers" + ))); + } + Value::Text(path) => match SystemProperty::from_name(path) { + Some(property) => WhereBound::System(property), + None => WhereBound::Property( + parse_where_referring_property(referenced_property, path)?.to_string(), + ), + }, + body => match constant(body, ordering_key)? { + integer @ WhereLiteral::Integer(_) => WhereBound::Constant(integer), + _ => { + return Err(invalid(format!( + "{ordering_key}.const must be an integer: an ordering compares \ + integers" + ))) + } + }, + }; + Ok(WhereCondition::Ordering { ordering, bound }) + } + } } /// A reference to a list the value must be an element of, declared as a @@ -1525,6 +1711,7 @@ fn parse_list_element_reference( contract_id: Option, document_type_name: String, mut property_agreement: BTreeMap, + where_conditions: BTreeMap, ) -> Result { if refers_to_map.contains_key(property_names::MINIMUM_AGE_BLOCKS) || refers_to_map.contains_key(property_names::CONSUME) @@ -1563,6 +1750,7 @@ fn parse_list_element_reference( if property_agreement .values() .any(|referenced| referenced.as_str() == ID) + || where_conditions.contains_key(ID) { return Err(DataContractError::InvalidContractStructure( "permanentDocument refersTo inList compares $id in findBy, so where may not compare \ @@ -1594,6 +1782,7 @@ fn parse_list_element_reference( contract_id, document_type_name, property_agreement, + where_conditions, in_list: in_list.to_string(), }) } @@ -2068,14 +2257,7 @@ pub(super) fn validate_reference_lookup_sources( }; if let Some(reason) = lookup .referring_side_error(document_type, holder.path()) - .or_else(|| { - create_only_leaf_error( - document_type, - &leaf_path, - lookup, - declaration.property_agreement, - ) - }) + .or_else(|| create_only_leaf_error(document_type, &leaf_path, lookup, &declaration)) .or_else(|| age_or_consume_error(document_type, lookup)) { let at = if leaf_path.is_empty() { @@ -2122,13 +2304,14 @@ fn age_or_consume_error( /// asks again would never be asked; /// * every `where` entry beside the function reads a referring property that /// is transient or fixed once written, since no replace checks the entry -/// again. `"$ownerId"` is exempt: it names the writer of the create, whose -/// own commitment the reveal is. +/// again, the property an equality compares and the one a condition is +/// bounded by alike. `"$ownerId"` is exempt: it names the writer of the +/// create, whose own commitment the reveal is. fn create_only_leaf_error( document_type: DocumentTypeRef, leaf_path: &str, lookup: &DocumentReferenceLookup, - property_agreement: &BTreeMap, + declaration: &DocumentReferenceDeclaration, ) -> Option { if !lookup.is_checked_on_create_only(document_type.documents_mutable()) { return None; @@ -2144,9 +2327,9 @@ fn create_only_leaf_error( .to_string(), ); } - property_agreement - .keys() - .filter(|referring| referring.as_str() != OWNER_ID) + declaration + .where_referring_properties() + .filter(|referring| *referring != OWNER_ID) .find(|referring| { !is_transient(document_type, referring) && (!schema_property_is_fixed_once_written(document_type, referring) @@ -2741,6 +2924,7 @@ fn apply_property_constraints_v0( &format!("rule \"{name}\""), constraint, false, + true, &structure_error, )?; } @@ -2875,6 +3059,7 @@ fn apply_delete_constraints_v0( &format!("rule \"{name}\""), constraint, false, + false, &structure_error, )?; } @@ -2936,6 +3121,283 @@ fn stored_path<'a>( } } +/// Why `document_type` has no property at `lookup` a rule `subject` can read +/// as `read`, the message naming it `path`; `None` when it has one: of the kind +/// the read needs, neither transient nor inside a transient object. Shared by +/// the reads of the type's own properties and those of a referenced document. +pub(super) fn property_read_error( + document_type: DocumentTypeRef, + subject: &str, + path: &str, + lookup: &str, + read: PropertyRead, +) -> Option { + let reads = match read { + PropertyRead::Value => "reads", + PropertyRead::Presence => "tests the presence of", + PropertyRead::Text | PropertyRead::Identifier => "compares", + PropertyRead::Length => "measures", + PropertyRead::Count => "counts the items of", + PropertyRead::Elements(_) => "looks in", + PropertyRead::Bytes { .. } => "reads the bytes of", + }; + match read { + PropertyRead::Value => match document_type + .flattened_properties() + .get(lookup) + .map(|property| &property.property_type) + { + // `is_integer` leaves out the 128-bit types, which the arithmetic holds + // too; a boolean reads as 1 for true and 0 for false + Some(property_type) + if property_type.is_integer() + || matches!( + property_type, + DocumentPropertyType::U128 + | DocumentPropertyType::I128 + | DocumentPropertyType::Boolean + ) => {} + // A string is compared with constants, never read as a number + Some(DocumentPropertyType::String(_)) => { + return Some(format!( + "{subject} reads \"{path}\", which has type string, not integer \ + or boolean: a string property is compared, by equal or notEqual, \ + with a {{ \"const\": ... }} or another string property, or with the \ + strings an in lists" + )); + } + // An identifier is compared with identifiers, never read as a number + Some(property_type) if property_type.is_identifier() => { + return Some(format!( + "{subject} reads \"{path}\", which has type identifier, not \ + integer or boolean: an identifier property is compared, by equal or \ + notEqual, with a {{ \"const\": base58 }} or another identifier \ + property, or with the identifiers an in lists" + )); + } + // A byte array is read a byte at a time + Some(DocumentPropertyType::ByteArray(_)) => { + return Some(format!( + "{subject} reads \"{path}\", which has type byteArray, not integer \ + or boolean: byteAt reads one of its bytes, and count how many it holds" + )); + } + Some(other) => { + return Some(format!( + "{subject} reads \"{path}\", which has type {}, not integer or \ + boolean", + other.name() + )); + } + // An object is not in the flattened map either: only its members hold values + None => { + return Some(format!( + "{subject} reads \"{path}\", which is not an integer or boolean \ + property of the document type (a nested one is named by its dotted \ + path)" + )); + } + }, + PropertyRead::Presence => { + if property_at_path(document_type.properties(), lookup).is_none() { + return Some(format!( + "{subject} tests the presence of \"{path}\", which is not a \ + property of the document type (a nested one is named by its dotted \ + path)" + )); + } + } + PropertyRead::Text => match document_type + .flattened_properties() + .get(lookup) + .map(|property| &property.property_type) + { + Some(DocumentPropertyType::String(_)) => {} + Some(other) => { + return Some(format!( + "{subject} compares \"{path}\" with a string, but it has type \ + {}, not string", + other.name() + )); + } + None => { + return Some(format!( + "{subject} compares \"{path}\" with a string, but it is not a \ + string property of the document type (a nested one is named by its \ + dotted path)" + )); + } + }, + PropertyRead::Length => match document_type + .flattened_properties() + .get(lookup) + .map(|property| &property.property_type) + { + Some(DocumentPropertyType::String(_)) => {} + Some(other) => { + return Some(format!( + "{subject} measures the length of \"{path}\", which has type {}, \ + not string: count gives the items of an array or byte array", + other.name() + )); + } + None => { + return Some(format!( + "{subject} measures the length of \"{path}\", which is not a \ + string property of the document type (a nested one is named by its \ + dotted path)" + )); + } + }, + PropertyRead::Count => match document_type + .flattened_properties() + .get(lookup) + .map(|property| &property.property_type) + { + Some( + DocumentPropertyType::TypedArray(_) + | DocumentPropertyType::ByteArray(_) + | DocumentPropertyType::Array(_) + | DocumentPropertyType::VariableTypeArray(_), + ) => {} + Some(DocumentPropertyType::String(_)) => { + return Some(format!( + "{subject} counts the items of \"{path}\", which has type \ + string, not array: length or byteLength gives the size of a string" + )); + } + Some(other) => { + return Some(format!( + "{subject} counts the items of \"{path}\", which has type {}, \ + not array or byteArray", + other.name() + )); + } + None => { + return Some(format!( + "{subject} counts the items of \"{path}\", which is not an array \ + or byte array property of the document type (a nested one is named \ + by its dotted path)" + )); + } + }, + PropertyRead::Elements(kind) => match document_type + .flattened_properties() + .get(lookup) + .map(|property| &property.property_type) + { + Some(DocumentPropertyType::TypedArray(array)) => { + let element_type = array.item_type.as_ref(); + let (holds, kind_name) = match kind { + ElementKind::Integer => ( + element_type.is_integer() + || matches!( + element_type, + DocumentPropertyType::U128 | DocumentPropertyType::I128 + ), + "an integer", + ), + ElementKind::Text => ( + matches!(element_type, DocumentPropertyType::String(_)), + "a string", + ), + ElementKind::Identifier => (element_type.is_identifier(), "an identifier"), + }; + if !holds { + return Some(format!( + "{subject} looks in \"{path}\" for {kind_name}, but its \ + elements have type {}: contains looks for an integer, a string \ + or an identifier among elements of that type", + element_type.name() + )); + } + } + Some(other) => { + return Some(format!( + "{subject} looks in \"{path}\", which has type {}, not an \ + array with items", + other.name() + )); + } + None => { + return Some(format!( + "{subject} looks in \"{path}\", which is not an array property \ + of the document type (a nested one is named by its dotted path)" + )); + } + }, + PropertyRead::Bytes { extent } => match document_type + .flattened_properties() + .get(lookup) + .map(|property| &property.property_type) + { + // A byte past the most the array may hold is never there + Some(DocumentPropertyType::ByteArray(sizes)) => { + if let Some(max_size) = sizes + .max_size + .filter(|max_size| extent > usize::from(*max_size)) + { + return Some(format!( + "{subject} reads \"{path}\" as at least {extent} bytes long, but \ + its maxItems is {max_size}: the byte a byteAt reads, or the \ + constant it must start or end with, is never there" + )); + } + } + Some(property_type) if property_type.is_identifier() => { + return Some(format!( + "{subject} reads the bytes of \"{path}\", which has type identifier, \ + not byteArray: an identifier property is compared whole, by equal, \ + notEqual or in" + )); + } + Some(other) => { + return Some(format!( + "{subject} reads the bytes of \"{path}\", which has type {}, not \ + byteArray", + other.name() + )); + } + None => { + return Some(format!( + "{subject} reads the bytes of \"{path}\", which is not a byte array \ + property of the document type (a nested one is named by its dotted \ + path)" + )); + } + }, + PropertyRead::Identifier => match document_type + .flattened_properties() + .get(lookup) + .map(|property| &property.property_type) + { + Some(property_type) if property_type.is_identifier() => {} + Some(other) => { + return Some(format!( + "{subject} compares \"{path}\" with an identifier, but it has \ + type {}, not identifier", + other.name() + )); + } + None => { + return Some(format!( + "{subject} compares \"{path}\" with an identifier, but it is \ + not an identifier property of the document type (a nested one is \ + named by its dotted path)" + )); + } + }, + } + if is_transient(document_type, lookup) { + return Some(format!( + "{subject} {reads} \"{path}\", which is transient or inside a \ + transient object: a transient value is never stored, so a stored document \ + could not be held to the rule" + )); + } + None +} + /// Checks every property `constraint` reads against `document_type`, as /// [`apply_property_constraints`] describes for a rule: each read names a /// property of the kind it reads, neither transient nor inside a transient @@ -2944,278 +3406,40 @@ fn stored_path<'a>( /// in the errors (`rule "fee"`), and `structure_error` wraps them. With /// `allow_stored_reads` (the condition of an `immutable` entry) a path may read /// the stored document through `$old.` and is judged as the path after it. +/// With `allow_referenced_reads` (a `propertyConstraints` rule) a +/// `referenced` operand may read a document one of the type's references +/// points at: what it reads is checked against the referenced type once it is +/// in hand ([`referenced_reads_error`]), and which reference it reads through +/// by [`validate_referencing_rules`]. pub(super) fn validate_property_constraint_reads( document_type: &DocumentTypeV2, schema_defs: Option<&BTreeMap>, subject: &str, constraint: &PropertyConstraint, allow_stored_reads: bool, + allow_referenced_reads: bool, structure_error: &dyn Fn(String) -> DataContractError, ) -> Result<(), DataContractError> { for (path, read) in constraint.property_reads() { - let lookup = stored_path(path, subject, allow_stored_reads).map_err(structure_error)?; - let reads = match read { - PropertyRead::Value => "reads", - PropertyRead::Presence => "tests the presence of", - PropertyRead::Text | PropertyRead::Identifier => "compares", - PropertyRead::Length => "measures", - PropertyRead::Count => "counts the items of", - PropertyRead::Elements(_) => "looks in", - PropertyRead::Bytes { .. } => "reads the bytes of", - }; - match read { - PropertyRead::Value => match document_type - .flattened_properties - .get(lookup) - .map(|property| &property.property_type) - { - // `is_integer` leaves out the 128-bit types, which the arithmetic holds - // too; a boolean reads as 1 for true and 0 for false - Some(property_type) - if property_type.is_integer() - || matches!( - property_type, - DocumentPropertyType::U128 - | DocumentPropertyType::I128 - | DocumentPropertyType::Boolean - ) => {} - // A string is compared with constants, never read as a number - Some(DocumentPropertyType::String(_)) => { - return Err(structure_error(format!( - "{subject} reads \"{path}\", which has type string, not integer \ - or boolean: a string property is compared, by equal or notEqual, \ - with a {{ \"const\": ... }} or another string property, or with the \ - strings an in lists" - ))); - } - // An identifier is compared with identifiers, never read as a number - Some(property_type) if property_type.is_identifier() => { - return Err(structure_error(format!( - "{subject} reads \"{path}\", which has type identifier, not \ - integer or boolean: an identifier property is compared, by equal or \ - notEqual, with a {{ \"const\": base58 }} or another identifier \ - property, or with the identifiers an in lists" - ))); - } - // A byte array is read a byte at a time - Some(DocumentPropertyType::ByteArray(_)) => { - return Err(structure_error(format!( - "{subject} reads \"{path}\", which has type byteArray, not integer \ - or boolean: byteAt reads one of its bytes, and count how many it holds" - ))); - } - Some(other) => { - return Err(structure_error(format!( - "{subject} reads \"{path}\", which has type {}, not integer or \ - boolean", - other.name() - ))); - } - // An object is not in the flattened map either: only its members hold values - None => { - return Err(structure_error(format!( - "{subject} reads \"{path}\", which is not an integer or boolean \ - property of the document type (a nested one is named by its dotted \ - path)" - ))); - } - }, - PropertyRead::Presence => { - if property_at_path(&document_type.properties, lookup).is_none() { - return Err(structure_error(format!( - "{subject} tests the presence of \"{path}\", which is not a \ - property of the document type (a nested one is named by its dotted \ - path)" - ))); - } + if path.starts_with(REFERENCED_DOCUMENT_PREFIX) { + if !allow_referenced_reads { + return Err(structure_error(format!( + "{subject} reads a value of the document a reference points at, which only \ + a propertyConstraints rule does: its writes are the ones that check the \ + references" + ))); } - PropertyRead::Text => match document_type - .flattened_properties - .get(lookup) - .map(|property| &property.property_type) - { - Some(DocumentPropertyType::String(_)) => {} - Some(other) => { - return Err(structure_error(format!( - "{subject} compares \"{path}\" with a string, but it has type \ - {}, not string", - other.name() - ))); - } - None => { - return Err(structure_error(format!( - "{subject} compares \"{path}\" with a string, but it is not a \ - string property of the document type (a nested one is named by its \ - dotted path)" - ))); - } - }, - PropertyRead::Length => match document_type - .flattened_properties - .get(lookup) - .map(|property| &property.property_type) - { - Some(DocumentPropertyType::String(_)) => {} - Some(other) => { - return Err(structure_error(format!( - "{subject} measures the length of \"{path}\", which has type {}, \ - not string: count gives the items of an array or byte array", - other.name() - ))); - } - None => { - return Err(structure_error(format!( - "{subject} measures the length of \"{path}\", which is not a \ - string property of the document type (a nested one is named by its \ - dotted path)" - ))); - } - }, - PropertyRead::Count => match document_type - .flattened_properties - .get(lookup) - .map(|property| &property.property_type) - { - Some( - DocumentPropertyType::TypedArray(_) - | DocumentPropertyType::ByteArray(_) - | DocumentPropertyType::Array(_) - | DocumentPropertyType::VariableTypeArray(_), - ) => {} - Some(DocumentPropertyType::String(_)) => { - return Err(structure_error(format!( - "{subject} counts the items of \"{path}\", which has type \ - string, not array: length or byteLength gives the size of a string" - ))); - } - Some(other) => { - return Err(structure_error(format!( - "{subject} counts the items of \"{path}\", which has type {}, \ - not array or byteArray", - other.name() - ))); - } - None => { - return Err(structure_error(format!( - "{subject} counts the items of \"{path}\", which is not an array \ - or byte array property of the document type (a nested one is named \ - by its dotted path)" - ))); - } - }, - PropertyRead::Elements(kind) => match document_type - .flattened_properties - .get(lookup) - .map(|property| &property.property_type) - { - Some(DocumentPropertyType::TypedArray(array)) => { - let element_type = array.item_type.as_ref(); - let (holds, kind_name) = match kind { - ElementKind::Integer => ( - element_type.is_integer() - || matches!( - element_type, - DocumentPropertyType::U128 | DocumentPropertyType::I128 - ), - "an integer", - ), - ElementKind::Text => ( - matches!(element_type, DocumentPropertyType::String(_)), - "a string", - ), - ElementKind::Identifier => (element_type.is_identifier(), "an identifier"), - }; - if !holds { - return Err(structure_error(format!( - "{subject} looks in \"{path}\" for {kind_name}, but its \ - elements have type {}: contains looks for an integer, a string \ - or an identifier among elements of that type", - element_type.name() - ))); - } - } - Some(other) => { - return Err(structure_error(format!( - "{subject} looks in \"{path}\", which has type {}, not an \ - array with items", - other.name() - ))); - } - None => { - return Err(structure_error(format!( - "{subject} looks in \"{path}\", which is not an array property \ - of the document type (a nested one is named by its dotted path)" - ))); - } - }, - PropertyRead::Bytes { extent } => match document_type - .flattened_properties - .get(lookup) - .map(|property| &property.property_type) - { - // A byte past the most the array may hold is never there - Some(DocumentPropertyType::ByteArray(sizes)) => { - if let Some(max_size) = sizes - .max_size - .filter(|max_size| extent > usize::from(*max_size)) - { - return Err(structure_error(format!( - "{subject} reads \"{path}\" as at least {extent} bytes long, but \ - its maxItems is {max_size}: the byte a byteAt reads, or the \ - constant it must start or end with, is never there" - ))); - } - } - Some(property_type) if property_type.is_identifier() => { - return Err(structure_error(format!( - "{subject} reads the bytes of \"{path}\", which has type identifier, \ - not byteArray: an identifier property is compared whole, by equal, \ - notEqual or in" - ))); - } - Some(other) => { - return Err(structure_error(format!( - "{subject} reads the bytes of \"{path}\", which has type {}, not \ - byteArray", - other.name() - ))); - } - None => { - return Err(structure_error(format!( - "{subject} reads the bytes of \"{path}\", which is not a byte array \ - property of the document type (a nested one is named by its dotted \ - path)" - ))); - } - }, - PropertyRead::Identifier => match document_type - .flattened_properties - .get(lookup) - .map(|property| &property.property_type) - { - Some(property_type) if property_type.is_identifier() => {} - Some(other) => { - return Err(structure_error(format!( - "{subject} compares \"{path}\" with an identifier, but it has \ - type {}, not identifier", - other.name() - ))); - } - None => { - return Err(structure_error(format!( - "{subject} compares \"{path}\" with an identifier, but it is \ - not an identifier property of the document type (a nested one is \ - named by its dotted path)" - ))); - } - }, + continue; } - if is_transient(DocumentTypeRef::V2(document_type), lookup) { - return Err(structure_error(format!( - "{subject} {reads} \"{path}\", which is transient or inside a \ - transient object: a transient value is never stored, so a stored document \ - could not be held to the rule" - ))); + let lookup = stored_path(path, subject, allow_stored_reads).map_err(structure_error)?; + if let Some(error) = property_read_error( + DocumentTypeRef::V2(document_type), + subject, + path, + lookup, + read, + ) { + return Err(structure_error(error)); } } // An indexOnly type's delete carries its row's values but not its owner, so @@ -3279,7 +3503,13 @@ pub(super) fn validate_property_constraint_reads( } // A constant or a default a string property's `enum` does not list is a // typo: the property could never hold it - for (path, constant) in constraint.text_constants() { + // The enums of a referenced type are checked with what is read of it + let own = |path: &&str| !path.starts_with(REFERENCED_DOCUMENT_PREFIX); + for (path, constant) in constraint + .text_constants() + .into_iter() + .filter(|(path, _)| own(path)) + { let lookup = stored_path(path, subject, allow_stored_reads).map_err(structure_error)?; if !enum_admits(&document_type.schema, schema_defs, lookup, constant)? { return Err(structure_error(format!( @@ -3290,7 +3520,11 @@ pub(super) fn validate_property_constraint_reads( } // A constant a string property must start or end with, when the property // declares an `enum`, must fit one of its values, or the test never holds - for (path, affix, position) in constraint.text_affixes() { + for (path, affix, position) in constraint + .text_affixes() + .into_iter() + .filter(|(path, _, _)| own(path)) + { let lookup = stored_path(path, subject, allow_stored_reads).map_err(structure_error)?; if !enum_any(&document_type.schema, schema_defs, lookup, |member| { position.holds(member, affix) @@ -3305,7 +3539,11 @@ pub(super) fn validate_property_constraint_reads( ))); } } - for (path, default) in constraint.text_defaults() { + for (path, default) in constraint + .text_defaults() + .into_iter() + .filter(|(path, _)| own(path)) + { let lookup = stored_path(path, subject, allow_stored_reads).map_err(structure_error)?; if !enum_admits(&document_type.schema, schema_defs, lookup, default)? { return Err(structure_error(format!( @@ -3317,6 +3555,378 @@ pub(super) fn validate_property_constraint_reads( Ok(()) } +/// Checks the declaring side of every `propertyConstraints` rule of +/// `document_type`, named `name`, that reads a document one of its references +/// points at (`{ "referenced": [reference, field] }`), once its properties, +/// references and moderator fields are parsed: +/// +/// * the reference is a top-level property of the type, not transient, holding +/// one reference to one document: `permanentDocument` or +/// `deletableDocument` by id or by a `findBy` without a function, or +/// `moderatedDocument`, not the elements of a typed array, an `anyOf` or +/// `allOf`, an `inList` or a `findBy` function, which is judged on the +/// create alone ([`referencing_property_error`]); +/// * the type is not indexOnly: the delete of such a document is judged by +/// the rules but checks no reference; +/// * on a type whose documents can be transferred or traded, the rule does not +/// read what a transfer or a purchase changes, nor, on a type whose prices +/// can be set, what a price update changes: those writes check no reference, +/// so they could not judge it; +/// * it reads no field the type keeps for its moderators to change, which +/// their change judges without the references either. +/// +/// What it reads of the referenced type is checked once that type is in hand +/// ([`referenced_reads_error`]). Registration only, like the rest of a rule's +/// limits: a stored contract passed it. +pub(super) fn validate_referencing_rules( + document_type: DocumentTypeRef, + name: &str, +) -> Result<(), DataContractError> { + let can_change_owner = owner_can_change(document_type); + let can_change_price = document_type.trade_mode() != TradeMode::None; + for (rule, constraint) in document_type.property_constraints() { + let reads = constraint.referenced_reads(); + if reads.is_empty() { + continue; + } + let error = |message: String| { + DataContractError::InvalidContractStructure(format!( + "document type \"{name}\" {} rule \"{rule}\" {message}", + property_names::PROPERTY_CONSTRAINTS + )) + }; + if document_type.index_only() { + return Err(error( + "reads a document a reference points at, but the type is indexOnly: the delete \ + of one of its documents is judged by its rules and checks no reference" + .to_string(), + )); + } + let references: BTreeSet<&str> = reads.iter().map(|(reference, _, _)| *reference).collect(); + for reference in references { + if let Some(reason) = referencing_property_error(document_type, reference) { + return Err(error(format!("reads through \"{reference}\", {reason}"))); + } + } + if can_change_owner && constraint.reads_change(SystemChange::Transfer) { + return Err(error( + "reads a document a reference points at and what a transfer or a purchase \ + changes ($ownerId, $transferredAt and its heights, or a total depending on the \ + owner): a transfer or a purchase checks no reference, so it could not judge \ + the rule" + .to_string(), + )); + } + if can_change_price && constraint.reads_change(SystemChange::PriceUpdate) { + return Err(error( + "reads a document a reference points at and what a price update changes \ + ($updatedAt and its heights): a price update checks no reference, so it could \ + not judge the rule" + .to_string(), + )); + } + if let Some(field) = constraint.property_paths().into_iter().find(|path| { + !path.starts_with(REFERENCED_DOCUMENT_PREFIX) + && is_path_listed(document_type.moderator_changeable_fields(), path) + }) { + return Err(error(format!( + "reads a document a reference points at and \"{field}\", which the type keeps \ + for its moderators to change: their change checks no reference, so it could \ + not judge the rule" + ))); + } + } + Ok(()) +} + +/// Why a rule of `document_type` may not read a document through its property +/// `reference`, `None` when it may: it must be a top-level property of the +/// type, not transient, holding one reference to one document it finds by id +/// or by a `findBy` without a function (a string or byte array property whose +/// value `findBy` reads included), so the reference check reads exactly that +/// document whenever the rule is judged. +fn referencing_property_error(document_type: DocumentTypeRef, reference: &str) -> Option { + let Some(property) = document_type.flattened_properties().get(reference) else { + return Some( + "which is not a property of the document type holding a reference: a \ + reference read through is a top-level property" + .to_string(), + ); + }; + if is_transient(document_type, reference) { + return Some( + "which is transient: the reference a stored document was judged through would \ + not be stored" + .to_string(), + ); + } + let target = match property.property_type.reference() { + Some(PropertyReference::Value(target)) => target, + Some(PropertyReference::Elements { .. }) => { + return Some( + "whose refersTo is on the elements of a typed array, which point at several \ + documents" + .to_string(), + ) + } + Some(PropertyReference::KeyId(_)) => { + return Some("whose refersTo names an identity key, not a document".to_string()) + } + Some(PropertyReference::Revealed(target)) => target, + None => match &property.revealed_reference { + Some(target) => target, + None => return Some("which carries no refersTo".to_string()), + }, + }; + if target.combinator().is_some() { + return Some( + "whose refersTo is an anyOf or allOf, which may hold through one document or \ + another" + .to_string(), + ); + } + let Some(declaration) = target.as_any_document_reference() else { + return Some( + "whose refersTo names an identity, a contract, a token or a key, not a document" + .to_string(), + ); + }; + if declaration.in_list.is_some() { + return Some( + "whose refersTo has inList: its value is an element of a list, not a document" + .to_string(), + ); + } + if declaration + .lookup + .is_some_and(|lookup| lookup.hash_key().is_some()) + { + return Some( + "whose findBy computes its key with a function: such a reference is judged on the \ + create alone, and a replace, which every rule judges, reads no document through it" + .to_string(), + ); + } + None +} + +/// Why a `propertyConstraints` rule, `subject` in the messages, can not read +/// what it reads through its reference `reference` of a document of +/// `referenced`, a type the reference declares of `kind`; `None` when it can. +/// `referenced_schema_defs` are the `$defs` of the referenced type's contract, +/// which resolve the `enum`s its constants are checked against, and +/// `records_creator_id` whether the referenced type records `$creatorId`. +/// +/// * `$id`, `$ownerId` and `$creatorId` are identifiers, compared by `equal`, +/// `notEqual`, `in` or `contains`; `$creatorId` only of a type that records +/// it. +/// * A time or height is an integer, of a type that lists it in `required`. +/// * A schema property is of the kind the rule reads it as, stored (not +/// `transient` nor inside a transient object), and a string constant or +/// default compared with it, or one it must start or end with, fits its +/// `enum`. +/// * Through a `moderatedDocument`, which may resolve to the record of its +/// removal: `$id`, `$ownerId` or a field the referenced type keeps under +/// `moderatorAbilities.deleteKeepsFields` (or one inside a kept object), +/// which every record holds, since a rule is judged on every replace. +/// +/// Checked by the contract parse for a type of the same contract +/// (`InvalidContractStructure`, 10231), and at registration against the +/// stored contract for another contract's (`ReferencedDocumentRuleReadInvalidError`, +/// 40148). +pub fn referenced_reads_error( + rule: &PropertyConstraint, + subject: &str, + reference: &str, + kind: DocumentReferenceKind, + referenced: DocumentTypeRef, + referenced_schema_defs: Option<&BTreeMap>, + records_creator_id: bool, +) -> Result, DataContractError> { + let label = |field: &str| format!("{reference}.{field}"); + for (read_reference, field, read) in rule.referenced_reads() { + if read_reference != reference { + continue; + } + if kind == DocumentReferenceKind::Moderated + && field != ID + && field != OWNER_ID + && !is_path_listed(referenced.moderator_deletion_kept_fields(), field) + { + return Ok(Some(format!( + "{subject} reads \"{}\" through a moderatedDocument reference: once a \ + moderator removes the document only its record is left, which keeps $id, \ + $ownerId and the fields its type lists under deleteKeepsFields, and a rule is \ + judged on every replace", + label(field) + ))); + } + if REFERENCED_IDENTIFIER_FIELDS.contains(&field) { + if read != PropertyRead::Identifier { + return Ok(Some(format!( + "{subject} reads \"{}\", an identifier, which a rule compares by equal, \ + notEqual or in, or looks for with contains", + label(field) + ))); + } + if field == CREATOR_ID && !records_creator_id { + return Ok(Some(format!( + "{subject} reads \"{}\", but the referenced document type does not \ + record $creatorId: only a transferable or tradeable document type of a \ + format-1 contract does", + label(field) + ))); + } + continue; + } + if let Some(system) = SystemProperty::from_name(field) { + if read != PropertyRead::Value { + return Ok(Some(format!( + "{subject} reads \"{}\", a time or height, which a rule reads as an \ + integer", + label(field) + ))); + } + if !referenced.required_fields().contains(system.name()) { + return Ok(Some(format!( + "{subject} reads \"{}\", which the referenced document type does not \ + record: it must list it in required", + label(field) + ))); + } + continue; + } + if let Some(reason) = property_read_error(referenced, subject, &label(field), field, read) { + return Ok(Some(reason)); + } + } + // A constant or a default the referenced property's `enum` does not list + fn read_through<'a>(path: &'a str, reference: &str) -> Option<&'a str> { + referenced_read(path).and_then(|(read_reference, field)| { + (read_reference == reference && !field.starts_with('$')).then_some(field) + }) + } + let schema = referenced.schema(); + for (path, constant) in rule.text_constants() { + let Some(field) = read_through(path, reference) else { + continue; + }; + if !enum_admits(schema, referenced_schema_defs, field, constant)? { + return Ok(Some(format!( + "{subject} compares \"{}\" with \"{constant}\", which is not one of its enum \ + values", + label(field) + ))); + } + } + for (path, affix, position) in rule.text_affixes() { + let Some(field) = read_through(path, reference) else { + continue; + }; + if !enum_any(schema, referenced_schema_defs, field, |member| { + position.holds(member, affix) + })? { + return Ok(Some(format!( + "{subject} tests \"{}\" against \"{affix}\", which none of its enum values \ + fits", + label(field) + ))); + } + } + for (path, default) in rule.text_defaults() { + let Some(field) = read_through(path, reference) else { + continue; + }; + if !enum_admits(schema, referenced_schema_defs, field, default)? { + return Ok(Some(format!( + "{subject} gives \"{}\" the default \"{default}\", which is not one of its \ + enum values", + label(field) + ))); + } + } + Ok(None) +} + +/// Checks what every `propertyConstraints` rule of `document_types`, one +/// contract's, reads of a document of the same contract one of its references +/// points at ([`referenced_reads_error`]), once all of them are parsed. A +/// reference into a type the contract does not have, or of a kind the type +/// does not admit, is left to the reference checks, which report it, and one +/// into another contract to registration, which reads that contract. +/// `contract_id`, `contract_system_version` and `contract_config_version` +/// describe the contract, whose types record `$creatorId` as they say. +pub(in crate::data_contract::document_type::class_methods) fn validate_property_constraint_referenced_reads( + document_types: &BTreeMap, + contract_id: Identifier, + contract_system_version: u16, + contract_config_version: u16, + schema_defs: Option<&BTreeMap>, + platform_version: &PlatformVersion, +) -> Result<(), ProtocolError> { + for (type_name, document_type) in document_types { + let declaring = document_type.as_ref(); + for (rule, constraint) in declaring.property_constraints() { + let references: BTreeSet<&str> = constraint + .referenced_reads() + .into_iter() + .map(|(reference, _, _)| reference) + .collect(); + for reference in references { + let Some(property) = declaring.flattened_properties().get(reference) else { + continue; + }; + let Some(declaration) = property + .property_type + .reference() + .and_then(|reference| reference.target()) + .or(property.revealed_reference.as_ref()) + .and_then(DocumentPropertyReferenceTarget::as_any_document_reference) + else { + continue; + }; + if declaration + .contract_id + .is_some_and(|referenced_contract| referenced_contract != contract_id) + { + continue; + } + let Some(referenced) = document_types.get(declaration.document_type_name) else { + continue; + }; + let referenced = referenced.as_ref(); + if declaration.kind != referenced.document_reference_kind() { + continue; + } + let records_creator_id = referenced.should_use_creator_id( + contract_system_version, + contract_config_version, + platform_version, + )?; + let reason = referenced_reads_error( + constraint, + &format!("rule \"{rule}\""), + reference, + declaration.kind, + referenced, + schema_defs, + records_creator_id, + ) + .map_err(consensus_or_protocol_data_contract_error)?; + if let Some(reason) = reason { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "document type \"{type_name}\" {} {reason}", + property_names::PROPERTY_CONSTRAINTS + )), + )); + } + } + } + } + Ok(()) +} + /// How the key of an aggregate's filter, and the value it is matched against, /// compare. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -4308,6 +4918,7 @@ mod tests { DocumentPropertyReferenceTarget::PermanentDocument { contract_id: Some(contract_id), document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), } ) @@ -4350,6 +4961,7 @@ mod tests { DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), } ) @@ -4989,6 +5601,7 @@ mod tests { DocumentPropertyReferenceTarget::DeletableDocument { contract_id: Some(contract_id), document_type_name: "draft".to_string(), + where_conditions: Default::default(), property_agreement: [ ("topic".to_string(), "topic".to_string()), ("$ownerId".to_string(), "$ownerId".to_string()), @@ -5003,6 +5616,7 @@ mod tests { DocumentPropertyReferenceTarget::DeletableDocument { contract_id: None, document_type_name: "draft".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), } ) @@ -5067,6 +5681,7 @@ mod tests { DocumentPropertyReferenceTarget::ModeratedDocument { contract_id: Some(contract_id), document_type_name: "post".to_string(), + where_conditions: Default::default(), property_agreement: [ ("topic".to_string(), "topic".to_string()), ("$ownerId".to_string(), "$ownerId".to_string()), @@ -5081,6 +5696,7 @@ mod tests { DocumentPropertyReferenceTarget::ModeratedDocument { contract_id: None, document_type_name: "post".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), } ) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/commit_reveal_lookup_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/commit_reveal_lookup_tests.rs index 81a5309cccd..9fab062f3e3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/commit_reveal_lookup_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/commit_reveal_lookup_tests.rs @@ -249,6 +249,7 @@ fn should_parse_the_dpns_salt_as_revealing_the_preorder_with_its_age_consumption DocumentPropertyReferenceTarget::DeletableDocumentLookup { contract_id: None, document_type_name: "preorder".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::from([("$ownerId".to_string(), "$ownerId".to_string())]), lookup: dpns_lookup(Some(1), true), } @@ -715,6 +716,7 @@ fn should_consume_a_commitment_only_a_consume_deletes() { DocumentPropertyReferenceTarget::DeletableDocumentLookup { contract_id: None, document_type_name: "preorder".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::from([("$ownerId".to_string(), "$ownerId".to_string())]), lookup: dpns_lookup(Some(1), true), } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/list_element_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/list_element_reference_tests.rs index ff22e9ba202..a3f29ab927d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/list_element_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/list_element_reference_tests.rs @@ -189,6 +189,7 @@ fn expected_reference(pairs: &[(&str, &str)], in_list: &str) -> DocumentProperty DocumentPropertyReferenceTarget::ListElement(ListElementReference { contract_id: None, document_type_name: "electedCharter".to_string(), + where_conditions: Default::default(), property_agreement: pairs .iter() .map(|(referring, referenced)| (referring.to_string(), referenced.to_string())) @@ -783,6 +784,7 @@ fn should_find_a_value_only_in_the_list_the_referenced_document_holds() { let reference = ListElementReference { contract_id: None, document_type_name: "electedCharter".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::from([("meta.charterId".to_string(), "$id".to_string())]), in_list: "seats.members".to_string(), }; 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 6801b818fc6..0c96fead528 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 @@ -67,7 +67,7 @@ use super::schema_at_path; use super::{ apply_property_constraints, parse_doctype_reference, validate_encrypted_for_declarations, validate_generated_from_declarations, validate_list_element_sources, - validate_reference_lookup_sources, + validate_reference_lookup_sources, validate_referencing_rules, }; mod ranked_prefix_overlap; @@ -691,6 +691,12 @@ fn parse_generation_3( if full_validation { validate_list_element_sources(DocumentTypeRef::V2(&v2), name) .map_err(consensus_or_protocol_data_contract_error)?; + // The declaring side of a rule reading a document a reference points at, once + // the references, `indexOnly`, the trade flags and the moderator fields are + // known; what it reads of the referenced type is checked where that type is in + // hand + validate_referencing_rules(DocumentTypeRef::V2(&v2), name) + .map_err(consensus_or_protocol_data_contract_error)?; } // Every stage above read the expanded schema; the document type holds the @@ -1962,6 +1968,8 @@ mod reference_lookup_tests; #[cfg(all(test, feature = "validation"))] mod reference_test_helpers; #[cfg(all(test, feature = "validation"))] +mod referenced_rule_tests; +#[cfg(all(test, feature = "validation"))] mod retracted_when_tests; #[cfg(all(test, feature = "validation"))] mod shared_stage_error_tests; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs index 9f77f33c3f6..45a14badb78 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs @@ -220,6 +220,7 @@ fn should_parse_an_identity_or_a_permanent_document_lookup_owner_reference() { DocumentPropertyReferenceTarget::PermanentDocumentLookup { contract_id: None, document_type_name: "addedModerator".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::new(), lookup: lookup.clone(), }, @@ -236,6 +237,7 @@ fn should_parse_an_identity_or_a_permanent_document_lookup_owner_reference() { DocumentPropertyReferenceTarget::PermanentDocumentLookup { contract_id: None, document_type_name: "addedModerator".to_string(), + where_conditions: Default::default(), property_agreement: agreement, lookup, }, @@ -596,6 +598,7 @@ fn should_parse_a_creator_reference_on_a_type_that_records_creator_ids() { DocumentPropertyReferenceTarget::PermanentDocumentLookup { contract_id: None, document_type_name: "addedModerator".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::new(), lookup: lookup.clone(), }, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_expression_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_expression_tests.rs index 8c199c2b21d..b93afe0d7dc 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_expression_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_expression_tests.rs @@ -192,6 +192,7 @@ fn expected_join_request_lookup() -> DocumentPropertyReferenceTarget { DocumentPropertyReferenceTarget::PermanentDocumentLookup { contract_id: None, document_type_name: "joinRequest".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::new(), lookup: DocumentReferenceLookup { keys: [ @@ -212,6 +213,7 @@ fn expected_added_moderator_lookup() -> DocumentPropertyReferenceTarget { DocumentPropertyReferenceTarget::PermanentDocumentLookup { contract_id: None, document_type_name: "addedModerator".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::new(), lookup: DocumentReferenceLookup { keys: [ @@ -309,6 +311,7 @@ fn should_parse_a_deletable_document_lookup_operand_and_refuse_it_in_an_immutabl contract_id, document_type_name, property_agreement, + where_conditions, lookup, } = expected_added_moderator_lookup() else { @@ -322,6 +325,7 @@ fn should_parse_a_deletable_document_lookup_operand_and_refuse_it_in_an_immutabl contract_id, document_type_name, property_agreement, + where_conditions, lookup, }, ])) @@ -359,6 +363,7 @@ fn should_parse_an_any_of_of_an_identity_and_a_document_with_its_own_agreement() DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, document_type_name: "joinRequest".to_string(), + where_conditions: Default::default(), property_agreement: [("title".to_string(), "message".to_string())].into(), }, ])) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_lookup_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_lookup_tests.rs index 6609762ba7c..8c04bb91505 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_lookup_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_lookup_tests.rs @@ -98,6 +98,7 @@ fn should_parse_find_by_with_a_property_source_an_owner_source_and_the_reference DocumentPropertyReferenceTarget::PermanentDocumentLookup { contract_id: None, document_type_name: "joinRequest".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::new(), lookup: expected_lookup(&[ ( @@ -460,6 +461,7 @@ fn should_parse_find_by_on_a_deletable_document_reference_and_check_its_referenc DocumentPropertyReferenceTarget::DeletableDocumentLookup { contract_id: None, document_type_name: "joinRequest".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::new(), lookup: expected_lookup(&[ ( @@ -531,6 +533,7 @@ fn should_parse_find_by_on_the_elements_of_a_typed_array_as_the_charter_declares target: &DocumentPropertyReferenceTarget::PermanentDocumentLookup { contract_id: None, document_type_name: "joinRequest".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::new(), lookup: expected_lookup(&[ ( @@ -666,6 +669,7 @@ fn should_round_trip_a_contract_through_platform_serialization_with_and_without_ DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, document_type_name: "joinRequest".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::new(), } ) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs new file mode 100644 index 00000000000..26542bdf877 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs @@ -0,0 +1,816 @@ +//! `propertyConstraints` rules reading the document a reference points at, +//! `{ "referenced": [reference, field] }`, and the `where` entries that are not +//! an equality with a referring value (protocol version 14). The parse reads +//! both into the document type; what a rule reads of a referenced type of the +//! same contract is checked once every type is parsed, and of another +//! contract's type at registration. + +use super::immutable_tests::expect_structure_error; +use crate::consensus::basic::document::PropertyConstraintViolation; +use crate::data_contract::config::moderation::{ContractModerationConfig, ContractModerators}; +use crate::data_contract::config::DataContractConfig; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; +use crate::data_contract::document_type::methods::DocumentTypeV0Methods; +use crate::data_contract::document_type::property_constraints::SystemProperty; +use crate::data_contract::document_type::property_constraints::{ + data_with_referenced_documents, referenced_fields, DocumentSystemValues, + REFERENCED_DOCUMENT_KEY, +}; +use crate::data_contract::document_type::{ + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentType, WhereBound, + WhereCondition, WhereLiteral, WhereOrdering, +}; +use crate::ProtocolError; +use platform_value::{platform_value, Identifier, Value}; +use platform_version::version::PlatformVersion; +use std::collections::BTreeMap; + +const CONTRACT_ID: [u8; 32] = [7; 32]; + +fn identifier(position: u64, refers_to: Option) -> Value { + let mut property = platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": position, + }); + if let (Some(refers_to), Value::Map(map)) = (refers_to, &mut property) { + map.push((Value::Text("refersTo".to_string()), refers_to)); + } + property +} + +/// Pollr's poll: a question and options, whether voters may pick more than one +/// (`multiChoice`), when it closes (`endsAt`, optional), and a `status` with an +/// enum. Its owner may delete it, so a reference to it is a deletableDocument +/// one. `note` is transient. +fn poll() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": false, + "properties": { + "question": { "type": "string", "minLength": 1, "maxLength": 512, "position": 0 }, + "option0": { "type": "string", "minLength": 1, "maxLength": 100, "position": 1 }, + "option1": { "type": "string", "minLength": 1, "maxLength": 100, "position": 2 }, + "option2": { "type": "string", "minLength": 1, "maxLength": 100, "position": 3 }, + "multiChoice": { "type": "boolean", "position": 4 }, + "endsAt": { "type": "integer", "minimum": 0, "maximum": 9007199254740991u64, "position": 5 }, + "status": { "type": "string", "enum": ["open", "closed"], "maxLength": 10, "position": 6 }, + "note": { "type": "string", "maxLength": 20, "position": 7 }, + "voters": { + "type": "array", + "minItems": 0, + "maxItems": 5, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 8 + }, + }, + "transient": ["note"], + "required": ["question", "option0", "option1", "$createdAt"], + "additionalProperties": false, + }) +} + +/// A post only moderators take down, each removal keeping `hashtag`. +fn moderated_post() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "moderatorAbilities": { "delete": true, "deleteKeepsFields": ["hashtag"] }, + "properties": { + "hashtag": { "type": "string", "minLength": 1, "maxLength": 63, "position": 0 }, + "score": { "type": "integer", "minimum": 0, "maximum": 100, "position": 1 }, + }, + "required": ["hashtag", "$createdAt"], + "additionalProperties": false, + }) +} + +/// A vote on a poll with `rules`, whose `pollId` refers to the poll as +/// `refers_to` says. `extra` adds properties and keywords to the type. +fn vote(refers_to: Value, rules: Value, extra: Vec<(&str, Value)>) -> Value { + let mut vote = platform_value!({ + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "pollId": identifier(0, Some(refers_to)), + "pollOwnerId": identifier(1, None), + "choice": { "type": "integer", "minimum": 0, "maximum": 9, "position": 2 }, + "label": { "type": "string", "maxLength": 20, "position": 3 }, + "postId": identifier(4, Some(platform_value!({ + "type": "moderatedDocument", + "documentType": "post", + }))), + "voterId": identifier(5, Some(platform_value!({ "type": "identity" }))), + }, + "required": ["pollId", "pollOwnerId", "choice", "$createdAt", "$updatedAt"], + "propertyConstraints": rules, + "additionalProperties": false, + }); + for (key, value) in extra { + vote.set_value(key, value) + .expect("the vote takes the keyword"); + } + vote +} + +fn poll_reference() -> Value { + platform_value!({ "type": "deletableDocument", "documentType": "poll" }) +} + +fn config() -> DataContractConfig { + DataContractConfig::default_for_version(PlatformVersion::latest()) + .expect("default config available") + .with_moderation(Some(ContractModerationConfig { + banlist: false, + suspensions: false, + moderators: ContractModerators::ContractOwner, + warnings: false, + })) +} + +fn parse_types( + types: Vec<(&str, Value)>, + full_validation: bool, +) -> Result, ProtocolError> { + let config = config(); + DocumentType::create_document_types_from_document_schemas( + Identifier::new(CONTRACT_ID), + 1, + config.version(), + types + .into_iter() + .map(|(name, schema)| (name.to_string(), schema)) + .collect(), + None, + &BTreeMap::new(), + &config, + full_validation, + false, + &mut vec![], + PlatformVersion::latest(), + ) +} + +fn parse(vote: Value) -> Result, ProtocolError> { + parse_types( + vec![("poll", poll()), ("post", moderated_post()), ("vote", vote)], + true, + ) +} + +fn parse_rules(rules: Value) -> Result, ProtocolError> { + parse(vote(poll_reference(), rules, vec![])) +} + +fn pollr_rules() -> Value { + platform_value!({ + "castBeforeTheEnd": { + "lessThan": [ + "$createdAt", + { "ifAbsent": [{ "referenced": ["pollId", "endsAt"] }, 9007199254740991u64] } + ] + }, + "singleChoicePoll": { "equal": [{ "referenced": ["pollId", "multiChoice"] }, 0] }, + "choiceIsAnOption": { + "lessThan": [ + "choice", + { "countPresent": [ + { "referenced": ["pollId", "option0"] }, + { "referenced": ["pollId", "option1"] }, + { "referenced": ["pollId", "option2"] } + ] } + ] + }, + "pollIsOpen": { + "notEqual": [{ "referenced": ["pollId", "status"] }, { "const": "closed" }] + }, + "ownerIsTheDenormalizedOne": { + "equal": [{ "referenced": ["pollId", "$ownerId"] }, "pollOwnerId"] + }, + "labelMatchesStatus": { + "anyOf": [ + { "absent": "label" }, + { "equal": ["label", { "referenced": ["pollId", "status"] }] } + ] + }, + "voterListed": { + "ifThen": [ + { "present": "voterId" }, + { "contains": [{ "referenced": ["pollId", "voters"] }, "voterId"] } + ] + } + }) +} + +#[test] +fn should_parse_rules_reading_the_referenced_document() { + let types = parse_rules(pollr_rules()).expect("the rules parse"); + let vote = types.get("vote").expect("the vote type").as_ref(); + let fields = referenced_fields(vote.property_constraints()); + assert_eq!( + fields + .get("pollId") + .expect("the rules read through pollId") + .iter() + .copied() + .collect::>(), + vec![ + "$ownerId", + "endsAt", + "multiChoice", + "option0", + "option1", + "option2", + "status", + "voters" + ] + ); + assert!(vote + .property_constraints() + .values() + .all(|rule| rule.reads_referenced())); +} + +/// The data a vote with `vote_data` is judged against, with the poll's +/// `poll_values` under the reference. +fn judged(vote_data: Value, poll_values: Value) -> Value { + let data: BTreeMap = vote_data + .into_btree_string_map() + .expect("the vote data is a map"); + data_with_referenced_documents(&data, BTreeMap::from([("pollId".to_string(), poll_values)])) +} + +#[test] +fn should_judge_a_rule_by_the_values_of_the_referenced_document() { + let types = parse_rules(pollr_rules()).expect("the rules parse"); + let vote = types.get("vote").expect("the vote type").as_ref(); + let owner = Identifier::new([3; 32]); + let poll_owner = Value::Identifier([4; 32]); + let system = DocumentSystemValues { + created_at: Some(1_000), + updated_at: Some(1_000), + referenced_documents: true, + ..DocumentSystemValues::owned_by(owner) + }; + let open_poll = platform_value!({ + "$ownerId": poll_owner.clone(), + "endsAt": 2_000u64, + "option0": "a", + "option1": "b", + "status": "open", + }); + let poll_id = Value::Identifier([5; 32]); + let vote_data = platform_value!({ "pollId": poll_id.clone(), "pollOwnerId": poll_owner.clone(), "choice": 1u8 }); + let data = judged(vote_data.clone(), open_poll.clone()); + let result = vote + .validate_referencing_property_constraints(&data, &system, PlatformVersion::latest()) + .expect("the rules evaluate"); + assert!(result.is_valid(), "{:?}", result.errors); + + // The poll has two options, so choice 2 is none of them + let data = judged( + platform_value!({ "pollId": poll_id.clone(), "pollOwnerId": poll_owner.clone(), "choice": 2u8 }), + open_poll.clone(), + ); + let result = vote + .validate_referencing_property_constraints(&data, &system, PlatformVersion::latest()) + .expect("the rules evaluate"); + assert!(format!("{:?}", result.errors).contains("choiceIsAnOption")); + + // Past the end + let mut ended = open_poll.clone(); + ended.set_value("endsAt", Value::U64(900)).expect("set"); + let result = vote + .validate_referencing_property_constraints( + &judged(vote_data.clone(), ended), + &system, + PlatformVersion::latest(), + ) + .expect("the rules evaluate"); + assert!(format!("{:?}", result.errors).contains("castBeforeTheEnd")); + + // A multiple-choice poll is no single-choice vote's + let mut multi = open_poll.clone(); + multi + .set_value("multiChoice", Value::Bool(true)) + .expect("set"); + let result = vote + .validate_referencing_property_constraints( + &judged(vote_data.clone(), multi), + &system, + PlatformVersion::latest(), + ) + .expect("the rules evaluate"); + assert!(format!("{:?}", result.errors).contains("singleChoicePoll")); + + // A poll without an end is open for good: the default stands in for endsAt + let mut endless = open_poll; + endless.remove("endsAt").expect("remove"); + let result = vote + .validate_referencing_property_constraints( + &judged(vote_data.clone(), endless), + &system, + PlatformVersion::latest(), + ) + .expect("the rules evaluate"); + assert!(result.is_valid(), "{:?}", result.errors); +} + +#[test] +fn should_leave_rules_reading_a_referenced_document_unjudged_without_its_values() { + let types = parse_rules(pollr_rules()).expect("the rules parse"); + let vote = types.get("vote").expect("the vote type").as_ref(); + let rule = vote + .property_constraints() + .get("castBeforeTheEnd") + .expect("the rule"); + // A client, or consensus before the references are checked, gives none + let system = DocumentSystemValues { + created_at: Some(5_000), + ..DocumentSystemValues::owned_by(Identifier::new([3; 32])) + }; + let data = platform_value!({ "choice": 0u8 }); + assert_eq!(rule.violation(&data, &system), None); + let result = vote + .validate_property_constraints(&data, &system, PlatformVersion::latest()) + .expect("the rules evaluate"); + assert!(result.is_valid()); + // With the values given, a missing endsAt reads as the default and holds; + // an endsAt before the creation does not + let with = DocumentSystemValues { + referenced_documents: true, + ..system + }; + let data = judged( + platform_value!({ "choice": 0u8 }), + platform_value!({ "endsAt": 4_000u64 }), + ); + assert_eq!( + rule.violation(&data, &with), + Some(PropertyConstraintViolation::NotMet) + ); + assert!(data.get_optional_value(REFERENCED_DOCUMENT_KEY).is_ok()); +} + +#[test] +fn should_refuse_a_referenced_read_through_a_property_without_a_single_document_reference() { + for (rule, needle) in [ + ( + platform_value!({ "lessThan": [{ "referenced": ["choice", "endsAt"] }, 5] }), + "carries no refersTo", + ), + ( + platform_value!({ "lessThan": [{ "referenced": ["missing", "endsAt"] }, 5] }), + "is not a property of the document type", + ), + ( + platform_value!({ "equal": [{ "referenced": ["voterId", "$ownerId"] }, "pollOwnerId"] }), + "names an identity", + ), + ] { + expect_structure_error(parse_rules(platform_value!({ "rule": rule })), needle); + } +} + +#[test] +fn should_refuse_a_referenced_read_through_elements_an_expression_or_a_list() { + let mut item = identifier(0, Some(poll_reference())); + item.remove("position").expect("an item has no position"); + let mut elements = vote( + poll_reference(), + platform_value!({ "rule": { "lessThan": [{ "referenced": ["polls", "endsAt"] }, 5] } }), + vec![], + ); + elements + .get_mut("properties") + .expect("properties accessible") + .expect("properties present") + .set_value( + "polls", + platform_value!({ + "type": "array", + "minItems": 0, + "maxItems": 3, + "items": item, + "position": 6 + }), + ) + .expect("the property applies"); + expect_structure_error(parse(elements), "elements of a typed array"); + + // A topic that never leaves state, so a permanentDocument reference may + // be an operand of an expression + let topic = platform_value!({ + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "properties": { + "rank": { "type": "integer", "minimum": 0, "maximum": 9, "position": 0 }, + }, + "required": ["rank"], + "additionalProperties": false, + }); + let any_of = vote( + platform_value!({ "anyOf": [ + { "type": "permanentDocument", "documentType": "topic" }, + { "type": "identity" } + ] }), + platform_value!({ "rule": { "lessThan": [{ "referenced": ["pollId", "rank"] }, 5] } }), + vec![], + ); + expect_structure_error( + parse_types( + vec![ + ("poll", poll()), + ("post", moderated_post()), + ("topic", topic), + ("vote", any_of), + ], + true, + ), + "anyOf or allOf", + ); +} + +#[test] +fn should_refuse_a_read_the_referenced_type_can_not_answer() { + for (rule, needle) in [ + // A string read as a number + ( + platform_value!({ "lessThan": [{ "referenced": ["pollId", "question"] }, 5] }), + "has type string, not integer", + ), + // No such property + ( + platform_value!({ "lessThan": [{ "referenced": ["pollId", "nothing"] }, 5] }), + "is not an integer or boolean property", + ), + // Transient on the poll + ( + platform_value!({ "equal": [{ "referenced": ["pollId", "note"] }, { "const": "x" }] }), + "transient", + ), + // A time the poll does not record + ( + platform_value!({ "lessThan": [{ "referenced": ["pollId", "$updatedAt"] }, "$createdAt"] }), + "does not record", + ), + // The poll records no creator + ( + platform_value!({ "equal": [{ "referenced": ["pollId", "$creatorId"] }, "pollOwnerId"] }), + "does not record $creatorId", + ), + // A constant the poll's enum does not list + ( + platform_value!({ "equal": [{ "referenced": ["pollId", "status"] }, { "const": "paused" }] }), + "not one of its enum values", + ), + // An identifier is compared, not counted + ( + platform_value!({ "lessThan": [{ "referenced": ["pollId", "$ownerId"] }, 5] }), + "an identifier", + ), + ] { + expect_structure_error(parse_rules(platform_value!({ "rule": rule })), needle); + } +} + +#[test] +fn should_read_only_what_a_removal_record_keeps_through_a_moderated_reference() { + // The record keeps $ownerId and the hashtag + parse_rules(platform_value!({ + "rule": { "notEqual": [{ "referenced": ["postId", "hashtag"] }, { "const": "spam" }] } + })) + .expect("a kept field reads"); + parse_rules(platform_value!({ + "rule": { "notEqual": [{ "referenced": ["postId", "$ownerId"] }, "$ownerId"] } + })) + .expect("the owner reads"); + expect_structure_error( + parse_rules(platform_value!({ + "rule": { "lessThan": [{ "referenced": ["postId", "score"] }, 50] } + })), + "deleteKeepsFields", + ); + expect_structure_error( + parse_rules(platform_value!({ + "rule": { "lessThan": [{ "referenced": ["postId", "$createdAt"] }, "$createdAt"] } + })), + "deleteKeepsFields", + ); +} + +#[test] +fn should_refuse_a_referenced_read_outside_property_constraints() { + let delete_rule = vote( + poll_reference(), + platform_value!({ "rule": { "greaterThan": ["choice", -1] } }), + vec![( + "deleteConstraints", + platform_value!({ "rule": { "lessThan": [{ "referenced": ["pollId", "endsAt"] }, 5] } }), + )], + ); + expect_structure_error(parse(delete_rule), "only a propertyConstraints rule"); + + let immutable = vote( + poll_reference(), + platform_value!({ "rule": { "greaterThan": ["choice", -1] } }), + vec![( + "immutable", + platform_value!([{ + "property": "choice", + "when": { "lessThan": [{ "referenced": ["pollId", "endsAt"] }, "$updatedAt"] } + }]), + )], + ); + expect_structure_error(parse(immutable), "only a propertyConstraints rule"); +} + +#[test] +fn should_refuse_a_referenced_read_beside_what_a_transfer_changes() { + let transferable = vote( + poll_reference(), + platform_value!({ + "rule": { + "allOf": [ + { "lessThan": ["$createdAt", { "referenced": ["pollId", "endsAt"] }] }, + { "notEqual": [{ "referenced": ["pollId", "$ownerId"] }, "$ownerId"] } + ] + } + }), + vec![("transferable", Value::U8(1))], + ); + expect_structure_error( + parse(transferable), + "a transfer or a purchase checks no reference", + ); + + // Reading neither the owner nor a transfer time, the rule stands on a transferable type + let transferable = vote( + poll_reference(), + platform_value!({ + "rule": { "lessThan": ["$createdAt", { "referenced": ["pollId", "endsAt"] }] } + }), + vec![("transferable", Value::U8(1))], + ); + parse(transferable).expect("the rule reads nothing a transfer changes"); +} + +#[test] +fn should_refuse_a_referenced_read_through_a_find_by_function() { + let commitment = platform_value!({ + "type": "object", + "documentsMutable": false, + "canBeDeleted": true, + "properties": { + "hash": { "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, "position": 0 }, + "weight": { "type": "integer", "minimum": 0, "maximum": 9, "position": 1 }, + }, + "indices": [{ "name": "byHash", "properties": [{ "hash": "asc" }], "unique": true }], + "required": ["hash", "weight", "$createdAtBlockHeight"], + "additionalProperties": false, + }); + let reveal = platform_value!({ + "type": "object", + "documentsMutable": false, + "canBeDeleted": true, + "properties": { + "secret": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "position": 0, + "refersTo": { + "type": "deletableDocument", + "documentType": "commitment", + "findBy": { + "hash": { + "function": "sys.hash.sha256d", + "params": ["secret"] + } + } + } + }, + "weight": { "type": "integer", "minimum": 0, "maximum": 9, "position": 1 }, + }, + "required": ["secret", "weight"], + "propertyConstraints": { + "rule": { "equal": ["weight", { "referenced": ["secret", "weight"] }] } + }, + "additionalProperties": false, + }); + expect_structure_error( + parse_types(vec![("commitment", commitment), ("reveal", reveal)], true), + "computes its key with a function", + ); +} + +#[test] +fn should_refuse_a_malformed_referenced_operand() { + for (rule, needle) in [ + ( + platform_value!({ "lessThan": [{ "referenced": ["pollId"] }, 5] }), + "must list a reference property", + ), + ( + platform_value!({ "lessThan": [{ "referenced": ["meta.pollId", "endsAt"] }, 5] }), + "a reference property is a top-level property", + ), + ( + platform_value!({ "lessThan": [{ "referenced": ["pollId", "$revision"] }, 5] }), + "a field of the referenced document", + ), + ( + platform_value!({ "lessThan": ["$referenced.pollId.endsAt", 5] }), + "is written { \"referenced\"", + ), + ] { + // Without full validation the meta-schema does not run, so the parser + // refuses them itself + let result = parse_types( + vec![ + ("poll", poll()), + ("post", moderated_post()), + ( + "vote", + vote(poll_reference(), platform_value!({ "rule": rule }), vec![]), + ), + ], + false, + ); + expect_structure_error(result, needle); + } +} + +fn where_conditions(types: &BTreeMap) -> BTreeMap { + let vote = types.get("vote").expect("the vote type").as_ref(); + let Some(DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::DeletableDocument { + where_conditions, .. + }, + )) = vote + .flattened_properties() + .get("pollId") + .map(|property| &property.property_type) + else { + panic!("pollId refers to a deletable poll by id"); + }; + where_conditions.clone() +} + +#[test] +fn should_parse_where_entries_that_are_not_equalities() { + let types = parse(vote( + platform_value!({ + "type": "deletableDocument", + "documentType": "poll", + "where": { + "$ownerId": "pollOwnerId", + "multiChoice": { "const": false }, + "status": { "in": ["open", "closed"] }, + "endsAt": { "greaterThan": "$createdAt" }, + "option0": { "notEqual": "label" }, + "$id": { "notEqual": "$ownerId" } + } + }), + platform_value!({ "rule": { "greaterThan": ["choice", -1] } }), + vec![], + )) + .expect("the where parses"); + let conditions = where_conditions(&types); + assert_eq!( + conditions.get("multiChoice"), + Some(&WhereCondition::Constant(WhereLiteral::Boolean(false))) + ); + assert_eq!( + conditions.get("status"), + Some(&WhereCondition::In( + [ + WhereLiteral::Text("open".to_string()), + WhereLiteral::Text("closed".to_string()) + ] + .into_iter() + .collect() + )) + ); + assert_eq!( + conditions.get("endsAt"), + Some(&WhereCondition::Ordering { + ordering: WhereOrdering::GreaterThan, + bound: WhereBound::System(SystemProperty::CreatedAt) + }) + ); + assert_eq!( + conditions.get("option0"), + Some(&WhereCondition::NotEqual(WhereBound::Property( + "label".to_string() + ))) + ); + assert_eq!( + conditions.get("$id"), + Some(&WhereCondition::NotEqual(WhereBound::Writer)) + ); + // The equality stays an equality + assert_eq!(conditions.len(), 5); +} + +#[test] +fn should_refuse_a_malformed_where_entry() { + for (entry, needle) in [ + ( + platform_value!({ "$ownerId": { "greaterThan": "choice" } }), + "orders an identifier", + ), + ( + platform_value!({ "endsAt": { "greaterThan": "$ownerId" } }), + "orders by $ownerId", + ), + ( + platform_value!({ "endsAt": { "lessThan": { "const": "soon" } } }), + "must be an integer", + ), + ( + platform_value!({ "status": { "in": ["open", 1] } }), + "of another kind", + ), + ( + platform_value!({ "status": { "in": ["open", "open"] } }), + "repeats an earlier value", + ), + ( + platform_value!({ "status": { "in": ["open"] } }), + "two or more values", + ), + ( + platform_value!({ "status": { "sha256": ["label"] } }), + "belongs in findBy", + ), + ( + platform_value!({ "status": { "notEqual": "$createdAt" } }), + "its $ownerId", + ), + ( + platform_value!({ "status": { "const": "open", "in": ["a", "b"] } }), + "one key", + ), + ] { + let result = parse_types( + vec![ + ("poll", poll()), + ("post", moderated_post()), + ( + "vote", + vote( + platform_value!({ + "type": "deletableDocument", + "documentType": "poll", + "where": entry + }), + platform_value!({ "rule": { "greaterThan": ["choice", -1] } }), + vec![], + ), + ), + ], + false, + ); + expect_structure_error(result, needle); + } +} + +#[test] +fn should_hold_a_moderated_writer_inequality_to_what_every_record_keeps() { + let mut refused = vote( + poll_reference(), + platform_value!({ "rule": { "greaterThan": ["choice", -1] } }), + vec![], + ); + refused + .get_mut("properties") + .expect("properties accessible") + .expect("properties present") + .set_value( + "postId", + identifier( + 4, + Some(platform_value!({ + "type": "moderatedDocument", + "documentType": "post", + "where": { "hashtag": { "notEqual": "$ownerId" } } + })), + ), + ) + .expect("the property applies"); + expect_structure_error(parse(refused), "compares the writer"); +} 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 b1d93f8ec49..8ab919476d3 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 @@ -120,6 +120,7 @@ fn should_parse_an_element_reference_of_each_target_type() { DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, document_type_name: "reason".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::from([ ("$ownerId".to_string(), "$ownerId".to_string()), ("topic".to_string(), "topic".to_string()), @@ -135,6 +136,7 @@ fn should_parse_an_element_reference_of_each_target_type() { DocumentPropertyReferenceTarget::DeletableDocument { contract_id: Some(foreign_contract), document_type_name: "draft".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::new(), }, ), @@ -148,6 +150,7 @@ fn should_parse_an_element_reference_of_each_target_type() { DocumentPropertyReferenceTarget::ModeratedDocument { contract_id: Some(foreign_contract), document_type_name: "post".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::from([("topic".to_string(), "topic".to_string())]), }, ), @@ -623,6 +626,7 @@ fn should_round_trip_a_contract_with_an_element_reference_through_platform_seria target: &DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, document_type_name: "reason".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::from([( "topic".to_string(), "topic".to_string() 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 4bbbad54752..0625be566a0 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 @@ -290,6 +290,7 @@ mod tests { DocumentPropertyReferenceTarget::PermanentDocument { contract_id, document_type_name: target_type.to_string(), + where_conditions: Default::default(), property_agreement: agreement .iter() .map(|(referring, referenced)| { @@ -494,6 +495,7 @@ mod tests { DocumentPropertyReferenceTarget::PermanentDocumentLookup { contract_id: None, document_type_name: "post".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::new(), lookup: DocumentReferenceLookup { keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), @@ -518,6 +520,7 @@ mod tests { let post = |document_type_name: &str| DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, document_type_name: document_type_name.to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::new(), }; let index = index_on(&["postId"]); @@ -554,6 +557,7 @@ mod tests { contract_id, document_type_name, property_agreement, + .. }, ) = property.property_type else { @@ -563,6 +567,7 @@ mod tests { DocumentPropertyReferenceTarget::ModeratedDocument { contract_id, document_type_name, + where_conditions: Default::default(), property_agreement, }, ); @@ -602,6 +607,7 @@ mod tests { DocumentPropertyReferenceTarget::DeletableDocument { contract_id: None, document_type_name: "post".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::new(), }, ); 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 28b000e89be..48f78a5bc40 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 @@ -1047,6 +1047,45 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe } } + /// Judges a document being created or replaced against the rules of the document + /// type's `propertyConstraints` that read a document one of its references points at + /// (`{ "referenced": [reference, field] }`), in name order: the first rule it breaks + /// fails with `DocumentPropertyConstraintViolatedError` (10422), as + /// [`Self::validate_property_constraints`] would. `data` holds the document's + /// properties and, under `$referenced`, the values those rules read of the referenced + /// documents, which consensus reads with the references it checks, so `system` must + /// say it does ([`DocumentSystemValues::referenced_documents`]); every other rule of + /// the type was judged before the references were checked, and is not judged again. + /// A type with no such rule costs nothing. + /// + /// Versioned with [`Self::validate_property_constraints`]: `None` before protocol + /// version 14, where no parsed document type carries a rule. + fn validate_referencing_property_constraints( + &self, + data: &Value, + system: &DocumentSystemValues, + platform_version: &PlatformVersion, + ) -> Result + where + Self: DocumentTypeV2Getters, + { + match platform_version + .dpp + .contract_versions + .document_type_versions + .methods + .validate_property_constraints + { + None => Ok(SimpleConsensusValidationResult::default()), + Some(0) => self.validate_referencing_property_constraints_v0(data, system), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "validate_referencing_property_constraints".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + /// Judges a stored document's properties, `data`, against the rules of the document /// type's `propertyConstraints` that `change` can break, with `system` the document's /// system values after it, in name order. A transfer or a purchase gives the document a diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs index ad6519b2416..9e875fed414 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs @@ -904,6 +904,39 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa Ok(SimpleConsensusValidationResult::default()) } + /// `validate_referencing_property_constraints` version 0: every rule of the document + /// type's `propertyConstraints` reading a document one of its references points at is + /// evaluated against `data`, which holds those values under `$referenced`, in name + /// order, and the first one broken is reported. The type's other rules are not judged + /// again. A rule reading a total consensus did not read is an error. + fn validate_referencing_property_constraints_v0( + &self, + data: &Value, + system: &DocumentSystemValues, + ) -> Result + where + Self: DocumentTypeV2Getters, + { + for (name, constraint) in self + .property_constraints() + .iter() + .filter(|(_, constraint)| constraint.reads_referenced()) + { + self.expect_every_aggregate_read(name, constraint, system)?; + if let Some(violation) = constraint.violation(data, system) { + return Ok(SimpleConsensusValidationResult::new_with_error( + DocumentPropertyConstraintViolatedError::new( + self.name().clone(), + name.clone(), + violation, + ) + .into(), + )); + } + } + Ok(SimpleConsensusValidationResult::default()) + } + /// `validate_delete_constraints` version 0: every rule of the document type's /// `deleteConstraints` is evaluated against the stored document's `data` with `system`, /// in name order, and the first one broken is reported. A rule reading a total consensus diff --git a/packages/rs-dpp/src/data_contract/document_type/property/list_element_reference.rs b/packages/rs-dpp/src/data_contract/document_type/property/list_element_reference.rs index c352d2c33af..e011a7a5fd6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/list_element_reference.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/list_element_reference.rs @@ -33,7 +33,7 @@ use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; use crate::data_contract::document_type::property::reference_lookup::schema_property_is_fixed_once_written; use crate::data_contract::document_type::property::{ - is_transient, top_level_property, DocumentPropertyType, + is_transient, top_level_property, DocumentPropertyType, WhereCondition, }; use crate::data_contract::document_type::DocumentTypeRef; use crate::document::property_names::ID; @@ -60,6 +60,9 @@ pub struct ListElementReference { /// document the list is read from. The others are checked against that /// document as any agreement is. pub property_agreement: BTreeMap, + /// The other `where` entries, by referenced property, checked against the + /// document holding the list ([`WhereCondition`]). + pub where_conditions: BTreeMap, /// The typed array of identifiers of `document_type_name` (a dotted path /// when nested) the value must be an element of. pub in_list: String, 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 ba7aac41580..b84b3e5eda4 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 @@ -46,6 +46,7 @@ pub mod list_element_reference; pub mod lookup_preimage; pub mod reference_expression; pub mod reference_lookup; +pub mod reference_where; pub use encrypted_for::{EncryptedFor, EncryptedForRecipient, EncryptionScheme}; pub use generated_from::{ @@ -60,6 +61,9 @@ pub use reference_expression::{ MAX_REFERENCE_EXPRESSION_DECODE_DEPTH, }; pub use reference_lookup::{DocumentReferenceLookup, LookupKeySource}; +pub use reference_where::{ + WhereBound, WhereCondition, WhereLiteral, WhereLiteralKind, WhereOrdering, +}; #[cfg(test)] mod byte_array_encoding_flip_tests; @@ -804,6 +808,15 @@ pub enum DocumentPropertyReferenceTarget { /// document type that records creator ids at all. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] property_agreement: BTreeMap, + /// The other `where` entries, by referenced property: a literal the + /// referenced value must equal, a value it must differ from, literals + /// it must be one of, or a bound it must compare with + /// ([`WhereCondition`]), checked against the document found as the + /// equalities are. A field of the variant, appended to it while + /// protocol version 14, the only one producing a `where`, is + /// unreleased. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + where_conditions: BTreeMap, }, /// A specific public key of an identity: the property value holds the /// identity id and the named sibling property of the same document type @@ -857,6 +870,9 @@ pub enum DocumentPropertyReferenceTarget { /// See [`Self::PermanentDocument`]'s `property_agreement`. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] property_agreement: BTreeMap, + /// See [`Self::PermanentDocument`]'s `where_conditions`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + where_conditions: BTreeMap, }, /// A `permanentDocument` reference found by `findBy`: the property's /// value (or each element of a typed array) is NOT the referenced @@ -887,6 +903,9 @@ pub enum DocumentPropertyReferenceTarget { /// See [`Self::PermanentDocument`]'s `property_agreement`. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] property_agreement: BTreeMap, + /// See [`Self::PermanentDocument`]'s `where_conditions`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + where_conditions: BTreeMap, /// How the referenced document is found. lookup: DocumentReferenceLookup, }, @@ -963,6 +982,9 @@ pub enum DocumentPropertyReferenceTarget { /// See [`Self::PermanentDocument`]'s `property_agreement`. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] property_agreement: BTreeMap, + /// See [`Self::PermanentDocument`]'s `where_conditions`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + where_conditions: BTreeMap, /// How the referenced document is found. lookup: DocumentReferenceLookup, }, @@ -1004,6 +1026,9 @@ pub enum DocumentPropertyReferenceTarget { /// See [`Self::PermanentDocument`]'s `property_agreement`. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] property_agreement: BTreeMap, + /// See [`Self::PermanentDocument`]'s `where_conditions`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + where_conditions: BTreeMap, }, } @@ -1059,6 +1084,8 @@ pub struct DocumentReferenceDeclaration<'a> { pub document_type_name: &'a str, /// The `{referring property: referenced property}` equalities pub property_agreement: &'a BTreeMap, + /// The other `where` entries, by referenced property + pub where_conditions: &'a BTreeMap, /// What the referenced document type must promise about its documents /// leaving state: never (`permanentDocument`, by id, by `findBy` or with /// `inList`), only through a moderator's recorded removal @@ -1079,6 +1106,37 @@ pub struct DocumentReferenceDeclaration<'a> { pub in_list: Option<&'a str>, } +impl<'a> DocumentReferenceDeclaration<'a> { + /// The values of the referring document the `where` reads, in no + /// particular order: the referring side of every equality (a property + /// path, or `$ownerId`, the writer) and every property path a condition + /// is bounded by. A property listed twice is read twice. + pub fn where_referring_properties(&self) -> impl Iterator + 'a { + let property_agreement = self.property_agreement; + let where_conditions = self.where_conditions; + property_agreement.keys().map(String::as_str).chain( + where_conditions + .values() + .filter_map(WhereCondition::referring_property), + ) + } + + /// Whether a replace of the referring document checks the `where` again + /// whatever it changed: an equality valued `"$ownerId"` (a writer gate) or + /// a condition bounded by the writer or by the time or heights of the last + /// update or transfer ([`WhereCondition::is_rechecked_on_every_replace`]), + /// all of which may differ without the referring document changing. + pub fn where_is_rechecked_on_every_replace(&self) -> bool { + self.property_agreement + .keys() + .any(|referring_property| is_referring_system_agreement_property(referring_property)) + || self + .where_conditions + .values() + .any(WhereCondition::is_rechecked_on_every_replace) + } +} + impl DocumentPropertyReferenceTarget { /// The declaration of a reference whose value is a DOCUMENT's id, of /// any kind; `None` for every other target, one found by `findBy` or @@ -1102,10 +1160,12 @@ impl DocumentPropertyReferenceTarget { contract_id, document_type_name, property_agreement, + where_conditions, } => Some(DocumentReferenceDeclaration { contract_id: *contract_id, document_type_name, property_agreement, + where_conditions, kind: DocumentReferenceKind::Permanent, lookup: None, in_list: None, @@ -1114,11 +1174,13 @@ impl DocumentPropertyReferenceTarget { contract_id, document_type_name, property_agreement, + where_conditions, lookup, } => Some(DocumentReferenceDeclaration { contract_id: *contract_id, document_type_name, property_agreement, + where_conditions, kind: DocumentReferenceKind::Permanent, lookup: Some(lookup), in_list: None, @@ -1127,10 +1189,12 @@ impl DocumentPropertyReferenceTarget { contract_id, document_type_name, property_agreement, + where_conditions, } => Some(DocumentReferenceDeclaration { contract_id: *contract_id, document_type_name, property_agreement, + where_conditions, kind: DocumentReferenceKind::Deletable, lookup: None, in_list: None, @@ -1139,11 +1203,13 @@ impl DocumentPropertyReferenceTarget { contract_id, document_type_name, property_agreement, + where_conditions, lookup, } => Some(DocumentReferenceDeclaration { contract_id: *contract_id, document_type_name, property_agreement, + where_conditions, kind: DocumentReferenceKind::Deletable, lookup: Some(lookup), in_list: None, @@ -1152,10 +1218,12 @@ impl DocumentPropertyReferenceTarget { contract_id, document_type_name, property_agreement, + where_conditions, } => Some(DocumentReferenceDeclaration { contract_id: *contract_id, document_type_name, property_agreement, + where_conditions, kind: DocumentReferenceKind::Moderated, lookup: None, in_list: None, @@ -1165,6 +1233,7 @@ impl DocumentPropertyReferenceTarget { contract_id: reference.contract_id, document_type_name: &reference.document_type_name, property_agreement: &reference.property_agreement, + where_conditions: &reference.where_conditions, kind: DocumentReferenceKind::Permanent, lookup: None, in_list: Some(&reference.in_list), @@ -10400,6 +10469,7 @@ mod tests { DocumentPropertyReferenceTarget::PermanentDocument { contract_id: Some(contract_id), document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), } .to_string(), @@ -10409,6 +10479,7 @@ mod tests { DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), } .to_string(), @@ -10418,6 +10489,7 @@ mod tests { DocumentPropertyReferenceTarget::DeletableDocument { contract_id: Some(contract_id), document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), } .to_string(), @@ -10427,6 +10499,7 @@ mod tests { DocumentPropertyReferenceTarget::DeletableDocument { contract_id: None, document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), } .to_string(), @@ -10436,6 +10509,7 @@ mod tests { DocumentPropertyReferenceTarget::PermanentDocumentLookup { contract_id: None, document_type_name: "joinRequest".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), lookup: DocumentReferenceLookup { keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), @@ -10455,16 +10529,19 @@ mod tests { let permanent = DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: agreement.clone(), }; let moderated = DocumentPropertyReferenceTarget::ModeratedDocument { contract_id: None, document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: agreement.clone(), }; let deletable = DocumentPropertyReferenceTarget::DeletableDocument { contract_id: None, document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: agreement.clone(), }; @@ -10497,6 +10574,7 @@ mod tests { let target = DocumentPropertyReferenceTarget::PermanentDocumentLookup { contract_id: None, document_type_name: "joinRequest".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), lookup: lookup.clone(), }; @@ -10513,6 +10591,7 @@ mod tests { let id_reference = DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, document_type_name: "joinRequest".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), }; assert_eq!( @@ -10531,6 +10610,7 @@ mod tests { DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), } } @@ -10807,6 +10887,7 @@ mod tests { DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), }, DocumentPropertyReferenceTarget::IdentityPublicKey { @@ -10816,11 +10897,13 @@ mod tests { DocumentPropertyReferenceTarget::DeletableDocument { contract_id: None, document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), }, DocumentPropertyReferenceTarget::PermanentDocumentLookup { contract_id: None, document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), lookup: DocumentReferenceLookup { keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), @@ -10833,6 +10916,7 @@ mod tests { DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), }, ])), @@ -10841,18 +10925,21 @@ mod tests { DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), }, ])), DocumentPropertyReferenceTarget::ListElement(ListElementReference { contract_id: None, document_type_name: "electedCharter".to_string(), + where_conditions: Default::default(), property_agreement: [("electedCharterId".to_string(), "$id".to_string())].into(), in_list: "members".to_string(), }), DocumentPropertyReferenceTarget::DeletableDocumentLookup { contract_id: None, document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), lookup: DocumentReferenceLookup { keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), @@ -10863,6 +10950,7 @@ mod tests { DocumentPropertyReferenceTarget::ModeratedDocument { contract_id: None, document_type_name: "post".to_string(), + where_conditions: Default::default(), property_agreement: Default::default(), }, ]; diff --git a/packages/rs-dpp/src/data_contract/document_type/property/reference_expression.rs b/packages/rs-dpp/src/data_contract/document_type/property/reference_expression.rs index 0006d90a945..171c2a667a9 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/reference_expression.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/reference_expression.rs @@ -179,6 +179,7 @@ mod tests { DocumentPropertyReferenceTarget::PermanentDocumentLookup { contract_id: None, document_type_name: "addedModerator".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::new(), lookup: DocumentReferenceLookup { keys: [("moderatorId".to_string(), LookupKeySource::ReferenceValue)].into(), diff --git a/packages/rs-dpp/src/data_contract/document_type/property/reference_where.rs b/packages/rs-dpp/src/data_contract/document_type/property/reference_where.rs new file mode 100644 index 00000000000..f7108e89522 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/property/reference_where.rs @@ -0,0 +1,622 @@ +//! The `where` entries of a document reference that are not an equality with a +//! value of the referring document (meta-schema v3, protocol version 14): a +//! literal the referenced value must equal, a value it must differ from, a list +//! of literals it must be one of, or a bound it must compare with. +//! +//! ```json +//! "where": { +//! "$ownerId": "pollOwnerId", +//! "multiChoice": { "const": true }, +//! "status": { "in": ["open", "extended"] }, +//! "endsAt": { "greaterThan": "$createdAt" }, +//! "reservePrice": { "lessThanOrEqual": "amount" }, +//! "$creatorId": { "notEqual": "$ownerId" } +//! } +//! ``` +//! +//! Each entry is keyed by the referenced side, as an equality entry is: a +//! property of the referenced document type, or its `$ownerId`, `$creatorId` +//! or `$id`. An entry whose value names a property of the referring document +//! type, or `"$ownerId"`, is an equality and stays in the reference's +//! `property_agreement`; every other entry is a [`WhereCondition`]. Whether a +//! literal reads as a string, an identifier or a byte array is decided by the +//! referenced property it is compared with ([`WhereLiteralKind`]), which the +//! check reads beside the referenced document, since the type may be another +//! contract's. What the names resolve to is checked at registration. + +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::property::{ + is_referenced_system_agreement_property, DocumentPropertyType, +}; +use crate::data_contract::document_type::property_constraints::SystemProperty; +use crate::data_contract::document_type::DocumentTypeRef; +use crate::document::property_names::OWNER_ID; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::string_encoding::Encoding; +use platform_value::{Identifier, Value}; +use serde::Serialize; +use std::collections::BTreeSet; +use std::fmt; + +/// The key of a `where` entry's literal: `{ "const": ... }`. +pub const WHERE_CONST: &str = "const"; +/// The key of a `where` entry the referenced side must differ from. +pub const WHERE_NOT_EQUAL: &str = "notEqual"; +/// The key of a `where` entry listing the literals the referenced side may be. +pub const WHERE_IN: &str = "in"; + +/// A literal of a `where` entry, `{ "const": ... }` or a value an `in` lists, +/// as the declaration writes it. A string is a string, a base58 identifier or a +/// byte array in hex, as the referenced property it is compared with decides +/// ([`Self::value_for`]). +// Embedded in `DocumentPropertyReferenceTarget`, which consensus errors carry. +// @append_only +#[derive( + Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Serialize, Encode, Decode, DecodeUntrusted, +)] +#[serde(untagged)] +pub enum WhereLiteral { + /// An integer, or a number with no fractional part. + Integer(i128), + /// `true` or `false`. + Boolean(bool), + /// A string. + Text(String), +} + +/// What kind of value the referenced side of a `where` entry holds, which +/// decides how a literal compared with it reads. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum WhereLiteralKind { + /// An integer property: an integer literal. + Integer, + /// A boolean property: `true` or `false`. + Boolean, + /// A string property: a string literal. + Text, + /// An identifier property, or `$ownerId`, `$creatorId` or `$id`: a + /// string literal, read as a base58 identifier of 32 bytes. + Identifier, + /// A byte array property: a string literal, read as hex, two digits a + /// byte. + Bytes, +} + +impl WhereLiteralKind { + /// The kind of value a referenced property of `property_type` holds, `None` + /// for one no literal is compared with: a float, an object or an array of + /// values. + pub fn of(property_type: &DocumentPropertyType) -> Option { + match property_type { + DocumentPropertyType::Boolean => Some(WhereLiteralKind::Boolean), + DocumentPropertyType::String(_) => Some(WhereLiteralKind::Text), + DocumentPropertyType::ByteArray(_) => Some(WhereLiteralKind::Bytes), + property_type if property_type.is_identifier() => Some(WhereLiteralKind::Identifier), + property_type + if property_type.is_integer() + || matches!( + property_type, + DocumentPropertyType::U128 | DocumentPropertyType::I128 + ) => + { + Some(WhereLiteralKind::Integer) + } + _ => None, + } + } + + /// The kind of value the referenced side `referenced_property` of a + /// `where` entry holds on a document of `referenced`: its `$ownerId`, + /// `$creatorId` and `$id` are identifiers, and a schema property is of its + /// type ([`Self::of`]). `None` for a property the type does not have or + /// that no literal is compared with. + pub fn of_referenced(referenced: DocumentTypeRef, referenced_property: &str) -> Option { + if is_referenced_system_agreement_property(referenced_property) { + return Some(WhereLiteralKind::Identifier); + } + referenced + .flattened_properties() + .get(referenced_property) + .and_then(|property| Self::of(&property.property_type)) + } + + /// What the declaration writes a literal of this kind as, for the errors. + pub fn describe(self) -> &'static str { + match self { + WhereLiteralKind::Integer => "an integer", + WhereLiteralKind::Boolean => "true or false", + WhereLiteralKind::Text => "a string", + WhereLiteralKind::Identifier => "a base58 identifier", + WhereLiteralKind::Bytes => "a string of hex digits, two a byte", + } + } +} + +impl WhereLiteral { + /// The literal a declaration's JSON value spells: an integer (a number + /// with no fractional part included), `true` or `false`, or a string. + /// `None` for anything else. + pub fn from_value(value: &Value) -> Option { + match value { + Value::Bool(flag) => Some(WhereLiteral::Boolean(*flag)), + Value::Text(text) => Some(WhereLiteral::Text(text.clone())), + // The cast is exact: the float is a whole number inside the range + Value::Float(float) + if float.fract() == 0.0 + && *float >= i128::MIN as f64 + && *float < i128::MAX as f64 => + { + Some(WhereLiteral::Integer(*float as i128)) + } + value if value.is_integer() => { + value.to_integer::().ok().map(WhereLiteral::Integer) + } + _ => None, + } + } + + /// The value a referenced property of `kind` holds when it equals this + /// literal, compared with it by `Value::same_scalar_data`; `None` when the + /// literal can not be one: of another kind, an identifier that is not + /// base58 of 32 bytes, or bytes that are not an even number of hex digits. + /// Registration refuses such a literal, so a write meets none. + pub fn value_for(&self, kind: WhereLiteralKind) -> Option { + match (self, kind) { + (WhereLiteral::Integer(integer), WhereLiteralKind::Integer) => { + Some(Value::I128(*integer)) + } + (WhereLiteral::Boolean(flag), WhereLiteralKind::Boolean) => Some(Value::Bool(*flag)), + (WhereLiteral::Text(text), WhereLiteralKind::Text) => Some(Value::Text(text.clone())), + (WhereLiteral::Text(text), WhereLiteralKind::Identifier) => { + Identifier::from_string(text, Encoding::Base58) + .ok() + .map(|identifier| Value::Identifier(identifier.to_buffer())) + } + (WhereLiteral::Text(text), WhereLiteralKind::Bytes) => { + hex::decode(text).ok().map(Value::Bytes) + } + _ => None, + } + } +} + +impl fmt::Display for WhereLiteral { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WhereLiteral::Integer(integer) => write!(f, "{integer}"), + WhereLiteral::Boolean(flag) => write!(f, "{flag}"), + WhereLiteral::Text(text) => write!(f, "\"{text}\""), + } + } +} + +/// What the referenced side of a `notEqual` or of an ordering is compared +/// with. +// Embedded in `DocumentPropertyReferenceTarget`, which consensus errors carry. +// @append_only +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] +#[serde(rename_all = "camelCase")] +pub enum WhereBound { + /// A property of the referring document, by its dotted path. + Property(String), + /// `"$ownerId"`: the writer. + Writer, + /// `{ "const": ... }`: a literal. + Constant(WhereLiteral), + /// A time or height the referring document records (`"$createdAt"`), + /// which its type lists in `required`: an ordering only. + System(SystemProperty), +} + +impl WhereBound { + /// The property of the referring document the bound reads, `None` for the + /// writer, a literal or a time or height. + pub fn referring_property(&self) -> Option<&str> { + match self { + WhereBound::Property(path) => Some(path), + WhereBound::Writer | WhereBound::Constant(_) | WhereBound::System(_) => None, + } + } + + /// Whether the value the bound reads may differ between two writes of the + /// referring document that change none of its properties: the writer, who + /// may be a new owner after a transfer or a purchase, and the time and + /// heights of the last update or transfer. An entry with such a bound is + /// checked again on every replace, as a writer gate is. + pub fn changes_without_a_write(&self) -> bool { + match self { + WhereBound::Writer => true, + WhereBound::System(property) => !matches!( + property, + SystemProperty::CreatedAt + | SystemProperty::CreatedAtBlockHeight + | SystemProperty::CreatedAtCoreBlockHeight + ), + WhereBound::Property(_) | WhereBound::Constant(_) => false, + } + } +} + +impl fmt::Display for WhereBound { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WhereBound::Property(path) => write!(f, "\"{path}\""), + WhereBound::Writer => write!(f, "\"{OWNER_ID}\""), + WhereBound::Constant(literal) => write!(f, "{{ \"const\": {literal} }}"), + WhereBound::System(property) => write!(f, "\"{}\"", property.name()), + } + } +} + +/// How the referenced side of an ordering compares with its bound. +// Embedded in `DocumentPropertyReferenceTarget`, which consensus errors carry. +// @append_only +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize, Encode, Decode, DecodeUntrusted)] +#[serde(rename_all = "camelCase")] +pub enum WhereOrdering { + /// `greaterThan` + GreaterThan, + /// `greaterThanOrEqual` + GreaterThanOrEqual, + /// `lessThan` + LessThan, + /// `lessThanOrEqual` + LessThanOrEqual, +} + +impl WhereOrdering { + /// Every ordering. + pub const ALL: [WhereOrdering; 4] = [ + WhereOrdering::GreaterThan, + WhereOrdering::GreaterThanOrEqual, + WhereOrdering::LessThan, + WhereOrdering::LessThanOrEqual, + ]; + + /// The key declaring it. + pub fn wire_name(self) -> &'static str { + match self { + WhereOrdering::GreaterThan => "greaterThan", + WhereOrdering::GreaterThanOrEqual => "greaterThanOrEqual", + WhereOrdering::LessThan => "lessThan", + WhereOrdering::LessThanOrEqual => "lessThanOrEqual", + } + } + + /// Whether `referenced` compares with `bound` this way. + pub fn holds(self, referenced: i128, bound: i128) -> bool { + match self { + WhereOrdering::GreaterThan => referenced > bound, + WhereOrdering::GreaterThanOrEqual => referenced >= bound, + WhereOrdering::LessThan => referenced < bound, + WhereOrdering::LessThanOrEqual => referenced <= bound, + } + } +} + +/// A `where` entry that is not an equality with a value of the referring +/// document: what the referenced value, the entry's key, must be. +// Embedded in `DocumentPropertyReferenceTarget`, which consensus errors carry. +// @append_only +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] +#[serde(rename_all = "camelCase")] +pub enum WhereCondition { + /// `{ "const": literal }`: equal to the literal. A referenced side the + /// document leaves out equals no literal. + Constant(WhereLiteral), + /// `{ "notEqual": bound }`: not equal to the bound, a property of the + /// referring document, the writer or a literal; the negation of an + /// equality, so two absent sides are equal and one absent side differs. + NotEqual(WhereBound), + /// `{ "in": [literal, ...] }`: equal to one of two or more distinct + /// literals. A referenced side the document leaves out is in none. + In(BTreeSet), + /// `{ "greaterThan": bound }` and the other orderings: an integer that + /// compares with the bound, a property of the referring document, a + /// literal or a time or height of the referring document, this way. Holds + /// only when both sides have a value. + Ordering { + ordering: WhereOrdering, + bound: WhereBound, + }, +} + +impl WhereCondition { + /// The key declaring the condition. + pub fn wire_name(&self) -> &'static str { + match self { + WhereCondition::Constant(_) => WHERE_CONST, + WhereCondition::NotEqual(_) => WHERE_NOT_EQUAL, + WhereCondition::In(_) => WHERE_IN, + WhereCondition::Ordering { ordering, .. } => ordering.wire_name(), + } + } + + /// The bound a `notEqual` or an ordering compares with, `None` for a + /// literal or a list of them. + pub fn bound(&self) -> Option<&WhereBound> { + match self { + WhereCondition::NotEqual(bound) | WhereCondition::Ordering { bound, .. } => Some(bound), + WhereCondition::Constant(_) | WhereCondition::In(_) => None, + } + } + + /// The property of the referring document the condition reads, `None` + /// when it reads none. + pub fn referring_property(&self) -> Option<&str> { + self.bound().and_then(WhereBound::referring_property) + } + + /// Whether the condition is checked again on every replace of the + /// referring document, as a writer gate is: its bound is the writer or the + /// time or heights of the last update or transfer + /// ([`WhereBound::changes_without_a_write`]). + pub fn is_rechecked_on_every_replace(&self) -> bool { + self.bound() + .is_some_and(WhereBound::changes_without_a_write) + } + + /// Whether the condition holds for `referenced`, the referenced side's + /// value (`None` when the document leaves it out) of `kind` (`None` for a + /// property no literal reads as, which registration refuses a literal + /// for), and `bound`, the value of a bound the referring document reads + /// (`None` when it leaves it out, and for a literal or a list, which carry + /// their own): a time or height reads as an integer, the writer as an + /// identifier. + pub fn holds( + &self, + referenced: Option<&Value>, + kind: Option, + bound: Option<&Value>, + ) -> bool { + let equals_literal = |literal: &WhereLiteral| { + matches!( + (referenced, kind.and_then(|kind| literal.value_for(kind))), + (Some(referenced), Some(literal)) if referenced.same_scalar_data(&literal) + ) + }; + match self { + WhereCondition::Constant(literal) => equals_literal(literal), + WhereCondition::In(literals) => literals.iter().any(equals_literal), + WhereCondition::NotEqual(WhereBound::Constant(literal)) => !equals_literal(literal), + // The negation of an equality entry: both absent agree + WhereCondition::NotEqual(_) => !match (referenced, bound) { + (Some(referenced), Some(bound)) => referenced.same_scalar_data(bound), + (None, None) => true, + (Some(_), None) | (None, Some(_)) => false, + }, + WhereCondition::Ordering { + ordering, + bound: declared, + } => { + let integer = |value: Option<&Value>| { + value + .filter(|value| value.is_integer()) + .and_then(|value| value.to_integer::().ok()) + }; + let bound = match declared { + WhereBound::Constant(WhereLiteral::Integer(integer)) => Some(*integer), + WhereBound::Constant(_) => None, + WhereBound::Property(_) | WhereBound::Writer | WhereBound::System(_) => { + integer(bound) + } + }; + matches!( + (integer(referenced), bound), + (Some(referenced), Some(bound)) if ordering.holds(referenced, bound) + ) + } + } + } +} + +impl fmt::Display for WhereCondition { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WhereCondition::Constant(literal) => write!(f, "{{ \"const\": {literal} }}"), + WhereCondition::NotEqual(bound) => write!(f, "{{ \"notEqual\": {bound} }}"), + WhereCondition::In(literals) => { + write!(f, "{{ \"in\": [")?; + for (index, literal) in literals.iter().enumerate() { + if index > 0 { + write!(f, ", ")?; + } + write!(f, "{literal}")?; + } + write!(f, "] }}") + } + WhereCondition::Ordering { ordering, bound } => { + write!(f, "{{ \"{}\": {bound} }}", ordering.wire_name()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ordering(ordering: WhereOrdering, bound: WhereBound) -> WhereCondition { + WhereCondition::Ordering { ordering, bound } + } + + #[test] + fn should_read_a_literal_as_the_kind_of_the_referenced_property() { + let text = WhereLiteral::Text("delivered".to_string()); + assert_eq!( + text.value_for(WhereLiteralKind::Text), + Some(Value::Text("delivered".to_string())) + ); + assert_eq!(text.value_for(WhereLiteralKind::Integer), None); + assert_eq!(text.value_for(WhereLiteralKind::Identifier), None); + assert_eq!(text.value_for(WhereLiteralKind::Bytes), None); + + let hex = WhereLiteral::Text("cafe".to_string()); + assert_eq!( + hex.value_for(WhereLiteralKind::Bytes), + Some(Value::Bytes(vec![0xca, 0xfe])) + ); + + let id = Identifier::new([7; 32]); + let base58 = WhereLiteral::Text(id.to_string(Encoding::Base58)); + assert_eq!( + base58.value_for(WhereLiteralKind::Identifier), + Some(Value::Identifier([7; 32])) + ); + assert_eq!( + WhereLiteral::Boolean(true).value_for(WhereLiteralKind::Boolean), + Some(Value::Bool(true)) + ); + assert_eq!( + WhereLiteral::Boolean(true).value_for(WhereLiteralKind::Integer), + None + ); + } + + #[test] + fn should_spell_a_literal_from_a_whole_number_float() { + assert_eq!( + WhereLiteral::from_value(&Value::Float(5.0)), + Some(WhereLiteral::Integer(5)) + ); + assert_eq!(WhereLiteral::from_value(&Value::Float(5.5)), None); + assert_eq!( + WhereLiteral::from_value(&Value::U64(9)), + Some(WhereLiteral::Integer(9)) + ); + assert_eq!(WhereLiteral::from_value(&Value::Null), None); + } + + #[test] + fn should_hold_a_constant_only_for_an_equal_present_value() { + let delivered = WhereCondition::Constant(WhereLiteral::Text("delivered".to_string())); + let kind = Some(WhereLiteralKind::Text); + assert!(delivered.holds(Some(&Value::Text("delivered".to_string())), kind, None)); + assert!(!delivered.holds(Some(&Value::Text("refunded".to_string())), kind, None)); + assert!(!delivered.holds(None, kind, None)); + + // An integer constant equals the value whatever width it is stored at + let three = WhereCondition::Constant(WhereLiteral::Integer(3)); + assert!(three.holds(Some(&Value::U8(3)), Some(WhereLiteralKind::Integer), None)); + assert!(!three.holds(Some(&Value::U8(4)), Some(WhereLiteralKind::Integer), None)); + } + + #[test] + fn should_hold_an_in_for_any_listed_value() { + let open = WhereCondition::In( + ["open", "extended"] + .into_iter() + .map(|text| WhereLiteral::Text(text.to_string())) + .collect(), + ); + let kind = Some(WhereLiteralKind::Text); + assert!(open.holds(Some(&Value::Text("extended".to_string())), kind, None)); + assert!(!open.holds(Some(&Value::Text("closed".to_string())), kind, None)); + assert!(!open.holds(None, kind, None)); + } + + #[test] + fn should_negate_equality_for_not_equal() { + let other = WhereCondition::NotEqual(WhereBound::Writer); + let kind = Some(WhereLiteralKind::Identifier); + let writer = Value::Identifier([1; 32]); + assert!(!other.holds(Some(&Value::Identifier([1; 32])), kind, Some(&writer))); + assert!(other.holds(Some(&Value::Identifier([2; 32])), kind, Some(&writer))); + + // Both sides absent are equal, so notEqual does not hold; one absent + // differs, so it does + let unlike = WhereCondition::NotEqual(WhereBound::Property("tag".to_string())); + let kind = Some(WhereLiteralKind::Text); + assert!(!unlike.holds(None, kind, None)); + assert!(unlike.holds(Some(&Value::Text("a".to_string())), kind, None)); + assert!(unlike.holds(None, kind, Some(&Value::Text("a".to_string())))); + + let not_closed = WhereCondition::NotEqual(WhereBound::Constant(WhereLiteral::Text( + "closed".to_string(), + ))); + assert!(not_closed.holds(None, kind, None)); + assert!(!not_closed.holds(Some(&Value::Text("closed".to_string())), kind, None)); + } + + #[test] + fn should_order_integers_only_when_both_sides_have_a_value() { + let kind = Some(WhereLiteralKind::Integer); + let after_creation = ordering( + WhereOrdering::GreaterThan, + WhereBound::System(SystemProperty::CreatedAt), + ); + assert!(after_creation.holds(Some(&Value::U64(10)), kind, Some(&Value::U64(9)))); + assert!(!after_creation.holds(Some(&Value::U64(9)), kind, Some(&Value::U64(9)))); + assert!(!after_creation.holds(None, kind, Some(&Value::U64(9)))); + assert!(!after_creation.holds(Some(&Value::U64(10)), kind, None)); + + let at_most_amount = ordering( + WhereOrdering::LessThanOrEqual, + WhereBound::Property("amount".to_string()), + ); + assert!(at_most_amount.holds(Some(&Value::I64(-5)), kind, Some(&Value::U32(0)))); + assert!(!at_most_amount.holds(Some(&Value::I64(1)), kind, Some(&Value::U32(0)))); + + let at_least_ten = ordering( + WhereOrdering::GreaterThanOrEqual, + WhereBound::Constant(WhereLiteral::Integer(10)), + ); + assert!(at_least_ten.holds(Some(&Value::U16(10)), kind, None)); + assert!(!at_least_ten.holds(Some(&Value::U16(9)), kind, None)); + // A float is no integer, which an ordering compares + assert!(!at_least_ten.holds(Some(&Value::Float(11.0)), kind, None)); + } + + #[test] + fn should_recheck_on_every_replace_only_bounds_that_change_without_a_write() { + assert!(WhereCondition::NotEqual(WhereBound::Writer).is_rechecked_on_every_replace()); + assert!(ordering( + WhereOrdering::LessThan, + WhereBound::System(SystemProperty::UpdatedAt) + ) + .is_rechecked_on_every_replace()); + assert!(ordering( + WhereOrdering::LessThan, + WhereBound::System(SystemProperty::TransferredAtBlockHeight) + ) + .is_rechecked_on_every_replace()); + assert!(!ordering( + WhereOrdering::LessThan, + WhereBound::System(SystemProperty::CreatedAt) + ) + .is_rechecked_on_every_replace()); + assert!(!ordering( + WhereOrdering::LessThan, + WhereBound::Property("amount".to_string()) + ) + .is_rechecked_on_every_replace()); + assert!( + !WhereCondition::Constant(WhereLiteral::Boolean(true)).is_rechecked_on_every_replace() + ); + } + + #[test] + fn should_display_the_condition_as_written() { + assert_eq!( + ordering( + WhereOrdering::GreaterThan, + WhereBound::System(SystemProperty::CreatedAt) + ) + .to_string(), + "{ \"greaterThan\": \"$createdAt\" }" + ); + assert_eq!( + WhereCondition::Constant(WhereLiteral::Boolean(true)).to_string(), + "{ \"const\": true }" + ); + assert_eq!( + WhereCondition::In( + [WhereLiteral::Integer(2), WhereLiteral::Integer(1)] + .into_iter() + .collect() + ) + .to_string(), + "{ \"in\": [1, 2] }" + ); + } +} 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 4c73ea2d039..f76c72ea681 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 @@ -101,14 +101,18 @@ use crate::consensus::basic::document::PropertyConstraintViolation; use crate::data_contract::document_type::property_names; use crate::data_contract::errors::DataContractError; use crate::document::property_names::{ - CREATED_AT, CREATED_AT_BLOCK_HEIGHT, CREATED_AT_CORE_BLOCK_HEIGHT, ID as DOCUMENT_ID, OWNER_ID, - TRANSFERRED_AT, TRANSFERRED_AT_BLOCK_HEIGHT, TRANSFERRED_AT_CORE_BLOCK_HEIGHT, UPDATED_AT, - UPDATED_AT_BLOCK_HEIGHT, UPDATED_AT_CORE_BLOCK_HEIGHT, + CREATED_AT, CREATED_AT_BLOCK_HEIGHT, CREATED_AT_CORE_BLOCK_HEIGHT, CREATOR_ID, + ID as DOCUMENT_ID, OWNER_ID, TRANSFERRED_AT, TRANSFERRED_AT_BLOCK_HEIGHT, + TRANSFERRED_AT_CORE_BLOCK_HEIGHT, UPDATED_AT, UPDATED_AT_BLOCK_HEIGHT, + UPDATED_AT_CORE_BLOCK_HEIGHT, }; use crate::document::{Document, DocumentV0Getters}; use crate::prelude::{BlockHeight, CoreBlockHeight, TimestampMillis}; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::btreemap_extensions::BTreeValueMapPathHelper; use platform_value::string_encoding::Encoding; use platform_value::{Identifier, Value, ValueMapHelper}; +use serde::{Serialize, Serializer}; use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; @@ -149,8 +153,8 @@ const CONST: &str = "const"; /// Every key an operand object may hold, for the errors. const OPERAND_KEYS: &str = "add, subtract, multiply, divide, modulo, power, min, max, abs, \ - ifAbsent, length, byteLength, count, countPresent, byteAt, countOf or \ - sumOf"; + ifAbsent, length, byteLength, count, countPresent, byteAt, countOf, \ + sumOf or referenced"; /// 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 @@ -177,6 +181,117 @@ pub const STORED_DOCUMENT_PREFIX: &str = "$old."; /// [`STORED_DOCUMENT_PREFIX`] reads them. pub const STORED_DOCUMENT_KEY: &str = "$old"; +/// The operand key of a value of the document a reference of the type points +/// at: `{ "referenced": ["pollId", "endsAt"] }` is the `endsAt` of the +/// document the top-level property `pollId` refers to. It stands wherever a +/// property path does, and is read as the path +/// `$referenced.pollId.endsAt` ([`REFERENCED_DOCUMENT_PREFIX`]). +pub const REFERENCED: &str = "referenced"; + +/// The key the values of the documents the type's references point at sit +/// under in the data a `propertyConstraints` rule reading one is judged +/// against, by the reference property, so that a path through +/// [`REFERENCED_DOCUMENT_PREFIX`] reads them. Consensus puts them there once +/// the references are checked, from the documents that check read; a rule +/// reading one is not judged without them +/// ([`DocumentSystemValues::referenced_documents`]). +pub const REFERENCED_DOCUMENT_KEY: &str = "$referenced"; + +/// The prefix of the path a `referenced` operand is read through: +/// `$referenced..`. +pub const REFERENCED_DOCUMENT_PREFIX: &str = "$referenced."; + +/// The fields of a referenced document a `referenced` operand may read beside +/// its schema properties and the nine times and heights: its identifiers, +/// which compare as identifiers. +pub const REFERENCED_IDENTIFIER_FIELDS: [&str; 3] = [DOCUMENT_ID, OWNER_ID, CREATOR_ID]; + +/// The reference property and the field of the document it points at that the +/// path a `referenced` operand is read through names: `("pollId", "endsAt")` +/// for `$referenced.pollId.endsAt`. `None` for any other path. +pub fn referenced_read(path: &str) -> Option<(&str, &str)> { + path.strip_prefix(REFERENCED_DOCUMENT_PREFIX)? + .split_once('.') +} + +/// The path a `referenced` operand reading `field` through `reference` is read +/// through ([`REFERENCED_DOCUMENT_PREFIX`]). +pub fn referenced_path(reference: &str, field: &str) -> String { + format!("{REFERENCED_DOCUMENT_PREFIX}{reference}.{field}") +} + +/// What `rules` read of the documents the type's references point at: by +/// reference property, the fields read of the document it points at. Empty when +/// no rule reads one. A reference listed here is checked again on every replace, +/// since every replace judges the rules. +pub fn referenced_fields( + rules: &BTreeMap, +) -> BTreeMap<&str, BTreeSet<&str>> { + let mut fields: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new(); + for rule in rules.values() { + for (reference, field, _) in rule.referenced_reads() { + fields.entry(reference).or_default().insert(field); + } + } + fields +} + +/// The values `fields` names of `document`, a document a reference points at, +/// as a map a rule reads through [`REFERENCED_DOCUMENT_PREFIX`] once it is put +/// under the reference property ([`data_with_referenced_documents`]): a schema +/// property at its dotted path, `$id`, `$ownerId` and `$creatorId` as +/// identifiers, a time or height as an integer. A field the document does not +/// hold is left out, so a rule reads it as absent. +pub fn referenced_document_values<'a>( + document: &Document, + fields: impl IntoIterator, +) -> Result { + let system = DocumentSystemValues::of_document(document); + let mut values = Value::Map(Vec::new()); + for field in fields { + let value = match field { + DOCUMENT_ID => Some(Value::Identifier(document.id().to_buffer())), + OWNER_ID => Some(Value::Identifier(document.owner_id().to_buffer())), + CREATOR_ID => document + .creator_id() + .map(|creator_id| Value::Identifier(creator_id.to_buffer())), + field => match SystemProperty::from_name(field) { + Some(property) => system.value(property).map(Value::I128), + None => document.properties().get_optional_at_path(field)?.cloned(), + }, + }; + if let Some(value) = value { + values.set_value_at_full_path(field, value)?; + } + } + Ok(values) +} + +/// The data a rule reading a referenced document is judged against: the +/// document's properties, `data`, with `referenced`, each reference property and +/// the values its rules read of the document it points at +/// ([`referenced_document_values`]), under [`REFERENCED_DOCUMENT_KEY`]. A +/// reference the document leaves out has no entry, and reads as absent. +pub fn data_with_referenced_documents( + data: &BTreeMap, + referenced: BTreeMap, +) -> Value { + let mut entries: Vec<(Value, Value)> = data + .iter() + .map(|(property, value)| (Value::Text(property.clone()), value.clone())) + .collect(); + entries.push(( + Value::Text(REFERENCED_DOCUMENT_KEY.to_string()), + Value::Map( + referenced + .into_iter() + .map(|(reference, values)| (Value::Text(reference), values)) + .collect(), + ), + )); + Value::Map(entries) +} + /// How the two sides of a comparison must compare. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConstraintComparison { @@ -236,8 +351,13 @@ impl ConstraintComparison { /// its last update (a create, a replace or a price update) or of its last /// transfer (a create, a transfer or a purchase). A rule may read one only on a /// document type that records it, by listing it in `required`, so every stored -/// document of the type holds it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +/// document of the type holds it. A `where` entry of a `refersTo` may bound an +/// ordering by one too (`WhereBound::System`), which embeds it in the reference +/// errors, so it encodes. +// @append_only +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode, DecodeUntrusted, +)] pub enum SystemProperty { /// `$createdAt` CreatedAt, @@ -314,6 +434,13 @@ impl SystemProperty { } } +/// Serialized as a rule writes it, `"$createdAt"`. +impl Serialize for SystemProperty { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.name()) + } +} + /// A write that changes a stored document's system values and none of its /// properties, so only the rules reading what it changes can break. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -351,6 +478,12 @@ pub struct DocumentSystemValues { /// `validate_property_constraints` reports as an error rather than skip the /// rule ([`PropertyConstraint::unread_aggregate`]). pub aggregates: Option>, + /// Whether the data the rules are judged against holds, under + /// [`REFERENCED_DOCUMENT_KEY`], the values they read of the documents the + /// type's references point at. Consensus sets it once the references are + /// checked, and judges then the rules reading one; anywhere else a rule + /// reading one is not judged ([`PropertyConstraint::violation`]). + pub referenced_documents: bool, } impl DocumentSystemValues { @@ -378,6 +511,7 @@ impl DocumentSystemValues { updated_at_core_block_height: Some(block_info.core_height), transferred_at_core_block_height: Some(block_info.core_height), aggregates: None, + referenced_documents: false, } } @@ -395,6 +529,7 @@ impl DocumentSystemValues { updated_at_core_block_height: document.updated_at_core_block_height(), transferred_at_core_block_height: document.transferred_at_core_block_height(), aggregates: None, + referenced_documents: false, } } @@ -1403,16 +1538,19 @@ impl PropertyConstraint { /// document type records, the only ones a rule may read, so only a client /// that does not know one skips the rule. So is a rule reading an aggregate /// `system` does not give: consensus reads every one before judging the - /// write, and a client, which cannot read state here, gives none. + /// write, and a client, which cannot read state here, gives none. And so + /// is a rule reading a referenced document unless `system` says `data` + /// holds the values it reads ([`DocumentSystemValues::referenced_documents`]). pub fn violation( &self, data: &Value, system: &DocumentSystemValues, ) -> Option { - if self - .system_reads() - .into_iter() - .any(|property| system.value(property).is_none()) + if (!system.referenced_documents && self.reads_referenced()) + || self + .system_reads() + .into_iter() + .any(|property| system.value(property).is_none()) || self .find_aggregate(&|read| { !system @@ -1502,6 +1640,26 @@ impl PropertyConstraint { reads } + /// What the rule reads of the documents the type's references point at, + /// each as the reference property, the field of the referenced document + /// and how it is read, in declared order, a read made twice listed twice. + pub fn referenced_reads(&self) -> Vec<(&str, &str, PropertyRead)> { + self.property_reads() + .into_iter() + .filter_map(|(path, read)| { + referenced_read(path).map(|(reference, field)| (reference, field, read)) + }) + .collect() + } + + /// Whether the rule reads a value of a document one of the type's + /// references points at (`{ "referenced": [...] }`). + pub fn reads_referenced(&self) -> bool { + self.property_reads() + .into_iter() + .any(|(path, _)| path.starts_with(REFERENCED_DOCUMENT_PREFIX)) + } + /// Whether the rule compares the document's owner, `$ownerId`, or reads an /// aggregate depending on it ([`AggregateRead::reads_owner`]): then a /// transfer or a purchase, which changes the owner and nothing else, is @@ -2246,6 +2404,117 @@ pub fn parse_property_constraint_condition( parse_condition(condition, &mut String::new(), 0, &context) } +/// The path `value` names where a property path stands: a bare string, or a +/// `referenced` operand, `{ "referenced": [reference, field] }`, read through +/// [`REFERENCED_DOCUMENT_PREFIX`]. `None` for anything else, `Some(Err)` for a +/// `referenced` operand that is malformed, the error placing it at `at`. A +/// bare string naming the path a `referenced` operand is read through is +/// refused: there is one way to write the operand. +fn named_path(value: &Value, at: &str) -> Option> { + if let Some(path) = value.as_text() { + return Some(bare_path(path, at)); + } + let (REFERENCED, operands) = single_entry(value)? else { + return None; + }; + Some(referenced_operand(operands, at)) +} + +/// A path written as a bare string at `at`, refused when it names the path a +/// `referenced` operand is read through, which has one spelling. +fn bare_path(path: &str, at: &str) -> Result { + if path.starts_with(REFERENCED_DOCUMENT_KEY) { + return Err(format!( + "{}names \"{path}\": a value of the document a reference points at is written \ + {{ \"{REFERENCED}\": [reference, field] }}", + located(at) + )); + } + Ok(path.to_string()) +} + +/// The path a `referenced` operand at `at` listing `operands` is read through: +/// exactly two strings, the name of a top-level property of the type (the +/// reference, whose declaration is checked against the parsed document type), +/// then a field of the document it points at: a dotted property path, its +/// `$id`, `$ownerId` or `$creatorId`, or one of the nine times and heights. +fn referenced_operand(operands: &Value, at: &str) -> Result { + let at = if at.is_empty() { + REFERENCED.to_string() + } else { + format!("{at}.{REFERENCED}") + }; + let Some([Value::Text(reference), Value::Text(field)]) = operands.as_array().map(Vec::as_slice) + else { + return Err(format!( + "at {at} must list a reference property of the document type, then a field of the \ + document it refers to" + )); + }; + if !is_rule_name(reference) { + return Err(format!( + "at {at}[0] names \"{reference}\", but a reference property is a top-level \ + property, named with 1 to 64 letters, digits or underscores" + )); + } + let is_field = REFERENCED_IDENTIFIER_FIELDS.contains(&field.as_str()) + || SystemProperty::from_name(field).is_some() + || (!field.is_empty() && field.split('.').all(is_rule_name)); + if !is_field { + return Err(format!( + "at {at}[1] names \"{field}\", but a field of the referenced document is a \ + property path of its type, its $id, $ownerId or $creatorId, or a time or height \ + it records" + )); + } + Ok(referenced_path(reference, field)) +} + +/// How a condition compares the value at `path`: `$ownerId` and the +/// identifiers of a referenced document as identifiers, a property of the +/// document type as `property_kind` says, and any other value of a referenced +/// document not at all ([`has_open_kind`]). +fn path_kind(context: &ParseContext, path: &str) -> Option { + if path == OWNER_ID { + return Some(EqualityKind::Identifier); + } + if let Some((_, field)) = referenced_read(path) { + return REFERENCED_IDENTIFIER_FIELDS + .contains(&field) + .then_some(EqualityKind::Identifier); + } + (context.property_kind)(path) +} + +/// Whether the value at `path` is a schema property of a referenced document, +/// whose type the parse of the declaring type does not know (it may be another +/// contract's): a comparison then reads it as what it is compared with says, a +/// string or an identifier beside a property of that kind, a string beside a +/// `const` or in an `in` listing strings, an integer otherwise, and the read is +/// checked against the referenced type at registration. +fn has_open_kind(path: &str) -> bool { + referenced_read(path).is_some_and(|(_, field)| !field.starts_with('$')) +} + +/// The kinds the two sides of a comparison, `left` and `right`, compare as +/// ([`path_kind`]), a side of an open kind ([`has_open_kind`]) taking the +/// other side's. +fn side_kinds( + context: &ParseContext, + left: &Value, + right: &Value, +) -> (Option, Option) { + let path = |value: &Value| named_path(value, "").and_then(Result::ok); + let (left_path, right_path) = (path(left), path(right)); + let kind = |path: &Option| path.as_deref().and_then(|path| path_kind(context, path)); + let open = |path: &Option| path.as_deref().is_some_and(has_open_kind); + let (left_kind, right_kind) = (kind(&left_path), kind(&right_path)); + ( + left_kind.or(if open(&left_path) { right_kind } else { None }), + right_kind.or(if open(&right_path) { left_kind } else { None }), + ) +} + /// Whether `name` can name a rule: 1 to 64 letters, digits or underscores, as /// a property name. fn is_rule_name(name: &str) -> bool { @@ -2325,14 +2594,9 @@ fn parse_condition( )); }; let parent = enter(at, key); - // `$ownerId`, the document's owner, compares as an identifier property - let kind_of = |path: &str| { - if path == OWNER_ID { - Some(EqualityKind::Identifier) - } else { - (context.property_kind)(path) - } - }; + // `$ownerId`, the document's owner, compares as an identifier property, and + // so do the identifiers of a referenced document + let kind_of = |path: &str| path_kind(context, path); let condition = match key { ANY_OF => PropertyConstraint::AnyOf(condition_list(body, key, at, depth + 1, context)?), ALL_OF => PropertyConstraint::AllOf(condition_list(body, key, at, depth + 1, context)?), @@ -2400,17 +2664,20 @@ fn parse_condition( .and_then(|values| values.first()) .is_some_and(|first| first.as_text().is_some()); // Strings listed for an identifier property are its identifiers, base58 - let identifier_path = operand - .as_text() - .filter(|path| over_strings && kind_of(path) == Some(EqualityKind::Identifier)); + let identifier_path = match named_path(operand, &format!("{at}[0]")) { + Some(Err(error)) => return Err(error), + Some(Ok(path)) + if over_strings && kind_of(&path) == Some(EqualityKind::Identifier) => + { + Some(path) + } + _ => None, + }; let listed = if let Some(path) = identifier_path { at.push_str("[1]"); let values = in_identifier_values(values, at)?; at.truncate(base); - PropertyConstraint::IdentifierIn { - path: path.to_string(), - values, - } + PropertyConstraint::IdentifierIn { path, values } } else if over_strings { let Ok(TextSide::Property(property)) = text_side(operand, &format!("{at}[0]")) else { @@ -2460,8 +2727,7 @@ fn parse_condition( }; // A bare path naming a byte array property on either side tests byte // arrays, and a const beside it is written in hex - let bare_kind = |value: &Value| value.as_text().and_then(kind_of); - match (bare_kind(text), bare_kind(affix)) { + match side_kinds(context, text, affix) { (Some(EqualityKind::Bytes), None | Some(EqualityKind::Bytes)) | (None, Some(EqualityKind::Bytes)) => { let bytes = bytes_operand(text, &format!("{at}[0]"))?; @@ -2526,14 +2792,33 @@ fn parse_condition( )); }; // `$ownerId` and the system times are values, never arrays - let Some(array) = array.as_text().filter(|path| !path.starts_with('$')) else { - return Err(format!("at {at}[0] must name an array property path")); + let array = match named_path(array, &format!("{at}[0]")) { + Some(Err(error)) => return Err(error), + Some(Ok(path)) + if !path.starts_with('$') || path.starts_with(REFERENCED_DOCUMENT_PREFIX) => + { + path + } + _ => return Err(format!("at {at}[0] must name an array property path")), }; let base = at.len(); at.push_str("[1]"); // The kind of the array's elements decides what a const spells, and - // what is checked against the parsed document type - let needle = match (context.property_kind)(array) { + // what is checked against the parsed document type. An array of a + // referenced document is of the kind of what is looked for in it: a + // string for a const or a string default, the kind of a path + let array_kind = (context.property_kind)(&array).or_else(|| { + if !has_open_kind(&array) { + return None; + } + if is_const(needle) || is_text_if_absent(needle) { + return Some(EqualityKind::Text); + } + named_path(needle, "") + .and_then(Result::ok) + .and_then(|path| path_kind(context, &path)) + }); + let needle = match array_kind { Some(EqualityKind::Text) => match text_side(needle, at)? { TextSide::Constant(value) => ContainsNeedle::TextConstant(value), TextSide::Property(property) => ContainsNeedle::TextProperty(property), @@ -2555,20 +2840,18 @@ fn parse_condition( } }; at.truncate(base); - PropertyConstraint::Contains { - array: array.to_string(), - needle, - } + PropertyConstraint::Contains { array, needle } } // What the path names is checked against the parsed document type PRESENT | ABSENT => { - let Some(path) = body.as_text() else { - return Err(format!("at {at} must name a property path")); + let path = match named_path(body, at) { + Some(path) => path?, + None => return Err(format!("at {at} must name a property path")), }; if key == PRESENT { - PropertyConstraint::Present(path.to_string()) + PropertyConstraint::Present(path) } else { - PropertyConstraint::Absent(path.to_string()) + PropertyConstraint::Absent(path) } } _ => { @@ -2587,14 +2870,14 @@ fn parse_condition( // A const or an ifAbsent with a string default on either side, or // two bare paths naming string or identifier properties, compare // strings or identifiers, as the properties named decide - let bare_kind = |value: &Value| value.as_text().and_then(kind_of); + let (left_kind, right_kind) = side_kinds(context, left, right); if is_const(left) || is_const(right) || is_text_if_absent(left) || is_text_if_absent(right) - || (bare_kind(left).is_some() && bare_kind(right).is_some()) + || (left_kind.is_some() && right_kind.is_some()) { - let compare = match (bare_kind(left), bare_kind(right)) { + let compare = match (left_kind, right_kind) { (Some(EqualityKind::Bytes), _) | (_, Some(EqualityKind::Bytes)) => { return Err(format!( "at {at} compares a byte array property, which only startsWith \ @@ -2784,8 +3067,8 @@ fn identifier_side(value: &Value, at: &str) -> Result { return identifier_constant(constant, &format!("{at}.{CONST}")) .map(IdentifierSide::Constant); } - if let Some(path) = value.as_text() { - return Ok(IdentifierSide::Property(path.to_string())); + if let Some(path) = named_path(value, at) { + return path.map(IdentifierSide::Property); } if is_text_if_absent(value) { return Err(format!( @@ -2876,26 +3159,23 @@ fn text_side(value: &Value, at: &str) -> Result { format!("at {at}.{CONST} must be a string: an integer is written as itself") }); } - if let Some(path) = value.as_text() { + if let Some(path) = named_path(value, at) { return Ok(TextSide::Property(TextProperty { - path: path.to_string(), + path: path?, if_absent: None, })); } if let Some((IF_ABSENT, operands)) = single_entry(value) { - match operands.as_array().map(Vec::as_slice) { - Some([Value::Text(path), Value::Text(default)]) => { - return Ok(TextSide::Property(TextProperty { - path: path.clone(), - if_absent: Some(default.clone()), - })); - } - Some([_, Value::Text(_)]) => { + if let Some([path, Value::Text(default)]) = operands.as_array().map(Vec::as_slice) { + let Some(path) = named_path(path, &format!("{at}.{IF_ABSENT}[0]")) else { return Err(format!( "at {at}.{IF_ABSENT} must name a property path first" )); - } - _ => {} + }; + return Ok(TextSide::Property(TextProperty { + path: path?, + if_absent: Some(default.clone()), + })); } } Err(format!( @@ -2933,8 +3213,8 @@ fn bytes_operand(value: &Value, at: &str) -> Result { ) }); } - if let Some(path) = value.as_text() { - return Ok(BytesOperand::Property(path.to_string())); + if let Some(path) = named_path(value, at) { + return path.map(BytesOperand::Property); } if single_entry(value).is_some_and(|(key, _)| key == IF_ABSENT) { return Err(format!( @@ -3009,13 +3289,11 @@ fn parse_expression( )); } if let Some(path) = value.as_text() { - if let Some(property) = SystemProperty::from_name(path) { + let path = bare_path(path, at)?; + if let Some(property) = SystemProperty::from_name(&path) { return Ok(ConstraintExpression::System(property)); } - return Ok(ConstraintExpression::Property { - path: path.to_string(), - if_absent: 0, - }); + return Ok(ConstraintExpression::Property { path, if_absent: 0 }); } if is_number(value) { return integer_value(value, at).map(ConstraintExpression::Value); @@ -3037,10 +3315,11 @@ fn parse_expression( the document leaves it out" )); }; - let Some(path) = path.as_text() else { - return Err(format!("at {at} must name a property path first")); + let path = match named_path(path, &format!("{at}[0]")) { + Some(path) => path?, + None => return Err(format!("at {at} must name a property path first")), }; - if SystemProperty::from_name(path).is_some() { + if SystemProperty::from_name(&path).is_some() { return Err(format!( "at {at} gives {path} a default, but a system property a rule reads is \ always set: name it on its own" @@ -3057,7 +3336,7 @@ fn parse_expression( } at.push_str("[1]"); ConstraintExpression::Property { - path: path.to_string(), + path, if_absent: integer_value(if_absent, at)?, } } @@ -3107,18 +3386,16 @@ fn parse_expression( } // What the path names is checked against the parsed document type LENGTH | BYTE_LENGTH | COUNT => { - let Some(path) = operands.as_text() else { - return Err(format!("at {at} must name a property path")); + let path = match named_path(operands, at) { + Some(path) => path?, + None => return Err(format!("at {at} must name a property path")), }; let measure = match key { LENGTH => SizeMeasure::Length, BYTE_LENGTH => SizeMeasure::ByteLength, _ => SizeMeasure::Count, }; - ConstraintExpression::Size { - measure, - path: path.to_string(), - } + ConstraintExpression::Size { measure, path } } // What the paths name is checked against the parsed document type COUNT_PRESENT => ConstraintExpression::CountPresent(count_present_paths(operands, at)?), @@ -3131,10 +3408,11 @@ fn parse_expression( it reads, counted from 0" )); }; - let Some(path) = path.as_text() else { - return Err(format!("at {at} must name a property path first")); + let path = match named_path(path, &format!("{at}[0]")) { + Some(path) => path?, + None => return Err(format!("at {at} must name a property path first")), }; - if path == OWNER_ID || SystemProperty::from_name(path).is_some() { + if path == OWNER_ID || SystemProperty::from_name(&path).is_some() { return Err(format!( "at {at} names {path}, but byteAt reads a byte array property" )); @@ -3154,16 +3432,21 @@ fn parse_expression( u16::MAX )); }; - ConstraintExpression::ByteAt { - path: path.to_string(), - index, - } + ConstraintExpression::ByteAt { path, index } } // Which document type it names, and what its keys name, is checked // once every document type of the contract is parsed COUNT_OF | SUM_OF => { ConstraintExpression::Aggregate(parse_aggregate(key == SUM_OF, operands, at, context)?) } + // A value of the document a reference points at, read as a property is + REFERENCED => { + at.truncate(parent); + ConstraintExpression::Property { + path: referenced_operand(operands, at)?, + if_absent: 0, + } + } CONST => { at.truncate(parent); return Err(format!( @@ -3196,16 +3479,17 @@ fn count_present_paths(paths: &Value, at: &mut String) -> Result, St for (index, path) in paths.iter().enumerate() { // Writing to a `String` cannot fail let _ = write!(at, "[{index}]"); - let Some(path) = path.as_text() else { - return Err(format!("at {at} must name a property path")); + let path = match named_path(path, at) { + Some(path) => path?, + None => return Err(format!("at {at} must name a property path")), }; - if let Some(earlier) = seen.insert(path, index) { + if let Some(earlier) = seen.insert(path.clone(), index) { return Err(format!( "at {at} repeats the path at {}[{earlier}]", &at[..base] )); } - parsed.push(path.to_string()); + parsed.push(path); at.truncate(base); } Ok(parsed) 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 45827cb7680..6e8d2420760 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 @@ -2208,8 +2208,8 @@ fn should_refuse_a_malformed_size_operand() { ( platform_value!({ "size": "title" }), "names \"size\", which is not one of add, subtract, multiply, divide, modulo, \ - power, min, max, abs, ifAbsent, length, byteLength, count, countPresent, byteAt, countOf \ - or sumOf", + power, min, max, abs, ifAbsent, length, byteLength, count, countPresent, byteAt, countOf, \ + sumOf or referenced", ), ] { expect_refusal( @@ -2600,6 +2600,7 @@ fn should_read_the_system_values_the_rule_is_judged_with() { updated_at_core_block_height: Some(8), transferred_at_core_block_height: Some(9), aggregates: None, + referenced_documents: false, }; for (index, property) in SystemProperty::ALL.into_iter().enumerate() { let expected = i128::try_from(index + 1).expect("small"); @@ -4273,6 +4274,7 @@ fn should_read_an_aggregate_from_the_system_values_and_skip_a_rule_not_given_one let cheap = data(&[("price", Value::U64(5))]); let with_total = |total: i128| DocumentSystemValues { aggregates: Some(BTreeMap::from([(read.clone(), total)])), + referenced_documents: false, ..DocumentSystemValues::default() }; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index d07b26f1137..4426072de18 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -385,6 +385,7 @@ impl ErrorWithCode for StateError { Self::ReferencedDocumentRemovedError(_) => 40145, Self::ReferencedDocumentTypeIndexOnlyError(_) => 40146, Self::DocumentDeleteConstraintViolatedError(_) => 40147, + Self::ReferencedDocumentRuleReadInvalidError(_) => 40148, // Identity Errors: 40200-40299 Self::IdentityAlreadyExistsError(_) => 40200, diff --git a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs index 10c8a00697d..843531db548 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs @@ -30,6 +30,7 @@ pub mod referenced_document_property_agreement_invalid_error; pub mod referenced_document_property_mismatch_error; pub mod referenced_document_removed_error; pub mod referenced_document_requirement_not_met_error; +pub mod referenced_document_rule_read_invalid_error; pub mod referenced_document_type_deletable_error; pub mod referenced_document_type_index_only_error; pub mod referenced_document_type_moderated_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_rule_read_invalid_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_rule_read_invalid_error.rs new file mode 100644 index 00000000000..1b8b64cd683 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_rule_read_invalid_error.rs @@ -0,0 +1,88 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A `propertyConstraints` rule reads, through a `refersTo` into a document +/// type of another contract (`{ "referenced": [reference, field] }`), a field +/// that type can not give it: one it does not have, a transient one, one of +/// another kind than the rule reads, a time or height it does not record, a +/// `$creatorId` it does not record, a constant its `enum` does not list, or, +/// through a `moderatedDocument` reference, a field its removal record does not +/// keep. Reported at contract registration and update, against the stored +/// contract; a rule reading a document type of the declaring contract is +/// refused by the contract parse instead. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "propertyConstraints rule {rule} of {document_type_name} reads through {reference}: {reason}" +)] +#[platform_serialize(unversioned)] +pub struct ReferencedDocumentRuleReadInvalidError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + document_type_name: String, + rule: String, + reference: String, + reason: String, +} + +impl ReferencedDocumentRuleReadInvalidError { + pub fn new( + document_type_name: String, + rule: String, + reference: String, + reason: String, + ) -> Self { + Self { + document_type_name, + rule, + reference, + reason, + } + } + + /// The document type declaring the rule. + pub fn document_type_name(&self) -> &str { + &self.document_type_name + } + + /// The rule's name. + pub fn rule(&self) -> &str { + &self.rule + } + + /// The reference property the rule reads through. + pub fn reference(&self) -> &str { + &self.reference + } + + /// What the referenced document type can not give the rule. + pub fn reason(&self) -> &str { + &self.reason + } +} + +impl From for ConsensusError { + fn from(err: ReferencedDocumentRuleReadInvalidError) -> Self { + Self::StateError(StateError::ReferencedDocumentRuleReadInvalidError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index 884b4e2c69e..76aa7c6ab5a 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -84,6 +84,7 @@ use crate::consensus::state::document::referenced_document_removed_error::Refere use crate::consensus::state::document::referenced_document_type_moderated_error::ReferencedDocumentTypeModeratedError; use crate::consensus::state::document::referenced_document_type_index_only_error::ReferencedDocumentTypeIndexOnlyError; use crate::consensus::state::document::document_delete_constraint_violated_error::DocumentDeleteConstraintViolatedError; +use crate::consensus::state::document::referenced_document_rule_read_invalid_error::ReferencedDocumentRuleReadInvalidError; use crate::consensus::state::document::referenced_document_type_not_moderated_error::ReferencedDocumentTypeNotModeratedError; use crate::consensus::state::document::referenced_document_lookup_invalid_error::ReferencedDocumentLookupInvalidError; use crate::consensus::state::document::referenced_document_list_invalid_error::ReferencedDocumentListInvalidError; @@ -733,6 +734,11 @@ pub enum StateError { // document type would pay such a token to its owner (protocol version 14). #[error(transparent)] TokenNotTransferableError(TokenNotTransferableError), + + // A `propertyConstraints` rule reads a field of another contract's document type, through a + // reference, that the type can not give it (protocol version 14). + #[error(transparent)] + ReferencedDocumentRuleReadInvalidError(ReferencedDocumentRuleReadInvalidError), } impl From for ConsensusError { @@ -782,18 +788,21 @@ mod tests { discriminant } - /// A reference error for an id reference encodes exactly as it did before - /// lookup references existed: the lookup form is an appended variant of - /// `DocumentPropertyReferenceTarget` (`PermanentDocumentLookup`), not a - /// field of `PermanentDocument`, so a client decoding with an earlier dpp - /// still reads every error an id reference produces. The bytes are pinned; - /// `PermanentDocument` keeps variant 3 of the target, the lookup form - /// takes 6. + /// A reference error for an id reference encodes as pinned here: the + /// lookup form is an appended variant of `DocumentPropertyReferenceTarget` + /// (`PermanentDocumentLookup`), not a field of `PermanentDocument`, so the + /// lookup changed nothing an id reference produces. The `where` entries + /// that are not equalities did: they are a field, `where_conditions`, + /// after `property_agreement` on every document variant, which protocol + /// version 14, unreleased, introduced, so an id reference's error carries + /// one more byte, its empty map. The bytes are pinned; `PermanentDocument` + /// keeps variant 3 of the target, the lookup form takes 6. #[test] fn should_keep_the_encoding_of_a_reference_error_for_an_id_reference() { let id_reference = DocumentPropertyReferenceTarget::PermanentDocument { contract_id: None, document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::new(), }; let error = StateError::ReferencedEntityNotFoundError(ReferencedEntityNotFoundError::new( @@ -804,7 +813,8 @@ mod tests { let bytes = bincode::encode_to_vec(error, bincode::config::standard()) .expect("expected to encode the state error"); // StateError variant 93, the referenced id, target variant 3 - // (PermanentDocument: no contract id, "note", no agreement), the path. + // (PermanentDocument: no contract id, "note", no agreement, no + // conditions), the path. assert_eq!( hex::encode(bytes), concat!( @@ -814,6 +824,7 @@ mod tests { "00", "046e6f7465", "00", + "00", "066e6f74654964", ) ); @@ -827,6 +838,7 @@ mod tests { target_variant(&DocumentPropertyReferenceTarget::PermanentDocumentLookup { contract_id: None, document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::new(), lookup: DocumentReferenceLookup { keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), @@ -843,6 +855,7 @@ mod tests { ListElementReference { contract_id: None, document_type_name: "electedCharter".to_string(), + where_conditions: Default::default(), property_agreement: [("electedCharterId".to_string(), "$id".to_string())] .into(), in_list: "members".to_string(), @@ -855,6 +868,7 @@ mod tests { target_variant(&DocumentPropertyReferenceTarget::DeletableDocumentLookup { contract_id: None, document_type_name: "note".to_string(), + where_conditions: Default::default(), property_agreement: BTreeMap::new(), lookup: DocumentReferenceLookup { keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), @@ -1626,5 +1640,18 @@ mod tests { )), 173 ); + // A rule reading through a reference a field another contract's type can not give it + // (protocol version 14). + assert_eq!( + discriminant_of(StateError::ReferencedDocumentRuleReadInvalidError( + ReferencedDocumentRuleReadInvalidError::new( + "vote".to_string(), + "castBeforeTheEnd".to_string(), + "pollId".to_string(), + "endsAt is a string".to_string(), + ) + )), + 174 + ); } } 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 d6357eec244..87d7d2c2fe3 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 @@ -5,7 +5,11 @@ use dpp::consensus::state::document::document_contest_maximum_contenders_reached use dpp::consensus::state::document::document_contest_not_paid_for_error::DocumentContestNotPaidForError; use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; -use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::data_contract::document_type::property_constraints::{ + data_with_referenced_documents, DocumentSystemValues, PropertyConstraint, +}; use dpp::identifier::Identifier; use dpp::validation::{ConsensusValidationResult, SimpleConsensusValidationResult}; use dpp::version::PlatformVersion; @@ -130,18 +134,25 @@ impl DocumentCreateTransitionActionStateValidationV2 for DocumentCreateTransitio // The creator of a document being created is its writer; the commitments the create // reveals and consumes are collected for the batch to delete with it, and the values // of the type's derived index properties, from the documents the references fetched, - // for Drive to key the document by without reading those documents again + // for Drive to key the document by without reading those documents again. The + // document is recorded in this block, for every time and height a `where` entry + // reads, and the values the type's rules read of the documents its references point + // at are collected from those documents too + let system = DocumentSystemValues::created_in_block(owner_id, &self.block_info()); let mut derived_index_values = BTreeMap::new(); + let mut referenced_documents = BTreeMap::new(); let reference_result = self.base().validate_document_references( self.data(), owner_id, Some(owner_id), + &system, None, None, platform, block_info, consumed_documents, Some(&mut derived_index_values), + &mut referenced_documents, transaction, execution_context, platform_version, @@ -151,6 +162,32 @@ impl DocumentCreateTransitionActionStateValidationV2 for DocumentCreateTransitio } self.set_derived_index_values(derived_index_values); + // The rules reading a document a reference points at (protocol version 14), judged + // once the references hold, with the values their checks read: the structure + // validation judged every other rule, and could not judge these before the reads. + // A type without such a rule judges nothing more, and copies nothing + let document_type = self.base().document_type()?; + if document_type + .property_constraints() + .values() + .any(PropertyConstraint::reads_referenced) + { + let result = document_type + .validate_referencing_property_constraints( + &data_with_referenced_documents(self.data(), referenced_documents), + &DocumentSystemValues { + aggregates: Some(self.property_constraint_aggregates().clone()), + referenced_documents: true, + ..system + }, + platform_version, + ) + .map_err(Error::Protocol)?; + if !result.is_valid() { + return Ok(result); + } + } + // A non-contested create must not take the id of a live contested // document. Both derive the id from the same owner, document type and // entropy, and v1 probes contested storage only for contested creates, 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 5462d759521..ed349d69ee7 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 @@ -3,6 +3,7 @@ pub mod v0; use std::collections::{BTreeMap, BTreeSet}; use dpp::block::block_info::BlockInfo; +use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use dpp::data_contract::document_type::DocumentPropertyReferenceTarget; use dpp::identifier::Identifier; use dpp::platform_value::Value; @@ -104,18 +105,29 @@ pub(crate) trait DocumentReferenceValidation { /// the document its reference points at, which this validation fetched: Drive keys the /// new document by them without reading those documents again. A value whose document it /// did not fetch is left out, for Drive to read. + /// + /// `referring_system` holds the system values of the document being written (its owner, + /// times and heights, as a `propertyConstraints` rule judges it), which a `where` entry + /// bounded by a time or height reads. A reference the type's `propertyConstraints` rules + /// read through (`{ "referenced": [reference, field] }`) is checked on every replace, and + /// an accepted write records in `referenced_documents`, under the reference property, the + /// values those rules read of the document its check found (or of its removal record), + /// for the caller to judge the rules on without another read. A reference left out + /// records nothing. #[allow(clippy::too_many_arguments)] fn validate_document_references( &self, document_data: &BTreeMap, owner_id: Identifier, creator_id: Option, + referring_system: &DocumentSystemValues, changed_fields: Option<&BTreeSet>, stored_values: Option<&BTreeMap>, platform: &PlatformStateRef, block_info: &BlockInfo, consumed_documents: &mut Vec, derived_index_values: Option<&mut BTreeMap>, + referenced_documents: &mut BTreeMap, transaction: TransactionArg, execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, @@ -163,12 +175,14 @@ impl DocumentReferenceValidation for DocumentBaseTransitionAction { document_data: &BTreeMap, owner_id: Identifier, creator_id: Option, + referring_system: &DocumentSystemValues, changed_fields: Option<&BTreeSet>, stored_values: Option<&BTreeMap>, platform: &PlatformStateRef, block_info: &BlockInfo, consumed_documents: &mut Vec, derived_index_values: Option<&mut BTreeMap>, + referenced_documents: &mut BTreeMap, transaction: TransactionArg, execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, @@ -184,12 +198,14 @@ impl DocumentReferenceValidation for DocumentBaseTransitionAction { document_data, owner_id, creator_id, + referring_system, changed_fields, stored_values, platform, block_info, consumed_documents, derived_index_values, + referenced_documents, transaction, execution_context, platform_version, 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 5b9333f8085..ff9fa3de2f0 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 @@ -21,6 +21,10 @@ use dpp::data_contract::document_type::{ ReferenceCombinator, ReferenceHolder, ReferringWrite, }; use dpp::data_contract::config::moderation::{kept_value_at, ContractDocumentRemoval}; +use dpp::data_contract::document_type::property_constraints::{ + referenced_document_values, referenced_fields, DocumentSystemValues, +}; +use dpp::data_contract::document_type::{WhereBound, WhereCondition, WhereLiteralKind}; use dpp::data_contract::DataContract; use dpp::document::property_names::{CREATOR_ID, ID, OWNER_ID}; use dpp::document::{Document, DocumentV0Getters}; @@ -92,12 +96,14 @@ pub(crate) trait DocumentReferenceValidationV0 { document_data: &BTreeMap, owner_id: Identifier, creator_id: Option, + referring_system: &DocumentSystemValues, changed_fields: Option<&BTreeSet>, stored_values: Option<&BTreeMap>, platform: &PlatformStateRef, block_info: &BlockInfo, consumed_documents: &mut Vec, derived_index_values: Option<&mut BTreeMap>, + referenced_documents: &mut BTreeMap, transaction: TransactionArg, execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, @@ -187,12 +193,14 @@ impl DocumentReferenceValidationV0 for DocumentBaseTransitionAction { document_data: &BTreeMap, owner_id: Identifier, creator_id: Option, + referring_system: &DocumentSystemValues, changed_fields: Option<&BTreeSet>, stored_values: Option<&BTreeMap>, platform: &PlatformStateRef, block_info: &BlockInfo, consumed_documents: &mut Vec, derived_index_values: Option<&mut BTreeMap>, + referenced_documents: &mut BTreeMap, transaction: TransactionArg, execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, @@ -214,12 +222,14 @@ impl DocumentReferenceValidationV0 for DocumentBaseTransitionAction { document_data, owner_id, creator_id, + referring_system, changed_fields, stored_values, platform, block_info, consumed_documents, derived_index_values, + referenced_documents, transaction, execution_context, platform_version, @@ -234,12 +244,14 @@ fn validate_document_type_references_v0( document_data: &BTreeMap, owner_id: Identifier, creator_id: Option, + referring_system: &DocumentSystemValues, changed_fields: Option<&BTreeSet>, stored_values: Option<&BTreeMap>, platform: &PlatformStateRef, block_info: &BlockInfo, consumed_documents: &mut Vec, derived_index_values: Option<&mut BTreeMap>, + referenced_documents: &mut BTreeMap, transaction: TransactionArg, execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, @@ -247,6 +259,13 @@ fn validate_document_type_references_v0( // The documents this write's references fetch by id, shared among them let mut fetched_documents = FetchedDocuments::default(); + // What the type's `propertyConstraints` rules read of the documents its references + // point at, by reference property (protocol version 14). Every replace judges every + // rule, so each such reference is checked on every replace, and once it holds the + // values the rules read of the document its check read are recorded in + // `referenced_documents`, for the rules to be judged on: no other read is made + let rule_reads = referenced_fields(document_type.property_constraints()); + // A create is the one write with no changed fields, and the only one a lookup with a // computed key, which reveals a commitment, is judged at let is_create = changed_fields.is_none(); @@ -342,6 +361,7 @@ fn validate_document_type_references_v0( if let Some(changed) = changed_fields { if hash_key.is_some() || (!is_changed_field(changed, path) + && !rule_reads.contains_key(path) && !binds_a_changed_property(target, changed, writer_can_change)) { continue; @@ -393,6 +413,7 @@ fn validate_document_type_references_v0( document_type, document_data, owner_id, + referring_system, target, referenced_id, Some(RevealedValue { value, billed_key }), @@ -404,6 +425,7 @@ fn validate_document_type_references_v0( block_info, is_create, consumed_documents, + RuleReads::of(&rule_reads, path, referenced_documents), transaction, execution_context, platform_version, @@ -430,7 +452,10 @@ fn validate_document_type_references_v0( // them all. let bound_property_changed = binds_a_changed_property(reference_target, changed, writer_can_change); - if !is_changed_field(changed, path) && !bound_property_changed { + if !is_changed_field(changed, path) + && !bound_property_changed + && !rule_reads.contains_key(path) + { continue; } bound_property_changed @@ -503,6 +528,7 @@ fn validate_document_type_references_v0( document_type, document_data, owner_id, + referring_system, reference_target, referenced_id, None, @@ -514,6 +540,7 @@ fn validate_document_type_references_v0( block_info, is_create, consumed_documents, + RuleReads::of(&rule_reads, path, referenced_documents), transaction, execution_context, platform_version, @@ -584,11 +611,14 @@ fn validate_document_type_references_v0( } let kept_value_changed_fields = kept_value_changed_fields(changed_fields, stored_values, path, &referenced_id); + // No rule reads through the elements of a typed array, which point at + // several documents (registration refuses it) let result = validate_reference_v0( contract, document_type, document_data, owner_id, + referring_system, reference_target, referenced_id, None, @@ -600,6 +630,7 @@ fn validate_document_type_references_v0( block_info, is_create, consumed_documents, + None, transaction, execution_context, platform_version, @@ -760,28 +791,30 @@ fn binds_a_changed_property( { return false; } + // The `where` of a document reference asks a replace about it when a + // referring property it reads changed, and on every replace when it reads + // a value that changes without a write: the writer (a writer gate, or a + // `notEqual` bounded by it), or the time or heights of the last update or + // transfer bounding an ordering (protocol version 14) + let where_binds = |declaration: DocumentReferenceDeclaration| { + declaration.where_is_rechecked_on_every_replace() + || declaration + .where_referring_properties() + .any(|referring_property| is_changed_field(changed_fields, referring_property)) + }; match reference_target { // A moderated reference is kept as a permanent one is: its document // leaves state only on a moderator's record, which the reference then - // resolves to, so nothing but its own pairs asks a replace about it - DocumentPropertyReferenceTarget::PermanentDocument { - property_agreement, .. - } - | DocumentPropertyReferenceTarget::ModeratedDocument { - property_agreement, .. - } => property_agreement.keys().any(|referring_property| { - is_referring_system_agreement_property(referring_property) - || is_changed_field(changed_fields, referring_property) - }), - DocumentPropertyReferenceTarget::PermanentDocumentLookup { - property_agreement, - lookup, - .. - } => { - property_agreement.keys().any(|referring_property| { - is_referring_system_agreement_property(referring_property) - || is_changed_field(changed_fields, referring_property) - }) || lookup_key_may_have_changed(lookup, changed_fields) + // resolves to, so nothing but its own `where` asks a replace about it + DocumentPropertyReferenceTarget::PermanentDocument { .. } + | DocumentPropertyReferenceTarget::ModeratedDocument { .. } => reference_target + .as_any_document_reference() + .is_some_and(where_binds), + DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. } => { + reference_target + .as_any_document_reference() + .is_some_and(where_binds) + || lookup_key_may_have_changed(lookup, changed_fields) } // A deletableDocument reference is re-validated on EVERY replace, // touched or not: its target may have been deleted since the last @@ -805,13 +838,9 @@ fn binds_a_changed_property( // A list element binds the properties its agreement reads, the // `$id` pair's among them: a changed one may name another document, // whose list is then checked against every value - DocumentPropertyReferenceTarget::ListElement(reference) => reference - .property_agreement - .keys() - .any(|referring_property| { - is_referring_system_agreement_property(referring_property) - || is_changed_field(changed_fields, referring_property) - }), + DocumentPropertyReferenceTarget::ListElement(_) => reference_target + .as_any_document_reference() + .is_some_and(where_binds), // A contract reference's `owner` requirement is judged against the // writer, transition metadata: a transfer or a purchase hands the // document to an owner the requirement never checked, without any @@ -851,6 +880,43 @@ fn binds_a_changed_property( } } +/// What the document type's `propertyConstraints` rules read of the document +/// one reference finds (`{ "referenced": [reference, field] }`), and where the +/// values go once the reference holds: under the reference property, in the map +/// the rules are judged on. Only a single reference to one document, found by +/// id or by a `findBy` without a function, carries one; registration refuses a +/// rule reading through any other. +struct RuleReads<'a> { + reference: &'a str, + fields: &'a BTreeSet<&'a str>, + values: &'a mut BTreeMap, +} + +impl<'a> RuleReads<'a> { + /// What the rules read through the reference at `path`, `None` when they + /// read nothing through it. + fn of( + rule_reads: &'a BTreeMap<&'a str, BTreeSet<&'a str>>, + path: &'a str, + values: &'a mut BTreeMap, + ) -> Option { + rule_reads.get(path).map(|fields| RuleReads { + reference: path, + fields, + values, + }) + } + + /// Records what the rules read of `document`, the document the reference + /// found. + fn record_document(self, document: &Document) -> Result<(), Error> { + let values = referenced_document_values(document, self.fields.iter().copied()) + .map_err(|error| Error::Protocol(error.into()))?; + self.values.insert(self.reference.to_string(), values); + Ok(()) + } +} + /// What a string or byte array property carrying a reference hands its lookup /// in place of an id: its `value`, which `"."` reads, and, when a `findBy` /// function computes the key, the hash already computed and billed. @@ -887,6 +953,7 @@ fn validate_reference_v0( document_type: DocumentTypeRef<'_>, document_data: &BTreeMap, owner_id: Identifier, + referring_system: &DocumentSystemValues, reference_target: &DocumentPropertyReferenceTarget, referenced_id: [u8; 32], revealed: Option>, @@ -898,6 +965,7 @@ fn validate_reference_v0( block_info: &BlockInfo, is_create: bool, consumed_documents: &mut Vec, + rule_reads: Option>, transaction: TransactionArg, execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, @@ -908,6 +976,7 @@ fn validate_reference_v0( document_type, document_data, owner_id, + referring_system, reference_target, referenced_id, revealed, @@ -919,6 +988,7 @@ fn validate_reference_v0( block_info, is_create, consumed_documents, + rule_reads, transaction, execution_context, platform_version, @@ -938,11 +1008,14 @@ fn validate_reference_v0( let mut result = SimpleConsensusValidationResult::new(); for operand in operands.operands() { let consumed_before_operand = consumed_documents.len(); + // No rule reads through an expression, which may hold through one + // document or another (registration refuses it) result = validate_reference_v0( contract, document_type, document_data, owner_id, + referring_system, operand, referenced_id, revealed, @@ -954,6 +1027,7 @@ fn validate_reference_v0( block_info, is_create, consumed_documents, + None, transaction, execution_context, platform_version, @@ -1013,6 +1087,7 @@ fn validate_reference_target_v0( document_type: DocumentTypeRef<'_>, document_data: &BTreeMap, owner_id: Identifier, + referring_system: &DocumentSystemValues, reference_target: &DocumentPropertyReferenceTarget, referenced_id: [u8; 32], revealed: Option>, @@ -1024,6 +1099,7 @@ fn validate_reference_target_v0( block_info: &BlockInfo, is_create: bool, consumed_documents: &mut Vec, + rule_reads: Option>, transaction: TransactionArg, execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, @@ -1113,6 +1189,7 @@ fn validate_reference_target_v0( contract_id: referenced_contract_id, document_type_name, property_agreement, + where_conditions, kind, lookup, in_list: _, @@ -1343,13 +1420,16 @@ fn validate_reference_target_v0( if let Some(removal) = removal.filter(|removal| !removal.is_restored()) { return validate_pairs_against_removal( property_agreement, + where_conditions, &removal, referenced_document_type, Identifier::from(referenced_id), changed_fields, document_data, owner_id, + referring_system, path, + rule_reads, ); } } @@ -1463,6 +1543,42 @@ fn validate_reference_target_v0( return Ok(mismatch()); } } + // The other `where` entries (protocol version 14): a literal the + // referenced side must equal or be one of, or a bound it must differ + // from or compare with, read from the document already in hand. A + // literal reads as the referenced property's kind says, of the type + // just resolved, since it may be another contract's + for (referenced_property, condition) in where_conditions { + let referenced_value = match referenced_property.as_str() { + OWNER_ID => Some(Cow::Owned(Value::Identifier( + referenced_document.owner_id().to_buffer(), + ))), + ID => Some(Cow::Owned(Value::Identifier( + referenced_document.id().to_buffer(), + ))), + CREATOR_ID => referenced_document.creator_id().map(|creator_id| { + Cow::Owned(Value::Identifier(creator_id.to_buffer())) + }), + _ => match referenced_document + .properties() + .get_optional_at_path(referenced_property) + { + Ok(value) => value.map(Cow::Borrowed), + Err(_) => { + return Ok(condition_mismatch(path, condition, referenced_property)) + } + }, + }; + let kind = WhereLiteralKind::of_referenced( + referenced_document_type, + referenced_property, + ); + let bound = + where_bound_value(condition, document_data, owner_id, referring_system); + if !condition.holds(referenced_value.as_deref(), kind, bound.as_deref()) { + return Ok(condition_mismatch(path, condition, referenced_property)); + } + } } // The commitment a computed key found: it must be old enough, and the create @@ -1502,6 +1618,15 @@ fn validate_reference_target_v0( } } + // What the type's rules read of the document, from the document in + // hand: the reference holds, every check above having passed. A list + // element reference never carries rule reads (registration refuses it) + if let (Some(rule_reads), Some(found), None) = + (rule_reads, referenced_document, list_reference) + { + rule_reads.record_document(found)?; + } + // A list element exists when the document does and its list // holds the value; the list is collected once per document and // path, so each value is a set lookup @@ -1593,6 +1718,50 @@ fn validate_reference_target_v0( Ok(SimpleConsensusValidationResult::new()) } +/// The refusal of a write whose referenced document does not meet the `where` +/// entry keyed by `referenced_property` that `condition` declares: a +/// `ReferencedDocumentPropertyMismatchError` (40127), whose referring side is +/// the condition as written (`{ "greaterThan": "$createdAt" }`). +fn condition_mismatch( + path: &str, + condition: &WhereCondition, + referenced_property: &str, +) -> SimpleConsensusValidationResult { + SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentPropertyMismatchError::new( + path.to_string(), + condition.to_string(), + referenced_property.to_string(), + ) + .into(), + ) +} + +/// The value of the bound `condition` compares the referenced side with, read +/// from the referring document: a property of `document_data` (`None` when it +/// leaves it out or a path on the way is no object), the writer `owner_id`, or +/// a time or height of `referring_system` as an integer. `None` for a literal +/// or a list of them, which the condition carries. +fn where_bound_value<'a>( + condition: &WhereCondition, + document_data: &'a BTreeMap, + owner_id: Identifier, + referring_system: &DocumentSystemValues, +) -> Option> { + match condition.bound()? { + WhereBound::Property(path) => document_data + .get_optional_at_path(path) + .ok() + .flatten() + .map(Cow::Borrowed), + WhereBound::Writer => Some(Cow::Owned(Value::Identifier(owner_id.to_buffer()))), + WhereBound::System(property) => referring_system + .value(*property) + .map(|value| Cow::Owned(Value::I128(value))), + WhereBound::Constant(_) => None, + } +} + /// A `moderatedDocument` reference a replace keeps, whose document a moderator /// removed: it holds through `removal`, the document's removal record. The /// record keeps the document's id and its owner at the removal, which no @@ -1622,13 +1791,16 @@ fn validate_reference_target_v0( #[allow(clippy::too_many_arguments)] fn validate_pairs_against_removal( property_agreement: &BTreeMap, + where_conditions: &BTreeMap, removal: &ContractDocumentRemoval, referenced_document_type: DocumentTypeRef, document_id: Identifier, changed_fields: &BTreeSet, document_data: &BTreeMap, owner_id: Identifier, + referring_system: &DocumentSystemValues, path: &str, + rule_reads: Option>, ) -> Result { let kept_paths = referenced_document_type.moderator_deletion_kept_fields(); let mut kept_values = None; @@ -1691,6 +1863,78 @@ fn validate_pairs_against_removal( )); } } + // The other `where` entries the replace asks about again, as the pairs + // above: one bounded by a changed property, or by a value that changes + // without a write (the writer, the last update or transfer). Registration + // keeps an entry asked about on every replace to what every record holds + for (referenced_property, condition) in where_conditions { + let rechecked = condition.is_rechecked_on_every_replace() + || condition + .referring_property() + .is_some_and(|property| is_changed_field(changed_fields, property)); + if !rechecked { + continue; + } + let referenced_value = match referenced_property.as_str() { + OWNER_ID => Some(Value::Identifier(removal.document_owner_id.to_buffer())), + ID => Some(Value::Identifier(document_id.to_buffer())), + property if !is_path_listed(kept_paths, property) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentRemovedError::new( + document_id, + path.to_string(), + referenced_property.clone(), + ) + .into(), + )) + } + property => { + let kept_values = match &mut kept_values { + Some(kept_values) => kept_values, + None => kept_values.insert(removal.kept_values(referenced_document_type)?), + }; + kept_value_at(kept_paths, kept_values, property).flatten() + } + }; + let kind = WhereLiteralKind::of_referenced(referenced_document_type, referenced_property); + let bound = where_bound_value(condition, document_data, owner_id, referring_system); + if !condition.holds(referenced_value.as_ref(), kind, bound.as_deref()) { + return Ok(condition_mismatch(path, condition, referenced_property)); + } + } + // What the type's rules read of the document, from its record: its id, its + // owner at the removal and the fields its type keeps, the only ones + // registration lets a rule read through such a reference + if let Some(rule_reads) = rule_reads { + let mut values = Value::Map(Vec::new()); + for field in rule_reads.fields.iter().copied() { + let value = match field { + OWNER_ID => Some(Value::Identifier(removal.document_owner_id.to_buffer())), + ID => Some(Value::Identifier(document_id.to_buffer())), + field if is_path_listed(kept_paths, field) => { + let kept_values = match &mut kept_values { + Some(kept_values) => kept_values, + None => kept_values.insert(removal.kept_values(referenced_document_type)?), + }; + kept_value_at(kept_paths, kept_values, field).flatten() + } + _ => { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "a rule reads through a moderatedDocument reference only what every \ + removal record keeps, which registration enforces", + ))) + } + }; + if let Some(value) = value { + values + .set_value_at_full_path(field, value) + .map_err(|error| Error::Protocol(error.into()))?; + } + } + rule_reads + .values + .insert(rule_reads.reference.to_string(), values); + } Ok(SimpleConsensusValidationResult::new()) } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs index 2294916eaad..9c60cd74830 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs @@ -63,6 +63,7 @@ impl DocumentReplaceTransitionActionStructureValidationV0 for DocumentReplaceTra updated_at_core_block_height: self.updated_at_core_block_height(), transferred_at_core_block_height: self.transferred_at_core_block_height(), aggregates: Some(self.property_constraint_aggregates().clone()), + referenced_documents: false, }; let result = data_contract .validate_document_properties( 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 7b608416caa..bc85ccf43ed 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 @@ -2,9 +2,11 @@ use dpp::block::block_info::BlockInfo; use dpp::consensus::state::document::document_immutable_property_changed_error::DocumentImmutablePropertyChangedError; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::property_constraints::{ - DocumentSystemValues, STORED_DOCUMENT_KEY, + data_with_referenced_documents, DocumentSystemValues, PropertyConstraint, STORED_DOCUMENT_KEY, }; +use std::collections::BTreeMap; use dpp::identifier::Identifier; use dpp::platform_value::Value; use dpp::validation::SimpleConsensusValidationResult; @@ -140,10 +142,17 @@ impl DocumentReplaceTransitionActionStateValidationV1 for DocumentReplaceTransit } } + // The document the replace writes keeps its creation and last transfer and is + // updated in this block, for every time and height a `where` entry reads; the values + // the type's rules read of the documents its references point at are collected from + // those documents, every such reference being checked on every replace + let system = self.condition_system_values(owner_id); + let mut referenced_documents = BTreeMap::new(); let reference_result = self.base().validate_document_references( self.data(), owner_id, self.creator_id(), + &system, Some(self.changed_data_fields()), Some(self.stored_changed_values()), platform, @@ -151,6 +160,7 @@ impl DocumentReplaceTransitionActionStateValidationV1 for DocumentReplaceTransit // A replace consumes nothing: a lookup that could is judged on a create only &mut Vec::new(), None, + &mut referenced_documents, transaction, execution_context, platform_version, @@ -159,6 +169,30 @@ impl DocumentReplaceTransitionActionStateValidationV1 for DocumentReplaceTransit return Ok(reference_result); } + // The rules reading a document a reference points at (protocol version 14), judged + // once the references hold, with the values their checks read, as on a create: every + // replace judges every rule, and the structure validation judged the others + if document_type + .property_constraints() + .values() + .any(PropertyConstraint::reads_referenced) + { + let result = document_type + .validate_referencing_property_constraints( + &data_with_referenced_documents(self.data(), referenced_documents), + &DocumentSystemValues { + aggregates: Some(self.property_constraint_aggregates().clone()), + referenced_documents: true, + ..system + }, + platform_version, + ) + .map_err(Error::Protocol)?; + if !result.is_valid() { + return Ok(result); + } + } + Ok(SimpleConsensusValidationResult::new()) } } @@ -224,6 +258,7 @@ impl ReplaceConditionInputs for DocumentReplaceTransitionAction { updated_at_core_block_height: self.updated_at_core_block_height(), transferred_at_core_block_height: self.transferred_at_core_block_height(), aggregates: None, + referenced_documents: false, } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs index 8f5547cffb7..6ee978ef30a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs @@ -22,6 +22,7 @@ use super::*; mod commit_reveal_lookup_tests { + use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use super::*; use crate::execution::types::execution_operation::ValidationOperation; use crate::execution::types::state_transition_execution_context::{ @@ -1046,12 +1047,14 @@ mod commit_reveal_lookup_tests { &data, alice, Some(alice), + &DocumentSystemValues::default(), changed_fields, None, &platform_ref, &block(REVEAL_HEIGHT), &mut consumed, None, + &mut BTreeMap::new(), None, &mut execution_context, platform_version, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/contract_owner_requirement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/contract_owner_requirement.rs index 40f5f9631ce..366568d7c6e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/contract_owner_requirement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/contract_owner_requirement.rs @@ -20,6 +20,7 @@ use super::*; mod contract_owner_requirement_tests { + use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use super::super::reference_test_setup::{ assert_successful, process_and_commit, register_contract_at, }; @@ -520,6 +521,7 @@ mod contract_owner_requirement_tests { writer, // Neither fixture declares a creator reference None, + &DocumentSystemValues::default(), Some(&changed_fields), // Nor a changed typed array, whose stored list this would // hold @@ -528,6 +530,7 @@ mod contract_owner_requirement_tests { &BlockInfo::default(), &mut Vec::new(), None, + &mut BTreeMap::new(), None, &mut execution_context, platform_version, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/derived_index_values.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/derived_index_values.rs index 84be54351c8..cdadf3058eb 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/derived_index_values.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/derived_index_values.rs @@ -3,6 +3,7 @@ //! create action carries to Drive, so the new document is keyed without a second read. use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use crate::execution::validation::state_transition::batch::action_validation::document::document_reference_validation::DocumentReferenceValidation; use crate::platform_types::platform::PlatformStateRef; use crate::test::helpers::setup::TestPlatformBuilder; @@ -141,12 +142,14 @@ fn should_record_the_derived_values_the_reference_validation_read() { &data, replier, Some(replier), + &DocumentSystemValues::default(), None, None, &platform_ref, &BlockInfo::default(), &mut Vec::new(), Some(&mut derived_index_values), + &mut BTreeMap::new(), None, &mut execution_context, platform_version, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/list_element_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/list_element_reference.rs index 60acdcf4965..c9b37a75797 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/list_element_reference.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/list_element_reference.rs @@ -21,6 +21,7 @@ use super::*; mod list_element_reference_tests { + use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use super::*; use crate::execution::types::state_transition_execution_context::{ StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, @@ -841,6 +842,7 @@ mod list_element_reference_tests { founder, // The resignation type records no creator ids None, + &DocumentSystemValues::default(), None, // A create: there is no stored document None, @@ -848,6 +850,7 @@ mod list_element_reference_tests { &BlockInfo::default(), &mut Vec::new(), None, + &mut BTreeMap::new(), None, &mut execution_context, platform_version, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/lookup_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/lookup_reference.rs index 8f6015bd1c3..4b3c997d352 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/lookup_reference.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/lookup_reference.rs @@ -18,6 +18,7 @@ use super::*; mod lookup_reference_tests { + use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use super::*; use crate::execution::types::execution_operation::ValidationOperation; use crate::execution::types::state_transition_execution_context::{ @@ -680,6 +681,7 @@ mod lookup_reference_tests { founder, // The charter type records no creator ids None, + &DocumentSystemValues::default(), None, // A create: there is no stored document None, @@ -687,6 +689,7 @@ mod lookup_reference_tests { &BlockInfo::default(), &mut Vec::new(), None, + &mut BTreeMap::new(), None, &mut execution_context, platform_version, 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 d72ea46026b..77fb35d9e81 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 @@ -32,6 +32,7 @@ mod property_type_shorthands; mod ranked_group_drain; mod reference_expression; mod reference_test_setup; +mod referenced_rules; 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/owner_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs index ba87d52693a..bc37b514740 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs @@ -24,6 +24,7 @@ use super::*; mod owner_reference_tests { + use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use super::*; use crate::execution::types::execution_operation::ValidationOperation; use crate::execution::types::state_transition_execution_context::{ @@ -360,12 +361,14 @@ mod owner_reference_tests { &data, self.id(who), creator.map(|creator| self.id(creator)), + &DocumentSystemValues::default(), changed_fields.as_ref(), None, &platform_ref, &BlockInfo::default(), &mut Vec::new(), None, + &mut BTreeMap::new(), None, &mut execution_context, platform_version, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/reference_expression.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/reference_expression.rs index 3f72df8585a..82c4352c31e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/reference_expression.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/reference_expression.rs @@ -21,6 +21,7 @@ use super::*; mod reference_expression_tests { + use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use super::super::reference_test_setup::{ create_document, register_contract_at, replace_document, }; @@ -728,6 +729,7 @@ mod reference_expression_tests { founder, // The resignation type records no creator ids None, + &DocumentSystemValues::default(), None, // A create: there is no stored document None, @@ -735,6 +737,7 @@ mod reference_expression_tests { &BlockInfo::default(), &mut Vec::new(), None, + &mut BTreeMap::new(), None, &mut execution_context, platform_version, @@ -896,12 +899,14 @@ mod reference_expression_tests { &data, Identifier::from([0x5D; 32]), None, + &DocumentSystemValues::default(), None, None, &platform_ref, &BlockInfo::default(), &mut Vec::new(), None, + &mut BTreeMap::new(), None, &mut execution_context, platform_version, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/referenced_rules.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/referenced_rules.rs new file mode 100644 index 00000000000..ad12a857b8e --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/referenced_rules.rs @@ -0,0 +1,311 @@ +//! Rules that read the document a reference points at, and `where` entries +//! that are not an equality, through the full ABCI pipeline (protocol version +//! 14). A `propertyConstraints` rule reading `{ "referenced": [reference, +//! field] }` is judged once the references hold, on the document the +//! reference's check read, and a reference a rule reads through is checked +//! again on every replace, so the rule sees the document as it is then. A +//! `where` entry with a literal, `notEqual` or an ordering is checked against +//! the document found as an equality is. Every transition here is processed in +//! a block whose time and heights are 0. + +use super::*; + +mod referenced_rules_tests { + use super::super::reference_test_setup::{assert_successful, ReferenceTestSetup}; + use super::*; + use crate::platform_types::state_transitions_processing_result::StateTransitionsProcessingResult; + use dpp::consensus::basic::BasicError; + use dpp::document::{Document, DocumentV0Setters}; + + const CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules.json"; + + fn assert_rule_broken(result: &StateTransitionsProcessingResult, rule: &str) { + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError( + BasicError::DocumentPropertyConstraintViolatedError(e) + ), + .. + }] if e.constraint() == rule, + "expected rule {rule} to refuse the write" + ); + } + + fn assert_where_refused(result: &StateTransitionsProcessingResult, condition: &str) { + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyMismatchError(e) + ), + .. + }] if e.referring_property() == condition, + "expected the where entry {condition} to refuse the write" + ); + } + + fn assert_not_found(result: &StateTransitionsProcessingResult) { + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(_)), + .. + }] + ); + } + + fn id_value(document: &Document) -> Value { + Value::Identifier(document.id().to_buffer()) + } + + async fn poll(setup: &mut ReferenceTestSetup, extra: &[(&str, Value)]) -> Document { + let mut properties = vec![ + ("option0", Value::Text("yes".to_string())), + ("option1", Value::Text("no".to_string())), + ]; + properties.extend(extra.iter().cloned()); + let (poll, result) = setup.create("poll", &properties).await; + assert_successful(&result, "the poll must be created"); + poll + } + + /// A vote is judged by the poll it names, read once for the reference and + /// the rules alike: before it ends, on a single-choice poll, and for one of + /// the options the poll holds. + #[tokio::test] + async fn should_judge_a_vote_by_the_poll_it_refers_to() { + let mut setup = ReferenceTestSetup::new(CONTRACT_PATH, 6101); + let two_options = poll(&mut setup, &[]).await; + let (_, result) = setup + .create( + "vote", + &[("pollId", id_value(&two_options)), ("choice", Value::U8(1))], + ) + .await; + assert_successful(&result, "a vote for the second of two options holds"); + let (_, result) = setup + .create( + "vote", + &[("pollId", id_value(&two_options)), ("choice", Value::U8(2))], + ) + .await; + assert_rule_broken(&result, "choiceIsAnOption"); + + let three_options = + poll(&mut setup, &[("option2", Value::Text("maybe".to_string()))]).await; + let (_, result) = setup + .create( + "vote", + &[ + ("pollId", id_value(&three_options)), + ("choice", Value::U8(2)), + ], + ) + .await; + assert_successful(&result, "the third option is there"); + + // The block's time is 0, so a poll ending at 0 has ended + let ended = poll(&mut setup, &[("endsAt", Value::U64(0))]).await; + let (_, result) = setup + .create( + "vote", + &[("pollId", id_value(&ended)), ("choice", Value::U8(0))], + ) + .await; + assert_rule_broken(&result, "castBeforeTheEnd"); + + let multiple = poll(&mut setup, &[("multiChoice", Value::Bool(true))]).await; + let (_, result) = setup + .create( + "vote", + &[("pollId", id_value(&multiple)), ("choice", Value::U8(0))], + ) + .await; + assert_rule_broken(&result, "singleChoicePoll"); + + // A missing poll is the reference's refusal, before any rule reading it + let (_, result) = setup + .create( + "vote", + &[ + ("pollId", Value::Identifier([9; 32])), + ("choice", Value::U8(0)), + ], + ) + .await; + assert_not_found(&result); + } + + /// A rule reads the referenced document on every replace, even one the + /// reference itself would not be checked again for: the auction a bid + /// names is permanent, yet its reserve price may have risen since, and an + /// edit of the bid's note is judged against the auction as it is now. The + /// `where` of the reference is checked again with it. + #[tokio::test] + async fn should_read_a_permanent_target_again_on_every_replace() { + let mut setup = ReferenceTestSetup::new(CONTRACT_PATH, 6102); + let (mut auction, result) = setup + .create( + "auction", + &[ + ("reservePrice", Value::U32(100)), + ("status", Value::Text("open".to_string())), + ], + ) + .await; + assert_successful(&result, "the auction must be created"); + + let (_, result) = setup + .create( + "bid", + &[ + ("auctionId", id_value(&auction)), + ("amount", Value::U32(50)), + ], + ) + .await; + assert_rule_broken(&result, "atLeastReserve"); + let (mut bid, result) = setup + .create( + "bid", + &[ + ("auctionId", id_value(&auction)), + ("amount", Value::U32(150)), + ], + ) + .await; + assert_successful(&result, "a bid over the reserve holds"); + + auction.set("reservePrice", Value::U32(200)); + let result = setup.replace("auction", &mut auction).await; + assert_successful(&result, "the auction's owner raises the reserve"); + + bid.set("note", Value::Text("still keen".to_string())); + let result = setup.replace("bid", &mut bid).await; + assert_rule_broken(&result, "atLeastReserve"); + + bid.set("amount", Value::U32(250)); + let result = setup.replace("bid", &mut bid).await; + assert_successful(&result, "a bid raised over the new reserve holds"); + + auction.set("status", Value::Text("closed".to_string())); + let result = setup.replace("auction", &mut auction).await; + assert_successful(&result, "the auction closes"); + bid.set("amount", Value::U32(300)); + let result = setup.replace("bid", &mut bid).await; + assert_where_refused(&result, "{ \"in\": [\"extended\", \"open\"] }"); + } + + /// The `where` entries that are not an equality: a literal the referenced + /// side must equal, a value it must differ from, and an ordering bounded by + /// the time of the write, which holds only when both sides have a value. + #[tokio::test] + async fn should_check_where_literals_inequalities_and_orderings() { + let mut setup = ReferenceTestSetup::new(CONTRACT_PATH, 6103); + let (pending, result) = setup + .create("order", &[("status", Value::Text("pending".to_string()))]) + .await; + assert_successful(&result, "the order must be created"); + let (_, result) = setup + .create( + "review", + &[("orderId", id_value(&pending)), ("stars", Value::U8(5))], + ) + .await; + assert_where_refused(&result, "{ \"const\": \"delivered\" }"); + let (delivered, result) = setup + .create("order", &[("status", Value::Text("delivered".to_string()))]) + .await; + assert_successful(&result, "the order must be created"); + let (_, result) = setup + .create( + "review", + &[("orderId", id_value(&delivered)), ("stars", Value::U8(5))], + ) + .await; + assert_successful(&result, "a delivered order is reviewed"); + + // The writer owns the order, so a rebuttal of it differs from no one + let (_, result) = setup + .create("rebuttal", &[("orderId", id_value(&delivered))]) + .await; + assert_where_refused(&result, "{ \"notEqual\": \"$ownerId\" }"); + + let open = poll(&mut setup, &[("endsAt", Value::U64(5))]).await; + let (mut vote, result) = setup + .create("deadlineVote", &[("pollId", id_value(&open))]) + .await; + assert_successful(&result, "a poll ending after the write takes a vote"); + // Checked again on every replace: the bound changes with each write + vote.set("note", Value::Text("edited".to_string())); + let result = setup.replace("deadlineVote", &mut vote).await; + assert_successful(&result, "the poll is still open at the replace"); + + let endless = poll(&mut setup, &[]).await; + let (_, result) = setup + .create("deadlineVote", &[("pollId", id_value(&endless))]) + .await; + assert_where_refused(&result, "{ \"greaterThan\": \"$updatedAt\" }"); + + let multiple = poll( + &mut setup, + &[ + ("endsAt", Value::U64(5)), + ("multiChoice", Value::Bool(true)), + ], + ) + .await; + let (_, result) = setup + .create("deadlineVote", &[("pollId", id_value(&multiple))]) + .await; + assert_where_refused(&result, "{ \"notEqual\": { \"const\": true } }"); + } + + /// A reference found by `findBy` reads the document the key finds, for the + /// rules too. + #[tokio::test] + async fn should_read_a_document_found_by_find_by() { + let mut setup = ReferenceTestSetup::new(CONTRACT_PATH, 6104); + let (_, result) = setup + .create( + "event", + &[ + ("code", Value::Text("concert".to_string())), + ("capacity", Value::U32(2)), + ], + ) + .await; + assert_successful(&result, "the event must be created"); + let (_, result) = setup + .create( + "ticket", + &[ + ("eventCode", Value::Text("concert".to_string())), + ("seat", Value::U32(1)), + ], + ) + .await; + assert_successful(&result, "the second seat is there"); + let (_, result) = setup + .create( + "ticket", + &[ + ("eventCode", Value::Text("concert".to_string())), + ("seat", Value::U32(2)), + ], + ) + .await; + assert_rule_broken(&result, "seatExists"); + let (_, result) = setup + .create( + "ticket", + &[ + ("eventCode", Value::Text("opera".to_string())), + ("seat", Value::U32(0)), + ], + ) + .await; + assert_not_found(&result); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs index 413d36670f1..1736a2b27dc 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/state/v0/mod.rs @@ -1598,6 +1598,7 @@ fn transform_document_fields_change_v0( updated_at_core_block_height: changed.updated_at_core_block_height(), transferred_at_core_block_height: changed.transferred_at_core_block_height(), aggregates: Some(aggregates), + referenced_documents: false, }; // Each check runs only on a document the previous ones passed, as a replace's do: the later // two read values the schema check makes well formed. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs index 8d104b873f8..62528c3a1ce 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests.rs @@ -81,6 +81,7 @@ mod duplicate_keys; mod moderated_document_reference; mod moderator_fields; mod preallocated_through_moderated_reference; +mod referenced_rules; mod restore_constraints; mod retraction; mod seated_team; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/referenced_rules.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/referenced_rules.rs new file mode 100644 index 00000000000..4c2bdefa4d3 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/referenced_rules.rs @@ -0,0 +1,296 @@ +//! `propertyConstraints` rules reading the document a `moderatedDocument` +//! reference points at (protocol version 14). Once a moderator removes the +//! post a reply names, the reply's rules read the post's removal record, which +//! keeps its owner, its id and the fields its type lists under +//! `deleteKeepsFields`, the only ones such a rule may read. A moderator's +//! restore checks no reference, and judges no rule reading one. + +use super::*; +use dpp::consensus::basic::document::PropertyConstraintViolation; +use dpp::consensus::basic::BasicError; + +const REPLY: &str = "reply"; +const TOPIC: &str = "topic"; +const THREAD_POST: &str = "threadPost"; + +fn identifier_property(position: u64, refers_to: Value) -> Value { + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": position, + "refersTo": refers_to, + }) +} + +/// A post only moderators take down, each removal keeping its text +fn moderated_post_schema() -> Value { + post_schema_with(platform_value!({ + "canBeDeleted": false, + "moderatorAbilities": { "delete": true, "deleteKeepsFields": ["text"] }, + })) +} + +/// A reply to a post, which may quote it word for word, and whose writer is not +/// the post's author +fn reply_schema() -> Value { + platform_value!({ + "type": "object", + "properties": { + "postId": identifier_property( + 0, + platform_value!({ "type": "moderatedDocument", "documentType": POST }), + ), + "quote": { "type": "string", "maxLength": 50, "position": 1 }, + "body": { "type": "string", "minLength": 1, "maxLength": 50, "position": 2 }, + }, + "required": ["postId", "body"], + "propertyConstraints": { + "quotesThePost": { + "anyOf": [ + { "absent": "quote" }, + { "equal": ["quote", { "referenced": ["postId", "text"] }] } + ] + }, + "notTheAuthor": { + "notEqual": [{ "referenced": ["postId", "$ownerId"] }, "$ownerId"] + } + }, + "additionalProperties": false, + }) +} + +/// A topic its owner may close, never deleted +fn topic_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "canBeDeleted": false, + "properties": { + "open": { "type": "boolean", "position": 0 }, + }, + "required": ["open"], + "additionalProperties": false, + }) +} + +/// A post on a topic, which moderators take down, written only while the topic +/// is open +fn thread_post_schema() -> Value { + platform_value!({ + "type": "object", + "canBeDeleted": false, + "moderatorAbilities": { "delete": true }, + "properties": { + "topicId": identifier_property( + 0, + platform_value!({ "type": "permanentDocument", "documentType": TOPIC }), + ), + "text": { "type": "string", "minLength": 1, "maxLength": 50, "position": 1 }, + }, + "required": ["topicId", "text"], + "propertyConstraints": { + "topicOpen": { "equal": [{ "referenced": ["topicId", "open"] }, 1] } + }, + "additionalProperties": false, + }) +} + +async fn setup() -> Setup { + Setup::new_at_with( + Some(moderators_without_lists()), + PlatformVersion::latest(), + |contract| { + add_document_type(contract, POST, moderated_post_schema()); + add_document_type(contract, REPLY, reply_schema()); + add_document_type(contract, TOPIC, topic_schema()); + add_document_type(contract, THREAD_POST, thread_post_schema()); + }, + ) + .await +} + +fn id_value(id: Identifier) -> Value { + Value::Identifier(id.to_buffer()) +} + +/// A creation by `actor` of a document of `document_type_name` holding exactly +/// `properties` +async fn create_with( + setup: &Setup, + actor: &Actor, + document_type_name: &str, + properties: Vec<(&str, Value)>, +) -> (Document, StateTransition) { + setup + .create_document_of_type_with(actor, document_type_name, |document| { + document.properties_mut().clear(); + for (property, value) in properties { + document.set(property, value); + } + }) + .await +} + +/// The replacement of `document` by `actor`, its owner, its revision bumped +async fn replacement( + actor: &Actor, + contract: &DataContract, + document_type_name: &str, + document: &mut Document, +) -> StateTransition { + document.increment_revision().expect("revision increments"); + BatchTransition::new_document_replacement_transition_from_document( + document.clone(), + contract + .document_type_for_name(document_type_name) + .expect("expected the document type"), + &actor.key, + actor.contract_nonce(), + 0, + None, + &actor.signer, + PlatformVersion::latest(), + None, + ) + .await + .expect("expected to build the replacement") +} + +fn assert_rule_broken(execution: &StateTransitionExecutionResult, rule: &str) { + assert!( + matches!( + execution, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError( + BasicError::DocumentPropertyConstraintViolatedError(e) + ), + .. + } if e.constraint() == rule && e.violation() == PropertyConstraintViolation::NotMet + ), + "expected rule {rule} to refuse the write, got {execution:?}" + ); +} + +/// A reply's rules read the post it names: its text and its author. Once a +/// moderator removes the post, a replace of the reply reads them from the +/// post's removal record, which keeps both. +#[tokio::test] +async fn should_judge_a_rule_by_the_record_of_a_removed_post() { + let setup = setup().await; + let transaction = setup.platform.drive.grove.start_transaction(); + let (post, create_post) = + create_with(&setup, &setup.user, POST, vec![("text", "hello".into())]).await; + assert_success(&setup.process(&create_post, &transaction)); + + let (_, misquote) = create_with( + &setup, + &setup.stranger, + REPLY, + vec![ + ("postId", id_value(post.id())), + ("quote", "goodbye".into()), + ("body", "reply".into()), + ], + ) + .await; + assert_rule_broken(&setup.process(&misquote, &transaction), "quotesThePost"); + + let (_, own_reply) = create_with( + &setup, + &setup.user, + REPLY, + vec![("postId", id_value(post.id())), ("body", "me".into())], + ) + .await; + assert_rule_broken(&setup.process(&own_reply, &transaction), "notTheAuthor"); + + let (mut reply, create_reply) = create_with( + &setup, + &setup.stranger, + REPLY, + vec![ + ("postId", id_value(post.id())), + ("quote", "hello".into()), + ("body", "reply".into()), + ], + ) + .await; + assert_success(&setup.process(&create_reply, &transaction)); + + let remove = setup + .moderate(&setup.moderator, delete_action(POST, post.id())) + .await; + assert_success(&setup.process(&remove, &transaction)); + assert!(setup.post_removal(post.id(), Some(&transaction)).is_some()); + + // The record keeps the text and the author, so the rules read them still + reply.set("body", "an edit".into()); + let edit = replacement(&setup.stranger, &setup.contract, REPLY, &mut reply).await; + assert_success(&setup.process(&edit, &transaction)); + + reply.set("quote", "goodbye".into()); + let misquote = replacement(&setup.stranger, &setup.contract, REPLY, &mut reply).await; + assert_rule_broken(&setup.process(&misquote, &transaction), "quotesThePost"); +} + +/// A restore brings a document back as it was, checking no reference, and +/// judges no rule reading one: the topic a removed thread post names has +/// closed since, which a new post can not be written on, and the restore +/// still brings the post back. +#[tokio::test] +async fn should_restore_a_document_without_judging_a_rule_reading_a_reference() { + let setup = setup().await; + let transaction = setup.platform.drive.grove.start_transaction(); + let (mut topic, create_topic) = create_with( + &setup, + &setup.user, + TOPIC, + vec![("open", Value::Bool(true))], + ) + .await; + assert_success(&setup.process(&create_topic, &transaction)); + let (thread_post, create_thread_post) = create_with( + &setup, + &setup.stranger, + THREAD_POST, + vec![("topicId", id_value(topic.id())), ("text", "first".into())], + ) + .await; + assert_success(&setup.process(&create_thread_post, &transaction)); + let stored = setup + .stored_document(THREAD_POST, thread_post.id(), Some(&transaction)) + .expect("expected the thread post to be stored"); + + topic.set("open", Value::Bool(false)); + let close = replacement(&setup.user, &setup.contract, TOPIC, &mut topic).await; + assert_success(&setup.process(&close, &transaction)); + let (_, late) = create_with( + &setup, + &setup.stranger, + THREAD_POST, + vec![("topicId", id_value(topic.id())), ("text", "late".into())], + ) + .await; + assert_rule_broken(&setup.process(&late, &transaction), "topicOpen"); + + let remove = setup + .moderate( + &setup.moderator, + delete_action(THREAD_POST, thread_post.id()), + ) + .await; + assert_success(&setup.process(&remove, &transaction)); + let restore = setup + .moderate( + &setup.moderator, + restore_action(THREAD_POST, setup.document_bytes(THREAD_POST, &stored)), + ) + .await; + assert_success(&setup.process(&restore, &transaction)); + assert!(setup + .stored_document(THREAD_POST, thread_post.id(), Some(&transaction)) + .is_some()); +} 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 8cc47aa0e1c..392f6238ad5 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 @@ -1,17 +1,22 @@ use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::class_methods::referenced_reads_error; use dpp::data_contract::document_type::{ - is_referenced_system_agreement_property, is_referring_system_agreement_property, is_transient, - DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, - DocumentReferenceDeclaration, DocumentTypeRef, KeyReferenceIdentityProperty, - PreallocatedKeySource, PropertyReference, ReferenceHolder, MAX_INDEX_SIZE, + is_path_listed, is_referenced_system_agreement_property, + is_referring_system_agreement_property, is_transient, DocumentProperty, + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceDeclaration, + DocumentReferenceKind, DocumentTypeRef, KeyReferenceIdentityProperty, PreallocatedKeySource, + PropertyReference, ReferenceHolder, WhereBound, WhereCondition, WhereLiteral, WhereLiteralKind, + MAX_INDEX_SIZE, }; +use dpp::data_contract::schema::DataContractSchemaMethodsV0; use dpp::data_contract::DataContract; use dpp::document::property_names::CREATOR_ID; use dpp::errors::consensus::state::document::referenced_document_list_invalid_error::ReferencedDocumentListInvalidError; use dpp::errors::consensus::state::document::referenced_document_lookup_invalid_error::ReferencedDocumentLookupInvalidError; use dpp::errors::consensus::state::document::referenced_document_property_agreement_invalid_error::ReferencedDocumentPropertyAgreementInvalidError; +use dpp::errors::consensus::state::document::referenced_document_rule_read_invalid_error::ReferencedDocumentRuleReadInvalidError; use dpp::errors::consensus::state::document::referenced_document_type_index_only_error::ReferencedDocumentTypeIndexOnlyError; use dpp::errors::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; use dpp::errors::consensus::state::document::referenced_key_id_property_invalid_error::ReferencedKeyIdPropertyInvalidError; @@ -434,6 +439,7 @@ fn validate_reference_target_declaration_v0( contract_id, document_type_name, property_agreement, + where_conditions, kind, lookup, in_list, @@ -584,6 +590,47 @@ fn validate_reference_target_declaration_v0( } } + // What the declaring type's `propertyConstraints` rules read through this + // reference of a document type of another contract (protocol version 14): + // the contract parse checks a rule reading a type of the declaring contract, + // and only here is another contract's in hand. The parse let a rule read + // only through a property holding a single reference to one document, so a + // leaf of an expression or the elements of a typed array carry no reads + if effective_contract_id != contract.id() { + if let Some(reference_property) = reference_property { + let records_creator_id = referenced_document_type + .should_use_creator_id( + referenced_contract.system_version_type(), + referenced_contract.config().version(), + platform_version, + ) + .map_err(Error::Protocol)?; + for (rule, constraint) in document_type.property_constraints() { + let reason = referenced_reads_error( + constraint, + &format!("rule \"{rule}\""), + reference_property, + kind, + referenced_document_type, + referenced_contract.schema_defs(), + records_creator_id, + ) + .map_err(|error| Error::Protocol(error.into()))?; + if let Some(reason) = reason { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentRuleReadInvalidError::new( + document_type.name().clone(), + rule.clone(), + reference_property.to_string(), + reason, + ) + .into(), + )); + } + } + } + } + // A list element's list, in a document type of another contract: a // stored typed array of identifiers fixed once a document is written (the // type is permanent, checked above). The contract parse checks a list in @@ -769,6 +816,173 @@ fn validate_reference_target_declaration_v0( } } + // The other `where` entries (protocol version 14): a literal the referenced + // side must equal or be one of, or a bound it must differ from or compare + // with. Each must be able to hold: the referenced side exists, is stored and + // holds a single value; a literal reads as its kind (a base58 identifier for + // an identifier, hex for a byte array); a `notEqual` compares two values of + // one kind, the writer an identifier; an ordering compares two integers, its + // bound an integer property of the declaring type, an integer or a time or + // height that type records. Through a `moderatedDocument` reference, an entry + // asked about on every replace compares only what every removal record + // holds, as a writer gate does: a time or height bound changes without a + // write, and once a moderator removes the document only its record is left + for (referenced_property, condition) in where_conditions { + let invalid = |reason: &str| { + SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentPropertyAgreementInvalidError::new( + declaration_path.clone(), + condition.to_string(), + referenced_property.clone(), + reason.to_string(), + ) + .into(), + ) + }; + let referenced_type = if is_referenced_system_agreement_property(referenced_property) { + if referenced_property == CREATOR_ID + && !referenced_document_type + .should_use_creator_id( + referenced_contract.system_version_type(), + referenced_contract.config().version(), + platform_version, + ) + .map_err(Error::Protocol)? + { + return Ok(invalid( + "the referenced document type does not record $creatorId: only \ + transferable or tradeable document types of a format-1 contract do", + )); + } + None + } else { + let Some(referenced) = referenced_document_type + .flattened_properties() + .get(referenced_property) + else { + return Ok(invalid( + "the referenced document type does not define the referenced property", + )); + }; + if is_transient(referenced_document_type, referenced_property) { + return Ok(invalid( + "the referenced property is transient or inside a transient object: no \ + stored document carries its value", + )); + } + Some(&referenced.property_type) + }; + let Some(literal_kind) = + WhereLiteralKind::of_referenced(referenced_document_type, referenced_property) + else { + return Ok(invalid( + "the referenced property holds no single value a where entry compares: a \ + float, an object or an array", + )); + }; + let literal_fits = |literal: &WhereLiteral| literal.value_for(literal_kind).is_some(); + match condition { + WhereCondition::Constant(literal) + | WhereCondition::NotEqual(WhereBound::Constant(literal)) => { + if !literal_fits(literal) { + return Ok(invalid(&format!( + "the literal {literal} is not {}, as the referenced property holds", + literal_kind.describe() + ))); + } + } + WhereCondition::In(literals) => { + if let Some(literal) = literals.iter().find(|literal| !literal_fits(literal)) { + return Ok(invalid(&format!( + "the literal {literal} is not {}, as the referenced property holds", + literal_kind.describe() + ))); + } + } + WhereCondition::NotEqual(WhereBound::Writer) => { + if literal_kind != WhereLiteralKind::Identifier { + return Ok(invalid( + "$ownerId, the writer, is an identifier, so the referenced side must be \ + one", + )); + } + } + WhereCondition::NotEqual(WhereBound::Property(referring_property)) => { + let Some(referring) = document_type.flattened_properties().get(referring_property) + else { + return Ok(invalid( + "the declaring document type does not define the referring property", + )); + }; + let same_kind = match referenced_type { + Some(referenced_type) => { + same_value_kind(&referring.property_type, referenced_type) + } + None => matches!( + referring.property_type, + DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_) + ), + }; + if !same_kind { + return Ok(invalid( + "the two properties must share one value kind: a cross-kind comparison \ + would always differ", + )); + } + } + WhereCondition::NotEqual(WhereBound::System(_)) => { + return Ok(invalid("notEqual takes no time or height")); + } + WhereCondition::Ordering { bound, .. } => { + if literal_kind != WhereLiteralKind::Integer { + return Ok(invalid( + "an ordering compares integers, and the referenced property is no \ + integer", + )); + } + match bound { + WhereBound::Property(referring_property) => { + let is_integer = document_type + .flattened_properties() + .get(referring_property) + .and_then(|referring| WhereLiteralKind::of(&referring.property_type)) + == Some(WhereLiteralKind::Integer); + if !is_integer { + return Ok(invalid( + "an ordering is bounded by an integer property of the declaring \ + document type", + )); + } + } + WhereBound::System(system) => { + if !document_type.required_fields().contains(system.name()) { + return Ok(invalid(&format!( + "the declaring document type does not record {}: list it in \ + required", + system.name() + ))); + } + } + WhereBound::Constant(_) | WhereBound::Writer => {} + } + if kind == DocumentReferenceKind::Moderated + && condition.is_rechecked_on_every_replace() + && !is_path_listed( + referenced_document_type.moderator_deletion_kept_fields(), + referenced_property, + ) + { + return Ok(invalid( + "the entry is checked on every replace, and once a moderator removes \ + the document only its record is left, which keeps the fields its type \ + lists under deleteKeepsFields: compare one of those", + )); + } + } + } + } + Ok(SimpleConsensusValidationResult::new()) } 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 117ca7326da..f5e8446524e 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 @@ -6897,6 +6897,111 @@ mod tests { ); } + /// The contract whose `auction` type the referenced-rules registration + /// fixtures read from another contract. + const REFERENCED_RULES_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules.json"; + + #[tokio::test] + async fn should_register_a_rule_reading_another_contracts_document() { + // `foreignBid` reads the reserve price of an auction of another + // contract, and its `where` lists the auction's statuses and orders + // the reserve by the bid's amount + let result = run_contract_create_with_foreign( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-foreign-valid.json", + REFERENCED_RULES_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_reject_a_rule_reading_another_contracts_property_as_another_kind() { + // Only registration sees another contract's type: the auction's + // status is a string, which a rule can not read as a number + let result = run_contract_create_with_foreign( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-foreign-wrong-kind.json", + REFERENCED_RULES_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentRuleReadInvalidError(e) + ), + .. + } if e.document_type_name() == "foreignBid" + && e.rule() == "statusAsNumber" + && e.reference() == "auctionId" + && e.reason().contains("has type string, not integer") + ); + } + + #[tokio::test] + async fn should_reject_a_rule_comparing_another_contracts_enum_with_a_value_it_lacks() { + let result = run_contract_create_with_foreign( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-foreign-enum.json", + REFERENCED_RULES_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentRuleReadInvalidError(e) + ), + .. + } if e.reason().contains("not one of its enum values") + ); + } + + #[tokio::test] + async fn should_reject_a_where_literal_of_another_kind_than_the_referenced_property() { + let result = run_contract_create_with_foreign( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-literal-kind.json", + REFERENCED_RULES_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyAgreementInvalidError(e) + ), + .. + } if e.referenced_property() == "reservePrice" + && e.referring_property() == "{ \"const\": \"high\" }" + ); + } + + #[tokio::test] + async fn should_reject_a_where_ordering_of_a_string() { + let result = run_contract_create_with_foreign( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-ordering-kind.json", + REFERENCED_RULES_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyAgreementInvalidError(e) + ), + .. + } if e.referenced_property() == "status" + && e.reason().contains("an ordering compares integers") + ); + } + #[tokio::test] async fn should_reject_find_by_naming_no_unique_index_another_contract_has() { // Only registration sees the other contract's indexes: the contract diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-foreign-enum.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-foreign-enum.json new file mode 100644 index 00000000000..1a36be54cca --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-foreign-enum.json @@ -0,0 +1,54 @@ +{ + "$formatVersion": "1", + "id": "FmNtTTiJ9X8sJdKvkpK58yzUAx78PW61bCpwW8dZnpMC", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "foreignBid": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "auctionId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "contractId": "31TfU1koATcamnBrNiRbturPP5kLaE8VzJcojeTxedxR", + "documentType": "auction" + }, + "position": 0 + }, + "amount": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000, + "position": 1 + } + }, + "required": [ + "auctionId", + "amount" + ], + "propertyConstraints": { + "notPaused": { + "notEqual": [ + { + "referenced": [ + "auctionId", + "status" + ] + }, + { + "const": "paused" + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-foreign-valid.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-foreign-valid.json new file mode 100644 index 00000000000..94512314ae2 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-foreign-valid.json @@ -0,0 +1,63 @@ +{ + "$formatVersion": "1", + "id": "FmNtTTiJ9X8sJdKvkpK58yzUAx78PW61bCpwW8dZnpMC", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "foreignBid": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "auctionId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "contractId": "31TfU1koATcamnBrNiRbturPP5kLaE8VzJcojeTxedxR", + "documentType": "auction", + "where": { + "status": { + "in": [ + "open", + "extended" + ] + }, + "reservePrice": { + "lessThanOrEqual": "amount" + } + } + }, + "position": 0 + }, + "amount": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000, + "position": 1 + } + }, + "required": [ + "auctionId", + "amount" + ], + "propertyConstraints": { + "atLeastReserve": { + "greaterThanOrEqual": [ + "amount", + { + "referenced": [ + "auctionId", + "reservePrice" + ] + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-foreign-wrong-kind.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-foreign-wrong-kind.json new file mode 100644 index 00000000000..5e39975a4c5 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-foreign-wrong-kind.json @@ -0,0 +1,52 @@ +{ + "$formatVersion": "1", + "id": "FmNtTTiJ9X8sJdKvkpK58yzUAx78PW61bCpwW8dZnpMC", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "foreignBid": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "auctionId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "contractId": "31TfU1koATcamnBrNiRbturPP5kLaE8VzJcojeTxedxR", + "documentType": "auction" + }, + "position": 0 + }, + "amount": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000, + "position": 1 + } + }, + "required": [ + "auctionId", + "amount" + ], + "propertyConstraints": { + "statusAsNumber": { + "greaterThan": [ + { + "referenced": [ + "auctionId", + "status" + ] + }, + 0 + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-literal-kind.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-literal-kind.json new file mode 100644 index 00000000000..f6213625131 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-literal-kind.json @@ -0,0 +1,44 @@ +{ + "$formatVersion": "1", + "id": "FmNtTTiJ9X8sJdKvkpK58yzUAx78PW61bCpwW8dZnpMC", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "foreignBid": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "auctionId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "contractId": "31TfU1koATcamnBrNiRbturPP5kLaE8VzJcojeTxedxR", + "documentType": "auction", + "where": { + "reservePrice": { + "const": "high" + } + } + }, + "position": 0 + }, + "amount": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000, + "position": 1 + } + }, + "required": [ + "auctionId", + "amount" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-ordering-kind.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-ordering-kind.json new file mode 100644 index 00000000000..b9e1ca3da01 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-ordering-kind.json @@ -0,0 +1,44 @@ +{ + "$formatVersion": "1", + "id": "FmNtTTiJ9X8sJdKvkpK58yzUAx78PW61bCpwW8dZnpMC", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "foreignBid": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "auctionId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "contractId": "31TfU1koATcamnBrNiRbturPP5kLaE8VzJcojeTxedxR", + "documentType": "auction", + "where": { + "status": { + "greaterThan": "amount" + } + } + }, + "position": 0 + }, + "amount": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000, + "position": 1 + } + }, + "required": [ + "auctionId", + "amount" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules.json new file mode 100644 index 00000000000..e305bea957f --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules.json @@ -0,0 +1,220 @@ +{ + "$formatVersion": "1", + "id": "31TfU1koATcamnBrNiRbturPP5kLaE8VzJcojeTxedxR", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "poll": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": true, + "properties": { + "option0": { "type": "string", "minLength": 1, "maxLength": 100, "position": 0 }, + "option1": { "type": "string", "minLength": 1, "maxLength": 100, "position": 1 }, + "option2": { "type": "string", "minLength": 1, "maxLength": 100, "position": 2 }, + "multiChoice": { "type": "boolean", "position": 3 }, + "endsAt": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "position": 4 } + }, + "required": ["option0", "option1", "$createdAt"], + "additionalProperties": false + }, + "vote": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "pollId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "deletableDocument", "documentType": "poll" }, + "position": 0 + }, + "choice": { "type": "integer", "minimum": 0, "maximum": 9, "position": 1 }, + "note": { "type": "string", "maxLength": 50, "position": 2 } + }, + "required": ["pollId", "choice", "$createdAt", "$updatedAt"], + "propertyConstraints": { + "castBeforeTheEnd": { + "lessThan": [ + "$updatedAt", + { "ifAbsent": [{ "referenced": ["pollId", "endsAt"] }, 9007199254740991] } + ] + }, + "singleChoicePoll": { "equal": [{ "referenced": ["pollId", "multiChoice"] }, 0] }, + "choiceIsAnOption": { + "lessThan": [ + "choice", + { + "countPresent": [ + { "referenced": ["pollId", "option0"] }, + { "referenced": ["pollId", "option1"] }, + { "referenced": ["pollId", "option2"] } + ] + } + ] + } + }, + "additionalProperties": false + }, + "deadlineVote": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "pollId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "deletableDocument", + "documentType": "poll", + "where": { + "endsAt": { "greaterThan": "$updatedAt" }, + "multiChoice": { "notEqual": { "const": true } } + } + }, + "position": 0 + }, + "note": { "type": "string", "maxLength": 50, "position": 1 } + }, + "required": ["pollId", "$createdAt", "$updatedAt"], + "additionalProperties": false + }, + "auction": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": false, + "properties": { + "reservePrice": { "type": "integer", "minimum": 0, "maximum": 1000000000, "position": 0 }, + "status": { "type": "string", "enum": ["open", "extended", "closed"], "maxLength": 10, "position": 1 } + }, + "required": ["reservePrice", "status"], + "additionalProperties": false + }, + "bid": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "auctionId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "auction", + "where": { "status": { "in": ["open", "extended"] } } + }, + "position": 0 + }, + "amount": { "type": "integer", "minimum": 0, "maximum": 1000000000, "position": 1 }, + "note": { "type": "string", "maxLength": 50, "position": 2 } + }, + "required": ["auctionId", "amount"], + "propertyConstraints": { + "atLeastReserve": { + "greaterThanOrEqual": ["amount", { "referenced": ["auctionId", "reservePrice"] }] + } + }, + "additionalProperties": false + }, + "order": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "status": { "type": "string", "enum": ["pending", "delivered"], "maxLength": 10, "position": 0 } + }, + "required": ["status"], + "additionalProperties": false + }, + "review": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": true, + "properties": { + "orderId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "deletableDocument", + "documentType": "order", + "where": { "status": { "const": "delivered" } } + }, + "position": 0 + }, + "stars": { "type": "integer", "minimum": 1, "maximum": 5, "position": 1 } + }, + "required": ["orderId", "stars"], + "additionalProperties": false + }, + "rebuttal": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": true, + "properties": { + "orderId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "deletableDocument", + "documentType": "order", + "where": { "$ownerId": { "notEqual": "$ownerId" } } + }, + "position": 0 + } + }, + "required": ["orderId"], + "additionalProperties": false + }, + "event": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "properties": { + "code": { "type": "string", "minLength": 1, "maxLength": 20, "position": 0 }, + "capacity": { "type": "integer", "minimum": 0, "maximum": 100000, "position": 1 } + }, + "indices": [{ "name": "byCode", "properties": [{ "code": "asc" }], "unique": true }], + "required": ["code", "capacity"], + "additionalProperties": false + }, + "ticket": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": true, + "properties": { + "eventCode": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "refersTo": { + "type": "permanentDocument", + "documentType": "event", + "findBy": { "code": "." } + }, + "position": 0 + }, + "seat": { "type": "integer", "minimum": 0, "maximum": 100000, "position": 1 } + }, + "required": ["eventCode", "seat"], + "propertyConstraints": { + "seatExists": { "lessThan": ["seat", { "referenced": ["eventCode", "capacity"] }] } + }, + "additionalProperties": false + } + } +} diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 869914168dc..72e46863910 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1164,6 +1164,33 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `submittedCharter`'s `rewardSplit` members add up to 100, replacing the /// charter-specific check, whose error 11001 keeps its place in /// `BasicError` but is never produced. +/// A rule may also read a value of the document a reference of the type +/// points at, `{ "referenced": [reference, field] }`, wherever a path +/// stands: `field` is a property path of the referenced type, its `$id`, +/// `$ownerId` or `$creatorId`, or a time or height it requires, read as +/// that property is, compared by what the rule compares it with (a string +/// beside a `const`, an identifier beside an identifier operand, an integer +/// otherwise), one node. The parser (`parse_property_constraints` 0 and +/// meta-schema v3, in place) reads it as the path +/// `$referenced..`; generation 3 refuses it, under full +/// validation, outside `propertyConstraints`, on an indexOnly type, +/// through anything but a top-level stored property holding one reference +/// to one document by id or by a `findBy` without a function, beside what +/// a transfer, a purchase or a price update of a type that has them +/// changes, and beside a field moderators change (10231); and +/// `create_document_types_from_document_schemas` 1, in place, checks what +/// it reads of a type of the same contract (10231: the field exists, is +/// stored and of the kind read, a time or `$creatorId` the type records, a +/// constant its `enum` lists, through a `moderatedDocument` only `$id`, +/// `$ownerId` and kept fields). Such a rule is not judged with the others: +/// `PropertyConstraint::violation` leaves it unjudged unless +/// `DocumentSystemValues::referenced_documents`, so the structure +/// validation, transfers, purchases, price updates, moderator restores and +/// changes of fields and the SDK pre-checks pass over it; document create +/// state validation 2 and replace state validation 1 (in place) judge it +/// after the references hold (`validate_referencing_property_constraints`, +/// versioned with `validate_property_constraints`, 0), on the values the +/// reference check read (item 63). /// /// 40. **Elected moderation teams moderate from their stored charter**: seating /// writes nothing. Awarding the contest of item 37 writes the winning @@ -1706,6 +1733,29 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// moderation charters system contract is written in the new keywords; /// the parsed declarations, and so validation and execution, are /// unchanged. +/// A `where` entry may instead hold a condition (`WhereCondition`, parsed +/// into `where_conditions` beside `property_agreement`, a field appended +/// to every document reference variant and `ListElementReference`): `{ +/// "const": v }`, `{ "notEqual": b }` (a referring property, `"$ownerId"` +/// or a `const`), `{ "in": [...] }` (two to thirty literals of one kind) +/// or an ordering (`greaterThan`, `greaterThanOrEqual`, `lessThan`, +/// `lessThanOrEqual`) of an integer by an integer property, an integer +/// `const` or a time or height of the referring document; a literal reads +/// as the referenced property's type says. Document reference validation +/// 0 (in place) checks them against the document found, or the removal +/// record for one asked about again, refusing with 40127 whose referring +/// property is the condition as written; one bounded by the writer or the +/// last update or transfer is asked about on every replace. Contract +/// create and update state validation (data contract reference validation +/// 0, in place) refuses one that cannot hold with 40126. The same +/// validation checks every reference a `propertyConstraints` rule reads +/// through (item 39) on every replace, the existence check's read, billed +/// as before, and records for the rule the values it reads of the +/// document found, or of a kept `moderatedDocument`'s removal record, so a +/// rule adds no read; and registration refuses a rule reading a field +/// another contract's type does not give it with +/// `ReferencedDocumentRuleReadInvalidError` (40148, appended to +/// `StateError`). /// 64. **`refersTo: moderatedDocument`**: a third kind of document reference, /// between `permanentDocument` and `deletableDocument` and disjoint from /// both (`DocumentReferenceKind`, `DocumentTypeV2Getters::document_reference_kind`), diff --git a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs index 64f8deca382..330724d89f9 100644 --- a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs +++ b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs @@ -41,7 +41,7 @@ use dash_sdk::dpp::data_contract::document_type::methods::{ DocumentTypeBasicMethods, DocumentTypeV0Methods, }; use dash_sdk::dpp::data_contract::document_type::property_constraints::{ - AggregateKind, AggregateRead, DocumentSystemValues, PropertyRead, + referenced_read, AggregateKind, AggregateRead, DocumentSystemValues, PropertyRead, }; use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; use dash_sdk::dpp::document::{Document, DocumentV0Getters}; @@ -63,7 +63,7 @@ const PROPERTY_CONSTRAINTS_KEYWORD: &str = "propertyConstraints"; /// Get the `propertyConstraints` rules of a document type as a JSON array /// /// Each element is -/// `{ "name": string, "rule": object, "reads": [{ "path": string, "kind": string }], "readsOwner": bool, "readsSystem": [string], "readsTotals": [{ "kind": string, "documentType": string, "property"?: string, "filter": [string] }] }`: +/// `{ "name": string, "rule": object, "reads": [{ "path": string, "kind": string }], "readsOwner": bool, "readsSystem": [string], "readsTotals": [{ "kind": string, "documentType": string, "property"?: string, "filter": [string] }], "readsReferenced": [{ "reference": string, "path": string, "kind": string }] }`: /// the rule's name (its key in `propertyConstraints`), the rule exactly as the /// document type's schema declares it, every property it reads in declared /// order (`kind` is `"value"` for an integer operand, `"presence"` for @@ -79,8 +79,12 @@ const PROPERTY_CONSTRAINTS_KEYWORD: &str = "propertyConstraints"; /// transfer's, and the `countOf` and `sumOf` totals it reads in declared order (`kind` /// `"countOf"` or `"sumOf"`, the type of the contract it totals, the summed `property` of /// a `sumOf` only, and the keys its `filter` matches documents by; a total the platform -/// reads from state when the document is sent, which the pre-check does not). Rules -/// are listed in name order, the order +/// reads from state when the document is sent, which the pre-check does not), and the +/// values it reads of the documents the type's references point at in declared order +/// (`{ "referenced": [reference, path] }`: the reference property of the type, the field +/// of the referenced document and the `kind` it is read as, which `reads` leaves out; the +/// platform reads the referenced document when it checks the reference, and the pre-check +/// does not judge such a rule). Rules are listed in name order, the order /// consensus checks them in. A document type declaring none gives `[]`, and so /// does every document type when the SDK's protocol version is below 14. /// @@ -147,7 +151,9 @@ pub unsafe extern "C" fn dash_sdk_data_contract_get_property_constraints( /// (`$createdAt`, `$updatedAt`, `$transferredAt`), and a rule reading a block /// height is not judged, since the height is unknown until the block, and /// neither is a rule reading a `countOf` or `sumOf` total (`"readsTotals"`), -/// which the platform reads from state when the document is sent. +/// which the platform reads from state when the document is sent, nor one +/// reading a value of a document a reference points at (`"readsReferenced"`), +/// which the platform reads when it checks the reference. /// Nothing but the rules is checked: not the JSON schema, not the state. /// /// The result is the first rule broken, as @@ -322,8 +328,16 @@ fn property_constraints_json( let reads: Vec = constraint .property_reads() .into_iter() + .filter(|(path, _)| referenced_read(path).is_none()) .map(|(path, read)| json!({ "path": path, "kind": read_kind_name(read) })) .collect(); + let reads_referenced: Vec = constraint + .referenced_reads() + .into_iter() + .map(|(reference, path, read)| { + json!({ "reference": reference, "path": path, "kind": read_kind_name(read) }) + }) + .collect(); rules.push(json!({ "name": name, "rule": rule, @@ -339,6 +353,7 @@ fn property_constraints_json( .into_iter() .map(total_read_json) .collect::>(), + "readsReferenced": reads_referenced, })); } Ok(serde_json::Value::Array(rules)) @@ -660,7 +675,8 @@ mod tests { ], "readsOwner": false, "readsSystem": [], - "readsTotals": [] + "readsTotals": [], + "readsReferenced": [] }, { "name": "discountBelowPrice", @@ -671,7 +687,8 @@ mod tests { ], "readsOwner": false, "readsSystem": [], - "readsTotals": [] + "readsTotals": [], + "readsReferenced": [] }, { "name": "perUnitFee", @@ -682,7 +699,8 @@ mod tests { ], "readsOwner": false, "readsSystem": [], - "readsTotals": [] + "readsTotals": [], + "readsReferenced": [] }, { "name": "sellerIsOwner", @@ -693,7 +711,8 @@ mod tests { ], "readsOwner": true, "readsSystem": [], - "readsTotals": [] + "readsTotals": [], + "readsReferenced": [] }, { "name": "tieredFee", @@ -701,7 +720,8 @@ mod tests { "reads": [{ "path": "fee", "kind": "value" }], "readsOwner": false, "readsSystem": [], - "readsTotals": [] + "readsTotals": [], + "readsReferenced": [] } ]) ); @@ -1111,7 +1131,8 @@ mod tests { ], "readsOwner": false, "readsSystem": [], - "readsTotals": [] + "readsTotals": [], + "readsReferenced": [] }, { "name": "openEndedSoldByOwner", @@ -1129,7 +1150,8 @@ mod tests { ], "readsOwner": true, "readsSystem": ["$createdAt"], - "readsTotals": [] + "readsTotals": [], + "readsReferenced": [] } ]) ); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift index 109b0457e7a..3c1ba0e12a0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift @@ -28,9 +28,11 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { /// `{ "startsWith": [a, b] }`, `{ "endsWith": [a, b] }`, /// `{ "notIn": [operand, [values]] }`, `{ "min": [a, b, ...] }`, /// `{ "max": [a, b, ...] }`, `{ "abs": a }`, `{ "ifThen": [if, then] }`, - /// `{ "ifThenElse": [if, then, else] }`, and the totals + /// `{ "ifThenElse": [if, then, else] }`, the totals /// `{ "countOf": [documentType, filter] }` and - /// `{ "sumOf": [documentType, property, filter] }` (the filter optional). + /// `{ "sumOf": [documentType, property, filter] }` (the filter optional), + /// and `{ "referenced": [reference, path] }`, a value of the document a + /// reference of the type points at, wherever a property path stands. public let ruleJSON: String /// Every property the rule reads, in declared order, a property read twice @@ -40,7 +42,8 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { /// nor are the system times and heights: see `readsSystem`. A property a /// total's filter takes a value from is listed (`category` in /// `{ "sumOf": ["listing", "price", { "category": "category" }] }`), the - /// total itself is not: see `readsTotals`. + /// total itself is not: see `readsTotals`. Nor is a value of a document a + /// reference points at: see `readsReferenced`. public let reads: [PropertyConstraintRead] /// Whether the rule reads the document's owner, `$ownerId`, in any branch, @@ -78,13 +81,28 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { /// built before the field existed. public let readsTotals: [PropertyConstraintTotalRead] + /// The values the rule reads of the documents the type's references point + /// at (`{ "referenced": [reference, path] }`), in declared order, one read + /// twice listed twice, every branch included as in `reads`, which leaves + /// them out. + /// + /// The platform reads the referenced document when it checks the + /// reference. The pre-check (`SDK.checkDocumentPropertyConstraints`) has + /// no referenced document, so it does not judge a rule reading one: such + /// a rule can still refuse a document the pre-check passes. + /// + /// Empty for a rule reading none, and for every rule reported by a library + /// built before the field existed. + public let readsReferenced: [PropertyConstraintReferencedRead] + public init( name: String, ruleJSON: String, reads: [PropertyConstraintRead], readsOwner: Bool, readsSystem: [String] = [], - readsTotals: [PropertyConstraintTotalRead] = [] + readsTotals: [PropertyConstraintTotalRead] = [], + readsReferenced: [PropertyConstraintReferencedRead] = [] ) { self.name = name self.ruleJSON = ruleJSON @@ -92,6 +110,7 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { self.readsOwner = readsOwner self.readsSystem = readsSystem self.readsTotals = readsTotals + self.readsReferenced = readsReferenced } /// `ruleJSON` indented for display, or `ruleJSON` itself should it not @@ -122,7 +141,8 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { let reads = rule["reads"] as? [Any], let readsOwner = DocumentTypedArray.jsonBool(rule["readsOwner"]), let readsSystem = systemReads(rule["readsSystem"]), - let readsTotals = totalReads(rule["readsTotals"]) + let readsTotals = totalReads(rule["readsTotals"]), + let readsReferenced = referencedReads(rule["readsReferenced"]) else { throw SDKError.serializationError("Malformed propertyConstraints rule: \(entry)") } @@ -132,7 +152,8 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { reads: try reads.map(PropertyConstraintRead.init(jsonEntry:)), readsOwner: readsOwner, readsSystem: readsSystem, - readsTotals: readsTotals + readsTotals: readsTotals, + readsReferenced: readsReferenced ) } } @@ -176,6 +197,27 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { } return totals } + + /// A rule's `readsReferenced`, `[]` when the key is missing (a library + /// built before it), or `nil` for anything but an array of well-formed + /// reads. + private static func referencedReads(_ value: Any?) -> [PropertyConstraintReferencedRead]? { + guard let value else { + return [] + } + guard let entries = value as? [Any] else { + return nil + } + var reads: [PropertyConstraintReferencedRead] = [] + reads.reserveCapacity(entries.count) + for entry in entries { + guard let read = PropertyConstraintReferencedRead(jsonEntry: entry) else { + return nil + } + reads.append(read) + } + return reads + } } /// A `countOf` or `sumOf` total a `propertyConstraints` rule reads: how many @@ -261,6 +303,41 @@ public struct PropertyConstraintTotalRead: Equatable, Sendable { } } +/// A value a `propertyConstraints` rule reads of the document a reference of +/// the type points at, `{ "referenced": [reference, path] }`. The fields mirror +/// wasm-dpp2's `PropertyConstraintReferencedRead` key for key. +public struct PropertyConstraintReferencedRead: Equatable, Sendable { + /// The top-level property of the document type holding the reference. + public let reference: String + /// The field of the referenced document: a dotted property path of its + /// type, `$id`, `$ownerId`, `$creatorId`, or a time or height it records + /// (`$createdAt`, ...). + public let path: String + /// How the rule reads the field, by the kinds `reads` uses: an + /// identifier (`$id`, `$ownerId`, `$creatorId`) as `.identifier`, a time + /// or height as `.value`. + public let kind: PropertyConstraintRead.Kind + + public init(reference: String, path: String, kind: PropertyConstraintRead.Kind) { + self.reference = reference + self.path = path + self.kind = kind + } + + /// A `readsReferenced` entry, or `nil` for one that is no object, or + /// lacks `reference`, `path` or `kind` or holds one that is not a string. + init?(jsonEntry entry: Any) { + guard let read = entry as? [String: Any], + let reference = read["reference"] as? String, + let path = read["path"] as? String, + let kind = read["kind"] as? String + else { + return nil + } + self.init(reference: reference, path: path, kind: PropertyConstraintRead.Kind(name: kind)) + } +} + /// A property a `propertyConstraints` rule reads, and how it reads it. public struct PropertyConstraintRead: Hashable, Sendable { /// How a rule reads a property; the names are wasm-dpp2's @@ -475,12 +552,14 @@ extension SDK { /// records as `$createdAt`, `$updatedAt` and `$transferredAt`, and a rule /// reading a block height (a `readsSystem` name ending in `BlockHeight` /// or `CoreBlockHeight`) is not judged at all. Nor is a rule reading a - /// `countOf` or `sumOf` total (its `readsTotals` not empty): the platform - /// reads the total from state when the document is sent, and this check - /// reads no state. So `nil` does not promise consensus accepts the - /// document: a rule reading a block height or a total, or a time rule the - /// device clock judges differently from the block time, can still refuse - /// it. + /// `countOf` or `sumOf` total (its `readsTotals` not empty), or a value of + /// a document a reference points at (its `readsReferenced` not empty): the + /// platform reads the total from state when the document is sent, and the + /// referenced document when it checks the reference, and this check reads + /// no state. So `nil` does not promise consensus accepts the document: a + /// rule reading a block height, a total or a referenced document, or a + /// time rule the device clock judges differently from the block time, can + /// still refuse it. /// /// - Throws: `SDKError.invalidParameter` for an owner id that is not 32 /// bytes or properties that are not a JSON object, `SDKError.notFound` for diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift index d23d703b4fc..570e44d1b73 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift @@ -191,8 +191,8 @@ extension PersistentDocumentType { /// `SDK.checkDocumentPropertyConstraints(serializedContract:documentType:propertiesJSON:ownerId:)` /// reports for the parent contract's stored platform serialization. As /// there, the device clock stands in for the create's block time, and a - /// rule reading a block height or a `countOf` or `sumOf` total is not - /// judged. + /// rule reading a block height, a `countOf` or `sumOf` total or a value of + /// a referenced document is not judged. /// /// - Throws: `SDKError.invalidState` when the parent contract has no /// stored serialization, or what the SDK call throws. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift index 6b7fd173de0..2beb74f810a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentTypeDetailsView.swift @@ -478,7 +478,8 @@ struct ExpandableIndexRowView: View { /// One `propertyConstraints` rule: its name, the rule as declared, what it /// reads (properties, `$ownerId`, system times and heights, `countOf` and -/// `sumOf` totals), and whether an owner change is judged against it too. +/// `sumOf` totals, values of referenced documents), and whether an owner +/// change is judged against it too. struct PropertyConstraintRowView: View { let rule: DocumentPropertyConstraint @@ -540,11 +541,21 @@ struct PropertyConstraintRowView: View { .font(.caption2) .foregroundColor(.secondary) } + + if !referencedReadsText.isEmpty { + Label("Reads referenced documents: \(referencedReadsText)", systemImage: "link") + .font(.caption2) + .foregroundColor(.blue) + .accessibilityIdentifier("documentType.propertyConstraint.\(rule.name).readsReferenced") + Text("The platform reads the referenced documents when it checks the references; the check before sending does not, so it cannot catch this rule.") + .font(.caption2) + .foregroundColor(.secondary) + } } .padding(.vertical, 4) - // Keeps the row's identifier on the row and the readsSystem and - // readsTotals lines' on those lines, rather than the row's on every - // child + // Keeps the row's identifier on the row and the readsSystem, + // readsTotals and readsReferenced lines' on those lines, rather than + // the row's on every child .accessibilityElement(children: .contain) .accessibilityIdentifier("documentType.propertyConstraint.\(rule.name)") } @@ -589,6 +600,18 @@ struct PropertyConstraintRowView: View { .filter { seen.insert($0).inserted } .joined(separator: "; ") } + + /// The values the rule reads of referenced documents, repeats dropped, + /// each as `. ()`: `pollId.status (text), + /// pollId.$ownerId (identifier)`. The Android example app builds the same + /// text, for cross-platform UAT: keep the two identical. + private var referencedReadsText: String { + var seen = Set() + return rule.readsReferenced + .map { "\($0.reference).\($0.path) (\($0.kind.name))" } + .filter { seen.insert($0).inserted } + .joined(separator: ", ") + } } struct PropertyRowView: View { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift index 4f907c5cdb4..33c1a672e9c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DocumentsView.swift @@ -1835,8 +1835,8 @@ struct CreateDocumentView: View { /// The first propertyConstraints rule the document would break, or `nil` /// when it meets every rule judged or has none (the device clock stands in - /// for the block time, and a rule reading a block height or a `countOf` or - /// `sumOf` total is not judged). + /// for the block time, and a rule reading a block height, a `countOf` or + /// `sumOf` total or a value of a referenced document is not judged). /// A check that cannot run (no SDK, no stored contract serialization) /// blocks nothing: consensus judges the document either way. private func propertyConstraintViolation( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift index 4f5fca56b81..925fe3f7cbf 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DocumentPropertyConstraintsTests.swift @@ -234,6 +234,70 @@ final class DocumentPropertyConstraintsTests: XCTestCase { ] """ + /// Rules reading values of the document a reference points at, as the FFI + /// reports them: a `vote` type whose `pollId` refers to a `poll`, reading + /// which options the poll has, its owner and its status, and a rule, + /// `smallChoice`, reading only the vote's choice. + private let referencedRulesJSON = """ + [ + { + "name": "choiceIsAnOption", + "rule": { + "lessThan": [ + "choice", + { + "countPresent": [ + { "referenced": ["pollId", "option0"] }, + { "referenced": ["pollId", "option1"] }, + { "referenced": ["pollId", "option2"] } + ] + } + ] + }, + "reads": [{ "path": "choice", "kind": "value" }], + "readsOwner": false, + "readsSystem": [], + "readsTotals": [], + "readsReferenced": [ + { "reference": "pollId", "path": "option0", "kind": "presence" }, + { "reference": "pollId", "path": "option1", "kind": "presence" }, + { "reference": "pollId", "path": "option2", "kind": "presence" } + ] + }, + { + "name": "ownerIsTheDenormalizedOne", + "rule": { "equal": [{ "referenced": ["pollId", "$ownerId"] }, "pollOwnerId"] }, + "reads": [{ "path": "pollOwnerId", "kind": "identifier" }], + "readsOwner": false, + "readsSystem": [], + "readsTotals": [], + "readsReferenced": [ + { "reference": "pollId", "path": "$ownerId", "kind": "identifier" } + ] + }, + { + "name": "pollIsOpen", + "rule": { "notEqual": [{ "referenced": ["pollId", "status"] }, { "const": "closed" }] }, + "reads": [], + "readsOwner": false, + "readsSystem": [], + "readsTotals": [], + "readsReferenced": [ + { "reference": "pollId", "path": "status", "kind": "text" } + ] + }, + { + "name": "smallChoice", + "rule": { "lessThan": ["choice", 10] }, + "reads": [{ "path": "choice", "kind": "value" }], + "readsOwner": false, + "readsSystem": [], + "readsTotals": [], + "readsReferenced": [] + } + ] + """ + /// The platform serialization of a contract created at protocol version /// 13, owned by `[7; 32]`, declaring one `note` type with a string /// `message` and no rules (generated by rs-sdk-ffi's @@ -700,6 +764,151 @@ final class DocumentPropertyConstraintsTests: XCTestCase { ) } + // MARK: - Referenced reads + + func testReferencedReadsDecodeWithTheirReferencePathAndKind() throws { + let rules = try DocumentPropertyConstraint.list(fromJSON: referencedRulesJSON) + + XCTAssertEqual( + rules.map(\.name), + ["choiceIsAnOption", "ownerIsTheDenormalizedOne", "pollIsOpen", "smallChoice"] + ) + XCTAssertEqual( + rules.map(\.readsReferenced), + [ + [ + PropertyConstraintReferencedRead(reference: "pollId", path: "option0", kind: .presence), + PropertyConstraintReferencedRead(reference: "pollId", path: "option1", kind: .presence), + PropertyConstraintReferencedRead(reference: "pollId", path: "option2", kind: .presence) + ], + [PropertyConstraintReferencedRead(reference: "pollId", path: "$ownerId", kind: .identifier)], + [PropertyConstraintReferencedRead(reference: "pollId", path: "status", kind: .text)], + [] + ] + ) + // A value of the referenced document is no property of this one; the + // properties the rule compares it with are + XCTAssertEqual(rules[0].reads, [PropertyConstraintRead(path: "choice", kind: .value)]) + XCTAssertEqual(rules[1].reads, [PropertyConstraintRead(path: "pollOwnerId", kind: .identifier)]) + XCTAssertEqual(rules[2].reads, []) + XCTAssertEqual(rules[3].reads, [PropertyConstraintRead(path: "choice", kind: .value)]) + // The poll's owner is not the vote's + XCTAssertEqual(rules.map(\.readsOwner), [false, false, false, false]) + XCTAssertEqual(rules.map(\.readsSystem), [[], [], [], []]) + XCTAssertEqual(rules.map(\.readsTotals), [[], [], [], []]) + XCTAssertEqual( + rules[1].ruleJSON, + #"{"equal":[{"referenced":["pollId","$ownerId"]},"pollOwnerId"]}"# + ) + } + + /// Referenced reads are kept in declared order, one read twice listed + /// twice, and a kind added by a later protocol version is kept by name + /// rather than failing the whole list. + func testReferencedReadsKeepRepeatsAndUnknownKinds() throws { + let rules = try DocumentPropertyConstraint.list(fromJSON: """ + [{ "name": "r", "readsOwner": false, "reads": [], "readsSystem": [], "readsTotals": [], + "readsReferenced": [ + { "reference": "pollId", "path": "endsAt", "kind": "value" }, + { "reference": "postId", "path": "meta.tags", "kind": "somethingNew" }, + { "reference": "pollId", "path": "endsAt", "kind": "value" } + ], + "rule": { "lessThan": [{ "referenced": ["pollId", "endsAt"] }, 10] } }] + """) + + let endsAt = PropertyConstraintReferencedRead(reference: "pollId", path: "endsAt", kind: .value) + XCTAssertEqual( + rules.first?.readsReferenced, + [ + endsAt, + PropertyConstraintReferencedRead( + reference: "postId", + path: "meta.tags", + kind: .other("somethingNew") + ), + endsAt + ] + ) + XCTAssertEqual(rules.first?.readsReferenced[1].kind.name, "somethingNew") + } + + /// A library built before `readsReferenced` leaves the key out. + func testMissingReferencedReadsDecodeAsEmpty() throws { + let rules = try DocumentPropertyConstraint.list(fromJSON: """ + [{ "name": "r", "rule": { "present": "a" }, "readsOwner": false, + "readsSystem": [], "readsTotals": [], "reads": [{ "path": "a", "kind": "presence" }] }] + """) + + XCTAssertEqual(rules.first?.readsReferenced, []) + } + + func testMalformedReferencedReadsAreRefused() { + let rule = #""name": "r", "rule": {"present": "a"}, "reads": [], "readsOwner": false"# + let status = #""reference": "pollId", "path": "status""# + let malformed = [ + // Not an array + #"[{\#(rule), "readsReferenced": "pollId"}]"#, + #"[{\#(rule), "readsReferenced": {\#(status), "kind": "text"}}]"#, + // A null is no missing key + #"[{\#(rule), "readsReferenced": null}]"#, + // An element that is not an object + #"[{\#(rule), "readsReferenced": ["pollId"]}]"#, + #"[{\#(rule), "readsReferenced": [[{\#(status), "kind": "text"}]]}]"#, + // A missing or mistyped reference + #"[{\#(rule), "readsReferenced": [{"path": "status", "kind": "text"}]}]"#, + #"[{\#(rule), "readsReferenced": [{"reference": 1, "path": "status", "kind": "text"}]}]"#, + #"[{\#(rule), "readsReferenced": [{"reference": null, "path": "status", "kind": "text"}]}]"#, + // A missing or mistyped path + #"[{\#(rule), "readsReferenced": [{"reference": "pollId", "kind": "text"}]}]"#, + #"[{\#(rule), "readsReferenced": [{"reference": "pollId", "path": ["status"], "kind": "text"}]}]"#, + // A missing or mistyped kind + #"[{\#(rule), "readsReferenced": [{\#(status)}]}]"#, + #"[{\#(rule), "readsReferenced": [{\#(status), "kind": 1}]}]"#, + #"[{\#(rule), "readsReferenced": [{\#(status), "kind": null}]}]"# + ] + // The same rule with well-formed referenced reads decodes + XCTAssertNoThrow(try DocumentPropertyConstraint.list(fromJSON: """ + [{\(rule), "readsReferenced": [{\(status), "kind": "text"}, + {"reference": "pollId", "path": "$ownerId", "kind": "identifier"}]}] + """)) + for json in malformed { + // Each is JSON, so what is refused is its readsReferenced + XCTAssertNoThrow(try JSONSerialization.jsonObject(with: Data(json.utf8)), json) + XCTAssertThrowsError(try DocumentPropertyConstraint.list(fromJSON: json), json) { error in + guard case SDKError.serializationError = error else { + return XCTFail("\(json): expected a serialization error, got \(error)") + } + } + } + } + + /// The initializer still builds a rule without naming `readsReferenced`, + /// as it did before the field existed. + func testInitializerDefaultsToNoReferencedReads() { + let rule = DocumentPropertyConstraint( + name: "r", + ruleJSON: #"{"lessThan":["choice",10]}"#, + reads: [PropertyConstraintRead(path: "choice", kind: .value)], + readsOwner: false, + readsSystem: [], + readsTotals: [] + ) + + XCTAssertEqual(rule.readsReferenced, []) + XCTAssertNotEqual( + rule, + DocumentPropertyConstraint( + name: "r", + ruleJSON: #"{"lessThan":["choice",10]}"#, + reads: [PropertyConstraintRead(path: "choice", kind: .value)], + readsOwner: false, + readsSystem: [], + readsTotals: [], + readsReferenced: [PropertyConstraintReferencedRead(reference: "pollId", path: "status", kind: .text)] + ) + ) + } + // MARK: - Violations func testViolationDecodes() throws { diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 84b7362c412..c0063daef02 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -84,6 +84,7 @@ use dpp::consensus::state::document::referenced_document_removed_error::Referenc use dpp::consensus::state::document::referenced_document_type_moderated_error::ReferencedDocumentTypeModeratedError; use dpp::consensus::state::document::referenced_document_type_index_only_error::ReferencedDocumentTypeIndexOnlyError; use dpp::consensus::state::document::document_delete_constraint_violated_error::DocumentDeleteConstraintViolatedError; +use dpp::consensus::state::document::referenced_document_rule_read_invalid_error::ReferencedDocumentRuleReadInvalidError; use dpp::consensus::state::document::referenced_document_type_not_moderated_error::ReferencedDocumentTypeNotModeratedError; use dpp::consensus::state::document::document_contest_index_mismatch_error::DocumentContestIndexMismatchError; use dpp::consensus::state::document::document_contest_not_joinable_error::DocumentContestNotJoinableError; @@ -800,6 +801,9 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::DocumentDeleteConstraintViolatedError(e) => { generic_consensus_error!(DocumentDeleteConstraintViolatedError, e).into() } + StateError::ReferencedDocumentRuleReadInvalidError(e) => { + generic_consensus_error!(ReferencedDocumentRuleReadInvalidError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 3fbaff968c5..191712fee68 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -108,6 +108,15 @@ pub enum DocumentReferenceErrorCodeWasm { /// as index entries and cannot be fetched by id, so no write could check /// the reference. ReferencedDocumentTypeIndexOnly = 40146, + /// A `propertyConstraints` rule reads, through a reference into a document + /// type of another contract (`{ "referenced": [reference, field] }`), a + /// field that type can not give it, reported at contract registration: one + /// it does not have, a transient one, one of another kind than the rule + /// reads, a time or height or a `$creatorId` it does not record, a constant + /// its `enum` does not list, or, through a `moderatedDocument` reference, a + /// field its removal record does not keep. (A rule reading a type of the + /// declaring contract is refused by the contract parse instead.) + ReferencedDocumentRuleReadInvalid = 40148, /// A create cannot reveal the preimage of a `refersTo` `findBy` /// function: a value a param reads is absent, or a variable-length /// value holds the one-byte separator that follows it in the preimage. @@ -116,8 +125,8 @@ pub enum DocumentReferenceErrorCodeWasm { impl DocumentReferenceErrorCodeWasm { /// The reference-validation error a code names, or `None` when the code - /// is not in the 40120-40125 range, 40131, 40135-40138, 40142-40146 or - /// 10423. + /// is not in the 40120-40125 range, 40131, 40135-40138, 40142-40146, + /// 40148 or 10423. fn from_code(code: u32) -> Option { match code { 40120 => Some(Self::ReferencedEntityNotFound), @@ -136,6 +145,7 @@ impl DocumentReferenceErrorCodeWasm { 40144 => Some(Self::ReferencedDocumentTypeModerated), 40145 => Some(Self::ReferencedDocumentRemoved), 40146 => Some(Self::ReferencedDocumentTypeIndexOnly), + 40148 => Some(Self::ReferencedDocumentRuleReadInvalid), 10423 => Some(Self::DocumentReferencePreimageInvalid), _ => None, } @@ -444,6 +454,7 @@ mod tests { use dpp::consensus::state::document::referenced_document_lookup_invalid_error::ReferencedDocumentLookupInvalidError; use dpp::consensus::state::document::referenced_document_removed_error::ReferencedDocumentRemovedError; use dpp::consensus::state::document::referenced_document_requirement_not_met_error::ReferencedDocumentRequirementNotMetError; + use dpp::consensus::state::document::referenced_document_rule_read_invalid_error::ReferencedDocumentRuleReadInvalidError; use dpp::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; use dpp::consensus::state::document::referenced_document_type_index_only_error::ReferencedDocumentTypeIndexOnlyError; use dpp::consensus::state::document::referenced_document_type_moderated_error::ReferencedDocumentTypeModeratedError; @@ -819,6 +830,18 @@ mod tests { .into(), DocumentReferenceErrorCodeWasm::ReferencedDocumentTypeIndexOnly, ), + ( + StateError::ReferencedDocumentRuleReadInvalidError( + ReferencedDocumentRuleReadInvalidError::new( + "vote".to_string(), + "castBeforeTheEnd".to_string(), + "pollId".to_string(), + "endsAt is a string".to_string(), + ), + ) + .into(), + DocumentReferenceErrorCodeWasm::ReferencedDocumentRuleReadInvalid, + ), ( BasicError::DocumentReferencePreimageInvalidError( DocumentReferencePreimageInvalidError::new( diff --git a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs index d247bb12c95..ec18e01db28 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs @@ -22,7 +22,7 @@ use dpp::data_contract::document_type::DocumentTypeRef; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; use dpp::data_contract::document_type::property_constraints::{ - AggregateKind, DocumentSystemValues, PropertyRead, + AggregateKind, DocumentSystemValues, PropertyRead, referenced_read, }; use dpp::document::{Document, DocumentV0Getters}; use dpp::platform_value::Value; @@ -33,6 +33,26 @@ use wasm_bindgen::prelude::wasm_bindgen; #[wasm_bindgen(typescript_custom_section)] const DOCUMENT_PROPERTY_CONSTRAINTS_TS: &'static str = r#" +/** + * A value of the document a reference of the document type points at, which + * stands wherever a property path does: `reference`, a top-level property of + * the type whose `refersTo` finds one document (by id, or by a `findBy` + * without a function), and `field`, a property path of the referenced type, + * its `$id`, `$ownerId` or `$creatorId`, or a time or height it records. It + * is read as that property of that type is. The platform judges a rule + * reading one once the references hold, with the documents their checks read; + * `checkDocumentPropertyConstraints` does not judge it. + */ +export type PropertyConstraintReferenced = { + referenced: [reference: string, field: string]; +}; + +/** + * Where a rule reads a value: the dotted path of a property of the document + * type, or a value of a referenced document. + */ +export type PropertyConstraintPath = string | PropertyConstraintReferenced; + /** * An integer expression of a `propertyConstraints` rule. * @@ -63,13 +83,16 @@ const DOCUMENT_PROPERTY_CONSTRAINTS_TS: &'static str = r#" * type of the same contract match a filter, or the total of an integer * property over them, as the type's count or sum trees keep it once the * write is done. `checkDocumentPropertyConstraints` does not judge a rule - * reading one. + * reading one; + * - `referenced`: an integer or boolean value, or a time or height, of the + * document a reference points at (`PropertyConstraintReferenced`). */ export type PropertyConstraintExpression = | number | bigint | string - | { ifAbsent: [path: string, value: number | bigint] } + | PropertyConstraintReferenced + | { ifAbsent: [path: PropertyConstraintPath, value: number | bigint] } | { add: PropertyConstraintExpression[] } | { multiply: PropertyConstraintExpression[] } | { subtract: [PropertyConstraintExpression, PropertyConstraintExpression] } @@ -79,11 +102,11 @@ export type PropertyConstraintExpression = | { min: PropertyConstraintExpression[] } | { max: PropertyConstraintExpression[] } | { abs: PropertyConstraintExpression } - | { length: string } - | { byteLength: string } - | { count: string } - | { countPresent: string[] } - | { byteAt: [path: string, index: number] } + | { length: PropertyConstraintPath } + | { byteLength: PropertyConstraintPath } + | { count: PropertyConstraintPath } + | { countPresent: PropertyConstraintPath[] } + | { byteAt: [path: PropertyConstraintPath, index: number] } | { countOf: [documentType: string] | [documentType: string, filter: PropertyConstraintAggregateFilter] } | { sumOf: @@ -111,15 +134,21 @@ export type PropertyConstraintAggregateFilter = Record< * - `const`: a string constant, a base58 identifier beside an identifier * property or `$ownerId`, or hex digits, two a byte, beside a byte array * property in a `startsWith` or `endsWith`; - * - `ifAbsent`: a string property with the string it takes when left out. + * - `ifAbsent`: a string property with the string it takes when left out; + * - `referenced`: a value of the document a reference points at + * (`PropertyConstraintReferenced`), compared as the other side says: as a + * string beside a `const` or a string property, as an identifier beside an + * identifier property or `$ownerId` (its `$id`, `$ownerId` and `$creatorId` + * always are). * * A property the document leaves out without a default equals nothing, and * a byte array left out starts and ends with nothing. */ export type PropertyConstraintEqualityOperand = | string + | PropertyConstraintReferenced | { const: string } - | { ifAbsent: [path: string, value: string] }; + | { ifAbsent: [path: PropertyConstraintPath, value: string] }; /** * A `propertyConstraints` rule, or a condition inside one. @@ -150,13 +179,13 @@ export type PropertyConstraintCondition = | { lessThanOrEqual: [PropertyConstraintExpression, PropertyConstraintExpression] } | { greaterThan: [PropertyConstraintExpression, PropertyConstraintExpression] } | { greaterThanOrEqual: [PropertyConstraintExpression, PropertyConstraintExpression] } - | { in: [PropertyConstraintExpression, Array] | [string | { ifAbsent: [path: string, value: string] }, string[]] } - | { notIn: [PropertyConstraintExpression, Array] | [string | { ifAbsent: [path: string, value: string] }, string[]] } + | { in: [PropertyConstraintExpression, Array] | [PropertyConstraintPath | { ifAbsent: [path: PropertyConstraintPath, value: string] }, string[]] } + | { notIn: [PropertyConstraintExpression, Array] | [PropertyConstraintPath | { ifAbsent: [path: PropertyConstraintPath, value: string] }, string[]] } | { startsWith: [PropertyConstraintEqualityOperand, PropertyConstraintEqualityOperand] } | { endsWith: [PropertyConstraintEqualityOperand, PropertyConstraintEqualityOperand] } - | { contains: [path: string, PropertyConstraintExpression | PropertyConstraintEqualityOperand] } - | { present: string } - | { absent: string } + | { contains: [path: PropertyConstraintPath, PropertyConstraintExpression | PropertyConstraintEqualityOperand] } + | { present: PropertyConstraintPath } + | { absent: PropertyConstraintPath } | { anyOf: PropertyConstraintCondition[] } | { allOf: PropertyConstraintCondition[] } | { not: PropertyConstraintCondition } @@ -215,6 +244,22 @@ export type PropertyConstraintTotalRead = { filter: string[]; }; +/** + * A value a rule reads of the document a reference of the type points at, + * `{ "referenced": [reference, path] }`: `reference` is the top-level + * property of the type holding the reference, `path` the field of the + * referenced document (a property path of its type, `$id`, `$ownerId`, + * `$creatorId`, or a time or height it records), read as `kind` says. The + * platform reads the referenced document when it checks the reference; + * `checkDocumentPropertyConstraints` cannot, and does not judge a rule + * reading one. + */ +export type PropertyConstraintReferencedRead = { + reference: string; + path: string; + kind: PropertyConstraintReadKind; +}; + /** * A single `propertyConstraints` rule of a document type. */ @@ -223,7 +268,11 @@ export type DocumentPropertyConstraint = { name: string; /** The rule as the schema declares it. */ rule: PropertyConstraintCondition; - /** Every property the rule reads, in declared order; `$ownerId` is no property and is not listed. */ + /** + * Every property of the document type the rule reads, in declared order; + * `$ownerId` is no property and is not listed, and neither is a value of a + * referenced document (`readsReferenced`). + */ reads: Array<{ path: string; kind: PropertyConstraintReadKind }>; /** * Whether the rule reads `$ownerId`, or a total that depends on the owner: @@ -241,6 +290,12 @@ export type DocumentPropertyConstraint = { * read twice listed twice. The pre-check does not judge a rule reading one. */ readsTotals: PropertyConstraintTotalRead[]; + /** + * The values the rule reads of the documents the type's references point + * at, in declared order, one read twice listed twice. The pre-check does not + * judge a rule reading one. + */ + readsReferenced: PropertyConstraintReferencedRead[]; }; /** @@ -319,9 +374,9 @@ fn violation_name(violation: PropertyConstraintViolation) -> &'static str { /// `Number.MAX_SAFE_INTEGER`, the largest integer a JS `number` holds exactly. const MAX_SAFE_INTEGER: i128 = (1 << 53) - 1; -/// An integer literal of a rule: a `number` while it is exact in JavaScript, -/// a `bigint` past that. -fn integer_to_js(integer: i128) -> JsValue { +/// An integer literal of a rule, or of a `refersTo` `where` entry: a `number` +/// while it is exact in JavaScript, a `bigint` past that. +pub(crate) fn integer_to_js(integer: i128) -> JsValue { if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&integer) { JsValue::from_f64(integer as f64) } else { @@ -410,7 +465,11 @@ pub(crate) fn property_constraints_for_document_type( set_field(&object, "rule", &rule_to_js(declared, name)?, name)?; let reads = Array::new(); - for (path, read) in constraint.property_reads() { + for (path, read) in constraint + .property_reads() + .into_iter() + .filter(|(path, _)| referenced_read(path).is_none()) + { let entry = Object::new(); set_field(&entry, "path", &JsValue::from_str(path), name)?; set_field( @@ -454,6 +513,20 @@ pub(crate) fn property_constraints_for_document_type( reads_totals.push(&entry); } set_field(&object, "readsTotals", &reads_totals, name)?; + let reads_referenced = Array::new(); + for (reference, path, read) in constraint.referenced_reads() { + let entry = Object::new(); + set_field(&entry, "reference", &JsValue::from_str(reference), name)?; + set_field(&entry, "path", &JsValue::from_str(path), name)?; + set_field( + &entry, + "kind", + &JsValue::from_str(read_kind_name(read)), + name, + )?; + reads_referenced.push(&entry); + } + set_field(&object, "readsReferenced", &reads_referenced, name)?; rules.push(&object); } @@ -466,7 +539,8 @@ pub(crate) fn property_constraints_for_document_type( /// time it records (its update, and its creation and transfer when the /// document has none yet). The block heights it records are unknown until the /// block, so a rule reading one is not judged, and so is a rule reading a -/// `countOf` or `sumOf` total, which no client reads from state here. +/// `countOf` or `sumOf` total, or a value of a referenced document, which no +/// client reads from state here. fn system_values_for_write(document: &Document) -> DocumentSystemValues { // Milliseconds since the epoch, a whole number well inside a `u64` let now = js_sys::Date::now() as u64; 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 24f2868a13f..43cd6cc63af 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -11,14 +11,15 @@ //! document type are references, and to what?" — without hand-parsing the //! contract's raw JSON schema. +use crate::data_contract::document_type_property_constraints::integer_to_js; use crate::error::{WasmDppError, WasmDppResult}; use crate::identifier::IdentifierWasm; use dpp::data_contract::document_type::{ DocumentPropertyReferenceTarget, DocumentReferenceLookup, DocumentTypeRef, IdentityKeyReferenceRequirements, KeyIdReference, LookupHashKey, LookupKeyParam, - LookupKeySource, PropertyReference, + LookupKeySource, PropertyReference, WhereBound, WhereCondition, WhereLiteral, }; -use dpp::document::property_names::ID; +use dpp::document::property_names::{ID, OWNER_ID}; use dpp::prelude::Identifier; use js_sys::{Array, Object, Reflect}; use std::collections::BTreeMap; @@ -333,8 +334,40 @@ export type DocumentReferenceFindBy = Record; + +/** + * A `where` entry that is not an equality with a referring value, as the + * schema writes it, one key: + * + * - `const`: a literal the referenced side must equal (a string is a base58 + * identifier for an identifier, hex for a byte array); a side the document + * leaves out equals none; + * - `notEqual`: a referring property path, `'$ownerId'` (the writer, checked on + * every replace) or a `{ const }` it must differ from; both sides absent are + * equal; + * - `in`: two or more literals of one kind, one of which it must equal; + * - `greaterThan`, `greaterThanOrEqual`, `lessThan`, `lessThanOrEqual`: an + * integer it must compare with, an integer property path of the referring + * type, an integer `{ const }`, or a time or height the referring type + * records (`'$createdAt'`; one of the update or transfer is checked on every + * replace); it holds only when both sides have a value. + * + * An entry that does not hold refuses the write with code 40127. */ -export type DocumentReferenceWhere = Record; +export type DocumentReferenceWhereCondition = + | { const: string | number | bigint | boolean } + | { notEqual: string | { const: string | number | bigint | boolean } } + | { in: Array } + | { greaterThan: string | { const: number | bigint } } + | { greaterThanOrEqual: string | { const: number | bigint } } + | { lessThan: string | { const: number | bigint } } + | { lessThanOrEqual: string | { const: number | bigint } }; /** * A `findBy` function, for a commit and reveal, keyed by the referenced @@ -498,13 +531,15 @@ fn set_key_requirements_field( /// The `where` field of a document reference: `{ referenced property: /// referring property }`, a consensus-enforced equality at write time, keyed by /// the referenced side as the schema spells it (the parsed model keys it the -/// other way). `find_by_entry` leaves out the entry `findBy` reads instead, a -/// list's `$id`. Absent, not `{}`-valued, when the declaration compares -/// nothing, matching the schema's own omission and the absent-field convention -/// of the other optional target fields. +/// other way), and the other entries, each `{ referenced property: condition }` +/// as the schema writes the condition. `find_by_entry` leaves out the entry +/// `findBy` reads instead, a list's `$id`. Absent, not `{}`-valued, when the +/// declaration compares nothing, matching the schema's own omission and the +/// absent-field convention of the other optional target fields. fn set_where_field( object: &Object, property_agreement: &BTreeMap, + where_conditions: &BTreeMap, find_by_entry: Option<&str>, path: &str, ) -> WasmDppResult<()> { @@ -512,7 +547,7 @@ fn set_where_field( .iter() .filter(|(_, referenced)| Some(referenced.as_str()) != find_by_entry) .collect(); - if entries.is_empty() { + if entries.is_empty() && where_conditions.is_empty() { return Ok(()); } let where_object = Object::new(); @@ -524,9 +559,65 @@ fn set_where_field( path, )?; } + for (referenced, condition) in where_conditions { + set_field( + &where_object, + referenced, + &where_condition_to_js(condition, path)?, + path, + )?; + } set_field(object, "where", &where_object, path) } +/// A `where` literal as the schema writes it: an integer past +/// `Number.MAX_SAFE_INTEGER` as a `bigint`, exactly. +fn where_literal_to_js(literal: &WhereLiteral) -> JsValue { + match literal { + WhereLiteral::Integer(integer) => integer_to_js(*integer), + WhereLiteral::Boolean(flag) => JsValue::from_bool(*flag), + WhereLiteral::Text(text) => JsValue::from_str(text), + } +} + +/// `{ "const": literal }`. +fn where_constant_to_js(literal: &WhereLiteral, path: &str) -> WasmDppResult { + let constant = Object::new(); + set_field(&constant, "const", &where_literal_to_js(literal), path)?; + Ok(constant.into()) +} + +/// The bound of a `notEqual` or an ordering as the schema writes it: a path, +/// `'$ownerId'`, a time or height, or `{ const }`. +fn where_bound_to_js(bound: &WhereBound, path: &str) -> WasmDppResult { + Ok(match bound { + WhereBound::Property(property) => JsValue::from_str(property), + WhereBound::Writer => JsValue::from_str(OWNER_ID), + WhereBound::System(property) => JsValue::from_str(property.name()), + WhereBound::Constant(literal) => where_constant_to_js(literal, path)?, + }) +} + +/// A `where` condition as the schema writes it: an object with one key. +fn where_condition_to_js(condition: &WhereCondition, path: &str) -> WasmDppResult { + let value = match condition { + WhereCondition::Constant(literal) => where_literal_to_js(literal), + WhereCondition::NotEqual(bound) | WhereCondition::Ordering { bound, .. } => { + where_bound_to_js(bound, path)? + } + WhereCondition::In(literals) => { + let values = Array::new(); + for literal in literals { + values.push(&where_literal_to_js(literal)); + } + values.into() + } + }; + let object = Object::new(); + set_field(&object, condition.wire_name(), &value, path)?; + Ok(object.into()) +} + /// The `findBy` field of a document reference found by it: every property it /// names mapped to its source as the schema spells it, `'.'`, `'$ownerId'`, a /// property path, or a function as `{ function, params }`, and, beside a @@ -682,28 +773,33 @@ fn set_reference_target_fields( contract_id, document_type_name, property_agreement, + where_conditions, } | DocumentPropertyReferenceTarget::PermanentDocumentLookup { contract_id, document_type_name, property_agreement, + where_conditions, .. } | DocumentPropertyReferenceTarget::DeletableDocument { contract_id, document_type_name, property_agreement, + where_conditions, } | DocumentPropertyReferenceTarget::DeletableDocumentLookup { contract_id, document_type_name, property_agreement, + where_conditions, .. } | DocumentPropertyReferenceTarget::ModeratedDocument { contract_id, document_type_name, property_agreement, + where_conditions, } => { let effective = contract_id.unwrap_or(declaring_contract_id); set_field( @@ -725,7 +821,7 @@ fn set_reference_target_fields( { set_find_by_fields(object, lookup, path)?; } - set_where_field(object, property_agreement, None, path)?; + set_where_field(object, property_agreement, where_conditions, None, path)?; } // A document found by its `$id` read from another property, whose list // the value must be in: the fields of `permanentDocument`, `findBy` @@ -749,7 +845,13 @@ fn set_reference_target_fields( set_field(&find_by, ID, &JsValue::from_str(document_id_property), path)?; set_field(object, "findBy", &find_by, path)?; } - set_where_field(object, &reference.property_agreement, Some(ID), path)?; + set_where_field( + object, + &reference.property_agreement, + &reference.where_conditions, + Some(ID), + path, + )?; set_field( object, "inList", diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts index 360a6829c5a..cc653c96df0 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyConstraints.spec.ts @@ -106,6 +106,85 @@ function offer( } describe('DataContract: propertyConstraints (v14)', () => { + describe('rules reading a referenced document', () => { + /** + * A `vote` whose rules read the `poll` its `pollId` refers to: the + * platform judges them with the poll its reference check read, and the + * pre-check, which reads no state, leaves them unjudged. + */ + const referencingSchemas = { + poll: { + type: 'object', + documentsMutable: false, + canBeDeleted: true, + properties: { + endsAt: { type: 'integer', minimum: 0, position: 0 }, + status: { + type: 'string', enum: ['open', 'closed'], maxLength: 10, position: 1, + }, + }, + required: ['$createdAt'], + additionalProperties: false, + }, + vote: { + type: 'object', + properties: { + pollId: { + ...identifierProperty(0), + refersTo: { type: 'deletableDocument', documentType: 'poll' }, + }, + choice: { type: 'integer', minimum: 0, maximum: 9, position: 1 }, + }, + required: ['pollId', 'choice', '$createdAt'], + additionalProperties: false, + propertyConstraints: { + castBeforeTheEnd: { lessThan: ['$createdAt', { referenced: ['pollId', 'endsAt'] }] }, + pollIsOpen: { + equal: [{ referenced: ['pollId', 'status'] }, { const: 'open' }], + }, + }, + }, + }; + + it('should list what a rule reads of the referenced document apart from its own reads', () => { + const contract = buildContract(referencingSchemas); + + expect(contract.documentTypePropertyConstraints('vote')).to.deep.equal([ + { + name: 'castBeforeTheEnd', + rule: referencingSchemas.vote.propertyConstraints.castBeforeTheEnd, + reads: [], + readsOwner: false, + readsSystem: ['$createdAt'], + readsTotals: [], + readsReferenced: [{ reference: 'pollId', path: 'endsAt', kind: 'value' }], + }, + { + name: 'pollIsOpen', + rule: referencingSchemas.vote.propertyConstraints.pollIsOpen, + reads: [], + readsOwner: false, + readsSystem: [], + readsTotals: [], + readsReferenced: [{ reference: 'pollId', path: 'status', kind: 'text' }], + }, + ]); + }); + + it('should leave a rule reading a referenced document unjudged', () => { + const contract = buildContract(referencingSchemas); + const vote = new wasm.Document({ + properties: { pollId: otherId, choice: 0 }, + documentTypeName: 'vote', + dataContractId: contract.id, + ownerId, + revision: BigInt(1), + }); + + expect(contract.checkDocumentPropertyConstraints(vote)).to.equal(undefined); + }); + }); + describe('documentTypePropertyConstraints()', () => { it('should list every rule in name order with what it reads', () => { const contract = buildContract(schemas); @@ -121,6 +200,7 @@ describe('DataContract: propertyConstraints (v14)', () => { readsOwner: false, readsSystem: [], readsTotals: [], + readsReferenced: [], }, { name: 'discountBelowPrice', @@ -132,6 +212,7 @@ describe('DataContract: propertyConstraints (v14)', () => { readsOwner: false, readsSystem: [], readsTotals: [], + readsReferenced: [], }, { name: 'perUnitFee', @@ -143,6 +224,7 @@ describe('DataContract: propertyConstraints (v14)', () => { readsOwner: false, readsSystem: [], readsTotals: [], + readsReferenced: [], }, { name: 'sellerIsOwner', @@ -154,6 +236,7 @@ describe('DataContract: propertyConstraints (v14)', () => { readsOwner: true, readsSystem: [], readsTotals: [], + readsReferenced: [], }, { name: 'tieredFee', @@ -162,6 +245,7 @@ describe('DataContract: propertyConstraints (v14)', () => { readsOwner: false, readsSystem: [], readsTotals: [], + readsReferenced: [], }, ]); }); @@ -217,6 +301,7 @@ describe('DataContract: propertyConstraints (v14)', () => { readsOwner: false, readsSystem: [], readsTotals: [], + readsReferenced: [], }, { name: 'titleBytes', @@ -225,6 +310,7 @@ describe('DataContract: propertyConstraints (v14)', () => { readsOwner: false, readsSystem: [], readsTotals: [], + readsReferenced: [], }, ]); @@ -272,6 +358,7 @@ describe('DataContract: propertyConstraints (v14)', () => { readsOwner: false, readsSystem: ['$createdAt'], readsTotals: [], + readsReferenced: [], }, { name: 'listedAfterHeight10', @@ -280,6 +367,7 @@ describe('DataContract: propertyConstraints (v14)', () => { readsOwner: false, readsSystem: ['$createdAtBlockHeight'], readsTotals: [], + readsReferenced: [], }, ]); @@ -327,6 +415,7 @@ describe('DataContract: propertyConstraints (v14)', () => { readsOwner: false, readsSystem: [], readsTotals: [], + readsReferenced: [], }, ]); @@ -530,6 +619,7 @@ describe('DataContract: propertyConstraints (v14)', () => { readsOwner: false, readsSystem: [], readsTotals: [{ kind: 'countOf', documentType: 'listing', filter: [] }], + readsReferenced: [], }, { name: 'atMostTwoPerOwner', @@ -538,6 +628,7 @@ describe('DataContract: propertyConstraints (v14)', () => { readsOwner: true, readsSystem: [], readsTotals: [{ kind: 'countOf', documentType: 'listing', filter: ['$ownerId'] }], + readsReferenced: [], }, { name: 'categoryBudget', @@ -548,6 +639,7 @@ describe('DataContract: propertyConstraints (v14)', () => { readsTotals: [{ kind: 'sumOf', documentType: 'listing', property: 'price', filter: ['category'], }], + readsReferenced: [], }, ]); @@ -588,6 +680,7 @@ describe('DataContract: propertyConstraints (v14)', () => { readsOwner: false, readsSystem: [], readsTotals: [], + readsReferenced: [], }, { name: 'knownTier', @@ -596,6 +689,7 @@ describe('DataContract: propertyConstraints (v14)', () => { readsOwner: false, readsSystem: [], readsTotals: [], + readsReferenced: [], }, ]; diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index 6d8d7ce49f4..feaf822b89d 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -59,11 +59,16 @@ const schemas = { paidWith: identifierProperty(2, { type: 'token' }), // `contractId` omitted: targets the declaring contract itself. // `where` binds the referenced document's property to the - // referring document's (write-time equality, PV14 #4505). + // referring document's (write-time equality, PV14 #4505), and holds + // the parent's recipient key id to at most this note's signer key id + // (a condition, an ordering by a referring property) parentNoteId: identifierProperty(3, { type: 'permanentDocument', documentType: 'note', - where: { signerKeyId: 'signerKeyId' }, + where: { + signerKeyId: 'signerKeyId', + recipientKeyId: { lessThanOrEqual: 'signerKeyId' }, + }, }), otherDoc: identifierProperty(4, { type: 'permanentDocument', @@ -152,7 +157,7 @@ type Reference = { string, string | { function: string; params: Array } >; - where?: Record; + where?: Record>; minimumAgeBlocks?: number; consume?: boolean; inList?: string; @@ -280,7 +285,10 @@ describe('DataContract — refersTo declarations (v14)', () => { const references = contract.documentTypeReferences('note') as Reference[]; const parent = references.find((reference) => reference.path === 'parentNoteId')!; - expect(parent.where).to.deep.equal({ signerKeyId: 'signerKeyId' }); + expect(parent.where).to.deep.equal({ + signerKeyId: 'signerKeyId', + recipientKeyId: { lessThanOrEqual: 'signerKeyId' }, + }); }); /** @@ -1031,6 +1039,7 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentTypeModerated).to.equal(40144); expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentRemoved).to.equal(40145); expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentTypeIndexOnly).to.equal(40146); + expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentRuleReadInvalid).to.equal(40148); }); it('should resolve a code back to its name', () => { From 28fcfde538921c69eedb980fd4c5ce88cf9bd98a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 11 Oct 2026 14:20:20 +0300 Subject: [PATCH 2/8] docs(platform): how a referenced identifier compares, and the 40148 getter doc A referenced identifier schema property compares with an identifier operand of the type (beside a const it reads as a string, which registration refuses); the referenced document's $id, $ownerId and $creatorId also take a base58 const. A parser test pins both. Co-Authored-By: Claude Opus 5.5 --- .../contract-keywords/property-constraints.md | 2 +- .../v3/referenced_rule_tests.rs | 27 +++++++++++++++++++ packages/wasm-dpp2/src/consensus_error.rs | 4 +-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index f0b3b56acdf..35be3e295d9 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -253,7 +253,7 @@ A bid meets its auction's reserve price, a review names an order that was delive - **The field** is a property of the referenced type by its dotted path, its `$id`, `$ownerId` or `$creatorId` (compared as identifiers, `$creatorId` on a type that records it), or a time or height it lists in `required` (`"$createdAt"`, read as an integer). - **Absence** reads as a property of the type left out does: an integer reads 0 (or an `ifAbsent` default), a string or an identifier equals nothing, `present` does not hold. A reference the document leaves out leaves every value read through it absent. -- **Which comparison.** A rule names another type's property, maybe another contract's, so how it compares follows the rule: as a string beside a `const` or a string property, or in an `in` listing strings; as an identifier beside an identifier property or `$ownerId`; as an integer otherwise. Registration checks the property is of that kind. So a referenced identifier property is compared with an identifier property of the type or `$ownerId`; a literal identifier is a `where` [`const`](refers-to.md#conditions) instead. +- **Which comparison.** A rule names another type's property, maybe another contract's, so how it compares follows the rule: as a string beside a `const` or a string property, or in an `in` listing strings; as an identifier beside an identifier property or `$ownerId`; as an integer otherwise. Registration checks the property is of that kind. So a referenced identifier schema property is compared with an identifier property of the type or `$ownerId`: beside a `{ "const": ... }` it would read as a string, which registration refuses, and a literal identifier for it is a `where` [`const`](refers-to.md#conditions) instead. The referenced document's own `$id`, `$ownerId` and `$creatorId` are always identifiers, so they also take a base58 `{ "const": ... }` in an `equal`, `notEqual` or `in`. **Which references.** The reference holds one document: a `permanentDocument` or `deletableDocument` reference found by id or by a [`findBy`](refers-to-lookup.md) without a function, or a `moderatedDocument` one, into this contract or another. Not the elements of a typed array, an `anyOf` or `allOf`, an `inList`, or a `findBy` function, which is judged on the create alone. Through a `moderatedDocument` reference a rule reads only `$id`, `$ownerId` and the fields the referenced type keeps under [`deleteKeepsFields`](deletion.md#moderatorabilitiesdeletekeepsfields): once a moderator removes the document its [removal record](../data-model/contract-moderation.md#deleting-documents) is all there is to read, and a rule is judged on every replace. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs index 26542bdf877..e68a431789d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs @@ -22,6 +22,7 @@ use crate::data_contract::document_type::{ WhereCondition, WhereLiteral, WhereOrdering, }; use crate::ProtocolError; +use platform_value::string_encoding::Encoding; use platform_value::{platform_value, Identifier, Value}; use platform_version::version::PlatformVersion; use std::collections::BTreeMap; @@ -73,6 +74,7 @@ fn poll() -> Value { }, "position": 8 }, + "curatorId": identifier(9, None), }, "transient": ["note"], "required": ["question", "option0", "option1", "$createdAt"], @@ -487,6 +489,31 @@ fn should_refuse_a_read_the_referenced_type_can_not_answer() { } } +/// How a referenced value compares follows the rule, since the referenced type +/// may be another contract's: beside a `const` it reads as a string. So a +/// referenced identifier schema property is compared with an identifier +/// operand of the type, while the referenced document's own identifiers +/// (`$id`, `$ownerId`, `$creatorId`), always identifiers, take a base58 `const` +/// too. +#[test] +fn should_compare_a_referenced_identifier_as_the_rule_says() { + let base58 = Identifier::new([4; 32]).to_string(Encoding::Base58); + parse_rules(platform_value!({ + "rule": { "equal": [{ "referenced": ["pollId", "curatorId"] }, "pollOwnerId"] } + })) + .expect("an identifier property of the type compares with it"); + parse_rules(platform_value!({ + "rule": { "notEqual": [{ "referenced": ["pollId", "$ownerId"] }, { "const": base58.clone() }] } + })) + .expect("the referenced document's owner takes a base58 const"); + expect_structure_error( + parse_rules(platform_value!({ + "rule": { "equal": [{ "referenced": ["pollId", "curatorId"] }, { "const": base58 }] } + })), + "compares \"pollId.curatorId\" with a string", + ); +} + #[test] fn should_read_only_what_a_removal_record_keeps_through_a_moderated_reference() { // The record keeps $ownerId and the hashtag diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 191712fee68..cb10ecf92ae 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -387,8 +387,8 @@ impl ConsensusErrorWasm { } /// The reference-validation error this is, or `undefined` when it is - /// not one of codes 40120-40125, 40131, 40135-40138, 40142-40146 and - /// 10423. + /// not one of codes 40120-40125, 40131, 40135-40138, 40142-40146, 40148 + /// and 10423. #[wasm_bindgen(getter = "documentReferenceErrorCode")] pub fn document_reference_error_code(&self) -> Option { DocumentReferenceErrorCodeWasm::from_code(self.0.code()) From fbad515620b7db2596cd3bf33a425c2d16e68a30 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 11 Oct 2026 16:54:52 +0300 Subject: [PATCH 3/8] fix(platform): refuse a referenced rule beside a total moderators move A rule reading a referenced document may not depend on a field the type keeps for its moderators to change, since their change checks no reference and so skips the rule. The registration check looked only at the paths the rule reads, which leave out the keys of a countOf or sumOf over the type's own documents: a rule capping the approved entries of a room by the room's capacity registered with `status` moderator-changeable, and moderators could approve past the capacity. The check now also takes those keys (AggregateRead::own_type_keys). A summed property needs no check: a summable index makes it required, so moderators never change it. reads_referenced() no longer collects every read of the rule into a Vec to answer a boolean: the read walk (now visit_property_reads) stops at the first `$referenced` read, and property_reads() collects through the same walk. Tests: an rs-dpp registration test and a contract create through the ABCI pipeline for the moderated total; a ballot entry whose permanent reference, read by no rule, has `where` ordering its ballot's end after `$updatedAt`, replaced in later blocks until the ballot ends (40127), so the every-replace recheck and the replace's block time are both covered. ReferenceTestSetup gains replace_at for a block of the test's choosing. Co-Authored-By: Claude Opus 5.5 --- .../contract-keywords/property-constraints.md | 4 +- .../class_methods/try_from_schema/mod.rs | 37 ++++-- .../v3/referenced_rule_tests.rs | 76 +++++++++++ .../document_type/property_constraints/mod.rs | 125 +++++++++++------- .../tests/document/reference_expression.rs | 1 + .../tests/document/reference_test_setup.rs | 41 +++++- .../batch/tests/document/referenced_rules.rs | 39 +++++- .../data_contract_create/mod.rs | 69 +++++++++- ...ed-rules-registration-moderated-total.json | 98 ++++++++++++++ ...-validation-contract-referenced-rules.json | 33 +++++ .../rs-platform-version/src/version/v14.rs | 3 +- 11 files changed, 455 insertions(+), 71 deletions(-) create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-moderated-total.json diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index 35be3e295d9..52a9b3545d3 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -259,7 +259,7 @@ A bid meets its auction's reserve price, a review names an order that was delive **When it is judged.** A rule reading a referenced document is judged once the type's references hold, with the documents their checks read, after the type's other rules; a missing target refuses the write with `ReferencedEntityNotFoundError` (40120) before it. A broken rule is `DocumentPropertyConstraintViolatedError` (10422), as any rule. Every replace judges every rule, so a reference a rule reads through is [checked again on every replace](refers-to.md#on-replace), its `where` included, and the rule reads the document as it is at the replace: a `permanentDocument` target replaced since is read with its new values. Each such check costs the read the reference's first check made, billed the same way. A kept `moderatedDocument` reference whose document a moderator removed is read from the removal record. -Transfers, purchases, price updates, a moderator's change of fields and a moderator's restore check no reference, and do not judge such a rule. Registration keeps that sound: on a type whose documents can be transferred or traded, a rule reading a referenced document may not read what those writes change (`$ownerId`, `$transferredAt` and its heights, a total depending on the owner, and on a tradeable type `$updatedAt` and its heights), and it may not read a field the type keeps for its [moderators to change](moderator-abilities.md#changefields). +Transfers, purchases, price updates, a moderator's change of fields and a moderator's restore check no reference, and do not judge such a rule. Registration keeps that sound: on a type whose documents can be transferred or traded, a rule reading a referenced document may not read what those writes change (`$ownerId`, `$transferredAt` and its heights, a total depending on the owner, and on a tradeable type `$updatedAt` and its heights), and it may not depend on a field the type keeps for its [moderators to change](moderator-abilities.md#changefields): neither read it nor total the type's own documents by it, as a key of a `countOf` or `sumOf` over them, since a moderator's change moves such a total. **Time against a referenced value.** A create records its block for every time and height; a replace keeps the stored creation and transfer and records its block as the update. So `$createdAt` below a referenced `endsAt` holds the create to the deadline and lets a later replace through, while `$updatedAt` holds every write to it: a vote that may be changed only while its poll is open reads `$updatedAt`. On a type whose documents are never replaced the two are the same. @@ -333,7 +333,7 @@ The parser then checks the rules against the document type (`InvalidContractStru - once every document type of the contract is parsed, every `countOf` and `sumOf` counts a type of the contract that is not index-only, and not its own type when that has a contested index, with a tree that keeps the total as set out in [Totals of other documents](#totals-of-other-documents). A unique, contested, ranked, time-range, integer-range or index-only-terminal index keeps no such total, nor does one with more properties than the filter has keys; - every key of a filter is `$ownerId` or an integer, string or identifier property of the counted type, and its value is of the same kind (`$ownerId` and `$id` are identifiers); a string constant is in the key's `enum` when it has one, and an identifier constant is base58; - every property a filter value reads is listed in `required`, with every object around it, so a write always has the value; an index-only type has no rule reading a total; -- a `referenced` reads through a top-level property of the type holding one reference to one document, as set out in [Values of a referenced document](#values-of-a-referenced-document), only in `propertyConstraints` (not `deleteConstraints`, an `immutable` condition or `retractedWhen`, whose writes check no reference), not on an index-only type, not beside what a transfer, purchase or price update of the type changes, and not beside a field the type keeps for its moderators to change; +- a `referenced` reads through a top-level property of the type holding one reference to one document, as set out in [Values of a referenced document](#values-of-a-referenced-document), only in `propertyConstraints` (not `deleteConstraints`, an `immutable` condition or `retractedWhen`, whose writes check no reference), not on an index-only type, not beside what a transfer, purchase or price update of the type changes, and not beside a field the type keeps for its moderators to change, read or deciding a total over the type's own documents; - once every document type of the contract is parsed, what a rule reads of a referenced type of the same contract is a field that type gives it: one it has, stored, of the kind the rule reads, a time or height it records, a `$creatorId` it records, a constant its `enum` lists, and through a `moderatedDocument` reference a field its removal record keeps. A referenced type of another contract is checked at registration against the stored contract, refused with `ReferencedDocumentRuleReadInvalidError` (40148). Three limits come from the protocol version 14 `SystemLimits`, and a rule over one is refused the same way: 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 203d3167bd8..b04ae077fdf 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 @@ -10,9 +10,9 @@ use crate::data_contract::document_type::property::reference_where::{ }; use crate::data_contract::document_type::property_constraints::{ parse_delete_constraints, parse_property_constraints, referenced_read, AffixPosition, - AggregateBinding, AggregateKind, ElementKind, EqualityKind, PropertyConstraint, PropertyRead, - SystemChange, SystemProperty, REFERENCED_DOCUMENT_PREFIX, REFERENCED_IDENTIFIER_FIELDS, - STORED_DOCUMENT_PREFIX, + AggregateBinding, AggregateKind, AggregateRead, ElementKind, EqualityKind, PropertyConstraint, + PropertyRead, SystemChange, SystemProperty, REFERENCED_DOCUMENT_PREFIX, + REFERENCED_IDENTIFIER_FIELDS, STORED_DOCUMENT_PREFIX, }; use crate::data_contract::document_type::reference_lookup::{ clearable_once_its_document_is_deleted, owner_can_change, @@ -3572,8 +3572,10 @@ pub(super) fn validate_property_constraint_reads( /// read what a transfer or a purchase changes, nor, on a type whose prices /// can be set, what a price update changes: those writes check no reference, /// so they could not judge it; -/// * it reads no field the type keeps for its moderators to change, which -/// their change judges without the references either. +/// * it depends on no field the type keeps for its moderators to change, +/// neither reading it nor totalling the type's own documents by it (a key +/// of a `countOf` or `sumOf`), which their change judges without the +/// references either. /// /// What it reads of the referenced type is checked once that type is in hand /// ([`referenced_reads_error`]). Registration only, like the rest of a rule's @@ -3625,14 +3627,25 @@ pub(super) fn validate_referencing_rules( .to_string(), )); } - if let Some(field) = constraint.property_paths().into_iter().find(|path| { - !path.starts_with(REFERENCED_DOCUMENT_PREFIX) - && is_path_listed(document_type.moderator_changeable_fields(), path) - }) { + // A moderator's change of a key of a total over the type's own + // documents moves the total, the rule reading the key itself or not + let moderated_field = constraint + .property_paths() + .into_iter() + .filter(|path| !path.starts_with(REFERENCED_DOCUMENT_PREFIX)) + .chain( + constraint + .aggregate_reads() + .into_iter() + .flat_map(AggregateRead::own_type_keys), + ) + .find(|path| is_path_listed(document_type.moderator_changeable_fields(), path)); + if let Some(field) = moderated_field { return Err(error(format!( - "reads a document a reference points at and \"{field}\", which the type keeps \ - for its moderators to change: their change checks no reference, so it could \ - not judge the rule" + "reads a document a reference points at and depends on \"{field}\" (a property \ + it reads, or a key of a total over the type's own documents), which the type \ + keeps for its moderators to change: their change checks no reference, so it \ + could not judge the rule" ))); } } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs index e68a431789d..49597700c0d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs @@ -595,6 +595,82 @@ fn should_refuse_a_referenced_read_beside_what_a_transfer_changes() { parse(transferable).expect("the rule reads nothing a transfer changes"); } +/// An entry on a poll whose moderators approve it (`status`), the poll's +/// entries counted in all and by approval, with `rules`. +fn moderated_entry(rules: Value) -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "moderatorAbilities": { "changeFields": ["status"] }, + "properties": { + "pollId": identifier(0, Some(poll_reference())), + "status": { "type": "string", "enum": ["approved"], "maxLength": 10, "position": 1 }, + }, + "required": ["pollId", "$createdAt"], + "indices": [ + { + "name": "byPollStatus", + "properties": [{ "pollId": "asc" }, { "status": "asc" }], + "countable": "countable" + }, + { + "name": "byPoll", + "properties": [{ "pollId": "asc" }], + "countable": "countable" + } + ], + "propertyConstraints": rules, + "additionalProperties": false, + }) +} + +fn parse_entry(rules: Value) -> Result, ProtocolError> { + parse_types( + vec![("poll", poll()), ("entry", moderated_entry(rules))], + true, + ) +} + +/// A moderator's change judges no rule reading a referenced document, so such +/// a rule may not depend on a field moderators change: neither read it nor +/// total the type's own documents by it. Approving an entry moves the count of +/// the poll's approved entries, though the rule does not read `status` itself. +#[test] +fn should_refuse_a_referenced_read_beside_a_total_moderators_move() { + let approved_cap = platform_value!({ + "cap": { + "lessThanOrEqual": [ + { "countOf": ["entry", { "pollId": "pollId", "status": { "const": "approved" } }] }, + { "referenced": ["pollId", "endsAt"] } + ] + } + }); + let reads_status = platform_value!({ + "cap": { + "anyOf": [ + { "absent": "status" }, + { "lessThan": ["$createdAt", { "referenced": ["pollId", "endsAt"] }] } + ] + } + }); + for rules in [approved_cap, reads_status] { + expect_structure_error(parse_entry(rules), "depends on \"status\""); + } + + // A moderator's approval leaves the count of all the poll's entries as it + // was, so a rule reading it stands + parse_entry(platform_value!({ + "cap": { + "lessThanOrEqual": [ + { "countOf": ["entry", { "pollId": "pollId" }] }, + { "referenced": ["pollId", "endsAt"] } + ] + } + })) + .expect("no total the rule reads moves with a moderator's change"); +} + #[test] fn should_refuse_a_referenced_read_through_a_find_by_function() { let commitment = platform_value!({ 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 f76c72ea681..beaf72fab81 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 @@ -116,6 +116,7 @@ use serde::{Serialize, Serializer}; use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; +use std::ops::ControlFlow; /// The operand key naming a property together with the value it takes when /// the document leaves it out: `{ "ifAbsent": ["quantity", 1] }`. @@ -666,6 +667,19 @@ impl AggregateRead { .any(|binding| *binding == AggregateBinding::Owner) || (self.of_own_type && self.filter.contains_key(OWNER_ID)) } + + /// The filter's keys when the total is over the type declaring the rule: + /// a write changing one of them on a document decides whether that + /// document counts, moving the total without the rule reading the property + /// itself. None for another type's documents, which a write of this type + /// leaves alone. The property a `sumOf` adds up moves the total too, but + /// a summable index makes it required, so it is written with the document. + pub fn own_type_keys(&self) -> impl Iterator + '_ { + self.of_own_type + .then(|| self.filter.keys().map(String::as_str)) + .into_iter() + .flatten() + } } /// An integer expression, one side of a rule or an operand inside one. @@ -903,12 +917,15 @@ impl ConstraintExpression { } } - /// Appends the properties the expression reads, each by its value or its - /// size, to `reads`, in the order it reads them. - fn collect_property_reads<'a>(&'a self, reads: &mut Vec<(&'a str, PropertyRead)>) { + /// Hands `visit` the properties the expression reads, each by its value or + /// its size, in the order it reads them, stopping at the first `Break`. + fn visit_property_reads<'a>( + &'a self, + visit: &mut dyn FnMut(&'a str, PropertyRead) -> ControlFlow<()>, + ) -> ControlFlow<()> { match self { ConstraintExpression::Value(_) | ConstraintExpression::System(_) => {} - ConstraintExpression::Property { path, .. } => reads.push((path, PropertyRead::Value)), + ConstraintExpression::Property { path, .. } => visit(path, PropertyRead::Value)?, // The properties of the document being written its filter reads ConstraintExpression::Aggregate(read) => { for binding in read.filter.values() { @@ -920,40 +937,41 @@ impl ConstraintExpression { Some(EqualityKind::Text) => PropertyRead::Text, Some(EqualityKind::Identifier) => PropertyRead::Identifier, }; - reads.push((path, read)); + visit(path, read)?; } } } - ConstraintExpression::Size { measure, path } => reads.push((path, measure.read())), - ConstraintExpression::CountPresent(paths) => reads.extend( - paths - .iter() - .map(|path| (path.as_str(), PropertyRead::Presence)), - ), + ConstraintExpression::Size { measure, path } => visit(path, measure.read())?, + ConstraintExpression::CountPresent(paths) => { + for path in paths { + visit(path, PropertyRead::Presence)?; + } + } // Byte `index` is there only in an array of `index + 1` bytes or more - ConstraintExpression::ByteAt { path, index } => reads.push(( + ConstraintExpression::ByteAt { path, index } => visit( path, PropertyRead::Bytes { extent: usize::from(*index) + 1, }, - )), + )?, ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) | ConstraintExpression::Min(operands) | ConstraintExpression::Max(operands) => { for operand in operands { - operand.collect_property_reads(reads); + operand.visit_property_reads(visit)?; } } - ConstraintExpression::Abs(operand) => operand.collect_property_reads(reads), + ConstraintExpression::Abs(operand) => operand.visit_property_reads(visit)?, ConstraintExpression::Subtract(left, right) | ConstraintExpression::Divide(left, right) | ConstraintExpression::Modulo(left, right) | ConstraintExpression::Power(left, right) => { - left.collect_property_reads(reads); - right.collect_property_reads(reads); + left.visit_property_reads(visit)?; + right.visit_property_reads(visit)?; } } + ControlFlow::Continue(()) } /// Appends the system properties the expression reads to `reads`, in the @@ -1636,7 +1654,10 @@ impl PropertyConstraint { /// order, a property read twice listed twice. pub fn property_reads(&self) -> Vec<(&str, PropertyRead)> { let mut reads = Vec::new(); - self.collect_property_reads(&mut reads); + let _ = self.visit_property_reads(&mut |path, read| { + reads.push((path, read)); + ControlFlow::Continue(()) + }); reads } @@ -1653,11 +1674,17 @@ impl PropertyConstraint { } /// Whether the rule reads a value of a document one of the type's - /// references points at (`{ "referenced": [...] }`). + /// references points at (`{ "referenced": [...] }`), the walk stopping at + /// the first such read rather than collecting every one. pub fn reads_referenced(&self) -> bool { - self.property_reads() - .into_iter() - .any(|(path, _)| path.starts_with(REFERENCED_DOCUMENT_PREFIX)) + self.visit_property_reads(&mut |path, _| { + if path.starts_with(REFERENCED_DOCUMENT_PREFIX) { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }) + .is_break() } /// Whether the rule compares the document's owner, `$ownerId`, or reads an @@ -2152,39 +2179,44 @@ impl PropertyConstraint { None } - fn collect_property_reads<'a>(&'a self, reads: &mut Vec<(&'a str, PropertyRead)>) { + /// Hands `visit` the properties the rule reads, each with how it reads + /// it, in declared order, stopping at the first `Break`. + fn visit_property_reads<'a>( + &'a self, + visit: &mut dyn FnMut(&'a str, PropertyRead) -> ControlFlow<()>, + ) -> ControlFlow<()> { match self { PropertyConstraint::Compare { left, right, .. } => { - left.collect_property_reads(reads); - right.collect_property_reads(reads); + left.visit_property_reads(visit)?; + right.visit_property_reads(visit)?; } - PropertyConstraint::In { operand, .. } => operand.collect_property_reads(reads), + PropertyConstraint::In { operand, .. } => operand.visit_property_reads(visit)?, PropertyConstraint::TextCompare { property, .. } | PropertyConstraint::TextIn { property, .. } => { - reads.push((&property.path, PropertyRead::Text)) + visit(&property.path, PropertyRead::Text)? } PropertyConstraint::TextCompareProperties { left, right, .. } => { - reads.push((&left.path, PropertyRead::Text)); - reads.push((&right.path, PropertyRead::Text)); + visit(&left.path, PropertyRead::Text)?; + visit(&right.path, PropertyRead::Text)?; } // `$ownerId` is the document's owner, no property of it PropertyConstraint::IdentifierCompare { path, .. } | PropertyConstraint::IdentifierIn { path, .. } => { if path != OWNER_ID { - reads.push((path, PropertyRead::Identifier)) + visit(path, PropertyRead::Identifier)? } } PropertyConstraint::IdentifierCompareProperties { left, right, .. } => { for path in [left, right] { if path != OWNER_ID { - reads.push((path, PropertyRead::Identifier)); + visit(path, PropertyRead::Identifier)?; } } } PropertyConstraint::TextAffix { text, affix, .. } => { for side in [text, affix] { if let TextOperand::Property(property) = side { - reads.push((&property.path, PropertyRead::Text)); + visit(&property.path, PropertyRead::Text)?; } } } @@ -2196,45 +2228,45 @@ impl PropertyConstraint { BytesOperand::Constant(constant) => constant.len(), BytesOperand::Property(_) => 0, }; - reads.push((path, PropertyRead::Bytes { extent })); + visit(path, PropertyRead::Bytes { extent })?; } if let BytesOperand::Property(path) = affix { - reads.push((path, PropertyRead::Bytes { extent: 0 })); + visit(path, PropertyRead::Bytes { extent: 0 })?; } } PropertyConstraint::Contains { array, needle } => match needle { ContainsNeedle::Integer(expression) => { - reads.push((array, PropertyRead::Elements(ElementKind::Integer))); - expression.collect_property_reads(reads); + visit(array, PropertyRead::Elements(ElementKind::Integer))?; + expression.visit_property_reads(visit)?; } ContainsNeedle::TextConstant(_) => { - reads.push((array, PropertyRead::Elements(ElementKind::Text))) + visit(array, PropertyRead::Elements(ElementKind::Text))? } ContainsNeedle::TextProperty(property) => { - reads.push((array, PropertyRead::Elements(ElementKind::Text))); - reads.push((&property.path, PropertyRead::Text)); + visit(array, PropertyRead::Elements(ElementKind::Text))?; + visit(&property.path, PropertyRead::Text)?; } ContainsNeedle::IdentifierConstant(_) => { - reads.push((array, PropertyRead::Elements(ElementKind::Identifier))) + visit(array, PropertyRead::Elements(ElementKind::Identifier))? } ContainsNeedle::IdentifierProperty(path) => { - reads.push((array, PropertyRead::Elements(ElementKind::Identifier))); + visit(array, PropertyRead::Elements(ElementKind::Identifier))?; // `$ownerId` is the document's owner, no property of it if path != OWNER_ID { - reads.push((path, PropertyRead::Identifier)); + visit(path, PropertyRead::Identifier)?; } } }, PropertyConstraint::Present(path) | PropertyConstraint::Absent(path) => { - reads.push((path, PropertyRead::Presence)) + visit(path, PropertyRead::Presence)? } PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { for condition in conditions { - condition.collect_property_reads(reads); + condition.visit_property_reads(visit)?; } } PropertyConstraint::Not(condition) | PropertyConstraint::NotIn(condition) => { - condition.collect_property_reads(reads) + condition.visit_property_reads(visit)? } PropertyConstraint::IfThen { condition, @@ -2245,10 +2277,11 @@ impl PropertyConstraint { .into_iter() .flatten() { - part.collect_property_reads(reads); + part.visit_property_reads(visit)?; } } } + ControlFlow::Continue(()) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/reference_expression.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/reference_expression.rs index 82c4352c31e..893793db3fd 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/reference_expression.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/reference_expression.rs @@ -211,6 +211,7 @@ mod reference_expression_tests { &self.founder.key, nonce, &self.founder.signer, + &BlockInfo::default(), platform_version, ) .await 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 index 58f8d43fd23..2beafbf00bf 100644 --- 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 @@ -49,6 +49,23 @@ pub(super) fn process_and_commit( platform_state: &PlatformState, transition: &StateTransition, platform_version: &PlatformVersion, +) -> StateTransitionsProcessingResult { + process_and_commit_at( + platform, + platform_state, + transition, + &BlockInfo::default(), + platform_version, + ) +} + +/// [`process_and_commit`] in the block `block_info`. +pub(super) fn process_and_commit_at( + platform: &TempPlatform, + platform_state: &PlatformState, + transition: &StateTransition, + block_info: &BlockInfo, + platform_version: &PlatformVersion, ) -> StateTransitionsProcessingResult { let serialized = transition .serialize_to_bytes() @@ -59,7 +76,7 @@ pub(super) fn process_and_commit( .process_raw_state_transitions( &[serialized], platform_state, - &BlockInfo::default(), + block_info, &transaction, platform_version, false, @@ -140,6 +157,7 @@ pub(super) async fn create_document>( (document, result) } +/// Replaces `document` in the block `block_info`. #[allow(clippy::too_many_arguments)] pub(super) async fn replace_document>( platform: &TempPlatform, @@ -150,6 +168,7 @@ pub(super) async fn replace_document>( key: &IdentityPublicKey, nonce: u64, signer: &S, + block_info: &BlockInfo, platform_version: &PlatformVersion, ) -> StateTransitionsProcessingResult { let document_type = contract @@ -168,7 +187,13 @@ pub(super) async fn replace_document>( ) .await .expect("expected the replace transition"); - process_and_commit(platform, platform_state, &replace, platform_version) + process_and_commit_at( + platform, + platform_state, + &replace, + block_info, + platform_version, + ) } #[allow(clippy::too_many_arguments)] @@ -274,6 +299,17 @@ impl ReferenceTestSetup { &mut self, type_name: &str, document: &mut Document, + ) -> StateTransitionsProcessingResult { + self.replace_at(type_name, document, &BlockInfo::default()) + .await + } + + /// [`Self::replace`] in the block `block_info`. + pub(super) async fn replace_at( + &mut self, + type_name: &str, + document: &mut Document, + block_info: &BlockInfo, ) -> StateTransitionsProcessingResult { document.increment_revision().expect("revision increments"); let nonce = self.next_nonce(); @@ -286,6 +322,7 @@ impl ReferenceTestSetup { &self.key, nonce, &self.signer, + block_info, PlatformVersion::latest(), ) .await; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/referenced_rules.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/referenced_rules.rs index ad12a857b8e..7aea550b926 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/referenced_rules.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/referenced_rules.rs @@ -6,7 +6,7 @@ //! again on every replace, so the rule sees the document as it is then. A //! `where` entry with a literal, `notEqual` or an ordering is checked against //! the document found as an equality is. Every transition here is processed in -//! a block whose time and heights are 0. +//! a block whose time and heights are 0, unless a test names a later time. use super::*; @@ -262,6 +262,43 @@ mod referenced_rules_tests { assert_where_refused(&result, "{ \"notEqual\": { \"const\": true } }"); } + fn block_at(time_ms: u64) -> BlockInfo { + BlockInfo { + time_ms, + ..Default::default() + } + } + + /// An ordering bounded by `$updatedAt` is checked again on every replace, + /// against the time of the replace's block, though the reference is a + /// permanentDocument one no rule reads through and the replace leaves it as + /// it was: an entry made before its ballot ends may be edited until then, + /// and no longer once it has ended. + #[tokio::test] + async fn should_check_an_ordering_by_the_time_of_each_replace() { + let mut setup = ReferenceTestSetup::new(CONTRACT_PATH, 6105); + let (ballot, result) = setup + .create("ballot", &[("endsAt", Value::U64(5_000))]) + .await; + assert_successful(&result, "the ballot must be created"); + let (mut entry, result) = setup + .create("ballotEntry", &[("ballotId", id_value(&ballot))]) + .await; + assert_successful(&result, "an entry made before the ballot ends holds"); + + entry.set("note", Value::Text("before the end".to_string())); + let result = setup + .replace_at("ballotEntry", &mut entry, &block_at(4_999)) + .await; + assert_successful(&result, "the ballot ends after this replace"); + + entry.set("note", Value::Text("at the end".to_string())); + let result = setup + .replace_at("ballotEntry", &mut entry, &block_at(5_000)) + .await; + assert_where_refused(&result, "{ \"greaterThan\": \"$updatedAt\" }"); + } + /// A reference found by `findBy` reads the document the key finds, for the /// rules too. #[tokio::test] 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 f5e8446524e..3592be9d0cb 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 @@ -5842,8 +5842,13 @@ mod tests { mod permanent_document_reference_declarations { use super::*; use dpp::consensus::state::state_error::StateError; + use dpp::data_contract::config::moderation::{ + ContractModerationConfig, ContractModerators, + }; + use dpp::data_contract::conversion::json::DataContractJsonConversionMethodsV0; use dpp::data_contract::errors::DataContractError; use dpp::platform_value::string_encoding::Encoding; + use dpp::tests::json_document::json_document_to_json_value; use drive::util::test_helpers::setup_contract; const FOREIGN_CONTRACT_PATH: &str = @@ -5861,6 +5866,21 @@ mod tests { async fn run_contract_create_with_foreign( fixture_path: &str, foreign_contract_path: &str, + ) -> StateTransitionExecutionResult { + run_contract_create_with_foreign_and_config( + fixture_path, + foreign_contract_path, + DataContractConfig::default_for_version(PlatformVersion::latest()).unwrap(), + ) + .await + } + + /// [`run_contract_create_with_foreign`] with the contract created under + /// `config`. + async fn run_contract_create_with_foreign_and_config( + fixture_path: &str, + foreign_contract_path: &str, + config: DataContractConfig, ) -> StateTransitionExecutionResult { let platform_version = PlatformVersion::latest(); let mut platform = TestPlatformBuilder::new() @@ -5881,18 +5901,19 @@ mod tests { None, ); - let mut data_contract = json_document_to_contract_with_ids( - fixture_path, - None, - None, + // The config goes in before the parse, since a type may use what it + // declares (moderation) + let mut contract_json = json_document_to_json_value(fixture_path) + .expect("expected to read the fixture contract"); + contract_json["config"] = + serde_json::to_value(&config).expect("expected the config to serialize"); + let data_contract = DataContract::from_json( + contract_json, false, //no need to validate the data contracts in tests for drive platform_version, ) .expect("expected to get json based contract"); - data_contract - .set_config(DataContractConfig::default_for_version(platform_version).unwrap()); - let data_contract_create_transition = DataContractCreateTransition::new_from_data_contract( data_contract, @@ -6962,6 +6983,40 @@ mod tests { ); } + #[tokio::test] + async fn should_reject_a_rule_reading_a_referenced_document_beside_a_total_moderators_move() + { + // Moderators approve an entry, which moves the count of a poll's + // approved entries the rule bounds by the poll's capacity; their + // change judges no rule reading a referenced document, so the + // contract is refused + let config = DataContractConfig::default_for_version(PlatformVersion::latest()) + .unwrap() + .with_moderation(Some(ContractModerationConfig { + banlist: false, + suspensions: false, + moderators: ContractModerators::ContractOwner, + warnings: false, + })); + let result = run_contract_create_with_foreign_and_config( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-moderated-total.json", + REFERENCED_RULES_CONTRACT_PATH, + config, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(message) + )), + .. + } if message.contains("rule \"cap\"") + && message.contains("depends on \"status\"") + ); + } + #[tokio::test] async fn should_reject_a_where_literal_of_another_kind_than_the_referenced_property() { let result = run_contract_create_with_foreign( diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-moderated-total.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-moderated-total.json new file mode 100644 index 00000000000..3d3853d5c56 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-moderated-total.json @@ -0,0 +1,98 @@ +{ + "$formatVersion": "1", + "id": "FmNtTTiJ9X8sJdKvkpK58yzUAx78PW61bCpwW8dZnpMC", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "room": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "properties": { + "capacity": { + "type": "integer", + "minimum": 0, + "maximum": 1000, + "position": 0 + } + }, + "required": [ + "capacity" + ], + "additionalProperties": false + }, + "entry": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "moderatorAbilities": { + "changeFields": [ + "status" + ] + }, + "properties": { + "roomId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "room" + }, + "position": 0 + }, + "status": { + "type": "string", + "enum": [ + "approved" + ], + "maxLength": 10, + "position": 1 + } + }, + "required": [ + "roomId" + ], + "indices": [ + { + "name": "byRoomStatus", + "properties": [ + { + "roomId": "asc" + }, + { + "status": "asc" + } + ], + "countable": "countable" + } + ], + "propertyConstraints": { + "cap": { + "lessThanOrEqual": [ + { + "countOf": [ + "entry", + { + "roomId": "roomId", + "status": { + "const": "approved" + } + } + ] + }, + { + "referenced": [ + "roomId", + "capacity" + ] + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules.json index e305bea957f..d26edb477c8 100644 --- a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules.json +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules.json @@ -215,6 +215,39 @@ "seatExists": { "lessThan": ["seat", { "referenced": ["eventCode", "capacity"] }] } }, "additionalProperties": false + }, + "ballot": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "properties": { + "endsAt": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, "position": 0 } + }, + "required": ["endsAt", "$createdAt"], + "additionalProperties": false + }, + "ballotEntry": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "ballotId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "ballot", + "where": { "endsAt": { "greaterThan": "$updatedAt" } } + }, + "position": 0 + }, + "note": { "type": "string", "maxLength": 50, "position": 1 } + }, + "required": ["ballotId", "$createdAt", "$updatedAt"], + "additionalProperties": false } } } diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 72e46863910..cef90be8303 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1177,7 +1177,8 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// through anything but a top-level stored property holding one reference /// to one document by id or by a `findBy` without a function, beside what /// a transfer, a purchase or a price update of a type that has them -/// changes, and beside a field moderators change (10231); and +/// changes, and beside a field moderators change, read or deciding a +/// total over the type's own documents (10231); and /// `create_document_types_from_document_schemas` 1, in place, checks what /// it reads of a type of the same contract (10231: the field exists, is /// stored and of the kind read, a time or `$creatorId` the type records, a From db44f29f19f7bef45cb7319215a97f0a7475582f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 11 Oct 2026 18:00:05 +0300 Subject: [PATCH 4/8] fix(platform): refuse a referenced rule beside a total a restore moves A moderator's restore checks no reference and judges no rule reading a referenced document, yet it brings the document back into every total over its type. On a type whose moderators' deletions keep records, a rule capping a room's entries by the room's capacity could be passed by deleting an entry, creating another and restoring the first. Under the no-reference-reads restore policy, registration now refuses a rule reading a referenced document beside a countOf or sumOf over the type's own documents there; deletions keeping no record are final and keep it. A `where` literal is no longer turned into an owned value to compare it: an absent referenced value equals no literal and reads none, and a string literal is compared in place. wasm-dpp2 writes the keys a schema names in a reference's `where` and `findBy` (properties of the referenced type) as own properties, so one named `__proto__` is kept instead of setting the listing's prototype. Tests: the restore refusal in rs-dpp and through a contract create; a moderated listing's offer checking its `where` entries against the listing's removal record (an unchanged bound left alone, a changed bound on a kept field refused 40127, on an unkept one 40145, and the `$updatedAt` bound at the replace's own time); a wasm-dpp2 spec for a `__proto__` key. Co-Authored-By: Claude Opus 5.5 --- .../contract-keywords/property-constraints.md | 6 +- .../class_methods/try_from_schema/mod.rs | 19 ++- .../v3/referenced_rule_tests.rs | 57 +++++++ .../document_type/property/reference_where.rs | 19 ++- .../tests/referenced_rules.rs | 153 +++++++++++++++++- .../data_contract_create/mod.rs | 34 ++++ ...ced-rules-registration-restored-total.json | 82 ++++++++++ .../rs-platform-version/src/version/v14.rs | 6 +- .../data_contract/document_type_reference.rs | 22 ++- .../unit/DocumentPropertyReference.spec.ts | 45 ++++++ 10 files changed, 429 insertions(+), 14 deletions(-) create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-restored-total.json diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index 52a9b3545d3..63567097798 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -51,7 +51,7 @@ - **Name order, first failure.** Rules are checked in the order of their names, and the first rule the document breaks refuses the transition with `DocumentPropertyConstraintViolatedError` (10422). The error names the document type, the rule, and why it failed (below). - **Transfer and purchase.** These change only the owner and the transfer's time and heights. Rules that read `$ownerId`, `$transferredAt…` or a total that depends on the owner are judged again, against the stored document with its new owner and transfer values; other rules are not, since nothing they read changed. A transfer or purchase that would break such a rule is refused with 10422. A rule reading a referenced document is never among them (see [Values of a referenced document](#values-of-a-referenced-document)). - **Price updates** change only the update's time and heights, so the rules that read `$updatedAt…` are judged again the same way; other rules are not. -- **Moderator restores.** Every rule of the restored type is judged on the retained document, with its original owner, times and heights, except a rule reading a referenced document: a restore checks no reference. Totals include the restored document as an insertion into the live trees; its removal record is not counted. Another write while it was deleted may make a cap, lower bound or cross-type prerequisite fail. Uniqueness is checked first. A refusal in a block charges the moderator and consumes its nonce, while the document stays deleted and its removal record stays unrestored. Mempool admission refuses the same invalid restore without persisting fees or a nonce change. Restore does not revalidate the full property schema or judge `deleteConstraints`. +- **Moderator restores.** Every rule of the restored type is judged on the retained document, with its original owner, times and heights, except a rule reading a referenced document: a restore checks no reference, and registration keeps such a rule from reading a total over the restored type's own documents. Totals include the restored document as an insertion into the live trees; its removal record is not counted. Another write while it was deleted may make a cap, lower bound or cross-type prerequisite fail. Uniqueness is checked first. A refusal in a block charges the moderator and consumes its nonce, while the document stays deleted and its removal record stays unrestored. Mempool admission refuses the same invalid restore without persisting fees or a nonce change. Restore does not revalidate the full property schema or judge `deleteConstraints`. - **Immutable properties.** The same grammar is the condition of an [`immutable`](mutability.md#immutable) entry, which freezes a property while it holds. Only such a condition may read the stored document, through `$old.`; a rule judges creates too, which have none. - **Deletes** are not judged, with one exception: a delete of an [index-only](index-only.md) document carries the row's values, which are validated like a create's, rules included. The delete carries neither the owner nor any time or height, which is why an index-only type may not have a rule reading `$ownerId` or a system time or height. What may be deleted is the job of [`deleteConstraints`](deletion.md#deleteconstraints), rules in this grammar the stored document must meet for its owner to delete it. - **State and fees.** A rule reads the document, its owner and its times and heights, and a `countOf` or `sumOf` reads a total from state. Each such total is a state read billed with the write; nothing else a rule does adds a fee, and it changes nothing stored. A value of a referenced document comes from the read the reference's check makes. The limits below bound its cost. SDKs that validate a document before sending it apply the same rules, except those reading a total or a referenced document, which they cannot read. @@ -259,7 +259,7 @@ A bid meets its auction's reserve price, a review names an order that was delive **When it is judged.** A rule reading a referenced document is judged once the type's references hold, with the documents their checks read, after the type's other rules; a missing target refuses the write with `ReferencedEntityNotFoundError` (40120) before it. A broken rule is `DocumentPropertyConstraintViolatedError` (10422), as any rule. Every replace judges every rule, so a reference a rule reads through is [checked again on every replace](refers-to.md#on-replace), its `where` included, and the rule reads the document as it is at the replace: a `permanentDocument` target replaced since is read with its new values. Each such check costs the read the reference's first check made, billed the same way. A kept `moderatedDocument` reference whose document a moderator removed is read from the removal record. -Transfers, purchases, price updates, a moderator's change of fields and a moderator's restore check no reference, and do not judge such a rule. Registration keeps that sound: on a type whose documents can be transferred or traded, a rule reading a referenced document may not read what those writes change (`$ownerId`, `$transferredAt` and its heights, a total depending on the owner, and on a tradeable type `$updatedAt` and its heights), and it may not depend on a field the type keeps for its [moderators to change](moderator-abilities.md#changefields): neither read it nor total the type's own documents by it, as a key of a `countOf` or `sumOf` over them, since a moderator's change moves such a total. +Transfers, purchases, price updates, a moderator's change of fields and a moderator's restore check no reference, and do not judge such a rule. Registration keeps that sound: on a type whose documents can be transferred or traded, a rule reading a referenced document may not read what those writes change (`$ownerId`, `$transferredAt` and its heights, a total depending on the owner, and on a tradeable type `$updatedAt` and its heights), and it may not depend on a field the type keeps for its [moderators to change](moderator-abilities.md#changefields): neither read it nor total the type's own documents by it, as a key of a `countOf` or `sumOf` over them, since a moderator's change moves such a total. On a type whose moderators' deletions keep records it may read no `countOf` or `sumOf` over the type's own documents at all: a restore brings a document back into those totals. **Time against a referenced value.** A create records its block for every time and height; a replace keeps the stored creation and transfer and records its block as the update. So `$createdAt` below a referenced `endsAt` holds the create to the deadline and lets a later replace through, while `$updatedAt` holds every write to it: a vote that may be changed only while its poll is open reads `$updatedAt`. On a type whose documents are never replaced the two are the same. @@ -333,7 +333,7 @@ The parser then checks the rules against the document type (`InvalidContractStru - once every document type of the contract is parsed, every `countOf` and `sumOf` counts a type of the contract that is not index-only, and not its own type when that has a contested index, with a tree that keeps the total as set out in [Totals of other documents](#totals-of-other-documents). A unique, contested, ranked, time-range, integer-range or index-only-terminal index keeps no such total, nor does one with more properties than the filter has keys; - every key of a filter is `$ownerId` or an integer, string or identifier property of the counted type, and its value is of the same kind (`$ownerId` and `$id` are identifiers); a string constant is in the key's `enum` when it has one, and an identifier constant is base58; - every property a filter value reads is listed in `required`, with every object around it, so a write always has the value; an index-only type has no rule reading a total; -- a `referenced` reads through a top-level property of the type holding one reference to one document, as set out in [Values of a referenced document](#values-of-a-referenced-document), only in `propertyConstraints` (not `deleteConstraints`, an `immutable` condition or `retractedWhen`, whose writes check no reference), not on an index-only type, not beside what a transfer, purchase or price update of the type changes, and not beside a field the type keeps for its moderators to change, read or deciding a total over the type's own documents; +- a `referenced` reads through a top-level property of the type holding one reference to one document, as set out in [Values of a referenced document](#values-of-a-referenced-document), only in `propertyConstraints` (not `deleteConstraints`, an `immutable` condition or `retractedWhen`, whose writes check no reference), not on an index-only type, not beside what a transfer, purchase or price update of the type changes, not beside a field the type keeps for its moderators to change, read or deciding a total over the type's own documents, and, on a type whose moderators' deletions keep records, not beside a total over the type's own documents; - once every document type of the contract is parsed, what a rule reads of a referenced type of the same contract is a field that type gives it: one it has, stored, of the kind the rule reads, a time or height it records, a `$creatorId` it records, a constant its `enum` lists, and through a `moderatedDocument` reference a field its removal record keeps. A referenced type of another contract is checked at registration against the stored contract, refused with `ReferencedDocumentRuleReadInvalidError` (40148). Three limits come from the protocol version 14 `SystemLimits`, and a rule over one is refused the same way: 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 b04ae077fdf..dcf11282b83 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 @@ -3575,7 +3575,11 @@ pub(super) fn validate_property_constraint_reads( /// * it depends on no field the type keeps for its moderators to change, /// neither reading it nor totalling the type's own documents by it (a key /// of a `countOf` or `sumOf`), which their change judges without the -/// references either. +/// references either; +/// * on a type whose moderators' deletions keep records, it reads no total +/// over the type's own documents: a moderator's restore brings a document +/// back into those totals and checks no reference, so it could not judge +/// the rule. /// /// What it reads of the referenced type is checked once that type is in hand /// ([`referenced_reads_error`]). Registration only, like the rest of a rule's @@ -3586,6 +3590,7 @@ pub(super) fn validate_referencing_rules( ) -> Result<(), DataContractError> { let can_change_owner = owner_can_change(document_type); let can_change_price = document_type.trade_mode() != TradeMode::None; + let can_restore = document_type.moderator_deletions_keep_records(); for (rule, constraint) in document_type.property_constraints() { let reads = constraint.referenced_reads(); if reads.is_empty() { @@ -3648,6 +3653,18 @@ pub(super) fn validate_referencing_rules( could not judge the rule" ))); } + if let Some(total) = constraint + .aggregate_reads() + .into_iter() + .find(|read| can_restore && read.of_own_type) + { + return Err(error(format!( + "reads a document a reference points at and a {} of the type's own documents, \ + which a moderator's restore brings a document back into: a restore checks no \ + reference, so it could not judge the rule", + total.wire_name() + ))); + } } Ok(()) } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs index 49597700c0d..d67b9b92431 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs @@ -671,6 +671,63 @@ fn should_refuse_a_referenced_read_beside_a_total_moderators_move() { .expect("no total the rule reads moves with a moderator's change"); } +/// An entry on a poll moderators take down, its removal keeping a record a +/// restore brings it back from when `keeps_record`, the poll's entries +/// counted, with `rules`. +fn removable_entry(keeps_record: bool, rules: Value) -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "moderatorAbilities": { "delete": true, "deleteKeepsRecord": keeps_record }, + "properties": { + "pollId": identifier(0, Some(poll_reference())), + }, + "required": ["pollId", "$createdAt"], + "indices": [ + { + "name": "byPoll", + "properties": [{ "pollId": "asc" }], + "countable": "countable" + } + ], + "propertyConstraints": rules, + "additionalProperties": false, + }) +} + +/// A moderator's restore judges no rule reading a referenced document, yet +/// brings the document back into every total over its type: a rule capping +/// the poll's entries by a value of the poll would let a restore past the cap. +#[test] +fn should_refuse_a_referenced_read_beside_a_total_a_restore_moves() { + let cap = platform_value!({ + "cap": { + "lessThanOrEqual": [ + { "countOf": ["entry", { "pollId": "pollId" }] }, + { "referenced": ["pollId", "endsAt"] } + ] + } + }); + expect_structure_error( + parse_types( + vec![ + ("poll", poll()), + ("entry", removable_entry(true, cap.clone())), + ], + true, + ), + "a moderator's restore brings a document back into", + ); + + // Deletions keeping no record are final, so no restore moves the count + parse_types( + vec![("poll", poll()), ("entry", removable_entry(false, cap))], + true, + ) + .expect("no restore moves a total the rule reads"); +} + #[test] fn should_refuse_a_referenced_read_through_a_find_by_function() { let commitment = platform_value!({ diff --git a/packages/rs-dpp/src/data_contract/document_type/property/reference_where.rs b/packages/rs-dpp/src/data_contract/document_type/property/reference_where.rs index f7108e89522..344ee6a2d17 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/reference_where.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/reference_where.rs @@ -178,6 +178,20 @@ impl WhereLiteral { _ => None, } } + + /// Whether `value`, a referenced property of `kind`, equals this literal + /// as `Value::same_scalar_data` compares it with [`Self::value_for`]'s + /// value, a string compared in place rather than copied. + pub fn equals(&self, value: &Value, kind: WhereLiteralKind) -> bool { + match (self, kind) { + (WhereLiteral::Text(text), WhereLiteralKind::Text) => { + matches!(value, Value::Text(held) if held == text) + } + _ => self + .value_for(kind) + .is_some_and(|literal| value.same_scalar_data(&literal)), + } + } } impl fmt::Display for WhereLiteral { @@ -369,10 +383,11 @@ impl WhereCondition { kind: Option, bound: Option<&Value>, ) -> bool { + // An absent value equals no literal, which is then not read at all let equals_literal = |literal: &WhereLiteral| { matches!( - (referenced, kind.and_then(|kind| literal.value_for(kind))), - (Some(referenced), Some(literal)) if referenced.same_scalar_data(&literal) + (referenced, kind), + (Some(referenced), Some(kind)) if literal.equals(referenced, kind) ) }; match self { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/referenced_rules.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/referenced_rules.rs index 4c2bdefa4d3..e22e7043d4a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/referenced_rules.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/referenced_rules.rs @@ -3,7 +3,9 @@ //! post a reply names, the reply's rules read the post's removal record, which //! keeps its owner, its id and the fields its type lists under //! `deleteKeepsFields`, the only ones such a rule may read. A moderator's -//! restore checks no reference, and judges no rule reading one. +//! restore checks no reference, and judges no rule reading one. The `where` +//! entries that are not an equality are checked against a removal record as +//! the equalities are. use super::*; use dpp::consensus::basic::document::PropertyConstraintViolation; @@ -12,6 +14,8 @@ use dpp::consensus::basic::BasicError; const REPLY: &str = "reply"; const TOPIC: &str = "topic"; const THREAD_POST: &str = "threadPost"; +const LISTING: &str = "listing"; +const OFFER: &str = "offer"; fn identifier_property(position: u64, refers_to: Value) -> Value { platform_value!({ @@ -98,6 +102,53 @@ fn thread_post_schema() -> Value { }) } +/// A listing moderators take down, each removal keeping its price and its +/// end, not its stock +fn listing_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "moderatorAbilities": { "delete": true, "deleteKeepsFields": ["price", "endsAt"] }, + "properties": { + "price": { "type": "integer", "minimum": 0, "maximum": 1000000000, "position": 0 }, + "stock": { "type": "integer", "minimum": 0, "maximum": 1000, "position": 1 }, + "endsAt": { "type": "integer", "minimum": 0, "maximum": 9007199254740991u64, "position": 2 }, + }, + "required": ["price", "stock", "endsAt"], + "additionalProperties": false, + }) +} + +/// An offer on a listing: a bid at least its price, for no more than its +/// stock, while it has not ended +fn offer_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "canBeDeleted": false, + "properties": { + "listingId": identifier_property( + 0, + platform_value!({ + "type": "moderatedDocument", + "documentType": LISTING, + "where": { + "price": { "lessThanOrEqual": "bid" }, + "stock": { "greaterThanOrEqual": "quantity" }, + "endsAt": { "greaterThan": "$updatedAt" }, + }, + }), + ), + "bid": { "type": "integer", "minimum": 0, "maximum": 1000000000, "position": 1 }, + "quantity": { "type": "integer", "minimum": 1, "maximum": 1000, "position": 2 }, + "note": { "type": "string", "maxLength": 50, "position": 3 }, + }, + "required": ["listingId", "bid", "quantity", "$updatedAt"], + "additionalProperties": false, + }) +} + async fn setup() -> Setup { Setup::new_at_with( Some(moderators_without_lists()), @@ -107,6 +158,8 @@ async fn setup() -> Setup { add_document_type(contract, REPLY, reply_schema()); add_document_type(contract, TOPIC, topic_schema()); add_document_type(contract, THREAD_POST, thread_post_schema()); + add_document_type(contract, LISTING, listing_schema()); + add_document_type(contract, OFFER, offer_schema()); }, ) .await @@ -294,3 +347,101 @@ async fn should_restore_a_document_without_judging_a_rule_reading_a_reference() .stored_document(THREAD_POST, thread_post.id(), Some(&transaction)) .is_some()); } + +fn assert_where_refused(execution: &StateTransitionExecutionResult, condition: &str) { + assert!( + matches!( + execution, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyMismatchError(e) + ), + .. + } if e.referring_property() == condition + ), + "expected the where entry {condition} to refuse the write, got {execution:?}" + ); +} + +/// Once a moderator removes the listing an offer names, a replace of the +/// offer checks the `where` entries it asks about against the listing's +/// removal record: one bounded by a property the replace changes, against a +/// field the record keeps (the price) or refused for one it does not (the +/// stock), and one bounded by `$updatedAt` on every replace, at the time of +/// the replace. An entry bounded by a property the replace leaves alone is +/// not checked, though the record no longer holds what it reads. +#[tokio::test] +async fn should_check_where_conditions_against_a_removal_record() { + let setup = setup().await; + let transaction = setup.platform.drive.grove.start_transaction(); + let ends_at = 2 * BLOCK_TIME_MS; + let (listing, create_listing) = create_with( + &setup, + &setup.user, + LISTING, + vec![ + ("price", Value::U32(100)), + ("stock", Value::U16(5)), + ("endsAt", Value::U64(ends_at)), + ], + ) + .await; + assert_success(&setup.process(&create_listing, &transaction)); + let (mut offer, create_offer) = create_with( + &setup, + &setup.stranger, + OFFER, + vec![ + ("listingId", id_value(listing.id())), + ("bid", Value::U32(150)), + ("quantity", Value::U16(3)), + ], + ) + .await; + assert_success(&setup.process(&create_offer, &transaction)); + + let remove = setup + .moderate(&setup.moderator, delete_action(LISTING, listing.id())) + .await; + assert_success(&setup.process(&remove, &transaction)); + + // Only the end is asked about, kept and still ahead: the stock, which the + // record does not keep, is not read + offer.set("note", "still keen".into()); + let edit = replacement(&setup.stranger, &setup.contract, OFFER, &mut offer).await; + assert_success(&setup.process(&edit, &transaction)); + + // A changed bid asks about the kept price again, the quantity left alone + let mut low_bid = offer.clone(); + low_bid.set("bid", Value::U32(50)); + let refused = replacement(&setup.stranger, &setup.contract, OFFER, &mut low_bid).await; + assert_where_refused( + &setup.process(&refused, &transaction), + "{ \"lessThanOrEqual\": \"bid\" }", + ); + + // A changed quantity asks about the stock, which no record keeps + let mut more = offer.clone(); + more.set("quantity", Value::U16(4)); + let refused = replacement(&setup.stranger, &setup.contract, OFFER, &mut more).await; + let execution = setup.process(&refused, &transaction); + assert!( + matches!( + &execution, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedDocumentRemovedError(e)), + .. + } if e.referenced_property() == "stock" + ), + "expected the unkept stock to refuse the replace, got {execution:?}" + ); + + // The end is asked about on every replace, at the replace's own time + let mut late = offer.clone(); + late.set("note", "too late".into()); + let refused = replacement(&setup.stranger, &setup.contract, OFFER, &mut late).await; + assert_where_refused( + &setup.process_at(&refused, ends_at, &transaction), + "{ \"greaterThan\": \"$updatedAt\" }", + ); +} 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 3592be9d0cb..4e3cbde0580 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 @@ -7017,6 +7017,40 @@ mod tests { ); } + #[tokio::test] + async fn should_reject_a_rule_reading_a_referenced_document_beside_a_total_a_restore_moves() + { + // A moderator's restore brings an entry back into the count of its + // room's entries the rule bounds by the room's capacity, and judges + // no rule reading a referenced document, so the contract is refused + // before any restore could pass the capacity + let config = DataContractConfig::default_for_version(PlatformVersion::latest()) + .unwrap() + .with_moderation(Some(ContractModerationConfig { + banlist: false, + suspensions: false, + moderators: ContractModerators::ContractOwner, + warnings: false, + })); + let result = run_contract_create_with_foreign_and_config( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-restored-total.json", + REFERENCED_RULES_CONTRACT_PATH, + config, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(message) + )), + .. + } if message.contains("rule \"cap\"") + && message.contains("a moderator's restore brings a document back into") + ); + } + #[tokio::test] async fn should_reject_a_where_literal_of_another_kind_than_the_referenced_property() { let result = run_contract_create_with_foreign( diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-restored-total.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-restored-total.json new file mode 100644 index 00000000000..5b4c11206ea --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-restored-total.json @@ -0,0 +1,82 @@ +{ + "$formatVersion": "1", + "id": "FmNtTTiJ9X8sJdKvkpK58yzUAx78PW61bCpwW8dZnpMC", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "room": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "properties": { + "capacity": { + "type": "integer", + "minimum": 0, + "maximum": 1000, + "position": 0 + } + }, + "required": [ + "capacity" + ], + "additionalProperties": false + }, + "entry": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "moderatorAbilities": { + "delete": true + }, + "properties": { + "roomId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "room" + }, + "position": 0 + } + }, + "required": [ + "roomId" + ], + "indices": [ + { + "name": "byRoom", + "properties": [ + { + "roomId": "asc" + } + ], + "countable": "countable" + } + ], + "propertyConstraints": { + "cap": { + "lessThanOrEqual": [ + { + "countOf": [ + "entry", + { + "roomId": "roomId" + } + ] + }, + { + "referenced": [ + "roomId", + "capacity" + ] + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index cef90be8303..a1dfea32eaa 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1177,8 +1177,10 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// through anything but a top-level stored property holding one reference /// to one document by id or by a `findBy` without a function, beside what /// a transfer, a purchase or a price update of a type that has them -/// changes, and beside a field moderators change, read or deciding a -/// total over the type's own documents (10231); and +/// changes, beside a field moderators change, read or deciding a total +/// over the type's own documents, and beside any total over the type's +/// own documents where a moderator's restore can bring one back (10231); +/// and /// `create_document_types_from_document_schemas` 1, in place, checks what /// it reads of a type of the same contract (10231: the field exists, is /// stored and of the kind read, a time or `$creatorId` the type records, a 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 43cd6cc63af..dfeb814e4c0 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -14,6 +14,7 @@ use crate::data_contract::document_type_property_constraints::integer_to_js; use crate::error::{WasmDppError, WasmDppResult}; use crate::identifier::IdentifierWasm; +use crate::utils::define_own_property; use dpp::data_contract::document_type::{ DocumentPropertyReferenceTarget, DocumentReferenceLookup, DocumentTypeRef, IdentityKeyReferenceRequirements, KeyIdReference, LookupHashKey, LookupKeyParam, @@ -449,6 +450,17 @@ fn set_field(target: &Object, key: &str, value: &JsValue, path: &str) -> WasmDpp Ok(()) } +/// [`set_field`] for a key the schema names, a property of the referenced +/// type: an own property even when the name is `__proto__`, whose assignment +/// would call the setter every object inherits instead. +fn set_schema_field(target: &Object, key: &str, value: JsValue, path: &str) -> WasmDppResult<()> { + define_own_property(target, key, value).map_err(|_| { + WasmDppError::generic(format!( + "unable to serialize the `{key}` field of the reference declared at '{path}'" + )) + }) +} + /// A `findBy` function as the schema spells it: `{ function, params }`. fn find_by_function_to_js(key: &LookupHashKey, path: &str) -> WasmDppResult { let computed = Object::new(); @@ -552,18 +564,18 @@ fn set_where_field( } let where_object = Object::new(); for (referring, referenced) in entries { - set_field( + set_schema_field( &where_object, referenced, - &JsValue::from_str(referring), + JsValue::from_str(referring), path, )?; } for (referenced, condition) in where_conditions { - set_field( + set_schema_field( &where_object, referenced, - &where_condition_to_js(condition, path)?, + where_condition_to_js(condition, path)?, path, )?; } @@ -633,7 +645,7 @@ fn set_find_by_fields( LookupKeySource::Hash(key) => find_by_function_to_js(key, path)?, source => JsValue::from_str(source.as_str()), }; - set_field(&find_by, index_property, &value, path)?; + set_schema_field(&find_by, index_property, value, path)?; } set_field(object, "findBy", &find_by, path)?; if let Some(blocks) = lookup.minimum_age_blocks { diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index feaf822b89d..65107ae1062 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -855,6 +855,51 @@ describe('DataContract — refersTo declarations (v14)', () => { } }); + /** + * A property of the referenced type may be named `__proto__`: the `where` + * entry naming it is an own key of the listing, not its prototype. The + * schemas come from JSON, where `__proto__` is an ordinary key. + */ + it('should keep a where entry named __proto__ as an own key', () => { + const schemas = JSON.parse(`{ + "post": { + "type": "object", + "canBeDeleted": false, + "properties": { + "__proto__": { "type": "string", "maxLength": 10, "position": 0 } + }, + "additionalProperties": false + }, + "reply": { + "type": "object", + "properties": { + "postId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "moderatedDocument", + "documentType": "post", + "where": { "__proto__": { "const": "open" } } + } + } + }, + "required": ["postId"], + "additionalProperties": false + } + }`); + const [postId] = buildModeratedContract(schemas).documentTypeReferences( + 'reply', + ) as Reference[]; + + expect(Object.keys(postId.where!)).to.deep.equal(['__proto__']); + expect(Object.getPrototypeOf(postId.where)).to.equal(Object.prototype); + expect(JSON.stringify(postId.where)).to.equal('{"__proto__":{"const":"open"}}'); + }); + it('should refuse a writer gate a removal record can not answer', () => { const refersTo = { type: 'moderatedDocument', From 090c7776dfd78c832114d7e87e09c92072bb6b21 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 11 Oct 2026 18:20:10 +0300 Subject: [PATCH 5/8] refactor(sdk): one list reader for a rule's reads in Swift and Kotlin The readsSystem, readsTotals and readsReferenced lists of a rule were each parsed by a copy of the same loop (a missing key is an empty list, a value that is not an array or any entry that does not decode refuses the rule). Each SDK now has one generic helper the three readers call. Co-Authored-By: Claude Opus 5.5 --- .../queries/DocumentPropertyConstraints.kt | 32 ++++++----- .../Utils/DocumentPropertyConstraints.swift | 53 ++++++------------- 2 files changed, 35 insertions(+), 50 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt index d163e628fa5..885d4e37e03 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/queries/DocumentPropertyConstraints.kt @@ -154,30 +154,36 @@ data class DocumentPropertyConstraint( * The names [rule]'s `readsSystem` lists: empty when the key is * missing, `null` when it is anything but an array of strings. */ - private fun readsSystemOf(rule: JsonObject): List? { - val value = rule["readsSystem"] ?: return emptyList() - val names = value as? JsonArray ?: return null - return names.map { it.jsonStringOrNull() ?: return null } - } + private fun readsSystemOf(rule: JsonObject): List? = + listOrNull(rule, "readsSystem") { it.jsonStringOrNull() } /** * The totals [rule]'s `readsTotals` lists: empty when the key is * missing, `null` when it or an entry is malformed. */ - private fun readsTotalsOf(rule: JsonObject): List? { - val value = rule["readsTotals"] ?: return emptyList() - val entries = value as? JsonArray ?: return null - return entries.map { PropertyConstraintTotalRead.fromJsonOrNull(it) ?: return null } - } + private fun readsTotalsOf(rule: JsonObject): List? = + listOrNull(rule, "readsTotals", PropertyConstraintTotalRead::fromJsonOrNull) /** * The reads [rule]'s `readsReferenced` lists: empty when the key is * missing, `null` when it or an entry is malformed. */ - private fun readsReferencedOf(rule: JsonObject): List? { - val value = rule["readsReferenced"] ?: return emptyList() + private fun readsReferencedOf(rule: JsonObject): List? = + listOrNull(rule, "readsReferenced", PropertyConstraintReferencedRead::fromJsonOrNull) + + /** + * The entries [rule]'s optional [key] lists, each read by [read]: + * empty when the key is missing (a native library built before it), + * `null` when the value is not an array or [read] refuses an entry. + */ + private fun listOrNull( + rule: JsonObject, + key: String, + read: (JsonElement) -> T?, + ): List? { + val value = rule[key] ?: return emptyList() val entries = value as? JsonArray ?: return null - return entries.map { PropertyConstraintReferencedRead.fromJsonOrNull(it) ?: return null } + return entries.map { read(it) ?: return null } } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift index 3c1ba0e12a0..73fb69883b7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Utils/DocumentPropertyConstraints.swift @@ -161,62 +161,41 @@ public struct DocumentPropertyConstraint: Equatable, Sendable { /// A rule's `readsSystem` names, `[]` when the key is missing (a library /// built before it), or `nil` for anything but an array of strings. private static func systemReads(_ value: Any?) -> [String]? { - guard let value else { - return [] - } - guard let entries = value as? [Any] else { - return nil - } - var names: [String] = [] - names.reserveCapacity(entries.count) - for entry in entries { - guard let name = entry as? String else { - return nil - } - names.append(name) - } - return names + entries(value) { $0 as? String } } /// A rule's `readsTotals`, `[]` when the key is missing (a library built /// before it), or `nil` for anything but an array of well-formed totals. private static func totalReads(_ value: Any?) -> [PropertyConstraintTotalRead]? { - guard let value else { - return [] - } - guard let entries = value as? [Any] else { - return nil - } - var totals: [PropertyConstraintTotalRead] = [] - totals.reserveCapacity(entries.count) - for entry in entries { - guard let total = PropertyConstraintTotalRead(jsonEntry: entry) else { - return nil - } - totals.append(total) - } - return totals + entries(value, PropertyConstraintTotalRead.init(jsonEntry:)) } /// A rule's `readsReferenced`, `[]` when the key is missing (a library /// built before it), or `nil` for anything but an array of well-formed /// reads. private static func referencedReads(_ value: Any?) -> [PropertyConstraintReferencedRead]? { + entries(value, PropertyConstraintReferencedRead.init(jsonEntry:)) + } + + /// The entries of a rule's optional `reads...` key, each decoded by + /// `make`: `[]` when the key is missing (a library built before it), or + /// `nil` when the value is not an array or `make` refuses an entry. + private static func entries(_ value: Any?, _ make: (Any) -> T?) -> [T]? { guard let value else { return [] } - guard let entries = value as? [Any] else { + guard let array = value as? [Any] else { return nil } - var reads: [PropertyConstraintReferencedRead] = [] - reads.reserveCapacity(entries.count) - for entry in entries { - guard let read = PropertyConstraintReferencedRead(jsonEntry: entry) else { + var decoded: [T] = [] + decoded.reserveCapacity(array.count) + for entry in array { + guard let item = make(entry) else { return nil } - reads.append(read) + decoded.append(item) } - return reads + return decoded } } From 049c5ea4bb67798fd620d3795b587734a6aa3c66 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 11 Oct 2026 18:59:29 +0300 Subject: [PATCH 6/8] fix(platform): close the open review findings on referenced rules and where Registration now refuses a `where` entry that can never hold or would go unchecked: - a `notEqual` bounded by the reference property itself, whose value found the document (as an equality with it already was); - a literal no value of the referenced property equals (its enum, length, byte size or integer range), an `in` repeating a value once read ("cafe" and "CAFE"), an ordering no value of the property meets; - an ordering bounded by `$updatedAt` or `$transferredAt` (or their heights) beside a findBy function, judged on the create alone; - on a moderatedDocument reference a rule reads through, an entry reading what a removal record does not keep: every replace now checks such a reference's whole `where`, against the record once the document is removed, as it does against the live document. A field of a referenced document is read as what it holds: a string constant compared with an identifier spells it base58, with a byte array hex (startsWith/endsWith included), a contains looks an identifier up in a referenced array, and two referenced fields under equal or notEqual compare by value. Registration checks each constant spells a value of the field's type and that two fields compared hold one kind, for another contract's types too (40148). The parse stays independent of the referenced type. A reference property may be a dotted path inside an object; the internal path is now `$referenced..$.`. Fees: a moderator's field change or restore no longer reads or bills the totals of rules it does not judge, and a write fetches and bills a foreign contract once, not once per reference declaration. `where` conditions keep their `in` literals in declared order and quote strings as JSON in 40126/40127; `notEqual` and the orderings have bound types of their own, so the combinations the grammar refuses cannot be built. The referenced-field reads, the removal-record reads, the registration checks of both `where` loops and the enum checks share one helper each; create and replace judge the referencing rules through one entry point; `own_property_reads` replaces the prefix filters in the FFI and wasm bindings; a rule's referenced reads are found without allocating. Docs: the book, meta-schema v3, v14 notes 39 and 63, the js-evo-sdk README and the wasm-dpp2 docs say all of the above, and warn that a `where` condition and a rule read absence differently. Co-Authored-By: Claude Opus 5.5 --- .../contract-keywords/property-constraints.md | 8 +- book/src/contract-keywords/refers-to.md | 8 +- packages/js-evo-sdk/README.md | 13 + .../document/v3/document-meta.json | 6 +- .../document_type/class_methods/mod.rs | 5 +- .../class_methods/try_from_schema/mod.rs | 686 ++++++++++++++---- .../v3/commit_reveal_lookup_tests.rs | 37 + .../v3/referenced_rule_tests.rs | 341 +++++++-- .../document_type/methods/mod.rs | 15 +- .../methods/versioned_methods.rs | 42 +- .../document_type/property/mod.rs | 3 +- .../document_type/property/reference_where.rs | 253 ++++--- .../document_type/property_constraints/mod.rs | 351 +++++++-- .../state_v2/mod.rs | 41 +- .../document_reference_validation/v0/mod.rs | 231 +++--- .../advanced_structure_v0/mod.rs | 5 + .../state_v1/mod.rs | 34 +- .../batch/tests/document/referenced_rules.rs | 112 ++- .../v0/property_constraint_aggregates.rs | 14 +- .../tests/referenced_rules.rs | 176 +++++ .../v0/mod.rs | 331 ++++++--- .../data_contract_create/mod.rs | 101 +++ ...s-registration-moderated-where-unkept.json | 94 +++ ...erenced-rules-registration-peer-kinds.json | 72 ++ ...rules-registration-where-literal-enum.json | 44 ++ ...egistration-where-not-equal-reference.json | 44 ++ ...les-registration-where-ordering-range.json | 46 ++ ...-validation-contract-referenced-rules.json | 109 +++ .../rs-platform-version/src/version/v14.rs | 51 +- .../src/data_contract/property_constraints.rs | 5 +- .../document_type_property_constraints.rs | 8 +- .../data_contract/document_type_reference.rs | 40 +- packages/wasm-dpp2/src/data_contract/model.rs | 8 +- 33 files changed, 2646 insertions(+), 688 deletions(-) create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-moderated-where-unkept.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-peer-kinds.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-literal-enum.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-not-equal-reference.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-ordering-range.json diff --git a/book/src/contract-keywords/property-constraints.md b/book/src/contract-keywords/property-constraints.md index 63567097798..fff03c0d395 100644 --- a/book/src/contract-keywords/property-constraints.md +++ b/book/src/contract-keywords/property-constraints.md @@ -235,7 +235,7 @@ A filter maps each key, a property of the counted type or `$ownerId`, to the val ## Values of a referenced document -`{ "referenced": [reference, field] }` reads a value of the document a reference of the type points at: `reference` names a top-level property of the type carrying a [`refersTo`](refers-to.md), and `field` a value of the document it finds. It stands wherever a path stands, and reads that value as a path reads a property of the type: as an integer operand, a side of a string or identifier comparison, the operand of an `in`, the path of an `ifAbsent`, `present` or `absent`, a size, a `byteAt`, a `startsWith` or `endsWith`, or either side of a `contains`. It is one node. +`{ "referenced": [reference, field] }` reads a value of the document a reference of the type points at: `reference` names a property of the type carrying a [`refersTo`](refers-to.md), by its dotted path when it sits inside an object (`"meta.pollId"`), and `field` a value of the document it finds. It stands wherever a path stands, and reads that value as a path reads a property of the type: as an integer operand, a side of a string or identifier comparison, the operand of an `in`, the path of an `ifAbsent`, `present` or `absent`, a size, a `byteAt`, a `startsWith` or `endsWith`, or either side of a `contains`. It is one node. ```json "propertyConstraints": { @@ -252,8 +252,8 @@ A filter maps each key, a property of the counted type or `$ownerId`, to the val A bid meets its auction's reserve price, a review names an order that was delivered, and only a member of a group writes to it. - **The field** is a property of the referenced type by its dotted path, its `$id`, `$ownerId` or `$creatorId` (compared as identifiers, `$creatorId` on a type that records it), or a time or height it lists in `required` (`"$createdAt"`, read as an integer). -- **Absence** reads as a property of the type left out does: an integer reads 0 (or an `ifAbsent` default), a string or an identifier equals nothing, `present` does not hold. A reference the document leaves out leaves every value read through it absent. -- **Which comparison.** A rule names another type's property, maybe another contract's, so how it compares follows the rule: as a string beside a `const` or a string property, or in an `in` listing strings; as an identifier beside an identifier property or `$ownerId`; as an integer otherwise. Registration checks the property is of that kind. So a referenced identifier schema property is compared with an identifier property of the type or `$ownerId`: beside a `{ "const": ... }` it would read as a string, which registration refuses, and a literal identifier for it is a `where` [`const`](refers-to.md#conditions) instead. The referenced document's own `$id`, `$ownerId` and `$creatorId` are always identifiers, so they also take a base58 `{ "const": ... }` in an `equal`, `notEqual` or `in`. +- **Absence** reads as a property of the type left out does: an integer reads 0 (or an `ifAbsent` default), a string or an identifier equals nothing, `present` does not hold. A reference the document leaves out leaves every value read through it absent. A `where` [condition](refers-to.md#conditions) reads absence its own way, so the same check written there and here can treat a value left out differently. +- **Which comparison.** The field's type decides, as a property of the type's does. A rule names another type's property, maybe another contract's, so beside a property of the type or `$ownerId` it compares as that property does, and beside a string constant, in an `in` listing strings, or as an array `contains` looks a string up in, the field is read as what it holds: a string is a string, an identifier is written base58, and a byte array hex, the only one of the three with a start and an end a `startsWith` or `endsWith` tests. Two fields of referenced documents compared by `equal` or `notEqual` compare by value whatever they hold, one left out equalling nothing (wrap an integer in `ifAbsent` to read it as 0 instead); an ordering compares two integers. Registration checks the field holds what the rule reads it as, that each constant spells a value of its type, and that two fields compared hold the same kind of value. The referenced document's own `$id`, `$ownerId` and `$creatorId` are identifiers. **Which references.** The reference holds one document: a `permanentDocument` or `deletableDocument` reference found by id or by a [`findBy`](refers-to-lookup.md) without a function, or a `moderatedDocument` one, into this contract or another. Not the elements of a typed array, an `anyOf` or `allOf`, an `inList`, or a `findBy` function, which is judged on the create alone. Through a `moderatedDocument` reference a rule reads only `$id`, `$ownerId` and the fields the referenced type keeps under [`deleteKeepsFields`](deletion.md#moderatorabilitiesdeletekeepsfields): once a moderator removes the document its [removal record](../data-model/contract-moderation.md#deleting-documents) is all there is to read, and a rule is judged on every replace. @@ -333,7 +333,7 @@ The parser then checks the rules against the document type (`InvalidContractStru - once every document type of the contract is parsed, every `countOf` and `sumOf` counts a type of the contract that is not index-only, and not its own type when that has a contested index, with a tree that keeps the total as set out in [Totals of other documents](#totals-of-other-documents). A unique, contested, ranked, time-range, integer-range or index-only-terminal index keeps no such total, nor does one with more properties than the filter has keys; - every key of a filter is `$ownerId` or an integer, string or identifier property of the counted type, and its value is of the same kind (`$ownerId` and `$id` are identifiers); a string constant is in the key's `enum` when it has one, and an identifier constant is base58; - every property a filter value reads is listed in `required`, with every object around it, so a write always has the value; an index-only type has no rule reading a total; -- a `referenced` reads through a top-level property of the type holding one reference to one document, as set out in [Values of a referenced document](#values-of-a-referenced-document), only in `propertyConstraints` (not `deleteConstraints`, an `immutable` condition or `retractedWhen`, whose writes check no reference), not on an index-only type, not beside what a transfer, purchase or price update of the type changes, not beside a field the type keeps for its moderators to change, read or deciding a total over the type's own documents, and, on a type whose moderators' deletions keep records, not beside a total over the type's own documents; +- a `referenced` reads through a property of the type holding one reference to one document, as set out in [Values of a referenced document](#values-of-a-referenced-document), only in `propertyConstraints` (not `deleteConstraints`, an `immutable` condition or `retractedWhen`, whose writes check no reference), not on an index-only type, not beside what a transfer, purchase or price update of the type changes, not beside a field the type keeps for its moderators to change, read or deciding a total over the type's own documents, and, on a type whose moderators' deletions keep records, not beside a total over the type's own documents; - once every document type of the contract is parsed, what a rule reads of a referenced type of the same contract is a field that type gives it: one it has, stored, of the kind the rule reads, a time or height it records, a `$creatorId` it records, a constant its `enum` lists, and through a `moderatedDocument` reference a field its removal record keeps. A referenced type of another contract is checked at registration against the stored contract, refused with `ReferencedDocumentRuleReadInvalidError` (40148). Three limits come from the protocol version 14 `SystemLimits`, and a rule over one is refused the same way: diff --git a/book/src/contract-keywords/refers-to.md b/book/src/contract-keywords/refers-to.md index 531eb07db9f..7a4581fa524 100644 --- a/book/src/contract-keywords/refers-to.md +++ b/book/src/contract-keywords/refers-to.md @@ -199,7 +199,7 @@ WHERE $id = :submittedCharterId -- the value, an id reference AND targetContractId = :targetContractId -- where ``` -At registration both sides must exist and hold the same type of value (for integers, the same stored integer type). Neither side may be an object or a typed array, the referring side may not be the reference property itself, and the referenced side may not be `transient` (no stored document carries it; the referring side may be). An entry through which a [`preallocated`](index-only.md#preallocated) index is keyed needs a referenced property whose every value fits an index key, at most 255 bytes: a string of at most 63 characters or with `maxBytes` at most 255, or a byte array of at most 255 bytes. A condition's referenced side must hold a single value a literal is compared with (an integer, a boolean, a string, an identifier or a byte array, not a float), every literal must read as its kind, a `notEqual` compares two values of one kind (the writer an identifier), and an ordering compares an integer with an integer bound, a time or height the declaring type records. An entry breaking one of these is refused with `ReferencedDocumentPropertyAgreementInvalidError` (40126), whose referring property names the condition as written. +At registration both sides must exist and hold the same type of value (for integers, the same stored integer type). Neither side may be an object or a typed array, the referring side may not be the reference property itself, and the referenced side may not be `transient` (no stored document carries it; the referring side may be). An entry through which a [`preallocated`](index-only.md#preallocated) index is keyed needs a referenced property whose every value fits an index key, at most 255 bytes: a string of at most 63 characters or with `maxBytes` at most 255, or a byte array of at most 255 bytes. A condition's referenced side must hold a single value a literal is compared with (an integer, a boolean, a string, an identifier or a byte array, not a float); every literal must read as its kind and be a value the property can hold (one of its `enum` values, of a length or size it takes, inside its `minimum` and `maximum`); an `in` must list values distinct once read (`"cafe"` and `"CAFE"` are one byte array); a `notEqual` compares two values of one kind (the writer an identifier), never the reference property itself, whose value found the document; and an ordering compares an integer with an integer bound, a time or height the declaring type records, an integer bound leaving some value of the property on its side. An entry breaking one of these is refused with `ReferencedDocumentPropertyAgreementInvalidError` (40126), whose referring property names the condition as written. #### Conditions @@ -234,7 +234,11 @@ A literal is an integer, `true` or `false`, or a string, and it reads as the ref A bid names an auction that is open or extended, whose reserve its `amount` meets, that has not ended at the block of the write (the bid type lists `$updatedAt` in `required`), and that its writer does not own. A time or height reads as the referring document records it: a create's block for all of them, and on a replace the stored creation and transfer and the replace's block as the update. `$createdAt` and its heights never change, while `$updatedAt` and `$transferredAt` and their heights do without the referring document changing (a replace, a transfer), so an entry bounded by one of them is checked again on every replace, as a writer gate is, and so is a `notEqual` bounded by `"$ownerId"`. -Conditions are checked when the reference is, against the same document, with the same refusal, 40127, whose referring property names the condition as written (`{ "in": ["extended", "open"] }`). They work on every form of document reference: the elements of a typed array, a leaf of an [expression](refers-to-expressions.md), [`findBy`](refers-to-lookup.md) and [`inList`](refers-to-list-element.md). They never key a [`preallocated`](index-only.md#preallocated) index. Beside a `findBy` function, the property a condition is bounded by must be fixed once written or transient, as an equality's referring property must. +Conditions are checked when the reference is, against the same document, with the same refusal, 40127, whose referring property names the condition as written, in its declared order, a string quoted as JSON writes it (`{ "in": ["open", "extended"] }`). They work on every form of document reference: the elements of a typed array, a leaf of an [expression](refers-to-expressions.md), [`findBy`](refers-to-lookup.md) and [`inList`](refers-to-list-element.md). They never key a [`preallocated`](index-only.md#preallocated) index. Beside a `findBy` function, the property a condition is bounded by must be fixed once written or transient, as an equality's referring property must, and no condition may be bounded by `$updatedAt` or `$transferredAt` or their heights, which a later write changes while such a reference is judged on the create alone. + +A condition reads absence as a `where` entry does, not as a [`propertyConstraints`](property-constraints.md#values-of-a-referenced-document) rule reading the same value: a `notEqual` bounded by a property finds two absent sides equal and refuses, where the rule's `notEqual` holds for a string or an identifier left out; an ordering needs both sides, where the rule reads an integer left out as 0. Moving a check from one to the other changes what a document leaving the value out may do. + +Through a `moderatedDocument` reference a [`propertyConstraints`](property-constraints.md#values-of-a-referenced-document) rule reads, every replace checks the whole `where`, against the removal record once a moderator removes the document, so registration keeps every entry of such a reference, equality or condition, to `$id`, `$ownerId` and the fields the type lists under `deleteKeepsFields`. The keyword `propertyAgreement`, whose pairs were keyed the other way (`{ "": "" }`), was replaced by `where` before protocol version 14 shipped. A declaration still carrying it is refused on every parse, with a message saying to key `where` by the referenced document's property. `"propertyAgreement": { "recipientId": "$ownerId" }` is now `"where": { "$ownerId": "recipientId" }`, and a symmetric pair such as `"$ownerId": "$ownerId"` reads the same. diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 7144511adc3..736d89b52a2 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -206,6 +206,19 @@ for (const ref of contract.documentTypeReferences('note')) { // the writer's own `$ownerId`: a write gate such as // `{ '$ownerId': '$ownerId' }` lets only the referenced document's current // owner create or replace the referring document. + // A value may instead be a condition object with one key, as the schema + // writes it: `{ const: v }`, `{ notEqual: b }`, `{ in: [v1, v2] }`, or an + // ordering such as `{ lessThanOrEqual: 'amount' }` or + // `{ greaterThan: '$updatedAt' }` (a bound is a referring property, + // `'$ownerId'`, a time or height, or `{ const: v }`; an integer past + // Number.MAX_SAFE_INTEGER is a bigint). Tell the two apart by type: + for (const [referenced, value] of Object.entries(ref.where ?? {})) { + if (typeof value === 'string') { + // an equality with the referring property `value` + } else { + // a condition, e.g. { in: ['open', 'extended'] } + } + } console.log(ref.path, ref.type); } 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 0df523d6892..d8dfef22b4d 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 @@ -494,12 +494,12 @@ ] }, "propertyConstraintReferenced": { - "description": "A value of the document a reference of the document type points at, standing wherever a property path does in a propertyConstraints rule: the name of a top-level property of the type whose refersTo finds one document, by id or by a findBy without a function (a permanentDocument, moderatedDocument or deletableDocument reference, not on the elements of a typed array, nor an anyOf, allOf or inList), then a field of that document: a property path of its type, read as that property's type says, its $id, $ownerId or $creatorId, compared as identifiers, or a time or height its type lists in required. A rule reading one is judged once the references are checked, with the document their check read, so it costs no read; its reference is checked again on every replace. Through a moderatedDocument reference only $id, $ownerId and the fields the type keeps under deleteKeepsFields are read. Available from protocol version 14", + "description": "A value of the document a reference of the document type points at, standing wherever a property path does in a propertyConstraints rule: the dotted path of a property of the type (a nested one through the objects around it) whose refersTo finds one document, by id or by a findBy without a function (a permanentDocument, moderatedDocument or deletableDocument reference, not on the elements of a typed array, nor an anyOf, allOf or inList), then a field of that document: a property path of its type, read as that property's type says, its $id, $ownerId or $creatorId, compared as identifiers, or a time or height its type lists in required. A rule reading one is judged once the references are checked, with the document their check read, so it costs no read; its reference is checked again on every replace. Through a moderatedDocument reference only $id, $ownerId and the fields the type keeps under deleteKeepsFields are read. Available from protocol version 14", "type": "array", "prefixItems": [ { "type": "string", - "pattern": "^[a-zA-Z0-9_]{1,64}$" + "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" }, { "type": "string", @@ -2780,7 +2780,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 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, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, startsWith or endsWith listing two strings, a const or a string property each, at least one a property, and holding if the first starts or ends with the second, byte for byte (a constant looked for in a string property that declares enum must start or end one of its values), contains listing the path of a typed array property and the value one of its elements must equal (an integer expression among integers, a const string or string property among strings, a const base58 identifier, identifier property or $ownerId among identifiers; an array left out holds nothing, and a constant must be one of the elements' enum values when they declare one), present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf, not, ifThen or ifThenElse 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, ifThen if its second condition holds whenever its first does (the second evaluated only then), ifThenElse if its second holds when its first does and its third when it does not (only that branch evaluated); notIn lists what in lists and holds if the expression takes none of the values. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, a system time or height the document type records by listing it in required ($createdAt, $updatedAt and $transferredAt, block times in milliseconds, and each with BlockHeight or CoreBlockHeight appended, the Platform and Core block heights: those of the create, of the last create, replace or price update, and of the last create, transfer or purchase), or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add, multiply, min or max, two or more operands; subtract, divide, modulo or power, exactly two; abs, one; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out ({ \"lessThanOrEqual\": [{ \"count\": \"tags\" }, \"maxTags\"] }); countOf or sumOf, how many documents of a document type of this contract match a filter (keys of that type or $ownerId, each mapped to a value read from the document written, $id its id) or the total of an integer property over them, as that type's count or sum trees keep it once the write is done, which needs documentsCountable or documentsSummable for a whole type and otherwise an index whose properties are exactly the filter's keys ({ \"lessThanOrEqual\": [{ \"countOf\": [\"listing\", { \"$ownerId\": \"$ownerId\" }] }, 10] }). A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. 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 an operand reads must be an integer or boolean property, every property length or byteLength measures a string property, every property count counts an array or byte array property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in 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 or a notIn, 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 in and each value it lists, every contains and its array, every const, every present or absent, every arithmetic operator and every operand, a size included; 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). $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. Likewise a transfer or a purchase is judged against the rules reading the transfer's time and heights, and a price update against those reading the update's, since each sets them; an indexOnly type reads no system time or height. The rules change nothing stored, and read state only for countOf and sumOf, each total a billed read of a count or sum tree, at most max_property_constraint_aggregates distinct totals per type (4); a type with a contested index totals no documents of its own type, since a document a contest awards is stored without the rules judged. 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, equal or notEqual of the path of a string property and a const string ({ \"equal\": [\"status\", { \"const\": \"closed\" }] }, either way round) or of the paths of two string properties, which compares their strings, and likewise for identifier properties, whose constants are base58 identifiers, in listing an integer expression and two or more distinct integer values it may take or the path of a string property and two or more distinct strings, startsWith or endsWith listing two strings, a const or a string property each, at least one a property, and holding if the first starts or ends with the second, byte for byte (a constant looked for in a string property that declares enum must start or end one of its values), contains listing the path of a typed array property and the value one of its elements must equal (an integer expression among integers, a const string or string property among strings, a const base58 identifier, identifier property or $ownerId among identifiers; an array left out holds nothing, and a constant must be one of the elements' enum values when they declare one), present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf, not, ifThen or ifThenElse 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, ifThen if its second condition holds whenever its first does (the second evaluated only then), ifThenElse if its second holds when its first does and its third when it does not (only that branch evaluated); notIn lists what in lists and holds if the expression takes none of the values. An expression is an integer value, the dotted path of an integer or boolean property of the document type, whose value it takes (a boolean reading as 1 for true and 0 for false), 0 when the document leaves it out, a system time or height the document type records by listing it in required ($createdAt, $updatedAt and $transferredAt, block times in milliseconds, and each with BlockHeight or CoreBlockHeight appended, the Platform and Core block heights: those of the create, of the last create, replace or price update, and of the last create, transfer or purchase), or an object with one key: ifAbsent, a property path and the value it takes when the document leaves it out (an integer, or a string for a string property compared with strings); add, multiply, min or max, two or more operands; subtract, divide, modulo or power, exactly two; abs, one; length or byteLength, the characters or UTF-8 bytes of a string property, or count, the items of an array or byte array property, each 0 when the document leaves the property out ({ \"lessThanOrEqual\": [{ \"count\": \"tags\" }, \"maxTags\"] }); countOf or sumOf, how many documents of a document type of this contract match a filter (keys of that type or $ownerId, each mapped to a value read from the document written, $id its id) or the total of an integer property over them, as that type's count or sum trees keep it once the write is done, which needs documentsCountable or documentsSummable for a whole type and otherwise an index whose properties are exactly the filter's keys ({ \"lessThanOrEqual\": [{ \"countOf\": [\"listing\", { \"$ownerId\": \"$ownerId\" }] }, 10] }). A string property the document leaves out equals no constant and no other string property, not even one also left out, unless an ifAbsent gives it a string default ({ \"ifAbsent\": [\"status\", \"open\"] }), which it then reads as, and a constant compared with a string property that declares enum must be one of its values. 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 an operand reads must be an integer or boolean property, every property length or byteLength measures a string property, every property count counts an array or byte array property, every property compared with a string a string property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison and in 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 or a notIn, 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 in and each value it lists, every contains and its array, every const, every present or absent, every arithmetic operator and every operand, a size included; all checked at contract registration. When a document is created or replaced, consensus checks every rule not reading a referenced document, in name order, after the schema validation, then, once the references hold, every rule reading one, in name order, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422); a reference that does not hold is refused before any rule reading through it. $ownerId, the document's owner, is an identifier operand (never a property: not in present, absent or an integer operand, and not on an indexOnly type), and a transfer or a purchase, which gives the document a new owner, is refused when it would break a rule reading it. Likewise a transfer or a purchase is judged against the rules reading the transfer's time and heights, and a price update against those reading the update's, since each sets them; an indexOnly type reads no system time or height. The rules change nothing stored, and read state only for countOf and sumOf, each total a billed read of a count or sum tree, at most max_property_constraint_aggregates distinct totals per type (4); a type with a contested index totals no documents of its own type, since a document a contest awards is stored without the rules judged. 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/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/mod.rs index f451831b780..8f3c5801e0e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/mod.rs @@ -13,7 +13,10 @@ mod should_use_creator_id; mod system_properties; mod try_from_schema; -pub use try_from_schema::referenced_reads_error; +pub use try_from_schema::{ + referenced_peer_kind_error, referenced_peer_kind_mismatch, referenced_reads_error, + where_condition_literal_error, +}; pub(in crate::data_contract) use try_from_schema::{ resolve_derived_index_properties, validate_preallocated_indexes_kept_on_removal, validate_summable_off_count_indexes_lossless, 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 dcf11282b83..c11577a9a62 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 @@ -9,10 +9,10 @@ use crate::data_contract::document_type::property::reference_where::{ WHERE_CONST, WHERE_IN, WHERE_NOT_EQUAL, }; use crate::data_contract::document_type::property_constraints::{ - parse_delete_constraints, parse_property_constraints, referenced_read, AffixPosition, - AggregateBinding, AggregateKind, AggregateRead, ElementKind, EqualityKind, PropertyConstraint, - PropertyRead, SystemChange, SystemProperty, REFERENCED_DOCUMENT_PREFIX, - REFERENCED_IDENTIFIER_FIELDS, STORED_DOCUMENT_PREFIX, + parse_delete_constraints, parse_property_constraints, referenced_path, referenced_read, + whole_integer, AffixPosition, AggregateBinding, AggregateKind, AggregateRead, ElementKind, + EqualityKind, PropertyConstraint, PropertyRead, SystemChange, SystemProperty, + REFERENCED_DOCUMENT_PREFIX, STORED_DOCUMENT_PREFIX, }; use crate::data_contract::document_type::reference_lookup::{ clearable_once_its_document_is_deleted, owner_can_change, @@ -32,7 +32,8 @@ use crate::data_contract::document_type::{ EncryptionScheme, GeneratedFrom, GenerationParam, IdentityKeyReferenceRequirements, KeyIdReference, KeyReferenceIdentityProperty, ListElementReference, LookupHashKey, LookupKeySource, PropertyReference, ReferenceCombinator, ReferenceOperands, SystemFunction, - WhereBound, WhereCondition, WhereLiteral, WhereOrdering, COMBINABLE_REFERENCE_TARGET_TYPES, + WhereBoundRead, WhereCondition, WhereLiteral, WhereLiteralKind, WhereNotEqualBound, + WhereOrdering, WhereOrderingBound, COMBINABLE_REFERENCE_TARGET_TYPES, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; @@ -1291,7 +1292,10 @@ fn parse_reference_target( where_conditions .iter() .filter(|(_, condition)| { - matches!(condition, WhereCondition::NotEqual(WhereBound::Writer)) + matches!( + condition, + WhereCondition::NotEqual(WhereNotEqualBound::Writer) + ) }) .map(|(referenced_property, _)| referenced_property), ); @@ -1615,11 +1619,11 @@ fn parse_where_condition( WHERE_CONST => Ok(WhereCondition::Constant(literal(body, WHERE_CONST)?)), WHERE_NOT_EQUAL => { let bound = match body { - Value::Text(path) if path == OWNER_ID => WhereBound::Writer, - Value::Text(path) => WhereBound::Property( + Value::Text(path) if path == OWNER_ID => WhereNotEqualBound::Writer, + Value::Text(path) => WhereNotEqualBound::Property( parse_where_referring_property(referenced_property, path)?.to_string(), ), - body => WhereBound::Constant(constant(body, WHERE_NOT_EQUAL)?), + body => WhereNotEqualBound::Constant(constant(body, WHERE_NOT_EQUAL)?), }; Ok(WhereCondition::NotEqual(bound)) } @@ -1627,7 +1631,7 @@ fn parse_where_condition( let Some(values) = body.as_array().filter(|values| values.len() >= 2) else { return Err(invalid("in must list two or more values".to_string())); }; - let mut literals = BTreeSet::new(); + let mut literals = Vec::with_capacity(values.len()); let mut first_kind = None; for (index, value) in values.iter().enumerate() { let parsed = literal(value, &format!("in[{index}]"))?; @@ -1638,9 +1642,10 @@ fn parse_where_condition( all integers, all booleans or all strings" ))); } - if !literals.insert(parsed) { + if literals.contains(&parsed) { return Err(invalid(format!("in[{index}] repeats an earlier value"))); } + literals.push(parsed); } Ok(WhereCondition::In(literals)) } @@ -1669,13 +1674,13 @@ fn parse_where_condition( ))); } Value::Text(path) => match SystemProperty::from_name(path) { - Some(property) => WhereBound::System(property), - None => WhereBound::Property( + Some(property) => WhereOrderingBound::System(property), + None => WhereOrderingBound::Property( parse_where_referring_property(referenced_property, path)?.to_string(), ), }, body => match constant(body, ordering_key)? { - integer @ WhereLiteral::Integer(_) => WhereBound::Constant(integer), + WhereLiteral::Integer(integer) => WhereOrderingBound::Constant(integer), _ => { return Err(invalid(format!( "{ordering_key}.const must be an integer: an ordering compares \ @@ -2344,6 +2349,31 @@ fn create_only_leaf_error( optional one once its document is deleted) or transient" ) }) + .or_else(|| { + // A time or height a later write changes, which no replace reads + // again here; the writer, `$ownerId`, is admitted as above + declaration + .where_conditions + .values() + .find_map(|condition| match condition.bound_read() { + Some(WhereBoundRead::System(property)) + if WhereBoundRead::System(property).changes_without_a_write() => + { + Some(property) + } + _ => None, + }) + .map(|property| { + format!( + "where is bounded by \"{}\" beside a findBy function, so it is judged \ + when the document is created only, while a later write changes \"{}\": \ + bound it by a time or height of the create ($createdAt or one of its \ + heights)", + property.name(), + property.name() + ) + }) + }) } /// Reads a property's `encryptedFor` declaration: how the bytes of a byte @@ -3420,17 +3450,14 @@ pub(super) fn validate_property_constraint_reads( allow_referenced_reads: bool, structure_error: &dyn Fn(String) -> DataContractError, ) -> Result<(), DataContractError> { - for (path, read) in constraint.property_reads() { - if path.starts_with(REFERENCED_DOCUMENT_PREFIX) { - if !allow_referenced_reads { - return Err(structure_error(format!( - "{subject} reads a value of the document a reference points at, which only \ - a propertyConstraints rule does: its writes are the ones that check the \ - references" - ))); - } - continue; - } + // What a rule reads of a referenced document is checked with its reference + if !allow_referenced_reads && constraint.reads_referenced() { + return Err(structure_error(format!( + "{subject} reads a value of the document a reference points at, which only a \ + propertyConstraints rule does: its writes are the ones that check the references" + ))); + } + for (path, read) in constraint.own_property_reads() { let lookup = stored_path(path, subject, allow_stored_reads).map_err(structure_error)?; if let Some(error) = property_read_error( DocumentTypeRef::V2(document_type), @@ -3502,55 +3529,25 @@ pub(super) fn validate_property_constraint_reads( } } // A constant or a default a string property's `enum` does not list is a - // typo: the property could never hold it - // The enums of a referenced type are checked with what is read of it - let own = |path: &&str| !path.starts_with(REFERENCED_DOCUMENT_PREFIX); - for (path, constant) in constraint - .text_constants() - .into_iter() - .filter(|(path, _)| own(path)) - { - let lookup = stored_path(path, subject, allow_stored_reads).map_err(structure_error)?; - if !enum_admits(&document_type.schema, schema_defs, lookup, constant)? { - return Err(structure_error(format!( - "{subject} compares \"{path}\" with \"{constant}\", which is not one of \ - its enum values" - ))); - } - } - // A constant a string property must start or end with, when the property - // declares an `enum`, must fit one of its values, or the test never holds - for (path, affix, position) in constraint - .text_affixes() - .into_iter() - .filter(|(path, _, _)| own(path)) - { - let lookup = stored_path(path, subject, allow_stored_reads).map_err(structure_error)?; - if !enum_any(&document_type.schema, schema_defs, lookup, |member| { - position.holds(member, affix) - })? { - let tests = match position { - AffixPosition::Start => "starts with", - AffixPosition::End => "ends with", - }; - return Err(structure_error(format!( - "{subject} tests whether \"{path}\" {tests} \"{affix}\", which none \ - of its enum values does" - ))); - } - } - for (path, default) in constraint - .text_defaults() - .into_iter() - .filter(|(path, _)| own(path)) - { - let lookup = stored_path(path, subject, allow_stored_reads).map_err(structure_error)?; - if !enum_admits(&document_type.schema, schema_defs, lookup, default)? { - return Err(structure_error(format!( - "{subject} gives \"{path}\" the default \"{default}\", which is not \ - one of its enum values" - ))); + // typo: the property could never hold it. The enums of a referenced type + // are checked with what is read of it + let lookup = |path: &str| -> Result, DataContractError> { + if path.starts_with(REFERENCED_DOCUMENT_PREFIX) { + return Ok(None); } + stored_path(path, subject, allow_stored_reads) + .map(|lookup| Some(lookup.to_string())) + .map_err(structure_error) + }; + if let Some(reason) = enum_miss( + constraint, + subject, + &document_type.schema, + schema_defs, + &lookup, + &|path| path.to_string(), + )? { + return Err(structure_error(reason)); } Ok(()) } @@ -3635,9 +3632,9 @@ pub(super) fn validate_referencing_rules( // A moderator's change of a key of a total over the type's own // documents moves the total, the rule reading the key itself or not let moderated_field = constraint - .property_paths() + .own_property_reads() .into_iter() - .filter(|path| !path.starts_with(REFERENCED_DOCUMENT_PREFIX)) + .map(|(path, _)| path) .chain( constraint .aggregate_reads() @@ -3669,6 +3666,21 @@ pub(super) fn validate_referencing_rules( Ok(()) } +/// What the property `reference` of `document_type` refers to, as every +/// reference check reads it ([`DocumentTypeRef::reference_declarations`]): its +/// value's target, its elements', a key id's, or the one its value reveals +/// into a `findBy` key. `None` for a property holding no reference. +fn property_reference<'a>( + document_type: DocumentTypeRef<'a>, + reference: &str, +) -> Option> { + document_type + .reference_declarations() + .find_map(|(holder, property_reference)| { + (holder.path() == reference).then_some(property_reference) + }) +} + /// Why a rule of `document_type` may not read a document through its property /// `reference`, `None` when it may: it must be a top-level property of the /// type, not transient, holding one reference to one document it finds by id @@ -3676,13 +3688,13 @@ pub(super) fn validate_referencing_rules( /// value `findBy` reads included), so the reference check reads exactly that /// document whenever the rule is judged. fn referencing_property_error(document_type: DocumentTypeRef, reference: &str) -> Option { - let Some(property) = document_type.flattened_properties().get(reference) else { + if !document_type.flattened_properties().contains_key(reference) { return Some( "which is not a property of the document type holding a reference: a \ reference read through is a top-level property" .to_string(), ); - }; + } if is_transient(document_type, reference) { return Some( "which is transient: the reference a stored document was judged through would \ @@ -3690,8 +3702,8 @@ fn referencing_property_error(document_type: DocumentTypeRef, reference: &str) - .to_string(), ); } - let target = match property.property_type.reference() { - Some(PropertyReference::Value(target)) => target, + let target = match property_reference(document_type, reference) { + Some(PropertyReference::Value(target) | PropertyReference::Revealed(target)) => target, Some(PropertyReference::Elements { .. }) => { return Some( "whose refersTo is on the elements of a typed array, which point at several \ @@ -3702,11 +3714,7 @@ fn referencing_property_error(document_type: DocumentTypeRef, reference: &str) - Some(PropertyReference::KeyId(_)) => { return Some("whose refersTo names an identity key, not a document".to_string()) } - Some(PropertyReference::Revealed(target)) => target, - None => match &property.revealed_reference { - Some(target) => target, - None => return Some("which carries no refersTo".to_string()), - }, + None => return Some("which carries no refersTo".to_string()), }; if target.combinator().is_some() { return Some( @@ -3791,7 +3799,7 @@ pub fn referenced_reads_error( label(field) ))); } - if REFERENCED_IDENTIFIER_FIELDS.contains(&field) { + if is_referenced_system_agreement_property(field) { if read != PropertyRead::Identifier { return Ok(Some(format!( "{subject} reads \"{}\", an identifier, which a rule compares by equal, \ @@ -3826,56 +3834,392 @@ pub fn referenced_reads_error( } continue; } + // A field the parse compares as a string, not knowing the referenced + // type, that holds something else: read as what it holds + match referenced_textual_read(rule, subject, referenced, reference, field, read, &label) { + Some(Ok(())) => continue, + Some(Err(reason)) => return Ok(Some(reason)), + None => {} + } if let Some(reason) = property_read_error(referenced, subject, &label(field), field, read) { return Ok(Some(reason)); } } // A constant or a default the referenced property's `enum` does not list - fn read_through<'a>(path: &'a str, reference: &str) -> Option<&'a str> { - referenced_read(path).and_then(|(read_reference, field)| { - (read_reference == reference && !field.starts_with('$')).then_some(field) - }) + let lookup = |path: &str| -> Result, DataContractError> { + Ok(referenced_read(path).and_then(|(read_reference, field)| { + (read_reference == reference && !field.starts_with('$')).then(|| field.to_string()) + })) + }; + if let Some(reason) = enum_miss( + rule, + subject, + referenced.schema(), + referenced_schema_defs, + &lookup, + &|path| referenced_read(path).map_or_else(|| path.to_string(), |(_, field)| label(field)), + )? { + return Ok(Some(reason)); } - let schema = referenced.schema(); - for (path, constant) in rule.text_constants() { - let Some(field) = read_through(path, reference) else { + Ok(None) +} + +/// How a rule reads `field` of `referenced` through `reference` where its +/// parse, which does not know the referenced type, compares it as a string +/// (`read`: a [`PropertyRead::Text`] read, or the string elements of an array +/// a `contains` looks in) though it holds something else. `None` when the +/// field holds strings, or the read is no such one, for the ordinary checks. +/// Otherwise the field is read as what it holds, which is sound when every +/// string constant compared with it spells a value of its type (base58 for an +/// identifier, hex of a size it takes for a byte array, which alone has a +/// start and an end a rule tests), no string default is given it, and every +/// property compared with it is another field of a referenced document +/// holding the same kind ([`referenced_peer_kind_error`] for one of another +/// reference): `Some(Ok(()))`, and `Some(Err(reason))` when not. +fn referenced_textual_read( + rule: &PropertyConstraint, + subject: &str, + referenced: DocumentTypeRef, + reference: &str, + field: &str, + read: PropertyRead, + label: &dyn Fn(&str) -> String, +) -> Option> { + let property_type = &referenced.flattened_properties().get(field)?.property_type; + let (kind, element_type) = match (read, property_type) { + (PropertyRead::Text, property_type) => { + (WhereLiteralKind::of(property_type)?, property_type) + } + (PropertyRead::Elements(ElementKind::Text), DocumentPropertyType::TypedArray(array)) => { + let item_type = array.item_type.as_ref(); + (WhereLiteralKind::of(item_type)?, item_type) + } + _ => return None, + }; + if kind == WhereLiteralKind::Text { + return None; + } + let path = referenced_path(reference, field); + let named = label(field); + let held = match kind { + WhereLiteralKind::Integer => "an integer", + WhereLiteralKind::Boolean => "a boolean", + WhereLiteralKind::Identifier => "an identifier", + WhereLiteralKind::Bytes => "a byte array", + WhereLiteralKind::Text => "a string", + }; + let refuse = |reason: String| Some(Err(format!("{subject} {reason}"))); + if rule + .text_defaults() + .iter() + .any(|(read_path, _)| *read_path == path) + { + return refuse(format!( + "gives \"{named}\" a string default, but it holds {held}" + )); + } + for (read_path, affix, _) in rule.text_affixes() { + if read_path != path { continue; - }; - if !enum_admits(schema, referenced_schema_defs, field, constant)? { - return Ok(Some(format!( - "{subject} compares \"{}\" with \"{constant}\", which is not one of its enum \ - values", - label(field) - ))); + } + if kind != WhereLiteralKind::Bytes { + return refuse(format!( + "tests whether \"{named}\" starts or ends with \"{affix}\", but it holds \ + {held}, which has no start or end a rule tests" + )); + } + if hex::decode(affix).is_err() { + return refuse(format!( + "tests whether \"{named}\" starts or ends with \"{affix}\", which is not hex, \ + as a byte array is written" + )); } } - for (path, affix, position) in rule.text_affixes() { - let Some(field) = read_through(path, reference) else { + for (read_path, constant) in rule.text_constants() { + if read_path != path { continue; + } + let spells = match kind { + WhereLiteralKind::Identifier => { + Identifier::from_string(constant, Encoding::Base58).is_ok() + } + WhereLiteralKind::Bytes => hex::decode(constant).is_ok_and(|bytes| { + let DocumentPropertyType::ByteArray(sizes) = element_type else { + return true; + }; + !sizes + .min_size + .is_some_and(|minimum| bytes.len() < usize::from(minimum)) + && !sizes + .max_size + .is_some_and(|maximum| bytes.len() > usize::from(maximum)) + }), + WhereLiteralKind::Integer | WhereLiteralKind::Boolean | WhereLiteralKind::Text => false, }; - if !enum_any(schema, referenced_schema_defs, field, |member| { - position.holds(member, affix) - })? { - return Ok(Some(format!( - "{subject} tests \"{}\" against \"{affix}\", which none of its enum values \ - fits", - label(field) - ))); + if !spells { + return refuse(format!( + "compares \"{named}\", which holds {held}, with \"{constant}\", which spells \ + none: an identifier is written base58, a byte array hex of a size it takes, \ + and an integer or a boolean is compared with no string" + )); } } - for (path, default) in rule.text_defaults() { - let Some(field) = read_through(path, reference) else { + for (left, right) in rule.text_peers() { + let other = if left == path { + right + } else if right == path { + left + } else { continue; }; - if !enum_admits(schema, referenced_schema_defs, field, default)? { - return Ok(Some(format!( - "{subject} gives \"{}\" the default \"{default}\", which is not one of its \ - enum values", - label(field) - ))); + let Some((other_reference, other_field)) = referenced_read(other) else { + return refuse(format!( + "compares \"{named}\", which holds {held}, with \"{other}\", a string \ + property of the type" + )); + }; + if other_reference == reference + && WhereLiteralKind::of_referenced(referenced, other_field) != Some(kind) + { + return refuse(format!( + "compares \"{named}\", which holds {held}, with \"{}\", which does not", + label(other_field) + )); } } - Ok(None) + Some(Ok(())) +} + +/// Why `rule`, `subject` in the messages, compares two fields of documents +/// different references point at that hold different kinds of value (a string +/// with an identifier, say), so that the comparison could never hold; `None` +/// when it does not. `kind_of` gives what the field of the document a +/// reference points at holds, `None` when the referenced type is not in hand +/// (another contract's, at the contract parse), which leaves the pair to +/// registration. A pair through one reference is checked with its other reads +/// ([`referenced_reads_error`]). +pub fn referenced_peer_kind_error( + rule: &PropertyConstraint, + subject: &str, + kind_of: &dyn Fn(&str, &str) -> Option, +) -> Option { + referenced_peer_kind_mismatch(rule, subject, kind_of).map(|(_, reason)| reason) +} + +/// [`referenced_peer_kind_error`] with the reference of the pair's first +/// side, which the registration error names. +pub fn referenced_peer_kind_mismatch<'a>( + rule: &'a PropertyConstraint, + subject: &str, + kind_of: &dyn Fn(&str, &str) -> Option, +) -> Option<(&'a str, String)> { + rule.text_peers().into_iter().find_map(|(left, right)| { + let (left_reference, left_field) = referenced_read(left)?; + let (right_reference, right_field) = referenced_read(right)?; + if left_reference == right_reference { + return None; + } + let left_kind = kind_of(left_reference, left_field)?; + let right_kind = kind_of(right_reference, right_field)?; + (left_kind != right_kind).then(|| { + ( + left_reference, + format!( + "{subject} compares \"{left_reference}.{left_field}\" with \ + \"{right_reference}.{right_field}\", which hold different kinds of value, \ + so the two never agree" + ), + ) + }) + }) +} + +/// Why the literals of a `where` entry keyed by `property` of `referenced`, +/// whose value reads as `kind`, leave the entry unable to hold, `None` when +/// they do not: each literal reads as `kind` ([`WhereLiteral::value_for`]) and +/// is a value the property can hold ([`where_literal_error`]), an `in` lists +/// values distinct once read (`"cafe"` and `"CAFE"` are one byte array), and +/// an ordering's integer leaves a value the property can hold on its side. +/// Registration only, with the referenced type in hand: a stored contract +/// passed it. +pub fn where_condition_literal_error( + referenced: DocumentTypeRef, + referenced_schema_defs: Option<&BTreeMap>, + property: &str, + kind: WhereLiteralKind, + condition: &WhereCondition, +) -> Result, DataContractError> { + match condition { + WhereCondition::Constant(literal) + | WhereCondition::NotEqual(WhereNotEqualBound::Constant(literal)) => { + where_literal_error(referenced, referenced_schema_defs, property, kind, literal) + } + WhereCondition::In(literals) => { + let mut read: Vec = Vec::with_capacity(literals.len()); + for literal in literals { + if let Some(reason) = where_literal_error( + referenced, + referenced_schema_defs, + property, + kind, + literal, + )? { + return Ok(Some(reason)); + } + let Some(value) = literal.value_for(kind) else { + continue; + }; + if read.iter().any(|earlier| earlier.same_scalar_data(&value)) { + return Ok(Some(format!( + "the literal {literal} reads as a value an earlier literal of the in \ + already is" + ))); + } + read.push(value); + } + Ok(None) + } + WhereCondition::Ordering { + ordering, + bound: WhereOrderingBound::Constant(bound), + } => { + let Some((minimum, maximum)) = + integer_range(referenced, referenced_schema_defs, property)? + else { + return Ok(None); + }; + let can_hold = match ordering { + WhereOrdering::GreaterThan => maximum > *bound, + WhereOrdering::GreaterThanOrEqual => maximum >= *bound, + WhereOrdering::LessThan => minimum < *bound, + WhereOrdering::LessThanOrEqual => minimum <= *bound, + }; + Ok((!can_hold).then(|| { + format!( + "no value the referenced property holds, from {minimum} to {maximum}, is \ + {} {bound}", + ordering.wire_name() + ) + })) + } + _ => Ok(None), + } +} + +/// Why `literal`, compared with `property` of `referenced` read as `kind`, +/// can never equal a value the property holds, `None` when it can: it reads as +/// `kind` and, for a schema property, is one of its `enum` values, of a length +/// (a string) or a size (a byte array) it takes, and inside its range (an +/// integer). +fn where_literal_error( + referenced: DocumentTypeRef, + referenced_schema_defs: Option<&BTreeMap>, + property: &str, + kind: WhereLiteralKind, + literal: &WhereLiteral, +) -> Result, DataContractError> { + let Some(value) = literal.value_for(kind) else { + return Ok(Some(format!( + "the literal {literal} is not {}, as the referenced property holds", + kind.describe() + ))); + }; + if is_referenced_system_agreement_property(property) { + return Ok(None); + } + let Some(declared) = referenced.flattened_properties().get(property) else { + return Ok(None); + }; + let reason = match (&declared.property_type, &value) { + (DocumentPropertyType::String(sizes), Value::Text(text)) => { + let length = text.chars().count(); + if sizes + .min_length + .is_some_and(|minimum| length < usize::from(minimum)) + || sizes + .max_length + .is_some_and(|maximum| length > usize::from(maximum)) + || sizes + .max_bytes + .is_some_and(|maximum| text.len() > usize::from(maximum)) + { + Some("is not of a length the referenced property takes".to_string()) + } else if !enum_admits(referenced.schema(), referenced_schema_defs, property, text)? { + Some("is not one of the referenced property's enum values".to_string()) + } else { + None + } + } + (DocumentPropertyType::ByteArray(sizes), Value::Bytes(bytes)) => { + let size = bytes.len(); + (sizes + .min_size + .is_some_and(|minimum| size < usize::from(minimum)) + || sizes + .max_size + .is_some_and(|maximum| size > usize::from(maximum))) + .then(|| "is not of a size the referenced property takes".to_string()) + } + (_, value) if kind == WhereLiteralKind::Integer => { + match ( + integer_range(referenced, referenced_schema_defs, property)?, + value.to_integer::().ok(), + ) { + (Some((minimum, maximum)), Some(integer)) + if integer < minimum || integer > maximum => + { + Some(format!( + "is outside the values the referenced property holds, {minimum} to \ + {maximum}" + )) + } + _ => None, + } + } + _ => None, + }; + Ok(reason.map(|reason| format!("the literal {literal} {reason}"))) +} + +/// The integers the property `property` of `referenced` can hold: those of +/// its type, inside its schema's `minimum` and `maximum`. `None` for a +/// property that is no integer. +fn integer_range( + referenced: DocumentTypeRef, + referenced_schema_defs: Option<&BTreeMap>, + property: &str, +) -> Result, DataContractError> { + let Some(declared) = referenced.flattened_properties().get(property) else { + return Ok(None); + }; + let (mut minimum, mut maximum): (i128, i128) = match declared.property_type { + DocumentPropertyType::U8 => (0, u8::MAX.into()), + DocumentPropertyType::I8 => (i8::MIN.into(), i8::MAX.into()), + DocumentPropertyType::U16 => (0, u16::MAX.into()), + DocumentPropertyType::I16 => (i16::MIN.into(), i16::MAX.into()), + DocumentPropertyType::U32 => (0, u32::MAX.into()), + DocumentPropertyType::I32 => (i32::MIN.into(), i32::MAX.into()), + DocumentPropertyType::U64 => (0, u64::MAX.into()), + DocumentPropertyType::I64 => (i64::MIN.into(), i64::MAX.into()), + DocumentPropertyType::U128 => (0, i128::MAX), + DocumentPropertyType::I128 => (i128::MIN, i128::MAX), + _ => return Ok(None), + }; + if let Some(schema) = schema_at_path(referenced.schema(), referenced_schema_defs, property)? { + if let Some(declared_minimum) = schema + .get(property_names::MINIMUM) + .and_then(|value| whole_integer(value)) + { + minimum = minimum.max(declared_minimum); + } + if let Some(declared_maximum) = schema + .get(property_names::MAXIMUM) + .and_then(|value| whole_integer(value)) + { + maximum = maximum.min(declared_maximum); + } + } + Ok(Some((minimum, maximum))) } /// Checks what every `propertyConstraints` rule of `document_types`, one @@ -3902,32 +4246,41 @@ pub(in crate::data_contract::document_type::class_methods) fn validate_property_ .into_iter() .map(|(reference, _, _)| reference) .collect(); - for reference in references { - let Some(property) = declaring.flattened_properties().get(reference) else { - continue; - }; - let Some(declaration) = property - .property_type - .reference() - .and_then(|reference| reference.target()) - .or(property.revealed_reference.as_ref()) - .and_then(DocumentPropertyReferenceTarget::as_any_document_reference) - else { - continue; - }; + // The referenced type of the same contract a reference points at + let same_contract = |reference: &str| { + let declaration = property_reference(declaring, reference)? + .target()? + .as_any_document_reference()?; if declaration .contract_id .is_some_and(|referenced_contract| referenced_contract != contract_id) { - continue; + return None; } - let Some(referenced) = document_types.get(declaration.document_type_name) else { + let referenced = document_types.get(declaration.document_type_name)?.as_ref(); + (declaration.kind == referenced.document_reference_kind()) + .then_some((declaration, referenced)) + }; + if let Some(reason) = referenced_peer_kind_error( + constraint, + &format!("rule \"{rule}\""), + &|reference, field| { + same_contract(reference).and_then(|(_, referenced)| { + WhereLiteralKind::of_referenced(referenced, field) + }) + }, + ) { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "document type \"{type_name}\" {} {reason}", + property_names::PROPERTY_CONSTRAINTS + )), + )); + } + for reference in references { + let Some((declaration, referenced)) = same_contract(reference) else { continue; }; - let referenced = referenced.as_ref(); - if declaration.kind != referenced.document_reference_kind() { - continue; - } let records_creator_id = referenced.should_use_creator_id( contract_system_version, contract_config_version, @@ -4201,6 +4554,65 @@ pub(in crate::data_contract::document_type::class_methods) fn validate_property_ Ok(()) } +/// Why a string constant, affix or default `rule` (described by `subject`) +/// compares with a property of `schema` is none its `enum` admits, `None` when +/// each is: a constant or a default the `enum` does not list, or an affix none +/// of its values fits, is a typo the property could never meet. `lookup` +/// gives the property of `schema` a rule path reads, `None` for one it does +/// not check here, and `label` names a rule path in the message. +fn enum_miss( + rule: &PropertyConstraint, + subject: &str, + schema: &Value, + schema_defs: Option<&BTreeMap>, + lookup: &dyn Fn(&str) -> Result, DataContractError>, + label: &dyn Fn(&str) -> String, +) -> Result, DataContractError> { + for (path, constant) in rule.text_constants() { + let Some(property) = lookup(path)? else { + continue; + }; + if !enum_admits(schema, schema_defs, &property, constant)? { + return Ok(Some(format!( + "{subject} compares \"{}\" with \"{constant}\", which is not one of its enum \ + values", + label(path) + ))); + } + } + for (path, affix, position) in rule.text_affixes() { + let Some(property) = lookup(path)? else { + continue; + }; + if !enum_any(schema, schema_defs, &property, |member| { + position.holds(member, affix) + })? { + let tests = match position { + AffixPosition::Start => "starts with", + AffixPosition::End => "ends with", + }; + return Ok(Some(format!( + "{subject} tests whether \"{}\" {tests} \"{affix}\", which none of its enum \ + values does", + label(path) + ))); + } + } + for (path, default) in rule.text_defaults() { + let Some(property) = lookup(path)? else { + continue; + }; + if !enum_admits(schema, schema_defs, &property, default)? { + return Ok(Some(format!( + "{subject} gives \"{}\" the default \"{default}\", which is not one of its \ + enum values", + label(path) + ))); + } + } + Ok(None) +} + /// Whether the string property at the dotted `path` of `schema`, a document /// type's, may hold `value`: always, unless it declares an `enum` that does not /// list it. `$ref`s are followed into `schema_defs`, the contract's `$defs`. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/commit_reveal_lookup_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/commit_reveal_lookup_tests.rs index 9fab062f3e3..7236b727b67 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/commit_reveal_lookup_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/commit_reveal_lookup_tests.rs @@ -1286,3 +1286,40 @@ fn should_accept_a_find_by_function_reading_the_writer_on_a_type_that_can_change assert!(lookup.is_checked_on_create_only(true)); } } + +#[test] +fn should_refuse_a_where_bound_a_later_write_changes_beside_a_function() { + // A reveal is judged when the document is created only, so an ordering + // bounded by `$updatedAt`, which every replace moves, would go unchecked + // after the create; one bounded by the create's own time is fixed with it + let bounded_by = |bound: &str| { + contract(dpns_contract_with( + salt_reveal(json!({}), json!({ "deadline": { "greaterThan": bound } })), + json!({ + "documentsMutable": true, + "immutable": ["label", "normalizedLabel", "parentDomainName"], + "required": [ + "label", "normalizedLabel", "parentDomainName", "preorderSalt", + "$createdAt", "$updatedAt" + ] + }), + json!({ + "properties": { + "saltedDomainHash": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "position": 0 + }, + "deadline": { + "type": "integer", "minimum": 0, "maximum": 9007199254740991u64, + "position": 1 + } + } + }), + )) + }; + assert_refused( + bounded_by("$updatedAt"), + "is bounded by \"$updatedAt\" beside a findBy function", + ); + bounded_by("$createdAt").expect("a time of the create is fixed with it"); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs index d67b9b92431..c572c4538f8 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/referenced_rule_tests.rs @@ -11,16 +11,19 @@ use crate::data_contract::config::moderation::{ContractModerationConfig, Contrac use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; +use crate::data_contract::document_type::class_methods::where_condition_literal_error; use crate::data_contract::document_type::methods::DocumentTypeV0Methods; -use crate::data_contract::document_type::property_constraints::SystemProperty; use crate::data_contract::document_type::property_constraints::{ data_with_referenced_documents, referenced_fields, DocumentSystemValues, REFERENCED_DOCUMENT_KEY, }; +use crate::data_contract::document_type::property_constraints::{PropertyRead, SystemProperty}; use crate::data_contract::document_type::{ - DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentType, WhereBound, - WhereCondition, WhereLiteral, WhereOrdering, + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentType, DocumentTypeRef, + WhereCondition, WhereLiteral, WhereLiteralKind, WhereNotEqualBound, WhereOrdering, + WhereOrderingBound, }; +use crate::validation::SimpleConsensusValidationResult; use crate::ProtocolError; use platform_value::string_encoding::Encoding; use platform_value::{platform_value, Identifier, Value}; @@ -75,6 +78,7 @@ fn poll() -> Value { "position": 8 }, "curatorId": identifier(9, None), + "payload": { "type": "array", "byteArray": true, "maxItems": 34, "position": 10 }, }, "transient": ["note"], "required": ["question", "option0", "option1", "$createdAt"], @@ -252,6 +256,27 @@ fn judged(vote_data: Value, poll_values: Value) -> Value { .into_btree_string_map() .expect("the vote data is a map"); data_with_referenced_documents(&data, BTreeMap::from([("pollId".to_string(), poll_values)])) + .expect("the values are put under the reference") +} + +/// Judges `vote_data`, a vote of `vote`, by the rules reading the poll it +/// names, whose values are `poll_values`, with `system`. +fn judge( + vote: DocumentTypeRef, + vote_data: Value, + poll_values: Value, + system: &DocumentSystemValues, +) -> SimpleConsensusValidationResult { + let data: BTreeMap = vote_data + .into_btree_string_map() + .expect("the vote data is a map"); + vote.validate_referencing_property_constraints( + &data, + BTreeMap::from([("pollId".to_string(), poll_values)]), + system, + PlatformVersion::latest(), + ) + .expect("the rules evaluate") } #[test] @@ -275,32 +300,22 @@ fn should_judge_a_rule_by_the_values_of_the_referenced_document() { }); let poll_id = Value::Identifier([5; 32]); let vote_data = platform_value!({ "pollId": poll_id.clone(), "pollOwnerId": poll_owner.clone(), "choice": 1u8 }); - let data = judged(vote_data.clone(), open_poll.clone()); - let result = vote - .validate_referencing_property_constraints(&data, &system, PlatformVersion::latest()) - .expect("the rules evaluate"); + let result = judge(vote, vote_data.clone(), open_poll.clone(), &system); assert!(result.is_valid(), "{:?}", result.errors); // The poll has two options, so choice 2 is none of them - let data = judged( + let result = judge( + vote, platform_value!({ "pollId": poll_id.clone(), "pollOwnerId": poll_owner.clone(), "choice": 2u8 }), open_poll.clone(), + &system, ); - let result = vote - .validate_referencing_property_constraints(&data, &system, PlatformVersion::latest()) - .expect("the rules evaluate"); assert!(format!("{:?}", result.errors).contains("choiceIsAnOption")); // Past the end let mut ended = open_poll.clone(); ended.set_value("endsAt", Value::U64(900)).expect("set"); - let result = vote - .validate_referencing_property_constraints( - &judged(vote_data.clone(), ended), - &system, - PlatformVersion::latest(), - ) - .expect("the rules evaluate"); + let result = judge(vote, vote_data.clone(), ended, &system); assert!(format!("{:?}", result.errors).contains("castBeforeTheEnd")); // A multiple-choice poll is no single-choice vote's @@ -308,25 +323,13 @@ fn should_judge_a_rule_by_the_values_of_the_referenced_document() { multi .set_value("multiChoice", Value::Bool(true)) .expect("set"); - let result = vote - .validate_referencing_property_constraints( - &judged(vote_data.clone(), multi), - &system, - PlatformVersion::latest(), - ) - .expect("the rules evaluate"); + let result = judge(vote, vote_data.clone(), multi, &system); assert!(format!("{:?}", result.errors).contains("singleChoicePoll")); // A poll without an end is open for good: the default stands in for endsAt let mut endless = open_poll; endless.remove("endsAt").expect("remove"); - let result = vote - .validate_referencing_property_constraints( - &judged(vote_data.clone(), endless), - &system, - PlatformVersion::latest(), - ) - .expect("the rules evaluate"); + let result = judge(vote, vote_data.clone(), endless, &system); assert!(result.is_valid(), "{:?}", result.errors); } @@ -495,8 +498,12 @@ fn should_refuse_a_read_the_referenced_type_can_not_answer() { /// operand of the type, while the referenced document's own identifiers /// (`$id`, `$ownerId`, `$creatorId`), always identifiers, take a base58 `const` /// too. +/// The parse of a rule does not know what a field of a referenced document +/// holds, so a string constant compared with one reads as the field holds: an +/// identifier written base58, a byte array hex. Registration, with the +/// referenced type in hand, checks the constant spells one. #[test] -fn should_compare_a_referenced_identifier_as_the_rule_says() { +fn should_compare_a_referenced_field_as_what_it_holds() { let base58 = Identifier::new([4; 32]).to_string(Encoding::Base58); parse_rules(platform_value!({ "rule": { "equal": [{ "referenced": ["pollId", "curatorId"] }, "pollOwnerId"] } @@ -506,12 +513,185 @@ fn should_compare_a_referenced_identifier_as_the_rule_says() { "rule": { "notEqual": [{ "referenced": ["pollId", "$ownerId"] }, { "const": base58.clone() }] } })) .expect("the referenced document's owner takes a base58 const"); + let types = parse_rules(platform_value!({ + "curated": { "equal": [{ "referenced": ["pollId", "curatorId"] }, { "const": base58.clone() }] }, + "listed": { "in": [{ "referenced": ["pollId", "curatorId"] }, [base58.clone(), Identifier::new([5; 32]).to_string(Encoding::Base58)]] }, + "multihash": { "startsWith": [{ "referenced": ["pollId", "payload"] }, { "const": "1220" }] }, + "voterListed": { "contains": [{ "referenced": ["pollId", "voters"] }, { "const": base58.clone() }] } + })) + .expect("an identifier takes base58 constants and a byte array a hex affix"); + let vote = types.get("vote").expect("the vote type").as_ref(); + let system = DocumentSystemValues::owned_by(Identifier::new([3; 32])); + let vote_data = platform_value!({ "pollId": Value::Identifier([5; 32]), "pollOwnerId": Value::Identifier([4; 32]), "choice": 0u8 }); + // The values as a stored poll decodes them: identifiers as identifiers, a + // byte array as bytes + let poll = |curator: u8, payload: Vec, voter: u8| { + Value::Map(vec![ + ( + Value::Text("curatorId".to_string()), + Value::Identifier([curator; 32]), + ), + (Value::Text("payload".to_string()), Value::Bytes(payload)), + ( + Value::Text("voters".to_string()), + Value::Array(vec![Value::Identifier([voter; 32])]), + ), + ]) + }; + let result = judge( + vote, + vote_data.clone(), + poll(4, vec![0x12, 0x20, 0xab], 4), + &system, + ); + assert!(result.is_valid(), "{:?}", result.errors); + for (poll_values, broken) in [ + (poll(6, vec![0x12, 0x20], 4), "curated"), + (poll(4, vec![0x12, 0x21], 4), "multihash"), + (poll(4, vec![0x12, 0x20], 7), "voterListed"), + ] { + let result = judge(vote, vote_data.clone(), poll_values, &system); + assert!( + format!("{:?}", result.errors).contains(broken), + "expected {broken} to break: {:?}", + result.errors + ); + } + + for (rule, needle) in [ + ( + platform_value!({ "equal": [{ "referenced": ["pollId", "curatorId"] }, { "const": "not base58 0OIl" }] }), + "which spells none", + ), + ( + platform_value!({ "startsWith": [{ "referenced": ["pollId", "payload"] }, { "const": "zz" }] }), + "which is not hex", + ), + ( + platform_value!({ "startsWith": [{ "referenced": ["pollId", "curatorId"] }, { "const": "12" }] }), + "which has no start or end", + ), + ( + platform_value!({ "equal": [{ "referenced": ["pollId", "curatorId"] }, "label"] }), + "a string property of the type", + ), + ( + platform_value!({ "equal": [{ "referenced": ["pollId", "endsAt"] }, { "const": "soon" }] }), + "which spells none", + ), + ] { + expect_structure_error(parse_rules(platform_value!({ "rule": rule })), needle); + } +} + +/// Two fields of referenced documents compared with each other compare by +/// value, whatever they hold, one left out equalling nothing; registration +/// refuses two that hold different kinds of value, which never agree. +#[test] +fn should_compare_two_referenced_fields_by_value() { + let types = parse_rules(platform_value!({ + "sameStatus": { "equal": [{ "referenced": ["pollId", "status"] }, { "referenced": ["postId", "hashtag"] }] } + })) + .expect("two strings compare"); + let vote = types.get("vote").expect("the vote type").as_ref(); + let system = DocumentSystemValues::owned_by(Identifier::new([3; 32])); + let data: BTreeMap = platform_value!({ "pollId": Value::Identifier([5; 32]), "postId": Value::Identifier([6; 32]), "choice": 0u8 }) + .into_btree_string_map() + .expect("a map"); + let judge_with = |status: Option<&str>, hashtag: Option<&str>| { + let poll = status.map_or_else( + || platform_value!({}), + |status| platform_value!({ "status": status }), + ); + let post = hashtag.map_or_else( + || platform_value!({}), + |hashtag| platform_value!({ "hashtag": hashtag }), + ); + vote.validate_referencing_property_constraints( + &data, + BTreeMap::from([("pollId".to_string(), poll), ("postId".to_string(), post)]), + &system, + PlatformVersion::latest(), + ) + .expect("the rules evaluate") + .is_valid() + }; + assert!(judge_with(Some("open"), Some("open"))); + assert!(!judge_with(Some("open"), Some("closed"))); + assert!(!judge_with(None, None)); + + expect_structure_error( + parse_rules(platform_value!({ + "rule": { "equal": [{ "referenced": ["pollId", "endsAt"] }, { "referenced": ["postId", "hashtag"] }] } + })), + "which hold different kinds of value", + ); expect_structure_error( parse_rules(platform_value!({ - "rule": { "equal": [{ "referenced": ["pollId", "curatorId"] }, { "const": base58 }] } + "rule": { "notEqual": [{ "referenced": ["pollId", "endsAt"] }, { "referenced": ["pollId", "status"] }] } })), - "compares \"pollId.curatorId\" with a string", + "which does not", + ); +} + +/// A reference inside an object is read through by its dotted path. +#[test] +fn should_read_through_a_reference_inside_an_object() { + let mut nested = vote( + poll_reference(), + platform_value!({ + "beforeTheEnd": { "lessThan": ["$createdAt", { "referenced": ["meta.pollId", "endsAt"] }] } + }), + vec![], + ); + nested + .set_value_at_full_path( + "properties.meta", + platform_value!({ + "type": "object", + "properties": { "pollId": identifier(0, Some(poll_reference())) }, + "required": ["pollId"], + "additionalProperties": false, + "position": 6, + }), + ) + .expect("the vote takes the object"); + let types = parse(nested).expect("a nested reference reads"); + let vote = types.get("vote").expect("the vote type").as_ref(); + let rule = vote + .property_constraints() + .get("beforeTheEnd") + .expect("the rule"); + assert_eq!( + rule.referenced_reads(), + vec![("meta.pollId", "endsAt", PropertyRead::Value)] ); + let system = DocumentSystemValues { + created_at: Some(1_000), + ..DocumentSystemValues::owned_by(Identifier::new([3; 32])) + }; + let data: BTreeMap = platform_value!({ "pollId": Value::Identifier([5; 32]), "pollOwnerId": Value::Identifier([4; 32]), "choice": 0u8, "meta": { "pollId": Value::Identifier([5; 32]) } }) + .into_btree_string_map() + .expect("a map"); + for (ends_at, holds) in [(2_000u64, true), (500, false)] { + let result = vote + .validate_referencing_property_constraints( + &data, + BTreeMap::from([( + "meta.pollId".to_string(), + platform_value!({ "endsAt": ends_at }), + )]), + &system, + PlatformVersion::latest(), + ) + .expect("the rules evaluate"); + assert_eq!( + result.is_valid(), + holds, + "endsAt {ends_at}: {:?}", + result.errors + ); + } } #[test] @@ -785,15 +965,15 @@ fn should_refuse_a_malformed_referenced_operand() { "must list a reference property", ), ( - platform_value!({ "lessThan": [{ "referenced": ["meta.pollId", "endsAt"] }, 5] }), - "a reference property is a top-level property", + platform_value!({ "lessThan": [{ "referenced": ["meta..pollId", "endsAt"] }, 5] }), + "a reference property is the dotted path", ), ( platform_value!({ "lessThan": [{ "referenced": ["pollId", "$revision"] }, 5] }), "a field of the referenced document", ), ( - platform_value!({ "lessThan": ["$referenced.pollId.endsAt", 5] }), + platform_value!({ "lessThan": ["$referenced.pollId.$.endsAt", 5] }), "is written { \"referenced\"", ), ] { @@ -869,18 +1049,18 @@ fn should_parse_where_entries_that_are_not_equalities() { conditions.get("endsAt"), Some(&WhereCondition::Ordering { ordering: WhereOrdering::GreaterThan, - bound: WhereBound::System(SystemProperty::CreatedAt) + bound: WhereOrderingBound::System(SystemProperty::CreatedAt) }) ); assert_eq!( conditions.get("option0"), - Some(&WhereCondition::NotEqual(WhereBound::Property( + Some(&WhereCondition::NotEqual(WhereNotEqualBound::Property( "label".to_string() ))) ); assert_eq!( conditions.get("$id"), - Some(&WhereCondition::NotEqual(WhereBound::Writer)) + Some(&WhereCondition::NotEqual(WhereNotEqualBound::Writer)) ); // The equality stays an equality assert_eq!(conditions.len(), 5); @@ -974,3 +1154,82 @@ fn should_hold_a_moderated_writer_inequality_to_what_every_record_keeps() { .expect("the property applies"); expect_structure_error(parse(refused), "compares the writer"); } + +/// A `where` literal no value of the referenced property can equal, or an +/// ordering no value of it meets, would refuse every write or hold for every +/// one, so registration refuses it, the referenced type in hand. +#[test] +fn should_refuse_a_where_literal_the_referenced_property_never_holds() { + let types = parse_rules(pollr_rules()).expect("the types parse"); + let poll = types.get("poll").expect("the poll type").as_ref(); + let text = |text: &str| WhereLiteral::Text(text.to_string()); + let error = |property: &str, kind: WhereLiteralKind, condition: WhereCondition| { + where_condition_literal_error(poll, None, property, kind, &condition) + .expect("the schema reads") + }; + for (property, kind, condition, reason) in [ + ( + "status", + WhereLiteralKind::Text, + WhereCondition::Constant(text("paused")), + "enum values", + ), + ( + "option0", + WhereLiteralKind::Text, + WhereCondition::NotEqual(WhereNotEqualBound::Constant(text(&"x".repeat(101)))), + "of a length", + ), + ( + "payload", + WhereLiteralKind::Bytes, + WhereCondition::In(vec![text("cafe"), text("CAFE")]), + "an earlier literal", + ), + ( + "endsAt", + WhereLiteralKind::Integer, + WhereCondition::Constant(WhereLiteral::Integer(-1)), + "outside the values", + ), + ( + "endsAt", + WhereLiteralKind::Integer, + WhereCondition::Ordering { + ordering: WhereOrdering::GreaterThan, + bound: WhereOrderingBound::Constant(9007199254740991), + }, + "no value the referenced property holds", + ), + ] { + let refused = error(property, kind, condition); + assert!( + refused + .as_deref() + .is_some_and(|refused| refused.contains(reason)), + "{property}: {refused:?}" + ); + } + for (property, kind, condition) in [ + ( + "status", + WhereLiteralKind::Text, + WhereCondition::In(vec![text("open"), text("closed")]), + ), + ( + "payload", + WhereLiteralKind::Bytes, + WhereCondition::In(vec![text("cafe"), text("beef")]), + ), + ( + "endsAt", + WhereLiteralKind::Integer, + WhereCondition::Ordering { + ordering: WhereOrdering::GreaterThanOrEqual, + bound: WhereOrderingBound::Constant(9007199254740991), + }, + ), + ] { + assert_eq!(error(property, kind, condition), None, "{property}"); + } +} 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 48f78a5bc40..5a9977060eb 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 @@ -1052,17 +1052,18 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe /// (`{ "referenced": [reference, field] }`), in name order: the first rule it breaks /// fails with `DocumentPropertyConstraintViolatedError` (10422), as /// [`Self::validate_property_constraints`] would. `data` holds the document's - /// properties and, under `$referenced`, the values those rules read of the referenced - /// documents, which consensus reads with the references it checks, so `system` must - /// say it does ([`DocumentSystemValues::referenced_documents`]); every other rule of - /// the type was judged before the references were checked, and is not judged again. - /// A type with no such rule costs nothing. + /// properties and `referenced`, by reference property, the values those rules read of + /// the documents the references point at, which consensus reads with the references it + /// checks; `system` holds the rest, the totals included. Every other rule of the type + /// was judged before the references were checked, and is not judged again. A type with + /// no such rule costs nothing, the data not even copied. /// /// Versioned with [`Self::validate_property_constraints`]: `None` before protocol /// version 14, where no parsed document type carries a rule. fn validate_referencing_property_constraints( &self, - data: &Value, + data: &BTreeMap, + referenced: BTreeMap, system: &DocumentSystemValues, platform_version: &PlatformVersion, ) -> Result @@ -1077,7 +1078,7 @@ pub trait DocumentTypeV0Methods: DocumentTypeV0Getters + DocumentTypeV0MethodsVe .validate_property_constraints { None => Ok(SimpleConsensusValidationResult::default()), - Some(0) => self.validate_referencing_property_constraints_v0(data, system), + Some(0) => self.validate_referencing_property_constraints_v0(data, referenced, system), Some(version) => Err(ProtocolError::UnknownVersionMismatch { method: "validate_referencing_property_constraints".to_string(), known_versions: vec![0], diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs index 9e875fed414..5bd3e9d01cd 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/versioned_methods.rs @@ -5,7 +5,7 @@ use crate::data_contract::document_type::accessors::{ }; use crate::data_contract::document_type::methods::DocumentTypeBasicMethods; use crate::data_contract::document_type::property_constraints::{ - DocumentSystemValues, PropertyConstraint, SystemChange, + data_with_referenced_documents, DocumentSystemValues, PropertyConstraint, SystemChange, }; use crate::data_contract::document_type::v0::DocumentTypeV0; use crate::data_contract::document_type::v1::DocumentTypeV1; @@ -888,7 +888,13 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa where Self: DocumentTypeV2Getters, { - for (name, constraint) in self.property_constraints() { + // A rule reading a referenced document waits for the values, and no + // total of it is read before + for (name, constraint) in self + .property_constraints() + .iter() + .filter(|(_, constraint)| constraint.is_judged_with(system)) + { self.expect_every_aggregate_read(name, constraint, system)?; if let Some(violation) = constraint.violation(data, system) { return Ok(SimpleConsensusValidationResult::new_with_error( @@ -906,24 +912,36 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa /// `validate_referencing_property_constraints` version 0: every rule of the document /// type's `propertyConstraints` reading a document one of its references points at is - /// evaluated against `data`, which holds those values under `$referenced`, in name - /// order, and the first one broken is reported. The type's other rules are not judged - /// again. A rule reading a total consensus did not read is an error. + /// evaluated, in name order, against `data` with `referenced` under `$referenced` + /// ([`data_with_referenced_documents`]), and the first one broken is reported. The data + /// is copied only when such a rule exists. The type's other rules are not judged again. + /// A rule reading a total consensus did not read is an error. fn validate_referencing_property_constraints_v0( &self, - data: &Value, + data: &BTreeMap, + referenced: BTreeMap, system: &DocumentSystemValues, ) -> Result where Self: DocumentTypeV2Getters, { - for (name, constraint) in self + let mut rules = self .property_constraints() .iter() .filter(|(_, constraint)| constraint.reads_referenced()) - { - self.expect_every_aggregate_read(name, constraint, system)?; - if let Some(violation) = constraint.violation(data, system) { + .peekable(); + if rules.peek().is_none() { + return Ok(SimpleConsensusValidationResult::default()); + } + let data = data_with_referenced_documents(data, referenced)?; + // The values the rules read are now in the data + let system = DocumentSystemValues { + referenced_documents: true, + ..system.clone() + }; + for (name, constraint) in rules { + self.expect_every_aggregate_read(name, constraint, &system)?; + if let Some(violation) = constraint.violation(&data, &system) { return Ok(SimpleConsensusValidationResult::new_with_error( DocumentPropertyConstraintViolatedError::new( self.name().clone(), @@ -1003,7 +1021,9 @@ pub trait DocumentTypeV0MethodsVersioned: DocumentTypeV0Getters + DocumentTypeBa let mut changed_rules = self .property_constraints() .iter() - .filter(|(_, constraint)| constraint.reads_change(change)) + .filter(|(_, constraint)| { + constraint.reads_change(change) && constraint.is_judged_with(system) + }) .peekable(); if changed_rules.peek().is_none() { return Ok(SimpleConsensusValidationResult::default()); 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 b84b3e5eda4..5a62ca8127a 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 @@ -62,7 +62,8 @@ pub use reference_expression::{ }; pub use reference_lookup::{DocumentReferenceLookup, LookupKeySource}; pub use reference_where::{ - WhereBound, WhereCondition, WhereLiteral, WhereLiteralKind, WhereOrdering, + WhereBoundRead, WhereCondition, WhereLiteral, WhereLiteralKind, WhereNotEqualBound, + WhereOrdering, WhereOrderingBound, }; #[cfg(test)] diff --git a/packages/rs-dpp/src/data_contract/document_type/property/reference_where.rs b/packages/rs-dpp/src/data_contract/document_type/property/reference_where.rs index 344ee6a2d17..a24f6618d9f 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/reference_where.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/reference_where.rs @@ -28,14 +28,15 @@ use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::property::{ is_referenced_system_agreement_property, DocumentPropertyType, }; -use crate::data_contract::document_type::property_constraints::SystemProperty; +use crate::data_contract::document_type::property_constraints::{ + spells_bytes, spells_identifier, whole_integer, SystemProperty, +}; use crate::data_contract::document_type::DocumentTypeRef; use crate::document::property_names::OWNER_ID; use bincode::{Decode, DecodeUntrusted, Encode}; use platform_value::string_encoding::Encoding; use platform_value::{Identifier, Value}; use serde::Serialize; -use std::collections::BTreeSet; use std::fmt; /// The key of a `where` entry's literal: `{ "const": ... }`. @@ -140,17 +141,8 @@ impl WhereLiteral { match value { Value::Bool(flag) => Some(WhereLiteral::Boolean(*flag)), Value::Text(text) => Some(WhereLiteral::Text(text.clone())), - // The cast is exact: the float is a whole number inside the range - Value::Float(float) - if float.fract() == 0.0 - && *float >= i128::MIN as f64 - && *float < i128::MAX as f64 => - { - Some(WhereLiteral::Integer(*float as i128)) - } - value if value.is_integer() => { - value.to_integer::().ok().map(WhereLiteral::Integer) - } + Value::Float(_) => whole_integer(value).map(WhereLiteral::Integer), + value if value.is_integer() => whole_integer(value).map(WhereLiteral::Integer), _ => None, } } @@ -187,6 +179,15 @@ impl WhereLiteral { (WhereLiteral::Text(text), WhereLiteralKind::Text) => { matches!(value, Value::Text(held) if held == text) } + // Decoded in place, beside the held bytes + (WhereLiteral::Text(text), WhereLiteralKind::Identifier) => value + .as_bytes_slice() + .ok() + .and_then(|held| <&[u8; 32]>::try_from(held).ok()) + .is_some_and(|held| spells_identifier(text, held)), + (WhereLiteral::Text(text), WhereLiteralKind::Bytes) => value + .as_bytes_slice() + .is_ok_and(|held| spells_bytes(text, held)), _ => self .value_for(kind) .is_some_and(|literal| value.same_scalar_data(&literal)), @@ -199,65 +200,92 @@ impl fmt::Display for WhereLiteral { match self { WhereLiteral::Integer(integer) => write!(f, "{integer}"), WhereLiteral::Boolean(flag) => write!(f, "{flag}"), - WhereLiteral::Text(text) => write!(f, "\"{text}\""), + // As a JSON string, quotes and backslashes escaped + WhereLiteral::Text(text) => match serde_json::to_string(text) { + Ok(quoted) => f.write_str("ed), + Err(_) => Err(fmt::Error), + }, } } } -/// What the referenced side of a `notEqual` or of an ordering is compared -/// with. +/// What the referenced side of a `notEqual` must differ from. // Embedded in `DocumentPropertyReferenceTarget`, which consensus errors carry. // @append_only #[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] #[serde(rename_all = "camelCase")] -pub enum WhereBound { +pub enum WhereNotEqualBound { /// A property of the referring document, by its dotted path. Property(String), /// `"$ownerId"`: the writer. Writer, /// `{ "const": ... }`: a literal. Constant(WhereLiteral), +} + +impl fmt::Display for WhereNotEqualBound { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WhereNotEqualBound::Property(path) => write!(f, "\"{path}\""), + WhereNotEqualBound::Writer => write!(f, "\"{OWNER_ID}\""), + WhereNotEqualBound::Constant(literal) => write!(f, "{{ \"const\": {literal} }}"), + } + } +} + +/// What the referenced side of an ordering, an integer, is compared with. +// Embedded in `DocumentPropertyReferenceTarget`, which consensus errors carry. +// @append_only +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] +#[serde(rename_all = "camelCase")] +pub enum WhereOrderingBound { + /// An integer property of the referring document, by its dotted path. + Property(String), + /// `{ "const": ... }`: an integer. + Constant(i128), /// A time or height the referring document records (`"$createdAt"`), - /// which its type lists in `required`: an ordering only. + /// which its type lists in `required`. System(SystemProperty), } -impl WhereBound { - /// The property of the referring document the bound reads, `None` for the - /// writer, a literal or a time or height. - pub fn referring_property(&self) -> Option<&str> { +impl fmt::Display for WhereOrderingBound { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - WhereBound::Property(path) => Some(path), - WhereBound::Writer | WhereBound::Constant(_) | WhereBound::System(_) => None, + WhereOrderingBound::Property(path) => write!(f, "\"{path}\""), + WhereOrderingBound::Constant(integer) => write!(f, "{{ \"const\": {integer} }}"), + WhereOrderingBound::System(property) => write!(f, "\"{}\"", property.name()), } } +} + +/// A value of the referring write a `notEqual` or an ordering compares the +/// referenced side with ([`WhereCondition::bound_read`]). +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum WhereBoundRead<'a> { + /// The referring document's property at this dotted path. + Property(&'a str), + /// The writer, an identifier. + Writer, + /// A time or height the referring document records, an integer. + System(SystemProperty), +} - /// Whether the value the bound reads may differ between two writes of the - /// referring document that change none of its properties: the writer, who - /// may be a new owner after a transfer or a purchase, and the time and - /// heights of the last update or transfer. An entry with such a bound is - /// checked again on every replace, as a writer gate is. - pub fn changes_without_a_write(&self) -> bool { +impl WhereBoundRead<'_> { + /// Whether the value may differ between two writes of the referring + /// document that change none of its properties: the writer, who may be a + /// new owner after a transfer or a purchase, and the time and heights of + /// the last update or transfer. An entry reading one is checked again on + /// every replace, as a writer gate is. + pub fn changes_without_a_write(self) -> bool { match self { - WhereBound::Writer => true, - WhereBound::System(property) => !matches!( + WhereBoundRead::Writer => true, + WhereBoundRead::System(property) => !matches!( property, SystemProperty::CreatedAt | SystemProperty::CreatedAtBlockHeight | SystemProperty::CreatedAtCoreBlockHeight ), - WhereBound::Property(_) | WhereBound::Constant(_) => false, - } - } -} - -impl fmt::Display for WhereBound { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - WhereBound::Property(path) => write!(f, "\"{path}\""), - WhereBound::Writer => write!(f, "\"{OWNER_ID}\""), - WhereBound::Constant(literal) => write!(f, "{{ \"const\": {literal} }}"), - WhereBound::System(property) => write!(f, "\"{}\"", property.name()), + WhereBoundRead::Property(_) => false, } } } @@ -321,17 +349,18 @@ pub enum WhereCondition { /// `{ "notEqual": bound }`: not equal to the bound, a property of the /// referring document, the writer or a literal; the negation of an /// equality, so two absent sides are equal and one absent side differs. - NotEqual(WhereBound), + NotEqual(WhereNotEqualBound), /// `{ "in": [literal, ...] }`: equal to one of two or more distinct - /// literals. A referenced side the document leaves out is in none. - In(BTreeSet), + /// literals, in the order the declaration lists them. A referenced side + /// the document leaves out is in none. + In(Vec), /// `{ "greaterThan": bound }` and the other orderings: an integer that - /// compares with the bound, a property of the referring document, a - /// literal or a time or height of the referring document, this way. Holds - /// only when both sides have a value. + /// compares with the bound, an integer property of the referring document, + /// an integer or a time or height of the referring document, this way. + /// Holds only when both sides have a value. Ordering { ordering: WhereOrdering, - bound: WhereBound, + bound: WhereOrderingBound, }, } @@ -346,36 +375,54 @@ impl WhereCondition { } } - /// The bound a `notEqual` or an ordering compares with, `None` for a - /// literal or a list of them. - pub fn bound(&self) -> Option<&WhereBound> { + /// The value of the referring write the condition compares with, `None` + /// for one that carries its own: a literal or a list of them. + pub fn bound_read(&self) -> Option> { match self { - WhereCondition::NotEqual(bound) | WhereCondition::Ordering { bound, .. } => Some(bound), - WhereCondition::Constant(_) | WhereCondition::In(_) => None, + WhereCondition::NotEqual(WhereNotEqualBound::Property(path)) + | WhereCondition::Ordering { + bound: WhereOrderingBound::Property(path), + .. + } => Some(WhereBoundRead::Property(path)), + WhereCondition::NotEqual(WhereNotEqualBound::Writer) => Some(WhereBoundRead::Writer), + WhereCondition::Ordering { + bound: WhereOrderingBound::System(property), + .. + } => Some(WhereBoundRead::System(*property)), + WhereCondition::Constant(_) + | WhereCondition::In(_) + | WhereCondition::NotEqual(WhereNotEqualBound::Constant(_)) + | WhereCondition::Ordering { + bound: WhereOrderingBound::Constant(_), + .. + } => None, } } /// The property of the referring document the condition reads, `None` /// when it reads none. pub fn referring_property(&self) -> Option<&str> { - self.bound().and_then(WhereBound::referring_property) + match self.bound_read() { + Some(WhereBoundRead::Property(path)) => Some(path), + _ => None, + } } /// Whether the condition is checked again on every replace of the - /// referring document, as a writer gate is: its bound is the writer or the + /// referring document, as a writer gate is: it reads the writer or the /// time or heights of the last update or transfer - /// ([`WhereBound::changes_without_a_write`]). + /// ([`WhereBoundRead::changes_without_a_write`]). pub fn is_rechecked_on_every_replace(&self) -> bool { - self.bound() - .is_some_and(WhereBound::changes_without_a_write) + self.bound_read() + .is_some_and(WhereBoundRead::changes_without_a_write) } /// Whether the condition holds for `referenced`, the referenced side's /// value (`None` when the document leaves it out) of `kind` (`None` for a /// property no literal reads as, which registration refuses a literal - /// for), and `bound`, the value of a bound the referring document reads - /// (`None` when it leaves it out, and for a literal or a list, which carry - /// their own): a time or height reads as an integer, the writer as an + /// for), and `bound`, the value [`Self::bound_read`] names (`None` when the + /// write leaves it out, and for a literal or a list, which carry their + /// own): a time or height reads as an integer, the writer as an /// identifier. pub fn holds( &self, @@ -393,9 +440,13 @@ impl WhereCondition { match self { WhereCondition::Constant(literal) => equals_literal(literal), WhereCondition::In(literals) => literals.iter().any(equals_literal), - WhereCondition::NotEqual(WhereBound::Constant(literal)) => !equals_literal(literal), + WhereCondition::NotEqual(WhereNotEqualBound::Constant(literal)) => { + !equals_literal(literal) + } // The negation of an equality entry: both absent agree - WhereCondition::NotEqual(_) => !match (referenced, bound) { + WhereCondition::NotEqual( + WhereNotEqualBound::Property(_) | WhereNotEqualBound::Writer, + ) => !match (referenced, bound) { (Some(referenced), Some(bound)) => referenced.same_scalar_data(bound), (None, None) => true, (Some(_), None) | (None, Some(_)) => false, @@ -410,9 +461,8 @@ impl WhereCondition { .and_then(|value| value.to_integer::().ok()) }; let bound = match declared { - WhereBound::Constant(WhereLiteral::Integer(integer)) => Some(*integer), - WhereBound::Constant(_) => None, - WhereBound::Property(_) | WhereBound::Writer | WhereBound::System(_) => { + WhereOrderingBound::Constant(integer) => Some(*integer), + WhereOrderingBound::Property(_) | WhereOrderingBound::System(_) => { integer(bound) } }; @@ -451,7 +501,7 @@ impl fmt::Display for WhereCondition { mod tests { use super::*; - fn ordering(ordering: WhereOrdering, bound: WhereBound) -> WhereCondition { + fn ordering(ordering: WhereOrdering, bound: WhereOrderingBound) -> WhereCondition { WhereCondition::Ordering { ordering, bound } } @@ -532,7 +582,7 @@ mod tests { #[test] fn should_negate_equality_for_not_equal() { - let other = WhereCondition::NotEqual(WhereBound::Writer); + let other = WhereCondition::NotEqual(WhereNotEqualBound::Writer); let kind = Some(WhereLiteralKind::Identifier); let writer = Value::Identifier([1; 32]); assert!(!other.holds(Some(&Value::Identifier([1; 32])), kind, Some(&writer))); @@ -540,15 +590,15 @@ mod tests { // Both sides absent are equal, so notEqual does not hold; one absent // differs, so it does - let unlike = WhereCondition::NotEqual(WhereBound::Property("tag".to_string())); + let unlike = WhereCondition::NotEqual(WhereNotEqualBound::Property("tag".to_string())); let kind = Some(WhereLiteralKind::Text); assert!(!unlike.holds(None, kind, None)); assert!(unlike.holds(Some(&Value::Text("a".to_string())), kind, None)); assert!(unlike.holds(None, kind, Some(&Value::Text("a".to_string())))); - let not_closed = WhereCondition::NotEqual(WhereBound::Constant(WhereLiteral::Text( - "closed".to_string(), - ))); + let not_closed = WhereCondition::NotEqual(WhereNotEqualBound::Constant( + WhereLiteral::Text("closed".to_string()), + )); assert!(not_closed.holds(None, kind, None)); assert!(!not_closed.holds(Some(&Value::Text("closed".to_string())), kind, None)); } @@ -558,7 +608,7 @@ mod tests { let kind = Some(WhereLiteralKind::Integer); let after_creation = ordering( WhereOrdering::GreaterThan, - WhereBound::System(SystemProperty::CreatedAt), + WhereOrderingBound::System(SystemProperty::CreatedAt), ); assert!(after_creation.holds(Some(&Value::U64(10)), kind, Some(&Value::U64(9)))); assert!(!after_creation.holds(Some(&Value::U64(9)), kind, Some(&Value::U64(9)))); @@ -567,14 +617,14 @@ mod tests { let at_most_amount = ordering( WhereOrdering::LessThanOrEqual, - WhereBound::Property("amount".to_string()), + WhereOrderingBound::Property("amount".to_string()), ); assert!(at_most_amount.holds(Some(&Value::I64(-5)), kind, Some(&Value::U32(0)))); assert!(!at_most_amount.holds(Some(&Value::I64(1)), kind, Some(&Value::U32(0)))); let at_least_ten = ordering( WhereOrdering::GreaterThanOrEqual, - WhereBound::Constant(WhereLiteral::Integer(10)), + WhereOrderingBound::Constant(10), ); assert!(at_least_ten.holds(Some(&Value::U16(10)), kind, None)); assert!(!at_least_ten.holds(Some(&Value::U16(9)), kind, None)); @@ -584,25 +634,27 @@ mod tests { #[test] fn should_recheck_on_every_replace_only_bounds_that_change_without_a_write() { - assert!(WhereCondition::NotEqual(WhereBound::Writer).is_rechecked_on_every_replace()); + assert!( + WhereCondition::NotEqual(WhereNotEqualBound::Writer).is_rechecked_on_every_replace() + ); assert!(ordering( WhereOrdering::LessThan, - WhereBound::System(SystemProperty::UpdatedAt) + WhereOrderingBound::System(SystemProperty::UpdatedAt) ) .is_rechecked_on_every_replace()); assert!(ordering( WhereOrdering::LessThan, - WhereBound::System(SystemProperty::TransferredAtBlockHeight) + WhereOrderingBound::System(SystemProperty::TransferredAtBlockHeight) ) .is_rechecked_on_every_replace()); assert!(!ordering( WhereOrdering::LessThan, - WhereBound::System(SystemProperty::CreatedAt) + WhereOrderingBound::System(SystemProperty::CreatedAt) ) .is_rechecked_on_every_replace()); assert!(!ordering( WhereOrdering::LessThan, - WhereBound::Property("amount".to_string()) + WhereOrderingBound::Property("amount".to_string()) ) .is_rechecked_on_every_replace()); assert!( @@ -615,7 +667,7 @@ mod tests { assert_eq!( ordering( WhereOrdering::GreaterThan, - WhereBound::System(SystemProperty::CreatedAt) + WhereOrderingBound::System(SystemProperty::CreatedAt) ) .to_string(), "{ \"greaterThan\": \"$createdAt\" }" @@ -631,7 +683,38 @@ mod tests { .collect() ) .to_string(), - "{ \"in\": [1, 2] }" + "{ \"in\": [2, 1] }" + ); + // A string as JSON writes it, quotes and backslashes escaped + assert_eq!( + WhereCondition::Constant(WhereLiteral::Text("say \"hi\" \\o/".to_string())).to_string(), + r#"{ "const": "say \"hi\" \\o/" }"# + ); + } + + #[test] + fn should_compare_a_literal_as_the_value_it_reads_as() { + let id = Identifier::new([7; 32]); + let base58 = WhereLiteral::Text(id.to_string(Encoding::Base58)); + let kind = WhereLiteralKind::Identifier; + assert!(base58.equals(&Value::Identifier([7; 32]), kind)); + assert!(base58.equals(&Value::Bytes(vec![7; 32]), kind)); + assert!(!base58.equals(&Value::Identifier([8; 32]), kind)); + assert!(!WhereLiteral::Text("not base58 0OIl".to_string()) + .equals(&Value::Identifier([7; 32]), kind)); + + let kind = WhereLiteralKind::Bytes; + for hex in ["cafe", "CAFE", "CaFe"] { + assert!( + WhereLiteral::Text(hex.to_string()).equals(&Value::Bytes(vec![0xca, 0xfe]), kind) + ); + } + assert!(!WhereLiteral::Text("cafe".to_string()).equals(&Value::Bytes(vec![0xca]), kind)); + assert!( + !WhereLiteral::Text("+a+e".to_string()).equals(&Value::Bytes(vec![0x0a, 0x0e]), kind) + ); + assert!( + !WhereLiteral::Text("caf".to_string()).equals(&Value::Bytes(vec![0xca, 0x0f]), kind) ); } } 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 beaf72fab81..d5d65ed8200 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 @@ -98,6 +98,7 @@ mod tests; use crate::block::block_info::BlockInfo; use crate::consensus::basic::document::PropertyConstraintViolation; +use crate::data_contract::document_type::property::is_referenced_system_agreement_property; use crate::data_contract::document_type::property_names; use crate::data_contract::errors::DataContractError; use crate::document::property_names::{ @@ -184,9 +185,9 @@ pub const STORED_DOCUMENT_KEY: &str = "$old"; /// The operand key of a value of the document a reference of the type points /// at: `{ "referenced": ["pollId", "endsAt"] }` is the `endsAt` of the -/// document the top-level property `pollId` refers to. It stands wherever a -/// property path does, and is read as the path -/// `$referenced.pollId.endsAt` ([`REFERENCED_DOCUMENT_PREFIX`]). +/// document the property `pollId` refers to. It stands wherever a property +/// path does, and is read as the path `$referenced.pollId.$.endsAt` +/// ([`referenced_path`]). pub const REFERENCED: &str = "referenced"; /// The key the values of the documents the type's references point at sit @@ -199,26 +200,28 @@ pub const REFERENCED: &str = "referenced"; pub const REFERENCED_DOCUMENT_KEY: &str = "$referenced"; /// The prefix of the path a `referenced` operand is read through: -/// `$referenced..`. +/// `$referenced..$.`. pub const REFERENCED_DOCUMENT_PREFIX: &str = "$referenced."; -/// The fields of a referenced document a `referenced` operand may read beside -/// its schema properties and the nine times and heights: its identifiers, -/// which compare as identifiers. -pub const REFERENCED_IDENTIFIER_FIELDS: [&str; 3] = [DOCUMENT_ID, OWNER_ID, CREATOR_ID]; +/// The segment between the reference property, which may be a dotted path +/// (`meta.pollId`), and the field of the document it points at, which may be +/// one too, in the path a `referenced` operand is read through: no property is +/// named `$`. +const REFERENCED_FIELD_SEPARATOR: &str = ".$."; /// The reference property and the field of the document it points at that the -/// path a `referenced` operand is read through names: `("pollId", "endsAt")` -/// for `$referenced.pollId.endsAt`. `None` for any other path. +/// path a `referenced` operand is read through names: `("meta.pollId", +/// "endsAt")` for `$referenced.meta.pollId.$.endsAt`. `None` for any other +/// path. pub fn referenced_read(path: &str) -> Option<(&str, &str)> { path.strip_prefix(REFERENCED_DOCUMENT_PREFIX)? - .split_once('.') + .split_once(REFERENCED_FIELD_SEPARATOR) } /// The path a `referenced` operand reading `field` through `reference` is read /// through ([`REFERENCED_DOCUMENT_PREFIX`]). pub fn referenced_path(reference: &str, field: &str) -> String { - format!("{REFERENCED_DOCUMENT_PREFIX}{reference}.{field}") + format!("{REFERENCED_DOCUMENT_PREFIX}{reference}{REFERENCED_FIELD_SEPARATOR}{field}") } /// What `rules` read of the documents the type's references point at: by @@ -247,50 +250,104 @@ pub fn referenced_document_values<'a>( document: &Document, fields: impl IntoIterator, ) -> Result { - let system = DocumentSystemValues::of_document(document); let mut values = Value::Map(Vec::new()); for field in fields { - let value = match field { - DOCUMENT_ID => Some(Value::Identifier(document.id().to_buffer())), - OWNER_ID => Some(Value::Identifier(document.owner_id().to_buffer())), - CREATOR_ID => document - .creator_id() - .map(|creator_id| Value::Identifier(creator_id.to_buffer())), - field => match SystemProperty::from_name(field) { - Some(property) => system.value(property).map(Value::I128), - None => document.properties().get_optional_at_path(field)?.cloned(), - }, - }; - if let Some(value) = value { - values.set_value_at_full_path(field, value)?; + if let Some(value) = referenced_document_value(document, field)? { + values.set_value_at_full_path(field, value.into_owned())?; } } Ok(values) } +/// The value `field` names of `document`, a document a reference points at, +/// as a rule or a `where` entry reads it: a schema property at its dotted path, +/// `$id`, `$ownerId` and `$creatorId` as identifiers, a time or height as an +/// integer. `None` when the document holds none there; an error when a value +/// on the way down a dotted path is no object. +pub fn referenced_document_value<'a>( + document: &'a Document, + field: &str, +) -> Result>, platform_value::Error> { + Ok(match field { + DOCUMENT_ID => Some(Cow::Owned(Value::Identifier(document.id().to_buffer()))), + OWNER_ID => Some(Cow::Owned(Value::Identifier( + document.owner_id().to_buffer(), + ))), + CREATOR_ID => document + .creator_id() + .map(|creator_id| Cow::Owned(Value::Identifier(creator_id.to_buffer()))), + field => match SystemProperty::from_name(field) { + Some(property) => DocumentSystemValues::of_document(document) + .value(property) + .map(|value| Cow::Owned(Value::I128(value))), + None => document + .properties() + .get_optional_at_path(field)? + .map(Cow::Borrowed), + }, + }) +} + /// The data a rule reading a referenced document is judged against: the /// document's properties, `data`, with `referenced`, each reference property and /// the values its rules read of the document it points at -/// ([`referenced_document_values`]), under [`REFERENCED_DOCUMENT_KEY`]. A -/// reference the document leaves out has no entry, and reads as absent. +/// ([`referenced_document_values`]), where [`referenced_path`] reads them, under +/// [`REFERENCED_DOCUMENT_KEY`]. A reference the document leaves out has no +/// entry, and reads as absent. pub fn data_with_referenced_documents( data: &BTreeMap, referenced: BTreeMap, -) -> Value { +) -> Result { + let mut documents = Value::Map(Vec::new()); + for (reference, values) in referenced { + // The path of the separator segment, without the leading `$referenced.` + let at = format!("{reference}{REFERENCED_FIELD_SEPARATOR}"); + documents.set_value_at_full_path(at.trim_end_matches('.'), values)?; + } let mut entries: Vec<(Value, Value)> = data .iter() .map(|(property, value)| (Value::Text(property.clone()), value.clone())) .collect(); - entries.push(( - Value::Text(REFERENCED_DOCUMENT_KEY.to_string()), - Value::Map( - referenced - .into_iter() - .map(|(reference, values)| (Value::Text(reference), values)) - .collect(), - ), - )); - Value::Map(entries) + entries.push((Value::Text(REFERENCED_DOCUMENT_KEY.to_string()), documents)); + Ok(Value::Map(entries)) +} + +/// Whether `text` spells `identifier` in base58, as a constant compared with an +/// identifier is written: decoded in place, 32 bytes exactly. +pub(crate) fn spells_identifier(text: &str, identifier: &[u8; 32]) -> bool { + let mut decoded = [0u8; 32]; + matches!(bs58::decode(text).onto(&mut decoded), Ok(32)) && decoded == *identifier +} + +/// Whether `text` spells `bytes` in hex, two digits of either case a byte, as a +/// constant compared with a byte array is written: read beside the bytes. +pub(crate) fn spells_bytes(text: &str, bytes: &[u8]) -> bool { + text.len() == 2 * bytes.len() + && bytes.iter().enumerate().all(|(index, byte)| { + text.get(2 * index..2 * index + 2) + .filter(|digits| digits.bytes().all(|digit| digit.is_ascii_hexdigit())) + .and_then(|digits| u8::from_str_radix(digits, 16).ok()) + == Some(*byte) + }) +} + +/// Whether `value`, a value of a referenced document a rule reads, is the one +/// the string `constant` spells: a string itself, an identifier in base58, a +/// byte array in hex. The parse of a rule does not know what a referenced +/// field holds (its type may be another contract's), so a string constant +/// compared with one reads as the field's value says; registration checks the +/// constant spells a value of the field's type. Consensus reads the value from +/// a stored document or removal record, decoded under the referenced type: an +/// identifier as `Value::Identifier`, a byte array as one of the bytes values, +/// never the other. +fn spelled_by(value: &Value, constant: &str) -> bool { + match value { + Value::Text(text) => text == constant, + Value::Identifier(identifier) => spells_identifier(constant, identifier), + value => value + .as_bytes_slice() + .is_ok_and(|bytes| spells_bytes(constant, bytes)), + } } /// How the two sides of a comparison must compare. @@ -353,7 +410,7 @@ impl ConstraintComparison { /// transfer (a create, a transfer or a purchase). A rule may read one only on a /// document type that records it, by listing it in `required`, so every stored /// document of the type holds it. A `where` entry of a `refersTo` may bound an -/// ordering by one too (`WhereBound::System`), which embeds it in the reference +/// ordering by one too (`WhereOrderingBound::System`), which embeds it in the reference /// errors, so it encodes. // @append_only #[derive( @@ -1144,6 +1201,31 @@ pub struct TextProperty { } impl TextProperty { + /// The value a document whose properties are `data` holds at a path a + /// `referenced` operand reads when it is no string: an identifier, a byte + /// array, an integer or a boolean of the referenced document, compared as + /// what it is ([`spelled_by`], `Value::same_scalar_data`). `None` for a + /// string, a value left out, and every property of the type's own. + fn referenced_other<'a>(&'a self, data: &'a Value) -> Option<&'a Value> { + if !self.path.starts_with(REFERENCED_DOCUMENT_PREFIX) { + return None; + } + match data.get_optional_value_at_path(&self.path) { + Ok(Some(Value::Text(_) | Value::Null)) | Ok(None) | Err(_) => None, + Ok(Some(value)) => Some(value), + } + } + + /// Whether the property, for a document whose properties are `data`, is the + /// string `constant` spells ([`Self::value`], or [`Self::referenced_other`] + /// read as [`spelled_by`] says). + fn equals_constant(&self, data: &Value, constant: &str) -> bool { + self.value(data) == Some(constant) + || self + .referenced_other(data) + .is_some_and(|value| spelled_by(value, constant)) + } + /// The string a document whose properties are `data` gives the property: /// the one it holds, or `if_absent` when it leaves the property out or /// sets it to null (where an operand takes its `ifAbsent` value). `None` @@ -1214,6 +1296,20 @@ impl TextOperand { TextOperand::Property(property) => property.value(data), } } + + /// The bytes it takes for a document whose properties are `data` where a + /// byte array of a referenced document is tested: a constant read as hex, + /// a byte array a `referenced` operand reads ([`TextProperty::referenced_other`]). + /// `None` for anything else. + fn bytes<'a>(&'a self, data: &'a Value) -> Option> { + match self { + TextOperand::Constant(value) => hex::decode(value).ok().map(Cow::Owned), + TextOperand::Property(property) => match property.referenced_other(data)? { + Value::Identifier(_) => None, + value => value.as_bytes_slice().ok().map(Cow::Borrowed), + }, + } + } } /// A side of a `startsWith` or `endsWith` of byte arrays: a constant, written @@ -1419,7 +1515,7 @@ impl PropertyConstraint { property, value, } => { - let equal = property.value(data) == Some(value.as_str()); + let equal = property.equals_constant(data, value); Ok(equal == (*comparison == ConstraintComparison::Equal)) } PropertyConstraint::TextCompareProperties { @@ -1427,15 +1523,23 @@ impl PropertyConstraint { left, right, } => { - let equal = matches!( - (left.value(data), right.value(data)), - (Some(left), Some(right)) if left == right - ); + // Two strings, or two values of referenced documents that are no + // strings, compared as what they are + let equal = match (left.value(data), right.value(data)) { + (Some(left), Some(right)) => left == right, + _ => matches!( + (left.referenced_other(data), right.referenced_other(data)), + (Some(left), Some(right)) if left.same_scalar_data(right) + ), + }; Ok(equal == (*comparison == ConstraintComparison::Equal)) } - PropertyConstraint::TextIn { property, values } => Ok(property - .value(data) - .is_some_and(|text| values.contains(text))), + PropertyConstraint::TextIn { property, values } => Ok(match property.value(data) { + Some(text) => values.contains(text), + None => property + .referenced_other(data) + .is_some_and(|value| values.iter().any(|constant| spelled_by(value, constant))), + }), PropertyConstraint::IdentifierCompare { comparison, path, @@ -1466,10 +1570,14 @@ impl PropertyConstraint { position, text, affix, - } => Ok(matches!( - (text.value(data), affix.value(data)), - (Some(text), Some(affix)) if position.holds(text, affix) - )), + } => Ok(match (text.value(data), affix.value(data)) { + (Some(text), Some(affix)) => position.holds(text, affix), + // A byte array of a referenced document, its affix in hex + _ => matches!( + (text.bytes(data), affix.bytes(data)), + (Some(bytes), Some(affix)) if position.holds_bytes(&bytes, &affix) + ), + }), PropertyConstraint::BytesAffix { position, bytes, @@ -1490,9 +1598,15 @@ impl PropertyConstraint { element.is_integer() && element.to_integer::().ok() == Some(value) }) } - ContainsNeedle::TextConstant(value) => elements - .iter() - .any(|element| element.as_text() == Some(value.as_str())), + // Among the identifiers or byte arrays of a referenced + // document as the constant spells them + ContainsNeedle::TextConstant(value) => { + let referenced = array.starts_with(REFERENCED_DOCUMENT_PREFIX); + elements.iter().any(|element| { + element.as_text() == Some(value.as_str()) + || (referenced && spelled_by(element, value)) + }) + } ContainsNeedle::TextProperty(property) => { property.value(data).is_some_and(|value| { elements @@ -1548,6 +1662,15 @@ impl PropertyConstraint { } } + /// Whether a write judging rules with `system` judges this one: a rule + /// reading a referenced document only where `system` says the data holds + /// the values it reads ([`DocumentSystemValues::referenced_documents`]), a + /// create or a replace once its references hold. Consensus reads no total + /// of a rule the write does not judge. + pub fn is_judged_with(&self, system: &DocumentSystemValues) -> bool { + system.referenced_documents || !self.reads_referenced() + } + /// Why a document whose properties are `data` and whose system values are /// `system` breaks the rule, `None` when it meets it: the first fault met /// on the way ([`Self::holds`]), or [`PropertyConstraintViolation::NotMet`] @@ -1564,7 +1687,7 @@ impl PropertyConstraint { data: &Value, system: &DocumentSystemValues, ) -> Option { - if (!system.referenced_documents && self.reads_referenced()) + if !self.is_judged_with(system) || self .system_reads() .into_iter() @@ -1661,6 +1784,20 @@ impl PropertyConstraint { reads } + /// The properties of the document type itself the rule reads, each with how + /// it reads it, in declared order: [`Self::property_reads`] without the + /// values of referenced documents, which [`Self::referenced_reads`] lists. + pub fn own_property_reads(&self) -> Vec<(&str, PropertyRead)> { + let mut reads = Vec::new(); + let _ = self.visit_property_reads(&mut |path, read| { + if !path.starts_with(REFERENCED_DOCUMENT_PREFIX) { + reads.push((path, read)); + } + ControlFlow::Continue(()) + }); + reads + } + /// What the rule reads of the documents the type's references point at, /// each as the reference property, the field of the referenced document /// and how it is read, in declared order, a read made twice listed twice. @@ -1945,6 +2082,55 @@ impl PropertyConstraint { affixes } + /// Every pair of properties the rule compares with each other as strings, + /// in declared order: the two sides of an `equal` or `notEqual` of string + /// properties, of a `startsWith` or `endsWith` of two, and an array and the + /// property a `contains` looks for in it. A field of a referenced document + /// compared so is read as what it holds, which must be what its peer holds. + pub fn text_peers(&self) -> Vec<(&str, &str)> { + let mut peers = Vec::new(); + self.collect_text_peers(&mut peers); + peers + } + + fn collect_text_peers<'a>(&'a self, peers: &mut Vec<(&'a str, &'a str)>) { + match self { + PropertyConstraint::TextCompareProperties { left, right, .. } => { + peers.push((&left.path, &right.path)) + } + PropertyConstraint::TextAffix { + text: TextOperand::Property(text), + affix: TextOperand::Property(affix), + .. + } => peers.push((&text.path, &affix.path)), + PropertyConstraint::Contains { + array, + needle: ContainsNeedle::TextProperty(needle), + } => peers.push((array, &needle.path)), + PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { + for condition in conditions { + condition.collect_text_peers(peers); + } + } + PropertyConstraint::Not(condition) | PropertyConstraint::NotIn(condition) => { + condition.collect_text_peers(peers) + } + PropertyConstraint::IfThen { + condition, + then, + otherwise, + } => { + for part in [Some(condition), Some(then), otherwise.as_ref()] + .into_iter() + .flatten() + { + part.collect_text_peers(peers); + } + } + _ => {} + } + } + fn collect_text_affixes<'a>(&'a self, affixes: &mut Vec<(&'a str, &'a str, AffixPosition)>) { match self { PropertyConstraint::TextAffix { @@ -2467,10 +2653,11 @@ fn bare_path(path: &str, at: &str) -> Result { } /// The path a `referenced` operand at `at` listing `operands` is read through: -/// exactly two strings, the name of a top-level property of the type (the -/// reference, whose declaration is checked against the parsed document type), -/// then a field of the document it points at: a dotted property path, its -/// `$id`, `$ownerId` or `$creatorId`, or one of the nine times and heights. +/// exactly two strings, the dotted path of a property of the type (the +/// reference, a nested one through the objects around it, whose declaration is +/// checked against the parsed document type), then a field of the document it +/// points at: a dotted property path, its `$id`, `$ownerId` or `$creatorId`, or +/// one of the nine times and heights. fn referenced_operand(operands: &Value, at: &str) -> Result { let at = if at.is_empty() { REFERENCED.to_string() @@ -2484,13 +2671,13 @@ fn referenced_operand(operands: &Value, at: &str) -> Result { document it refers to" )); }; - if !is_rule_name(reference) { + if reference.is_empty() || !reference.split('.').all(is_rule_name) { return Err(format!( - "at {at}[0] names \"{reference}\", but a reference property is a top-level \ - property, named with 1 to 64 letters, digits or underscores" + "at {at}[0] names \"{reference}\", but a reference property is the dotted path \ + of a property of the type, each name of 1 to 64 letters, digits or underscores" )); } - let is_field = REFERENCED_IDENTIFIER_FIELDS.contains(&field.as_str()) + let is_field = is_referenced_system_agreement_property(field) || SystemProperty::from_name(field).is_some() || (!field.is_empty() && field.split('.').all(is_rule_name)); if !is_field { @@ -2512,9 +2699,7 @@ fn path_kind(context: &ParseContext, path: &str) -> Option { return Some(EqualityKind::Identifier); } if let Some((_, field)) = referenced_read(path) { - return REFERENCED_IDENTIFIER_FIELDS - .contains(&field) - .then_some(EqualityKind::Identifier); + return is_referenced_system_agreement_property(field).then_some(EqualityKind::Identifier); } (context.property_kind)(path) } @@ -2904,11 +3089,24 @@ fn parse_condition( // two bare paths naming string or identifier properties, compare // strings or identifiers, as the properties named decide let (left_kind, right_kind) = side_kinds(context, left, right); + // Two fields of referenced documents, whose types the parse does + // not know, compared with each other by value, whatever they hold + let open = |side: &Value| { + named_path(side, "") + .and_then(Result::ok) + .is_some_and(|path| has_open_kind(&path)) + }; + let open_pair = matches!( + comparison, + ConstraintComparison::Equal | ConstraintComparison::NotEqual + ) && open(left) + && open(right); if is_const(left) || is_const(right) || is_text_if_absent(left) || is_text_if_absent(right) || (left_kind.is_some() && right_kind.is_some()) + || open_pair { let compare = match (left_kind, right_kind) { (Some(EqualityKind::Bytes), _) | (_, Some(EqualityKind::Bytes)) => { @@ -3693,11 +3891,11 @@ fn is_number(value: &Value) -> bool { value.is_integer() || matches!(value, Value::Float(_)) } -/// An integer literal at `at`: an integer, or a float with no fractional part, -/// in the range of an `i128`. -fn integer_value(value: &Value, at: &str) -> Result { - let integer = match value { - // The cast is exact: the float is a whole number inside the range +/// The integer a declaration's number spells: an integer, or a float with no +/// fractional part inside the range of a 128-bit signed integer, the cast then +/// exact. `None` for any other value. +pub(crate) fn whole_integer(value: &Value) -> Option { + match value { Value::Float(float) if float.fract() == 0.0 && *float >= i128::MIN as f64 && *float < i128::MAX as f64 => { @@ -3705,8 +3903,13 @@ fn integer_value(value: &Value, at: &str) -> Result { } Value::Float(_) => None, _ => value.to_integer::().ok(), - }; - integer.ok_or_else(|| { + } +} + +/// An integer literal at `at`: an integer, or a float with no fractional part, +/// in the range of an `i128`. +fn integer_value(value: &Value, at: &str) -> Result { + whole_integer(value).ok_or_else(|| { format!( "at {at} holds {}, which is not an integer in the range of a 128-bit signed \ integer", 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 87d7d2c2fe3..9e4b650a687 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 @@ -5,11 +5,9 @@ use dpp::consensus::state::document::document_contest_maximum_contenders_reached use dpp::consensus::state::document::document_contest_not_paid_for_error::DocumentContestNotPaidForError; use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; -use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; -use dpp::data_contract::document_type::property_constraints::{ - data_with_referenced_documents, DocumentSystemValues, PropertyConstraint, -}; +use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use dpp::identifier::Identifier; use dpp::validation::{ConsensusValidationResult, SimpleConsensusValidationResult}; use dpp::version::PlatformVersion; @@ -166,26 +164,21 @@ impl DocumentCreateTransitionActionStateValidationV2 for DocumentCreateTransitio // once the references hold, with the values their checks read: the structure // validation judged every other rule, and could not judge these before the reads. // A type without such a rule judges nothing more, and copies nothing - let document_type = self.base().document_type()?; - if document_type - .property_constraints() - .values() - .any(PropertyConstraint::reads_referenced) - { - let result = document_type - .validate_referencing_property_constraints( - &data_with_referenced_documents(self.data(), referenced_documents), - &DocumentSystemValues { - aggregates: Some(self.property_constraint_aggregates().clone()), - referenced_documents: true, - ..system - }, - platform_version, - ) - .map_err(Error::Protocol)?; - if !result.is_valid() { - return Ok(result); - } + let result = self + .base() + .document_type()? + .validate_referencing_property_constraints( + self.data(), + referenced_documents, + &DocumentSystemValues { + aggregates: Some(self.property_constraint_aggregates().clone()), + ..system + }, + platform_version, + ) + .map_err(Error::Protocol)?; + if !result.is_valid() { + return Ok(result); } // A non-contested create must not take the id of a live contested 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 ff9fa3de2f0..45ec82c8d17 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 @@ -22,11 +22,11 @@ use dpp::data_contract::document_type::{ }; use dpp::data_contract::config::moderation::{kept_value_at, ContractDocumentRemoval}; use dpp::data_contract::document_type::property_constraints::{ - referenced_document_values, referenced_fields, DocumentSystemValues, + referenced_document_value, referenced_document_values, referenced_fields, DocumentSystemValues, }; -use dpp::data_contract::document_type::{WhereBound, WhereCondition, WhereLiteralKind}; +use dpp::data_contract::document_type::{WhereBoundRead, WhereCondition, WhereLiteralKind}; use dpp::data_contract::DataContract; -use dpp::document::property_names::{CREATOR_ID, ID, OWNER_ID}; +use dpp::document::property_names::{ID, OWNER_ID}; use dpp::document::{Document, DocumentV0Getters}; use dpp::errors::consensus::state::document::referenced_document_property_mismatch_error::ReferencedDocumentPropertyMismatchError; use dpp::errors::consensus::state::document::referenced_document_removed_error::ReferencedDocumentRemovedError; @@ -259,6 +259,14 @@ fn validate_document_type_references_v0( // The documents this write's references fetch by id, shared among them let mut fetched_documents = FetchedDocuments::default(); + // The foreign contracts this write's references resolved, so each is + // fetched, and billed, once whatever number of declarations, elements of a + // typed array and leaves of an expression name it. Reached from protocol + // version 14 only (document create state validation 2 and replace state + // validation 1), where a reference a rule reads through is checked on + // every replace + let mut referenced_contracts = BTreeMap::new(); + // What the type's `propertyConstraints` rules read of the documents its references // point at, by reference property (protocol version 14). Every replace judges every // rule, so each such reference is checked on every replace, and once it holds the @@ -463,10 +471,6 @@ fn validate_document_type_references_v0( 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(); - if !holds_elements { let referenced_id = match holder { // The writer: an identity target has nothing to fetch, the @@ -1219,10 +1223,8 @@ fn validate_reference_target_v0( 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 + // Every reference of the write resolves a contract once: the + // first fetch is billed and the rest reuse it let fetch_info = match referenced_contracts.get(&effective_contract_id) { Some(resolved) => resolved.clone(), None => { @@ -1504,25 +1506,10 @@ fn validate_reference_target_v0( // registration validated that each faces an identifier // property on the referring side, so the two compare as // identifiers below. - let referenced_value: Option> = match referenced_property.as_str() { - OWNER_ID => Some(Cow::Owned(Value::Identifier( - referenced_document.owner_id().to_buffer(), - ))), - ID => Some(Cow::Owned(Value::Identifier( - referenced_document.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 Ok(referenced_value) = + referenced_document_value(referenced_document, referenced_property) + else { + return Ok(mismatch()); }; let (referring_value, referenced_value) = match (referring_value, referenced_value) { @@ -1549,25 +1536,10 @@ fn validate_reference_target_v0( // literal reads as the referenced property's kind says, of the type // just resolved, since it may be another contract's for (referenced_property, condition) in where_conditions { - let referenced_value = match referenced_property.as_str() { - OWNER_ID => Some(Cow::Owned(Value::Identifier( - referenced_document.owner_id().to_buffer(), - ))), - ID => Some(Cow::Owned(Value::Identifier( - referenced_document.id().to_buffer(), - ))), - CREATOR_ID => referenced_document.creator_id().map(|creator_id| { - Cow::Owned(Value::Identifier(creator_id.to_buffer())) - }), - _ => match referenced_document - .properties() - .get_optional_at_path(referenced_property) - { - Ok(value) => value.map(Cow::Borrowed), - Err(_) => { - return Ok(condition_mismatch(path, condition, referenced_property)) - } - }, + let Ok(referenced_value) = + referenced_document_value(referenced_document, referenced_property) + else { + return Ok(condition_mismatch(path, condition, referenced_property)); }; let kind = WhereLiteralKind::of_referenced( referenced_document_type, @@ -1748,17 +1720,59 @@ fn where_bound_value<'a>( owner_id: Identifier, referring_system: &DocumentSystemValues, ) -> Option> { - match condition.bound()? { - WhereBound::Property(path) => document_data + match condition.bound_read()? { + WhereBoundRead::Property(path) => document_data .get_optional_at_path(path) .ok() .flatten() .map(Cow::Borrowed), - WhereBound::Writer => Some(Cow::Owned(Value::Identifier(owner_id.to_buffer()))), - WhereBound::System(property) => referring_system - .value(*property) + WhereBoundRead::Writer => Some(Cow::Owned(Value::Identifier(owner_id.to_buffer()))), + WhereBoundRead::System(property) => referring_system + .value(property) .map(|value| Cow::Owned(Value::I128(value))), - WhereBound::Constant(_) => None, + } +} + +/// What a removal record holds of the document a moderator removed, read field +/// by field: its `$id`, its `$ownerId` at the removal, which no transfer can +/// move since (a removed document can not be transferred, and a restore brings +/// it back as it was), and the values of the fields its type keeps under +/// `deleteKeepsFields` (a kept path, or one inside a kept object), decoded under +/// `referenced_document_type` on the first read of one. +struct RemovalRecord<'a> { + removal: &'a ContractDocumentRemoval, + document_id: Identifier, + referenced_document_type: DocumentTypeRef<'a>, + kept_values: Option>, +} + +impl RemovalRecord<'_> { + /// The record's value of `field`, `Some(None)` when it keeps the field and + /// the document held none there, `None` when it keeps no such field. A + /// record whose values do not read under the type is a corrupted state, an + /// error rather than a refusal. + fn value(&mut self, field: &str) -> Result>, Error> { + Ok(match field { + OWNER_ID => Some(Some(Value::Identifier( + self.removal.document_owner_id.to_buffer(), + ))), + ID => Some(Some(Value::Identifier(self.document_id.to_buffer()))), + field => { + let kept_paths = self + .referenced_document_type + .moderator_deletion_kept_fields(); + if !is_path_listed(kept_paths, field) { + return Ok(None); + } + if self.kept_values.is_none() { + self.kept_values = + Some(self.removal.kept_values(self.referenced_document_type)?); + } + self.kept_values + .as_ref() + .map(|kept_values| kept_value_at(kept_paths, kept_values, field).flatten()) + } + }) } } @@ -1784,7 +1798,10 @@ fn where_bound_value<'a>( /// on a `moderatedDocument` reference against the referenced `$ownerId` or /// `$id` alone, since a writer gate is asked about on every replace. A pair the replace does not ask about held when it was last /// checked, against a document whose values the removal froze, and is left as -/// it is. No read: the record was read and billed by the caller. +/// it is, unless a rule reads the document through the reference: then every +/// entry is checked, as the replace checks every entry against a document in +/// state, and registration keeps each to what every record holds. No read: the +/// record was read and billed by the caller. /// /// In place in generation 0: reached from protocol version 14 only, the only /// version whose parser produces a `moderatedDocument` reference. @@ -1802,38 +1819,35 @@ fn validate_pairs_against_removal( path: &str, rule_reads: Option>, ) -> Result { - let kept_paths = referenced_document_type.moderator_deletion_kept_fields(); - let mut kept_values = None; + let mut record = RemovalRecord { + removal, + document_id, + referenced_document_type, + kept_values: None, + }; + let removed = |referenced_property: &str| { + SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentRemovedError::new( + document_id, + path.to_string(), + referenced_property.to_string(), + ) + .into(), + ) + }; + // A rule reading the document has the reference checked on every replace, + // its `where` whole, as against a document in state; registration keeps + // every entry of such a reference to what every record holds + let checks_every_entry = rule_reads.is_some(); for (referring_property, referenced_property) in property_agreement { - let rechecked = is_referring_system_agreement_property(referring_property) + let rechecked = checks_every_entry + || is_referring_system_agreement_property(referring_property) || is_changed_field(changed_fields, referring_property); if !rechecked { continue; } - let referenced_value = match referenced_property.as_str() { - OWNER_ID => Some(Cow::Owned(Value::Identifier( - removal.document_owner_id.to_buffer(), - ))), - ID => Some(Cow::Owned(Value::Identifier(document_id.to_buffer()))), - property if !is_path_listed(kept_paths, property) => { - return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedDocumentRemovedError::new( - document_id, - path.to_string(), - referenced_property.clone(), - ) - .into(), - )) - } - property => { - let kept_values = match &mut kept_values { - Some(kept_values) => kept_values, - None => kept_values.insert(removal.kept_values(referenced_document_type)?), - }; - kept_value_at(kept_paths, kept_values, property) - .flatten() - .map(Cow::Owned) - } + let Some(referenced_value) = record.value(referenced_property)? else { + return Ok(removed(referenced_property)); }; // As against a document in state: the writer for `$ownerId`, a // property of the document otherwise, one unreadable a mismatch @@ -1865,36 +1879,20 @@ fn validate_pairs_against_removal( } // The other `where` entries the replace asks about again, as the pairs // above: one bounded by a changed property, or by a value that changes - // without a write (the writer, the last update or transfer). Registration - // keeps an entry asked about on every replace to what every record holds + // without a write (the writer, the last update or transfer), or every one + // when a rule reads the document. Registration keeps an entry asked about + // on every replace to what every record holds for (referenced_property, condition) in where_conditions { - let rechecked = condition.is_rechecked_on_every_replace() + let rechecked = checks_every_entry + || condition.is_rechecked_on_every_replace() || condition .referring_property() .is_some_and(|property| is_changed_field(changed_fields, property)); if !rechecked { continue; } - let referenced_value = match referenced_property.as_str() { - OWNER_ID => Some(Value::Identifier(removal.document_owner_id.to_buffer())), - ID => Some(Value::Identifier(document_id.to_buffer())), - property if !is_path_listed(kept_paths, property) => { - return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedDocumentRemovedError::new( - document_id, - path.to_string(), - referenced_property.clone(), - ) - .into(), - )) - } - property => { - let kept_values = match &mut kept_values { - Some(kept_values) => kept_values, - None => kept_values.insert(removal.kept_values(referenced_document_type)?), - }; - kept_value_at(kept_paths, kept_values, property).flatten() - } + let Some(referenced_value) = record.value(referenced_property)? else { + return Ok(removed(referenced_property)); }; let kind = WhereLiteralKind::of_referenced(referenced_document_type, referenced_property); let bound = where_bound_value(condition, document_data, owner_id, referring_system); @@ -1908,22 +1906,11 @@ fn validate_pairs_against_removal( if let Some(rule_reads) = rule_reads { let mut values = Value::Map(Vec::new()); for field in rule_reads.fields.iter().copied() { - let value = match field { - OWNER_ID => Some(Value::Identifier(removal.document_owner_id.to_buffer())), - ID => Some(Value::Identifier(document_id.to_buffer())), - field if is_path_listed(kept_paths, field) => { - let kept_values = match &mut kept_values { - Some(kept_values) => kept_values, - None => kept_values.insert(removal.kept_values(referenced_document_type)?), - }; - kept_value_at(kept_paths, kept_values, field).flatten() - } - _ => { - return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( - "a rule reads through a moderatedDocument reference only what every \ - removal record keeps, which registration enforces", - ))) - } + let Some(value) = record.value(field)? else { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "a rule reads through a moderatedDocument reference only what every \ + removal record keeps, which registration enforces", + ))); }; if let Some(value) = value { values diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs index 9c60cd74830..42b304be333 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs @@ -63,6 +63,11 @@ impl DocumentReplaceTransitionActionStructureValidationV0 for DocumentReplaceTra updated_at_core_block_height: self.updated_at_core_block_height(), transferred_at_core_block_height: self.transferred_at_core_block_height(), aggregates: Some(self.property_constraint_aggregates().clone()), + // The rules reading a referenced document wait for replace state + // validation 1, once the references hold. Added in place at protocol + // version 14, inert for every earlier version this generation serves: + // no rule parses before 14 (`parse_property_constraints` is `None`), + // so no rule reads a referenced document there referenced_documents: false, }; let result = data_contract 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 bc85ccf43ed..82005f1eb1c 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 @@ -4,7 +4,7 @@ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::property_constraints::{ - data_with_referenced_documents, DocumentSystemValues, PropertyConstraint, STORED_DOCUMENT_KEY, + DocumentSystemValues, STORED_DOCUMENT_KEY, }; use std::collections::BTreeMap; use dpp::identifier::Identifier; @@ -172,25 +172,19 @@ impl DocumentReplaceTransitionActionStateValidationV1 for DocumentReplaceTransit // The rules reading a document a reference points at (protocol version 14), judged // once the references hold, with the values their checks read, as on a create: every // replace judges every rule, and the structure validation judged the others - if document_type - .property_constraints() - .values() - .any(PropertyConstraint::reads_referenced) - { - let result = document_type - .validate_referencing_property_constraints( - &data_with_referenced_documents(self.data(), referenced_documents), - &DocumentSystemValues { - aggregates: Some(self.property_constraint_aggregates().clone()), - referenced_documents: true, - ..system - }, - platform_version, - ) - .map_err(Error::Protocol)?; - if !result.is_valid() { - return Ok(result); - } + let result = document_type + .validate_referencing_property_constraints( + self.data(), + referenced_documents, + &DocumentSystemValues { + aggregates: Some(self.property_constraint_aggregates().clone()), + ..system + }, + platform_version, + ) + .map_err(Error::Protocol)?; + if !result.is_valid() { + return Ok(result); } Ok(SimpleConsensusValidationResult::new()) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/referenced_rules.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/referenced_rules.rs index 7aea550b926..848a7fd9bb5 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/referenced_rules.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/referenced_rules.rs @@ -194,7 +194,7 @@ mod referenced_rules_tests { assert_successful(&result, "the auction closes"); bid.set("amount", Value::U32(300)); let result = setup.replace("bid", &mut bid).await; - assert_where_refused(&result, "{ \"in\": [\"extended\", \"open\"] }"); + assert_where_refused(&result, "{ \"in\": [\"open\", \"extended\"] }"); } /// The `where` entries that are not an equality: a literal the referenced @@ -299,6 +299,116 @@ mod referenced_rules_tests { assert_where_refused(&result, "{ \"greaterThan\": \"$updatedAt\" }"); } + /// `where` conditions hold on every form of document reference: on each + /// element of a typed array, on each leaf of an `anyOf` (which holds + /// through either, refusing with its last leaf's refusal when none does), + /// and on the document a `findBy` finds. + #[tokio::test] + async fn should_check_where_conditions_on_every_reference_form() { + let mut setup = ReferenceTestSetup::new(CONTRACT_PATH, 6106); + let (open_ballot, result) = setup + .create("ballot", &[("endsAt", Value::U64(5_000))]) + .await; + assert_successful(&result, "the ballot must be created"); + let (short_ballot, result) = setup.create("ballot", &[("endsAt", Value::U64(50))]).await; + assert_successful(&result, "the ballot must be created"); + + let (_, result) = setup + .create( + "ballotList", + &[("ballotIds", Value::Array(vec![id_value(&open_ballot)]))], + ) + .await; + assert_successful(&result, "every element names a ballot ending after 100"); + let (_, result) = setup + .create( + "ballotList", + &[( + "ballotIds", + Value::Array(vec![id_value(&open_ballot), id_value(&short_ballot)]), + )], + ) + .await; + assert_where_refused(&result, "{ \"greaterThan\": { \"const\": 100 } }"); + + let (open_auction, result) = setup + .create( + "auction", + &[ + ("reservePrice", Value::U32(1)), + ("status", Value::Text("open".to_string())), + ], + ) + .await; + assert_successful(&result, "the auction must be created"); + let (closed_auction, result) = setup + .create( + "auction", + &[ + ("reservePrice", Value::U32(1)), + ("status", Value::Text("closed".to_string())), + ], + ) + .await; + assert_successful(&result, "the auction must be created"); + for target in [&open_ballot, &open_auction] { + let (_, result) = setup + .create("ballotOrAuction", &[("targetId", id_value(target))]) + .await; + assert_successful(&result, "one leaf holds"); + } + // The ballot leaf's condition fails, and the auction leaf finds no + // auction, the last refusal + let (_, result) = setup + .create("ballotOrAuction", &[("targetId", id_value(&short_ballot))]) + .await; + assert_not_found(&result); + let (_, result) = setup + .create( + "ballotOrAuction", + &[("targetId", id_value(&closed_auction))], + ) + .await; + assert_where_refused(&result, "{ \"const\": \"open\" }"); + + for (code, capacity) in [("small", 1u32), ("large", 5)] { + let (_, result) = setup + .create( + "event", + &[ + ("code", Value::Text(code.to_string())), + ("capacity", Value::U32(capacity)), + ], + ) + .await; + assert_successful(&result, "the event must be created"); + } + let (_, result) = setup + .create("pass", &[("eventCode", Value::Text("large".to_string()))]) + .await; + assert_successful(&result, "the event found holds more than one"); + let (_, result) = setup + .create("pass", &[("eventCode", Value::Text("small".to_string()))]) + .await; + assert_where_refused(&result, "{ \"greaterThan\": { \"const\": 1 } }"); + } + + /// A reference inside an object is read through by its dotted path. + #[tokio::test] + async fn should_read_through_a_reference_inside_an_object() { + let mut setup = ReferenceTestSetup::new(CONTRACT_PATH, 6107); + let single = poll(&mut setup, &[]).await; + let multiple = poll(&mut setup, &[("multiChoice", Value::Bool(true))]).await; + let meta = + |poll: &Document| Value::Map(vec![(Value::Text("pollId".to_string()), id_value(poll))]); + let (_, result) = setup.create("nestedVote", &[("meta", meta(&single))]).await; + assert_successful(&result, "a single-choice poll takes the vote"); + let (_, result) = setup + .create("nestedVote", &[("meta", meta(&multiple))]) + .await; + assert_rule_broken(&result, "singleChoicePoll"); + } + /// A reference found by `findBy` reads the document the key finds, for the /// rules too. #[tokio::test] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/property_constraint_aggregates.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/property_constraint_aggregates.rs index 6a58e363751..42d8b048322 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/property_constraint_aggregates.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/property_constraint_aggregates.rs @@ -76,7 +76,10 @@ pub(super) fn attach_property_constraint_aggregates( return Ok(()); }; let stored = stored.map(DocumentVersion::of); - let mut read = |document_type_name: &str, written: DocumentVersion, change| { + // A create or a replace judges the rules reading a referenced document, + // once its references hold; a transfer, a purchase or a price update does + // not + let mut read = |document_type_name: &str, written: DocumentVersion, change: Option<_>| { read_property_constraint_aggregates( drive, contract, @@ -84,6 +87,7 @@ pub(super) fn attach_property_constraint_aggregates( written, stored, change, + change.is_none(), block_info, execution_context, transaction, @@ -162,6 +166,7 @@ pub(crate) fn read_property_constraint_aggregates_for_moderator_change( DocumentVersion::of(written), Some(DocumentVersion::of(stored)), None, + false, block_info, execution_context, transaction, @@ -189,6 +194,7 @@ pub(crate) fn read_property_constraint_aggregates_for_restore( DocumentVersion::of(document), None, None, + false, block_info, execution_context, transaction, @@ -201,7 +207,9 @@ pub(crate) fn read_property_constraint_aggregates_for_restore( /// `stored` (`None` for a create), each as it will be once the write is done: the total the /// count or sum tree keeps now, less what `stored` adds to it, plus what `written` adds. For /// a transfer, a purchase or a price update (`change`), only the rules the change can break -/// are judged, so only their totals are read. The reads are billed to `execution_context`. +/// are judged, so only their totals are read, and a rule reading a referenced document only +/// where `referencing_rules_judged`: a create or a replace judges it, a moderator's change +/// or restore and the system changes do not. The reads are billed to `execution_context`. /// /// Consensus reads them when it builds the action, before judging the rules /// ([`DocumentSystemValues::aggregates`]), so that the rules stay a structure check. Added @@ -223,6 +231,7 @@ fn read_property_constraint_aggregates( written: DocumentVersion, stored: Option, change: Option, + referencing_rules_judged: bool, block_info: &BlockInfo, execution_context: &mut StateTransitionExecutionContext, transaction: TransactionArg, @@ -236,6 +245,7 @@ fn read_property_constraint_aggregates( .property_constraints() .values() .filter(|rule| change.is_none_or(|change| rule.reads_change(change))) + .filter(|rule| referencing_rules_judged || !rule.reads_referenced()) .flat_map(|rule| rule.aggregate_reads()) .collect::>(); if reads.is_empty() { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/referenced_rules.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/referenced_rules.rs index e22e7043d4a..fd64a68adf6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/referenced_rules.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/contract_user_moderation/tests/referenced_rules.rs @@ -16,6 +16,9 @@ const TOPIC: &str = "topic"; const THREAD_POST: &str = "threadPost"; const LISTING: &str = "listing"; const OFFER: &str = "offer"; +const ARTICLE: &str = "article"; +const COMMENT: &str = "comment"; +const STALL: &str = "stall"; fn identifier_property(position: u64, refers_to: Value) -> Value { platform_value!({ @@ -95,6 +98,9 @@ fn thread_post_schema() -> Value { "text": { "type": "string", "minLength": 1, "maxLength": 50, "position": 1 }, }, "required": ["topicId", "text"], + "indices": [ + { "name": "byTopic", "properties": [{ "topicId": "asc" }], "countable": "countable" } + ], "propertyConstraints": { "topicOpen": { "equal": [{ "referenced": ["topicId", "open"] }, 1] } }, @@ -149,6 +155,82 @@ fn offer_schema() -> Value { }) } +/// An article its owner may close, which moderators take down, each removal +/// keeping its text and its status +fn article_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "canBeDeleted": false, + "moderatorAbilities": { "delete": true, "deleteKeepsFields": ["text", "status"] }, + "properties": { + "text": { "type": "string", "minLength": 1, "maxLength": 50, "position": 0 }, + "status": { "type": "string", "enum": ["open", "closed"], "maxLength": 10, "position": 1 }, + }, + "required": ["text", "status"], + "additionalProperties": false, + }) +} + +/// A comment on an open article, which may quote it +fn comment_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "canBeDeleted": false, + "properties": { + "articleId": identifier_property( + 0, + platform_value!({ + "type": "moderatedDocument", + "documentType": ARTICLE, + "where": { "status": { "const": "open" } }, + }), + ), + "quote": { "type": "string", "maxLength": 50, "position": 1 }, + "body": { "type": "string", "minLength": 1, "maxLength": 50, "position": 2 }, + }, + "required": ["articleId", "body"], + "propertyConstraints": { + "quotesTheArticle": { + "anyOf": [ + { "absent": "quote" }, + { "equal": ["quote", { "referenced": ["articleId", "text"] }] } + ] + } + }, + "additionalProperties": false, + }) +} + +/// A stall on a topic, kept to a hundred and one posts on it while it is +/// open, with a note only moderators write +fn stall_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "canBeDeleted": false, + "moderatorAbilities": { "changeFields": ["note"] }, + "properties": { + "topicId": identifier_property( + 0, + platform_value!({ "type": "permanentDocument", "documentType": TOPIC }), + ), + "note": { "type": "string", "maxLength": 50, "position": 1 }, + }, + "required": ["topicId"], + "propertyConstraints": { + "fewPosts": { + "lessThanOrEqual": [ + { "countOf": [THREAD_POST, { "topicId": "topicId" }] }, + { "add": [{ "referenced": ["topicId", "open"] }, 100] } + ] + } + }, + "additionalProperties": false, + }) +} + async fn setup() -> Setup { Setup::new_at_with( Some(moderators_without_lists()), @@ -160,6 +242,9 @@ async fn setup() -> Setup { add_document_type(contract, THREAD_POST, thread_post_schema()); add_document_type(contract, LISTING, listing_schema()); add_document_type(contract, OFFER, offer_schema()); + add_document_type(contract, ARTICLE, article_schema()); + add_document_type(contract, COMMENT, comment_schema()); + add_document_type(contract, STALL, stall_schema()); }, ) .await @@ -445,3 +530,94 @@ async fn should_check_where_conditions_against_a_removal_record() { "{ \"greaterThan\": \"$updatedAt\" }", ); } + +/// A rule reads the article a comment names, so every replace of the comment +/// checks the reference, its `where` whole: once the article's owner closes it, +/// no comment on it is edited, and a moderator's removal of the article +/// changes nothing, the record keeping the closed status. +#[tokio::test] +async fn should_check_every_where_entry_of_a_removed_document_a_rule_reads() { + let setup = setup().await; + let transaction = setup.platform.drive.grove.start_transaction(); + let (mut article, create_article) = create_with( + &setup, + &setup.user, + ARTICLE, + vec![("text", "hello".into()), ("status", "open".into())], + ) + .await; + assert_success(&setup.process(&create_article, &transaction)); + let (comment, create_comment) = create_with( + &setup, + &setup.stranger, + COMMENT, + vec![ + ("articleId", id_value(article.id())), + ("quote", "hello".into()), + ("body", "nice".into()), + ], + ) + .await; + assert_success(&setup.process(&create_comment, &transaction)); + + article.set("status", "closed".into()); + let close = replacement(&setup.user, &setup.contract, ARTICLE, &mut article).await; + assert_success(&setup.process(&close, &transaction)); + let mut edit = comment.clone(); + edit.set("body", "an edit".into()); + let refused = replacement(&setup.stranger, &setup.contract, COMMENT, &mut edit).await; + assert_where_refused( + &setup.process(&refused, &transaction), + "{ \"const\": \"open\" }", + ); + + let remove = setup + .moderate(&setup.moderator, delete_action(ARTICLE, article.id())) + .await; + assert_success(&setup.process(&remove, &transaction)); + let mut edit = comment.clone(); + edit.set("body", "another edit".into()); + let refused = replacement(&setup.stranger, &setup.contract, COMMENT, &mut edit).await; + assert_where_refused( + &setup.process(&refused, &transaction), + "{ \"const\": \"open\" }", + ); +} + +/// A moderator's change of a field judges no rule reading a referenced +/// document, and reads none of its totals: the stall's rule, reading the posts +/// on its topic and the topic itself, is left for the owner's writes. +#[tokio::test] +async fn should_change_a_field_without_reading_a_total_of_a_rule_it_does_not_judge() { + let setup = setup().await; + let transaction = setup.platform.drive.grove.start_transaction(); + let (topic, create_topic) = create_with( + &setup, + &setup.user, + TOPIC, + vec![("open", Value::Bool(true))], + ) + .await; + assert_success(&setup.process(&create_topic, &transaction)); + let (stall, create_stall) = create_with( + &setup, + &setup.stranger, + STALL, + vec![("topicId", id_value(topic.id()))], + ) + .await; + assert_success(&setup.process(&create_stall, &transaction)); + + let change = setup + .moderate( + &setup.moderator, + ContractUserModerationAction::ChangeDocumentFields { + document_type_name: STALL.to_string(), + document_id: stall.id(), + fields: BTreeMap::from([("note".to_string(), Value::Text("tidy".to_string()))]), + reason: ContractModerationReason::from_text("handled"), + }, + ) + .await; + assert_success(&setup.process(&change, &transaction)); +} 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 392f6238ad5..33c4e7dcc80 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 @@ -1,18 +1,21 @@ use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; -use dpp::data_contract::document_type::class_methods::referenced_reads_error; +use dpp::data_contract::document_type::class_methods::{ + referenced_peer_kind_mismatch, referenced_reads_error, where_condition_literal_error, +}; +use dpp::data_contract::document_type::property_constraints::referenced_fields; use dpp::data_contract::document_type::{ is_path_listed, is_referenced_system_agreement_property, is_referring_system_agreement_property, is_transient, DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceDeclaration, DocumentReferenceKind, DocumentTypeRef, KeyReferenceIdentityProperty, PreallocatedKeySource, - PropertyReference, ReferenceHolder, WhereBound, WhereCondition, WhereLiteral, WhereLiteralKind, - MAX_INDEX_SIZE, + PropertyReference, ReferenceHolder, WhereCondition, WhereLiteralKind, WhereNotEqualBound, + WhereOrderingBound, MAX_INDEX_SIZE, }; use dpp::data_contract::schema::DataContractSchemaMethodsV0; use dpp::data_contract::DataContract; -use dpp::document::property_names::CREATOR_ID; +use dpp::document::property_names::{CREATOR_ID, ID, OWNER_ID}; use dpp::errors::consensus::state::document::referenced_document_list_invalid_error::ReferencedDocumentListInvalidError; use dpp::errors::consensus::state::document::referenced_document_lookup_invalid_error::ReferencedDocumentLookupInvalidError; use dpp::errors::consensus::state::document::referenced_document_property_agreement_invalid_error::ReferencedDocumentPropertyAgreementInvalidError; @@ -45,6 +48,69 @@ fn same_value_kind(a: &DocumentPropertyType, b: &DocumentPropertyType) -> bool { a.value_kind() == b.value_kind() } +/// Why the referenced side `referenced_property` of a `where` entry holds no +/// value to compare on a document of `referenced_document_type`, of +/// `referenced_contract`, `None` when it does: `$creatorId` on a type that +/// records none, a property the type does not define, or one no stored +/// document carries. `$ownerId` and `$id` always hold one. Inert before +/// protocol version 14, which alone reaches this module (contract create and +/// update state validation 1). +fn referenced_side_error( + referenced_contract: &DataContract, + referenced_document_type: DocumentTypeRef, + referenced_property: &str, + platform_version: &PlatformVersion, +) -> Result, Error> { + if is_referenced_system_agreement_property(referenced_property) { + let recorded = referenced_property != CREATOR_ID + || referenced_document_type + .should_use_creator_id( + referenced_contract.system_version_type(), + referenced_contract.config().version(), + platform_version, + ) + .map_err(Error::Protocol)?; + return Ok((!recorded).then_some( + "the referenced document type does not record $creatorId: only transferable or \ + tradeable document types of a format-1 contract do", + )); + } + if referenced_document_type + .flattened_properties() + .get(referenced_property) + .is_none() + { + return Ok(Some( + "the referenced document type does not define the referenced property", + )); + } + // No stored document carries a transient value, so a referring document + // could only agree by omitting its own side. The referring side may be + // transient: it is judged on the transition, a write gate like the + // writer's `$ownerId` + if is_transient(referenced_document_type, referenced_property) { + return Ok(Some( + "the referenced property is transient or inside a transient object: no stored \ + document carries its value, so none could be compared with", + )); + } + Ok(None) +} + +/// Whether every removal record of a document of `referenced_document_type` +/// holds `referenced_property`: its `$id`, its `$ownerId`, or a field its type +/// lists under `deleteKeepsFields` (or inside a kept object). +fn kept_by_every_record( + referenced_document_type: DocumentTypeRef, + referenced_property: &str, +) -> bool { + matches!(referenced_property, ID | OWNER_ID) + || is_path_listed( + referenced_document_type.moderator_deletion_kept_fields(), + referenced_property, + ) +} + /// Whether a key reference pairing the key id at `key_id_path` with the /// identity at `identity_path` of `document_type` would store the key id /// without its identity: the identity transient (or inside a transient @@ -344,6 +410,52 @@ pub(super) fn validate_data_contract_references_v0( } } + // Two fields of documents different references point at, compared with + // each other by a rule, hold one kind of value; the contract parse checked + // the pairs whose types are both the contract's, and another contract's + // type is in hand only here, fetched above. Inert before protocol version + // 14, the only one whose parse reads such a rule + for (declaring_type_name, document_type) in contract.document_types() { + let document_type = document_type.as_ref(); + let kind_of = |reference: &str, field: &str| -> Option { + let declaration = document_type + .reference_declarations() + .find_map(|(holder, property_reference)| { + (holder.path() == reference).then_some(property_reference) + })? + .target()? + .as_any_document_reference()?; + let referenced_contract = match declaration.contract_id { + Some(referenced_contract_id) if referenced_contract_id != contract.id() => { + &fetched_contracts + .get(&referenced_contract_id)? + .as_ref()? + .contract + } + _ => contract, + }; + let referenced = referenced_contract + .document_type_for_name(declaration.document_type_name) + .ok()?; + WhereLiteralKind::of_referenced(referenced, field) + }; + for (rule, constraint) in document_type.property_constraints() { + if let Some((reference, reason)) = + referenced_peer_kind_mismatch(constraint, &format!("rule \"{rule}\""), &kind_of) + { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentRuleReadInvalidError::new( + declaring_type_name.clone(), + rule.clone(), + reference.to_string(), + reason, + ) + .into(), + )); + } + } + } + Ok(SimpleConsensusValidationResult::new()) } @@ -722,23 +834,24 @@ fn validate_reference_target_declaration_v0( property must be an identifier", )); } - if referenced_property == CREATOR_ID - && !referenced_document_type - .should_use_creator_id( - referenced_contract.system_version_type(), - referenced_contract.config().version(), - platform_version, - ) - .map_err(Error::Protocol)? - { - return Ok(invalid( - "the referenced document type does not record $creatorId: only \ - transferable or tradeable document types of a format-1 contract \ - do", - )); + if let Some(reason) = referenced_side_error( + referenced_contract, + referenced_document_type, + referenced_property, + platform_version, + )? { + return Ok(invalid(reason)); } continue; } + if let Some(reason) = referenced_side_error( + referenced_contract, + referenced_document_type, + referenced_property, + platform_version, + )? { + return Ok(invalid(reason)); + } let Some(referenced) = referenced_document_type .flattened_properties() .get(referenced_property) @@ -747,18 +860,6 @@ fn validate_reference_target_declaration_v0( "the referenced document type does not define the referenced property", )); }; - // No stored document carries a transient value, so a referring - // document could only agree by omitting its own side. The referring - // side may be transient: it is judged on the transition, a write - // gate like the writer's `$ownerId`. In place: inert before protocol - // version 14, which alone reaches this module (contract create and - // update state validation 1). - if is_transient(referenced_document_type, referenced_property) { - return Ok(invalid( - "the referenced property is transient or inside a transient object: no \ - stored document carries its value, so none could be agreed with", - )); - } if matches!(referring_type, DocumentPropertyType::Object(_)) || matches!(referenced.property_type, DocumentPropertyType::Object(_)) { @@ -820,13 +921,18 @@ fn validate_reference_target_declaration_v0( // side must equal or be one of, or a bound it must differ from or compare // with. Each must be able to hold: the referenced side exists, is stored and // holds a single value; a literal reads as its kind (a base58 identifier for - // an identifier, hex for a byte array); a `notEqual` compares two values of - // one kind, the writer an identifier; an ordering compares two integers, its - // bound an integer property of the declaring type, an integer or a time or - // height that type records. Through a `moderatedDocument` reference, an entry - // asked about on every replace compares only what every removal record - // holds, as a writer gate does: a time or height bound changes without a - // write, and once a moderator removes the document only its record is left + // an identifier, hex for a byte array) and is a value the referenced + // property can hold; a `notEqual` compares two values of one kind, never + // the reference's own value, the writer an identifier; an ordering compares + // two integers, its bound an integer property of the declaring type, an + // integer some value of the referenced property is on the right side of, or + // a time or height that type records. Through a `moderatedDocument` + // reference, an entry asked about on every replace compares only what every + // removal record holds, as a writer gate does: a time or height bound + // changes without a write, and once a moderator removes the document only + // its record is left. Inert before protocol version 14, which alone reaches + // this module (contract create and update state validation 1), and the only + // one whose parse reads such an entry for (referenced_property, condition) in where_conditions { let invalid = |reason: &str| { SimpleConsensusValidationResult::new_with_error( @@ -839,39 +945,18 @@ fn validate_reference_target_declaration_v0( .into(), ) }; - let referenced_type = if is_referenced_system_agreement_property(referenced_property) { - if referenced_property == CREATOR_ID - && !referenced_document_type - .should_use_creator_id( - referenced_contract.system_version_type(), - referenced_contract.config().version(), - platform_version, - ) - .map_err(Error::Protocol)? - { - return Ok(invalid( - "the referenced document type does not record $creatorId: only \ - transferable or tradeable document types of a format-1 contract do", - )); - } - None - } else { - let Some(referenced) = referenced_document_type - .flattened_properties() - .get(referenced_property) - else { - return Ok(invalid( - "the referenced document type does not define the referenced property", - )); - }; - if is_transient(referenced_document_type, referenced_property) { - return Ok(invalid( - "the referenced property is transient or inside a transient object: no \ - stored document carries its value", - )); - } - Some(&referenced.property_type) - }; + if let Some(reason) = referenced_side_error( + referenced_contract, + referenced_document_type, + referenced_property, + platform_version, + )? { + return Ok(invalid(reason)); + } + let referenced_type = referenced_document_type + .flattened_properties() + .get(referenced_property) + .map(|referenced| &referenced.property_type); let Some(literal_kind) = WhereLiteralKind::of_referenced(referenced_document_type, referenced_property) else { @@ -880,26 +965,22 @@ fn validate_reference_target_declaration_v0( float, an object or an array", )); }; - let literal_fits = |literal: &WhereLiteral| literal.value_for(literal_kind).is_some(); + if let Some(reason) = where_condition_literal_error( + referenced_document_type, + referenced_contract.schema_defs(), + referenced_property, + literal_kind, + condition, + ) + .map_err(|error| Error::Protocol(error.into()))? + { + return Ok(invalid(&reason)); + } match condition { - WhereCondition::Constant(literal) - | WhereCondition::NotEqual(WhereBound::Constant(literal)) => { - if !literal_fits(literal) { - return Ok(invalid(&format!( - "the literal {literal} is not {}, as the referenced property holds", - literal_kind.describe() - ))); - } - } - WhereCondition::In(literals) => { - if let Some(literal) = literals.iter().find(|literal| !literal_fits(literal)) { - return Ok(invalid(&format!( - "the literal {literal} is not {}, as the referenced property holds", - literal_kind.describe() - ))); - } - } - WhereCondition::NotEqual(WhereBound::Writer) => { + WhereCondition::Constant(_) + | WhereCondition::In(_) + | WhereCondition::NotEqual(WhereNotEqualBound::Constant(_)) => {} + WhereCondition::NotEqual(WhereNotEqualBound::Writer) => { if literal_kind != WhereLiteralKind::Identifier { return Ok(invalid( "$ownerId, the writer, is an identifier, so the referenced side must be \ @@ -907,7 +988,14 @@ fn validate_reference_target_declaration_v0( )); } } - WhereCondition::NotEqual(WhereBound::Property(referring_property)) => { + WhereCondition::NotEqual(WhereNotEqualBound::Property(referring_property)) => { + // The reference's own value is what found the document, so it + // can not differ from it, as an equality with it always holds + if Some(referring_property.as_str()) == reference_property { + return Ok(invalid( + "the referring property cannot be the reference property itself", + )); + } let Some(referring) = document_type.flattened_properties().get(referring_property) else { return Ok(invalid( @@ -931,9 +1019,6 @@ fn validate_reference_target_declaration_v0( )); } } - WhereCondition::NotEqual(WhereBound::System(_)) => { - return Ok(invalid("notEqual takes no time or height")); - } WhereCondition::Ordering { bound, .. } => { if literal_kind != WhereLiteralKind::Integer { return Ok(invalid( @@ -942,7 +1027,7 @@ fn validate_reference_target_declaration_v0( )); } match bound { - WhereBound::Property(referring_property) => { + WhereOrderingBound::Property(referring_property) => { let is_integer = document_type .flattened_properties() .get(referring_property) @@ -955,7 +1040,7 @@ fn validate_reference_target_declaration_v0( )); } } - WhereBound::System(system) => { + WhereOrderingBound::System(system) => { if !document_type.required_fields().contains(system.name()) { return Ok(invalid(&format!( "the declaring document type does not record {}: list it in \ @@ -964,23 +1049,51 @@ fn validate_reference_target_declaration_v0( ))); } } - WhereBound::Constant(_) | WhereBound::Writer => {} - } - if kind == DocumentReferenceKind::Moderated - && condition.is_rechecked_on_every_replace() - && !is_path_listed( - referenced_document_type.moderator_deletion_kept_fields(), - referenced_property, - ) - { - return Ok(invalid( - "the entry is checked on every replace, and once a moderator removes \ - the document only its record is left, which keeps the fields its type \ - lists under deleteKeepsFields: compare one of those", - )); + WhereOrderingBound::Constant(_) => {} } } } + if kind == DocumentReferenceKind::Moderated + && condition.is_rechecked_on_every_replace() + && !kept_by_every_record(referenced_document_type, referenced_property) + { + return Ok(invalid( + "the entry is checked on every replace, and once a moderator removes the \ + document only its record is left, which keeps its $id, its $ownerId and the \ + fields its type lists under deleteKeepsFields: compare one of those", + )); + } + } + + // A rule reading the document through a `moderatedDocument` reference has + // the reference checked, its `where` whole, on every replace, which once a + // moderator removes the document reads its record: every entry compares + // what the record keeps, as every value the rule reads does + let read_by_a_rule = reference_property.is_some_and(|reference_property| { + referenced_fields(document_type.property_constraints()).contains_key(reference_property) + }); + if kind == DocumentReferenceKind::Moderated && read_by_a_rule { + let unkept = property_agreement + .values() + .chain(where_conditions.keys()) + .find(|referenced_property| { + !kept_by_every_record(referenced_document_type, referenced_property) + }); + if let Some(referenced_property) = unkept { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentPropertyAgreementInvalidError::new( + declaration_path.clone(), + reference_property.unwrap_or_default().to_string(), + referenced_property.clone(), + "a rule reads the document through this moderatedDocument reference, so \ + every replace checks its where whole, which once a moderator removes the \ + document reads its record: compare its $id, its $ownerId or a field its \ + type lists under deleteKeepsFields" + .to_string(), + ) + .into(), + )); + } } Ok(SimpleConsensusValidationResult::new()) 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 4e3cbde0580..fa3a2cb65a8 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 @@ -7051,6 +7051,107 @@ mod tests { ); } + /// A where entry that can never hold refuses at registration, as one + /// of another kind does: a notEqual bounded by the reference's own + /// value, which found the document; a literal no value of the + /// referenced property equals; an ordering no value of it meets. + #[tokio::test] + async fn should_reject_a_where_entry_that_can_never_hold() { + for (fixture, referenced_property, reason) in [ + ( + "where-not-equal-reference", + "$id", + "the referring property cannot be the reference property itself", + ), + ( + "where-literal-enum", + "status", + "is not one of the referenced property's enum values", + ), + ( + "where-ordering-range", + "reservePrice", + "no value the referenced property holds", + ), + ] { + let result = run_contract_create_with_foreign( + &format!( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-{fixture}.json" + ), + REFERENCED_RULES_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyAgreementInvalidError(e) + ), + .. + } if e.referenced_property() == referenced_property + && e.reason().contains(reason), + "{fixture}" + ); + } + } + + #[tokio::test] + async fn should_reject_a_rule_comparing_fields_of_another_contracts_documents_of_two_kinds() + { + // An auction's status, a string, never equals an event's + // capacity, an integer, and only registration sees both types + let result = run_contract_create_with_foreign( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-peer-kinds.json", + REFERENCED_RULES_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentRuleReadInvalidError(e) + ), + .. + } if e.rule() == "statusIsCapacity" + && e.reason().contains("which hold different kinds of value") + ); + } + + #[tokio::test] + async fn should_reject_a_where_entry_a_removal_record_lacks_on_a_reference_a_rule_reads() { + // The reply's rule reads the post through its moderatedDocument + // reference, so every replace checks the reference's where, which a + // removal record answers only for what it keeps: not the status + let config = DataContractConfig::default_for_version(PlatformVersion::latest()) + .unwrap() + .with_moderation(Some(ContractModerationConfig { + banlist: false, + suspensions: false, + moderators: ContractModerators::ContractOwner, + warnings: false, + })); + let result = run_contract_create_with_foreign_and_config( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-moderated-where-unkept.json", + REFERENCED_RULES_CONTRACT_PATH, + config, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyAgreementInvalidError(e) + ), + .. + } if e.referenced_property() == "status" + && e.reason().contains("a rule reads the document through this \ + moderatedDocument reference") + ); + } + #[tokio::test] async fn should_reject_a_where_literal_of_another_kind_than_the_referenced_property() { let result = run_contract_create_with_foreign( diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-moderated-where-unkept.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-moderated-where-unkept.json new file mode 100644 index 00000000000..68c79de09f8 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-moderated-where-unkept.json @@ -0,0 +1,94 @@ +{ + "$formatVersion": "1", + "id": "FmNtTTiJ9X8sJdKvkpK58yzUAx78PW61bCpwW8dZnpMC", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "post": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": false, + "moderatorAbilities": { + "delete": true, + "deleteKeepsFields": [ + "text" + ] + }, + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "position": 0 + }, + "status": { + "type": "string", + "enum": [ + "open", + "closed" + ], + "maxLength": 10, + "position": 1 + } + }, + "required": [ + "text", + "status" + ], + "additionalProperties": false + }, + "reply": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": false, + "properties": { + "postId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "moderatedDocument", + "documentType": "post", + "where": { + "status": { + "const": "open" + } + } + }, + "position": 0 + }, + "quote": { + "type": "string", + "maxLength": 50, + "position": 1 + } + }, + "required": [ + "postId" + ], + "propertyConstraints": { + "quotesThePost": { + "anyOf": [ + { + "absent": "quote" + }, + { + "equal": [ + "quote", + { + "referenced": [ + "postId", + "text" + ] + } + ] + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-peer-kinds.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-peer-kinds.json new file mode 100644 index 00000000000..a774f6f8df1 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-peer-kinds.json @@ -0,0 +1,72 @@ +{ + "$formatVersion": "1", + "id": "FmNtTTiJ9X8sJdKvkpK58yzUAx78PW61bCpwW8dZnpMC", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "foreignBid": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "auctionId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "contractId": "31TfU1koATcamnBrNiRbturPP5kLaE8VzJcojeTxedxR", + "documentType": "auction" + }, + "position": 0 + }, + "amount": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000, + "position": 1 + }, + "eventCode": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "refersTo": { + "type": "permanentDocument", + "contractId": "31TfU1koATcamnBrNiRbturPP5kLaE8VzJcojeTxedxR", + "documentType": "event", + "findBy": { + "code": "." + } + }, + "position": 2 + } + }, + "required": [ + "auctionId", + "amount", + "eventCode" + ], + "propertyConstraints": { + "statusIsCapacity": { + "equal": [ + { + "referenced": [ + "auctionId", + "status" + ] + }, + { + "referenced": [ + "eventCode", + "capacity" + ] + } + ] + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-literal-enum.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-literal-enum.json new file mode 100644 index 00000000000..b03212228fd --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-literal-enum.json @@ -0,0 +1,44 @@ +{ + "$formatVersion": "1", + "id": "FmNtTTiJ9X8sJdKvkpK58yzUAx78PW61bCpwW8dZnpMC", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "foreignBid": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "auctionId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "contractId": "31TfU1koATcamnBrNiRbturPP5kLaE8VzJcojeTxedxR", + "documentType": "auction", + "where": { + "status": { + "const": "paused" + } + } + }, + "position": 0 + }, + "amount": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000, + "position": 1 + } + }, + "required": [ + "auctionId", + "amount" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-not-equal-reference.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-not-equal-reference.json new file mode 100644 index 00000000000..44d9fcada31 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-not-equal-reference.json @@ -0,0 +1,44 @@ +{ + "$formatVersion": "1", + "id": "FmNtTTiJ9X8sJdKvkpK58yzUAx78PW61bCpwW8dZnpMC", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "foreignBid": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "auctionId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "contractId": "31TfU1koATcamnBrNiRbturPP5kLaE8VzJcojeTxedxR", + "documentType": "auction", + "where": { + "$id": { + "notEqual": "auctionId" + } + } + }, + "position": 0 + }, + "amount": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000, + "position": 1 + } + }, + "required": [ + "auctionId", + "amount" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-ordering-range.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-ordering-range.json new file mode 100644 index 00000000000..d2fbb74c3f9 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules-registration-where-ordering-range.json @@ -0,0 +1,46 @@ +{ + "$formatVersion": "1", + "id": "FmNtTTiJ9X8sJdKvkpK58yzUAx78PW61bCpwW8dZnpMC", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "foreignBid": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "auctionId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "contractId": "31TfU1koATcamnBrNiRbturPP5kLaE8VzJcojeTxedxR", + "documentType": "auction", + "where": { + "reservePrice": { + "greaterThan": { + "const": 1000000000 + } + } + } + }, + "position": 0 + }, + "amount": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000, + "position": 1 + } + }, + "required": [ + "auctionId", + "amount" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules.json index d26edb477c8..4ecbe61c00b 100644 --- a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules.json +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-referenced-rules.json @@ -248,6 +248,115 @@ }, "required": ["ballotId", "$createdAt", "$updatedAt"], "additionalProperties": false + }, + "ballotList": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "ballotIds": { + "type": "array", + "minItems": 0, + "maxItems": 4, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "ballot", + "where": { "endsAt": { "greaterThan": { "const": 100 } } } + } + }, + "position": 0 + } + }, + "required": [], + "additionalProperties": false + }, + "ballotOrAuction": { + "type": "object", + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "targetId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "anyOf": [ + { + "type": "permanentDocument", + "documentType": "ballot", + "where": { "endsAt": { "greaterThan": { "const": 100 } } } + }, + { + "type": "permanentDocument", + "documentType": "auction", + "where": { "status": { "const": "open" } } + } + ] + }, + "position": 0 + } + }, + "required": ["targetId"], + "additionalProperties": false + }, + "pass": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": true, + "properties": { + "eventCode": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "refersTo": { + "type": "permanentDocument", + "documentType": "event", + "findBy": { "code": "." }, + "where": { "capacity": { "greaterThan": { "const": 1 } } } + }, + "position": 0 + } + }, + "required": ["eventCode"], + "additionalProperties": false + }, + "nestedVote": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": true, + "properties": { + "meta": { + "type": "object", + "properties": { + "pollId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "deletableDocument", "documentType": "poll" }, + "position": 0 + } + }, + "required": ["pollId"], + "additionalProperties": false, + "position": 0 + } + }, + "required": ["meta"], + "propertyConstraints": { + "singleChoicePoll": { "equal": [{ "referenced": ["meta.pollId", "multiChoice"] }, 0] } + }, + "additionalProperties": false } } } diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index a1dfea32eaa..ca0581f2446 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1168,13 +1168,16 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// points at, `{ "referenced": [reference, field] }`, wherever a path /// stands: `field` is a property path of the referenced type, its `$id`, /// `$ownerId` or `$creatorId`, or a time or height it requires, read as -/// that property is, compared by what the rule compares it with (a string -/// beside a `const`, an identifier beside an identifier operand, an integer -/// otherwise), one node. The parser (`parse_property_constraints` 0 and -/// meta-schema v3, in place) reads it as the path -/// `$referenced..`; generation 3 refuses it, under full +/// that property is; one the parse cannot type (a schema field of another +/// type, maybe another contract's) compared with a string constant or +/// another such field is read as what it holds (a string, an identifier +/// the constant spells base58, a byte array it spells hex, two fields by +/// value), one node. `reference` is the dotted path of a stored property of +/// the type, a nested one included. The parser (`parse_property_constraints` +/// 0 and meta-schema v3, in place) reads it as the path +/// `$referenced..$.`; generation 3 refuses it, under full /// validation, outside `propertyConstraints`, on an indexOnly type, -/// through anything but a top-level stored property holding one reference +/// through anything but a stored property holding one reference /// to one document by id or by a `findBy` without a function, beside what /// a transfer, a purchase or a price update of a type that has them /// changes, beside a field moderators change, read or deciding a total @@ -1184,8 +1187,10 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `create_document_types_from_document_schemas` 1, in place, checks what /// it reads of a type of the same contract (10231: the field exists, is /// stored and of the kind read, a time or `$creatorId` the type records, a -/// constant its `enum` lists, through a `moderatedDocument` only `$id`, -/// `$ownerId` and kept fields). Such a rule is not judged with the others: +/// constant its `enum` lists and that spells a value of the field's type, +/// two fields compared hold one kind, through a `moderatedDocument` only +/// `$id`, `$ownerId` and kept fields). Such a rule is not judged with the others, +/// and no total it reads is read for a write that does not judge it: /// `PropertyConstraint::violation` leaves it unjudged unless /// `DocumentSystemValues::referenced_documents`, so the structure /// validation, transfers, purchases, price updates, moderator restores and @@ -1746,17 +1751,25 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `const` or a time or height of the referring document; a literal reads /// as the referenced property's type says. Document reference validation /// 0 (in place) checks them against the document found, or the removal -/// record for one asked about again, refusing with 40127 whose referring -/// property is the condition as written; one bounded by the writer or the -/// last update or transfer is asked about on every replace. Contract -/// create and update state validation (data contract reference validation -/// 0, in place) refuses one that cannot hold with 40126. The same -/// validation checks every reference a `propertyConstraints` rule reads -/// through (item 39) on every replace, the existence check's read, billed -/// as before, and records for the rule the values it reads of the -/// document found, or of a kept `moderatedDocument`'s removal record, so a -/// rule adds no read; and registration refuses a rule reading a field -/// another contract's type does not give it with +/// record for one asked about again (every entry when a rule reads +/// through the reference), refusing with 40127 whose referring property is +/// the condition as written; one bounded by the writer or the last update +/// or transfer is asked about on every replace, and none of those may sit +/// beside a `findBy` function (10231). Contract create and update state +/// validation (data contract reference validation 0, in place) refuses one +/// that cannot hold with 40126: a literal no value of the referenced +/// property equals (its kind, `enum`, length, size or range), an `in` +/// repeating a value once read, a `notEqual` bounded by the reference +/// property itself, an ordering no value of the property meets, and an +/// entry of a `moderatedDocument` reference a rule reads through that +/// reads what a removal record does not keep. The same validation checks +/// every reference a `propertyConstraints` rule reads through (item 39) on +/// every replace, the existence check's read, billed as before, a foreign +/// contract fetched and billed once per write, and records for the rule +/// the values it reads of the document found, or of a kept +/// `moderatedDocument`'s removal record, so a rule adds no read; and +/// registration refuses a rule reading a field another contract's type +/// does not give it, or comparing two fields of different kinds, with /// `ReferencedDocumentRuleReadInvalidError` (40148, appended to /// `StateError`). /// 64. **`refersTo: moderatedDocument`**: a third kind of document reference, diff --git a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs index 330724d89f9..e6cb9a81bfd 100644 --- a/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs +++ b/packages/rs-sdk-ffi/src/data_contract/property_constraints.rs @@ -41,7 +41,7 @@ use dash_sdk::dpp::data_contract::document_type::methods::{ DocumentTypeBasicMethods, DocumentTypeV0Methods, }; use dash_sdk::dpp::data_contract::document_type::property_constraints::{ - referenced_read, AggregateKind, AggregateRead, DocumentSystemValues, PropertyRead, + AggregateKind, AggregateRead, DocumentSystemValues, PropertyRead, }; use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; use dash_sdk::dpp::document::{Document, DocumentV0Getters}; @@ -326,9 +326,8 @@ fn property_constraints_json( ) })?; let reads: Vec = constraint - .property_reads() + .own_property_reads() .into_iter() - .filter(|(path, _)| referenced_read(path).is_none()) .map(|(path, read)| json!({ "path": path, "kind": read_kind_name(read) })) .collect(); let reads_referenced: Vec = constraint diff --git a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs index ec18e01db28..a44e918240e 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_property_constraints.rs @@ -22,7 +22,7 @@ use dpp::data_contract::document_type::DocumentTypeRef; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; use dpp::data_contract::document_type::property_constraints::{ - AggregateKind, DocumentSystemValues, PropertyRead, referenced_read, + AggregateKind, DocumentSystemValues, PropertyRead, }; use dpp::document::{Document, DocumentV0Getters}; use dpp::platform_value::Value; @@ -465,11 +465,7 @@ pub(crate) fn property_constraints_for_document_type( set_field(&object, "rule", &rule_to_js(declared, name)?, name)?; let reads = Array::new(); - for (path, read) in constraint - .property_reads() - .into_iter() - .filter(|(path, _)| referenced_read(path).is_none()) - { + for (path, read) in constraint.own_property_reads() { let entry = Object::new(); set_field(&entry, "path", &JsValue::from_str(path), name)?; set_field( 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 dfeb814e4c0..119142e2556 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -18,7 +18,8 @@ use crate::utils::define_own_property; use dpp::data_contract::document_type::{ DocumentPropertyReferenceTarget, DocumentReferenceLookup, DocumentTypeRef, IdentityKeyReferenceRequirements, KeyIdReference, LookupHashKey, LookupKeyParam, - LookupKeySource, PropertyReference, WhereBound, WhereCondition, WhereLiteral, + LookupKeySource, PropertyReference, WhereCondition, WhereLiteral, WhereNotEqualBound, + WhereOrderingBound, }; use dpp::document::property_names::{ID, OWNER_ID}; use dpp::prelude::Identifier; @@ -250,8 +251,11 @@ export type DocumentPropertyReferenceTarget = * resolves to the document or, once a moderator removed it, to its * removal record, which a restore can bring the document back from. A * replace keeps it, re-validating it only when the value or a property - * `where` reads changes (every replace for a `where` value `$ownerId`), - * as for `permanentDocument`. + * `where` reads changes, as for `permanentDocument`; on every replace + * for a `where` value `$ownerId`, a `notEqual` bounded by `$ownerId`, + * an ordering bounded by `$updatedAt` or `$transferredAt` (or their + * heights), and a reference a `propertyConstraints` rule reads through, + * which checks its whole `where`. */ type: 'moderatedDocument'; /** @@ -599,14 +603,25 @@ fn where_constant_to_js(literal: &WhereLiteral, path: &str) -> WasmDppResult WasmDppResult { +/// The bound of a `notEqual` as the schema writes it: a path, `'$ownerId'` or +/// `{ const }`. +fn not_equal_bound_to_js(bound: &WhereNotEqualBound, path: &str) -> WasmDppResult { Ok(match bound { - WhereBound::Property(property) => JsValue::from_str(property), - WhereBound::Writer => JsValue::from_str(OWNER_ID), - WhereBound::System(property) => JsValue::from_str(property.name()), - WhereBound::Constant(literal) => where_constant_to_js(literal, path)?, + WhereNotEqualBound::Property(property) => JsValue::from_str(property), + WhereNotEqualBound::Writer => JsValue::from_str(OWNER_ID), + WhereNotEqualBound::Constant(literal) => where_constant_to_js(literal, path)?, + }) +} + +/// The bound of an ordering as the schema writes it: a path, a time or +/// height, or `{ const }`. +fn ordering_bound_to_js(bound: &WhereOrderingBound, path: &str) -> WasmDppResult { + Ok(match bound { + WhereOrderingBound::Property(property) => JsValue::from_str(property), + WhereOrderingBound::System(property) => JsValue::from_str(property.name()), + WhereOrderingBound::Constant(integer) => { + where_constant_to_js(&WhereLiteral::Integer(*integer), path)? + } }) } @@ -614,9 +629,8 @@ fn where_bound_to_js(bound: &WhereBound, path: &str) -> WasmDppResult { fn where_condition_to_js(condition: &WhereCondition, path: &str) -> WasmDppResult { let value = match condition { WhereCondition::Constant(literal) => where_literal_to_js(literal), - WhereCondition::NotEqual(bound) | WhereCondition::Ordering { bound, .. } => { - where_bound_to_js(bound, path)? - } + WhereCondition::NotEqual(bound) => not_equal_bound_to_js(bound, path)?, + WhereCondition::Ordering { bound, .. } => ordering_bound_to_js(bound, path)?, WhereCondition::In(literals) => { let values = Array::new(); for literal in literals { diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index ceebd4eefe2..025602b6805 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -895,9 +895,11 @@ impl DataContractWasm { /// device clock in place of the block time the write will record (the /// document's stored creation and transfer times when it has them). A /// rule reading a block height the write records is not judged, since the - /// height is unknown until the block, and neither is a rule reading a - /// `countOf` or `sumOf` total, which only the platform reads from state - /// (a rule's `readsTotals` lists them). + /// height is unknown until the block, a rule reading a `countOf` or + /// `sumOf` total, which only the platform reads from state (a rule's + /// `readsTotals` lists them), or a rule reading a value of the document a + /// reference points at, which the platform reads with the reference (a + /// rule's `readsReferenced` lists them). /// `undefined` when it meets every rule of its document type. /// /// A pre-check, so an app can refuse a document before paying for a From 90435a2cf0b6136e7735143c30c5a394530588fc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 11 Oct 2026 19:03:00 +0300 Subject: [PATCH 7/8] test(drive-abci): a where condition on a list element's reference The list element fixture's resignation gains alphaMemberId, a member of the charter its electedCharterId names that must be titled "alpha", and the contract update fixtures keep it so their updates still only add. A charter titled otherwise refuses the write with 40127 naming the condition. Co-Authored-By: Claude Opus 5.5 --- .../tests/document/list_element_reference.rs | 41 +++++++++++++++++++ ...tion-contract-list-element-update-bad.json | 19 +++++++++ ...ion-contract-list-element-update-good.json | 19 +++++++++ ...ence-validation-contract-list-element.json | 19 +++++++++ 4 files changed, 98 insertions(+) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/list_element_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/list_element_reference.rs index c9b37a75797..3bc15572791 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/list_element_reference.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/list_element_reference.rs @@ -516,6 +516,47 @@ mod list_element_reference_tests { ); } + /// A `where` condition holds on a list element's reference as on any + /// other, against the charter its `findBy` names: `alphaMemberId` must be + /// a member of a charter titled "alpha". + #[tokio::test] + async fn should_check_a_where_condition_against_the_charter() { + let mut fixture = ListElementFixture::new(); + let member = fixture.id(Who::Member); + let alpha = fixture + .seat_charter(submitted_charter_id(1), "alpha", &[member]) + .await; + let beta = fixture + .seat_charter(submitted_charter_id(2), "beta", &[member]) + .await; + + let (_, held) = fixture + .resign(&[ + ("electedCharterId", id_value(alpha)), + ("alphaMemberId", id_value(member)), + ]) + .await; + assert_succeeded(held); + + let (_, refused) = fixture + .resign(&[ + ("electedCharterId", id_value(beta)), + ("alphaMemberId", id_value(member)), + ]) + .await; + assert_matches!( + refused, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyMismatchError(e) + ), + .. + } if e.path() == "alphaMemberId" + && e.referring_property() == "{ \"const\": \"alpha\" }" + && e.referenced_property() == "title" + ); + } + /// `$id` on the referenced side of an ordinary reference's agreement: /// `echoCharterId` must repeat the id of the charter `echoedCharterId` /// refers to. diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-update-bad.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-update-bad.json index ad6760cfc71..274eb63569a 100644 --- a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-update-bad.json +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-update-bad.json @@ -166,6 +166,25 @@ "inList": "members" } }, + "alphaMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 13, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter", + "findBy": { + "$id": "electedCharterId" + }, + "where": { + "title": { "const": "alpha" } + }, + "inList": "members" + } + }, "meta": { "type": "object", "properties": { diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-update-good.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-update-good.json index e1d95b9c853..f594d2728ad 100644 --- a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-update-good.json +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-update-good.json @@ -166,6 +166,25 @@ "inList": "members" } }, + "alphaMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 13, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter", + "findBy": { + "$id": "electedCharterId" + }, + "where": { + "title": { "const": "alpha" } + }, + "inList": "members" + } + }, "meta": { "type": "object", "properties": { diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element.json index 43441705d49..08d0cd8a30b 100644 --- a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element.json +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element.json @@ -166,6 +166,25 @@ "inList": "members" } }, + "alphaMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 13, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter", + "findBy": { + "$id": "electedCharterId" + }, + "where": { + "title": { "const": "alpha" } + }, + "inList": "members" + } + }, "meta": { "type": "object", "properties": { From 9195c671c223ef79cf6e5cb8c9e23fe59bab9ce8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 11 Oct 2026 19:17:55 +0300 Subject: [PATCH 8/8] fix(drive-abci): a revealed reference shares the write's contract cache A reference whose value a findBy function reveals still fetched a foreign contract into a map of its own, so beside another reference into the same contract the write fetched and billed it twice. It now shares the write's cache, as every other reference does. The wasm-dpp2 spec's local schemas constant no longer shadows the module's. Co-Authored-By: Claude Opus 5.5 --- .../document/document_reference_validation/v0/mod.rs | 2 +- .../wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 45ec82c8d17..45ef6b515c2 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 @@ -427,7 +427,7 @@ fn validate_document_type_references_v0( Some(RevealedValue { value, billed_key }), path, None, - &mut BTreeMap::new(), + &mut referenced_contracts, &mut fetched_documents, platform, block_info, diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index 65107ae1062..aef6617dea0 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -861,7 +861,7 @@ describe('DataContract — refersTo declarations (v14)', () => { * schemas come from JSON, where `__proto__` is an ordinary key. */ it('should keep a where entry named __proto__ as an own key', () => { - const schemas = JSON.parse(`{ + const protoSchemas = JSON.parse(`{ "post": { "type": "object", "canBeDeleted": false, @@ -891,7 +891,7 @@ describe('DataContract — refersTo declarations (v14)', () => { "additionalProperties": false } }`); - const [postId] = buildModeratedContract(schemas).documentTypeReferences( + const [postId] = buildModeratedContract(protoSchemas).documentTypeReferences( 'reply', ) as Reference[];