diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 4e6115f5b28..26019e3dd51 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -270,7 +270,7 @@ Revision 0 is never used for active documents. This allows `0` to serve as a sen ## Document References (`refersTo`) -From protocol version 14 a property of a document type can declare what it points at, and consensus refuses a create or replace whose target does not exist when the document is written (the reference is a write-time constraint only; nothing resolves it for a reader). The keyword is `refersTo` on the property, its `type` one of `identity`, `contract` (optionally with `contractRequirements`, see [Contract Moderation](contract-moderation.md)), `token`, `permanentDocument`, `deletableDocument` and `identityPublicKey`, or a reference expression combining several with `anyOf` and `allOf` (see [Reference expressions](#reference-expressions-anyof-allof)). Every form sits on an identifier property, with one exception below. The parsed shape is `DocumentPropertyType::IdentifierWithReference(target)`, and any change to a declaration on contract update is an incompatible schema change. +From protocol version 14 a property of a document type can declare what it points at, and consensus refuses a create or replace whose target does not exist when the document is written (the reference is a write-time constraint only; nothing resolves it for a reader). The keyword is `refersTo` on the property, its `type` one of `identity`, `contract` (optionally with `contractRequirements`, see [Contract Moderation](contract-moderation.md)), `token`, `permanentDocument`, `deletableDocument`, `identityPublicKey` and `listElement` (see [An element of a list](#an-element-of-a-list-listelement)), or a reference expression combining several with `anyOf` and `allOf` (see [Reference expressions](#reference-expressions-anyof-allof)). Every form sits on an identifier property, with one exception below. The parsed shape is `DocumentPropertyType::IdentifierWithReference(target)`, and any change to a declaration on contract update is an incompatible schema change. An `identityPublicKey` reference names one key of one identity, and comes in two forms that differ in which property carries what: @@ -378,6 +378,60 @@ When the referring document is created or replaced, the document reference valid Joins and preallocated indexes need one target: a chained query or a composite by-id join refuses an expression join property, and a `preallocated` index is never bound through one. In Rust the combinators are `DocumentPropertyReferenceTarget::AnyOf(ReferenceOperands)` and `AllOf(ReferenceOperands)`, appended to the enum so every single target keeps its encoding. An expression is no document reference as a whole (`as_any_document_reference` is `None`); code that checks every declaration walks `DocumentPropertyReferenceTarget::leaves` (or `leaves_with_paths`), the leaves of an expression or the declaration itself. Since the enum is embedded in consensus errors, which clients decode from bytes a node sends, decoding refuses a nesting deeper than `MAX_REFERENCE_EXPRESSION_DECODE_DEPTH` (16, above every protocol version's registration limit, which a test holds it to), so no bytes can drive the decoder into unbounded recursion. A reference error never carries a combinator: a refusal is a leaf's error. +### An element of a list (`listElement`) + +A `listElement` reference says the value must be one of the identifiers a list of another document holds, the document an agreement pair names by its `$id`: + +```json +"resignation": { + "type": "object", + "properties": { + "electedCharterId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "permanentDocument", "documentType": "electedCharter" }, + "position": 0 + }, + "memberId": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { "electedCharterId": "$id" }, + "inList": "members" + }, + "position": 1 + } + }, + "required": ["electedCharterId", "memberId"], + "additionalProperties": false +} +``` + +reads: `memberId` must be one of the `members` of the `electedCharter` document whose `$id` this document's `electedCharterId` holds. The moderation charters, whose elected charter holds its `members`, are the first users. The declaration sits on an identifier property, on the `items` of a typed array of identifiers, where every element must be listed (see [References on the Elements](#references-on-the-elements)), as a leaf of a reference expression (see [Reference expressions](#reference-expressions-anyof-allof)), or on the writer or the creator (see [On the writer or the creator](#on-the-writer-or-the-creator-ownerrefersto-creatorrefersto)), where the value is that identity: `"ownerRefersTo": { "anyOf": [, ] }` is the charters' rule that the writer is a seated or an added moderator. + +A list element is a document reference in every respect but one. It takes the `contractId`, `documentType` and `propertyAgreement` of a `permanentDocument` reference, with the same checks (the referenced type must forbid deletion, every pair must exist and share one value kind), and `$id` joins `$ownerId` and `$creatorId` as a system name the referenced side of any agreement pair may carry. What differs is what the value is: not the referenced document's id but an element of its list. The document is the one the pair with `$id` on the referenced side names, so a `listElement` holds exactly one such pair, read from an identifier property of the referring type (a schema property, never `$ownerId`: no document has the writer's id), and `inList` names the typed array of identifiers on the referenced type. What is checked when the contract enters the chain: + +- The `$id` pair reads a stored identifier property of the referring type (it and every object around it not transient), so a reader can tell from the stored document which list the value was checked against. It may be optional, and it needs no `refersTo` of its own. Checked by the parse under full validation (generation 3), a leaf of an expression as it would be alone. +- The list's document type forbids deletion, and `inList` is a stored typed array of identifiers on it that never changes once a document is written: the type is immutable (`documentsMutable: false`), or the list's top-level property is listed under `immutable`, the rule a lookup's key parts are judged by. An `immutableAllowSetting` entry can only be set on a document that has no value for it, against which no value was ever accepted, so it does not weaken the rule. For a type of the same contract the contract parse checks this (`create_document_types_from_document_schemas` 1); for a type of another contract, registration checks it against that contract in state and refuses a list that does not qualify with `ReferencedDocumentListInvalidError` (state code 40138). A missing document type is refused as for any document reference (40121), and a deletable one by the parse for the same contract (with the list reason) or by registration for another (40122). +- Each value counts against `SystemLimits::max_references_per_document`, one for a property, `maxItems` for a typed array, like every other reference. +- A changed, added or removed `listElement` is an incompatible schema change on update. + +When the referring document is created or replaced, the document reference validation fetches the document whose id the `$id` pair's property holds, by id, checks the other pairs against it exactly as for a `permanentDocument`, and requires the value to be in its list. Every by-id document fetch of one write is shared: a charter that `electedCharterId`'s own reference already fetched, or that the elements of one typed array all name, is fetched and billed once, and the list is collected once into a set, so each value is a set lookup. A value the list does not hold, a document the id names that does not exist, or a value set while the `$id` property is not, refuses the write, paid, with `ReferencedEntityNotFoundError` (40120) naming the property, or the element by its list path (`witnesses[1]`); its target reads "list element (`` of the `` document `` names)". A failing extra pair is `ReferencedDocumentPropertyMismatchError` (40127), as always. A replace checks a list element again when its value changed or when the referring side of any of its pairs changed (the `$id` property among them, since it may name another charter), the rule every agreement follows, and then every value, every element included; only a list that changed on its own leaves out the elements the stored list already held. Nothing else can make a validated value unlisted: the list's document can never be deleted and its list never changes. + +For example: + +```text +electedCharter 7kX...: members [Alice, Bob] +resignation { electedCharterId: 7kX..., memberId: Alice } -> accepted +resignation { electedCharterId: 7kX..., memberId: Carol } -> refused, 40120: + referenced list element (members of the electedCharter document electedCharterId names) + not found for path memberId +``` + +In Rust the declaration is the appended variant `DocumentPropertyReferenceTarget::ListElement(ListElementReference)`, so every earlier variant keeps its encoding in the reference errors; the rules are on `ListElementReference` (`document_id_property`, `referring_side_error`, `referenced_side_error`, `listed_values`). `as_any_document_reference` carries it with `in_list` set, so the registration validator checks its contract, type and pairs through the same code as the other document references, while `as_document_reference` leaves it out, as it does a lookup: joins and `preallocated` indexes never go through it. + ### On the writer or the creator (`ownerRefersTo`, `creatorRefersTo`) A property's reference constrains a value the writer chose. Some rules constrain the writer instead: in the moderation charters, a `resignationRequest` may only come from a moderator of the team it resigns from. A document type states that with the doctype-level `ownerRefersTo` keyword, one `refersTo` declaration whose value is the document's `$ownerId`, the writer, rather than a property's value: @@ -399,7 +453,7 @@ A property's reference constrains a value the writer chose. Some rules constrain reads: the writer must be the `memberId` of an `addedModerator` for this document's `electedCharterId`. -- The declaration is the one an identifier property carries, read by the same code (`apply_property_reference` 0), but only two targets can hold a writer: `identity`, and a `permanentDocument` found through a `lookup`, alone or as the leaves of a reference expression (above). The rest are refused, as a leaf of an expression too. `contract`, `token` and a document by id would need the writer's identity id to be a contract, token or document id, which it never is, so a document type declaring one could never be written; `identityPublicKey` pairs the value with a key id the writer does not carry. Meta-schema v3 reuses the property declaration by `$ref` and admits only those two forms; the parser (generation 3, `parse_owner_reference`, which reads the stored schema once the core parse has run the meta-schema) refuses the others on the stored path too. The parsed declaration is `DocumentTypeV2::owner_reference`, read through `DocumentTypeV2Getters::owner_reference`, and the property types are unchanged. +- The declaration is the one an identifier property carries, read by the same code (`apply_property_reference` 0), but only three targets can hold a writer: `identity`, a `permanentDocument` found through a `lookup`, and a `listElement` (the writer an element of the list, see [An element of a list](#an-element-of-a-list-listelement)), alone or as the leaves of a reference expression (above). The rest are refused, as a leaf of an expression too. `contract`, `token` and a document by id would need the writer's identity id to be a contract, token or document id, which it never is, so a document type declaring one could never be written; `identityPublicKey` pairs the value with a key id the writer does not carry. Meta-schema v3 reuses the property declaration by `$ref` and admits only those forms; the parser (generation 3, `parse_owner_reference`, which reads the stored schema once the core parse has run the meta-schema) refuses the others on the stored path too. The parsed declaration is `DocumentTypeV2::owner_reference`, read through `DocumentTypeV2Getters::owner_reference`, and the property types are unchanged. - Only a document type whose documents can be neither transferred nor traded may declare it, checked on every parse. A transfer or a purchase is not a write, so it would hand the document to an owner the declaration never checked; with neither possible, the owner of every document is the writer that was checked. - In a `lookup`, `"."` is the writer, and a `"$ownerId"` key part is the writer as well. Every referring-side rule of a property's lookup applies unchanged (its `"$ownerId"` rule holds by the point above), and so does every referenced-side rule, for a type of the same contract at contract level and for one of another contract at registration. - A `propertyAgreement` works as on a property reference; its referring side may name `$ownerId`, which is then the same writer as the reference's value. @@ -426,7 +480,7 @@ A document type whose documents can be transferred or traded declares `creatorRe } ``` -- It takes the same two targets, with `"."` the creator in a lookup, and is refused where `ownerRefersTo` is admitted: only a document type that records creator ids may declare it, a transferable or tradeable type of a format-1 contract (`should_use_creator_id`), checked on every parse. A type therefore declares at most one of the two. A `"$ownerId"` key part in its lookup is refused, as in a property's lookup on such a type, since the owner moves. +- It takes the same three targets, with `"."` the creator in a lookup, and is refused where `ownerRefersTo` is admitted: only a document type that records creator ids may declare it, a transferable or tradeable type of a format-1 contract (`should_use_creator_id`), checked on every parse. A type therefore declares at most one of the two. A `"$ownerId"` key part in its lookup is refused, as in a property's lookup on such a type, since the owner moves. - When a document is created its creator is the writer; on a replace, the value is the stored creator, whoever writes, and the replace rules are those of its target, as for the owner reference. A transfer or a purchase needs no check. A failure is the target's error at the path `$creatorId`, and registration names the declaration `.$creatorId`. An `identity` target reads nothing: the creator existed when it wrote the document, and an identity is never removed. It counts one against `max_references_per_document`, and a change to it is an incompatible schema change on update. ## Immutable Properties on Mutable Document Types 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 4304edb3e5b..c4023113ed8 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 @@ -5,7 +5,7 @@ "type": "object", "$defs": { "referenceOperands": { - "description": "The operands of a refersTo anyOf or allOf: two or more, no two alike, each a leaf or an expression of the other combinator. At most SystemLimits max_reference_operands of them in one list, and at most SystemLimits max_reference_expression_depth combinators on any path from the declaration to a leaf (4 and 4 from protocol version 14), checked at contract registration; every leaf counts against max_references_per_document, since each may be read for each value. A leaf is an identity or permanentDocument target (by id or through a lookup): both are existence checks against entities that are never deleted, so an expression of them holds for good once it holds and is re-validated on replace only when its value or a property bound to one of its leaves changes, as a single target is. The other types do not compose with other operands: deletableDocument is re-validated on every replace and may be cleared once its document is deleted, which assumes the property refers to that one target; identityPublicKey pairs the value with a key id property no other operand reads; a contract target's requirements are gates judged against the block time and the writer rather than an existence check, and a contract or token id is never also an identity or document id. A propertyAgreement belongs to its leaf and is checked only against that leaf's document. Allowed on identifier properties and on the items of a typed array of identifiers; a changed expression is an incompatible schema change on update", + "description": "The operands of a refersTo anyOf or allOf: two or more, no two alike, each a leaf or an expression of the other combinator. At most SystemLimits max_reference_operands of them in one list, and at most SystemLimits max_reference_expression_depth combinators on any path from the declaration to a leaf (4 and 4 from protocol version 14), checked at contract registration; every leaf counts against max_references_per_document, since each may be read for each value. A leaf is an identity, permanentDocument (by id or through a lookup) or listElement target: all are existence checks against entities that are never deleted (a list element reads a list that never changes on a document that is never deleted), so an expression of them holds for good once it holds and is re-validated on replace only when its value or a property bound to one of its leaves changes, as a single target is. The other types do not compose with other operands: deletableDocument is re-validated on every replace and may be cleared once its document is deleted, which assumes the property refers to that one target; identityPublicKey pairs the value with a key id property no other operand reads; a contract target's requirements are gates judged against the block time and the writer rather than an existence check, and a contract or token id is never also an identity or document id. A propertyAgreement belongs to its leaf and is checked only against that leaf's document. Allowed on identifier properties and on the items of a typed array of identifiers; a changed expression is an incompatible schema change on update", "type": "array", "minItems": 2, "uniqueItems": true, @@ -15,7 +15,8 @@ "type": { "enum": [ "identity", - "permanentDocument" + "permanentDocument", + "listElement" ] }, "identityProperty": false @@ -157,7 +158,8 @@ "token", "permanentDocument", "identityPublicKey", - "deletableDocument" + "deletableDocument", + "listElement" ] }, "anyOf": { @@ -200,7 +202,7 @@ ] }, "documentType": { - "description": "The name of the referenced document type; for permanentDocument it must forbid deletion (canBeDeleted: false), for deletableDocument it must allow it; a deletableDocument reference is re-validated on every replace of the referring document, so one whose target was deleted has to be repointed at an existing document or cleared", + "description": "The name of the referenced document type; for permanentDocument it must forbid deletion (canBeDeleted: false), for deletableDocument it must allow it; a deletableDocument reference is re-validated on every replace of the referring document, so one whose target was deleted has to be repointed at an existing document or cleared. For listElement, the document type holding the list, whose documents must forbid deletion; the document is the one the propertyAgreement's $id pair names", "type": "string", "minLength": 1, "maxLength": 64, @@ -294,7 +296,7 @@ "additionalProperties": false }, "propertyAgreement": { - "description": "permanentDocument and deletableDocument references only: each { referring property: referenced property } pair must hold as an equality between the referring document's value and the referenced document's value, enforced by consensus at document write time. The referring side is a schema property of the declaring document type or its own $ownerId, the writer, which turns the pair into a write gate: only an identity whose id equals the referenced side may create or replace the document. The referenced side is a schema property of the referenced document type, or one of its $ownerId and $creatorId system identifiers, in which case the referring property must be an identifier; $creatorId additionally needs a referenced document type that records creator ids (transferable or tradeable types of a format-1 contract). Both sides must exist and share one value kind, validated at contract registration. $ownerId follows the referenced document through transfers while $creatorId never changes; either is checked when the referring document is written, not when the referenced document later moves", + "description": "permanentDocument, deletableDocument and listElement references only: each { referring property: referenced property } pair must hold as an equality between the referring document's value and the referenced document's value, enforced by consensus at document write time. The referring side is a schema property of the declaring document type or its own $ownerId, the writer, which turns the pair into a write gate: only an identity whose id equals the referenced side may create or replace the document. The referenced side is a schema property of the referenced document type, or one of its $ownerId, $creatorId and $id system identifiers, in which case the referring property must be an identifier; $creatorId additionally needs a referenced document type that records creator ids (transferable or tradeable types of a format-1 contract). 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 listElement reference holds exactly one pair with $id on the referenced side, read from a schema identifier property (not $ownerId): it names the document whose list the value must be in", "type": "object", "minProperties": 1, "maxProperties": 10, @@ -319,12 +321,20 @@ { "enum": [ "$ownerId", - "$creatorId" + "$creatorId", + "$id" ] } ] } }, + "inList": { + "description": "listElement references only: the typed array of identifiers of documentType (a dotted path when nested) the value must be an element of. The document holding it is the one whose $id the propertyAgreement's $id pair reads from an identifier property of the referring document type (stored, not transient nor inside a transient object, checked at contract registration; it may be optional). At contract registration documentType must forbid deletion, and the list must be stored and fixed once a document is written: documentType is immutable (documentsMutable: false) or lists the list's top-level property under immutable, so a value accepted as an element stays one. When the referring document is created or replaced, consensus fetches that document by its id (once per write, shared with any other reference of the same document), checks the other agreement pairs against it, and requires the value (on a typed array, every element) to be an element of its list; a value that is not, or one set while the $id property is not, refuses the write with ReferencedEntityNotFoundError (40120). A replace checks it again when the value or a referring side of any pair changed, as every agreement is", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" + }, "lookup": { "description": "permanentDocument references only (a key into a deletable type could find a new document once the one it found is deleted, where an id is produced at most once): the property's value is not the referenced document's id. The referenced document is the one the named unique index of the referenced document type finds for a key assembled from the referring document, and the reference holds if that document exists. index names an index of the referenced document type that is unique, carries no timeRange and does not belong to an indexOnly type; its key must stay with the document it found (every schema property of the index fixed by an immutable document type or the immutable list, $ownerId only on a type whose documents can be neither transferred nor traded), so the reference never dangles; keys maps every property of that index (by its name on the referenced side, system ones such as $ownerId included), in any order, to where its value comes from: a property path of the referring document type, \"$ownerId\" for the referring document's owner, or \".\" for the value of the property carrying the reference (on an array element, the element), which must appear exactly once. A property a key reads must be required (and so must every object around it), not transient nor inside a transient object, and hold the same kind of value as its index property, so a lookup never runs with a missing key part; all of this is validated at contract registration. When the referring document is created or replaced, consensus queries the index for the assembled key, billed as a document fetch, and refuses the write with ReferencedEntityNotFoundError (40120) if it finds no document; propertyAgreement pairs are checked against the document found. A replace re-validates the reference when a property a key reads changed, and on every replace when a key reads $ownerId", "type": "object", @@ -395,7 +405,7 @@ { "if": { "properties": { - "type": { "enum": ["permanentDocument", "deletableDocument"] } + "type": { "enum": ["permanentDocument", "deletableDocument", "listElement"] } }, "required": ["type"] }, @@ -409,6 +419,20 @@ } } }, + { + "if": { + "properties": { "type": { "const": "listElement" } }, + "required": ["type"] + }, + "then": { + "required": ["type", "documentType", "propertyAgreement", "inList"] + }, + "else": { + "properties": { + "inList": false + } + } + }, { "if": { "properties": { "type": { "const": "identityPublicKey" } }, @@ -1746,13 +1770,14 @@ "description": "The subset of the immutable properties a replace may still set while the stored document has no value for them: a first-time set is accepted, after which the property is frozen like the rest of the immutable list (it can neither change nor be removed). Every entry must also appear in immutable; it is only meaningful for optional properties, since a required one always has a value from creation. On contract update an entry may be dropped (tightening) at any time, but may only be added for a property that becomes immutable in the same update: an already-immutable property cannot start allowing a set. Available from protocol version 14." }, "ownerRefersTo": { - "description": "A refersTo declaration whose value is the document's $ownerId, the writer, instead of a property's value: the declaration of an identifier property, with the same keys and the same checks, for the two targets a writer can be: identity, or a permanentDocument found through a lookup, where \".\" is the writer, or an anyOf or allOf expression whose every leaf is one of them and \"$ownerId\" names the writer as well. contract, token and a document by id are refused, since the writer's identity id is never one of those ids, and identityPublicKey pairs the value with a key id, which the writer does not carry. A propertyAgreement's referring side is still a property of the document or its $ownerId, the same writer. Only on a document type whose documents can be neither transferred nor traded, so the writer stays the owner; on a type whose documents can, creatorRefersTo checks the creator instead. When a document is created, and when a replace changes a property the lookup or a propertyAgreement reads (every replace for a $ownerId agreement pair), consensus checks the writer against the target exactly as a property's value is checked, and refuses the write with the error that target reports for a property (ReferencedEntityNotFoundError, 40120, and the rest), naming $ownerId as the path. It counts as one reference against the references a document may carry. Fixed when the document type is created: adding, removing or changing it is an incompatible schema change on update. Available from protocol version 14.", + "description": "A refersTo declaration whose value is the document's $ownerId, the writer, instead of a property's value: the declaration of an identifier property, with the same keys and the same checks, for the three targets a writer can be: identity, a permanentDocument found through a lookup, where \".\" is the writer, or a listElement, the writer an element of the list, or an anyOf or allOf expression whose every leaf is one of them and \"$ownerId\" names the writer as well. contract, token and a document by id are refused, since the writer's identity id is never one of those ids, and identityPublicKey pairs the value with a key id, which the writer does not carry. A propertyAgreement's referring side is still a property of the document or its $ownerId, the same writer. Only on a document type whose documents can be neither transferred nor traded, so the writer stays the owner; on a type whose documents can, creatorRefersTo checks the creator instead. When a document is created, and when a replace changes a property the lookup or a propertyAgreement reads (every replace for a $ownerId agreement pair), consensus checks the writer against the target exactly as a property's value is checked, and refuses the write with the error that target reports for a property (ReferencedEntityNotFoundError, 40120, and the rest), naming $ownerId as the path. It counts as one reference against the references a document may carry. Fixed when the document type is created: adding, removing or changing it is an incompatible schema change on update. Available from protocol version 14.", "$ref": "#/$defs/documentSchema/properties/refersTo", "properties": { "type": { "enum": [ "identity", - "permanentDocument" + "permanentDocument", + "listElement" ] } }, @@ -1773,13 +1798,14 @@ } }, "creatorRefersTo": { - "description": "A refersTo declaration whose value is the document's $creatorId, its creator, instead of a property's value: the counterpart of ownerRefersTo for a document type whose documents can be transferred or traded, since the creator never changes. The declaration of an identifier property, with the same keys and the same checks, for the two targets a creator can be: identity, or a permanentDocument found through a lookup, where \".\" is the creator, or an anyOf or allOf expression whose every leaf is one of them. contract, token and a document by id are refused, since the creator's identity id is never one of those ids, and identityPublicKey pairs the value with a key id, which the creator does not carry. A propertyAgreement's referring side is still a property of the document or its $ownerId, the writer. Only on a document type that records creator ids: a transferable or tradeable document type of a format-1 contract; a type declares at most one of ownerRefersTo and creatorRefersTo. When a document is created (by its creator) and when a replace changes a property the lookup or a propertyAgreement reads (every replace for a $ownerId agreement pair), consensus checks the creator against the target exactly as a property's value is checked, and refuses the write with the error that target reports for a property (ReferencedEntityNotFoundError, 40120, and the rest), naming $creatorId as the path. A transfer or a purchase needs no check: it does not change the creator. It counts as one reference against the references a document may carry. Fixed when the document type is created: adding, removing or changing it is an incompatible schema change on update. Available from protocol version 14.", + "description": "A refersTo declaration whose value is the document's $creatorId, its creator, instead of a property's value: the counterpart of ownerRefersTo for a document type whose documents can be transferred or traded, since the creator never changes. The declaration of an identifier property, with the same keys and the same checks, for the three targets a creator can be: identity, a permanentDocument found through a lookup, where \".\" is the creator, or a listElement, the creator an element of the list, or an anyOf or allOf expression whose every leaf is one of them. contract, token and a document by id are refused, since the creator's identity id is never one of those ids, and identityPublicKey pairs the value with a key id, which the creator does not carry. A propertyAgreement's referring side is still a property of the document or its $ownerId, the writer. Only on a document type that records creator ids: a transferable or tradeable document type of a format-1 contract; a type declares at most one of ownerRefersTo and creatorRefersTo. When a document is created (by its creator) and when a replace changes a property the lookup or a propertyAgreement reads (every replace for a $ownerId agreement pair), consensus checks the creator against the target exactly as a property's value is checked, and refuses the write with the error that target reports for a property (ReferencedEntityNotFoundError, 40120, and the rest), naming $creatorId as the path. A transfer or a purchase needs no check: it does not change the creator. It counts as one reference against the references a document may carry. Fixed when the document type is created: adding, removing or changing it is an incompatible schema change on update. Available from protocol version 14.", "$ref": "#/$defs/documentSchema/properties/refersTo", "properties": { "type": { "enum": [ "identity", - "permanentDocument" + "permanentDocument", + "listElement" ] } }, 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 08a419f6b91..582f1fe767f 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 @@ -224,6 +224,62 @@ impl DocumentType { } } + // Protocol version 14 and later: a `refersTo: listElement` whose list lives in a + // document type of this contract must find a list there that holds identifiers and + // never changes: its documents cannot be deleted, `inList` is a stored typed array of + // identifiers of it, and the list is fixed once a document is written (see + // `ListElementReference::referenced_side_error`). The `$id` pair naming the list's + // document was checked by the document type parse under full validation, and the + // other agreement pairs are checked at registration as every agreement is; a list in + // another contract is checked against that contract's state at registration, and one + // in a document type this contract does not have is left to the reference validation, + // which reports it. A leaf of a reference expression is judged as it would be alone. + // + // Inert for every protocol version before 14 for the same reason as the checks above: + // a parsed reference is a `listElement` only where the tables carry + // `apply_property_reference: Some(_)`, so the loop below finds none there. + for (name, document_type) in &contract_document_types { + let declaring = document_type.as_ref(); + // On the writer or the creator, on an identifier property or on the elements of + // a typed array + for (holder, declaration) in declaring.reference_declarations() { + let Some(declaration) = declaration.target() else { + continue; + }; + for (leaf_path, target) in declaration.leaves_with_paths() { + let Some(reference) = target.as_list_element_reference() else { + continue; + }; + if reference + .contract_id + .is_some_and(|contract_id| contract_id != data_contract_id) + { + continue; + } + let Some(referenced_document_type) = + contract_document_types.get(&reference.document_type_name) + else { + continue; + }; + if let Some(reason) = + reference.referenced_side_error(referenced_document_type.as_ref()) + { + let at = if leaf_path.is_empty() { + String::new() + } else { + format!(" {leaf_path}") + }; + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "document type \"{name}\" {}{at} listElement: {reason}", + holder.describe() + )), + )); + } + } + } + } + Ok(contract_document_types) } } 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 240b540830a..f73d7da4be3 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 @@ -14,10 +14,12 @@ use crate::data_contract::document_type::{ DocumentPropertyType, DocumentPropertyTypeParsingOptions, DocumentReferenceLookup, DocumentType, DocumentTypeRef, EncryptedFor, EncryptedForRecipient, EncryptionScheme, IdentityKeyReferenceRequirements, KeyIdReference, KeyReferenceIdentityProperty, - LookupKeySource, ReferenceCombinator, ReferenceOperands, COMBINABLE_REFERENCE_TARGET_TYPES, + ListElementReference, LookupKeySource, ReferenceCombinator, ReferenceOperands, + COMBINABLE_REFERENCE_TARGET_TYPES, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; +use crate::document::property_names::ID; use crate::identity::Purpose; use crate::util::json_schema::resolve_uri; use crate::validation::operations::ProtocolValidationOperation; @@ -802,15 +804,27 @@ fn validate_reference_target_keys( // `propertyAgreement` compares against a referenced DOCUMENT's values; // no other target kind has a document body to agree with if refers_to_map.contains_key(property_names::PROPERTY_AGREEMENT) - && !matches!(reference_type, "permanentDocument" | "deletableDocument") + && !matches!( + reference_type, + "permanentDocument" | "deletableDocument" | "listElement" + ) { return Err(DataContractError::InvalidContractStructure( - "propertyAgreement is only allowed on permanentDocument and deletableDocument \ - references" + "propertyAgreement is only allowed on permanentDocument, deletableDocument and \ + listElement references" .to_string(), )); } + // `inList` names the list a list element belongs to; no other target + // reads a list + if refers_to_map.contains_key(property_names::IN_LIST) && reference_type != "listElement" { + return Err(DataContractError::InvalidContractStructure(format!( + "{reference_type} refersTo does not take inList: it is only allowed on listElement \ + references" + ))); + } + // `lookup` finds a referenced DOCUMENT through an index of its type, and // only a permanent one: a key into a deletable type could find a new // document once the one it found is deleted, where an id is produced at @@ -838,10 +852,12 @@ fn parse_reference_target( contract_requirements: parse_contract_reference_requirements(refers_to_map)?, }, "token" => DocumentPropertyReferenceTarget::Token, - // The two document targets share one declaration shape; they differ - // only in whether the referenced document type must forbid deletion, - // which is checked against state at contract registration - document_target @ ("permanentDocument" | "deletableDocument") => { + // The three document targets share one declaration shape; they + // differ in whether the referenced document type must forbid deletion, + // checked against state at contract registration, and in how the + // document is found: by the value, through a lookup, or, for a list + // element, by a `$id` agreement pair + document_target @ ("permanentDocument" | "deletableDocument" | "listElement") => { // An absent contractId means the reference targets a document // type of the declaring contract itself let contract_id = refers_to_map @@ -863,92 +879,40 @@ fn parse_reference_target( ))); } - let property_agreement = match refers_to_map.get(property_names::PROPERTY_AGREEMENT) { - None => BTreeMap::new(), - Some(agreement_value) => { - let agreement_map = agreement_value.to_btree_ref_string_map()?; - if agreement_map.is_empty() || agreement_map.len() > 10 { - return Err(DataContractError::InvalidContractStructure(format!( - "{document_target} refersTo propertyAgreement must declare \ - between 1 and 10 property pairs" - ))); - } - agreement_map - .iter() - .map(|(referring_property, referenced_value)| { - let referenced_property = - referenced_value.as_text().ok_or_else(|| { - DataContractError::InvalidContractStructure( - "propertyAgreement values must be referenced \ - property paths (strings)" - .to_string(), - ) - })?; - for path in [referring_property.as_str(), referenced_property] { - if path.is_empty() || path.len() > MAX_PROPERTY_PATH_LENGTH { - return Err(DataContractError::InvalidContractStructure( - format!( - "propertyAgreement property paths must be between 1 \ - and {MAX_PROPERTY_PATH_LENGTH} characters" - ), - )); - } - } - // Either side may name a system property, but only one - // the agreement can read back as an identifier: the - // writer's `$ownerId` on the referring side (a write - // gate), `$ownerId` or `$creatorId` on the referenced - // side. - if referring_property.starts_with('$') - && !is_referring_system_agreement_property(referring_property) - { - return Err(DataContractError::InvalidContractStructure( - "propertyAgreement keys must name a schema property of \ - the declaring document type or its $ownerId" - .to_string(), - )); - } - if referenced_property.starts_with('$') - && !is_referenced_system_agreement_property(referenced_property) - { - return Err(DataContractError::InvalidContractStructure( - "propertyAgreement values must name a schema property of \ - the referenced document type or one of its $ownerId and \ - $creatorId system properties" - .to_string(), - )); - } - Ok((referring_property.clone(), referenced_property.to_string())) - }) - .collect::, DataContractError>>()? - } - }; + let property_agreement = parse_property_agreement(refers_to_map, document_target)?; let document_type_name = document_type_name.to_string(); - if document_target == "permanentDocument" { - // A lookup is its own variant, so an id reference keeps its - // shape (and its encoding in the reference errors) - match refers_to_map.get(property_names::LOOKUP) { - Some(lookup_value) => { - DocumentPropertyReferenceTarget::PermanentDocumentLookup { + match document_target { + "permanentDocument" => { + // A lookup is its own variant, so an id reference keeps its + // shape (and its encoding in the reference errors) + match refers_to_map.get(property_names::LOOKUP) { + Some(lookup_value) => { + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id, + document_type_name, + property_agreement, + lookup: parse_document_reference_lookup(lookup_value)?, + } + } + None => DocumentPropertyReferenceTarget::PermanentDocument { contract_id, document_type_name, property_agreement, - lookup: parse_document_reference_lookup(lookup_value)?, - } + }, } - None => DocumentPropertyReferenceTarget::PermanentDocument { - contract_id, - document_type_name, - property_agreement, - }, } - } else { - DocumentPropertyReferenceTarget::DeletableDocument { + "deletableDocument" => DocumentPropertyReferenceTarget::DeletableDocument { contract_id, document_type_name, property_agreement, - } + }, + _ => DocumentPropertyReferenceTarget::ListElement(parse_list_element_reference( + refers_to_map, + contract_id, + document_type_name, + property_agreement, + )?), } } "identityPublicKey" => { @@ -978,6 +942,169 @@ fn parse_reference_target( Ok(target) } +/// The `propertyAgreement` of a document reference of `document_target`: each +/// `{referring property: referenced property}` pair, either side a property +/// path or the system property its side admits (the writer's `$ownerId` on the +/// referring side; `$ownerId`, `$creatorId` or `$id` on the referenced side). +/// What the names resolve to is checked at registration. +fn parse_property_agreement( + refers_to_map: &BTreeMap, + document_target: &str, +) -> Result, DataContractError> { + let Some(agreement_value) = refers_to_map.get(property_names::PROPERTY_AGREEMENT) else { + return Ok(BTreeMap::new()); + }; + let agreement_map = agreement_value.to_btree_ref_string_map()?; + if agreement_map.is_empty() || agreement_map.len() > 10 { + return Err(DataContractError::InvalidContractStructure(format!( + "{document_target} refersTo propertyAgreement must declare between 1 and 10 \ + property pairs" + ))); + } + agreement_map + .iter() + .map(|(referring_property, referenced_value)| { + let referenced_property = referenced_value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "propertyAgreement values must be referenced property paths (strings)" + .to_string(), + ) + })?; + for path in [referring_property.as_str(), referenced_property] { + if path.is_empty() || path.len() > MAX_PROPERTY_PATH_LENGTH { + return Err(DataContractError::InvalidContractStructure(format!( + "propertyAgreement property paths must be between 1 and \ + {MAX_PROPERTY_PATH_LENGTH} characters" + ))); + } + } + // Either side may name a system property, but only one the + // agreement can read back as an identifier: the writer's + // `$ownerId` on the referring side (a write gate); `$ownerId`, + // `$creatorId` or `$id` on the referenced side. + if referring_property.starts_with('$') + && !is_referring_system_agreement_property(referring_property) + { + return Err(DataContractError::InvalidContractStructure( + "propertyAgreement keys must name a schema property of the declaring \ + document type or its $ownerId" + .to_string(), + )); + } + if referenced_property.starts_with('$') + && !is_referenced_system_agreement_property(referenced_property) + { + return Err(DataContractError::InvalidContractStructure( + "propertyAgreement values must name a schema property of the referenced \ + document type or one of its $ownerId, $creatorId and $id system \ + properties" + .to_string(), + )); + } + Ok((referring_property.clone(), referenced_property.to_string())) + }) + .collect() +} + +/// A `listElement` declaration beyond what it shares with the other document +/// references: `inList`, the typed array of identifiers of the referenced +/// type the value must be an element of, and, in `property_agreement`, exactly +/// one pair with `$id` on the referenced side, whose referring side names the +/// property holding the id of the document the list is read from (a schema +/// property: no document has the writer's id). What the names resolve to is +/// checked under full validation, once the document types are parsed: the +/// `$id` pair's property against the declaring type +/// ([`validate_list_element_sources`]), the list against the referenced one +/// (at contract level for a type of the same contract, at registration for +/// one of another contract), the other pairs as every agreement's. +fn parse_list_element_reference( + refers_to_map: &BTreeMap, + contract_id: Option, + document_type_name: String, + property_agreement: BTreeMap, +) -> Result { + let id_pairs = property_agreement + .iter() + .filter(|(_, referenced)| referenced.as_str() == ID) + .count(); + if id_pairs != 1 { + return Err(DataContractError::InvalidContractStructure(format!( + "listElement refersTo propertyAgreement must hold exactly one pair with $id on the \ + referenced side, naming the property whose value is the id of the document \ + holding the list, found {id_pairs}" + ))); + } + if property_agreement + .iter() + .any(|(referring, referenced)| referenced.as_str() == ID && referring.starts_with('$')) + { + return Err(DataContractError::InvalidContractStructure( + "listElement refersTo $id pair must read a property of the referring document \ + type: no document has the writer's id" + .to_string(), + )); + } + + let in_list = refers_to_map + .get_str(property_names::IN_LIST) + .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; + if in_list.is_empty() || in_list.len() > MAX_PROPERTY_PATH_LENGTH || in_list.starts_with('$') { + return Err(DataContractError::InvalidContractStructure(format!( + "listElement refersTo inList must be a property path of 1 to \ + {MAX_PROPERTY_PATH_LENGTH} characters" + ))); + } + + Ok(ListElementReference { + contract_id, + document_type_name, + property_agreement, + in_list: in_list.to_string(), + }) +} + +/// Checks the referring side of every `refersTo: listElement` of a document +/// type, alone or as a leaf of a reference expression, once all its +/// properties are parsed: the `$id` pair must read a stored identifier +/// property of the type. See [`ListElementReference::referring_side_error`]. +/// +/// Full validation only (registration), like the meta-schema, but in every +/// build: a contract read back from state passed it when it was written, and +/// the write-time check refuses a value whose `$id` property finds no +/// document rather than relying on it. Generation 3 is the only parser +/// admitting `refersTo` at all. +pub(super) fn validate_list_element_sources( + document_type: DocumentTypeRef, + document_type_name: &str, +) -> Result<(), DataContractError> { + // On the writer or the creator, on an identifier property or on the + // elements of a typed array + for (holder, reference) in document_type.reference_declarations() { + let Some(target) = reference.target() else { + continue; + }; + // Each leaf of a reference expression is checked as it would be + // alone, and the error names the leaf (`refersTo anyOf[1] listElement`) + for (leaf_path, leaf) in target.leaves_with_paths() { + let Some(list_reference) = leaf.as_list_element_reference() else { + continue; + }; + if let Some(reason) = list_reference.referring_side_error(document_type) { + let at = if leaf_path.is_empty() { + String::new() + } else { + format!(" {leaf_path}") + }; + return Err(DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" {}{at} listElement: {reason}", + holder.describe() + ))); + } + } + } + Ok(()) +} + /// An `identityPublicKey` declaration on the KEY ID property: `identityProperty` names /// whose key the value is (`$ownerId`, `$creatorId` or an identifier property of the /// same document type), so the declaration takes no `keyIdProperty` and sits on an @@ -1103,8 +1230,9 @@ fn apply_element_reference_v0( /// errors. The declaration goes through [`apply_property_reference`] as one /// declared on an identifier property does, `propertyAgreement` and `lookup` /// included (where `"."` is that identity), but only two targets can hold an -/// identity: `identity`, and a `permanentDocument` found through a `lookup`, -/// alone or as the leaves of an `anyOf` / `allOf` expression. +/// identity: `identity`, a `permanentDocument` found through a `lookup`, and a +/// `listElement` (the identity an element of the list), alone or as the leaves +/// of an `anyOf` / `allOf` expression. /// The others are refused: `contract`, `token` and a document by id, since an /// identity id is never a contract, token or document id, so a type declaring /// one could never be written, and `identityPublicKey`, which pairs the value @@ -1193,8 +1321,11 @@ pub(super) fn parse_doctype_reference( format!(" {leaf_path}") }; match leaf { + // An identity id can be an element of a list of identities: the + // charters' "the writer is a seated member" DocumentPropertyReferenceTarget::Identity - | DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } => {} + | DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } + | DocumentPropertyReferenceTarget::ListElement(_) => {} DocumentPropertyReferenceTarget::PermanentDocument { .. } => { return Err(DataContractError::InvalidContractStructure(format!( "{keyword}{at} takes a permanentDocument reference only with a lookup: its \ @@ -1203,8 +1334,8 @@ pub(super) fn parse_doctype_reference( } _ => { return Err(DataContractError::InvalidContractStructure(format!( - "{keyword}{at} takes an identity reference or a permanentDocument reference \ - with a lookup" + "{keyword}{at} takes an identity reference, a permanentDocument reference \ + with a lookup or a listElement reference" ))) } } @@ -2188,18 +2319,21 @@ mod tests { #[test] fn should_reject_other_system_properties_on_the_referenced_side_of_an_agreement() { - for referenced in ["$id", "$createdAt", "$revision", "$owner"] { + for referenced in ["$createdAt", "$revision", "$owner"] { let err = try_document_type_from_schema(system_agreement_schema(json!({ "authorId": referenced }))) - .expect_err("only $ownerId and $creatorId may be referenced"); + .expect_err("only $ownerId, $creatorId and $id may be referenced"); let message = err.to_string(); assert!( - message.contains("$ownerId and $creatorId system properties"), + message.contains("$ownerId, $creatorId and $id system properties"), "unexpected error for {referenced}: {message}" ); } + // `$id`, the referenced document's own id, is one of them + try_document_type_from_schema(system_agreement_schema(json!({ "authorId": "$id" }))) + .expect("$id may be referenced"); } #[test] 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 new file mode 100644 index 00000000000..bd39bae0ff0 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/list_element_reference_tests.rs @@ -0,0 +1,779 @@ +//! References to an element of a list of a referenced document (`refersTo: +//! listElement`, protocol version 14): the parse of the declaration, the checks +//! of its `$id` pair under full validation, the check of its list at contract +//! level for a document type of the same contract, the protocol version gate, +//! the reference bound and the platform serialization round trip. + +use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; +use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; +use crate::data_contract::document_type::{ + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceDeclaration, + ListElementReference, PropertyReference, +}; +use crate::data_contract::DataContract; +use crate::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, + PlatformSerializableWithPlatformVersion, +}; +use crate::ProtocolError; +use platform_value::string_encoding::Encoding; +use platform_value::Identifier; +use platform_version::version::PlatformVersion; +use serde_json::json; +use std::collections::BTreeMap; + +const CONTRACT_ID: [u8; 32] = [7; 32]; + +fn identifier(position: u32) -> serde_json::Value { + json!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": position + }) +} + +fn identifier_referring_to(position: u32, refers_to: serde_json::Value) -> serde_json::Value { + let mut property = identifier(position); + property["refersTo"] = refers_to; + property +} + +/// A typed array of at most `max_items` identifiers, whose elements declare +/// `refers_to` when given. +fn identifier_list( + position: u32, + max_items: u32, + refers_to: Option, +) -> serde_json::Value { + let mut items = json!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }); + if let Some(refers_to) = refers_to { + items["refersTo"] = refers_to; + } + json!({ "type": "array", "maxItems": max_items, "items": items, "position": position }) +} + +/// The declaration of the moderation charters' resignations: the value must be +/// one of the `members` of the elected charter whose id `electedCharterId` +/// holds. +fn members_of_the_charter() -> serde_json::Value { + list_element(json!({ "electedCharterId": "$id" }), "members") +} + +fn list_element(property_agreement: serde_json::Value, in_list: &str) -> serde_json::Value { + json!({ + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": property_agreement, + "inList": in_list + }) +} + +/// A contract with a permanent, immutable `electedCharter` type holding the +/// `members` list (and a `tags` list of strings and a `title`), a permanent +/// `joinRequest` type, and a `resignation` type whose `memberId` declares +/// `refers_to`. Its other properties are what a `$id` pair can read: +/// `electedCharterId` refers to the charter by id, `plainCharterId` is a +/// plain identifier, `identityId` refers to an identity, and `note` is a +/// string; `charterTitle` is a string another pair can agree on. +fn charter_contract(refers_to: serde_json::Value) -> serde_json::Value { + json!({ + "$formatVersion": "1", + "id": Identifier::from(CONTRACT_ID).to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": 1, + "documentSchemas": { + "electedCharter": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "submittedCharterId": identifier(0), + "members": identifier_list(1, 15, None), + "tags": { + "type": "array", + "maxItems": 4, + "items": { "type": "string", "maxLength": 16 }, + "position": 2 + }, + "title": { "type": "string", "maxLength": 63, "position": 3 } + }, + "required": ["submittedCharterId", "members"], + "additionalProperties": false + }, + "joinRequest": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "message": { "type": "string", "maxLength": 63, "position": 0 } + }, + "additionalProperties": false + }, + "resignation": { + "type": "object", + "properties": { + "electedCharterId": identifier_referring_to( + 0, + json!({ "type": "permanentDocument", "documentType": "electedCharter" }) + ), + "memberId": identifier_referring_to(1, refers_to), + "plainCharterId": identifier(2), + "identityId": identifier_referring_to(3, json!({ "type": "identity" })), + "note": { "type": "string", "maxLength": 63, "position": 4 }, + "charterTitle": { "type": "string", "maxLength": 63, "position": 5 } + }, + "required": ["electedCharterId"], + "additionalProperties": false + } + } + }) +} + +fn contract_on( + contract: serde_json::Value, + full_validation: bool, + platform_version: &PlatformVersion, +) -> Result { + let value = platform_value::to_value(contract).expect("the contract should convert"); + DataContract::from_value(value, full_validation, platform_version) +} + +fn contract(contract: serde_json::Value) -> Result { + contract_on(contract, true, PlatformVersion::latest()) +} + +fn resignation_property_type(contract: &DataContract, property: &str) -> DocumentPropertyType { + contract + .document_type_for_name("resignation") + .expect("the resignation document type") + .flattened_properties() + .get(property) + .expect("the property") + .property_type + .clone() +} + +fn expected_reference(pairs: &[(&str, &str)], in_list: &str) -> DocumentPropertyReferenceTarget { + DocumentPropertyReferenceTarget::ListElement(ListElementReference { + contract_id: None, + document_type_name: "electedCharter".to_string(), + property_agreement: pairs + .iter() + .map(|(referring, referenced)| (referring.to_string(), referenced.to_string())) + .collect(), + in_list: in_list.to_string(), + }) +} + +fn assert_refused(result: Result, fragment: &str) { + let error = result.expect_err("the contract should be refused"); + assert!( + error.to_string().contains(fragment), + "expected {fragment:?} in: {error}" + ); +} + +/// `schema` with the `electedCharter` type's `key` set to `value`. +fn with_elected_charter( + mut schema: serde_json::Value, + key: &str, + value: serde_json::Value, +) -> serde_json::Value { + schema["documentSchemas"]["electedCharter"][key] = value; + schema +} + +#[test] +fn should_parse_a_list_element_reference_on_an_identifier_property() { + let parsed = contract(charter_contract(members_of_the_charter())).expect("parses"); + assert_eq!( + resignation_property_type(&parsed, "memberId"), + DocumentPropertyType::IdentifierWithReference(expected_reference( + &[("electedCharterId", "$id")], + "members" + )) + ); + let reference = expected_reference(&[("electedCharterId", "$id")], "members"); + let DocumentPropertyReferenceTarget::ListElement(reference) = &reference else { + unreachable!() + }; + assert_eq!(reference.document_id_property(), Some("electedCharterId")); + // A document reference, whose value is not the document's id + let member_id = resignation_property_type(&parsed, "memberId"); + let declaration = reference_declaration(&member_id); + assert_eq!(declaration.document_type_name, "electedCharter"); + assert!(declaration.permanent); + assert_eq!(declaration.in_list, Some("members")); +} + +fn reference_declaration(property_type: &DocumentPropertyType) -> DocumentReferenceDeclaration<'_> { + let DocumentPropertyType::IdentifierWithReference(target) = property_type else { + panic!("expected a reference"); + }; + assert!( + target.as_document_reference().is_none(), + "a list element's value is not a document id" + ); + target + .as_any_document_reference() + .expect("a list element is a document reference") +} + +#[test] +fn should_parse_a_list_element_reference_through_a_plain_identifier_and_with_more_pairs() { + // The `$id` property needs no reference of its own: the list element + // fetches the document itself + let parsed = contract(charter_contract(list_element( + json!({ "plainCharterId": "$id" }), + "members", + ))) + .expect("parses"); + assert_eq!( + resignation_property_type(&parsed, "memberId"), + DocumentPropertyType::IdentifierWithReference(expected_reference( + &[("plainCharterId", "$id")], + "members" + )) + ); + + // Other pairs are ordinary agreements, checked against the same document + let parsed = contract(charter_contract(list_element( + json!({ "electedCharterId": "$id", "charterTitle": "title" }), + "members", + ))) + .expect("parses"); + assert_eq!( + resignation_property_type(&parsed, "memberId"), + DocumentPropertyType::IdentifierWithReference(expected_reference( + &[("electedCharterId", "$id"), ("charterTitle", "title")], + "members" + )) + ); +} + +#[test] +fn should_parse_a_list_element_reference_on_the_elements_of_a_typed_array() { + let mut schema = charter_contract(json!({ "type": "identity" })); + schema["documentSchemas"]["resignation"]["properties"]["witnesses"] = + identifier_list(6, 4, Some(members_of_the_charter())); + let parsed = contract(schema).expect("parses"); + + let witnesses = resignation_property_type(&parsed, "witnesses"); + assert_eq!( + witnesses.reference(), + Some(PropertyReference::Elements { + target: &expected_reference(&[("electedCharterId", "$id")], "members"), + max_items: 4, + }) + ); +} + +/// A list element is an existence check against a document that is never +/// deleted with a list that never changes, so it composes with the other +/// permanent targets in a reference expression, checked as a leaf as it would +/// be alone. +#[test] +fn should_parse_a_list_element_as_a_leaf_of_a_reference_expression() { + let parsed = contract(charter_contract(json!({ + "anyOf": [ + members_of_the_charter(), + { "type": "permanentDocument", "documentType": "electedCharter" } + ] + }))) + .expect("parses"); + let DocumentPropertyType::IdentifierWithReference(DocumentPropertyReferenceTarget::AnyOf( + operands, + )) = resignation_property_type(&parsed, "memberId") + else { + panic!("expected an anyOf"); + }; + assert_eq!( + operands.operands()[0], + expected_reference(&[("electedCharterId", "$id")], "members") + ); + + // The leaf's own checks still run, naming the leaf + assert_refused( + contract(charter_contract(json!({ + "anyOf": [ + list_element(json!({ "electedCharterId": "$id" }), "title"), + { "type": "permanentDocument", "documentType": "electedCharter" } + ] + }))), + "refersTo anyOf[0] listElement: \"title\" of \"electedCharter\" is not a typed array", + ); +} + +/// The moderation charters' owner rule: the writer must be one of the +/// charter's members. A list element is a target an identity can be (an +/// element of a list of identities), so `ownerRefersTo` takes it, alone or as a +/// leaf of an expression, and its `$id` pair is checked as a property's is. +#[test] +fn should_accept_a_list_element_on_the_writer() { + let mut schema = charter_contract(json!({ "type": "identity" })); + schema["documentSchemas"]["resignation"]["ownerRefersTo"] = members_of_the_charter(); + let parsed = contract(schema).expect("parses"); + assert_eq!( + parsed + .document_type_for_name("resignation") + .expect("the resignation document type") + .owner_reference(), + Some(&expected_reference( + &[("electedCharterId", "$id")], + "members" + )) + ); + + let mut expression = charter_contract(json!({ "type": "identity" })); + expression["documentSchemas"]["resignation"]["ownerRefersTo"] = json!({ + "anyOf": [members_of_the_charter(), { "type": "identity" }] + }); + contract(expression).expect("an expression with a list element leaf parses"); + + let mut bad_id_property = charter_contract(json!({ "type": "identity" })); + bad_id_property["documentSchemas"]["resignation"]["ownerRefersTo"] = + list_element(json!({ "note": "$id" }), "members"); + assert_refused( + contract(bad_id_property), + "ownerRefersTo listElement: the $id pair reads \"note\", which is not an identifier property", + ); +} + +#[test] +fn should_accept_an_optional_id_property() { + // Only a value set while the `$id` property is not is refused, when the + // document is written + let mut schema = charter_contract(members_of_the_charter()); + schema["documentSchemas"]["resignation"]["required"] = json!([]); + contract(schema).expect("an optional $id property is accepted"); +} + +#[test] +fn should_refuse_a_list_element_reference_on_a_non_identifier_property() { + let mut schema = charter_contract(json!({ "type": "identity" })); + schema["documentSchemas"]["resignation"]["properties"]["note"]["refersTo"] = + members_of_the_charter(); + contract(schema.clone()).expect_err("the meta-schema should refuse it"); + assert_refused( + contract_on(schema, false, PlatformVersion::latest()), + "refersTo is only allowed on identifier properties", + ); +} + +#[test] +fn should_refuse_an_id_pair_reading_a_missing_or_non_identifier_property_or_the_writer() { + for (id_property, fragment) in [ + ( + "nothing", + "the $id pair reads \"nothing\", which is not a property of the referring document type", + ), + ( + "note", + "the $id pair reads \"note\", which is not an identifier property", + ), + ] { + let schema = charter_contract(list_element(json!({ id_property: "$id" }), "members")); + assert_refused(contract(schema.clone()), fragment); + // Without full validation (a contract read back from state) the + // referring side is not re-checked + contract_on(schema, false, PlatformVersion::latest()) + .expect("a contract read back from state is not re-checked"); + } + + // The writer's id is no document's id: refused on every parse + let schema = charter_contract(list_element(json!({ "$ownerId": "$id" }), "members")); + for full_validation in [true, false] { + assert_refused( + contract_on(schema.clone(), full_validation, PlatformVersion::latest()), + "listElement refersTo $id pair must read a property of the referring document type", + ); + } +} + +/// The `$id` property needs no reference, but one it carries must name the +/// list's document type by id in the list's contract, or its value could +/// never be the id of the document holding the list. +#[test] +fn should_refuse_an_id_property_whose_reference_names_something_else() { + let refused = |refers_to: serde_json::Value| { + let mut schema = charter_contract(list_element(json!({ "otherId": "$id" }), "members")); + schema["documentSchemas"]["resignation"]["properties"]["otherId"] = + identifier_referring_to(6, refers_to); + assert_refused( + contract(schema), + "the $id pair reads \"otherId\", whose refersTo is not a reference by id to \ + \"electedCharter\" in the list's contract", + ); + }; + // An identity, another document type, another contract, a lookup key + // part and an expression hold values no charter has as its id + refused(json!({ "type": "identity" })); + refused(json!({ "type": "permanentDocument", "documentType": "joinRequest" })); + refused(json!({ + "type": "permanentDocument", + "contractId": Identifier::from([9; 32]).to_string(Encoding::Base58), + "documentType": "electedCharter" + })); + refused(json!({ + "type": "permanentDocument", + "documentType": "electedCharter", + "lookup": { "index": "bySubmittedCharter", "keys": { "submittedCharterId": "." } } + })); + refused(json!({ "anyOf": [ + { "type": "permanentDocument", "documentType": "electedCharter" }, + { "type": "identity" } + ] })); + + // Naming the declaring contract explicitly is the same contract + let mut own_contract = charter_contract(list_element(json!({ "otherId": "$id" }), "members")); + own_contract["documentSchemas"]["resignation"]["properties"]["otherId"] = + identifier_referring_to( + 6, + json!({ + "type": "permanentDocument", + "contractId": Identifier::from(CONTRACT_ID).to_string(Encoding::Base58), + "documentType": "electedCharter" + }), + ); + contract(own_contract).expect("the declaring contract named explicitly is the same contract"); +} + +#[test] +fn should_refuse_an_agreement_without_exactly_one_id_pair() { + for (agreement, found) in [ + (json!({ "charterTitle": "title" }), 0), + ( + json!({ "electedCharterId": "$id", "plainCharterId": "$id" }), + 2, + ), + ] { + let schema = charter_contract(list_element(agreement, "members")); + for full_validation in [true, false] { + assert_refused( + contract_on(schema.clone(), full_validation, PlatformVersion::latest()), + &format!("exactly one pair with $id on the referenced side, naming the property whose value is the id of the document holding the list, found {found}"), + ); + } + } +} + +#[test] +fn should_refuse_a_transient_id_property_or_one_inside_a_transient_object() { + let mut schema = charter_contract(members_of_the_charter()); + schema["documentSchemas"]["resignation"]["transient"] = json!(["electedCharterId"]); + assert_refused( + contract(schema), + "the $id pair reads \"electedCharterId\", which is transient", + ); + + // An object listed as transient is never stored, and neither is anything + // in it, on either side of the declaration + let seats = json!({ + "type": "object", + "properties": { "members": identifier_list(0, 15, None) }, + "additionalProperties": false, + "position": 4 + }); + let meta = json!({ + "type": "object", + "properties": { + "charterId": identifier_referring_to( + 0, + json!({ "type": "permanentDocument", "documentType": "electedCharter" }) + ) + }, + "additionalProperties": false, + "position": 6 + }); + let with_objects = || { + let mut schema = charter_contract(list_element( + json!({ "meta.charterId": "$id" }), + "seats.members", + )); + schema["documentSchemas"]["electedCharter"]["properties"]["seats"] = seats.clone(); + schema["documentSchemas"]["resignation"]["properties"]["meta"] = meta.clone(); + schema + }; + contract(with_objects()).expect("nested stored paths are accepted"); + + let mut transient_id_property = with_objects(); + transient_id_property["documentSchemas"]["resignation"]["transient"] = json!(["meta"]); + assert_refused( + contract(transient_id_property), + "the $id pair reads \"meta.charterId\", which is transient", + ); + + let mut transient_list = with_objects(); + transient_list["documentSchemas"]["electedCharter"]["transient"] = json!(["seats"]); + assert_refused( + contract(transient_list), + "\"seats.members\" of \"electedCharter\" is transient", + ); +} + +#[test] +fn should_refuse_a_list_that_is_missing_or_not_a_typed_array_of_identifiers() { + for (list, fragment) in [ + ("absent", "\"electedCharter\" has no property \"absent\""), + ( + "title", + "\"title\" of \"electedCharter\" is not a typed array of identifiers", + ), + ( + "tags", + "\"tags\" of \"electedCharter\" is not a typed array of identifiers", + ), + ( + "submittedCharterId", + "\"submittedCharterId\" of \"electedCharter\" is not a typed array of identifiers", + ), + ] { + let schema = charter_contract(list_element(json!({ "electedCharterId": "$id" }), list)); + assert_refused(contract(schema.clone()), fragment); + contract_on(schema, false, PlatformVersion::latest()) + .expect("a contract read back from state is not re-checked"); + } +} + +#[test] +fn should_refuse_a_list_held_by_a_deletable_or_mutable_document_type() { + // A document holding the list that could be deleted + assert_refused( + contract(with_elected_charter( + charter_contract(members_of_the_charter()), + "canBeDeleted", + json!(true), + )), + "documents of \"electedCharter\" can be deleted", + ); + + // A list a replace could change + let mutable = with_elected_charter( + charter_contract(members_of_the_charter()), + "documentsMutable", + json!(true), + ); + assert_refused( + contract(mutable.clone()), + "property \"memberId\" refersTo listElement: \"members\" of \"electedCharter\" can be \ + changed by a replace", + ); + + // Freezing the list on the mutable type is enough + contract(with_elected_charter( + mutable.clone(), + "immutable", + json!(["members"]), + )) + .expect("an immutable list on a mutable type is accepted"); + + // Freezing another property is not + assert_refused( + contract(with_elected_charter(mutable, "immutable", json!(["title"]))), + "\"members\" of \"electedCharter\" can be changed by a replace", + ); +} + +#[test] +fn should_leave_a_list_in_another_contract_to_registration() { + // The list's document type is in another contract, so the parse cannot + // see it: registration checks it against that contract in state. The `$id` + // property is a plain identifier, as `electedCharterId` refers to this + // contract's charter + let mut schema = charter_contract(list_element(json!({ "plainCharterId": "$id" }), "anything")); + schema["documentSchemas"]["resignation"]["properties"]["memberId"]["refersTo"]["contractId"] = + json!(Identifier::from([9; 32]).to_string(Encoding::Base58)); + let parsed = contract(schema).expect("parses"); + let DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::ListElement(reference), + ) = resignation_property_type(&parsed, "memberId") + else { + panic!("expected a list element reference"); + }; + assert_eq!(reference.contract_id, Some(Identifier::from([9; 32]))); + assert_eq!(reference.in_list, "anything"); +} + +#[test] +fn should_refuse_a_list_element_below_protocol_version_14_and_accept_it_at_14() { + let schema = charter_contract(members_of_the_charter()); + let platform_version_13 = PlatformVersion::get(13).expect("platform version 13 should exist"); + + // Meta-schema v2 knows no refersTo, so a registering parse refuses it + contract_on(schema.clone(), true, platform_version_13) + .expect_err("protocol version 13 should refuse the declaration"); + // A parse predating refersTo ignores the declaration. It predates typed + // arrays too, so the lists are left out: no list element can exist there + let mut without_lists = schema.clone(); + let elected_charter = &mut without_lists["documentSchemas"]["electedCharter"]; + for list in ["members", "tags"] { + elected_charter["properties"] + .as_object_mut() + .expect("the properties") + .remove(list); + } + elected_charter["required"] = json!(["submittedCharterId"]); + let ignored = contract_on(without_lists, false, platform_version_13) + .expect("protocol version 13 should parse it as a plain identifier"); + assert_eq!( + resignation_property_type(&ignored, "memberId"), + DocumentPropertyType::Identifier + ); + + let accepted = contract_on(schema, true, PlatformVersion::latest()).expect("parses"); + assert_eq!( + resignation_property_type(&accepted, "memberId"), + DocumentPropertyType::IdentifierWithReference(expected_reference( + &[("electedCharterId", "$id")], + "members" + )) + ); +} + +#[test] +fn should_count_list_elements_against_the_reference_bound() { + let limit = PlatformVersion::latest() + .system_limits + .max_references_per_document; + // The type's other references: `electedCharterId`, `memberId` and + // `identityId` + let others = 3; + let witnesses_up_to = |max_items: u16| { + let mut schema = charter_contract(members_of_the_charter()); + schema["documentSchemas"]["resignation"]["properties"]["witnesses"] = + identifier_list(6, u32::from(max_items), Some(members_of_the_charter())); + schema + }; + + contract(witnesses_up_to(limit - others)).expect("exactly at the bound parses"); + assert_refused( + contract(witnesses_up_to(limit - others + 1)), + &format!("above the maximum of {limit}"), + ); +} + +#[test] +fn should_round_trip_a_contract_through_platform_serialization_with_a_list_element() { + let platform_version = PlatformVersion::latest(); + let mut schema = charter_contract(list_element( + json!({ "electedCharterId": "$id", "charterTitle": "title" }), + "members", + )); + schema["documentSchemas"]["resignation"]["properties"]["witnesses"] = identifier_list( + 6, + 4, + Some(list_element(json!({ "plainCharterId": "$id" }), "members")), + ); + + let original = contract(schema).expect("parses"); + let bytes = original + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the contract should serialize"); + let recovered = DataContract::versioned_deserialize_untrusted(&bytes, false, platform_version) + .expect("the contract should deserialize"); + + assert_eq!(original, recovered); + for property in ["memberId", "witnesses"] { + assert_eq!( + resignation_property_type(&original, property), + resignation_property_type(&recovered, property) + ); + } + assert_eq!( + resignation_property_type(&recovered, "memberId"), + DocumentPropertyType::IdentifierWithReference(expected_reference( + &[("electedCharterId", "$id"), ("charterTitle", "title")], + "members" + )) + ); +} + +#[test] +fn should_refuse_malformed_list_element_declarations_in_the_parser() { + for (refers_to, fragment) in [ + ( + json!({ "type": "listElement", "documentType": "electedCharter", "inList": "members" }), + "exactly one pair with $id on the referenced side", + ), + ( + json!({ "type": "listElement", "documentType": "electedCharter", "propertyAgreement": { "electedCharterId": "$id" } }), + "inList", + ), + ( + json!({ "type": "listElement", "documentType": "electedCharter", "propertyAgreement": { "electedCharterId": "$id" }, "inList": "$members" }), + "listElement refersTo inList must be a property path", + ), + ( + json!({ "type": "listElement", "documentType": "electedCharter", "propertyAgreement": { "electedCharterId": "$id" }, "inList": "members", "lookup": { "index": "x", "keys": { "a": "." } } }), + "listElement refersTo does not take lookup", + ), + ( + json!({ "type": "identity", "inList": "members" }), + "identity refersTo does not take inList", + ), + ( + json!({ "type": "permanentDocument", "documentType": "electedCharter", "inList": "members" }), + "permanentDocument refersTo does not take inList", + ), + ( + json!({ "type": "identity", "propertyAgreement": { "electedCharterId": "$id" } }), + "propertyAgreement is only allowed on permanentDocument, deletableDocument and listElement", + ), + ] { + let schema = charter_contract(refers_to.clone()); + assert_refused( + contract_on(schema.clone(), false, PlatformVersion::latest()), + fragment, + ); + contract(schema).expect_err("the meta-schema should refuse it too"); + } +} + +/// The declaration's own rules, apart from any contract. +#[test] +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(), + property_agreement: BTreeMap::from([("meta.charterId".to_string(), "$id".to_string())]), + in_list: "seats.members".to_string(), + }; + let listed = [1u8; 32]; + let unlisted = [2u8; 32]; + let properties = platform_value::platform_value!({ + "seats": { "members": [Identifier::from(listed), Identifier::from([3u8; 32])] } + }) + .into_btree_string_map() + .expect("a map"); + + let listed_values = reference.listed_values(&properties); + assert!(listed_values.contains(&listed)); + assert!(!listed_values.contains(&unlisted)); + assert_eq!(listed_values.len(), 2); + // An absent list holds nothing + assert!(reference.listed_values(&Default::default()).is_empty()); + assert!(reference + .listed_values( + &platform_value::platform_value!({ "seats": {} }) + .into_btree_string_map() + .expect("a map") + ) + .is_empty()); + assert_eq!(reference.document_id_property(), Some("meta.charterId")); + assert_eq!( + DocumentPropertyReferenceTarget::ListElement(reference).to_string(), + "list element (seats.members of the electedCharter document meta.charterId names)" + ); +} 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 3899b9665b0..5315d9ccda1 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 @@ -52,7 +52,8 @@ use crate::consensus::ConsensusError; use super::common; use super::{ - parse_doctype_reference, validate_encrypted_for_declarations, validate_reference_lookup_sources, + parse_doctype_reference, validate_encrypted_for_declarations, validate_list_element_sources, + validate_reference_lookup_sources, }; mod ranked_prefix_overlap; @@ -513,6 +514,14 @@ fn try_from_schema_generation_3( validate_reference_count(&v2, name, platform_version)?; validate_no_immutable_deletable_element_references(&v2, name)?; } + // The property a `listElement` reads the list's document through; the list + // itself is checked where the referenced type is in hand. In every build, + // like the lookup sources above: without it a same-contract list check + // would silently skip a declaration whose property finds no document + if full_validation { + validate_list_element_sources(DocumentTypeRef::V2(&v2), name) + .map_err(consensus_or_protocol_data_contract_error)?; + } Ok(v2) } @@ -664,6 +673,11 @@ fn with_own_contract_id_omitted( *referenced = None; } } + DocumentPropertyReferenceTarget::ListElement(reference) => { + if reference.contract_id == Some(contract_id) { + reference.contract_id = None; + } + } DocumentPropertyReferenceTarget::AnyOf(operands) | DocumentPropertyReferenceTarget::AllOf(operands) => { *operands = ReferenceOperands::new( @@ -810,6 +824,8 @@ mod index_only_tests; #[cfg(test)] mod keep_history_tests; +#[cfg(all(test, feature = "validation"))] +mod list_element_reference_tests; #[cfg(test)] mod meta_schema_v0_stray_keyword_tests; #[cfg(test)] 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 e8321024ed5..3bc57111ccb 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 @@ -289,8 +289,8 @@ fn should_refuse_refers_to_on_the_typed_array_itself() { /// The rules the parse itself holds for a `propertyAgreement`, identical for /// an element declaration: only `$ownerId` among the referring document's -/// system properties, only `$ownerId` and `$creatorId` among the referenced -/// document's. Whether a named schema property exists on either side is +/// system properties, only `$ownerId`, `$creatorId` and `$id` among the +/// referenced document's. Whether a named schema property exists on either side is /// checked at registration against the referenced contract (drive-abci). #[test] fn should_refuse_an_element_property_agreement_naming_an_unusable_system_property() { @@ -300,7 +300,7 @@ fn should_refuse_an_element_property_agreement_naming_an_unusable_system_propert "propertyAgreement keys must name a schema property", ), ( - platform_value!({ "topic": "$id" }), + platform_value!({ "topic": "$createdAt" }), "propertyAgreement values must name a schema property", ), ] { diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs index 881536e3d3d..a9f57d4643a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs @@ -2416,6 +2416,139 @@ mod tests { } } + /// `reasons`, a typed array of identifiers, with `refersTo` on its items as given, + /// parsed as a contract read back from state is, so a `listElement` declaration + /// needs no `$id` property to exist beside it. + fn element_list_document_type( + refers_to: Option, + platform_version: &PlatformVersion, + ) -> DocumentType { + let mut items = platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }); + if let Some(refers_to) = refers_to { + items + .insert("refersTo".to_string(), refers_to) + .expect("should insert refersTo"); + } + let schema = platform_value!({ + "type": "object", + "properties": { + "reasons": { "type": "array", "maxItems": 8, "items": items, "position": 0 } + }, + "additionalProperties": false, + }); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + "resignation", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + /// A list element reference is frozen like every other declaration: stored + /// documents were checked against the list it names, on the document its `$id` + /// pair names, so adding, removing or changing it (on an identifier property or on + /// the elements of a typed array) is an incompatible schema change. + #[test] + fn should_return_invalid_result_when_a_list_element_reference_changes() { + let platform_version = PlatformVersion::latest(); + let list_element = |id_property: &str, in_list: &str| { + platform_value!({ + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { id_property: "$id" }, + "inList": in_list + }) + }; + let members = list_element("electedCharterId", "members"); + + for (old_refers_to, new_refers_to, changed_path) in [ + (None, Some(members.clone()), "/refersTo"), + (Some(members.clone()), None, "/refersTo"), + ( + Some(platform_value!({ + "type": "permanentDocument", + "documentType": "electedCharter" + })), + Some(members.clone()), + "/refersTo/type", + ), + ( + Some(members.clone()), + Some(list_element("electedCharterId", "seats")), + "/refersTo/inList", + ), + ( + Some(members.clone()), + Some(list_element("otherCharterId", "members")), + "/refersTo/propertyAgreement", + ), + ] { + for (old_document_type, new_document_type, property_path) in [ + ( + identifier_document_type(old_refers_to.clone(), platform_version), + identifier_document_type(new_refers_to.clone(), platform_version), + "/properties/toUserId", + ), + ( + element_list_document_type(old_refers_to.clone(), platform_version), + element_list_document_type(new_refers_to.clone(), platform_version), + "/properties/reasons/items", + ), + ] { + let result = old_document_type + .as_ref() + .validate_update(new_document_type.as_ref(), 2, platform_version) + .expect("validate_update should not error"); + + // Swapping the target kind also adds the keywords only a + // list element takes, each its own incompatible change; + // a renamed pair is a removal and an addition under + // propertyAgreement + let expected_path = format!("{property_path}{changed_path}"); + let changed_paths: Vec<&str> = result + .errors + .iter() + .map(|error| match error { + ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e), + ) => e.property_path(), + other => panic!("expected an incompatible schema change, got {other}"), + }) + .collect(); + assert!( + changed_paths + .iter() + .any(|changed| changed.starts_with(expected_path.as_str())), + "{old_refers_to:?} -> {new_refers_to:?}: {changed_paths:?}" + ); + } + } + + // An unchanged declaration is no change + let document_type = identifier_document_type(Some(members), platform_version); + let result = document_type + .as_ref() + .validate_update(document_type.as_ref(), 2, platform_version) + .expect("validate_update should not error"); + assert!(result.is_valid(), "{:?}", result.errors); + } + /// `toUserId` and `delegateId`, two identifier properties, with `distinctFrom` on /// `delegateId` as given. fn distinct_from_document_type( diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index ab7541b61e0..55dd9f21537 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -132,6 +132,10 @@ pub(crate) mod property_names { pub const LOOKUP_INDEX: &str = "index"; /// `lookup`: every index property mapped to its referring-side source. pub const LOOKUP_KEYS: &str = "keys"; + /// `refersTo: listElement`: the typed array of identifiers, on the + /// referenced document type, the value must be an element of. + /// Meta-schema v3+ (protocol version 14). + pub const IN_LIST: &str = "inList"; pub const CONTRACT_REQUIREMENTS: &str = "contractRequirements"; pub const MODERATION: &str = "moderation"; pub const MINIMUM_AGE_SECONDS: &str = "minimumAgeSeconds"; 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 new file mode 100644 index 00000000000..4d2f9c750c0 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/property/list_element_reference.rs @@ -0,0 +1,230 @@ +//! The `listElement` target of a `refersTo` declaration: the value must be an +//! element of a typed array of identifiers held by a document that agrees +//! with the referring document, found by the `propertyAgreement` pair whose +//! referenced side is `$id`. +//! +//! Declared on an identifier property, or on the `items` of a typed array of +//! identifiers, where every element must be one, alone or as a leaf of a +//! reference expression (meta-schema v3, protocol version 14): +//! +//! ```json +//! "refersTo": { +//! "type": "listElement", +//! "documentType": "electedCharter", +//! "propertyAgreement": { "electedCharterId": "$id" }, +//! "inList": "members" +//! } +//! ``` +//! +//! reads: the value must be one of the `members` of the `electedCharter` +//! document whose `$id` this document's `electedCharterId` holds. It is a +//! document reference like `permanentDocument`, with the same `contractId`, +//! `documentType` and `propertyAgreement` and the same checks on them, except +//! that the value is not the document's id: the document is the one the `$id` +//! pair names, any other pair is an ordinary agreement checked against it, and +//! the value must be in its list. The referenced document can never be deleted +//! and its list never changes (the type is immutable or lists the property +//! under `immutable`), so a value accepted once stays an element for good. The +//! rules live here so the two places that check a declaration against its +//! referenced document type (the contract parse for a type of the same +//! contract, the registration state validation for a type of another +//! contract) cannot drift. + +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, DocumentPropertyType}; +use crate::data_contract::document_type::DocumentTypeRef; +use crate::document::property_names::ID; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::btreemap_extensions::BTreeValueMapPathHelper; +use platform_value::{Identifier, Value}; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; + +/// A `refersTo: listElement` declaration: the value (or each element of a +/// typed array) must be an element of the typed array `in_list` of the +/// `document_type_name` document the `$id` pair of `property_agreement` +/// names. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] +pub struct ListElementReference { + /// The contract the document type holding the list lives in; `None` + /// means the declaring contract itself. + pub contract_id: Option, + /// The document type holding the list. + pub document_type_name: String, + /// The `{referring property: referenced property}` equalities, exactly + /// one of which has `$id` on the referenced side: its referring side, an + /// identifier property of the declaring type, holds the id of the + /// document the list is read from. The others are checked against that + /// document as any agreement is. + pub property_agreement: 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, +} + +impl ListElementReference { + /// The referring side of the `$id` pair: the identifier property of the + /// declaring type whose value is the id of the document the list is read + /// from. `None` only for a declaration the parser never produces. + pub fn document_id_property(&self) -> Option<&str> { + self.property_agreement + .iter() + .find(|(_, referenced)| referenced.as_str() == ID) + .map(|(referring, _)| referring.as_str()) + } + + /// Why the referring side of this declaration, on a property of + /// `declaring`, cannot name the document holding the list; `None` when + /// it can. The `$id` pair's referring side must be an identifier property + /// of the declaring type (a schema property, not the writer: no document + /// has the writer's id; not a typed array: one document holds the list), + /// and it must be stored (it and every object around it not transient), + /// so a reader can tell from the stored document which list the value was + /// checked against. It may be optional: a value set while it is not is + /// refused when the document is written. It needs no `refersTo` of its + /// own, but one it carries must be a reference by id to + /// `document_type_name` in the list's contract, or the pair could never + /// hold. The other pairs are checked as every agreement is, at + /// registration. + pub fn referring_side_error(&self, declaring: DocumentTypeRef) -> Option { + let Some(document_id_property) = self.document_id_property() else { + return Some( + "propertyAgreement must hold exactly one pair with $id on the referenced side, \ + naming the property whose value is the id of the document holding the list" + .to_string(), + ); + }; + if document_id_property.starts_with('$') { + return Some(format!( + "the $id pair reads \"{document_id_property}\": it must read an identifier \ + property of the referring document type, which holds the id of the document \ + holding the list" + )); + } + let Some(property) = declaring.flattened_properties().get(document_id_property) else { + return Some(format!( + "the $id pair reads \"{document_id_property}\", which is not a property of the \ + referring document type" + )); + }; + if !matches!( + property.property_type, + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) + ) { + return Some(format!( + "the $id pair reads \"{document_id_property}\", which is not an identifier \ + property" + )); + } + if is_transient(declaring, document_id_property) { + return Some(format!( + "the $id pair reads \"{document_id_property}\", which is transient: the stored \ + document must name the document whose list the value was checked against" + )); + } + // A reference of its own must agree that the value is the id of a + // document of the list's type in the list's contract: anything else + // (an identity, a lookup key part, a list element, another type or + // contract, an expression) holds a value no such document has, and + // every write setting the list element would be refused + if let DocumentPropertyType::IdentifierWithReference(target) = &property.property_type { + let declaring_contract_id = declaring.data_contract_id(); + let names_the_list_document = target.as_document_reference().is_some_and(|reference| { + reference.document_type_name == self.document_type_name + && reference.contract_id.unwrap_or(declaring_contract_id) + == self.contract_id.unwrap_or(declaring_contract_id) + }); + if !names_the_list_document { + return Some(format!( + "the $id pair reads \"{document_id_property}\", whose refersTo is not a \ + reference by id to \"{}\" in the list's contract: its value could never be \ + the id of the document holding the list", + self.document_type_name + )); + } + } + None + } + + /// Why `referenced`, the document type holding the list, cannot hold it; + /// `None` when it can. Its documents must never be deleted, `in_list` + /// must be a stored typed array of identifiers of it (it and every object + /// around it not transient), and the list must be fixed once a document + /// is written (see `schema_property_is_fixed_once_written`, the rule a + /// lookup's key parts are judged by), so a value accepted once stays an + /// element. + pub fn referenced_side_error(&self, referenced: DocumentTypeRef) -> Option { + let referenced_name = referenced.name(); + let list = &self.in_list; + if referenced.documents_can_be_deleted() + || referenced.documents_can_be_deleted_by_moderators() + { + return Some(format!( + "documents of \"{referenced_name}\" can be deleted: the list must be held by a \ + document that never is" + )); + } + let Some(property) = referenced.flattened_properties().get(list) else { + return Some(format!("\"{referenced_name}\" has no property \"{list}\"")); + }; + let holds_identifiers = match &property.property_type { + DocumentPropertyType::TypedArray(typed_array) => matches!( + typed_array.item_type.as_ref(), + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) + ), + _ => false, + }; + if !holds_identifiers { + return Some(format!( + "\"{list}\" of \"{referenced_name}\" is not a typed array of identifiers" + )); + } + if is_transient(referenced, list) { + return Some(format!( + "\"{list}\" of \"{referenced_name}\" is transient, so no stored document holds it" + )); + } + if !schema_property_is_fixed_once_written(referenced, list) { + let top_level = list.split('.').next().unwrap_or(list); + return Some(format!( + "\"{list}\" of \"{referenced_name}\" can be changed by a replace: the list must \ + be fixed once the document is written, so a value accepted as an element stays \ + one (make the type immutable or list \"{top_level}\" under `immutable`)" + )); + } + None + } + + /// The identifiers the list holds on the referenced document whose + /// properties are `referenced_properties`, collected once so each value + /// checked against them is a set lookup rather than a scan of the list. + /// An absent list holds nothing. + pub fn listed_values( + &self, + referenced_properties: &BTreeMap, + ) -> BTreeSet<[u8; 32]> { + match referenced_properties.get_optional_at_path(&self.in_list) { + Ok(Some(Value::Array(elements))) => elements + .iter() + .filter_map(|element| element.to_hash256().ok()) + .collect(), + _ => BTreeSet::new(), + } + } +} + +impl std::fmt::Display for ListElementReference { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "list element ({} of the ", self.in_list)?; + if let Some(contract_id) = self.contract_id { + write!(f, "contract {contract_id} ")?; + } + write!(f, "{} document", self.document_type_name)?; + if let Some(document_id_property) = self.document_id_property() { + write!(f, " {document_id_property} names")?; + } + write!(f, ")") + } +} 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 f0253041799..655ac364541 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 @@ -20,7 +20,7 @@ use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::{property_names, DocumentTypeRef}; use crate::data_contract::DataContract; -use crate::document::property_names::{CREATOR_ID, OWNER_ID}; +use crate::document::property_names::{CREATOR_ID, ID, OWNER_ID}; use crate::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use crate::identity::identity_public_key::contract_bounds::ContractBounds; use crate::identity::{IdentityPublicKey, Purpose}; @@ -41,10 +41,12 @@ use serde::{Deserialize, Serialize}; pub mod array; pub mod encrypted_for; +pub mod list_element_reference; pub mod reference_expression; pub mod reference_lookup; pub use encrypted_for::{EncryptedFor, EncryptedForRecipient, EncryptionScheme}; +pub use list_element_reference::ListElementReference; pub use reference_expression::{ ReferenceCombinator, ReferenceOperands, COMBINABLE_REFERENCE_TARGET_TYPES, MAX_REFERENCE_EXPRESSION_DECODE_DEPTH, @@ -877,11 +879,30 @@ pub enum DocumentPropertyReferenceTarget { /// read is billed. Otherwise as [`Self::AnyOf`]. #[serde(rename = "allOf")] AllOf(ReferenceOperands), + /// An element of a list: the value must be one of the identifiers the + /// typed array [`ListElementReference::in_list`] holds on the one + /// document of a permanent document type that agrees with the referring + /// document on every `propertyAgreement` pair, found by the pair whose + /// referenced side is `$id`. A document reference in every other respect + /// ([`Self::as_any_document_reference`] carries it with `in_list` set): + /// the value is neither the document's id nor a lookup key, so + /// [`Self::as_document_reference`] leaves it out. The list's document can + /// never be deleted and its list never changes (checked at registration), + /// so an accepted value stays an element for good. Appended, so every + /// earlier variant keeps its consensus encoding. + #[serde(rename = "listElement")] + ListElement(ListElementReference), } -/// The declaration content the two document reference targets, +/// The declaration content every document reference target shares: /// [`DocumentPropertyReferenceTarget::PermanentDocument`] and -/// [`DocumentPropertyReferenceTarget::DeletableDocument`], share. +/// [`DocumentPropertyReferenceTarget::DeletableDocument`], whose value is the +/// referenced document's id, [`DocumentPropertyReferenceTarget::PermanentDocumentLookup`] +/// (`lookup` set), whose value is one part of a key, and +/// [`DocumentPropertyReferenceTarget::ListElement`] (`in_list` set), whose +/// value is an element of the referenced document's list. Only +/// [`DocumentPropertyReferenceTarget::as_document_reference`] promises the +/// value is a document id. #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub struct DocumentReferenceDeclaration<'a> { /// The contract the referenced document type lives in; `None` means @@ -892,7 +913,8 @@ pub struct DocumentReferenceDeclaration<'a> { /// The `{referring property: referenced property}` equalities pub property_agreement: &'a BTreeMap, /// Whether the referenced document type must forbid deletion - /// (`permanentDocument`) or must allow it (`deletableDocument`) + /// (`permanentDocument`, by id or through a lookup, and `listElement`) + /// or must allow it (`deletableDocument`) pub permanent: bool, /// How the referenced document is found when the value is not its id /// ([`DocumentPropertyReferenceTarget::PermanentDocumentLookup`]); `None` @@ -900,23 +922,31 @@ pub struct DocumentReferenceDeclaration<'a> { /// [`DocumentPropertyReferenceTarget::as_any_document_reference`] ever /// returns a declaration carrying one. pub lookup: Option<&'a DocumentReferenceLookup>, + /// The typed array of identifiers the value must be an element of + /// ([`DocumentPropertyReferenceTarget::ListElement`]); `None` when the + /// value is the referenced document's id or a lookup key part. Only + /// [`DocumentPropertyReferenceTarget::as_any_document_reference`] ever + /// returns a declaration carrying one. + pub in_list: Option<&'a str>, } impl DocumentPropertyReferenceTarget { /// The declaration of a reference whose value is a DOCUMENT's id, of - /// either kind; `None` for every other target, a lookup reference - /// included, whose value is not a document id. This is the accessor for - /// code that treats the value as the referenced document's `$id` (by-id - /// joins); code that validates every kind of document reference uses - /// [`Self::as_any_document_reference`]. + /// either kind; `None` for every other target, a lookup reference or a + /// list element included, whose value is not a document id. This is the + /// accessor for code that treats the value as the referenced document's + /// `$id` (by-id joins); code that validates every kind of document + /// reference uses [`Self::as_any_document_reference`]. pub fn as_document_reference(&self) -> Option> { self.as_any_document_reference() - .filter(|declaration| declaration.lookup.is_none()) + .filter(|declaration| declaration.lookup.is_none() && declaration.in_list.is_none()) } /// The declaration of any reference to a DOCUMENT: of either kind, and - /// found by its id or through a `lookup` (then `lookup` is `Some`, and - /// the value is not the document's id). `None` for every other target. + /// found by its id, through a `lookup` (then `lookup` is `Some`, and the + /// value is not the document's id) or by a `$id` agreement pair with the + /// value an element of its list (then `in_list` is `Some`). `None` for + /// every other target. pub fn as_any_document_reference(&self) -> Option> { match self { DocumentPropertyReferenceTarget::PermanentDocument { @@ -929,6 +959,7 @@ impl DocumentPropertyReferenceTarget { property_agreement, permanent: true, lookup: None, + in_list: None, }), DocumentPropertyReferenceTarget::PermanentDocumentLookup { contract_id, @@ -941,6 +972,7 @@ impl DocumentPropertyReferenceTarget { property_agreement, permanent: true, lookup: Some(lookup), + in_list: None, }), DocumentPropertyReferenceTarget::DeletableDocument { contract_id, @@ -952,7 +984,18 @@ impl DocumentPropertyReferenceTarget { property_agreement, permanent: false, lookup: None, + in_list: None, }), + DocumentPropertyReferenceTarget::ListElement(reference) => { + Some(DocumentReferenceDeclaration { + contract_id: reference.contract_id, + document_type_name: &reference.document_type_name, + property_agreement: &reference.property_agreement, + permanent: true, + lookup: None, + in_list: Some(&reference.in_list), + }) + } DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::Contract { .. } | DocumentPropertyReferenceTarget::Token @@ -962,6 +1005,15 @@ impl DocumentPropertyReferenceTarget { } } + /// The declaration of a `listElement` reference; `None` for every other + /// target. + pub fn as_list_element_reference(&self) -> Option<&ListElementReference> { + match self { + DocumentPropertyReferenceTarget::ListElement(reference) => Some(reference), + _ => None, + } + } + /// The combinator and operands of a reference expression, `None` for a /// single target (a leaf). pub fn combinator(&self) -> Option<(ReferenceCombinator, &ReferenceOperands)> { @@ -1159,12 +1211,14 @@ impl<'a> DocumentTypeRef<'a> { /// The system properties of a referenced document that the referenced side /// of a `propertyAgreement` pair may name, next to the referenced document /// type's schema properties: `$ownerId`, the current owner (which follows -/// the document through transfers), and `$creatorId`, the original creator +/// the document through transfers), `$creatorId`, the original creator /// (set once, and only recorded by transferable or tradeable document types -/// of a format-1 contract). Both are identifiers, so the referring side must -/// be an identifier property. The referring side is a schema property or -/// the writer's own `$ownerId`, see [`REFERRING_SYSTEM_AGREEMENT_PROPERTIES`]. -pub const REFERENCED_SYSTEM_AGREEMENT_PROPERTIES: [&str; 2] = [OWNER_ID, CREATOR_ID]; +/// of a format-1 contract), and `$id`, the document's own id, which never +/// changes (a `listElement` reference finds its document by such a pair). +/// All are identifiers, so the referring side must be an identifier +/// property. The referring side is a schema property or the writer's own +/// `$ownerId`, see [`REFERRING_SYSTEM_AGREEMENT_PROPERTIES`]. +pub const REFERENCED_SYSTEM_AGREEMENT_PROPERTIES: [&str; 3] = [OWNER_ID, CREATOR_ID, ID]; /// Whether `name` is one of [`REFERENCED_SYSTEM_AGREEMENT_PROPERTIES`]. pub fn is_referenced_system_agreement_property(name: &str) -> bool { @@ -1274,6 +1328,7 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { document_type_name, .. } => write_document_reference(f, "deletable", *contract_id, document_type_name, None), + DocumentPropertyReferenceTarget::ListElement(reference) => reference.fmt(f), DocumentPropertyReferenceTarget::AnyOf(operands) | DocumentPropertyReferenceTarget::AllOf(operands) => { let (name, joiner) = match self { @@ -10202,6 +10257,12 @@ mod tests { property_agreement: Default::default(), }, ])), + DocumentPropertyReferenceTarget::ListElement(ListElementReference { + contract_id: None, + document_type_name: "electedCharter".to_string(), + property_agreement: [("electedCharterId".to_string(), "$id".to_string())].into(), + in_list: "members".to_string(), + }), ]; for target in &targets { @@ -10217,6 +10278,7 @@ mod tests { "permanentDocument" } // Not a `type`: the schema declares them under their own keys + DocumentPropertyReferenceTarget::ListElement(_) => "listElement", DocumentPropertyReferenceTarget::AnyOf(_) => "anyOf", DocumentPropertyReferenceTarget::AllOf(_) => "allOf", }; 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 eb93bd0b3df..f39bd40132e 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 @@ -36,12 +36,16 @@ use bincode::{BorrowDecode, BorrowDecodeUntrusted, Decode, DecodeUntrusted, Enco use serde::Serialize; use std::cell::Cell; -/// The `type` values a leaf of a reference expression may declare. +/// The `type` values a leaf of a reference expression may declare: existence +/// checks against entities that are never deleted (`listElement` reads a +/// list that never changes on a document that is never deleted, so it holds +/// for good once it holds, as the other two do). /// /// Read by `apply_property_reference` 0 (protocol version 14). Admitting /// another type once that version is released takes a new generation of the /// parser (and a new meta-schema), not an edit here. -pub const COMBINABLE_REFERENCE_TARGET_TYPES: [&str; 2] = ["identity", "permanentDocument"]; +pub const COMBINABLE_REFERENCE_TARGET_TYPES: [&str; 3] = + ["identity", "permanentDocument", "listElement"]; /// The deepest nesting of `anyOf` and `allOf` a decoder accepts. It only keeps /// crafted bytes from driving the decoder into unbounded recursion: the diff --git a/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs b/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs index 8a495152454..ee8208b8d8e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs @@ -358,11 +358,8 @@ impl DocumentReferenceLookup { TRANSFERRED_AT | TRANSFERRED_AT_BLOCK_HEIGHT | TRANSFERRED_AT_CORE_BLOCK_HEIGHT => { changes_owner.then_some("a transfer or a purchase changes") } - property => { - let top_level = property.split('.').next().unwrap_or(property); - (replaceable && !referenced.immutable_fields().contains(top_level)) - .then_some("a replace can change") - } + property => (!schema_property_is_fixed_once_written(referenced, property)) + .then_some("a replace can change"), }; why.map(|why| (index_property.as_str(), why)) }) @@ -408,6 +405,22 @@ pub(crate) fn owner_can_change(document_type: DocumentTypeRef) -> bool { || document_type.trade_mode() != TradeMode::None } +/// Whether the schema property at `path` of a document of `document_type` can +/// never change once the document is written: the type is immutable +/// (`documentsMutable: false`), or the property's top-level property is listed +/// under `immutable`. An `immutableAllowSetting` entry can only be set on a +/// document that has no value for it yet, so a value read once stays. Both +/// flags are immutable on contract update and the `immutable` list may only +/// grow, so the answer holds for good. The one rule both a lookup's key parts +/// and a list element's list are judged by. +pub(crate) fn schema_property_is_fixed_once_written( + document_type: DocumentTypeRef, + path: &str, +) -> bool { + let top_level = path.split('.').next().unwrap_or(path); + !document_type.documents_mutable() || document_type.immutable_fields().contains(top_level) +} + /// The kind of value an index property of `document_type` holds: a system /// property's fixed type, or the schema property's. `None` when the name is /// neither. diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 3e04ecfe6b9..df9db3003a4 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -363,6 +363,7 @@ impl ErrorWithCode for StateError { Self::ReferencedContractRequirementNotMetError(_) => 40135, Self::ReferencedIdentityKeyRequirementNotMetError(_) => 40136, Self::ReferencedDocumentLookupInvalidError(_) => 40137, + Self::ReferencedDocumentListInvalidError(_) => 40138, // 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 1c873c7cb42..f6e6a6cd72a 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs @@ -20,6 +20,7 @@ pub mod document_timestamps_mismatch_error; pub mod duplicate_unique_index_error; pub mod invalid_document_revision_error; pub mod referenced_contract_requirement_not_met_error; +pub mod referenced_document_list_invalid_error; pub mod referenced_document_lookup_invalid_error; pub mod referenced_document_property_agreement_invalid_error; pub mod referenced_document_property_mismatch_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_list_invalid_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_list_invalid_error.rs new file mode 100644 index 00000000000..1cad5a54dfd --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_list_invalid_error.rs @@ -0,0 +1,70 @@ +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 `refersTo: listElement` whose list lives in a document type of another +/// contract cannot be served by it: the document type's documents can be +/// deleted, the list is not a stored typed array of identifiers of it, or a +/// replace could change it. Reported at contract registration and update; a +/// list in the declaring contract's own document type is refused by the +/// contract parse instead. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("invalid refersTo listElement into inList {in_list} declared at {path}: {reason}")] +#[platform_serialize(unversioned)] +pub struct ReferencedDocumentListInvalidError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + path: String, + in_list: String, + reason: String, +} + +impl ReferencedDocumentListInvalidError { + pub fn new(path: String, in_list: String, reason: String) -> Self { + Self { + path, + in_list, + reason, + } + } + + /// The declaring property, as `documentTypeName.propertyPath`. + pub fn path(&self) -> &str { + &self.path + } + + /// The list the declaration names, its `inList`. + pub fn in_list(&self) -> &str { + &self.in_list + } + + pub fn reason(&self) -> &str { + &self.reason + } +} + +impl From for ConsensusError { + fn from(err: ReferencedDocumentListInvalidError) -> Self { + Self::StateError(StateError::ReferencedDocumentListInvalidError(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 5564e3df48f..36900800fe6 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -70,6 +70,7 @@ use crate::consensus::state::document::referenced_document_type_not_deletable_er use crate::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; use crate::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; use crate::consensus::state::document::referenced_document_lookup_invalid_error::ReferencedDocumentLookupInvalidError; +use crate::consensus::state::document::referenced_document_list_invalid_error::ReferencedDocumentListInvalidError; use crate::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; use crate::consensus::state::document::referenced_identity_key_disabled_error::ReferencedIdentityKeyDisabledError; use crate::consensus::state::document::referenced_identity_key_not_found_error::ReferencedIdentityKeyNotFoundError; @@ -597,6 +598,10 @@ pub enum StateError { // Document references resolved through a unique index (protocol version 14). #[error(transparent)] ReferencedDocumentLookupInvalidError(ReferencedDocumentLookupInvalidError), + + // References to an element of a list of a referenced document (protocol version 14). + #[error(transparent)] + ReferencedDocumentListInvalidError(ReferencedDocumentListInvalidError), } impl From for ConsensusError { @@ -618,7 +623,8 @@ mod tests { ActionFeePricing, ContractFeePot, DocumentActionFee, }; use crate::data_contract::document_type::{ - DocumentPropertyReferenceTarget, DocumentReferenceLookup, LookupKeySource, + DocumentPropertyReferenceTarget, DocumentReferenceLookup, ListElementReference, + LookupKeySource, }; use crate::tokens::gas_fees_paid_by::GasFeesPaidBy; use crate::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; @@ -694,6 +700,20 @@ mod tests { }), 6 ); + // A list element reference is appended after the expressions (anyOf + // 7, allOf 8, pinned in `reference_expression.rs`) + assert_eq!( + target_variant(&DocumentPropertyReferenceTarget::ListElement( + ListElementReference { + contract_id: None, + document_type_name: "electedCharter".to_string(), + property_agreement: [("electedCharterId".to_string(), "$id".to_string())] + .into(), + in_list: "members".to_string(), + } + )), + 9 + ); } #[test] @@ -1182,8 +1202,7 @@ mod tests { )), 144 ); - // Document references resolved through a unique index (protocol version 14): the - // tail of the enum. + // Document references resolved through a unique index (protocol version 14). assert_eq!( discriminant_of(StateError::ReferencedDocumentLookupInvalidError( ReferencedDocumentLookupInvalidError::new( @@ -1194,5 +1213,17 @@ mod tests { )), 145 ); + // References to an element of a list of a referenced document (protocol version + // 14): the tail of the enum. + assert_eq!( + discriminant_of(StateError::ReferencedDocumentListInvalidError( + ReferencedDocumentListInvalidError::new( + "resignation.memberId".to_string(), + "members".to_string(), + "is not a typed array of identifiers".to_string(), + ) + )), + 146 + ); } } diff --git a/packages/rs-dpp/src/validation/meta_validators/mod.rs b/packages/rs-dpp/src/validation/meta_validators/mod.rs index 781155bc06d..b074c6b4c99 100644 --- a/packages/rs-dpp/src/validation/meta_validators/mod.rs +++ b/packages/rs-dpp/src/validation/meta_validators/mod.rs @@ -499,6 +499,75 @@ mod tests { } } + #[test] + fn should_accept_a_list_element_refers_to_in_v3_document_schema() { + for (property_agreement, in_list) in [ + (json!({ "electedCharterId": "$id" }), "members"), + ( + json!({ "meta.charterId": "$id", "charterTitle": "title" }), + "seats.members", + ), + ] { + let schema = document_schema_with_refers_to(json!({ + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": property_agreement, + "inList": in_list + })); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_ok(), + "expected a listElement agreeing on {property_agreement} into {in_list} to be valid" + ); + } + + // With a contract id, and as a leaf of a reference expression + for refers_to in [ + json!({ + "type": "listElement", + "contractId": "4uAB6wAdt6FJ7djwjYrLnooVhYeQpzgkssBmgmvZ9WnM", + "documentType": "electedCharter", + "propertyAgreement": { "electedCharterId": "$id" }, + "inList": "members" + }), + json!({ "anyOf": [ + { "type": "listElement", "documentType": "electedCharter", "propertyAgreement": { "electedCharterId": "$id" }, "inList": "members" }, + { "type": "permanentDocument", "documentType": "electedCharter" } + ] }), + ] { + let schema = document_schema_with_refers_to(refers_to.clone()); + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_ok(), + "expected refersTo {refers_to} to be valid" + ); + } + } + + #[test] + fn should_reject_malformed_or_misplaced_list_element_keywords_in_v3_document_schema() { + for refers_to in [ + // Every listElement keyword is required + json!({ "type": "listElement", "propertyAgreement": { "electedCharterId": "$id" }, "inList": "members" }), + json!({ "type": "listElement", "documentType": "electedCharter", "inList": "members" }), + json!({ "type": "listElement", "documentType": "electedCharter", "propertyAgreement": { "electedCharterId": "$id" } }), + json!({ "type": "listElement", "documentType": "electedCharter", "propertyAgreement": { "electedCharterId": "$id" }, "inList": "members", "lookup": { "index": "byOwner", "keys": { "$ownerId": "." } } }), + json!({ "type": "listElement", "documentType": "electedCharter", "propertyAgreement": { "electedCharterId": "$id" }, "inList": "bad-name" }), + json!({ "type": "listElement", "documentType": "electedCharter", "propertyAgreement": { "electedCharterId": "$id" }, "inList": "" }), + json!({ "type": "listElement", "documentType": "electedCharter", "propertyAgreement": { "electedCharterId": "$documentId" }, "inList": "members" }), + // inList belongs to listElement alone + json!({ "type": "identity", "inList": "members" }), + json!({ "type": "permanentDocument", "documentType": "electedCharter", "inList": "members" }), + json!({ "type": "deletableDocument", "documentType": "electedCharter", "inList": "members" }), + ] { + let schema = document_schema_with_refers_to(refers_to.clone()); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_err(), + "expected refersTo {refers_to} to be invalid" + ); + } + } + #[test] fn should_accept_permanent_document_refers_to_in_v3_document_schema() { let schema = document_schema_with_refers_to(json!({ @@ -618,7 +687,7 @@ mod tests { #[test] fn should_accept_referenced_system_identifiers_in_property_agreement() { - for referenced in ["$ownerId", "$creatorId"] { + for referenced in ["$ownerId", "$creatorId", "$id"] { let schema = document_schema_with_agreement(json!({ "authorId": referenced })); assert!( @@ -630,7 +699,8 @@ mod tests { #[test] fn should_reject_other_system_properties_on_the_referenced_side_of_an_agreement() { - for referenced in ["$id", "$createdAt", "$revision", "$owner"] { + // `$id`, `$ownerId` and `$creatorId` are the referenced side's system names + for referenced in ["$createdAt", "$revision", "$owner", "$documentId"] { let schema = document_schema_with_agreement(json!({ "authorId": referenced })); assert!( 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 5315487b68e..1d11f820b2f 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 @@ -12,13 +12,14 @@ use dpp::data_contract::document_type::accessors::{ use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::{ is_referring_system_agreement_property, DocumentPropertyReferenceTarget, - DocumentPropertyType, DocumentReferenceLookup, DocumentTypeRef, - IdentityKeyReferenceRequirements, KeyReferenceIdentityProperty, PropertyReference, + DocumentPropertyType, DocumentReferenceDeclaration, DocumentReferenceLookup, DocumentTypeRef, + IdentityKeyReferenceRequirements, KeyReferenceIdentityProperty, ListElementReference, + PropertyReference, ReferenceCombinator, ReferenceHolder, ReferringWrite, }; use dpp::data_contract::DataContract; -use dpp::document::property_names::{CREATOR_ID, OWNER_ID}; -use dpp::document::DocumentV0Getters; +use dpp::document::property_names::{CREATOR_ID, 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_type_deletable_error::ReferencedDocumentTypeDeletableError; use dpp::errors::consensus::state::document::referenced_document_type_not_deletable_error::ReferencedDocumentTypeNotDeletableError; @@ -227,6 +228,9 @@ fn validate_document_type_references_v0( execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, ) -> Result { + // The documents this write's references fetch by id, shared among them + let mut fetched_documents = FetchedDocuments::default(); + // A reference is the writer's (`ownerRefersTo`, whose value is the // document's `$ownerId` and which the errors name by that path), the // creator's (`creatorRefersTo`, `$creatorId`), an identifier property's @@ -372,6 +376,7 @@ fn validate_document_type_references_v0( referenced_id, path, &mut referenced_contracts, + &mut fetched_documents, platform, block_info, transaction, @@ -450,6 +455,7 @@ fn validate_document_type_references_v0( referenced_id, &element_path, &mut referenced_contracts, + &mut fetched_documents, platform, block_info, transaction, @@ -466,6 +472,88 @@ fn validate_document_type_references_v0( Ok(SimpleConsensusValidationResult::new()) } +/// The documents one write's references fetched by id, by (contract, +/// document id) and then document type name, each with the lists collected +/// from it for its list elements, by list path: a document two references +/// name (a `permanentDocument` reference to a charter and a `listElement` +/// read through the same `$id` property, or the elements of one typed array) +/// is fetched and billed once, and a list is collected into a set once. A +/// hit allocates nothing. Lookup results are not kept: a key is not an id. +#[derive(Default)] +struct FetchedDocuments { + documents: BTreeMap<(Identifier, Identifier), BTreeMap>, +} + +/// One document fetched by id (`None` when it does not exist) and the lists +/// read from it. +struct FetchedDocument { + document: Option, + lists: BTreeMap>, +} + +impl FetchedDocuments { + /// The document `document_type_name` of `contract_id` holds under + /// `document_id`, fetched with `fetch` the first time this write asks. + fn document( + &mut self, + contract_id: Identifier, + document_type_name: &str, + document_id: Identifier, + fetch: impl FnOnce() -> Result, Error>, + ) -> Result, Error> { + let by_type = self + .documents + .entry((contract_id, document_id)) + .or_default(); + if !by_type.contains_key(document_type_name) { + let document = fetch()?; + by_type.insert( + document_type_name.to_string(), + FetchedDocument { + document, + lists: BTreeMap::new(), + }, + ); + } + Ok(by_type + .get(document_type_name) + .and_then(|fetched| fetched.document.as_ref())) + } + + /// Whether `value` is an element of `list_reference`'s list on the + /// document [`Self::document`] fetched for the same key, collected into a + /// set the first time; `false` when no such document was fetched or it + /// does not exist. + fn is_listed( + &mut self, + contract_id: Identifier, + document_type_name: &str, + document_id: Identifier, + list_reference: &ListElementReference, + value: &[u8; 32], + ) -> bool { + let Some(FetchedDocument { + document: Some(document), + lists, + }) = self + .documents + .get_mut(&(contract_id, document_id)) + .and_then(|by_type| by_type.get_mut(document_type_name)) + else { + return false; + }; + if !lists.contains_key(&list_reference.in_list) { + lists.insert( + list_reference.in_list.clone(), + list_reference.listed_values(document.properties()), + ); + } + lists + .get(&list_reference.in_list) + .is_some_and(|listed| listed.contains(value)) + } +} + /// Whether a replace that changed `changed_fields` must re-validate a /// reference declared by `reference_target` although the reference property /// itself is untouched, because the target binds a sibling property of the @@ -520,6 +608,16 @@ fn binds_a_changed_property( DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property, .. } => is_changed_field(changed_fields, key_id_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::Identity | DocumentPropertyReferenceTarget::Contract { .. } | DocumentPropertyReferenceTarget::Token => false, @@ -562,6 +660,7 @@ fn validate_reference_v0( referenced_id: [u8; 32], path: &str, referenced_contracts: &mut BTreeMap>>, + fetched_documents: &mut FetchedDocuments, platform: &PlatformStateRef, block_info: &BlockInfo, transaction: TransactionArg, @@ -578,6 +677,7 @@ fn validate_reference_v0( referenced_id, path, referenced_contracts, + fetched_documents, platform, block_info, transaction, @@ -603,6 +703,7 @@ fn validate_reference_v0( referenced_id, path, referenced_contracts, + fetched_documents, platform, block_info, transaction, @@ -644,6 +745,7 @@ fn validate_reference_target_v0( referenced_id: [u8; 32], path: &str, referenced_contracts: &mut BTreeMap>>, + fetched_documents: &mut FetchedDocuments, platform: &PlatformStateRef, block_info: &BlockInfo, transaction: TransactionArg, @@ -722,27 +824,27 @@ fn validate_reference_target_v0( referenced_token_info.is_some() } - DocumentPropertyReferenceTarget::PermanentDocument { - contract_id: referenced_contract_id, - document_type_name, - property_agreement, - } - | DocumentPropertyReferenceTarget::PermanentDocumentLookup { - contract_id: referenced_contract_id, - document_type_name, - property_agreement, - .. - } - | DocumentPropertyReferenceTarget::DeletableDocument { - contract_id: referenced_contract_id, - document_type_name, - property_agreement, - } => { - let permanent = matches!( - reference_target, - DocumentPropertyReferenceTarget::PermanentDocument { .. } - | DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } - ); + // The document references: found by the value (their id), through a + // lookup, or, for a list element, by the document its `$id` + // agreement pair names, whose list must then hold the value + DocumentPropertyReferenceTarget::PermanentDocument { .. } + | DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } + | DocumentPropertyReferenceTarget::DeletableDocument { .. } + | DocumentPropertyReferenceTarget::ListElement(_) => { + let Some(DocumentReferenceDeclaration { + contract_id: referenced_contract_id, + document_type_name, + property_agreement, + permanent, + lookup, + in_list: _, + }) = reference_target.as_any_document_reference() + else { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "every document reference target carries a document reference declaration", + ))); + }; + let list_reference = reference_target.as_list_element_reference(); // An absent contract id targets the declaring contract itself; the // declaring contract may also name its own id explicitly. Either // way it is already loaded for this transition, so no fetch is @@ -788,7 +890,7 @@ fn validate_reference_target_v0( return Ok(SimpleConsensusValidationResult::new_with_error( ReferencedDocumentTypeNotFoundError::new( effective_contract_id, - document_type_name.clone(), + document_type_name.to_string(), path.to_string(), ) .into(), @@ -805,7 +907,7 @@ fn validate_reference_target_v0( return Ok(SimpleConsensusValidationResult::new_with_error( ReferencedDocumentTypeNotFoundError::new( effective_contract_id, - document_type_name.clone(), + document_type_name.to_string(), path.to_string(), ) .into(), @@ -828,7 +930,7 @@ fn validate_reference_target_v0( return Ok(SimpleConsensusValidationResult::new_with_error( ReferencedDocumentTypeDeletableError::new( effective_contract_id, - document_type_name.clone(), + document_type_name.to_string(), path.to_string(), ) .into(), @@ -838,46 +940,78 @@ fn validate_reference_target_v0( return Ok(SimpleConsensusValidationResult::new_with_error( ReferencedDocumentTypeNotDeletableError::new( effective_contract_id, - document_type_name.clone(), + document_type_name.to_string(), path.to_string(), ) .into(), )); } - // The value is the referenced document's id, unless the - // (permanentDocument) declaration looks the document up through a - // unique index of its type, with the value, or the element, as one - // part of the key - let lookup = match reference_target { - DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. } => { - Some(lookup) + // The document: the one whose id the value is, or the one the + // (permanentDocument) lookup finds through a unique index of its + // type with the value as one key part, or, for a list element, the + // one whose id the `$id` pair's property holds (unset: no document, + // so no list holds the value). A by-id fetch is shared with every + // other reference of the same document in this write + let document_id = match list_reference { + None => Some(referenced_id), + Some(list_reference) => { + let Some(id_property) = list_reference.document_id_property() else { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "a listElement reference carries a $id agreement pair, which the \ + parser enforces", + ))); + }; + match document_data.get_optional_identifier_at_path(id_property) { + Ok(Some(document_id)) => Some(document_id), + Ok(None) => None, + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidIdentifierError::new( + id_property.to_string(), + err.to_string(), + ) + .into(), + )) + } + } } - _ => None, }; - let referenced_document = match lookup { - None => fetch_document_with_id( - platform.drive, - referenced_contract, - referenced_document_type, - Identifier::from(referenced_id), - &block_info.epoch, - execution_context, - transaction, - platform_version, - )?, - Some(lookup) => fetch_document_through_lookup( - platform.drive, - referenced_contract, - referenced_document_type, - lookup, - Identifier::from(referenced_id), - document_data, - owner_id, - &block_info.epoch, - execution_context, - transaction, - platform_version, + let looked_up_document; + let referenced_document: Option<&Document> = match (lookup, document_id) { + (_, None) => None, + (Some(lookup), Some(document_id)) => { + looked_up_document = fetch_document_through_lookup( + platform.drive, + referenced_contract, + referenced_document_type, + lookup, + Identifier::from(document_id), + document_data, + owner_id, + &block_info.epoch, + execution_context, + transaction, + platform_version, + )?; + looked_up_document.as_ref() + } + (None, Some(document_id)) => fetched_documents.document( + effective_contract_id, + document_type_name, + Identifier::from(document_id), + || { + fetch_document_with_id( + platform.drive, + referenced_contract, + referenced_document_type, + Identifier::from(document_id), + &block_info.epoch, + execution_context, + transaction, + platform_version, + ) + }, )?, }; @@ -898,8 +1032,13 @@ fn validate_reference_target_v0( // side's absence is what lets a referring doctype whose // agreement key triggers a skipIfAbsent index stay // consistently absent for untagged targets. - if let Some(referenced_document) = &referenced_document { + if let Some(referenced_document) = referenced_document { for (referring_property, referenced_property) in property_agreement { + // A list element's document is the one its `$id` pair + // names, fetched by that very id: the pair holds + if list_reference.is_some() && referenced_property == ID { + continue; + } let mismatch = || { SimpleConsensusValidationResult::new_with_error( ReferencedDocumentPropertyMismatchError::new( @@ -929,19 +1068,23 @@ fn validate_reference_target_v0( }; referring_value.map(Cow::Borrowed) }; - // The referenced side may name one of the two system + // The referenced side may name one of the system // identifiers a document carries outside its data: // `$ownerId`, which follows the document through - // transfers, and `$creatorId`, set once at creation - // and absent on document types that do not record - // it. Contract registration validated that either - // faces an identifier property on the referring - // side, and the key serializer below already encodes - // both names as 32-byte identifiers. + // transfers, `$creatorId`, set once at creation and + // absent on document types that do not record it, and + // `$id`, the document's own (the pair a list element is + // found by, which holds by construction). Contract + // registration validated that each faces an identifier + // property on the referring side, and the key serializer + // below already encodes the names as 32-byte identifiers. let referenced_value: Option> = match referenced_property.as_str() { OWNER_ID => Some(Cow::Owned(Value::Identifier( referenced_document.owner_id().to_buffer(), ))), + 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())) }), @@ -986,7 +1129,22 @@ fn validate_reference_target_v0( } } - referenced_document.is_some() + // 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 + let document_exists = referenced_document.is_some(); + match (list_reference, document_id) { + (Some(list_reference), Some(document_id)) if document_exists => fetched_documents + .is_listed( + effective_contract_id, + document_type_name, + Identifier::from(document_id), + list_reference, + &referenced_id, + ), + (Some(_), _) => false, + (None, _) => document_exists, + } } DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property, 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 new file mode 100644 index 00000000000..4b8937ca99f --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/list_element_reference.rs @@ -0,0 +1,913 @@ +//! References to an element of a list of a referenced document (`refersTo: +//! listElement`, protocol version 14) through the full ABCI pipeline. In the +//! fixture an `electedCharter` holds its `members` (and `seats.members`), and +//! it can never be deleted or replaced. A `resignation` names its charter by +//! id in `electedCharterId` (a `permanentDocument` reference of its own), and +//! `memberId` must be one of that charter's members, found by the agreement +//! pair `{ "electedCharterId": "$id" }`; each of `witnesses`, a typed array, +//! must be too. `plainMemberId` reads its charter through `plainCharterId`, an +//! identifier with no reference of its own; `titledMemberId` also agrees on +//! `charterTitle` with the charter's `title`; `metaMemberId` reads the nested +//! `seats.members` through the nested `meta.charterId`; and +//! `memberOrCharterId` is an `anyOf` of a member and the charter itself. A +//! `seatedNote` may only be written by a member of the charter it names +//! (`ownerRefersTo`). The second contract's `ballot` reads a charter of the +//! first contract. +//! +//! A value the list does not hold is refused, paid, with +//! `ReferencedEntityNotFoundError` (40120) naming the property, or the element +//! by its list path, and the list element declaration as the entity type. + +use super::*; + +mod list_element_reference_tests { + use super::*; + use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, + }; + use crate::execution::validation::state_transition::batch::action_validation::document::document_reference_validation::DocumentReferenceValidation; + use crate::platform_types::platform::PlatformStateRef; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::data_contract::document_type::{DocumentPropertyReferenceTarget, DocumentTypeRef}; + use dpp::document::Document; + use dpp::identifier::Identifier; + use dpp::identity::{Identity, IdentityPublicKey}; + use dpp::prelude::{DataContract, IdentityNonce}; + use dpp::state_transition::StateTransition; + use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; + use dpp::version::DefaultForPlatformVersion; + use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionV0}; + use simple_signer::signer::SimpleSigner; + use std::collections::BTreeMap; + + const CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element.json"; + + /// A `ballot` type whose `voterId` must be a member of a charter of the + /// contract above: the list in another contract. + const BALLOT_CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-registration-foreign-valid.json"; + + /// The identities of the fixture: the `Founder` writes charters, + /// resignations and ballots; the `Member` and the `Stranger` are listed, + /// or not, in them. + #[derive(Clone, Copy)] + enum Who { + Founder, + Member, + Stranger, + } + + struct Writer { + identity: Identity, + signer: SimpleSigner, + key: IdentityPublicKey, + /// The identity contract nonce the next transition uses, per contract; + /// every processed transition consumes one, a refused one included. + next_nonces: BTreeMap, + } + + impl Writer { + fn next_nonce(&mut self, contract_id: Identifier) -> IdentityNonce { + let nonce = self.next_nonces.entry(contract_id).or_insert(1); + let this_one = *nonce; + *nonce += 1; + this_one + } + } + + struct ListElementFixture { + platform: TempPlatform, + contract: DataContract, + ballot_contract: DataContract, + rng: StdRng, + founder: Writer, + member: Writer, + stranger: Writer, + } + + fn id_value(id: Identifier) -> Value { + Value::Identifier(id.to_buffer()) + } + + fn ids(ids: &[Identifier]) -> Value { + Value::Array(ids.iter().copied().map(id_value).collect()) + } + + impl ListElementFixture { + fn new() -> Self { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut writer = |seed| { + let (identity, signer, key) = + setup_identity(&mut platform, seed, dash_to_credits!(0.5)); + Writer { + identity, + signer, + key, + next_nonces: BTreeMap::new(), + } + }; + let founder = writer(1958); + let member = writer(1959); + let stranger = writer(1960); + + // Parsed with full validation, so the list checks run on the + // fixtures too + let [contract, ballot_contract] = [CONTRACT_PATH, BALLOT_CONTRACT_PATH].map(|path| { + let contract = json_document_to_contract(path, true, platform_version) + .expect("expected to parse the contract"); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + contract + }); + + Self { + platform, + contract, + ballot_contract, + rng: StdRng::seed_from_u64(5533), + founder, + member, + stranger, + } + } + + fn id(&self, who: Who) -> Identifier { + match who { + Who::Founder => self.founder.identity.id(), + Who::Member => self.member.identity.id(), + Who::Stranger => self.stranger.identity.id(), + } + } + + /// The document type named `type_name`, in whichever fixture contract + /// has it, with the contract's id, `who`'s writer and the random + /// source: the parts a transition is built from, borrowed at once. + fn parts( + &mut self, + who: Who, + type_name: &str, + ) -> (DocumentTypeRef<'_>, Identifier, &mut Writer, &mut StdRng) { + let (document_type, contract_id) = [&self.contract, &self.ballot_contract] + .into_iter() + .find_map(|contract| { + contract + .document_type_optional_for_name(type_name) + .map(|document_type| (document_type, contract.id())) + }) + .expect("expected the document type"); + let writer = match who { + Who::Founder => &mut self.founder, + Who::Member => &mut self.member, + Who::Stranger => &mut self.stranger, + }; + (document_type, contract_id, writer, &mut self.rng) + } + + fn process(&self, transition: &StateTransition) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let platform_state = self.platform.state.load(); + let serialized = transition + .serialize_to_bytes() + .expect("expected the transition to serialize"); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) + } + + /// Creates a `type_name` document owned by the founder, with `values` + /// set over the random required ones, and returns it with the result. + async fn create( + &mut self, + type_name: &str, + values: &[(&str, Value)], + ) -> (Document, StateTransitionExecutionResult) { + self.create_as(Who::Founder, type_name, values).await + } + + /// [`Self::create`], written and owned by `who`. + async fn create_as( + &mut self, + who: Who, + type_name: &str, + values: &[(&str, Value)], + ) -> (Document, StateTransitionExecutionResult) { + let platform_version = PlatformVersion::latest(); + let owner_id = self.id(who); + let (document_type, contract_id, writer, rng) = self.parts(who, type_name); + let entropy = Bytes32::random_with_rng(rng); + let mut document = document_type + .random_document_with_identifier_and_entropy( + rng, + owner_id, + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + for (property, value) in values { + document.set(property, value.clone()); + } + let nonce = writer.next_nonce(contract_id); + document + .set_id_for_creation(document_type, &entropy.0, nonce, platform_version) + .expect("expected to set the document id"); + let transition = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + document_type, + entropy.0, + &writer.key, + nonce, + 0, + None, + &writer.signer, + platform_version, + None, + ) + .await + .expect("expected the create transition"); + let result = self.process(&transition); + (document, result) + } + + /// Replaces `document`, as last accepted, with `change` applied. + async fn replace( + &mut self, + type_name: &str, + document: &Document, + change: impl FnOnce(&mut Document), + ) -> (Document, StateTransitionExecutionResult) { + let platform_version = PlatformVersion::latest(); + let mut replacement = document.clone(); + replacement + .increment_revision() + .expect("the revision increments"); + change(&mut replacement); + let (document_type, contract_id, writer, _) = self.parts(Who::Founder, type_name); + let nonce = writer.next_nonce(contract_id); + let transition = BatchTransition::new_document_replacement_transition_from_document( + replacement.clone(), + document_type, + &writer.key, + nonce, + 0, + None, + &writer.signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition"); + let result = self.process(&transition); + (replacement, result) + } + + /// An elected charter titled `title` for the submitted charter + /// `submitted_charter_id` seating `members` (in `seats.members` too); + /// returns its id. + async fn seat_charter( + &mut self, + submitted_charter_id: Identifier, + title: &str, + members: &[Identifier], + ) -> Identifier { + let (charter, result) = self + .create( + "electedCharter", + &[ + ("submittedCharterId", id_value(submitted_charter_id)), + ("title", title.into()), + ("members", ids(members)), + ( + "seats", + Value::Map(vec![(Value::Text("members".to_string()), ids(members))]), + ), + ], + ) + .await; + assert_succeeded(result); + charter.id() + } + + async fn resign( + &mut self, + values: &[(&str, Value)], + ) -> (Document, StateTransitionExecutionResult) { + self.create("resignation", values).await + } + } + + fn submitted_charter_id(byte: u8) -> Identifier { + Identifier::from([byte; 32]) + } + + fn assert_succeeded(result: StateTransitionExecutionResult) { + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + /// The refusal of a value the list does not hold, naming the property (or + /// the element) and the value, with the list element declaration. + fn assert_not_listed(result: StateTransitionExecutionResult, path: &str, value: Identifier) { + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(e)), + .. + } if e.path() == path + && *e.entity_id() == value + && matches!(e.entity_type(), DocumentPropertyReferenceTarget::ListElement(_)), + "expected 40120 at {path}" + ); + } + + #[tokio::test] + async fn should_accept_a_value_the_referenced_list_holds() { + let mut fixture = ListElementFixture::new(); + let member = fixture.id(Who::Member); + let charter = fixture + .seat_charter(submitted_charter_id(1), "alpha", &[member]) + .await; + + let (_, result) = fixture + .resign(&[ + ("electedCharterId", id_value(charter)), + ("memberId", id_value(member)), + ]) + .await; + + assert_succeeded(result); + } + + #[tokio::test] + async fn should_refuse_a_value_the_referenced_list_does_not_hold() { + let mut fixture = ListElementFixture::new(); + let member = fixture.id(Who::Member); + let stranger = fixture.id(Who::Stranger); + let charter = fixture + .seat_charter(submitted_charter_id(1), "alpha", &[member]) + .await; + // The stranger sits on another charter, which is not the one named + fixture + .seat_charter(submitted_charter_id(2), "beta", &[stranger]) + .await; + + let (_, result) = fixture + .resign(&[ + ("electedCharterId", id_value(charter)), + ("memberId", id_value(stranger)), + ]) + .await; + + assert_not_listed(result, "memberId", stranger); + } + + #[tokio::test] + async fn should_refuse_a_value_set_while_the_id_property_is_not_or_names_no_charter() { + let mut fixture = ListElementFixture::new(); + let member = fixture.id(Who::Member); + fixture + .seat_charter(submitted_charter_id(1), "alpha", &[member]) + .await; + + // No charter is named, so no list holds the member + let (_, unnamed) = fixture.resign(&[("memberId", id_value(member))]).await; + assert_not_listed(unnamed, "memberId", member); + + // A charter that does not exist holds no list either: `plainCharterId` + // carries no reference of its own, so the list element is what finds it + let (_, missing) = fixture + .resign(&[ + ("plainCharterId", id_value(Identifier::from([0xEE; 32]))), + ("plainMemberId", id_value(member)), + ]) + .await; + assert_not_listed(missing, "plainMemberId", member); + } + + /// The `$id` property needs no reference of its own: the list element + /// fetches the charter itself. + #[tokio::test] + async fn should_read_the_list_through_a_plain_identifier() { + let mut fixture = ListElementFixture::new(); + let member = fixture.id(Who::Member); + let stranger = fixture.id(Who::Stranger); + let charter = fixture + .seat_charter(submitted_charter_id(1), "alpha", &[member]) + .await; + + let (_, listed) = fixture + .resign(&[ + ("plainCharterId", id_value(charter)), + ("plainMemberId", id_value(member)), + ]) + .await; + assert_succeeded(listed); + + let (_, unlisted) = fixture + .resign(&[ + ("plainCharterId", id_value(charter)), + ("plainMemberId", id_value(stranger)), + ]) + .await; + assert_not_listed(unlisted, "plainMemberId", stranger); + } + + #[tokio::test] + async fn should_check_every_element_of_a_typed_array_against_the_list() { + let mut fixture = ListElementFixture::new(); + let founder = fixture.id(Who::Founder); + let member = fixture.id(Who::Member); + let stranger = fixture.id(Who::Stranger); + let charter = fixture + .seat_charter(submitted_charter_id(1), "alpha", &[founder, member]) + .await; + + let (_, all_listed) = fixture + .resign(&[ + ("electedCharterId", id_value(charter)), + ("witnesses", ids(&[founder, member])), + ]) + .await; + assert_succeeded(all_listed); + + // One element the list does not hold refuses the write, the error + // naming the element by its list path + let (_, one_unlisted) = fixture + .resign(&[ + ("electedCharterId", id_value(charter)), + ("witnesses", ids(&[member, stranger])), + ]) + .await; + assert_not_listed(one_unlisted, "witnesses[1]", stranger); + } + + /// The other agreement pairs are checked against the charter the `$id` + /// pair names, as for any document reference. + #[tokio::test] + async fn should_check_the_other_agreement_pairs_against_the_charter() { + let mut fixture = ListElementFixture::new(); + let member = fixture.id(Who::Member); + let charter = fixture + .seat_charter(submitted_charter_id(1), "alpha", &[member]) + .await; + + let (_, agreeing) = fixture + .resign(&[ + ("electedCharterId", id_value(charter)), + ("charterTitle", "alpha".into()), + ("titledMemberId", id_value(member)), + ]) + .await; + assert_succeeded(agreeing); + + let (_, disagreeing) = fixture + .resign(&[ + ("electedCharterId", id_value(charter)), + ("charterTitle", "beta".into()), + ("titledMemberId", id_value(member)), + ]) + .await; + assert_matches!( + disagreeing, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyMismatchError(e) + ), + .. + } if e.path() == "titledMemberId" + && e.referring_property() == "charterTitle" + && 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. + #[tokio::test] + async fn should_check_an_id_agreement_on_an_ordinary_reference() { + let mut fixture = ListElementFixture::new(); + let member = fixture.id(Who::Member); + let charter = fixture + .seat_charter(submitted_charter_id(1), "alpha", &[member]) + .await; + let other_charter = fixture + .seat_charter(submitted_charter_id(2), "beta", &[member]) + .await; + + let (_, agreeing) = fixture + .resign(&[ + ("echoedCharterId", id_value(charter)), + ("echoCharterId", id_value(charter)), + ]) + .await; + assert_succeeded(agreeing); + + let (_, disagreeing) = fixture + .resign(&[ + ("echoedCharterId", id_value(charter)), + ("echoCharterId", id_value(other_charter)), + ]) + .await; + assert_matches!( + disagreeing, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyMismatchError(e) + ), + .. + } if e.path() == "echoedCharterId" + && e.referring_property() == "echoCharterId" + && e.referenced_property() == "$id" + ); + } + + #[tokio::test] + async fn should_refuse_a_replace_that_points_the_id_property_at_a_charter_not_listing_the_value( + ) { + let mut fixture = ListElementFixture::new(); + let founder = fixture.id(Who::Founder); + let member = fixture.id(Who::Member); + let stranger = fixture.id(Who::Stranger); + let charter = fixture + .seat_charter(submitted_charter_id(1), "alpha", &[member, founder]) + .await; + let other_charter = fixture + .seat_charter(submitted_charter_id(2), "beta", &[stranger]) + .await; + let (resignation, result) = fixture + .resign(&[ + ("electedCharterId", id_value(charter)), + ("memberId", id_value(member)), + ("witnesses", ids(&[member])), + ]) + .await; + assert_succeeded(result); + + // Touching neither the value nor its charter leaves the reference alone + let (resignation, untouched) = fixture + .replace("resignation", &resignation, |resignation| { + resignation.set("reason", "moving on".into()); + }) + .await; + assert_succeeded(untouched); + + // Pointing the charter elsewhere re-validates the member against the + // other charter's list, which does not hold it (`memberId` is judged + // before `witnesses`, in declaration order) + let (_, repointed) = fixture + .replace("resignation", &resignation, |resignation| { + resignation.set("electedCharterId", id_value(other_charter)); + }) + .await; + assert_not_listed(repointed, "memberId", member); + + // Changing the value to one the charter does not list is refused too + let (_, changed) = fixture + .replace("resignation", &resignation, |resignation| { + resignation.set("memberId", id_value(stranger)); + }) + .await; + assert_not_listed(changed, "memberId", stranger); + + // and to one it lists is accepted: the charter is fetched again for + // the check, while its own reference is left alone + let (resignation, changed_listed) = fixture + .replace("resignation", &resignation, |resignation| { + resignation.set("memberId", id_value(founder)); + }) + .await; + assert_succeeded(changed_listed); + + // Pointing the charter elsewhere re-validates EVERY element, not only + // the ones the stored list did not hold + let (_, repointed_elements) = fixture + .replace("resignation", &resignation, |resignation| { + resignation.remove("memberId"); + resignation.set("electedCharterId", id_value(other_charter)); + }) + .await; + assert_not_listed(repointed_elements, "witnesses[0]", member); + + // Repointing with values the other charter lists is accepted + let (_, repointed_listed) = fixture + .replace("resignation", &resignation, |resignation| { + resignation.set("electedCharterId", id_value(other_charter)); + resignation.set("memberId", id_value(stranger)); + resignation.set("witnesses", ids(&[stranger])); + }) + .await; + assert_succeeded(repointed_listed); + } + + /// `metaMemberId` reads `seats.members` through `meta.charterId`: nested + /// paths on both sides, and a replace of the whole `meta` object moves it. + #[tokio::test] + async fn should_read_a_nested_list_through_a_nested_id_property() { + let mut fixture = ListElementFixture::new(); + let member = fixture.id(Who::Member); + let stranger = fixture.id(Who::Stranger); + let charter = fixture + .seat_charter(submitted_charter_id(1), "alpha", &[member]) + .await; + let other_charter = fixture + .seat_charter(submitted_charter_id(2), "beta", &[stranger]) + .await; + let meta = |charter: Identifier| { + Value::Map(vec![( + Value::Text("charterId".to_string()), + id_value(charter), + )]) + }; + + let (_, unlisted) = fixture + .resign(&[ + ("meta", meta(charter)), + ("metaMemberId", id_value(stranger)), + ]) + .await; + assert_not_listed(unlisted, "metaMemberId", stranger); + + let (resignation, listed) = fixture + .resign(&[("meta", meta(charter)), ("metaMemberId", id_value(member))]) + .await; + assert_succeeded(listed); + + let (_, moved) = fixture + .replace("resignation", &resignation, |resignation| { + resignation.set("meta", meta(other_charter)); + }) + .await; + assert_not_listed(moved, "metaMemberId", member); + } + + /// A list element is a leaf a reference expression takes: `memberOrCharterId` + /// is a member of the named charter, or the charter itself. + #[tokio::test] + async fn should_compose_with_a_reference_expression() { + let mut fixture = ListElementFixture::new(); + let member = fixture.id(Who::Member); + let stranger = fixture.id(Who::Stranger); + let charter = fixture + .seat_charter(submitted_charter_id(1), "alpha", &[member]) + .await; + + for value in [member, charter] { + let (_, held) = fixture + .resign(&[ + ("electedCharterId", id_value(charter)), + ("memberOrCharterId", id_value(value)), + ]) + .await; + assert_succeeded(held); + } + + // Neither: refused with the last operand's error, the charter reference's + let (_, neither) = fixture + .resign(&[ + ("electedCharterId", id_value(charter)), + ("memberOrCharterId", id_value(stranger)), + ]) + .await; + assert_matches!( + neither, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(e)), + .. + } if e.path() == "memberOrCharterId" + && *e.entity_id() == stranger + && matches!( + e.entity_type(), + DocumentPropertyReferenceTarget::PermanentDocument { .. } + ) + ); + } + + /// Composing with `ownerRefersTo` (#4941): the moderation charters' owner + /// rule, the writer must be one of the charter's members. The value is the + /// writer, so a refusal names `$ownerId`. + #[tokio::test] + async fn should_accept_only_a_writer_the_referenced_list_holds() { + let mut fixture = ListElementFixture::new(); + let member = fixture.id(Who::Member); + let stranger = fixture.id(Who::Stranger); + let charter = fixture + .seat_charter(submitted_charter_id(1), "alpha", &[member]) + .await; + + let (_, seated) = fixture + .create_as( + Who::Member, + "seatedNote", + &[("electedCharterId", id_value(charter))], + ) + .await; + assert_succeeded(seated); + + let (_, not_seated) = fixture + .create_as( + Who::Stranger, + "seatedNote", + &[("electedCharterId", id_value(charter))], + ) + .await; + assert_not_listed(not_seated, "$ownerId", stranger); + } + + /// A list in another contract is read from that contract's document. + #[tokio::test] + async fn should_read_the_list_of_a_charter_of_another_contract() { + let mut fixture = ListElementFixture::new(); + let member = fixture.id(Who::Member); + let stranger = fixture.id(Who::Stranger); + let charter = fixture + .seat_charter(submitted_charter_id(1), "alpha", &[member]) + .await; + + let (_, listed) = fixture + .create( + "ballot", + &[ + ("electedCharterId", id_value(charter)), + ("voterId", id_value(member)), + ], + ) + .await; + assert_succeeded(listed); + + let (_, unlisted) = fixture + .create( + "ballot", + &[ + ("electedCharterId", id_value(charter)), + ("voterId", id_value(stranger)), + ], + ) + .await; + assert_not_listed(unlisted, "voterId", stranger); + } + + /// The charter `electedCharterId`'s own reference fetches is shared with + /// the list elements read through it: a write with list elements is + /// billed exactly what the same write without them is, one document + /// fetch. A list element through a plain identifier is that one fetch. + #[tokio::test] + async fn should_bill_one_fetch_for_the_charter_and_its_list_elements() { + let mut fixture = ListElementFixture::new(); + let platform_version = PlatformVersion::latest(); + let founder = fixture.id(Who::Founder); + let member = fixture.id(Who::Member); + let stranger = fixture.id(Who::Stranger); + let charter = fixture + .seat_charter(submitted_charter_id(1), "alpha", &[founder, member]) + .await; + let other_charter = fixture + .seat_charter(submitted_charter_id(2), "beta", &[founder]) + .await; + + let (_, contract_fetch_info) = fixture + .platform + .drive + .get_contract_with_fetch_info_and_fee( + fixture.contract.id().to_buffer(), + None, + false, + None, + platform_version, + ) + .expect("expected to fetch the contract"); + let base = DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { + id: Identifier::from([0xAA; 32]), + identity_contract_nonce: 1, + document_type_name: "resignation".to_string(), + data_contract: contract_fetch_info.expect("the contract is in state"), + token_cost: None, + gas_fees_paid_by: GasFeesPaidBy::default(), + contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, + }); + let platform_state = fixture.platform.state.load(); + let platform_ref = PlatformStateRef { + drive: &fixture.platform.drive, + state: &platform_state, + config: &fixture.platform.config, + }; + let validate = |values: &[(&str, Value)]| { + let data: BTreeMap = values + .iter() + .map(|(property, value)| (property.to_string(), value.clone())) + .collect(); + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("expected an execution context"); + let result = base + .validate_document_references( + &data, + founder, + // The resignation type records no creator ids + None, + None, + // A create: there is no stored document + None, + &platform_ref, + &BlockInfo::default(), + None, + &mut execution_context, + platform_version, + ) + .expect("expected the references to be validated"); + (result, execution_context.operations_slice().to_vec()) + }; + + let (result, charter_only) = validate(&[("electedCharterId", id_value(charter))]); + assert!(result.is_valid(), "{:?}", result.errors); + assert_eq!(charter_only.len(), 1, "one fetch, the charter"); + + let (result, with_list_elements) = validate(&[ + ("electedCharterId", id_value(charter)), + ("memberId", id_value(member)), + ("witnesses", ids(&[founder, member])), + ]); + assert!(result.is_valid(), "{:?}", result.errors); + assert_eq!( + with_list_elements, charter_only, + "the list elements add no read to the charter's fetch" + ); + + // A refused one is billed the same + let (result, refused) = validate(&[ + ("electedCharterId", id_value(charter)), + ("memberId", id_value(stranger)), + ]); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::ReferencedEntityNotFoundError(_) + )] + ); + assert_eq!(refused, charter_only); + + // Through a plain identifier the list element's fetch is the one read + let (result, plain) = validate(&[ + ("plainCharterId", id_value(charter)), + ("plainMemberId", id_value(member)), + ]); + assert!(result.is_valid(), "{:?}", result.errors); + assert_eq!(plain, charter_only); + // Two ordinary references naming the same charter share one fetch, + // and naming two charters fetch both + let meta = |charter: Identifier| { + Value::Map(vec![( + Value::Text("charterId".to_string()), + id_value(charter), + )]) + }; + let (result, same_charter) = validate(&[ + ("electedCharterId", id_value(charter)), + ("meta", meta(charter)), + ]); + assert!(result.is_valid(), "{:?}", result.errors); + assert_eq!(same_charter, charter_only); + + let (result, two_charters) = validate(&[ + ("electedCharterId", id_value(charter)), + ("meta", meta(other_charter)), + ]); + assert!(result.is_valid(), "{:?}", result.errors); + assert_eq!(two_charters.len(), 2, "one fetch per charter"); + } +} 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 6c96ab36bac..bcbd7f6419c 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 @@ -10,6 +10,7 @@ mod id_reuse; mod immutable; mod index_only; mod keep_history; +mod list_element_reference; mod lookup_reference; mod nft; mod owner_balance_proof; 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 e2498195789..100a5dfa245 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 @@ -9,6 +9,7 @@ use dpp::data_contract::document_type::{ }; 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_type_deletable_error::ReferencedDocumentTypeDeletableError; @@ -54,6 +55,13 @@ fn same_value_kind(a: &DocumentPropertyType, b: &DocumentPropertyType) -> bool { /// reference its own document types on creation; foreign contract fetches are /// billed. /// +/// `listElement`: a document reference like `permanentDocument` (same +/// checks, the type must forbid deletion, `$id` admitted on the referenced +/// side of a pair), plus, for a list in a document type of another contract, +/// that `inList` is a stored typed array of identifiers fixed once a document +/// is written. One in the declaring contract was checked by the contract +/// parse. +/// /// `identityPublicKey`: the declared key id property must exist in the same /// document type and be an integer. /// @@ -316,6 +324,7 @@ fn validate_reference_target_declaration_v0( property_agreement, permanent, lookup, + in_list, }) = reference_target.as_any_document_reference() else { return Ok(SimpleConsensusValidationResult::new()); @@ -438,6 +447,28 @@ fn validate_reference_target_declaration_v0( } } + // 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 + // the declaring contract under full validation, where it sees every + // document type; only here is another contract's document type in hand. + // The `$id` pair naming the list's document was checked by the parse, and + // the other pairs are checked below as every agreement is + if let (Some(_), Some(reference)) = (in_list, reference_target.as_list_element_reference()) { + if effective_contract_id != contract.id() { + if let Some(reason) = reference.referenced_side_error(referenced_document_type) { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentListInvalidError::new( + declaration_path, + reference.in_list.clone(), + reason, + ) + .into(), + )); + } + } + } + // propertyAgreement declarations: both sides must exist, be // plain values (not containers), and share one value kind — a // cross-kind equality could never be satisfied and would brick @@ -494,7 +525,7 @@ fn validate_reference_target_declaration_v0( if referenced_property.starts_with('$') { if !is_referenced_system_agreement_property(referenced_property) { return Ok(invalid( - "only the referenced document's $ownerId and $creatorId system \ + "only the referenced document's $ownerId, $creatorId and $id system \ properties may be agreed with", )); } @@ -503,7 +534,7 @@ fn validate_reference_target_declaration_v0( DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) ) { return Ok(invalid( - "$ownerId and $creatorId are identifiers, so the referring \ + "$ownerId, $creatorId and $id are identifiers, so the referring \ property must be an identifier", )); } 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 97846cfb16a..64ee117b2f2 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 @@ -6081,6 +6081,29 @@ mod tests { ); } + /// `$id`, the referenced document's own id, is an identifier like + /// `$ownerId` and `$creatorId`: an agreement pair facing it with a + /// string could never hold. + #[tokio::test] + async fn should_reject_an_id_agreement_facing_a_non_identifier_property() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-id-kind-mismatch.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyAgreementInvalidError(e) + ), + .. + } if e.referring_property() == "authorId" + && e.referenced_property() == "$id" + && e.reason().contains("$ownerId, $creatorId and $id are identifiers") + ); + } + /// `{ "$ownerId": "$ownerId" }` makes the writer the referring side: /// only the note's current owner may write a message on it. #[tokio::test] @@ -6408,5 +6431,72 @@ mod tests { } if message.contains("index \"byCharter\" of \"ballot\" is not unique") ); } + + /// The contract whose immutable, permanent `electedCharter` type holds the + /// `members` list the list element registration fixtures read from another + /// contract. + const LIST_ELEMENT_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element.json"; + + #[tokio::test] + async fn should_register_a_list_element_reading_a_list_of_another_contract() { + let result = run_contract_create_with_foreign( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-registration-foreign-valid.json", + LIST_ELEMENT_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_reject_a_list_element_naming_a_property_of_another_contract_that_is_no_list( + ) { + // Only registration sees the other contract's document type: the + // contract parse cannot, so this is a state error + let result = run_contract_create_with_foreign( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-registration-foreign-not-a-list.json", + LIST_ELEMENT_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentListInvalidError(e) + ), + .. + } if e.path() == "ballot.voterId" + && e.in_list() == "submittedCharterId" + && e.reason().contains("is not a typed array of identifiers") + ); + } + + #[tokio::test] + async fn should_reject_a_list_element_reading_a_replaceable_list_of_the_same_contract() { + // The contract parse sees the list's document type of the same + // contract, and refuses the declaration before any state is read + let result = run_contract_create_with_foreign( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-registration-own-mutable.json", + LIST_ELEMENT_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(message) + )), + .. + } if message.contains( + "refersTo listElement: \"members\" of \"electedCharter\" can be changed by a replace" + ) + ); + } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs index da6ac9a75d6..cab070c28b9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_update/mod.rs @@ -4077,6 +4077,7 @@ mod tests { mod permanent_document_reference_declarations { use super::*; use dpp::consensus::state::state_error::StateError; + use dpp::data_contract::errors::DataContractError; use drive::util::test_helpers::setup_contract; const V1_PATH: &str = @@ -4089,6 +4090,18 @@ mod tests { /// from the given fixture at version 2 and returns the execution /// result. async fn run_contract_update(updated_fixture_path: &str) -> StateTransitionExecutionResult { + run_contract_update_from(V1_PATH, updated_fixture_path, true).await + } + + /// [`run_contract_update`] from the contract at `v1_path`. The updated + /// fixture is only checked by the test itself when + /// `validate_updated_fixture` is set: one the contract parse refuses + /// has to reach the node to be refused there. + async fn run_contract_update_from( + v1_path: &str, + updated_fixture_path: &str, + validate_updated_fixture: bool, + ) -> StateTransitionExecutionResult { let mut platform = TestPlatformBuilder::new() .build_with_mock_rpc() .set_genesis_state(); @@ -4110,7 +4123,7 @@ mod tests { None, ); - let mut contract = json_document_to_contract(V1_PATH, true, platform_version) + let mut contract = json_document_to_contract(v1_path, true, platform_version) .expect("expected to get data contract"); contract.set_owner_id(identity.id()); @@ -4128,9 +4141,12 @@ mod tests { ) .expect("expected to apply contract successfully"); - let mut updated_contract = - json_document_to_contract(updated_fixture_path, true, platform_version) - .expect("expected to get updated data contract"); + let mut updated_contract = json_document_to_contract( + updated_fixture_path, + validate_updated_fixture, + platform_version, + ) + .expect("expected to get updated data contract"); updated_contract.set_owner_id(identity.id()); updated_contract @@ -4216,5 +4232,50 @@ mod tests { } ); } + + /// The contract whose immutable `electedCharter` holds the `members` + /// list, updated below with an `appeal` type reading it. + const LIST_ELEMENT_V1_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element.json"; + + #[tokio::test] + async fn should_update_contract_adding_a_list_element_into_a_fixed_list() { + let result = run_contract_update_from( + LIST_ELEMENT_V1_PATH, + "tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-update-good.json", + true, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_reject_contract_update_adding_a_list_element_into_a_property_that_is_no_list( + ) { + // The updated contract's parse runs the list checks as a + // registration's does + let result = run_contract_update_from( + LIST_ELEMENT_V1_PATH, + "tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-update-bad.json", + false, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(message) + )), + .. + } if message.contains( + "document type \"appeal\" property \"appellantId\" refersTo listElement: \"title\" of \"electedCharter\" is not a typed array of identifiers" + ) + ); + } } } diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-id-kind-mismatch.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-id-kind-mismatch.json new file mode 100644 index 00000000000..14a03886c65 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-agreement-id-kind-mismatch.json @@ -0,0 +1,52 @@ +{ + "$formatVersion": "1", + "id": "5ZqWSC3VJKj2c4b7nL1Sfd9rWm8ngdGUWM3tqoqqgTg3", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "note": { + "type": "object", + "canBeDeleted": false, + "transferable": 1, + "properties": { + "content": { + "type": "string", + "position": 0, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + }, + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "noteId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "note", + "propertyAgreement": { + "authorId": "$id" + } + } + }, + "authorId": { + "type": "string", + "position": 1, + "maxLength": 63 + } + }, + "required": [ + "noteId" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-registration-foreign-not-a-list.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-registration-foreign-not-a-list.json new file mode 100644 index 00000000000..640e33e8cc5 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-registration-foreign-not-a-list.json @@ -0,0 +1,47 @@ +{ + "$formatVersion": "1", + "id": "GGjX3NzgfwaEwo3qgHqzakSbzNerAHa751nsLBMtKegp", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "ballot": { + "type": "object", + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "contractId": "9AJn2N8f1Sz9wNA9ShzGWBTwALcULn7QhGMBoesgg4zG", + "documentType": "electedCharter" + } + }, + "voterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1, + "refersTo": { + "type": "listElement", + "contractId": "9AJn2N8f1Sz9wNA9ShzGWBTwALcULn7QhGMBoesgg4zG", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "submittedCharterId" + } + } + }, + "required": [ + "electedCharterId" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-registration-foreign-valid.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-registration-foreign-valid.json new file mode 100644 index 00000000000..f3718f8b048 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-registration-foreign-valid.json @@ -0,0 +1,47 @@ +{ + "$formatVersion": "1", + "id": "7aS168gt2SNGWTDFQChsjdCbzh3sShGL68uMedEwoeVE", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "ballot": { + "type": "object", + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "contractId": "9AJn2N8f1Sz9wNA9ShzGWBTwALcULn7QhGMBoesgg4zG", + "documentType": "electedCharter" + } + }, + "voterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1, + "refersTo": { + "type": "listElement", + "contractId": "9AJn2N8f1Sz9wNA9ShzGWBTwALcULn7QhGMBoesgg4zG", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + } + } + }, + "required": [ + "electedCharterId" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-registration-own-mutable.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-registration-own-mutable.json new file mode 100644 index 00000000000..50644f9b62d --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-registration-own-mutable.json @@ -0,0 +1,258 @@ +{ + "$formatVersion": "1", + "id": "DsrDkXBzp6KRqwi14t6EJZTj5QeziM29Eg2DajkArpDk", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "electedCharter": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": true, + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "members": { + "type": "array", + "minItems": 0, + "maxItems": 15, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 1 + }, + "title": { + "type": "string", + "maxLength": 63, + "position": 2 + }, + "seats": { + "type": "object", + "properties": { + "members": { + "type": "array", + "minItems": 0, + "maxItems": 15, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 0 + } + }, + "additionalProperties": false, + "position": 3 + } + }, + "required": [ + "submittedCharterId", + "members" + ], + "additionalProperties": false + }, + "resignation": { + "type": "object", + "documentsMutable": true, + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter" + } + }, + "memberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + } + }, + "witnesses": { + "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": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + } + }, + "position": 2 + }, + "plainCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 3 + }, + "plainMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 4, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "plainCharterId": "$id" + }, + "inList": "members" + } + }, + "charterTitle": { + "type": "string", + "maxLength": 63, + "position": 5 + }, + "titledMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 6, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id", + "charterTitle": "title" + }, + "inList": "members" + } + }, + "meta": { + "type": "object", + "properties": { + "charterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter" + } + } + }, + "additionalProperties": false, + "position": 7 + }, + "metaMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 8, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "meta.charterId": "$id" + }, + "inList": "seats.members" + } + }, + "memberOrCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 9, + "refersTo": { + "anyOf": [ + { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + }, + { + "type": "permanentDocument", + "documentType": "electedCharter" + } + ] + } + }, + "reason": { + "type": "string", + "maxLength": 63, + "position": 10 + }, + "echoCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 11 + }, + "echoedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 12, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter", + "propertyAgreement": { + "echoCharterId": "$id" + } + } + } + }, + "additionalProperties": false + } + } +} 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 new file mode 100644 index 00000000000..3d8a0cd0fd8 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-update-bad.json @@ -0,0 +1,325 @@ +{ + "$formatVersion": "1", + "id": "9AJn2N8f1Sz9wNA9ShzGWBTwALcULn7QhGMBoesgg4zG", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 2, + "documentSchemas": { + "electedCharter": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "members": { + "type": "array", + "minItems": 0, + "maxItems": 15, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 1 + }, + "title": { + "type": "string", + "maxLength": 63, + "position": 2 + }, + "seats": { + "type": "object", + "properties": { + "members": { + "type": "array", + "minItems": 0, + "maxItems": 15, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 0 + } + }, + "additionalProperties": false, + "position": 3 + } + }, + "required": [ + "submittedCharterId", + "members" + ], + "additionalProperties": false + }, + "resignation": { + "type": "object", + "documentsMutable": true, + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter" + } + }, + "memberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + } + }, + "witnesses": { + "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": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + } + }, + "position": 2 + }, + "plainCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 3 + }, + "plainMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 4, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "plainCharterId": "$id" + }, + "inList": "members" + } + }, + "charterTitle": { + "type": "string", + "maxLength": 63, + "position": 5 + }, + "titledMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 6, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id", + "charterTitle": "title" + }, + "inList": "members" + } + }, + "meta": { + "type": "object", + "properties": { + "charterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter" + } + } + }, + "additionalProperties": false, + "position": 7 + }, + "metaMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 8, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "meta.charterId": "$id" + }, + "inList": "seats.members" + } + }, + "memberOrCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 9, + "refersTo": { + "anyOf": [ + { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + }, + { + "type": "permanentDocument", + "documentType": "electedCharter" + } + ] + } + }, + "reason": { + "type": "string", + "maxLength": 63, + "position": 10 + }, + "echoCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 11 + }, + "echoedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 12, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter", + "propertyAgreement": { + "echoCharterId": "$id" + } + } + } + }, + "additionalProperties": false + }, + "appeal": { + "type": "object", + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter" + } + }, + "appellantId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "title" + } + } + }, + "required": [ + "electedCharterId" + ], + "additionalProperties": false + }, + "seatedNote": { + "type": "object", + "ownerRefersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + }, + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "text": { + "type": "string", + "maxLength": 63, + "position": 1 + } + }, + "required": [ + "electedCharterId" + ], + "additionalProperties": false + } + } +} 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 new file mode 100644 index 00000000000..9c742e447b2 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element-update-good.json @@ -0,0 +1,325 @@ +{ + "$formatVersion": "1", + "id": "9AJn2N8f1Sz9wNA9ShzGWBTwALcULn7QhGMBoesgg4zG", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 2, + "documentSchemas": { + "electedCharter": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "members": { + "type": "array", + "minItems": 0, + "maxItems": 15, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 1 + }, + "title": { + "type": "string", + "maxLength": 63, + "position": 2 + }, + "seats": { + "type": "object", + "properties": { + "members": { + "type": "array", + "minItems": 0, + "maxItems": 15, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 0 + } + }, + "additionalProperties": false, + "position": 3 + } + }, + "required": [ + "submittedCharterId", + "members" + ], + "additionalProperties": false + }, + "resignation": { + "type": "object", + "documentsMutable": true, + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter" + } + }, + "memberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + } + }, + "witnesses": { + "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": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + } + }, + "position": 2 + }, + "plainCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 3 + }, + "plainMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 4, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "plainCharterId": "$id" + }, + "inList": "members" + } + }, + "charterTitle": { + "type": "string", + "maxLength": 63, + "position": 5 + }, + "titledMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 6, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id", + "charterTitle": "title" + }, + "inList": "members" + } + }, + "meta": { + "type": "object", + "properties": { + "charterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter" + } + } + }, + "additionalProperties": false, + "position": 7 + }, + "metaMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 8, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "meta.charterId": "$id" + }, + "inList": "seats.members" + } + }, + "memberOrCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 9, + "refersTo": { + "anyOf": [ + { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + }, + { + "type": "permanentDocument", + "documentType": "electedCharter" + } + ] + } + }, + "reason": { + "type": "string", + "maxLength": 63, + "position": 10 + }, + "echoCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 11 + }, + "echoedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 12, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter", + "propertyAgreement": { + "echoCharterId": "$id" + } + } + } + }, + "additionalProperties": false + }, + "appeal": { + "type": "object", + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter" + } + }, + "appellantId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + } + } + }, + "required": [ + "electedCharterId" + ], + "additionalProperties": false + }, + "seatedNote": { + "type": "object", + "ownerRefersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + }, + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "text": { + "type": "string", + "maxLength": 63, + "position": 1 + } + }, + "required": [ + "electedCharterId" + ], + "additionalProperties": false + } + } +} 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 new file mode 100644 index 00000000000..85e7ecd0ee5 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-list-element.json @@ -0,0 +1,288 @@ +{ + "$formatVersion": "1", + "id": "9AJn2N8f1Sz9wNA9ShzGWBTwALcULn7QhGMBoesgg4zG", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "electedCharter": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "members": { + "type": "array", + "minItems": 0, + "maxItems": 15, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 1 + }, + "title": { + "type": "string", + "maxLength": 63, + "position": 2 + }, + "seats": { + "type": "object", + "properties": { + "members": { + "type": "array", + "minItems": 0, + "maxItems": 15, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "position": 0 + } + }, + "additionalProperties": false, + "position": 3 + } + }, + "required": [ + "submittedCharterId", + "members" + ], + "additionalProperties": false + }, + "resignation": { + "type": "object", + "documentsMutable": true, + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter" + } + }, + "memberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + } + }, + "witnesses": { + "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": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + } + }, + "position": 2 + }, + "plainCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 3 + }, + "plainMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 4, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "plainCharterId": "$id" + }, + "inList": "members" + } + }, + "charterTitle": { + "type": "string", + "maxLength": 63, + "position": 5 + }, + "titledMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 6, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id", + "charterTitle": "title" + }, + "inList": "members" + } + }, + "meta": { + "type": "object", + "properties": { + "charterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter" + } + } + }, + "additionalProperties": false, + "position": 7 + }, + "metaMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 8, + "refersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "meta.charterId": "$id" + }, + "inList": "seats.members" + } + }, + "memberOrCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 9, + "refersTo": { + "anyOf": [ + { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + }, + { + "type": "permanentDocument", + "documentType": "electedCharter" + } + ] + } + }, + "reason": { + "type": "string", + "maxLength": 63, + "position": 10 + }, + "echoCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 11 + }, + "echoedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 12, + "refersTo": { + "type": "permanentDocument", + "documentType": "electedCharter", + "propertyAgreement": { + "echoCharterId": "$id" + } + } + } + }, + "additionalProperties": false + }, + "seatedNote": { + "type": "object", + "ownerRefersTo": { + "type": "listElement", + "documentType": "electedCharter", + "propertyAgreement": { + "electedCharterId": "$id" + }, + "inList": "members" + }, + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "text": { + "type": "string", + "maxLength": 63, + "position": 1 + } + }, + "required": [ + "electedCharterId" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 6db487a73b2..9e3c8d4b53e 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -758,7 +758,6 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// incompatible schema change on update. Chained queries and composite /// by-id joins refuse a lookup reference as a join property, and /// preallocated indexes are never bound through one. -/// /// 33. **Reference expressions (`anyOf` / `allOf`)**: a `refersTo`, on an /// identifier property or on the elements of a typed array (item 31), may /// be `{ "anyOf": [operand, ...] }`, holding if at least one operand @@ -800,7 +799,6 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// changed. A changed expression is an incompatible schema change on /// update. Chained queries and composite by-id joins refuse an expression /// join property, and preallocated indexes are never bound through one. -/// /// 34. **References on the document's writer or creator (`ownerRefersTo`, /// `creatorRefersTo`)**: a document type may declare one `refersTo` /// declaration of its own, under the doctype-level `ownerRefersTo` @@ -851,6 +849,49 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// writer on a create and the stored creator on a replace under the same /// rules, never on a transfer or a purchase, and frozen on update the same /// way. +/// 35. **References to an element of a list of a referenced document**: a +/// new `refersTo` target, `listElement` (meta-schema v3, +/// `apply_property_reference` 0, parsed to the appended +/// `DocumentPropertyReferenceTarget::ListElement`, so every earlier +/// variant keeps its encoding), on an identifier property, on the +/// elements of a typed array (item 31), as a leaf of a reference +/// expression (item 33), or on the writer or the creator (item 34, whose +/// identity then must be listed; a third target those two take next to +/// `identity` and a `permanentDocument` lookup, since an identity id can +/// be an element of a list of identities): the value must be an element +/// of the typed array +/// of identifiers `inList` held by one document of `documentType`, the +/// document whose `$id` the `propertyAgreement` pair with `$id` on the +/// referenced side reads from an identifier property of the referring +/// type (stored, optional or not; generation 3 of the parser checks it +/// under full validation). `$id` joins `$ownerId` and `$creatorId` as a +/// referenced-side agreement name for every document reference. In every +/// other respect a list element is a document reference: `contractId`, +/// `documentType` and its other agreement pairs are checked at +/// registration as a `permanentDocument`'s are (the type must forbid +/// deletion), and the list must be a stored typed array of identifiers +/// fixed once a document is written (the type is immutable or lists the +/// list's top-level property under `immutable`). +/// `create_document_types_from_document_schemas` 1, edited in place like +/// for items 29 and 32 (inert before this version, where no parsed +/// reference is a list element), checks a list in the same contract +/// under full validation, and the contract reference validation checks +/// one in another contract, refusing it with +/// `ReferencedDocumentListInvalidError` (40138). The document reference +/// validation (generation 0, reached only from this version) fetches the +/// list's document by the `$id` pair's value, once per write and shared +/// with any other reference of the same document (every by-id document +/// fetch of one write is now memoized), checks the other pairs against it, +/// and refuses a value the list does not hold, or one set while the `$id` +/// property is not, with `ReferencedEntityNotFoundError` (40120, the list +/// element declaration as its entity type, an element named by its list +/// path); the list is collected once, each value a set lookup. A replace +/// checks it again when its value or a referring side of any pair +/// changed, as every agreement is. Each value counts against +/// `SystemLimits::max_references_per_document` like every other +/// reference. A changed `listElement` is an incompatible schema change on +/// update. +/// /// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 88453f8962f..848ef4a66e4 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -161,6 +161,7 @@ use dpp::consensus::basic::state_transition::{StateTransitionNotActiveError, Tra use dpp::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; use dpp::consensus::state::document::referenced_identity_key_requirement_not_met_error::ReferencedIdentityKeyRequirementNotMetError; use dpp::consensus::state::document::referenced_document_lookup_invalid_error::ReferencedDocumentLookupInvalidError; +use dpp::consensus::state::document::referenced_document_list_invalid_error::ReferencedDocumentListInvalidError; use dpp::consensus::state::voting::masternode_incorrect_voter_identity_id_error::MasternodeIncorrectVoterIdentityIdError; use dpp::consensus::state::voting::masternode_incorrect_voting_address_error::MasternodeIncorrectVotingAddressError; use dpp::consensus::state::voting::masternode_not_found_error::MasternodeNotFoundError; @@ -695,6 +696,9 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::ReferencedDocumentLookupInvalidError(e) => { generic_consensus_error!(ReferencedDocumentLookupInvalidError, e).into() } + StateError::ReferencedDocumentListInvalidError(e) => { + generic_consensus_error!(ReferencedDocumentListInvalidError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index ba63519e79b..9a89473646c 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -28,7 +28,9 @@ use wasm_bindgen::prelude::wasm_bindgen; #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum DocumentReferenceErrorCodeWasm { /// The referenced identity, contract, token or document (permanent or - /// deletable) does not exist. + /// deletable) does not exist, or a `listElement` value is not an element + /// of the list it must be in (or was set while the property finding the + /// list's document was not). ReferencedEntityNotFound = 40120, /// A `permanentDocument` or `deletableDocument` reference names a /// document type the referenced contract does not define, or the @@ -68,11 +70,18 @@ pub enum DocumentReferenceErrorCodeWasm { /// holds a different kind of value than its index property. (A lookup into /// the declaring contract is refused by the contract parse instead.) ReferencedDocumentLookupInvalid = 40137, + /// A `refersTo: listElement` whose list lives in a document type of + /// another contract cannot be served by it, reported at contract + /// registration: that type's documents can be deleted, the list is not a + /// stored typed array of identifiers of it, or a replace could change the + /// list. (A list in the declaring contract is refused by the contract parse + /// instead.) + ReferencedDocumentListInvalid = 40138, } impl DocumentReferenceErrorCodeWasm { /// The reference-validation error a code names, or `None` when the code - /// is not in the 40120-40125 range, 40131, 40135, 40136 or 40137. + /// is not in the 40120-40125 range, 40131 or 40135-40138. fn from_code(code: u32) -> Option { match code { 40120 => Some(Self::ReferencedEntityNotFound), @@ -85,6 +94,7 @@ impl DocumentReferenceErrorCodeWasm { 40135 => Some(Self::ReferencedContractRequirementNotMet), 40136 => Some(Self::ReferencedIdentityKeyRequirementNotMet), 40137 => Some(Self::ReferencedDocumentLookupInvalid), + 40138 => Some(Self::ReferencedDocumentListInvalid), _ => None, } } @@ -252,6 +262,7 @@ impl_wasm_type_info!(ConsensusErrorWasm, ConsensusError); mod tests { use super::*; use dpp::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; + use dpp::consensus::state::document::referenced_document_list_invalid_error::ReferencedDocumentListInvalidError; use dpp::consensus::state::document::referenced_document_lookup_invalid_error::ReferencedDocumentLookupInvalidError; use dpp::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; use dpp::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; @@ -421,6 +432,17 @@ mod tests { .into(), DocumentReferenceErrorCodeWasm::ReferencedDocumentLookupInvalid, ), + ( + StateError::ReferencedDocumentListInvalidError( + ReferencedDocumentListInvalidError::new( + "resignation.memberId".to_string(), + "members".to_string(), + "is not a typed array of identifiers".to_string(), + ), + ) + .into(), + DocumentReferenceErrorCodeWasm::ReferencedDocumentListInvalid, + ), ( StateError::ReferencedDocumentTypeNotFoundError( ReferencedDocumentTypeNotFoundError::new( @@ -518,7 +540,7 @@ mod tests { #[test] fn codes_outside_the_reference_range_are_not_claimed() { - for code in [40119, 40126, 40138, 0, 40200] { + for code in [40119, 40126, 40139, 0, 40200] { assert_eq!(DocumentReferenceErrorCodeWasm::from_code(code), None); } } 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 0ff450e61bd..d59e5f973c0 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -19,6 +19,7 @@ use dpp::data_contract::document_type::{ }; use dpp::prelude::Identifier; use js_sys::{Array, Object, Reflect}; +use std::collections::BTreeMap; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::wasm_bindgen; @@ -194,6 +195,37 @@ export type DocumentPropertyReferenceTarget = */ propertyAgreement?: Record; } + | { + /** + * An element of a list: the value (on a typed array, every element) + * must be one of the identifiers the typed array `inList` holds on the + * `documentType` document that `propertyAgreement`'s `$id` pair names. + * A document reference like `permanentDocument` (same `contractId`, + * `documentType` and agreement rules, the type forbids deletion), + * except that the value is not the document's id: consensus fetches + * the document whose id the `$id` pair's property holds, checks the + * other pairs against it, and refuses a value its list does not hold, + * or one set while that property is not (code 40120). The list's + * document can never be deleted and the list never changes, so a value + * accepted once stays an element. To resolve it yourself, fetch the + * document by that id and look in `inList`. + */ + type: 'listElement'; + /** The contract the document type holding the list lives in. Always present, resolved as for `permanentDocument`. */ + contractId: Identifier; + /** Name of the document type holding the list; it must forbid deletion. */ + documentType: string; + /** + * The agreement pairs, always present: exactly one has `'$id'` on the + * referenced side, its referring side the dotted path of the identifier + * property holding the id of the document the list is read from; any + * other pair is checked against that document as for + * `permanentDocument`. + */ + propertyAgreement: Record; + /** Dotted path of the typed array of identifiers on `documentType`. */ + inList: string; + } | DocumentPropertyReferenceExpression; /** @@ -354,6 +386,27 @@ fn set_key_requirements_field( set_field(object, "keyRequirements", &fields, path) } +/// The `propertyAgreement` field of a document reference: `{ referring +/// property: referenced property }`, a consensus-enforced equality at write +/// time (for a `listElement`, the `$id` pair names the document). Absent, +/// not `{}`-valued, when the declaration carries none, matching the schema's +/// own omission and the absent-field convention of the other optional target +/// fields. +fn set_property_agreement_field( + object: &Object, + property_agreement: &BTreeMap, + path: &str, +) -> WasmDppResult<()> { + if property_agreement.is_empty() { + return Ok(()); + } + let agreement = Object::new(); + for (referring, referenced) in property_agreement { + set_field(&agreement, referring, &JsValue::from_str(referenced), path)?; + } + set_field(object, "propertyAgreement", &agreement, path) +} + /// Build the flat, internally-tagged JS object for one declaration. fn reference_to_js( path: &str, @@ -416,6 +469,7 @@ fn set_reference_target_fields( | DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } => "permanentDocument", DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => "identityPublicKey", DocumentPropertyReferenceTarget::DeletableDocument { .. } => "deletableDocument", + DocumentPropertyReferenceTarget::ListElement(_) => "listElement", DocumentPropertyReferenceTarget::AnyOf(_) | DocumentPropertyReferenceTarget::AllOf(_) => { return Err(WasmDppError::generic(format!( "the reference expression declared at '{path}' has no single target kind" @@ -503,18 +557,7 @@ fn set_reference_target_fields( &JsValue::from_str(document_type_name), path, )?; - // `propertyAgreement` binds a referring property to a property - // of the referenced document (consensus-enforced equality at - // write time). Absent — not `{}`-valued — when the declaration - // carries none, matching the schema's own omission and the - // absent-field convention of the other optional target fields. - if !property_agreement.is_empty() { - let agreement = Object::new(); - for (referring, referenced) in property_agreement { - set_field(&agreement, referring, &JsValue::from_str(referenced), path)?; - } - set_field(object, "propertyAgreement", &agreement, path)?; - } + set_property_agreement_field(object, property_agreement, path)?; // Present only on a lookup reference, absent when the value is // the referenced document's id, as the schema omits it; the // sources keep their schema spelling. @@ -540,6 +583,31 @@ fn set_reference_target_fields( set_field(object, "lookup", &lookup_object, path)?; } } + // A document reference found by its `$id` agreement pair, whose list + // the value must be in: the same fields as `permanentDocument`, plus + // `inList` + DocumentPropertyReferenceTarget::ListElement(reference) => { + let effective = reference.contract_id.unwrap_or(declaring_contract_id); + set_field( + object, + "contractId", + &JsValue::from(IdentifierWasm::from(effective)), + path, + )?; + set_field( + object, + "documentType", + &JsValue::from_str(&reference.document_type_name), + path, + )?; + set_property_agreement_field(object, &reference.property_agreement, path)?; + set_field( + object, + "inList", + &JsValue::from_str(&reference.in_list), + path, + )?; + } DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property, key_requirements, diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index 17a0d90e566..6d15eca3f81 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -150,6 +150,7 @@ type Reference = { identityProperty?: string; propertyAgreement?: Record; lookup?: { index: string; keys: Record }; + inList?: string; }; /** @@ -454,6 +455,80 @@ describe('DataContract — refersTo declarations (v14)', () => { }); }); + describe('listElement', () => { + /** + * An `electedCharter` that can be neither deleted nor replaced holds its + * `members`, and a `resignation` names its charter (`electedCharterId`) + * and a `memberId` that must be one of that charter's members. + */ + const listElementSchemas = { + electedCharter: { + type: 'object', + canBeDeleted: false, + // The list must be fixed once the charter is written + documentsMutable: false, + properties: { + members: { + type: 'array', + maxItems: 15, + items: { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', + }, + position: 0, + }, + }, + required: ['members'], + additionalProperties: false, + }, + resignation: { + type: 'object', + properties: { + electedCharterId: plainIdentifier, + memberId: identifierProperty(1, { + type: 'listElement', + documentType: 'electedCharter', + propertyAgreement: { electedCharterId: '$id' }, + inList: 'members', + }), + }, + required: ['electedCharterId'], + additionalProperties: false, + }, + }; + const buildListElementContract = (schemas: object) => new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(14), + }); + + it('should carry the list and the agreement pair naming its document', () => { + const contract = buildListElementContract(listElementSchemas); + const member = (contract.documentTypeReferences('resignation') as Reference[]).find( + (reference) => reference.path === 'memberId', + )!; + + expect(member.type).to.equal('listElement'); + expect(member.contractId!.toBase58()).to.equal(contract.id.toBase58()); + expect(member.documentType).to.equal('electedCharter'); + expect(member.propertyAgreement).to.deep.equal({ electedCharterId: '$id' }); + expect(member.inList).to.equal('members'); + }); + + it('should refuse a list the document holding it can replace', () => { + const replaceable = structuredClone(listElementSchemas); + replaceable.electedCharter.documentsMutable = true; + + expect(() => buildListElementContract(replaceable)).to.throw(/can be changed by a replace/); + }); + }); + describe('reference expressions', () => { /** * The moderation charter's resignation: the member is either the owner of @@ -784,6 +859,7 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(wasm.DocumentReferenceErrorCode.ReferencedContractRequirementNotMet).to.equal(40135); expect(wasm.DocumentReferenceErrorCode.ReferencedIdentityKeyRequirementNotMet).to.equal(40136); expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentLookupInvalid).to.equal(40137); + expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentListInvalid).to.equal(40138); }); it('should resolve a code back to its name', () => {