diff --git a/book/src/contract-keywords.md b/book/src/contract-keywords.md index 8e5398bab84..ab68a8577f8 100644 --- a/book/src/contract-keywords.md +++ b/book/src/contract-keywords.md @@ -195,8 +195,8 @@ A typed array's element (`items`) takes `type` (the `identifier` and `bytes` sho | `findBy` | 1 to 10 entries: referenced property → `"."`, `"$ownerId"`, a path or a function | Finds the document through the unique index of `documentType` over exactly these properties, in any order; `"."` is the value itself. Without it the value is the document's `$id`. | 14 | [findBy](contract-keywords/refers-to-lookup.md) · [internals](data-model/documents.md#found-by-a-unique-index-findby) | | `findBy.` | `{ "function": "sys.hash.sha256d", "params" }` | A function entry: the referenced property holds the hash of `params` (paths, `{ "const": text }`, `"."` for a value without a path), which fills that part of the key, finding a commitment made earlier. At most one. | 14 | [Commit and reveal](contract-keywords/refers-to-lookup.md#commit-and-reveal) · [internals](data-model/documents.md#commit-and-reveal-a-findby-function) | | `where` | 1 to 10 entries `{ "": "" }` | Checked on the document found: each referenced property (or `$ownerId`, `$creatorId`, `$id`) must equal the referring property. A value of `"$ownerId"`, the writer, makes a write gate. Never finds the document. | 14 | [where](contract-keywords/refers-to.md#where) | -| `minimumAgeBlocks` | 1 to 4294967295 | Beside a `findBy` function: the commitment was created at least this many blocks before the create. | 14 | [Commit and reveal](contract-keywords/refers-to-lookup.md#commit-and-reveal) | -| `consume` | `true` | Beside a `findBy` function, on a `deletableDocument` with the `where` entry `"$ownerId": "$ownerId"`, into a type with `canBeDeleted` `true` or `"onlyWhenConsumed"` and no `deleteConstraints`: the create deletes the writer's commitment. | 14 | [Commit and reveal](contract-keywords/refers-to-lookup.md#commit-and-reveal) | +| `minimumAgeBlocks` | 1 to 4294967295 | Beside `findBy`, on a reference judged on the create alone (a `findBy` function, or a type whose documents are never replaced): the document found was created at least this many blocks before the create. | 14 | [Commit and reveal](contract-keywords/refers-to-lookup.md#commit-and-reveal) | +| `consume` | `true` | Beside `findBy`, on a reference judged on the create alone (a `findBy` function, or a type whose documents are never replaced), on a `deletableDocument` with the `where` entry `"$ownerId": "$ownerId"`, into a type with `canBeDeleted` `true` or `"onlyWhenConsumed"` and no `deleteConstraints`: the create deletes the writer's commitment. | 14 | [Commit and reveal](contract-keywords/refers-to-lookup.md#commit-and-reveal) | | `inList` | typed array path | On a `permanentDocument` whose `findBy` is `{ "$id": }`: the list on that document the value must be in. | 14 | [List Elements](contract-keywords/refers-to-list-element.md) · [internals](data-model/documents.md#an-element-of-a-list-inlist) | | `keyIdProperty` | integer property path | On an identity property: the property holding the key id. | 14 | [keyIdProperty and identityProperty](contract-keywords/refers-to.md#keyidproperty-and-identityproperty) | | `identityProperty` | `"$ownerId"`, `"$creatorId"` or a path | On a key id property: whose key it is. | 14 | [keyIdProperty and identityProperty](contract-keywords/refers-to.md#keyidproperty-and-identityproperty) | diff --git a/book/src/contract-keywords/refers-to-lookup.md b/book/src/contract-keywords/refers-to-lookup.md index df5441b5904..19cdbdc6acb 100644 --- a/book/src/contract-keywords/refers-to-lookup.md +++ b/book/src/contract-keywords/refers-to-lookup.md @@ -158,7 +158,7 @@ Hashing the writer matters as much as keying by it. A copy of a pending preorder - **Carrier.** A string or byte array property may carry a `refersTo` only this way, and keeps its type. The value carrying the reference fills the key exactly once, as a `"."` entry or a param; a param names a property by its path, never as `"."`. In `ownerRefersTo` and `creatorRefersTo` the value may be left out beside a function. - **Judged once.** A function's key is checked when the document is created, never on a replace, so every stored value it reads must be fixed once written, the carrier included (listed under `immutable` on a mutable type, and required when it is a `deletableDocument` reference by id, which a replace may otherwise clear once its document is deleted). The `where` entries are judged with it, so each referring property they name must be fixed once written too, or transient. On a type whose documents can be replaced it may not be an operand of an `anyOf`, which would then hold on every replace. Params may be transient: they are read from the create. The hash is billed as the double SHA-256 it is, by the blocks it hashes, beside the document fetch. -`minimumAgeBlocks` and `consume` sit on the `refersTo`, beside `findBy`: they describe the document `findBy` finds, and need a function in it. +`minimumAgeBlocks` and `consume` sit on the `refersTo`, beside `findBy`: they describe the document `findBy` finds when the referring document is created, so the reference must be judged on the create alone. A function in `findBy` makes it so, and so does a referring type whose documents are never replaced (`documentsMutable: false`): a replace is the one write that checks a reference again. Any other `findBy` declaring either is refused at registration (10231). - **`minimumAgeBlocks`**: the commitment's `$createdAtBlockHeight` is at least that many blocks below the create's height. The commitment type must list `$createdAtBlockHeight` in `required`. - **`consume: true`**: the create deletes the commitment, its storage refunded to its owner. Only on a `deletableDocument` reference that makes the writer the commitment's owner, by the `where` entry `"$ownerId": "$ownerId"` or the `findBy` entry `"$ownerId": "$ownerId"`, into a type of the same contract whose owner may delete its documents (`canBeDeleted: true`) or whose documents only a consume deletes (`canBeDeleted: "onlyWhenConsumed"`, see [Deletion](deletion.md#canbedeleted)), that declares no delete token cost or delete action fee and no [`deleteConstraints`](deletion.md#deleteconstraints) (no delete transition charges or judges them), and that requires no stricter signature security level than the revealing type. A contract-bound key signing the create must be allowed to act on the consumed type too (`ContractBoundedKeyOutOfBoundsError`, 20014). @@ -173,7 +173,7 @@ A create missing a param, or with a value holding its separator, is refused befo - The reference's own value is read exactly once, as a `"."` entry or as a param of a [function](#commit-and-reveal). Without it every value would find the same document. Only `ownerRefersTo` or `creatorRefersTo` may leave it out, beside a function. - Every other source is `"$ownerId"`, a property path or a function. No other `$` name is accepted, and a path may not name the reference property itself: write `"."` for that. - A property an entry reads must exist on the referring type, be required (and so must every object around it), not be `transient` or inside a transient object, and hold a single value, not an object or an array. `findBy` never runs with a missing key part, and a reader can assemble the same key from the stored document. A function's params follow [their own rules](#commit-and-reveal): they may be transient or optional, since they are read from the create alone. -- A `"$ownerId"` source needs a referring type whose documents can be neither transferred nor traded, unless the `findBy` holds a [function](#commit-and-reveal): a transfer or a purchase would move the writer part of the key without a write, but a key with a function is judged on the create alone. +- A `"$ownerId"` source needs a referring type whose documents can be neither transferred nor traded, unless the reference is judged on the create alone (a [function](#commit-and-reveal) in `findBy`, or a referring type whose documents are never replaced): a transfer or a purchase would move the writer part of the key without a write, but such a reference is never checked again. - A `deletableDocument` found by `findBy` may not sit under an `immutable` property, alone or as an operand of an expression: every replace checks it again, so once its document is deleted the property would have to change. One whose key a [function](#commit-and-reveal) computes is the exception: it is checked on the create alone, and its carrier must be fixed once written. - `{ "$id": ... }` is allowed only with [`inList`](refers-to-list-element.md), as the only entry. diff --git a/book/src/contract-keywords/refers-to.md b/book/src/contract-keywords/refers-to.md index bec72854691..a738d251bdb 100644 --- a/book/src/contract-keywords/refers-to.md +++ b/book/src/contract-keywords/refers-to.md @@ -278,7 +278,7 @@ A replace checks a reference again only when its outcome could have changed: | any document reference with a `where` entry whose value is `"$ownerId"` | every replace | | `permanentDocument` with `findBy`, `inList` included | also when a property `findBy` reads changed | | `deletableDocument`, by id or with `findBy` | every replace | -| a document reference with a `findBy` function | never: the whole declaration, its `where` included, is judged on the create alone (see [Commit and reveal](refers-to-lookup.md#commit-and-reveal)) | +| a document reference with a `findBy` function | never: the whole declaration, its `where` included, is judged on the create alone (see [Commit and reveal](refers-to-lookup.md#commit-and-reveal)); a type whose documents are never replaced has no replace at all | | `identityPublicKey` with `keyIdProperty` | the identity or the key id changed | | key id with `identityProperty: "$ownerId"` | every replace | | key id with `identityProperty: "$creatorId"` | the key id changed | diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index d2625fcd091..25bfc392b2b 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -352,7 +352,7 @@ A `deletableDocument` reference may carry `findBy` too, and then promises less t What is checked when the contract enters the chain, on registration and on update: - `findBy` is only allowed on `permanentDocument` and `deletableDocument` references, and `where` on those and `moderatedDocument` ones (meta-schema v3 and the parser, `apply_property_reference` 0), on the property or on the `items` of a typed array. -- Each property an entry reads must exist on the referring type, be required (and so must every object around it), not be transient nor sit inside a transient object, and hold a single value, so `findBy` never runs with a missing key part and a reader can assemble the same key from the stored document. An entry that reads `"$ownerId"` needs a referring type whose documents can be neither transferred nor traded: the reference is judged when the document is written, and a transfer or purchase would move the writer part of its key without a write. A `findBy` holding a function is the exception: its lookup is judged when the document is created only (see [Commit and reveal](#commit-and-reveal-a-findby-function)), so a later transfer moves no key it reads. These are properties of the referring type alone and are checked on every parse (generation 3). +- Each property an entry reads must exist on the referring type, be required (and so must every object around it), not be transient nor sit inside a transient object, and hold a single value, so `findBy` never runs with a missing key part and a reader can assemble the same key from the stored document. An entry that reads `"$ownerId"` needs a referring type whose documents can be neither transferred nor traded: the reference is judged when the document is written, and a transfer or purchase would move the writer part of its key without a write. A reference judged on the create alone is the exception (a function in `findBy`, see [Commit and reveal](#commit-and-reveal-a-findby-function), or a referring type whose documents are never replaced): no later write checks it again, so a transfer moves no key it reads. These are properties of the referring type alone and are checked on every parse (generation 3). - The entries must name exactly the properties of a unique index of the referenced type, and nothing else. One naming no such index is refused with the unique indexes the type has (`"joinRequest" has no unique index over exactly (submittedCharterId): findBy must name every property of one of its unique indexes and nothing else (bySubmittedCharter (submittedCharterId, $ownerId))`), and one naming exactly the properties of an index that is not unique is refused too, since it could find several documents (`index "byOwner" of "joinRequest" over ($ownerId) is not unique: findBy must find at most one document`). The index may not bucket its first property with `timeRange` or `integerRange`, and the referenced type may not be `indexOnly`. Each source must hold the same kind of value as the property it fills (the rule of `where`, `DocumentPropertyType::value_kind`). - The key must stay with the document it found, or the reference could dangle without the document being deleted: every schema property of the index must be fixed once written (the referenced type is immutable, or the property, or the top-level object holding it, is listed under `immutable` and is no optional `deletableDocument` reference by id, which a replace may clear once its document is deleted), `$ownerId` is only a key part on a type whose documents can be neither transferred nor traded, and the update and transfer times are refused where a replace, transfer or purchase moves them. `$id`, `$creatorId` and the creation times are always fixed. - `{ "$id": }` is admitted only with `inList`, as its only entry (see [An element of a list](#an-element-of-a-list-inlist)). @@ -505,7 +505,7 @@ A document type whose documents can be transferred or traded declares `creatorRe } ``` -- It takes the same targets but a `deletableDocument` (the creator never changes, and a document a transfer handed on could not be replaced once the one `findBy` found is deleted), except one found by a `findBy` function, which is judged on the create alone (see [Commit and reveal](#commit-and-reveal-a-findby-function)), with `"."` the creator in `findBy`, 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"` source in its `findBy` is refused, as in a property's `findBy` on such a type, since the owner moves, unless the `findBy` holds a function, whose lookup is judged on the create alone. +- It takes the same targets but a `deletableDocument` (the creator never changes, and a document a transfer handed on could not be replaced once the one `findBy` found is deleted), except one found by a `findBy` function, which is judged on the create alone (see [Commit and reveal](#commit-and-reveal-a-findby-function)), with `"."` the creator in `findBy`, 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"` source in its `findBy` is refused, as in a property's `findBy` on such a type, since the owner moves, unless the reference is judged on the create alone. - 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. ### Commit and reveal (a `findBy` function) @@ -571,15 +571,15 @@ The preimage is the bytes of the params joined in order with nothing between the The document such a key finds is judged once, when the document is created: - A param may be transient (the DPNS salt is), since it is read from the create transition, and optional, since a create missing one is refused. Every stored value `findBy` reads, params and other entries alike, must be fixed once written (the type is immutable or lists the property under `immutable`), and so must a stored property carrying the reference, set when the document is created (not listed under `immutableAllowSetting`). Such a `deletableDocument` found by `findBy` may sit on an `immutable` property, which one re-validated on every replace may not. A replace leaves the reference alone: nothing it reads can have changed, and the commitment may be gone. The declaration's `where` entries are judged with it, on the create alone, so each referring property they name must be fixed once written the same way, or transient. On a document type whose documents can be replaced, such a reference may not be an operand of an `anyOf`: judged on the create alone, it would hold on every replace, whichever operand held on the create. -- `creatorRefersTo` takes a `deletableDocument` target only through a function. -- `findBy` holds at most one function. The property it fills must be a byte array of exactly the hash's size, 32 bytes for `sys.hash.sha256d`. Beside `findBy`, on the `refersTo`, the reference may then declare what it asks of the document `findBy` finds (both are refused without a function in `findBy`): +- `creatorRefersTo` takes a `deletableDocument` target only when it is judged on the create alone: through a function, or on a type whose documents are never replaced. +- `findBy` holds at most one function. The property it fills must be a byte array of exactly the hash's size, 32 bytes for `sys.hash.sha256d`. Beside `findBy`, on the `refersTo`, the reference may then declare what it asks of the document `findBy` finds. Both need a reference judged on the create alone, which a function makes it; so does a referring type whose documents are never replaced (`documentsMutable: false`), with or without a function, since a replace is the one write that checks a reference again. Any other `findBy` declaring either is refused (10231): - `minimumAgeBlocks`: the found document's `$createdAtBlockHeight` must be at least that many blocks below the height of the create. `1` means an earlier block, so a commitment and its reveal cannot share a block. The referenced type must list `$createdAtBlockHeight` in `required`; a document recording no creation height never meets it. - `consume: true`: the create deletes the found document in the same state transition, its storage refunded to its owner as a delete by that owner would be. Only on a `deletableDocument` reference whose `where` or `findBy` holds the entry `"$ownerId": "$ownerId"`, into a document type of the declaring contract whose owner may delete its documents (`canBeDeleted: true`), or whose documents only a consume deletes (`canBeDeleted: "onlyWhenConsumed"`, `DocumentTypeV2Getters::documents_deleted_only_when_consumed`). No delete transition is charged for the consumed document, so its type may declare no delete token cost and no delete action fee, and the key signing the create deletes it, so its type may not require a stricter signature security level than the declaring type. - Whose commitment it is, is the entry `"$ownerId": "$ownerId"` beside the function, in `where` or in `findBy`: the writer must own the document found. Without it anyone who learns a preimage may reveal it. In `findBy` it needs a unique index over the owner and the hash, which also keeps one identity's commitments from colliding with another's; it may sit there on a referring type whose documents change owner, since the key is judged on the create alone. A commitment that should be revealed by its maker only also hashes `"$ownerId"`: a copy stored under another owner then commits to the maker, not the copier. What is refused, and where: -- The shape, the params and the rules above: the contract parse on registration and update (meta-schema v3 `findByFunction`, `apply_property_reference` 0, `DocumentReferenceLookup::referring_side_error` and `referenced_side_error`), and for a `findBy` into another contract the registration state validation (`ReferencedDocumentLookupInvalidError`, 40137), which also refuses `consume` there. A function in `where` is refused (it finds the document, so it belongs in `findBy`), as are a second function in `findBy` and `minimumAgeBlocks` or `consume` without one. Any change to a function, its `minimumAgeBlocks` or its `consume` is an incompatible schema change on update, like the rest of a `refersTo`. +- The shape, the params and the rules above: the contract parse on registration and update (meta-schema v3 `findByFunction`, `apply_property_reference` 0, `DocumentReferenceLookup::referring_side_error` and `referenced_side_error`), and for a `findBy` into another contract the registration state validation (`ReferencedDocumentLookupInvalidError`, 40137), which also refuses `consume` there. A function in `where` is refused (it finds the document, so it belongs in `findBy`), as are a second function in `findBy` and `minimumAgeBlocks` or `consume` on a reference not judged on the create alone (validate_reference_lookup_sources). Any change to a function, its `minimumAgeBlocks` or its `consume` is an incompatible schema change on update, like the rest of a `refersTo`. - A create missing a param, or whose variable-length value holds its separator: document create structure validation 1, before any read (`DocumentReferencePreimageInvalidError`, 10423). A function in a leaf of a reference expression is left to the write-time check, where a key it cannot assemble finds no document, so that operand fails and the others still decide. - No document for the key: `ReferencedEntityNotFoundError` (40120), whose entity id for a string or byte array carrier is the hash. The hash is computed once and billed as the double SHA-256 it is (`ValidationOperation::DoubleSha256`, by the blocks it hashes: the padded preimage and the second pass), the index query as the document fetch it is. Another identity's commitment: `ReferencedDocumentPropertyMismatchError` (40127) when the owner entry is in `where`, and 40120 when it is in `findBy`, whose key then finds none of the writer's. A commitment younger than `minimumAgeBlocks`: `ReferencedDocumentRequirementNotMetError` (40142). All paid, from document create state validation 2. - A create signed by a contract-bound key whose bounds leave out a document type the created type may consume: batch advanced structure validation 1, as a direct delete of that type would be (`ContractBoundedKeyOutOfBoundsError`, 20014, paid). 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 f280e99f3c2..49dbce274c7 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 @@ -766,7 +766,7 @@ "additionalProperties": false }, "findBy": { - "description": "permanentDocument and deletableDocument references only: how the referenced document is found when the property's value is not its id. Each entry maps a property of the referenced document type (by its name there, system ones such as $ownerId included) to where its value comes from: \".\", the value of the property carrying the reference (on an array element, the element; the writer for ownerRefersTo, the creator for creatorRefersTo), which must be read exactly once; \"$ownerId\", the referring document's owner; a property path of the referring document type; or a function computing it (findByFunction). The entries must name exactly the properties of one unique index of the referenced document type, in any order, that carries no timeRange or integerRange on its first property and does not belong to an indexOnly type: that index finds the document, at most one. Its key must stay with the document it found (every schema property it names 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. A property an entry 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 the property it fills, so findBy never runs with a missing part; all of this is validated at contract registration. When the referring document is created or replaced, consensus queries that index for the key, billed as a document fetch, and refuses the write with ReferencedEntityNotFoundError (40120) if it finds no document; where is checked against the document found. A replace re-validates a permanentDocument found by findBy when a property an entry reads changed, and on every replace when an entry reads $ownerId. On a deletableDocument reference findBy says less: once the document it found is deleted the same key may find another filed later, so the reference means a document with this key exists now; every replace re-validates it, as it does a deletableDocument reference by id, and an immutable property may not hold one, except one found by a function, judged when the document is created only, as below. Without findBy the value is the referenced document's $id; { \"$id\": } is the one entry naming $id, with inList: it finds the document holding the list by the id the property holds. A function entry, { referenced property: { \"function\": \"sys.hash.sha256d\", \"params\": [...] } }, fills a byte array property of exactly 32 bytes with the hash of the params' bytes joined in order, for a commit and reveal: the document such a key finds is a commitment made earlier, and the reference is checked when the referring document is created only, never on a replace: every stored property it reads (the function's params, the other entries, the property carrying the reference, the where values) must be fixed once written: a property carrying it or read by it is set when the document is created and never changes, so one listed under immutable with a condition is refused. A param may be transient, being read from the create, and optional, a create missing a param being refused (DocumentReferencePreimageInvalidError, 10423), as is one whose param sits behind a key repeated in an object on the way. So the joined bytes split into the params one way only, every variable-length param (a string, or a byte array without a fixed size) with another variable-length param anywhere after it must be followed directly by a one-byte const, its separator, which its value may not hold (refused the same way). At most one entry is a function; the value carrying the reference is read exactly once, as \".\" or a param of the function, which names a single property carrying the reference by its path and uses \".\" only for a value without one (each element of a typed array, the writer, the creator), except beside a function of an ownerRefersTo or creatorRefersTo, which may leave it out. A string or byte array property may carry a refersTo only this way, its value read by the function: it is not an id. Beside a function the refersTo may declare minimumAgeBlocks and consume. A where entry { \"$ownerId\": \"$ownerId\" } demands that the commitment be the writer's own (ReferencedDocumentPropertyMismatchError, 40127, otherwise), and a findBy entry { \"$ownerId\": \"$ownerId\" } finds only the writer's own (ReferencedEntityNotFoundError, 40120, otherwise); beside a function that findBy entry may read the writer on a type whose documents change owner, its key being judged when the document is created only. Without either, anyone who learns the params may reveal them. Available from protocol version 14.", + "description": "permanentDocument and deletableDocument references only: how the referenced document is found when the property's value is not its id. Each entry maps a property of the referenced document type (by its name there, system ones such as $ownerId included) to where its value comes from: \".\", the value of the property carrying the reference (on an array element, the element; the writer for ownerRefersTo, the creator for creatorRefersTo), which must be read exactly once; \"$ownerId\", the referring document's owner; a property path of the referring document type; or a function computing it (findByFunction). The entries must name exactly the properties of one unique index of the referenced document type, in any order, that carries no timeRange or integerRange on its first property and does not belong to an indexOnly type: that index finds the document, at most one. Its key must stay with the document it found (every schema property it names 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. A property an entry 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 the property it fills, so findBy never runs with a missing part; all of this is validated at contract registration. When the referring document is created or replaced, consensus queries that index for the key, billed as a document fetch, and refuses the write with ReferencedEntityNotFoundError (40120) if it finds no document; where is checked against the document found. A replace re-validates a permanentDocument found by findBy when a property an entry reads changed, and on every replace when an entry reads $ownerId. On a deletableDocument reference findBy says less: once the document it found is deleted the same key may find another filed later, so the reference means a document with this key exists now; every replace re-validates it, as it does a deletableDocument reference by id, and an immutable property may not hold one, except one found by a function, judged when the document is created only, as below. Without findBy the value is the referenced document's $id; { \"$id\": } is the one entry naming $id, with inList: it finds the document holding the list by the id the property holds. A function entry, { referenced property: { \"function\": \"sys.hash.sha256d\", \"params\": [...] } }, fills a byte array property of exactly 32 bytes with the hash of the params' bytes joined in order, for a commit and reveal: the document such a key finds is a commitment made earlier, and the reference is checked when the referring document is created only, never on a replace: every stored property it reads (the function's params, the other entries, the property carrying the reference, the where values) must be fixed once written: a property carrying it or read by it is set when the document is created and never changes, so one listed under immutable with a condition is refused. A param may be transient, being read from the create, and optional, a create missing a param being refused (DocumentReferencePreimageInvalidError, 10423), as is one whose param sits behind a key repeated in an object on the way. So the joined bytes split into the params one way only, every variable-length param (a string, or a byte array without a fixed size) with another variable-length param anywhere after it must be followed directly by a one-byte const, its separator, which its value may not hold (refused the same way). At most one entry is a function; the value carrying the reference is read exactly once, as \".\" or a param of the function, which names a single property carrying the reference by its path and uses \".\" only for a value without one (each element of a typed array, the writer, the creator), except beside a function of an ownerRefersTo or creatorRefersTo, which may leave it out. A string or byte array property may carry a refersTo only this way, its value read by the function: it is not an id. Beside a function, or on a document type whose documents are never replaced (documentsMutable: false, where no replace judges the reference again), the refersTo may declare minimumAgeBlocks and consume. A where entry { \"$ownerId\": \"$ownerId\" } demands that the commitment be the writer's own (ReferencedDocumentPropertyMismatchError, 40127, otherwise), and a findBy entry { \"$ownerId\": \"$ownerId\" } finds only the writer's own (ReferencedEntityNotFoundError, 40120, otherwise); beside a function that findBy entry may read the writer on a type whose documents change owner, its key being judged when the document is created only. Without either, anyone who learns the params may reveal them. Available from protocol version 14.", "type": "object", "minProperties": 1, "maxProperties": 10, @@ -832,13 +832,13 @@ "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" }, "minimumAgeBlocks": { - "description": "Beside a findBy function, only: how many blocks before the create the document findBy finds must have been created, judged against its $createdAtBlockHeight, so that 1 keeps a commitment and the create revealing it out of the same block. The referenced document type must list $createdAtBlockHeight in its required, checked at contract registration; a found document recording no creation height never meets it. An unmet age refuses the create (ReferencedDocumentRequirementNotMetError, 40142). Available from protocol version 14.", + "description": "Beside findBy, on a reference judged when the referring document is created only (a findBy function, or a document type whose documents are never replaced, documentsMutable: false; the parser refuses any other): how many blocks before the create the document findBy finds must have been created, judged against its $createdAtBlockHeight, so that 1 keeps a commitment and the create revealing it out of the same block. The referenced document type must list $createdAtBlockHeight in its required, checked at contract registration; a found document recording no creation height never meets it. An unmet age refuses the create (ReferencedDocumentRequirementNotMetError, 40142). Available from protocol version 14.", "type": "integer", "minimum": 1, "maximum": 4294967295 }, "consume": { - "description": "Beside a findBy function, only: the create deletes the document findBy finds, in the same state transition, its storage refunded to its owner as a delete by that owner would. Only on a deletableDocument reference whose where or findBy maps \"$ownerId\" to \"$ownerId\", so a create consumes the writer's own commitment and no other, into a document type of the declaring contract whose documents their owner may delete (canBeDeleted: true) or that only a consume removes (canBeDeleted: \"onlyWhenConsumed\"), that declares no delete token cost and no delete action fee (no delete transition charges them) and requires no stricter signature security level than the declaring type, checked at contract registration. A key bound to a contract or a document type must be allowed to act on every document type the create may consume (ContractBoundedKeyOutOfBoundsError, 20014). A batch that consumes the same document twice, or also deletes, replaces, transfers, reprices or buys it, refuses the create that would consume it (ReferencedEntityNotFoundError, 40120). Available from protocol version 14.", + "description": "Beside findBy, on a reference judged when the referring document is created only (a findBy function, or a document type whose documents are never replaced, documentsMutable: false; the parser refuses any other): the create deletes the document findBy finds, in the same state transition, its storage refunded to its owner as a delete by that owner would. Only on a deletableDocument reference whose where or findBy maps \"$ownerId\" to \"$ownerId\", so a create consumes the writer's own commitment and no other, into a document type of the declaring contract whose documents their owner may delete (canBeDeleted: true) or that only a consume removes (canBeDeleted: \"onlyWhenConsumed\"), that declares no delete token cost and no delete action fee (no delete transition charges them) and requires no stricter signature security level than the declaring type, checked at contract registration. A key bound to a contract or a document type must be allowed to act on every document type the create may consume (ContractBoundedKeyOutOfBoundsError, 20014). A batch that consumes the same document twice, or also deletes, replaces, transfers, reprices or buys it, refuses the create that would consume it (ReferencedEntityNotFoundError, 40120). Available from protocol version 14.", "const": true } }, @@ -868,17 +868,12 @@ "additionalProperties": false, "allOf": [ { - "$comment": "minimumAgeBlocks and consume describe the document a findBy function finds: they need findBy holding a value that is not a string", + "$comment": "minimumAgeBlocks and consume describe the document findBy finds when the referring document is created: they need findBy, and the parser requires the reference to be judged on the create alone (a findBy function, or a document type whose documents are never replaced)", "if": { "anyOf": [{ "required": ["minimumAgeBlocks"] }, { "required": ["consume"] }] }, "then": { - "required": ["findBy"], - "properties": { - "findBy": { - "not": { "additionalProperties": { "type": "string" } } - } - } + "required": ["findBy"] } }, { @@ -2616,7 +2611,7 @@ } }, "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 targets a creator can be: identity, a permanentDocument found by findBy, where \".\" is the creator, a permanentDocument with inList, the creator an element of the list, a deletableDocument found by a findBy function (a commitment the creator reveals, checked when the document is created only, see findBy), or an anyOf or allOf expression whose every leaf is one of them. contract, token, a document by id and any other deletableDocument (the creator never changes, and a document a transfer handed on could not be replaced once the one findBy found is deleted) 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 where value 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 findBy or where reads (every replace for a where value $ownerId), 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 targets a creator can be: identity, a permanentDocument found by findBy, where \".\" is the creator, a permanentDocument with inList, the creator an element of the list, a deletableDocument found by a findBy function (a commitment the creator reveals, checked when the document is created only, see findBy) or declared on a document type whose documents are never replaced (documentsMutable: false, so also checked when the document is created only), or an anyOf or allOf expression whose every leaf is one of them. contract, token, a document by id and any other deletableDocument (the creator never changes, and a document a transfer handed on could not be replaced once the one findBy found is deleted) 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 where value 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 findBy or where reads (every replace for a where value $ownerId), 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": { 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 3409f859862..810637d9e24 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 @@ -911,7 +911,7 @@ fn apply_revealed_reference_v0( let computed = target .as_any_document_reference() .and_then(|declaration| declaration.lookup) - .is_some_and(|lookup| lookup.is_checked_on_create_only()); + .is_some_and(|lookup| lookup.hash_key().is_some()); if !computed { return Err(refused()); } @@ -1762,6 +1762,7 @@ pub(super) fn parse_doctype_reference( schema: &Value, keyword: &str, value: &str, + documents_mutable: bool, platform_version: &PlatformVersion, ) -> Result, DataContractError> { if platform_version @@ -1845,17 +1846,19 @@ pub(super) fn parse_doctype_reference( // keeps to targets that hold for good DocumentPropertyReferenceTarget::DeletableDocumentLookup { .. } if keyword == property_names::OWNER_REFERS_TO => {} - // A lookup with a computed key is judged when the document is - // created only, by its creator: a commitment the creator revealed, - // which no replace asks for again, so the creator's gate cannot - // outlive a transfer + // A lookup judged when the document is created only (a commitment + // the creator revealed, or on a type whose documents are never + // replaced), which no replace asks for again, so the creator's gate + // cannot outlive a transfer DocumentPropertyReferenceTarget::DeletableDocumentLookup { lookup, .. } - if lookup.is_checked_on_create_only() => {} + if lookup.is_checked_on_create_only(documents_mutable) => {} DocumentPropertyReferenceTarget::DeletableDocumentLookup { .. } => { return Err(DataContractError::InvalidContractStructure(format!( - "{keyword}{at} does not take a deletableDocument reference unless its findBy \ - key is computed: the creator never changes, and a document a transfer \ - handed on could not be replaced once the one findBy found is deleted" + "{keyword}{at} does not take a deletableDocument reference unless it is \ + judged on the create alone (a findBy function, or a document type whose \ + documents are never replaced): the creator never changes, and a document a \ + transfer handed on could not be replaced once the one findBy found is \ + deleted" ))) } DocumentPropertyReferenceTarget::PermanentDocument { .. } @@ -2008,14 +2011,6 @@ fn parse_find_by( )) } }; - if computed_keys == 0 && (minimum_age_blocks.is_some() || consume) { - return Err(DataContractError::InvalidContractStructure( - "refersTo minimumAgeBlocks and consume need a findBy function computing the key: \ - they describe the commitment a create reveals" - .to_string(), - )); - } - Ok(DocumentReferenceLookup { minimum_age_blocks, consume, @@ -2069,6 +2064,7 @@ pub(super) fn validate_reference_lookup_sources( declaration.property_agreement, ) }) + .or_else(|| age_or_consume_error(document_type, lookup)) { let at = if leaf_path.is_empty() { String::new() @@ -2085,6 +2081,24 @@ pub(super) fn validate_reference_lookup_sources( Ok(()) } +/// Why a lookup declared on `document_type` may not declare `minimumAgeBlocks` or `consume`; +/// `None` when it declares neither or may. Both describe the document found when the +/// referring document is created, so the reference must be judged on the create alone +/// ([`DocumentReferenceLookup::is_checked_on_create_only`]): its key holds a `findBy` function, +/// or the type's documents are never replaced. +fn age_or_consume_error( + document_type: DocumentTypeRef, + lookup: &DocumentReferenceLookup, +) -> Option { + let declares = lookup.minimum_age_blocks.is_some() || lookup.consume; + (declares && !lookup.is_checked_on_create_only(document_type.documents_mutable())).then(|| { + "minimumAgeBlocks and consume describe the document found when the referring document \ + is created, so the reference must be judged on the create alone: give findBy a \ + function, or make the document type immutable (documentsMutable: false)" + .to_string() + }) +} + /// Why a `refersTo` leaf whose key a `findBy` function computes, at /// `leaf_path` of its declaration on `document_type`, cannot be /// judged on the create alone; `None` when it can (or the lookup holds no @@ -2104,7 +2118,7 @@ fn create_only_leaf_error( lookup: &DocumentReferenceLookup, property_agreement: &BTreeMap, ) -> Option { - if !lookup.is_checked_on_create_only() { + if !lookup.is_checked_on_create_only(document_type.documents_mutable()) { return None; } if document_type.documents_mutable() diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/commit_reveal_lookup_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/commit_reveal_lookup_tests.rs index 83ff83cb2aa..b8958303849 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/commit_reveal_lookup_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/commit_reveal_lookup_tests.rs @@ -321,7 +321,9 @@ fn should_parse_a_function_pair_that_demands_nothing_more() { }; assert_eq!(lookup, dpns_lookup(None, false)); assert!(property_agreement.is_empty()); - assert!(lookup.is_checked_on_create_only()); + // A computed key is judged on the create alone even on a type whose documents can be + // replaced + assert!(lookup.is_checked_on_create_only(true)); } #[test] @@ -376,21 +378,40 @@ fn should_parse_a_creator_function_reading_the_creator_or_nothing() { } #[test] -fn should_refuse_a_deletable_creator_lookup_without_a_computed_key() { +fn should_refuse_a_deletable_creator_lookup_without_a_computed_key_on_a_replaceable_type() { // The creator's gate on a deletable document would outlive a transfer: - // only a computed key, judged on the create alone, is admitted - assert_refused( - contract(dpns_contract_with_creator_reference(json!({ + // only a reference judged on the create alone is admitted + let mut contract_value = + with_identifier_preorder_key(dpns_contract_with_creator_reference(json!({ "type": "deletableDocument", "documentType": "preorder", "findBy": { "saltedDomainHash": "." } - }))), - "does not take a deletableDocument reference unless its findBy key is computed", + }))); + contract_value["documentSchemas"]["domain"]["documentsMutable"] = json!(true); + assert_refused( + contract(contract_value), + "does not take a deletableDocument reference unless it is judged on the create alone", ); } +#[test] +fn should_parse_a_deletable_creator_lookup_without_a_computed_key_on_a_type_never_replaced() { + // The domain's documents are never replaced, so the creator's lookup is judged on the + // create alone, as a computed key is + contract(with_identifier_preorder_key( + dpns_contract_with_creator_reference(json!({ + "type": "deletableDocument", + "documentType": "preorder", + "findBy": { + "saltedDomainHash": "." + } + })), + )) + .expect("a creator lookup on a type never replaced should parse"); +} + #[test] fn should_refuse_malformed_functions_in_the_parser() { let with_function = |function: serde_json::Value| { @@ -490,32 +511,78 @@ fn should_refuse_a_function_that_computes_no_find_by_key() { ); } +/// A `domain` declaring `refers_to` on an identifier `referrerId`, its documents replaceable +/// or not. +fn referrer_contract(refers_to: serde_json::Value, documents_mutable: bool) -> serde_json::Value { + let mut contract_value = dpns_contract_carried_by( + "referrerId", + json!({ + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", "position": 6 + }), + refers_to, + ); + contract_value["documentSchemas"]["domain"]["documentsMutable"] = json!(documents_mutable); + with_identifier_preorder_key(contract_value) +} + +/// `contract_value` with the preorder's `saltedDomainHash` an identifier, so a plain `findBy` +/// filling it from an identifier finds a value of the same kind. +fn with_identifier_preorder_key(mut contract_value: serde_json::Value) -> serde_json::Value { + contract_value["documentSchemas"]["preorder"]["properties"]["saltedDomainHash"] + ["contentMediaType"] = json!("application/x.dash.dpp.identifier"); + contract_value +} + +/// A plain `findBy` the writer's own document, with `extra` merged in. +fn plain_owned_lookup(extra: serde_json::Value) -> serde_json::Value { + let mut refers_to = json!({ + "type": "deletableDocument", + "documentType": "preorder", + "findBy": { "saltedDomainHash": "." }, + "where": { "$ownerId": "$ownerId" } + }); + merge(&mut refers_to, extra); + refers_to +} + #[test] -fn should_refuse_age_and_consume_without_a_function() { - let referrer = |refers_to: serde_json::Value| { - dpns_contract_carried_by( - "referrerId", - json!({ - "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, - "contentMediaType": "application/x.dash.dpp.identifier", "position": 6 - }), - refers_to, - ) +fn should_parse_age_and_consume_without_a_function_on_a_type_never_replaced() { + // The domain's documents are never replaced, so its plain lookup is judged on the create + // alone, as a computed key is, and may declare both + let parsed = contract(referrer_contract( + plain_owned_lookup(json!({ "minimumAgeBlocks": 1, "consume": true })), + false, + )) + .expect("age and consume on a type never replaced should parse"); + let reference = match domain_references(&parsed).as_slice() { + [(path, PropertyReference::Value(target))] if path == "referrerId" => (*target).clone(), + other => panic!("expected referrerId's reference alone, got {other:?}"), + }; + let DocumentPropertyReferenceTarget::DeletableDocumentLookup { lookup, .. } = reference else { + panic!("expected a deletable lookup"); }; - // A key read as is finds no commitment + assert_eq!(lookup.minimum_age_blocks, Some(1)); + assert!(lookup.consume); + assert!(lookup.hash_key().is_none()); + assert!(lookup.is_checked_on_create_only(false)); + assert!(!lookup.is_checked_on_create_only(true)); +} + +#[test] +fn should_refuse_age_and_consume_without_a_function_on_a_type_whose_documents_can_be_replaced() { + // A replace judges the plain lookup again, so it is not judged on the create alone for extra in [json!({ "minimumAgeBlocks": 1 }), json!({ "consume": true })] { - let mut refers_to = json!({ - "type": "deletableDocument", - "documentType": "preorder", - "findBy": { "saltedDomainHash": "." }, - "where": { "$ownerId": "$ownerId" } - }); - merge(&mut refers_to, extra); assert_refused_by_the_parser( - referrer(refers_to), - "need a findBy function computing the key", + referrer_contract(plain_owned_lookup(extra), true), + "the reference must be judged on the create alone", ); } +} + +#[test] +fn should_refuse_age_and_consume_without_find_by() { + let referrer = |refers_to: serde_json::Value| referrer_contract(refers_to, false); // Nor does a reference without findBy assert_refused_by_the_parser( referrer(json!({ 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 9fd1b0e0906..a1cd4afc754 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 @@ -525,6 +525,7 @@ fn parse_generation_3( &v2.schema, property_names::OWNER_REFERS_TO, "the writer", + v2.documents_mutable, platform_version, ) .map_err(consensus_or_protocol_data_contract_error)?; @@ -532,6 +533,7 @@ fn parse_generation_3( &v2.schema, property_names::CREATOR_REFERS_TO, "the creator", + v2.documents_mutable, platform_version, ) .map_err(consensus_or_protocol_data_contract_error)?; @@ -1805,7 +1807,7 @@ fn validate_no_immutable_deletable_element_references( matches!( leaf, DocumentPropertyReferenceTarget::DeletableDocumentLookup { lookup, .. } - if !lookup.is_checked_on_create_only() + if !lookup.is_checked_on_create_only(document_type.documents_mutable) ) }); if !deletable_lookup diff --git a/packages/rs-dpp/src/data_contract/document_type/property/lookup_preimage.rs b/packages/rs-dpp/src/data_contract/document_type/property/lookup_preimage.rs index 5164f86ad19..f272b100c3d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/lookup_preimage.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/lookup_preimage.rs @@ -560,7 +560,7 @@ pub fn first_unrevealable_lookup_key( let computed = target.leaves().into_iter().any(|leaf| { leaf.as_any_document_reference() .and_then(|declaration| declaration.lookup) - .is_some_and(|lookup| lookup.is_checked_on_create_only()) + .is_some_and(|lookup| lookup.hash_key().is_some()) }); match read_data_at_path(document_data, path) { Err(RepeatedKey) if computed => { 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 aeef4d62fbc..0b31448087c 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 @@ -151,7 +151,8 @@ pub struct DocumentReferenceLookup { /// except in the lookup of an `ownerRefersTo` or `creatorRefersTo` holding /// a computed key, where it may be left out. pub keys: BTreeMap, - /// With a computed key only: how many blocks before the create the + /// On a lookup judged on the create alone only + /// ([`Self::is_checked_on_create_only`]): how many blocks before the create the /// document the key finds must have been created (the reference's /// `minimumAgeBlocks`, beside `findBy`), judged on its /// `$createdAtBlockHeight`, so that 1 keeps a commitment and its reveal out @@ -159,7 +160,8 @@ pub struct DocumentReferenceLookup { /// record `$createdAtBlockHeight`. #[serde(rename = "minimumAgeBlocks", skip_serializing_if = "Option::is_none")] pub minimum_age_blocks: Option, - /// With a computed key only: whether the create deletes the document the + /// On a lookup judged on the create alone only + /// ([`Self::is_checked_on_create_only`]): whether the create deletes the document the /// key found (the reference's `consume`, beside `findBy`), in the same /// state transition. Only a `deletableDocument` reference whose `where` or /// `findBy` pairs the found document's `$ownerId` with the writer's may @@ -298,13 +300,17 @@ impl DocumentReferenceLookup { .sum() } - /// Whether this lookup is judged only when the referring document is - /// created: it holds a computed key, a commitment the create reveals. - /// Nothing such a lookup reads can change afterwards (registration makes - /// every stored value it reads fixed once written), and the commitment it - /// found may be consumed or deleted, so a replace never re-validates it. - pub fn is_checked_on_create_only(&self) -> bool { - self.hash_key().is_some() + /// Whether this lookup, declared on a document type whose documents can + /// (`documents_mutable`) or can not be replaced, is judged only when the + /// referring document is created: it holds a computed key, a commitment the + /// create reveals, or its type's documents are never replaced, and a + /// replace is the one write that judges a reference again. Nothing such a + /// lookup reads can change afterwards (registration makes every stored value + /// it reads fixed once written, and no `changeFields` entry may name one), + /// and the document it found may be consumed or deleted, so a replace never + /// re-validates it. + pub fn is_checked_on_create_only(&self, documents_mutable: bool) -> bool { + self.hash_key().is_some() || !documents_mutable } /// The referring document type's property paths the key reads as is, in @@ -358,7 +364,8 @@ impl DocumentReferenceLookup { declaring: DocumentTypeRef, reference_path: &str, ) -> Option { - let computed = self.is_checked_on_create_only(); + let computed = self.hash_key().is_some(); + let create_only = self.is_checked_on_create_only(declaring.documents_mutable()); let on_the_document = reference_path == OWNER_ID || reference_path == CREATOR_ID; let carrier_type = declaring .flattened_properties() @@ -431,14 +438,15 @@ impl DocumentReferenceLookup { continue; } LookupKeySource::OwnerId => { - // A lookup with a computed key is judged when the document is created - // only, so a later transfer or purchase moving the writer moves no key - if !computed && owner_can_change(declaring) { + // A lookup judged when the document is created only, so a later transfer + // or purchase moving the writer moves no key + if !create_only && owner_can_change(declaring) { return Some(format!( "findBy \"{index_property}\" reads \"$ownerId\", which a transfer or \ a purchase of the referring document changes without re-validating \ the reference: findBy may read the writer only on a document type \ - that cannot be transferred or traded, or beside a function" + that cannot be transferred or traded, beside a function, or on a \ + type whose documents are never replaced" )); } continue; 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 285f187edc0..9b21bed28aa 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 @@ -737,12 +737,13 @@ fn binds_a_changed_property( writer_can_change: bool, ) -> bool { // A lookup with a computed key is judged on a create only: nothing it reads can change, - // and the commitment it found may be gone. In place in generation 0, reached from - // protocol version 14 only, the only version whose parser produces one + // and the commitment it found may be gone. Only a replace asks, which a document type + // whose documents are never replaced does not reach. In place in generation 0, reached + // from protocol version 14 only, the only version whose parser produces one if reference_target .as_any_document_reference() .and_then(|declaration| declaration.lookup) - .is_some_and(|lookup| lookup.is_checked_on_create_only()) + .is_some_and(|lookup| lookup.is_checked_on_create_only(true)) { return false; } @@ -1098,8 +1099,12 @@ fn validate_reference_target_v0( }; // A commitment is revealed once, by the create; nothing the lookup reads can // change on a replace. In place in generation 0: only the protocol version 14 - // parser produces a computed key - if !is_create && lookup.is_some_and(|lookup| lookup.is_checked_on_create_only()) { + // parser produces a lookup judged on the create alone + if !is_create + && lookup.is_some_and(|lookup| { + lookup.is_checked_on_create_only(document_type.documents_mutable()) + }) + { return Ok(SimpleConsensusValidationResult::new()); } let list_reference = reference_target.as_list_element_reference(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs index 91c1bfed77a..db7fa74f109 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/commit_reveal_lookup.rs @@ -1198,6 +1198,142 @@ mod commit_reveal_lookup_tests { assert!(fixture.preorder_exists(preorder.id())); } + /// The fixture with a `voucher`, a deletable document keyed by a unique `serial`, and a + /// `redemption` that finds one by its serial through a plain `findBy`, no function: the + /// writer's own (`where $ownerId`), from an earlier block, and consumes it. A redemption + /// is never replaced, so its lookup is judged on the create alone, as a commitment's is. + fn voucher_fixture() -> CommitRevealFixture { + CommitRevealFixture::new_with(|contract| { + let schemas = &mut contract["documentSchemas"]; + schemas["voucher"] = serde_json::json!({ + "type": "object", + "documentsMutable": false, + "canBeDeleted": true, + "properties": { + "serial": { "type": "identifier", "position": 0 } + }, + "indices": [ + { "name": "serial", "properties": [{ "serial": "asc" }], "unique": true } + ], + "required": ["$createdAtBlockHeight", "serial"], + "additionalProperties": false + }); + schemas["redemption"] = serde_json::json!({ + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "properties": { + "voucherSerial": { + "type": "identifier", + "position": 0, + "refersTo": { + "type": "deletableDocument", + "documentType": "voucher", + "findBy": { "serial": "." }, + "where": { "$ownerId": "$ownerId" }, + "minimumAgeBlocks": 1, + "consume": true + } + } + }, + "required": ["voucherSerial"], + "additionalProperties": false + }); + }) + } + + const VOUCHER_SERIAL: [u8; 32] = [0x5e; 32]; + + /// A voucher of `who` with [`VOUCHER_SERIAL`], made in `height`. + async fn make_voucher(fixture: &mut CommitRevealFixture, who: Who, height: u64) -> Document { + let (voucher, result) = fixture + .create( + who, + "voucher", + &[("serial", Value::Identifier(VOUCHER_SERIAL))], + &[], + height, + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + voucher + } + + /// The redemption of the voucher with [`VOUCHER_SERIAL`] by `who`, in `height`. + async fn redeem( + fixture: &mut CommitRevealFixture, + who: Who, + height: u64, + ) -> (Document, StateTransitionExecutionResult) { + fixture + .create( + who, + "redemption", + &[("voucherSerial", Value::Identifier(VOUCHER_SERIAL))], + &[], + height, + ) + .await + } + + #[tokio::test] + async fn should_consume_a_document_a_plain_lookup_finds_on_a_type_never_replaced() { + let mut fixture = voucher_fixture(); + let voucher = make_voucher(&mut fixture, Who::Alice, COMMIT_HEIGHT).await; + + let (redemption, result) = redeem(&mut fixture, Who::Alice, REVEAL_HEIGHT).await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert!(!fixture.document_exists("voucher", voucher.id())); + assert!(fixture.document_exists("redemption", redemption.id())); + } + + #[tokio::test] + async fn should_refuse_a_plain_lookup_redemption_in_the_block_of_its_voucher() { + let mut fixture = voucher_fixture(); + let voucher = make_voucher(&mut fixture, Who::Alice, COMMIT_HEIGHT).await; + + let (_, result) = redeem(&mut fixture, Who::Alice, COMMIT_HEIGHT).await; + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentRequirementNotMetError(e) + ), + .. + } if *e.document_id() == voucher.id() + && e.field() == "minimumAgeBlocks" + && e.required() == "1" + && e.path() == "voucherSerial" + ); + assert!(fixture.document_exists("voucher", voucher.id())); + } + + #[tokio::test] + async fn should_refuse_redeeming_another_identitys_voucher_through_a_plain_lookup() { + let mut fixture = voucher_fixture(); + let voucher = make_voucher(&mut fixture, Who::Alice, COMMIT_HEIGHT).await; + + let (_, result) = redeem(&mut fixture, Who::Mallory, REVEAL_HEIGHT).await; + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyMismatchError(e) + ), + .. + } if e.path() == "voucherSerial" + && e.referring_property() == "$ownerId" + && e.referenced_property() == "$ownerId" + ); + assert!(fixture.document_exists("voucher", voucher.id())); + } + /// The type in the fixture reads what [`DocumentTypeRef`] reports: the /// declaration is the salt's, revealed, and the key is computed. #[test] @@ -1218,7 +1354,8 @@ mod commit_reveal_lookup_tests { .as_any_document_reference() .and_then(|declaration| declaration.lookup) .expect("expected the salt's lookup"); - assert!(lookup.is_checked_on_create_only()); + assert!(lookup.hash_key().is_some()); + assert!(lookup.is_checked_on_create_only(true)); assert_eq!(lookup.minimum_age_blocks, Some(1)); assert!(lookup.consume); } diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 3128a56202a..025a1df89de 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1616,8 +1616,12 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// Document create structure validation 1 refuses a create missing a param, /// repeating a key on the way to one, or whose variable-length param holds /// the one-byte separator that must follow it -/// (`DocumentReferencePreimageInvalidError`, 10423). Beside a `findBy` -/// function the reference may declare `minimumAgeBlocks`, judged by +/// (`DocumentReferencePreimageInvalidError`, 10423). A reference judged +/// on the create alone (`DocumentReferenceLookup::is_checked_on_create_only`: +/// beside a `findBy` function, or any `findBy` on a document type whose +/// documents are never replaced, `documentsMutable: false`, since a replace +/// is the one write that judges a reference again) may declare +/// `minimumAgeBlocks`, judged by /// document create state validation 2 against the found document's /// `$createdAtBlockHeight` (`ReferencedDocumentRequirementNotMetError`, /// 40142), and @@ -1630,10 +1634,10 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `validate_no_immutable_deletable_element_references` otherwise refuses. /// A `where` entry `{"$ownerId": "$ownerId"}` makes the commitment the /// writer's own, and so does a `findBy` entry `{"$ownerId": "$ownerId"}` -/// through a unique index over the owner and the hash, which beside a -/// function a transferable referring type may declare too, the key being -/// judged on the create alone; `consume` requires one of the two, into the declaring contract, -/// on a type whose owners may delete, that keeps no history, declares no +/// through a unique index over the owner and the hash, which a +/// transferable referring type may declare too on a reference judged on the +/// create alone; `consume` requires one of the two, into the declaring +/// contract, on a type whose owners may delete, that keeps no history, declares no /// delete token cost or delete action fee and requires no stricter /// signature security level than the declaring type; batch advanced /// structure 1 refuses a contract-bound key whose bounds leave out a type @@ -1645,7 +1649,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// transient; on a mutable type such a reference may not be an `anyOf` /// operand; and no property a function reads may be listed under /// `moderatorAbilities.changeFields` (57). `creatorRefersTo` takes a -/// `deletableDocument` target through a function, and the `findBy` of an +/// `deletableDocument` target judged on the create alone, and the `findBy` of an /// `ownerRefersTo` or `creatorRefersTo` may leave the value out beside /// one. DPNS v3 declares such a reveal (90). See /// `book/src/data-model/documents.md`. 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 0324b5f43d7..b764427accb 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -113,7 +113,8 @@ export type DocumentPropertyReferenceTarget = */ where?: DocumentReferenceWhere; /** - * Beside a `findBy` function only: how many blocks before the create + * Beside `findBy`, on a reference judged on the create alone (a `findBy` + * function, or a document type whose documents are never replaced): how many blocks before the create * the document `findBy` finds must have been created, from its * `$createdAtBlockHeight`, so 1 means an earlier block (code 40142 when * it was not). @@ -220,14 +221,16 @@ export type DocumentPropertyReferenceTarget = */ where?: DocumentReferenceWhere; /** - * Beside a `findBy` function only: how many blocks before the create + * Beside `findBy`, on a reference judged on the create alone (a `findBy` + * function, or a document type whose documents are never replaced): how many blocks before the create * the document `findBy` finds must have been created, from its * `$createdAtBlockHeight`, so 1 means an earlier block (code 40142 when * it was not). */ minimumAgeBlocks?: number; /** - * Beside a `findBy` function only: the create deletes the document + * Beside `findBy`, on a reference judged on the create alone (a `findBy` + * function, or a document type whose documents are never replaced): the create deletes the document * `findBy` finds, the writer's own commitment, in the same state * transition. */