From cc7c3b6305ad89d5b0cbd6526b82ae76a714dc99 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 07:46:56 +0700 Subject: [PATCH] feat(platform)!: document references resolved through a unique index (PV14) A permanentDocument refersTo, on an identifier property or on the elements of a typed array, may carry a lookup: the value is then not the referenced document's id, and the referenced document is the one a unique index of the referenced type finds for a key assembled from the referring document (a property path, $ownerId, or "." for the value or the element, exactly once). The key must stay with the document it found. deletableDocument references take none. A lookup reference parses to the appended PermanentDocumentLookup variant, so an id reference keeps its variant and the encoding of the errors embedding it. as_document_reference returns only references whose value is a document id; the validators use as_any_document_reference. Registration checks the sources on every parse, a same-contract index in create_document_types_from_document_schemas v1 (in place, inert before PV14) and a cross-contract one in the contract reference validation (ReferencedDocumentLookupInvalidError, 40137). Writes query the index, billed as a document fetch, and refuse a miss with 40120. Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/documents.md | 48 + .../document/v3/document-meta.json | 53 ++ .../v1/mod.rs | 69 +- .../class_methods/try_from_schema/mod.rs | 166 +++- .../class_methods/try_from_schema/v3/mod.rs | 9 +- .../v3/reference_lookup_tests.rs | 652 ++++++++++++++ .../document_type/index/preallocation.rs | 32 +- .../methods/validate_update/common/mod.rs | 47 + .../src/data_contract/document_type/mod.rs | 8 + .../document_type/property/mod.rs | 224 ++++- .../property/reference_lookup.rs | 826 ++++++++++++++++++ packages/rs-dpp/src/errors/consensus/codes.rs | 1 + .../errors/consensus/state/document/mod.rs | 1 + ...eferenced_document_lookup_invalid_error.rs | 70 ++ .../src/errors/consensus/state/state_error.rs | 78 +- .../src/validation/meta_validators/mod.rs | 50 ++ .../document_reference_validation/v0/mod.rs | 93 +- .../batch/state/v0/fetch_documents.rs | 101 ++- .../batch/tests/document/lookup_reference.rs | 732 ++++++++++++++++ .../batch/tests/document/mod.rs | 1 + .../v0/mod.rs | 53 +- .../data_contract_create/mod.rs | 76 +- ...up-registration-foreign-missing-index.json | 44 + ...act-lookup-registration-foreign-valid.json | 45 + ...ct-lookup-registration-own-not-unique.json | 71 ++ .../reference-validation-contract-lookup.json | 196 +++++ .../v0/tests/lookup_reference_join_tests.rs | 144 +++ .../insert/insert_contract/v0/tests/mod.rs | 1 + .../src/query/chained_document_query/mod.rs | 17 +- .../src/query/composite_document_query/mod.rs | 17 +- .../rs-platform-version/src/version/v14.rs | 38 + .../src/errors/consensus/consensus_error.rs | 4 + packages/wasm-dpp2/src/consensus_error.rs | 23 +- .../data_contract/document_type_reference.rs | 59 +- .../unit/DocumentPropertyReference.spec.ts | 136 +++ .../wasm-sdk/src/queries/chained_document.rs | 4 +- .../src/queries/composite_document.rs | 6 +- 37 files changed, 4112 insertions(+), 83 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_lookup_tests.rs create mode 100644 packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/document/referenced_document_lookup_invalid_error.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/lookup_reference.rs create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-missing-index.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-valid.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-own-not-unique.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup.json create mode 100644 packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/lookup_reference_join_tests.rs diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 9dc555a5f26..29a42bec5a3 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -287,6 +287,54 @@ An `identityPublicKey` reference names one key of one identity, and comes in two Both forms share the state check (`validate_referenced_identity_key_v0` in the document reference validation): the key must exist and not be disabled, else the write is refused, paid, with `ReferencedIdentityKeyNotFoundError` (40123) or `ReferencedIdentityKeyDisabledError` (40124). Identity keys can be disabled but never removed, so a validated reference never dangles. The owner form's identity is the transition's signer, which the transition already proved exists, so the key fetch is its only read; a key id of some other identity's key is meaningless by construction, there is no property to name another identity. On replace the identity form is re-validated when either the identity property or its key id property changed. For the key id form it depends on where the identity comes from. `$ownerId` is the writer, transition metadata that never appears among the changed fields, and the document may have changed hands since the key id was written, so the reference is re-validated on every replace, touched or not, as the `$ownerId` writer gate is: after a transfer the new owner has to repoint the key id at one of its own keys. `$creatorId` never changes, so it is re-validated when the key id changed. A property path is re-validated when the key id or that property changed, and a key id set while the property is not is refused (`ReferencedKeyIdPropertyInvalidError`, 40125). A transfer itself is not checked in any form, so the reference governs writing, not holding. The declaring property must carry exactly the key id range in its schema, whatever the contract's integer sizing setting, and a `keyIdProperty` of the identity form may not name a property that carries this form, nor may a path name an identifier carrying an `identityPublicKey` reference: one pair is declared once (40125 at registration). The charter contract's `joinRequest.senderKeyId`, the owner's encryption key a shared secret is derived from, is the first user. +### Resolved through a unique index (`lookup`) + +A `permanentDocument` reference normally holds the referenced document's id. It may instead carry a `lookup`: the property's value, or on the elements of a typed array each element (see [References on the Elements](#references-on-the-elements)), is then one part of a key, and the referenced document is the one a unique index of the referenced document type finds for that key. The reference holds if that document exists. The moderation charter's `members` is the first user: + +```json +"members": { + "type": "array", "minItems": 0, "maxItems": 15, "uniqueItems": true, + "items": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "$ownerId", + "refersTo": { + "type": "permanentDocument", + "documentType": "joinRequest", + "lookup": { + "index": "bySubmittedCharter", + "keys": { "submittedCharterId": "submittedCharterId", "$ownerId": "." } + } + } + }, + "position": 2 +} +``` + +reads: every member must be the owner of a `joinRequest` whose `submittedCharterId` equals this document's `submittedCharterId`. Without `lookup` the list would have to hold the join requests' ids, which the writer would have to find first, and which say nothing about who asked to join. The same form works on a scalar identifier property, where `"."` is the property's own value. + +A `deletableDocument` reference takes no `lookup`. Once the document a key found is deleted, a new document with the same key would make the reference resolve again, to different content, where an id is produced at most once and a dead id reference stays dead. + +`index` names an index of the referenced document type. `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 on the referring side: + +- a property path of the referring document type (`"submittedCharterId"`, `"meta.charterId"`); +- `"$ownerId"`, the referring document's owner, the writer; +- `"."`, the value of the property that carries the reference, or the element. It appears exactly once: without it every value would resolve to the same document. + +What is checked when the contract enters the chain, on registration and on update: + +- `lookup` is only allowed on `permanentDocument` references (meta-schema v3 and the parser, `apply_property_reference` 0), on the property or on the `items` of a typed array. +- Each property a key reads must exist on the referring type, be required (and so must every object around it), not be transient, and hold a single value, so a lookup never runs with a missing key part and a reader can assemble the same key from the stored document. A key 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. These are properties of the referring type alone and are checked on every parse (generation 3). +- The index must exist and be unique, so the key finds at most one document; it may not bucket a timestamp with `timeRange`, and the referenced type may not be `indexOnly`. `keys` must cover each property of the index exactly once and nothing else, and each source must hold the same kind of value as the index property it fills (the rule of `propertyAgreement`, `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`), `$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. +- A changed, added or removed `lookup` is an incompatible schema change on update, like the rest of a `refersTo`. + +The checks on the referenced type run where that type is in hand. For a document type of the same contract the contract parse runs them under full validation (`create_document_types_from_document_schemas` 1, next to the `keyRequirements.boundTo` check), once every document type is parsed; a deletable target is left to registration, which refuses it for the `permanentDocument` reference (40122). For a type of another contract (`contractId`) the registration state validation runs them against that contract, where the other `refersTo` checks into another contract run, and refuses a declaration that cannot resolve with `ReferencedDocumentLookupInvalidError` (state code 40137). Index definitions cannot change on a contract update from protocol version 14 (`validate_update` 1 compares them by name), and neither can the flags the permanence rule reads, so the answer holds. + +When the referring document is created or replaced, the document reference validation assembles the key for each value and queries the index for at most one document, billed as a document fetch of the same kind as the id lookup (`fetch_document_through_lookup`). No document, or a key it cannot assemble, refuses the write, paid, with `ReferencedEntityNotFoundError` (40120) naming the property, or the element by its list path (`members[1]`); its target reads "found through unique index ``". A `propertyAgreement` beside the `lookup` is checked against the document the index found, exactly as for an id reference. A replace re-validates the reference when the property itself changed (for a list, the elements the stored list did not hold), and every value, every element included, when a property a key reads changed. Nothing else can move a key part: the writer is fixed on a type allowed to read it, and the referenced side's key is fixed by the rule above, so a validated lookup reference never dangles. + +Joins cannot go through a lookup reference: a chained query or a composite by-id join needs the join property's values to be the outer documents' ids, so both refuse such a property, and a `preallocated` index cannot be bound through one. In Rust the declaration is its own variant, `DocumentPropertyReferenceTarget::PermanentDocumentLookup`, appended to the enum rather than a field of `PermanentDocument`: the enum is embedded in the reference errors, so an id reference keeps its encoding, and code matching `PermanentDocument` as "the value is a document id" cannot mistake a lookup for one. The rules are on `DocumentReferenceLookup`. `as_document_reference` returns only references whose value is a document id, the accessor for joins; the validators use `as_any_document_reference`, whose declaration carries the lookup. + ## Immutable Properties on Mutable Document Types A document type either allows replaces (`documentsMutable: true`, the default) or freezes its documents entirely. Protocol version 14 adds a middle ground: the doctype-level `immutable` keyword lists top-level properties that are frozen at creation while the rest of the document stays replaceable. 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 5485483dae3..adc1000459d 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 @@ -287,6 +287,47 @@ } ] } + }, + "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, 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", + "properties": { + "index": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "keys": { + "type": "object", + "minProperties": 1, + "maxProperties": 10, + "propertyNames": { + "minLength": 1, + "maxLength": 256 + }, + "additionalProperties": { + "type": "string", + "anyOf": [ + { + "const": "." + }, + { + "const": "$ownerId" + }, + { + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" + } + ] + } + } + }, + "required": [ + "index", + "keys" + ], + "additionalProperties": false } }, "required": [ @@ -352,6 +393,18 @@ "contractRequirements": false } } + }, + { + "if": { + "properties": { "type": { "const": "permanentDocument" } }, + "required": ["type"] + }, + "then": {}, + "else": { + "properties": { + "lookup": false + } + } } ] }, 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 ed5c0eb45f5..26a9ca3799a 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 @@ -1,9 +1,12 @@ use crate::consensus::basic::data_contract::DocumentTypesAreMissingError; use crate::data_contract::config::DataContractConfig; -use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::accessors::{ + DocumentTypeV0Getters, DocumentTypeV2Getters, +}; use crate::data_contract::document_type::class_methods::consensus_or_protocol_data_contract_error; use crate::data_contract::document_type::{ - DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentType, + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceDeclaration, + DocumentType, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{DocumentName, TokenConfiguration, TokenContractPosition}; @@ -143,6 +146,68 @@ impl DocumentType { } } + // Protocol version 14 and later: a `refersTo` lookup into a document type of this + // contract must resolve in it: the named index exists and is unique, the keys cover + // its properties exactly, every source holds the kind of value its index property + // does, and the key cannot move off the document it found (see + // `DocumentReferenceLookup::referenced_side_error`). Like the check above it + // needs every document type of the contract, and runs under full validation only. A + // lookup into another contract is checked against that contract's state at + // registration, and a reference naming a document type this contract does not have + // is left to that validation too, which reports it. + // + // Inert for every protocol version before 14 for the same reason as the check above: + // a parsed reference carries a `lookup` only where the tables carry + // `apply_property_reference: Some(_)`, so the loop below finds none there. + for (name, document_type) in &contract_document_types { + for (path, property) in document_type.as_ref().flattened_properties() { + // On an identifier property or on the elements of a typed array + let Some(target) = property + .property_type + .reference() + .and_then(|reference| reference.target()) + else { + continue; + }; + let Some(DocumentReferenceDeclaration { + contract_id, + document_type_name, + lookup: Some(lookup), + .. + }) = target.as_any_document_reference() + else { + continue; + }; + if contract_id.is_some_and(|contract_id| contract_id != data_contract_id) { + continue; + } + let Some(referenced_document_type) = + contract_document_types.get(document_type_name) + else { + continue; + }; + // A lookup is only declared on a permanentDocument reference, and a + // deletable target fails that reference whatever its indexes say: + // registration reports it (ReferencedDocumentTypeDeletableError), so the + // lookup is not judged against a type it could never reference + let referenced = referenced_document_type.as_ref(); + if referenced.documents_can_be_deleted() + || referenced.documents_can_be_deleted_by_moderators() + { + continue; + } + if let Some(reason) = + lookup.referenced_side_error(document_type.as_ref(), referenced) + { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "document type \"{name}\" property \"{path}\" refersTo lookup: {reason}" + )), + )); + } + } + } + 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 bae56371b33..74a0f58d4f5 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 @@ -1,6 +1,10 @@ use crate::data_contract::config::DataContractConfig; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::class_methods::apply_required_since::apply_required_since; use crate::data_contract::document_type::class_methods::parse_typed_array::parse_typed_array; +use crate::data_contract::document_type::reference_lookup::{ + MAX_LOOKUP_INDEX_NAME_LENGTH, MAX_LOOKUP_KEYS, MAX_LOOKUP_PATH_LENGTH, +}; use crate::data_contract::document_type::v0::DocumentTypeV0; use crate::data_contract::document_type::v1::DocumentTypeV1; use crate::data_contract::document_type::v2::DocumentTypeV2; @@ -8,9 +12,10 @@ use crate::data_contract::document_type::{ is_referenced_system_agreement_property, is_referring_system_agreement_property, property_names, ContractReferenceModeration, ContractReferenceOwner, ContractReferenceRequirements, DistinctFrom, DocumentProperty, DocumentPropertyReferenceTarget, - DocumentPropertyType, DocumentPropertyTypeParsingOptions, DocumentType, EncryptedFor, - EncryptedForRecipient, EncryptionScheme, IdentityKeyReferenceRequirements, KeyIdReference, - KeyReferenceIdentityProperty, + DocumentPropertyType, DocumentPropertyTypeParsingOptions, DocumentReferenceLookup, + DocumentType, DocumentTypeRef, EncryptedFor, EncryptedForRecipient, EncryptionScheme, + IdentityKeyReferenceRequirements, KeyIdReference, KeyReferenceIdentityProperty, + LookupKeySource, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; @@ -619,6 +624,17 @@ fn apply_property_reference_v0( )); } + // `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 + // most once. The other targets are found by the value itself + if refers_to_map.contains_key(property_names::LOOKUP) && reference_type != "permanentDocument" { + return Err(DataContractError::InvalidContractStructure(format!( + "{reference_type} refersTo does not take lookup: it is only allowed on \ + permanentDocument references" + ))); + } + // A key reference declared on the key id property itself names whose key // it is through `identityProperty`; it is the one form that sits on a // non-identifier property @@ -737,10 +753,22 @@ fn apply_property_reference_v0( let document_type_name = document_type_name.to_string(); if document_target == "permanentDocument" { - DocumentPropertyReferenceTarget::PermanentDocument { - contract_id, - document_type_name, - property_agreement, + // 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, + }, } } else { DocumentPropertyReferenceTarget::DeletableDocument { @@ -895,6 +923,130 @@ fn apply_element_reference_v0( apply_property_reference_v0(items, element_type) } +/// The `lookup` of a `permanentDocument` reference: `index`, the name of an index of the +/// referenced document type, and `keys`, every property of that index mapped to +/// its referring-side source (`"."`, `"$ownerId"` or a property path), with `"."` +/// exactly once. What the names resolve to is checked once the document types +/// are parsed: the sources against the declaring type +/// ([`validate_reference_lookup_sources`]), the index against the referenced +/// one (at contract level for a type of the same contract, at registration for +/// one of another contract). +fn parse_document_reference_lookup( + lookup_value: &Value, +) -> Result { + let lookup_map = lookup_value.to_btree_ref_string_map()?; + if let Some(unknown) = lookup_map.keys().find(|key| { + !matches!( + key.as_str(), + property_names::LOOKUP_INDEX | property_names::LOOKUP_KEYS + ) + }) { + return Err(DataContractError::InvalidContractStructure(format!( + "permanentDocument refersTo lookup {unknown:?} is unknown: a lookup takes index and \ + keys" + ))); + } + + let index = lookup_map + .get_str(property_names::LOOKUP_INDEX) + .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; + if index.is_empty() || index.len() > MAX_LOOKUP_INDEX_NAME_LENGTH { + return Err(DataContractError::InvalidContractStructure(format!( + "permanentDocument refersTo lookup index must be between 1 and \ + {MAX_LOOKUP_INDEX_NAME_LENGTH} characters" + ))); + } + + let keys_map = lookup_map + .get(property_names::LOOKUP_KEYS) + .ok_or_else(|| { + DataContractError::InvalidContractStructure( + "permanentDocument refersTo lookup must declare keys".to_string(), + ) + })? + .to_btree_ref_string_map()?; + if keys_map.is_empty() || keys_map.len() > MAX_LOOKUP_KEYS { + return Err(DataContractError::InvalidContractStructure(format!( + "permanentDocument refersTo lookup keys must map between 1 and {MAX_LOOKUP_KEYS} \ + index properties" + ))); + } + + let keys = keys_map + .into_iter() + .map(|(index_property, source_value)| { + if index_property.is_empty() || index_property.len() > MAX_LOOKUP_PATH_LENGTH { + return Err(DataContractError::InvalidContractStructure(format!( + "permanentDocument refersTo lookup index property names must be between 1 \ + and {MAX_LOOKUP_PATH_LENGTH} characters" + ))); + } + let source = source_value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "permanentDocument refersTo lookup keys must map each index property to a \ + string: \".\", \"$ownerId\" or a property path" + .to_string(), + ) + })?; + Ok((index_property, LookupKeySource::from_wire_name(source)?)) + }) + .collect::, DataContractError>>()?; + + // Without the reference's own value in the key, every document would + // resolve to the same referenced document whatever the property holds + let reference_value_uses = keys + .values() + .filter(|source| matches!(source, LookupKeySource::ReferenceValue)) + .count(); + if reference_value_uses != 1 { + return Err(DataContractError::InvalidContractStructure(format!( + "permanentDocument refersTo lookup keys must fill exactly one index property from \ + \".\", the reference's own value, found {reference_value_uses}" + ))); + } + + Ok(DocumentReferenceLookup { + index: index.to_string(), + keys, + }) +} + +/// Checks the referring side of every `refersTo` lookup of a document type, +/// once all its properties are parsed: each property a key reads must exist, +/// be a stored, required, single value (with every object around it +/// required), and not be the reference property itself. See +/// [`DocumentReferenceLookup::referring_side_error`]. +/// +/// Runs on every parse, validating or not, like the `encryptedFor` check: the +/// rule is a property of the document type, and the write-time lookup reads +/// the sources through the same flattened map. Generation 3 is the only +/// parser admitting `refersTo` at all. +pub(super) fn validate_reference_lookup_sources( + document_type: DocumentTypeRef, + document_type_name: &str, +) -> Result<(), DataContractError> { + for (path, property) in document_type.flattened_properties() { + // On an identifier property or on the elements of a typed array: the + // key's other parts are the same for every element + let Some(lookup) = property + .property_type + .reference() + .and_then(|reference| reference.target()) + .and_then(|target| target.as_any_document_reference()) + .and_then(|declaration| declaration.lookup) + else { + continue; + }; + if let Some(reason) = lookup.referring_side_error(document_type, path) { + return Err(DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" property \"{path}\" refersTo lookup: \ + {reason}" + ))); + } + } + Ok(()) +} + /// Reads a property's `encryptedFor` declaration: how the bytes of a byte /// array property were encrypted. Non-byte-array properties, identifiers /// among them, cannot carry it. diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 9bdf90a7ea4..9369456d887 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 @@ -31,7 +31,7 @@ use crate::data_contract::document_type::property::{ }; use crate::data_contract::document_type::property_names; use crate::data_contract::document_type::v2::DocumentTypeV2; -use crate::data_contract::document_type::DocumentType; +use crate::data_contract::document_type::{DocumentType, DocumentTypeRef}; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; use crate::validation::operations::ProtocolValidationOperation; @@ -48,7 +48,7 @@ use crate::consensus::basic::data_contract::InvalidIndexedPropertyConstraintErro use crate::consensus::ConsensusError; use super::common; -use super::validate_encrypted_for_declarations; +use super::{validate_encrypted_for_declarations, validate_reference_lookup_sources}; mod ranked_prefix_overlap; use ranked_prefix_overlap::validate_no_ranked_prefix_overlap; @@ -417,6 +417,9 @@ fn try_from_schema_generation_3( // properties it names. Generation 3 is the only one admitting the keyword. validate_encrypted_for_declarations(&v2, name) .map_err(consensus_or_protocol_data_contract_error)?; + // The same for the properties a `refersTo` lookup reads to assemble its key. + validate_reference_lookup_sources(DocumentTypeRef::V2(&v2), name) + .map_err(consensus_or_protocol_data_contract_error)?; // After `apply_index_only`: the flag is refused on an indexOnly type, so it // has to see that one already applied. @@ -623,6 +626,8 @@ mod moderators_delete_tests; #[cfg(all(test, feature = "validation"))] mod name_rules_tests; #[cfg(all(test, feature = "validation"))] +mod reference_lookup_tests; +#[cfg(all(test, feature = "validation"))] mod typed_array_reference_tests; #[cfg(all(test, feature = "validation"))] mod typed_array_test_helpers; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_lookup_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_lookup_tests.rs new file mode 100644 index 00000000000..9245a15e4c7 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/reference_lookup_tests.rs @@ -0,0 +1,652 @@ +//! Document references resolved through a unique index (`refersTo.lookup`, +//! protocol version 14): the parse of the declaration, the checks of its +//! referring side on every parse, the checks of its referenced side at contract +//! level for a document type of the same contract, the protocol version gate and +//! the platform serialization round trip. + +use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::config::DataContractConfig; +use crate::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; +use crate::data_contract::document_type::{ + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceLookup, DocumentType, + LookupKeySource, PropertyReference, +}; +use crate::data_contract::DataContract; +use crate::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, + PlatformSerializableWithPlatformVersion, +}; +use crate::ProtocolError; +use platform_value::string_encoding::Encoding; +use platform_value::{Identifier, Value}; +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 + }) +} + +/// The lookup of the moderation charter's `members`: the member is the owner of +/// a `joinRequest` for the same submitted charter. +fn members_lookup() -> serde_json::Value { + json!({ + "index": "bySubmittedCharter", + "keys": { "submittedCharterId": "submittedCharterId", "$ownerId": "." } + }) +} + +/// A contract with a permanent, immutable `joinRequest` type, unique on +/// (`submittedCharterId`, `$ownerId`) and with a non-unique `byMessage` index, +/// and an `electedCharter` type whose `memberId` declares `refers_to`, next to +/// a required `submittedCharterId`, an optional `alternateCharterId` and a +/// required string `title`. +fn charter_contract(refers_to: serde_json::Value) -> serde_json::Value { + let mut member_id = identifier(1); + member_id["refersTo"] = refers_to; + json!({ + "$formatVersion": "1", + "id": Identifier::from(CONTRACT_ID).to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": 1, + "documentSchemas": { + "joinRequest": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "submittedCharterId": identifier(0), + "message": { "type": "string", "maxLength": 63, "position": 1 } + }, + "indices": [ + { + "name": "bySubmittedCharter", + "properties": [{ "submittedCharterId": "asc" }, { "$ownerId": "asc" }], + "unique": true + }, + { "name": "byMessage", "properties": [{ "message": "asc" }] } + ], + "required": ["submittedCharterId", "message"], + "additionalProperties": false + }, + "electedCharter": { + "type": "object", + "properties": { + "submittedCharterId": identifier(0), + "memberId": member_id, + "alternateCharterId": identifier(2), + "title": { "type": "string", "maxLength": 63, "position": 3 } + }, + "required": ["submittedCharterId", "title"], + "additionalProperties": false + } + } + }) +} + +fn permanent_join_request(lookup: serde_json::Value) -> serde_json::Value { + json!({ "type": "permanentDocument", "documentType": "joinRequest", "lookup": lookup }) +} + +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 member_id_type(contract: &DataContract) -> DocumentPropertyType { + contract + .document_type_for_name("electedCharter") + .expect("the electedCharter document type") + .flattened_properties() + .get("memberId") + .expect("the memberId property") + .property_type + .clone() +} + +fn expected_lookup(keys: &[(&str, LookupKeySource)]) -> DocumentReferenceLookup { + DocumentReferenceLookup { + index: "bySubmittedCharter".to_string(), + keys: keys + .iter() + .map(|(index_property, source)| (index_property.to_string(), source.clone())) + .collect(), + } +} + +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}" + ); +} + +#[test] +fn should_parse_a_lookup_with_a_property_source_an_owner_source_and_the_reference_value() { + let parsed = + contract(charter_contract(permanent_join_request(members_lookup()))).expect("parses"); + assert_eq!( + member_id_type(&parsed), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "joinRequest".to_string(), + property_agreement: BTreeMap::new(), + lookup: expected_lookup(&[ + ( + "submittedCharterId", + LookupKeySource::Property("submittedCharterId".to_string()) + ), + ("$ownerId", LookupKeySource::ReferenceValue), + ]), + } + ) + ); + + // The writer can fill a key part too, and the reference's own value can fill + // a schema property of the index + let parsed = contract(charter_contract(permanent_join_request(json!({ + "index": "bySubmittedCharter", + "keys": { "submittedCharterId": ".", "$ownerId": "$ownerId" } + })))) + .expect("parses"); + let DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. }, + ) = member_id_type(&parsed) + else { + panic!("expected a permanentDocument lookup reference"); + }; + assert_eq!( + lookup, + expected_lookup(&[ + ("submittedCharterId", LookupKeySource::ReferenceValue), + ("$ownerId", LookupKeySource::OwnerId), + ]) + ); +} + +#[test] +fn should_parse_a_lookup_beside_a_property_agreement() { + let parsed = contract(charter_contract(json!({ + "type": "permanentDocument", + "documentType": "joinRequest", + "propertyAgreement": { "title": "message" }, + "lookup": members_lookup() + }))) + .expect("parses"); + let DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + property_agreement, + lookup, + .. + }, + ) = member_id_type(&parsed) + else { + panic!("expected a permanentDocument lookup reference"); + }; + assert_eq!( + property_agreement, + BTreeMap::from([("title".to_string(), "message".to_string())]) + ); + assert_eq!(lookup.index, "bySubmittedCharter"); +} + +#[test] +fn should_refuse_a_lookup_on_any_reference_but_a_permanent_document_one() { + for reference_type in [ + "identity", + "contract", + "token", + "identityPublicKey", + "deletableDocument", + ] { + let mut refers_to = json!({ "type": reference_type, "lookup": members_lookup() }); + if reference_type == "identityPublicKey" { + refers_to["keyIdProperty"] = json!("title"); + } + let mut schema = charter_contract(refers_to); + if reference_type == "deletableDocument" { + // A deletable target, so the reference itself is well formed: only + // the lookup is out of place + refers_to_deletable_join_requests(&mut schema); + } + + // The meta-schema refuses it under full validation, and the parser on + // its own without it + contract(schema.clone()).expect_err("the meta-schema should refuse it"); + assert_refused( + contract_on(schema, false, PlatformVersion::latest()), + &format!("{reference_type} refersTo does not take lookup"), + ); + } +} + +#[test] +fn should_refuse_a_lookup_naming_a_missing_or_non_unique_index() { + for (lookup, fragment) in [ + ( + json!({ "index": "byNothing", "keys": { "submittedCharterId": "." } }), + "has no index named \"byNothing\"", + ), + ( + json!({ "index": "byMessage", "keys": { "message": "." } }), + "is not unique", + ), + ] { + assert_refused( + contract(charter_contract(permanent_join_request(lookup))), + fragment, + ); + } +} + +#[test] +fn should_refuse_lookup_keys_that_miss_or_add_an_index_property() { + for (keys, fragment) in [ + ( + json!({ "$ownerId": "." }), + "does not map \"submittedCharterId\"", + ), + ( + json!({ "submittedCharterId": "submittedCharterId", "$ownerId": ".", "message": "title" }), + "maps \"message\", which is not a property of index \"bySubmittedCharter\"", + ), + ] { + assert_refused( + contract(charter_contract(permanent_join_request( + json!({ "index": "bySubmittedCharter", "keys": keys }), + ))), + fragment, + ); + } +} + +#[test] +fn should_refuse_lookup_keys_that_use_the_reference_value_twice_or_not_at_all() { + for (keys, found) in [ + (json!({ "submittedCharterId": ".", "$ownerId": "." }), 2), + ( + json!({ "submittedCharterId": "submittedCharterId", "$ownerId": "$ownerId" }), + 0, + ), + ] { + let schema = charter_contract(permanent_join_request( + json!({ "index": "bySubmittedCharter", "keys": keys }), + )); + for full_validation in [true, false] { + assert_refused( + contract_on(schema.clone(), full_validation, PlatformVersion::latest()), + &format!("exactly one index property from \".\", the reference's own value, found {found}"), + ); + } + } +} + +#[test] +fn should_refuse_a_lookup_source_of_the_wrong_value_kind() { + assert_refused( + contract(charter_contract(permanent_join_request(json!({ + "index": "bySubmittedCharter", + "keys": { "submittedCharterId": "title", "$ownerId": "." } + })))), + "is filled from \"title\", which holds a different kind of value", + ); +} + +#[test] +fn should_refuse_an_optional_transient_or_missing_lookup_source_property() { + let lookup_reading = |source: &str| { + permanent_join_request(json!({ + "index": "bySubmittedCharter", + "keys": { "submittedCharterId": source, "$ownerId": "." } + })) + }; + + // Refused on every parse, validating or not: the rule belongs to the type + for full_validation in [true, false] { + assert_refused( + contract_on( + charter_contract(lookup_reading("alternateCharterId")), + full_validation, + PlatformVersion::latest(), + ), + "which is not required", + ); + assert_refused( + contract_on( + charter_contract(lookup_reading("ghostId")), + full_validation, + PlatformVersion::latest(), + ), + "which is not a property of the referring document type", + ); + assert_refused( + contract_on( + charter_contract(lookup_reading("memberId")), + full_validation, + PlatformVersion::latest(), + ), + "names the reference property itself", + ); + } + + let mut transient = charter_contract(lookup_reading("submittedCharterId")); + transient["documentSchemas"]["electedCharter"]["transient"] = json!(["submittedCharterId"]); + assert_refused(contract(transient), "which is transient"); + + // A required leaf inside an optional object is not always present + let mut nested = charter_contract(lookup_reading("meta.charterId")); + nested["documentSchemas"]["electedCharter"]["properties"]["meta"] = json!({ + "type": "object", + "position": 4, + "properties": { "charterId": identifier(0) }, + "required": ["charterId"], + "additionalProperties": false + }); + assert_refused(contract(nested.clone()), "which is not required"); + nested["documentSchemas"]["electedCharter"]["required"] = + json!(["submittedCharterId", "title", "meta"]); + contract(nested).expect("a required leaf of a required object is a valid source"); +} + +#[test] +fn should_refuse_a_lookup_into_a_document_type_that_can_move_the_key() { + let mutable = |schema: &mut serde_json::Value| { + schema["documentSchemas"]["joinRequest"]["documentsMutable"] = json!(true); + }; + + let mut moving = charter_contract(permanent_join_request(members_lookup())); + mutable(&mut moving); + assert_refused( + contract(moving.clone()), + "keys documents by \"submittedCharterId\", which a replace can change", + ); + + // Freezing the key's schema property is enough + let mut frozen = moving; + frozen["documentSchemas"]["joinRequest"]["immutable"] = json!(["submittedCharterId"]); + contract(frozen).expect("an immutable key property holds the key"); +} + +#[test] +fn should_refuse_a_lookup_reading_the_writer_on_a_type_that_can_change_owner() { + let reads_the_writer = charter_contract(permanent_join_request(json!({ + "index": "bySubmittedCharter", + "keys": { "submittedCharterId": ".", "$ownerId": "$ownerId" } + }))); + for (keyword, value) in [("transferable", json!(1)), ("tradeMode", json!(1))] { + let mut changes_owner = reads_the_writer.clone(); + changes_owner["documentSchemas"]["electedCharter"][keyword] = value; + assert_refused( + contract(changes_owner), + "key \"$ownerId\" reads \"$ownerId\", which a transfer or a purchase of the \ + referring document changes", + ); + } + + // A transferable type may still refer through a lookup whose key does not + // read the writer: its value and properties change only with a replace + let mut transferable = charter_contract(permanent_join_request(members_lookup())); + transferable["documentSchemas"]["electedCharter"]["transferable"] = json!(1); + contract(transferable).expect("a key that does not read the writer holds"); +} + +/// Makes the fixture's `joinRequest` deletable, the target a +/// `deletableDocument` reference needs. +fn refers_to_deletable_join_requests(schema: &mut serde_json::Value) { + schema["documentSchemas"]["joinRequest"]["canBeDeleted"] = json!(true); +} + +/// The moderation charter's `members` (#4898), verbatim: a typed array whose +/// elements must each be the owner of a join request for the charter. +fn members(refers_to: serde_json::Value) -> serde_json::Value { + let mut items = identifier(0); + items.as_object_mut().expect("an object").remove("position"); + items["distinctFrom"] = json!("$ownerId"); + items["refersTo"] = refers_to; + json!({ + "type": "array", "minItems": 0, "maxItems": 15, "uniqueItems": true, + "items": items, + "position": 4 + }) +} + +fn with_members(refers_to: serde_json::Value) -> serde_json::Value { + let mut schema = charter_contract(json!({ "type": "identity" })); + schema["documentSchemas"]["electedCharter"]["properties"]["members"] = members(refers_to); + schema +} + +#[test] +fn should_parse_a_lookup_on_the_elements_of_a_typed_array_as_the_charter_declares() { + let parsed = contract(with_members(permanent_join_request(members_lookup()))).expect("parses"); + let members = parsed + .document_type_for_name("electedCharter") + .expect("the electedCharter document type") + .flattened_properties() + .get("members") + .expect("the members property") + .property_type + .clone(); + assert_eq!( + members.reference(), + Some(PropertyReference::Elements { + target: &DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "joinRequest".to_string(), + property_agreement: BTreeMap::new(), + lookup: expected_lookup(&[ + ( + "submittedCharterId", + LookupKeySource::Property("submittedCharterId".to_string()) + ), + ("$ownerId", LookupKeySource::ReferenceValue), + ]), + }, + max_items: 15, + }) + ); +} + +#[test] +fn should_check_an_element_lookup_as_a_single_one_is_checked() { + // The referring side, on every parse + assert_refused( + contract_on( + with_members(permanent_join_request(json!({ + "index": "bySubmittedCharter", + "keys": { "submittedCharterId": "alternateCharterId", "$ownerId": "." } + }))), + false, + PlatformVersion::latest(), + ), + "document type \"electedCharter\" property \"members\" refersTo lookup: key \ + \"submittedCharterId\" reads \"alternateCharterId\", which is not required", + ); + // The referenced side, at contract level + assert_refused( + contract(with_members(permanent_join_request( + json!({ "index": "byMessage", "keys": { "message": "." } }), + ))), + "index \"byMessage\" of \"joinRequest\" is not unique", + ); + // And never on a deletableDocument reference + let mut deletable = with_members(json!({ + "type": "deletableDocument", + "documentType": "joinRequest", + "lookup": members_lookup() + })); + refers_to_deletable_join_requests(&mut deletable); + contract(deletable.clone()).expect_err("the meta-schema should refuse it"); + assert_refused( + contract_on(deletable, false, PlatformVersion::latest()), + "deletableDocument refersTo does not take lookup", + ); +} + +#[test] +fn should_refuse_a_lookup_below_protocol_version_14_and_accept_it_at_14() { + let schema = charter_contract(permanent_join_request(members_lookup())); + 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 whole declaration, lookup and all + let ignored = contract_on(schema.clone(), false, platform_version_13) + .expect("protocol version 13 should parse it as a plain identifier"); + assert_eq!(member_id_type(&ignored), DocumentPropertyType::Identifier); + + let accepted = contract_on(schema, true, PlatformVersion::latest()).expect("parses"); + assert!(matches!( + member_id_type(&accepted), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } + ) + )); +} + +#[test] +fn should_leave_a_lookup_into_another_contract_to_registration() { + // The referenced type is not in this contract, so the parse cannot see its + // indexes: registration checks them against the other contract in state + let parsed = contract(charter_contract(json!({ + "type": "permanentDocument", + "contractId": Identifier::from([9; 32]).to_string(Encoding::Base58), + "documentType": "joinRequest", + "lookup": { "index": "byAnything", "keys": { "anything": "." } } + }))) + .expect("parses"); + assert!(matches!( + member_id_type(&parsed), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: Some(_), + .. + } + ) + )); +} + +#[test] +fn should_round_trip_a_contract_through_platform_serialization_with_and_without_a_lookup() { + let platform_version = PlatformVersion::latest(); + + for refers_to in [ + json!({ "type": "permanentDocument", "documentType": "joinRequest" }), + permanent_join_request(members_lookup()), + ] { + let original = contract(charter_contract(refers_to.clone())).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, "refersTo {refers_to}"); + assert_eq!(member_id_type(&original), member_id_type(&recovered)); + } + + // Without a lookup, the parsed reference is exactly the id reference it was + let without = contract(charter_contract( + json!({ "type": "permanentDocument", "documentType": "joinRequest" }), + )) + .expect("parses"); + assert_eq!( + member_id_type(&without), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: None, + document_type_name: "joinRequest".to_string(), + property_agreement: BTreeMap::new(), + } + ) + ); +} + +#[test] +fn should_refuse_malformed_lookup_declarations_in_the_parser() { + for (lookup, fragment) in [ + ( + json!({ "index": "bySubmittedCharter" }), + "must declare keys", + ), + ( + json!({ "index": "bySubmittedCharter", "keys": {} }), + "between 1 and 10 index properties", + ), + ( + json!({ "index": "", "keys": { "$ownerId": "." } }), + "lookup index must be between 1 and 32 characters", + ), + ( + json!({ "index": "bySubmittedCharter", "keys": { "$ownerId": 1 } }), + "must map each index property to a string", + ), + ( + json!({ "index": "bySubmittedCharter", "keys": { "$ownerId": "." }, "extra": true }), + "lookup \"extra\" is unknown", + ), + ( + json!({ "index": "bySubmittedCharter", "keys": { "submittedCharterId": "$createdAt", "$ownerId": "." } }), + "not system property \"$createdAt\"", + ), + ] { + let schema = charter_contract(permanent_join_request(lookup.clone())); + assert_refused( + contract_on(schema.clone(), false, PlatformVersion::latest()), + fragment, + ); + contract(schema).expect_err("the meta-schema should refuse it too"); + } +} + +/// The document type parse alone, without the rest of the contract, as a +/// client building one document type sees it. +#[test] +fn should_check_only_the_referring_side_when_a_document_type_is_parsed_alone() { + let platform_version = PlatformVersion::latest(); + let config = + DataContractConfig::default_for_version(platform_version).expect("config should build"); + let schema = charter_contract(permanent_join_request(json!({ + "index": "byNothing", + "keys": { "anything": "." } + }))); + let elected_charter: Value = + platform_value::to_value(schema["documentSchemas"]["electedCharter"].clone()) + .expect("the schema should convert"); + DocumentType::try_from_schema( + Identifier::from(CONTRACT_ID), + 1, + config.version(), + "electedCharter", + elected_charter, + None, + &BTreeMap::new(), + &config, + true, + &mut vec![], + platform_version, + ) + .expect("the referenced type is out of sight, so only the sources are checked"); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs index b96f40a863b..75eb6892bb3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs @@ -107,7 +107,9 @@ impl Index { continue; }; // Only a scalar reference can bind: an index property is never a - // typed array, so element references never reach an index + // typed array, so element references never reach an index. A + // lookup reference (`PermanentDocumentLookup`) never matches + // either: its value is not the referenced document's `$id` let DocumentPropertyType::IdentifierWithReference( DocumentPropertyReferenceTarget::PermanentDocument { contract_id, @@ -165,7 +167,9 @@ impl Index { #[cfg(test)] mod tests { use super::*; - use crate::data_contract::document_type::IndexProperty; + use crate::data_contract::document_type::{ + DocumentReferenceLookup, IndexProperty, LookupKeySource, + }; use std::collections::BTreeMap; fn identifier_reference_property( @@ -359,4 +363,28 @@ mod tests { ); assert_eq!(index.preallocation_bindings(&own, own_contract_id).len(), 1); } + #[test] + fn should_not_bind_through_a_lookup_reference() { + let own_contract_id = Identifier::from([1u8; 32]); + let mut property = identifier_reference_property("post", None, &[]); + property.property_type = DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "post".to_string(), + property_agreement: BTreeMap::new(), + lookup: DocumentReferenceLookup { + index: "byAuthor".to_string(), + keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), + }, + }, + ); + let mut properties = IndexMap::new(); + properties.insert("postId".to_string(), property); + + // The value names the post's owner, not the post, so no path follows + // from the post being created + assert!(index_on(&["postId"]) + .preallocation_bindings(&properties, own_contract_id) + .is_empty()); + } } 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 30228187644..8aebc1c788a 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 @@ -2235,6 +2235,53 @@ mod tests { assert!(result.is_valid(), "{:?}", result.errors); } + #[test] + fn should_return_invalid_result_when_a_document_reference_lookup_changes() { + let platform_version = PlatformVersion::latest(); + let owner_lookup = |index: &str| { + platform_value!({ + "type": "permanentDocument", + "documentType": "note", + "lookup": { "index": index, "keys": { "$ownerId": "." } } + }) + }; + + for (old_refers_to, new_refers_to, changed_path) in [ + ( + platform_value!({ "type": "permanentDocument", "documentType": "note" }), + owner_lookup("byOwner"), + "/properties/toUserId/refersTo/lookup", + ), + ( + owner_lookup("byOwner"), + platform_value!({ "type": "permanentDocument", "documentType": "note" }), + "/properties/toUserId/refersTo/lookup", + ), + ( + owner_lookup("byOwner"), + owner_lookup("byAuthor"), + "/properties/toUserId/refersTo/lookup/index", + ), + ] { + let old_document_type = + identifier_document_type(Some(old_refers_to), platform_version); + let new_document_type = + identifier_document_type(Some(new_refers_to), platform_version); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.property_path() == changed_path + ); + } + } + /// `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 8b2c384f8bf..ed17c917031 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -114,6 +114,14 @@ pub(crate) mod property_names { /// [`KEY_ID_PROPERTY`]; a declaration carries one or the other. pub const IDENTITY_PROPERTY: &str = "identityProperty"; pub const PROPERTY_AGREEMENT: &str = "propertyAgreement"; + /// `refersTo` on a document reference: the unique index of the referenced + /// document type the referenced document is found through, and the key. + /// Meta-schema v3+ (protocol version 14). + pub const LOOKUP: &str = "lookup"; + /// `lookup`: the name of the referenced document type's unique index. + pub const LOOKUP_INDEX: &str = "index"; + /// `lookup`: every index property mapped to its referring-side source. + pub const LOOKUP_KEYS: &str = "keys"; 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/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs index 8f2eb424585..3477d4cfe66 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 @@ -40,8 +40,10 @@ use serde::{Deserialize, Serialize}; pub mod array; pub mod encrypted_for; +pub mod reference_lookup; pub use encrypted_for::{EncryptedFor, EncryptedForRecipient, EncryptionScheme}; +pub use reference_lookup::{DocumentReferenceLookup, LookupKeySource}; #[cfg(test)] mod byte_array_encoding_flip_tests; @@ -791,7 +793,9 @@ pub enum DocumentPropertyReferenceTarget { /// expect the reference to resolve to nothing. It can not come back /// pointing at something else: a document id commits to the nonce of /// its create transition, so an id is produced at most once and a - /// reference means that one document or nothing. A WRITER may not leave + /// reference means that one document or nothing (which is why there is + /// no lookup form of it: a key could find a new document once the one it + /// found is deleted). A WRITER may not leave /// it that way: every replace of the referring document re-validates /// the reference, so a dead one has to be repointed at a document that /// exists or cleared (on an `immutable` property, clearing is the only @@ -808,6 +812,39 @@ pub enum DocumentPropertyReferenceTarget { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] property_agreement: BTreeMap, }, + /// A `permanentDocument` reference declared with a `lookup`: the + /// property's value (or each element of a typed array) is NOT the + /// referenced document's id, but one part of a key; the referenced + /// document is the one the named unique index of the referenced document + /// type finds for the key the [`DocumentReferenceLookup`] assembles from + /// the referring document. Everything else is as for + /// [`Self::PermanentDocument`]: the referenced type must forbid deletion, + /// the agreement pairs are checked against the document found, and the + /// key must stay with that document (its parts cannot be changed by a + /// replace, a transfer or a purchase), so the reference can not dangle + /// either. There is no deletable form: 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. + /// + /// A variant of its own rather than a field of + /// [`Self::PermanentDocument`], appended as this enum's rule requires: an + /// id reference keeps its consensus encoding (the enum is embedded in + /// reference errors), and code matching `PermanentDocument` as "the value + /// is a document id" can not mistake a lookup for one. It serializes + /// under the same `permanentDocument` tag, with a `lookup` field (the + /// enum is serialize-only, so the shared tag is never read back). + #[serde(rename = "permanentDocument")] + PermanentDocumentLookup { + /// The contract the referenced document type lives in; `None` means + /// the declaring contract itself + contract_id: Option, + document_type_name: String, + /// See [`Self::PermanentDocument`]'s `property_agreement`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + property_agreement: BTreeMap, + /// How the referenced document is found. + lookup: DocumentReferenceLookup, + }, } /// The declaration content the two document reference targets, @@ -825,12 +862,30 @@ pub struct DocumentReferenceDeclaration<'a> { /// Whether the referenced document type must forbid deletion /// (`permanentDocument`) or must allow it (`deletableDocument`) pub permanent: bool, + /// How the referenced document is found when the value is not its id + /// ([`DocumentPropertyReferenceTarget::PermanentDocumentLookup`]); `None` + /// when the value is the referenced document's id. Only + /// [`DocumentPropertyReferenceTarget::as_any_document_reference`] ever + /// returns a declaration carrying one. + pub lookup: Option<&'a DocumentReferenceLookup>, } impl DocumentPropertyReferenceTarget { - /// The declaration of a reference to a DOCUMENT, of either kind; - /// `None` for every other target. + /// 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`]. pub fn as_document_reference(&self) -> Option> { + self.as_any_document_reference() + .filter(|declaration| declaration.lookup.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. + pub fn as_any_document_reference(&self) -> Option> { match self { DocumentPropertyReferenceTarget::PermanentDocument { contract_id, @@ -841,6 +896,19 @@ impl DocumentPropertyReferenceTarget { document_type_name, property_agreement, permanent: true, + lookup: None, + }), + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id, + document_type_name, + property_agreement, + lookup, + } => Some(DocumentReferenceDeclaration { + contract_id: *contract_id, + document_type_name, + property_agreement, + permanent: true, + lookup: Some(lookup), }), DocumentPropertyReferenceTarget::DeletableDocument { contract_id, @@ -851,6 +919,7 @@ impl DocumentPropertyReferenceTarget { document_type_name, property_agreement, permanent: false, + lookup: None, }), DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::Contract { .. } @@ -979,20 +1048,21 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { } DocumentPropertyReferenceTarget::Token => write!(f, "token"), DocumentPropertyReferenceTarget::PermanentDocument { - contract_id: Some(contract_id), + contract_id, document_type_name, .. - } => write!( - f, - "permanent document (contract {contract_id}, document type {document_type_name})" - ), - DocumentPropertyReferenceTarget::PermanentDocument { - contract_id: None, + } => write_document_reference(f, "permanent", *contract_id, document_type_name, None), + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id, document_type_name, + lookup, .. - } => write!( + } => write_document_reference( f, - "permanent document (own contract, document type {document_type_name})" + "permanent", + *contract_id, + document_type_name, + Some(lookup), ), DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property, @@ -1008,21 +1078,10 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { Ok(()) } DocumentPropertyReferenceTarget::DeletableDocument { - contract_id: Some(contract_id), - document_type_name, - .. - } => write!( - f, - "deletable document (contract {contract_id}, document type {document_type_name})" - ), - DocumentPropertyReferenceTarget::DeletableDocument { - contract_id: None, + contract_id, document_type_name, .. - } => write!( - f, - "deletable document (own contract, document type {document_type_name})" - ), + } => write_document_reference(f, "deletable", *contract_id, document_type_name, None), } } } @@ -1119,6 +1178,32 @@ impl KeyIdReference { } } +/// How the two document reference targets read: the kind, the contract, the +/// document type and, for a lookup, the unique index the document is found +/// through. +fn write_document_reference( + f: &mut std::fmt::Formatter<'_>, + kind: &str, + contract_id: Option, + document_type_name: &str, + lookup: Option<&DocumentReferenceLookup>, +) -> std::fmt::Result { + match contract_id { + Some(contract_id) => write!( + f, + "{kind} document (contract {contract_id}, document type {document_type_name}" + )?, + None => write!( + f, + "{kind} document (own contract, document type {document_type_name}" + )?, + } + if let Some(lookup) = lookup { + write!(f, ", found through unique index {}", lookup.index)?; + } + write!(f, ")") +} + // @append_only #[derive(Debug, PartialEq, Clone, Serialize)] pub enum DocumentPropertyType { @@ -1197,6 +1282,23 @@ impl DocumentPropertyType { } } + /// The kind of value this type holds, for the rules that compare a value of + /// one property with a value of another (`propertyAgreement` pairs, + /// `lookup` key parts): two types of the same kind can hold equal values. + /// Sizes and other constraints do not count, and an identifier, or a `u32` + /// key id, is one kind whether or not it carries its own reference. + pub fn value_kind(&self) -> std::mem::Discriminant { + match self { + DocumentPropertyType::IdentifierWithReference(_) => { + std::mem::discriminant(&DocumentPropertyType::Identifier) + } + DocumentPropertyType::KeyIdWithReference(_) => { + std::mem::discriminant(&DocumentPropertyType::U32) + } + other => std::mem::discriminant(other), + } + } + pub fn name(&self) -> String { match self { DocumentPropertyType::U128 => "u128".to_string(), @@ -9490,6 +9592,20 @@ mod tests { .to_string(), "deletable document (own contract, document type note)" ); + assert_eq!( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "joinRequest".to_string(), + property_agreement: Default::default(), + lookup: DocumentReferenceLookup { + index: "bySubmittedCharter".to_string(), + keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), + }, + } + .to_string(), + "permanent document (own contract, document type joinRequest, found through unique \ + index bySubmittedCharter)" + ); } #[test] @@ -9521,6 +9637,48 @@ mod tests { .is_none()); } + /// A lookup reference is a document reference, but its value is not a + /// document id: only the accessor for every kind returns it, so code that + /// treats the value as an id can not take it for one. + #[test] + fn should_return_a_lookup_reference_only_from_the_accessor_for_every_kind() { + let lookup = DocumentReferenceLookup { + index: "bySubmittedCharter".to_string(), + keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), + }; + let target = DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "joinRequest".to_string(), + property_agreement: Default::default(), + lookup: lookup.clone(), + }; + + assert_eq!(target.as_document_reference(), None); + let declaration = target + .as_any_document_reference() + .expect("a document reference"); + assert!(declaration.permanent); + assert_eq!(declaration.document_type_name, "joinRequest"); + assert_eq!(declaration.lookup, Some(&lookup)); + + // An id reference is returned by both, without a lookup + let id_reference = DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: None, + document_type_name: "joinRequest".to_string(), + property_agreement: Default::default(), + }; + assert_eq!( + id_reference.as_document_reference(), + id_reference.as_any_document_reference() + ); + assert_eq!( + id_reference + .as_document_reference() + .and_then(|declaration| declaration.lookup), + None + ); + } + fn key_with(purpose: Purpose, contract_bounds: Option) -> IdentityPublicKey { IdentityPublicKey::V0(IdentityPublicKeyV0 { id: 2, @@ -9672,8 +9830,8 @@ mod tests { /// notably by wasm-dpp2's `DocumentPropertyReference` TypeScript union /// and the conversion that builds it. Those live behind a `match` that /// a new variant would not break, because they can fall back to a - /// catch-all. This exhaustive `match` has no catch-all, so adding a - /// seventh variant fails to compile *here*, in the crate that owns the + /// catch-all. This exhaustive `match` has no catch-all, so adding an + /// eighth variant fails to compile *here*, in the crate that owns the /// enum, where whoever adds it will see it. #[test] fn reference_targets_are_exhaustively_mirrored() { @@ -9697,6 +9855,15 @@ mod tests { document_type_name: "note".to_string(), property_agreement: Default::default(), }, + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "note".to_string(), + property_agreement: Default::default(), + lookup: DocumentReferenceLookup { + index: "byOwner".to_string(), + keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), + }, + }, ]; for target in &targets { @@ -9708,6 +9875,9 @@ mod tests { DocumentPropertyReferenceTarget::PermanentDocument { .. } => "permanentDocument", DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => "identityPublicKey", DocumentPropertyReferenceTarget::DeletableDocument { .. } => "deletableDocument", + DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } => { + "permanentDocument" + } }; // The tag is the `refersTo` schema keyword's own `type` value, 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 new file mode 100644 index 00000000000..5813a1b4be6 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs @@ -0,0 +1,826 @@ +//! The `lookup` of a document reference: the referenced document is the one a +//! unique index of the referenced document type finds for a key assembled from +//! the referring document, rather than the document whose id the property holds. +//! +//! Declared inside a `permanentDocument` `refersTo` (meta-schema v3, protocol +//! version 14); a `deletableDocument` reference cannot carry one, since 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: +//! +//! ```json +//! "refersTo": { +//! "type": "permanentDocument", +//! "documentType": "joinRequest", +//! "lookup": { +//! "index": "bySubmittedCharter", +//! "keys": { "submittedCharterId": "submittedCharterId", "$ownerId": "." } +//! } +//! } +//! ``` +//! +//! reads: the value must be the owner of a `joinRequest` whose +//! `submittedCharterId` equals this document's `submittedCharterId`. 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::DocumentPropertyType; +use crate::data_contract::document_type::DocumentTypeRef; +use crate::data_contract::errors::DataContractError; +use crate::document::property_names::{ + CREATED_AT, CREATED_AT_BLOCK_HEIGHT, CREATED_AT_CORE_BLOCK_HEIGHT, CREATOR_ID, ID, OWNER_ID, + TRANSFERRED_AT, TRANSFERRED_AT_BLOCK_HEIGHT, TRANSFERRED_AT_CORE_BLOCK_HEIGHT, UPDATED_AT, + UPDATED_AT_BLOCK_HEIGHT, UPDATED_AT_CORE_BLOCK_HEIGHT, +}; +use crate::nft::TradeMode; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_value::btreemap_extensions::BTreeValueMapPathHelper; +use platform_value::{Identifier, Value}; +use serde::Serialize; +use std::collections::BTreeMap; +use std::mem::Discriminant; + +/// The wire name of [`LookupKeySource::ReferenceValue`]. +pub const LOOKUP_REFERENCE_VALUE: &str = "."; + +/// Where the value of one key part of a [`DocumentReferenceLookup`] comes from, +/// on the referring side. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] +#[serde(into = "String")] +pub enum LookupKeySource { + /// `"."`: the value of the property carrying the reference, or, for a + /// reference on the elements of an array, the element. The one part of the + /// key the reference's own value supplies. + ReferenceValue, + /// `"$ownerId"`: the `$ownerId` of the referring document, the writer. + OwnerId, + /// A property path of the referring document type. The property must be + /// required, so the key is never missing a part. + Property(String), +} + +impl LookupKeySource { + /// The source a wire name names: `"."`, `"$ownerId"` or a property path. + /// Any other `$`-prefixed name is refused: the writer is the only system + /// value a referring document carries that a key could need. + pub fn from_wire_name(name: &str) -> Result { + match name { + LOOKUP_REFERENCE_VALUE => Ok(LookupKeySource::ReferenceValue), + OWNER_ID => Ok(LookupKeySource::OwnerId), + _ if name.starts_with('$') => { + Err(DataContractError::InvalidContractStructure(format!( + "refersTo lookup keys take \".\", \"{OWNER_ID}\" or a property of the \ + referring document type, not system property \"{name}\"" + ))) + } + _ if name.is_empty() || name.len() > MAX_LOOKUP_PATH_LENGTH => { + Err(DataContractError::InvalidContractStructure(format!( + "refersTo lookup key sources must be between 1 and {MAX_LOOKUP_PATH_LENGTH} \ + characters" + ))) + } + _ => Ok(LookupKeySource::Property(name.to_string())), + } + } + + /// The wire name, as the schema spells it. + pub fn as_str(&self) -> &str { + match self { + LookupKeySource::ReferenceValue => LOOKUP_REFERENCE_VALUE, + LookupKeySource::OwnerId => OWNER_ID, + LookupKeySource::Property(path) => path.as_str(), + } + } +} + +impl From for String { + fn from(source: LookupKeySource) -> Self { + source.as_str().to_string() + } +} + +/// The longest index property name or source path a lookup may name, the bound +/// meta-schema v3 puts on index property names. +pub const MAX_LOOKUP_PATH_LENGTH: usize = 256; + +/// The most key parts a lookup may map, the most properties an index can have. +pub const MAX_LOOKUP_KEYS: usize = 10; + +/// The longest index name a lookup may name, the bound meta-schema v3 puts on +/// index names. +pub const MAX_LOOKUP_INDEX_NAME_LENGTH: usize = 32; + +/// How a document reference finds the referenced document when its value is not +/// that document's id: through the unique index `index` of the referenced +/// document type, with one key part per index property. +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Encode, Decode, DecodeUntrusted)] +pub struct DocumentReferenceLookup { + /// The name of a unique index of the referenced document type. + pub index: String, + /// Every property of the index, by its name on the referenced side + /// (`$ownerId` among the system ones), mapped to the referring-side source + /// of its value. [`LookupKeySource::ReferenceValue`] appears exactly once. + pub keys: BTreeMap, +} + +impl DocumentReferenceLookup { + /// The referring document type's property paths the key reads, in key + /// order: a replace that changes one of them re-validates the reference. + pub fn referring_properties(&self) -> impl Iterator { + self.keys.values().filter_map(|source| match source { + LookupKeySource::Property(path) => Some(path.as_str()), + LookupKeySource::ReferenceValue | LookupKeySource::OwnerId => None, + }) + } + + /// Why the referring side of this lookup, declared on the property at + /// `reference_path` of `declaring`, cannot always assemble a key; `None` + /// when it can. Every property source must be a stored, required, single + /// value of the declaring type (with every object around it required too), + /// so the lookup never runs with a missing key part and a reader can + /// assemble the same key from the stored document; it may not be the + /// reference property itself, whose value is `"."`. A `"$ownerId"` source + /// needs a declaring type whose documents cannot be transferred or + /// traded: the reference is validated when the document is written, and + /// a transfer or a purchase would move the writer part of the key + /// without any write, leaving a validated reference that finds nothing. + /// With that, every part of the referring side's key changes only through + /// a replace, which re-validates the reference. + pub fn referring_side_error( + &self, + declaring: DocumentTypeRef, + reference_path: &str, + ) -> Option { + for (index_property, source) in &self.keys { + let path = match source { + LookupKeySource::ReferenceValue => continue, + LookupKeySource::OwnerId => { + if owner_can_change(declaring) { + return Some(format!( + "key \"{index_property}\" reads \"$ownerId\", which a transfer or a \ + purchase of the referring document changes without re-validating \ + the reference: a lookup may read the writer only on a document type \ + that cannot be transferred or traded" + )); + } + continue; + } + LookupKeySource::Property(path) => path, + }; + if path == reference_path { + return Some(format!( + "key \"{index_property}\" names the reference property itself: write \".\" \ + for the reference's own value" + )); + } + let Some(property) = declaring.flattened_properties().get(path) else { + return Some(format!( + "key \"{index_property}\" reads \"{path}\", which is not a property of the \ + referring document type" + )); + }; + if matches!( + property.property_type, + DocumentPropertyType::TypedArray(_) + | DocumentPropertyType::Array(_) + | DocumentPropertyType::VariableTypeArray(_) + | DocumentPropertyType::Object(_) + ) { + return Some(format!( + "key \"{index_property}\" reads \"{path}\", which is not a single value" + )); + } + if declaring.transient_fields().contains(path) { + return Some(format!( + "key \"{index_property}\" reads \"{path}\", which is transient: the key must \ + be readable from the stored document" + )); + } + // A required leaf inside an optional object is only present when the + // object is, so every ancestor must be required as well + let mut prefix = String::new(); + for segment in path.split('.') { + if !prefix.is_empty() { + prefix.push('.'); + } + prefix.push_str(segment); + if !declaring.required_fields().contains(&prefix) { + return Some(format!( + "key \"{index_property}\" reads \"{path}\", which is not required: a \ + lookup never runs with a missing key part, so every property it reads \ + (and every object around it) must be listed in `required`" + )); + } + } + } + None + } + + /// Why this lookup, declared on a property of `declaring`, cannot resolve + /// in `referenced`, the referenced document type; `None` when it can. The + /// index must exist and be unique, so the key finds at most one document; + /// it may not bucket a timestamp (`timeRange`), since its first key part + /// is then a bucket start no referring value names; the referenced type + /// may not be `indexOnly`; `keys` must map every property of the index + /// exactly once and nothing else; and each source must hold the same kind + /// of value as the index property it fills, or no document could ever + /// match. The key must also stay with the document it found, see + /// [`Self::moving_key_part`]. + pub fn referenced_side_error( + &self, + declaring: DocumentTypeRef, + referenced: DocumentTypeRef, + ) -> Option { + let Some(index) = referenced.indexes().get(&self.index) else { + return Some(format!( + "the referenced document type \"{}\" has no index named \"{}\"", + referenced.name(), + self.index + )); + }; + if !index.unique { + return Some(format!( + "index \"{}\" of \"{}\" is not unique: a lookup must find at most one document", + self.index, + referenced.name() + )); + } + if index.time_range.is_some() { + return Some(format!( + "index \"{}\" of \"{}\" buckets its first property by a timeRange, so no \ + referring value could name a key of it", + self.index, + referenced.name() + )); + } + if referenced.index_only() { + return Some(format!( + "\"{}\" is an indexOnly document type, which a lookup cannot reference", + referenced.name() + )); + } + if let Some(missing) = index + .properties + .iter() + .find(|property| !self.keys.contains_key(&property.name)) + { + return Some(format!( + "keys does not map \"{}\", a property of index \"{}\": every index property \ + needs a source", + missing.name, self.index + )); + } + if let Some(extra) = self.keys.keys().find(|name| { + !index + .properties + .iter() + .any(|property| &property.name == *name) + }) { + return Some(format!( + "keys maps \"{extra}\", which is not a property of index \"{}\"", + self.index + )); + } + for (index_property, source) in &self.keys { + let Some(indexed_kind) = index_property_value_kind(referenced, index_property) else { + return Some(format!( + "index property \"{index_property}\" is not a property of \"{}\"", + referenced.name() + )); + }; + let source_kind = match source { + // Both are identifiers, the reference's own value because + // `refersTo` is only allowed on identifiers + LookupKeySource::ReferenceValue | LookupKeySource::OwnerId => { + std::mem::discriminant(&DocumentPropertyType::Identifier) + } + LookupKeySource::Property(path) => { + let Some(property) = declaring.flattened_properties().get(path) else { + return Some(format!( + "key \"{index_property}\" reads \"{path}\", which is not a property \ + of the referring document type" + )); + }; + property.property_type.value_kind() + } + }; + if source_kind != indexed_kind { + return Some(format!( + "key \"{index_property}\" is filled from \"{}\", which holds a different \ + kind of value: no document could ever match", + source.as_str() + )); + } + } + if let Some((index_property, why)) = self.moving_key_part(referenced) { + return Some(format!( + "index \"{}\" of \"{}\" keys documents by \"{index_property}\", which {why}: a \ + lookup must keep finding the document it found, so every part of its key must \ + be fixed once the document is written (make the type immutable or list the \ + property under `immutable`)", + self.index, + referenced.name() + )); + } + None + } + + /// The first key part a referenced document of `referenced` can change + /// after it was written, with how, `None` when every part is fixed for + /// good. A `permanentDocument` reference, the only kind that takes a + /// lookup, promises it never dangles: its referenced type forbids + /// deletion, and a lookup's key must not move off the document either, or + /// a document validated against it would later find nothing. A schema property is fixed on a type whose documents are + /// immutable or when its top-level property is listed under `immutable` + /// (an `immutableAllowSetting` entry can only be set on a document that + /// has no value for it, which no key could have found); `$ownerId` is + /// fixed unless documents can be transferred or traded; `$id`, + /// `$creatorId` and the creation times never change; the update and + /// transfer times change with the document. Every flag read here is + /// immutable on contract update, and the `immutable` list may only grow, + /// so the answer holds for good. + fn moving_key_part(&self, referenced: DocumentTypeRef) -> Option<(&str, &'static str)> { + let changes_owner = owner_can_change(referenced); + let replaceable = referenced.documents_mutable(); + self.keys.keys().find_map(|index_property| { + let why = match index_property.as_str() { + ID + | CREATOR_ID + | CREATED_AT + | CREATED_AT_BLOCK_HEIGHT + | CREATED_AT_CORE_BLOCK_HEIGHT => None, + OWNER_ID => changes_owner.then_some("a transfer or a purchase changes"), + UPDATED_AT | UPDATED_AT_BLOCK_HEIGHT | UPDATED_AT_CORE_BLOCK_HEIGHT => (replaceable + || changes_owner) + .then_some("a replace, a transfer or a purchase changes"), + 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") + } + }; + why.map(|why| (index_property.as_str(), why)) + }) + } + + /// The equality values of the key the lookup assembles for one write, by + /// index property name: `reference_value` for `"."` (the property's value, + /// or one array element's), `owner_id` for `"$ownerId"`, and the value at + /// each source path in `document_data`. `None` when a source path holds no + /// value or does not resolve. Registration admits required sources only, + /// so a value is missing only on a document stamped before the property + /// became required (`requiredSince`) or one the schema validation refuses + /// anyway, and a key with a missing part finds no document. + pub fn key_values( + &self, + reference_value: Identifier, + document_data: &BTreeMap, + owner_id: Identifier, + ) -> Option> { + self.keys + .iter() + .map(|(index_property, source)| { + let value = match source { + LookupKeySource::ReferenceValue => { + Value::Identifier(reference_value.to_buffer()) + } + LookupKeySource::OwnerId => Value::Identifier(owner_id.to_buffer()), + LookupKeySource::Property(path) => { + document_data.get_optional_at_path(path).ok()??.clone() + } + }; + Some((index_property.clone(), value)) + }) + .collect() + } +} + +/// 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. +/// Whether a document of `document_type` can change owner after it was +/// written, by a transfer or a purchase. Both flags are immutable on contract +/// update, so the answer holds for good. +fn owner_can_change(document_type: DocumentTypeRef) -> bool { + document_type.documents_transferable().is_transferable() + || document_type.trade_mode() != TradeMode::None +} + +fn index_property_value_kind( + document_type: DocumentTypeRef, + name: &str, +) -> Option> { + let system_type = match name { + ID | OWNER_ID | CREATOR_ID => Some(DocumentPropertyType::Identifier), + CREATED_AT | UPDATED_AT | TRANSFERRED_AT => Some(DocumentPropertyType::Date), + CREATED_AT_BLOCK_HEIGHT | UPDATED_AT_BLOCK_HEIGHT | TRANSFERRED_AT_BLOCK_HEIGHT => { + Some(DocumentPropertyType::U64) + } + CREATED_AT_CORE_BLOCK_HEIGHT + | UPDATED_AT_CORE_BLOCK_HEIGHT + | TRANSFERRED_AT_CORE_BLOCK_HEIGHT => Some(DocumentPropertyType::U32), + _ => None, + }; + match system_type { + Some(system_type) => Some(system_type.value_kind()), + None => document_type + .flattened_properties() + .get(name) + .map(|property| property.property_type.value_kind()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::data_contract::config::DataContractConfig; + use crate::data_contract::document_type::DocumentType; + use platform_value::platform_value; + use platform_version::version::PlatformVersion; + + fn identifier() -> Value { + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32u32, + "maxItems": 32u32, + "contentMediaType": "application/x.dash.dpp.identifier" + }) + } + + fn with_position(mut property: Value, position: u32) -> Value { + property + .insert("position".to_string(), Value::U32(position)) + .expect("the position inserts"); + property + } + + fn document_type(name: &str, schema: Value) -> DocumentType { + let platform_version = PlatformVersion::latest(); + let config = + DataContractConfig::default_for_version(platform_version).expect("config should build"); + DocumentType::try_from_schema( + Identifier::from([1; 32]), + 1, + config.version(), + name, + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut vec![], + platform_version, + ) + .expect("the document type should parse") + } + + /// A `joinRequest` with a unique (`submittedCharterId`, `$ownerId`) index + /// and a non-unique `byNote` index, immutable and not transferable, with + /// `extra` merged into its schema. + fn join_request_with(extra: Value) -> DocumentType { + let mut schema = platform_value!({ + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "submittedCharterId": with_position(identifier(), 0), + "note": { "type": "string", "maxLength": 32u32, "position": 1u32 } + }, + "indices": [ + { + "name": "bySubmittedCharter", + "properties": [{ "submittedCharterId": "asc" }, { "$ownerId": "asc" }], + "unique": true + }, + { "name": "byNote", "properties": [{ "note": "asc" }] } + ], + "required": ["submittedCharterId"], + "additionalProperties": false + }); + merge_keywords(&mut schema, extra); + document_type("joinRequest", schema) + } + + /// Merges the top-level keywords of `extra` into `schema`. + fn merge_keywords(schema: &mut Value, extra: Value) { + if let Value::Map(entries) = extra { + for (key, value) in entries { + let key = key.to_text().expect("a text key"); + schema.insert(key, value).expect("the keyword inserts"); + } + } + } + + fn join_request() -> DocumentType { + join_request_with(platform_value!({})) + } + + /// An `electedCharter` with a required `submittedCharterId`, an optional + /// `optionalCharterId`, a string `note` and the reference `memberId`, with + /// `extra` merged into its schema. + fn elected_charter_with(extra: Value) -> DocumentType { + let mut schema = platform_value!({ + "type": "object", + "properties": { + "submittedCharterId": with_position(identifier(), 0), + "optionalCharterId": with_position(identifier(), 1), + "note": { "type": "string", "maxLength": 32u32, "position": 2u32 }, + "memberId": with_position(identifier(), 3) + }, + "required": ["submittedCharterId", "note"], + "additionalProperties": false + }); + merge_keywords(&mut schema, extra); + document_type("electedCharter", schema) + } + + fn elected_charter() -> DocumentType { + elected_charter_with(platform_value!({})) + } + + fn lookup(index: &str, keys: &[(&str, &str)]) -> DocumentReferenceLookup { + DocumentReferenceLookup { + index: index.to_string(), + keys: keys + .iter() + .map(|(index_property, source)| { + ( + index_property.to_string(), + LookupKeySource::from_wire_name(source).expect("a valid source"), + ) + }) + .collect(), + } + } + + #[test] + fn should_accept_a_lookup_covering_a_unique_index_with_sources_of_the_right_kind() { + let lookup = lookup( + "bySubmittedCharter", + &[ + ("submittedCharterId", "submittedCharterId"), + ("$ownerId", "."), + ], + ); + let declaring = elected_charter(); + assert_eq!( + lookup.referring_side_error(declaring.as_ref(), "memberId"), + None + ); + assert_eq!( + lookup.referenced_side_error(declaring.as_ref(), join_request().as_ref()), + None + ); + } + + #[test] + fn should_refuse_a_missing_or_non_unique_index() { + let declaring = elected_charter(); + for (index, fragment) in [ + ("byMissing", "has no index named \"byMissing\""), + ("byNote", "is not unique"), + ] { + let lookup = lookup(index, &[("note", "."), ("x", "note")]); + let error = lookup + .referenced_side_error(declaring.as_ref(), join_request().as_ref()) + .expect("the index should be refused"); + assert!(error.contains(fragment), "{index}: {error}"); + } + } + + #[test] + fn should_refuse_keys_that_miss_or_add_an_index_property() { + let declaring = elected_charter(); + for (keys, fragment) in [ + ( + vec![("$ownerId", ".")], + "does not map \"submittedCharterId\"", + ), + ( + vec![ + ("submittedCharterId", "submittedCharterId"), + ("$ownerId", "."), + ("note", "note"), + ], + "maps \"note\", which is not a property of index", + ), + ] { + let error = lookup("bySubmittedCharter", &keys) + .referenced_side_error(declaring.as_ref(), join_request().as_ref()) + .expect("the keys should be refused"); + assert!(error.contains(fragment), "{keys:?}: {error}"); + } + } + + #[test] + fn should_refuse_a_source_of_the_wrong_value_kind() { + let declaring = elected_charter(); + let error = lookup( + "bySubmittedCharter", + &[("submittedCharterId", "note"), ("$ownerId", ".")], + ) + .referenced_side_error(declaring.as_ref(), join_request().as_ref()) + .expect("the string source should be refused"); + assert!(error.contains("holds a different kind of value"), "{error}"); + } + + #[test] + fn should_refuse_an_optional_missing_or_self_referring_source() { + let declaring = elected_charter(); + for (source, fragment) in [ + ("optionalCharterId", "which is not required"), + ( + "ghostId", + "which is not a property of the referring document type", + ), + ("memberId", "names the reference property itself"), + ] { + let error = lookup( + "bySubmittedCharter", + &[("submittedCharterId", source), ("$ownerId", ".")], + ) + .referring_side_error(declaring.as_ref(), "memberId") + .expect("the source should be refused"); + assert!(error.contains(fragment), "{source}: {error}"); + } + } + + #[test] + fn should_refuse_a_lookup_whose_key_the_referenced_document_can_move() { + let lookup = lookup( + "bySubmittedCharter", + &[ + ("submittedCharterId", "submittedCharterId"), + ("$ownerId", "."), + ], + ); + let declaring = elected_charter(); + let refused = |referenced: DocumentType| { + lookup.referenced_side_error(declaring.as_ref(), referenced.as_ref()) + }; + + // A replace could move `submittedCharterId`, a transfer or a purchase `$ownerId` + for (extra, moving) in [ + ( + platform_value!({ "documentsMutable": true }), + "keys documents by \"submittedCharterId\", which a replace can change", + ), + ( + platform_value!({ "transferable": 1u8 }), + "keys documents by \"$ownerId\", which a transfer or a purchase changes", + ), + ( + platform_value!({ "tradeMode": 1u8 }), + "keys documents by \"$ownerId\", which a transfer or a purchase changes", + ), + ] { + let error = + refused(join_request_with(extra.clone())).expect("a moving key should be refused"); + assert!(error.contains(moving), "{extra:?}: {error}"); + } + + // Frozen by the `immutable` list on a mutable type, the key holds + assert_eq!( + refused(join_request_with(platform_value!({ + "documentsMutable": true, + "immutable": ["submittedCharterId"] + }))), + None + ); + } + + /// A key part read from the writer would move with a transfer or a + /// purchase of the referring document, which re-validates nothing, so + /// only a type that can do neither may read it. + #[test] + fn should_refuse_a_writer_key_part_on_a_referring_type_that_can_change_owner() { + let lookup = lookup( + "bySubmittedCharter", + &[("submittedCharterId", "."), ("$ownerId", "$ownerId")], + ); + for extra in [ + platform_value!({ "transferable": 1u8 }), + platform_value!({ "tradeMode": 1u8 }), + ] { + let error = lookup + .referring_side_error(elected_charter_with(extra.clone()).as_ref(), "memberId") + .expect("a writer key part should be refused"); + assert!( + error.contains( + "key \"$ownerId\" reads \"$ownerId\", which a transfer or a purchase of \ + the referring document changes" + ), + "{extra:?}: {error}" + ); + } + + // A type that can neither be transferred nor traded keeps its writer + assert_eq!( + lookup.referring_side_error(elected_charter().as_ref(), "memberId"), + None + ); + } + + #[test] + fn should_accept_a_key_id_reference_as_a_source_for_a_key_id_index_property() { + let key_id = |position: u32| { + platform_value!({ + "type": "integer", + "minimum": 0u32, + "maximum": 4294967295u32, + "position": position + }) + }; + let referenced = document_type( + "keyRequest", + platform_value!({ + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { "keyId": key_id(0) }, + "indices": [{ + "name": "byKey", + "properties": [{ "keyId": "asc" }, { "$ownerId": "asc" }], + "unique": true + }], + "required": ["keyId"], + "additionalProperties": false + }), + ); + let mut signer_key_id = key_id(0); + signer_key_id + .insert( + "refersTo".to_string(), + platform_value!({ "type": "identityPublicKey", "identityProperty": "$ownerId" }), + ) + .expect("refersTo inserts"); + let declaring = document_type( + "keyUse", + platform_value!({ + "type": "object", + "properties": { + "signerKeyId": signer_key_id, + "requesterId": with_position(identifier(), 1) + }, + "required": ["signerKeyId"], + "additionalProperties": false + }), + ); + assert!(matches!( + declaring.as_ref().flattened_properties()["signerKeyId"].property_type, + DocumentPropertyType::KeyIdWithReference(_) + )); + + // A key id reference holds a u32 like the plain key id it is matched with + let lookup = lookup("byKey", &[("keyId", "signerKeyId"), ("$ownerId", ".")]); + assert_eq!( + lookup.referenced_side_error(declaring.as_ref(), referenced.as_ref()), + None + ); + } + + #[test] + fn should_refuse_other_system_sources() { + for source in ["$id", "$createdAt", "$creatorId"] { + let error = + LookupKeySource::from_wire_name(source).expect_err("the source should be refused"); + assert!( + error.to_string().contains("not system property"), + "{source}: {error}" + ); + } + } + + #[test] + fn should_assemble_the_key_from_the_reference_value_the_owner_and_the_document() { + let lookup = lookup( + "bySubmittedCharter", + &[ + ("submittedCharterId", "submittedCharterId"), + ("$ownerId", "."), + ], + ); + let member = Identifier::from([3; 32]); + let owner = Identifier::from([4; 32]); + let data = BTreeMap::from([("submittedCharterId".to_string(), Value::Identifier([5; 32]))]); + + assert_eq!( + lookup.key_values(member, &data, owner), + Some(BTreeMap::from([ + ("$ownerId".to_string(), Value::Identifier([3; 32])), + ("submittedCharterId".to_string(), Value::Identifier([5; 32])), + ])) + ); + assert_eq!(lookup.key_values(member, &BTreeMap::new(), owner), None); + assert_eq!( + lookup.referring_properties().collect::>(), + vec!["submittedCharterId"] + ); + } +} diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 9e5f25d7bde..3e04ecfe6b9 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -362,6 +362,7 @@ impl ErrorWithCode for StateError { Self::DocumentActionFeeMultiplierNotToleratedError(_) => 40134, Self::ReferencedContractRequirementNotMetError(_) => 40135, Self::ReferencedIdentityKeyRequirementNotMetError(_) => 40136, + Self::ReferencedDocumentLookupInvalidError(_) => 40137, // 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 7205c31d512..1c873c7cb42 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_lookup_invalid_error; pub mod referenced_document_property_agreement_invalid_error; pub mod referenced_document_property_mismatch_error; pub mod referenced_document_type_deletable_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_lookup_invalid_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_lookup_invalid_error.rs new file mode 100644 index 00000000000..82d65fb76c0 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_document_lookup_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` lookup into a document type of another contract cannot resolve +/// there: the named index is missing or not unique, its keys do not cover the +/// index exactly, or a source holds a different kind of value than its index +/// property. Reported at contract registration and update; a lookup into 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 lookup through index {index} declared at {path}: {reason}")] +#[platform_serialize(unversioned)] +pub struct ReferencedDocumentLookupInvalidError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + path: String, + index: String, + reason: String, +} + +impl ReferencedDocumentLookupInvalidError { + pub fn new(path: String, index: String, reason: String) -> Self { + Self { + path, + index, + reason, + } + } + + /// The declaring property, as `documentTypeName.propertyPath`. + pub fn path(&self) -> &str { + &self.path + } + + /// The index the lookup names. + pub fn index(&self) -> &str { + &self.index + } + + pub fn reason(&self) -> &str { + &self.reason + } +} + +impl From for ConsensusError { + fn from(err: ReferencedDocumentLookupInvalidError) -> Self { + Self::StateError(StateError::ReferencedDocumentLookupInvalidError(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 bad77898bd2..5564e3df48f 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -69,6 +69,7 @@ use crate::consensus::state::document::referenced_document_type_deletable_error: use crate::consensus::state::document::referenced_document_type_not_deletable_error::ReferencedDocumentTypeNotDeletableError; 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_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; @@ -592,6 +593,10 @@ pub enum StateError { // Requirements on a referenced identity key (protocol version 14). #[error(transparent)] ReferencedIdentityKeyRequirementNotMetError(ReferencedIdentityKeyRequirementNotMetError), + + // Document references resolved through a unique index (protocol version 14). + #[error(transparent)] + ReferencedDocumentLookupInvalidError(ReferencedDocumentLookupInvalidError), } impl From for ConsensusError { @@ -612,11 +617,15 @@ mod tests { use crate::data_contract::document_type::action_fees::{ ActionFeePricing, ContractFeePot, DocumentActionFee, }; + use crate::data_contract::document_type::{ + DocumentPropertyReferenceTarget, DocumentReferenceLookup, LookupKeySource, + }; use crate::tokens::gas_fees_paid_by::GasFeesPaidBy; use crate::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use crate::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; use crate::voting::vote_polls::VotePoll; use platform_value::Identifier; + use std::collections::BTreeMap; /// `StateError` is encoded by variant position, so inserting a variant /// anywhere but the end silently reassigns the discriminant of every @@ -632,6 +641,61 @@ mod tests { bytes[0] } + /// A reference error for an id reference encodes exactly as it did before + /// lookup references existed: the lookup form is an appended variant of + /// `DocumentPropertyReferenceTarget` (`PermanentDocumentLookup`), not a + /// field of `PermanentDocument`, so a client decoding with an earlier dpp + /// still reads every error an id reference produces. The bytes are pinned; + /// `PermanentDocument` keeps variant 3 of the target, the lookup form + /// takes 6. + #[test] + fn should_keep_the_encoding_of_a_reference_error_for_an_id_reference() { + let id_reference = DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: None, + document_type_name: "note".to_string(), + property_agreement: BTreeMap::new(), + }; + let error = StateError::ReferencedEntityNotFoundError(ReferencedEntityNotFoundError::new( + Identifier::from([1; 32]), + id_reference.clone(), + "noteId".to_string(), + )); + let bytes = bincode::encode_to_vec(error, bincode::config::standard()) + .expect("expected to encode the state error"); + // StateError variant 93, the referenced id, target variant 3 + // (PermanentDocument: no contract id, "note", no agreement), the path. + assert_eq!( + hex::encode(bytes), + concat!( + "5d", + "0101010101010101010101010101010101010101010101010101010101010101", + "03", + "00", + "046e6f7465", + "00", + "066e6f74654964", + ) + ); + + let target_variant = |target: &DocumentPropertyReferenceTarget| { + bincode::encode_to_vec(target, bincode::config::standard()) + .expect("expected to encode the target")[0] + }; + assert_eq!(target_variant(&id_reference), 3); + assert_eq!( + target_variant(&DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "note".to_string(), + property_agreement: BTreeMap::new(), + lookup: DocumentReferenceLookup { + index: "byOwner".to_string(), + keys: [("$ownerId".to_string(), LookupKeySource::ReferenceValue)].into(), + }, + }), + 6 + ); + } + #[test] fn state_error_discriminants_are_frozen() { assert_eq!( @@ -1103,7 +1167,7 @@ mod tests { )), 143 ); - // Requirements on a referenced identity key (protocol version 14): the tail of the enum. + // Requirements on a referenced identity key (protocol version 14). assert_eq!( discriminant_of(StateError::ReferencedIdentityKeyRequirementNotMetError( ReferencedIdentityKeyRequirementNotMetError::new( @@ -1118,5 +1182,17 @@ mod tests { )), 144 ); + // Document references resolved through a unique index (protocol version 14): the + // tail of the enum. + assert_eq!( + discriminant_of(StateError::ReferencedDocumentLookupInvalidError( + ReferencedDocumentLookupInvalidError::new( + "electedCharter.members".to_string(), + "bySubmittedCharter".to_string(), + "is not unique".to_string(), + ) + )), + 145 + ); } } diff --git a/packages/rs-dpp/src/validation/meta_validators/mod.rs b/packages/rs-dpp/src/validation/meta_validators/mod.rs index 1565f9fbf55..781155bc06d 100644 --- a/packages/rs-dpp/src/validation/meta_validators/mod.rs +++ b/packages/rs-dpp/src/validation/meta_validators/mod.rs @@ -449,6 +449,56 @@ mod tests { } } + #[test] + fn should_accept_a_lookup_on_a_permanent_document_refers_to_in_v3_document_schema() { + for reference_type in ["permanentDocument"] { + for keys in [ + json!({ "submittedCharterId": "submittedCharterId", "$ownerId": "." }), + json!({ "submittedCharterId": ".", "$ownerId": "$ownerId" }), + json!({ "meta.charterId": "meta.charterId", "$ownerId": "." }), + ] { + let schema = document_schema_with_refers_to(json!({ + "type": reference_type, + "documentType": "joinRequest", + "lookup": { "index": "bySubmittedCharter", "keys": keys } + })); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_ok(), + "expected a {reference_type} lookup with keys {keys} to be valid" + ); + } + } + } + + #[test] + fn should_reject_malformed_or_misplaced_lookups_in_v3_document_schema() { + let keys = json!({ "$ownerId": "." }); + for refers_to in [ + json!({ "type": "identity", "lookup": { "index": "byOwner", "keys": keys } }), + json!({ "type": "contract", "lookup": { "index": "byOwner", "keys": keys } }), + json!({ "type": "token", "lookup": { "index": "byOwner", "keys": keys } }), + json!({ "type": "identityPublicKey", "keyIdProperty": "keyId", "lookup": { "index": "byOwner", "keys": keys } }), + json!({ "type": "deletableDocument", "documentType": "note", "lookup": { "index": "byOwner", "keys": keys } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "keys": keys } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "byOwner" } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "", "keys": keys } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "x".repeat(33), "keys": keys } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "byOwner", "keys": {} } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "byOwner", "keys": { "$ownerId": 1 } } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "byOwner", "keys": { "$ownerId": "$createdAt" } } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "byOwner", "keys": { "$ownerId": "bad-name" } } }), + json!({ "type": "permanentDocument", "documentType": "note", "lookup": { "index": "byOwner", "keys": keys, "extra": 1 } }), + ] { + 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!({ 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 2cca54ad70d..c3954b5fb8a 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,8 +12,9 @@ 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, DocumentTypeRef, IdentityKeyReferenceRequirements, - KeyReferenceIdentityProperty, PropertyReference, ReferringWrite, + DocumentPropertyType, DocumentReferenceLookup, DocumentTypeRef, + IdentityKeyReferenceRequirements, KeyReferenceIdentityProperty, PropertyReference, + ReferringWrite, }; use dpp::data_contract::DataContract; use dpp::document::property_names::{CREATOR_ID, OWNER_ID}; @@ -51,7 +52,9 @@ use crate::execution::types::execution_operation::{RetrieveIdentityInfo, Validat use crate::execution::types::state_transition_execution_context::{ StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, }; -use crate::execution::validation::state_transition::batch::state::v0::fetch_documents::fetch_document_with_id; +use crate::execution::validation::state_transition::batch::state::v0::fetch_documents::{ + fetch_document_through_lookup, fetch_document_with_id, +}; use crate::platform_types::platform::PlatformStateRef; /// Versioned, stateful validation of document references using the v0 rules. @@ -290,12 +293,17 @@ fn validate_document_type_references_v0( // - a propertyAgreement pair binds each referring property; // - an identityPublicKey reference binds the key id property, // since the referenced key is the (identity id, key id) pair - // and a freshly written key id must exist and not be disabled. + // and a freshly written key id must exist and not be disabled; + // - a lookup binds every property its key reads, since the + // referenced document is the one the whole key finds. // A writer gate (an agreement keyed by `$ownerId`) is re-checked // on EVERY replace: the writer is transition metadata that never // appears among the changed fields, and either document may have // been transferred since the last write, so a replace of an - // unrelated field by a now-unauthorized owner must still fail. + // unrelated field by a now-unauthorized owner must still fail. A + // lookup whose key reads `$ownerId` needs no such rule: its + // declaring type can be neither transferred nor traded + // (registration refuses it otherwise), so the writer never moves. // The same rules hold for the elements of a typed array, which // share one declaration: the array is one field, so a replace // that changes it re-validates the elements the stored list did @@ -308,6 +316,16 @@ fn validate_document_type_references_v0( is_referring_system_agreement_property(referring_property) || is_changed_field(changed, referring_property) }), + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + property_agreement, + lookup, + .. + } => { + property_agreement.keys().any(|referring_property| { + is_referring_system_agreement_property(referring_property) + || is_changed_field(changed, referring_property) + }) || lookup_key_may_have_changed(lookup, changed) + } // A deletableDocument reference is re-validated on EVERY // replace, touched or not: its target may have been deleted // since the last write, and a referring document is not @@ -557,6 +575,12 @@ fn validate_reference_v0( 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, @@ -565,6 +589,7 @@ fn validate_reference_v0( let permanent = matches!( reference_target, DocumentPropertyReferenceTarget::PermanentDocument { .. } + | DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } ); // An absent contract id targets the declaring contract itself; the // declaring contract may also name its own id explicitly. Either @@ -668,16 +693,41 @@ fn validate_reference_v0( )); } - let referenced_document = fetch_document_with_id( - platform.drive, - referenced_contract, - referenced_document_type, - Identifier::from(referenced_id), - &block_info.epoch, - execution_context, - transaction, - platform_version, - )?; + // 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) + } + _ => 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, + )?, + }; // Property agreement: the referenced document is already in // hand for the existence check, so comparing the declared @@ -1003,6 +1053,19 @@ fn validate_referenced_identity_key_v0( Ok(SimpleConsensusValidationResult::new()) } +/// Whether a replace may have changed the key of a lookup: a property the key +/// reads changed. The reference's own value is covered by the changed-field +/// check of the property itself, and a `$ownerId` part is fixed, since +/// registration admits it only on a type that cannot be transferred or traded. +fn lookup_key_may_have_changed( + lookup: &DocumentReferenceLookup, + changed_fields: &BTreeSet, +) -> bool { + lookup + .referring_properties() + .any(|path| is_changed_field(changed_fields, path)) +} + /// A flattened property path counts as changed when the replace transition changed /// the path itself or any of its ancestors: `changed_data_fields` holds top-level /// document keys, so a changed object key replaces its entire subtree, including diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs index 2486f1574c4..e26fb251567 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs @@ -4,7 +4,8 @@ use crate::execution::types::state_transition_execution_context::{ StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, }; use dpp::block::epoch::Epoch; -use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::{DocumentReferenceLookup, DocumentTypeRef}; use dpp::data_contract::DataContract; use dpp::document::Document; use dpp::fee::fee_result::FeeResult; @@ -22,6 +23,7 @@ use drive::query::drive_contested_document_query::{ DriveContestedDocumentQuery, PrimaryContestedInternalClauses, }; use drive::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; +use std::collections::BTreeMap; // ============================================================================ // fetch_documents_for_transitions_knowing_contract_and_document_type @@ -404,6 +406,103 @@ fn fetch_document_with_id_v1( } } +// ============================================================================ +// fetch_document_through_lookup +// ============================================================================ + +/// The document a `refersTo` lookup resolves to for one value: the one the +/// declared unique index of `document_type` finds for the key assembled from +/// `reference_value` (the property's value, or one array element's), the +/// writer `owner_id` and the sources in `document_data`. `None` when no +/// document matches, and when the key cannot be assembled or the index is +/// missing or not unique. Contract registration and update refuse the last +/// three (an optional source, a missing or non-unique index), and a referenced +/// type's indexes cannot change on update, so for a validated contract they +/// cannot happen; if one did, the write is refused rather than judged against +/// a partial key. +/// +/// The query is billed exactly as [`fetch_document_with_id`] v1 bills an id +/// fetch: an equality query over the index's properties (the shape the unique +/// index conflict check builds), limit 1, whose processing cost is added to +/// `execution_context`. Only reached from the document reference validation, +/// which exists from protocol version 14, so it carries no version of its own. +#[allow(clippy::too_many_arguments)] +pub(crate) fn fetch_document_through_lookup( + drive: &Drive, + contract: &DataContract, + document_type: DocumentTypeRef, + lookup: &DocumentReferenceLookup, + reference_value: Identifier, + document_data: &BTreeMap, + owner_id: Identifier, + epoch: &Epoch, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result, Error> { + if !document_type + .indexes() + .get(&lookup.index) + .is_some_and(|index| index.unique) + { + return Ok(None); + } + let Some(key_values) = lookup.key_values(reference_value, document_data, owner_id) else { + return Ok(None); + }; + let equal_clauses = key_values + .into_iter() + .map(|(field, value)| { + ( + field.clone(), + WhereClause { + field, + operator: WhereOperator::Equal, + value, + }, + ) + }) + .collect(); + + let drive_query = DriveDocumentQuery { + contract, + document_type, + internal_clauses: InternalClauses { + primary_key_in_clause: None, + primary_key_equal_clause: None, + in_clauses: Vec::new(), + range_clause: None, + equal_clauses, + }, + offset: None, + // The index is unique and every one of its properties is fixed, so at + // most one document matches + limit: Some(1), + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + }; + + let documents_outcome = drive.query_documents( + drive_query, + Some(epoch), + false, + transaction, + Some(platform_version.protocol_version), + )?; + execution_context.add_operation(ValidationOperation::PrecalculatedOperation(FeeResult { + storage_fee: 0, + processing_fee: documents_outcome.cost(), + fee_refunds: Default::default(), + removed_bytes_from_system: 0, + })); + + Ok(documents_outcome.documents_owned().into_iter().next()) +} + // ============================================================================ // has_contested_document_with_document_id — unchanged // ============================================================================ diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/lookup_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/lookup_reference.rs new file mode 100644 index 00000000000..e949836f875 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/lookup_reference.rs @@ -0,0 +1,732 @@ +//! Document references resolved through a unique index (`refersTo.lookup`, +//! protocol version 14) through the full ABCI pipeline. The fixture's +//! `joinRequest` is unique on (`submittedCharterId`, `$ownerId`), and the +//! `charter` type refers to it by the requester instead of by the request's +//! id: `memberId` must be the owner of a join request for the +//! charter's own `submittedCharterId`, and `members` is the moderation +//! charter's list of them, a typed array whose elements carry the same +//! declaration. `agreedMemberId` adds a `propertyAgreement` checked against the +//! request the lookup finds, `requestedCharterId` reads the writer as a key +//! part (which is why `charter` can be neither transferred nor traded) and +//! `metaMemberId` a nested one, `meta.charterId`. The second contract, +//! `vote`, refers to the same requests from another contract. +//! +//! A value the index finds no document for is refused, paid, with +//! `ReferencedEntityNotFoundError` (40120) naming the property, or the element +//! by its list path. + +use super::*; + +mod lookup_reference_tests { + use super::*; + use crate::execution::types::execution_operation::ValidationOperation; + 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::platform_value::platform_value; + 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-lookup.json"; + + /// A `vote` type whose `voterId` looks join requests up in the contract + /// above, by its id: the lookup into another contract. + const VOTING_CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-valid.json"; + + /// The identities of the fixture: the `Founder` writes charters and votes, + /// the `Member` and the `Stranger` join requests where a test needs 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 LookupFixture { + platform: TempPlatform, + contract: DataContract, + voting_contract: DataContract, + rng: StdRng, + founder: Writer, + member: Writer, + stranger: Writer, + } + + fn id_value(id: Identifier) -> Value { + Value::Identifier(id.to_buffer()) + } + + impl LookupFixture { + 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(958); + let member = writer(959); + let stranger = writer(960); + + // Parsed with full validation, so the contract-level lookup + // checks run on the fixtures too + let [contract, voting_contract] = [CONTRACT_PATH, VOTING_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, + voting_contract, + rng: StdRng::seed_from_u64(4433), + 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.voting_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 `who`, with `values` set + /// over the random required ones, and returns it with the result. + async fn create( + &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, as + /// `who`, its current owner. + async fn replace( + &mut self, + who: Who, + type_name: &str, + document: &Document, + change: impl FnOnce(&mut 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, type_name); + let nonce = writer.next_nonce(contract_id); + let transition = BatchTransition::new_document_replacement_transition_from_document( + replacement, + document_type, + &writer.key, + nonce, + 0, + None, + &writer.signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition"); + self.process(&transition) + } + + /// A join request by `who` for the submitted charter `charter_id`. + async fn request_to_join( + &mut self, + who: Who, + charter_id: Identifier, + message: &str, + ) -> Document { + let (request, result) = self + .create( + who, + "joinRequest", + &[ + ("submittedCharterId", id_value(charter_id)), + ("message", message.into()), + ], + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + request + } + + /// A charter by the founder for `charter_id`, with `values` set. + async fn create_charter( + &mut self, + charter_id: Identifier, + values: &[(&str, Value)], + ) -> (Document, StateTransitionExecutionResult) { + let mut all = vec![ + ("submittedCharterId", id_value(charter_id)), + ("title", "charter".into()), + ]; + all.extend(values.iter().cloned()); + self.create(Who::Founder, "charter", &all).await + } + } + + fn charter_id(byte: u8) -> Identifier { + Identifier::from([byte; 32]) + } + + /// The refusal of a value the index found no document for, naming the + /// property and the value. + fn assert_not_found(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::PermanentDocumentLookup { .. } + ), + "expected 40120 at {path}" + ); + } + + #[tokio::test] + async fn should_create_a_document_whose_lookup_value_owns_a_request_for_its_target() { + let mut fixture = LookupFixture::new(); + let member = fixture.id(Who::Member); + fixture + .request_to_join(Who::Member, charter_id(1), "let me in") + .await; + + let (_, result) = fixture + .create_charter(charter_id(1), &[("memberId", id_value(member))]) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_refuse_a_lookup_value_that_owns_no_request_for_its_target() { + let mut fixture = LookupFixture::new(); + let stranger = fixture.id(Who::Stranger); + fixture + .request_to_join(Who::Member, charter_id(1), "let me in") + .await; + + let (_, result) = fixture + .create_charter(charter_id(1), &[("memberId", id_value(stranger))]) + .await; + + assert_not_found(result, "memberId", stranger); + } + + #[tokio::test] + async fn should_not_resolve_a_lookup_through_a_request_for_another_target() { + let mut fixture = LookupFixture::new(); + let member = fixture.id(Who::Member); + fixture + .request_to_join(Who::Member, charter_id(2), "let me in") + .await; + + // The member asked to join charter 2, not charter 1 + let (_, result) = fixture + .create_charter(charter_id(1), &[("memberId", id_value(member))]) + .await; + + assert_not_found(result, "memberId", member); + } + + #[tokio::test] + async fn should_refuse_a_replace_that_moves_the_target_to_one_with_no_request() { + let mut fixture = LookupFixture::new(); + let member = fixture.id(Who::Member); + fixture + .request_to_join(Who::Member, charter_id(1), "let me in") + .await; + let (charter, result) = fixture + .create_charter(charter_id(1), &[("memberId", id_value(member))]) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + // Touching neither the value nor a key part leaves the reference alone + let result = fixture + .replace(Who::Founder, "charter", &charter, |charter| { + charter.set("title", "renamed".into()); + }) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let mut charter = charter; + charter + .increment_revision() + .expect("the revision increments"); + charter.set("title", "renamed".into()); + + // Moving the key part re-validates it: the member never asked to join + // charter 2 + let result = fixture + .replace(Who::Founder, "charter", &charter, |charter| { + charter.set("submittedCharterId", id_value(charter_id(2))); + }) + .await; + assert_not_found(result, "memberId", member); + } + + #[tokio::test] + async fn should_check_a_property_agreement_beside_the_lookup_against_the_resolved_document() { + let mut fixture = LookupFixture::new(); + let member = fixture.id(Who::Member); + fixture + .request_to_join(Who::Member, charter_id(1), "welcome") + .await; + + let (_, agreeing) = fixture + .create_charter( + charter_id(1), + &[ + ("title", "welcome".into()), + ("agreedMemberId", id_value(member)), + ], + ) + .await; + assert_matches!( + agreeing, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let (_, disagreeing) = fixture + .create_charter( + charter_id(1), + &[ + ("title", "goodbye".into()), + ("agreedMemberId", id_value(member)), + ], + ) + .await; + assert_matches!( + disagreeing, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyMismatchError(e) + ), + .. + } if e.path() == "agreedMemberId" + && e.referring_property() == "title" + && e.referenced_property() == "message" + ); + } + + #[tokio::test] + async fn should_resolve_a_lookup_whose_key_reads_the_writer() { + let mut fixture = LookupFixture::new(); + // `requestedCharterId` must be a charter the writer itself asked to join + fixture + .request_to_join(Who::Founder, charter_id(1), "my own") + .await; + + let (_, asked) = fixture + .create_charter( + charter_id(9), + &[("requestedCharterId", id_value(charter_id(1)))], + ) + .await; + assert_matches!( + asked, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let (_, never_asked) = fixture + .create_charter( + charter_id(9), + &[("requestedCharterId", id_value(charter_id(2)))], + ) + .await; + assert_not_found(never_asked, "requestedCharterId", charter_id(2)); + } + + #[tokio::test] + async fn should_read_a_nested_key_part() { + let mut fixture = LookupFixture::new(); + let member = fixture.id(Who::Member); + fixture + .request_to_join(Who::Member, charter_id(2), "let me in") + .await; + + // `metaMemberId` is keyed by `meta.charterId`, not by the charter's + // own `submittedCharterId` + let (_, nested) = fixture + .create_charter( + charter_id(1), + &[ + ("meta", platform_value!({ "charterId": charter_id(2) })), + ("metaMemberId", id_value(member)), + ], + ) + .await; + assert_matches!( + nested, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let (_, elsewhere) = fixture + .create_charter( + charter_id(2), + &[ + ("meta", platform_value!({ "charterId": charter_id(1) })), + ("metaMemberId", id_value(member)), + ], + ) + .await; + assert_not_found(elsewhere, "metaMemberId", member); + } + + /// The charter contract's `members`: every element must be the owner of a + /// join request for the charter's own submitted charter. + #[tokio::test] + async fn should_look_up_every_element_of_a_members_list() { + let mut fixture = LookupFixture::new(); + let member = fixture.id(Who::Member); + let stranger = fixture.id(Who::Stranger); + fixture + .request_to_join(Who::Member, charter_id(1), "let me in") + .await; + + // One element without a request refuses the write, the error naming + // the element by its list path + let (_, one_missing) = fixture + .create_charter( + charter_id(1), + &[( + "members", + Value::Array(vec![id_value(member), id_value(stranger)]), + )], + ) + .await; + assert_not_found(one_missing, "members[1]", stranger); + + fixture + .request_to_join(Who::Stranger, charter_id(1), "me too") + .await; + let (charter, all_asked) = fixture + .create_charter( + charter_id(1), + &[( + "members", + Value::Array(vec![id_value(member), id_value(stranger)]), + )], + ) + .await; + assert_matches!( + all_asked, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + // Moving the charter's key part re-validates every element, not only + // the ones the stored list did not hold + let moved = fixture + .replace(Who::Founder, "charter", &charter, |charter| { + charter.set("submittedCharterId", id_value(charter_id(2))); + }) + .await; + assert_not_found(moved, "members[0]", member); + } + + /// A lookup into another contract resolves in that contract's documents. + #[tokio::test] + async fn should_look_up_documents_of_another_contract() { + let mut fixture = LookupFixture::new(); + let member = fixture.id(Who::Member); + let stranger = fixture.id(Who::Stranger); + fixture + .request_to_join(Who::Member, charter_id(1), "let me in") + .await; + + let (_, voted) = fixture + .create( + Who::Founder, + "vote", + &[ + ("submittedCharterId", id_value(charter_id(1))), + ("voterId", id_value(member)), + ], + ) + .await; + assert_matches!( + voted, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let (_, not_a_member) = fixture + .create( + Who::Founder, + "vote", + &[ + ("submittedCharterId", id_value(charter_id(1))), + ("voterId", id_value(stranger)), + ], + ) + .await; + assert_not_found(not_a_member, "voterId", stranger); + } + + /// The lookup is billed like the id fetch it replaces: one document query + /// whose processing cost lands in the execution context. + #[tokio::test] + async fn should_bill_the_lookup_as_a_document_fetch() { + let mut fixture = LookupFixture::new(); + let platform_version = PlatformVersion::latest(); + let member = fixture.id(Who::Member); + let founder = fixture.id(Who::Founder); + let stranger = fixture.id(Who::Stranger); + fixture + .request_to_join(Who::Member, charter_id(1), "let me in") + .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: "charter".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 = |data: BTreeMap| { + 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 charter 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) + }; + + let charter = |member_id: Option| { + let mut data = BTreeMap::from([ + ("submittedCharterId".to_string(), id_value(charter_id(1))), + ("title".to_string(), Value::Text("charter".to_string())), + ]); + if let Some(member_id) = member_id { + data.insert("memberId".to_string(), id_value(member_id)); + } + data + }; + + let (result, execution_context) = validate(charter(Some(member))); + assert!(result.is_valid(), "{:?}", result.errors); + assert_matches!( + execution_context.operations_slice(), + [ValidationOperation::PrecalculatedOperation(fee)] if fee.processing_fee > 0, + "the lookup query is the one billed operation" + ); + + // A refused lookup is billed the same way + let (result, execution_context) = validate(charter(Some(stranger))); + assert_matches!( + result.errors.as_slice(), + [ConsensusError::StateError( + StateError::ReferencedEntityNotFoundError(_) + )] + ); + assert_matches!( + execution_context.operations_slice(), + [ValidationOperation::PrecalculatedOperation(fee)] if fee.processing_fee > 0 + ); + + // Without the reference there is nothing to look up + let (result, execution_context) = validate(charter(None)); + assert!(result.is_valid()); + assert!(execution_context.operations_slice().is_empty()); + } +} 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 7bd70f0fd93..23e8c230af1 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 lookup_reference; mod nft; mod owner_balance_proof; mod ranked_group_drain; 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 a7a441902c8..1a7ac58c775 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 @@ -8,6 +8,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_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; use dpp::errors::consensus::state::document::referenced_document_type_not_deletable_error::ReferencedDocumentTypeNotDeletableError; @@ -31,19 +32,10 @@ use crate::execution::types::state_transition_execution_context::{ /// Whether two property types hold the same KIND of value for agreement /// purposes: sizes and other constraints may differ (both sides validated -/// their own documents already), and an identifier, or a `u32` key id, is one -/// kind whether or not it carries its own reference annotation. +/// their own documents already). The rule is `DocumentPropertyType::value_kind`, +/// shared with the key parts of a `refersTo` lookup. fn same_value_kind(a: &DocumentPropertyType, b: &DocumentPropertyType) -> bool { - let normalized_kind = |property_type: &DocumentPropertyType| match property_type { - DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { - std::mem::discriminant(&DocumentPropertyType::Identifier) - } - DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { - std::mem::discriminant(&DocumentPropertyType::U32) - } - other => std::mem::discriminant(other), - }; - normalized_kind(a) == normalized_kind(b) + a.value_kind() == b.value_kind() } /// Checks every reference declaration of the given contract that carries @@ -54,10 +46,12 @@ fn same_value_kind(a: &DocumentPropertyType, b: &DocumentPropertyType) -> bool { /// including when it names its own id) and the referenced document type must /// exist in it; for `permanentDocument` that type must forbid deletion, for /// `deletableDocument` it must allow it. -/// Every `propertyAgreement` pair is checked for both. Self references are -/// checked against the in-flight -/// contract, so a contract may reference its own document types on creation; -/// foreign contract fetches are billed. +/// Every `propertyAgreement` pair is checked for both, and a `lookup` into +/// another contract's document type is checked against that type's indexes +/// (one into the declaring contract was checked by the contract parse). Self +/// references are checked against the in-flight contract, so a contract may +/// reference its own document types on creation; foreign contract fetches are +/// billed. /// /// `identityPublicKey`: the declared key id property must exist in the same /// document type and be an integer. @@ -238,7 +232,8 @@ pub(super) fn validate_data_contract_references_v0( document_type_name, property_agreement, permanent, - }) = reference_target.as_document_reference() + lookup, + }) = reference_target.as_any_document_reference() else { continue; }; @@ -338,6 +333,30 @@ pub(super) fn validate_data_contract_references_v0( )); } + // A lookup, only ever on a permanentDocument reference, must + // resolve in the referenced document type: a unique index its keys + // cover exactly, filled from sources of the right kinds, with a key + // that stays with the document it found. The contract parse checks + // a lookup into the declaring contract under full validation, where + // it sees every document type; only here is another contract's + // document type in hand. + if let Some(lookup) = lookup { + if effective_contract_id != contract.id() { + if let Some(reason) = lookup + .referenced_side_error(document_type.as_ref(), referenced_document_type) + { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedDocumentLookupInvalidError::new( + declaration_path, + lookup.index.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 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 b0b68fd8460..d9dc53b4d1d 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 @@ -5555,6 +5555,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 FOREIGN_CONTRACT_PATH: &str = @@ -5564,6 +5565,15 @@ mod tests { /// fixture, with the foreign permanent-document fixture contract /// already in state, and returns the execution result. async fn run_contract_create(fixture_path: &str) -> StateTransitionExecutionResult { + run_contract_create_with_foreign(fixture_path, FOREIGN_CONTRACT_PATH).await + } + + /// [`run_contract_create`] with the contract at `foreign_contract_path` + /// in state instead. + async fn run_contract_create_with_foreign( + fixture_path: &str, + foreign_contract_path: &str, + ) -> StateTransitionExecutionResult { let platform_version = PlatformVersion::latest(); let mut platform = TestPlatformBuilder::new() .build_with_mock_rpc() @@ -5575,7 +5585,7 @@ mod tests { setup_contract( &platform.drive, - FOREIGN_CONTRACT_PATH, + foreign_contract_path, None, None, None::, @@ -6178,5 +6188,69 @@ mod tests { } ); } + + /// The contract whose `joinRequest` type, unique on + /// (`submittedCharterId`, `$ownerId`), the lookup registration + /// fixtures reference from another contract. + const LOOKUP_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup.json"; + + #[tokio::test] + async fn should_register_a_lookup_into_a_unique_index_of_another_contract() { + let result = run_contract_create_with_foreign( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-valid.json", + LOOKUP_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_reject_a_lookup_naming_an_index_another_contract_does_not_have() { + // Only registration sees the other contract's indexes: 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-lookup-registration-foreign-missing-index.json", + LOOKUP_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentLookupInvalidError(e) + ), + .. + } if e.path() == "vote.voterId" + && e.index() == "byMessage" + && e.reason().contains("has no index named \"byMessage\"") + ); + } + + #[tokio::test] + async fn should_reject_a_lookup_into_a_non_unique_index_of_the_same_contract() { + // The contract parse sees the referenced 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-lookup-registration-own-not-unique.json", + LOOKUP_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(message) + )), + .. + } if message.contains("index \"byCharter\" of \"ballot\" is not unique") + ); + } } } diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-missing-index.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-missing-index.json new file mode 100644 index 00000000000..bf560919d11 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-missing-index.json @@ -0,0 +1,44 @@ +{ + "$formatVersion": "1", + "id": "HBpE9BP7v9czi2hmsozHXSJdtVGcmykvAhBuYfm2r4Xi", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "vote": { + "type": "object", + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "voterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1, + "refersTo": { + "type": "permanentDocument", + "contractId": "4uAB6wAdt6FJ7djwjYrLnooVhYeQpzgkssBmgmvZ9WnM", + "documentType": "joinRequest", + "lookup": { + "index": "byMessage", + "keys": { + "message": "." + } + } + } + } + }, + "required": [ + "submittedCharterId" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-valid.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-valid.json new file mode 100644 index 00000000000..939f51d0361 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-foreign-valid.json @@ -0,0 +1,45 @@ +{ + "$formatVersion": "1", + "id": "HBpE9BP7v9czi2hmsozHXSJdtVGcmykvAhBuYfm2r4Xi", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "vote": { + "type": "object", + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "voterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1, + "refersTo": { + "type": "permanentDocument", + "contractId": "4uAB6wAdt6FJ7djwjYrLnooVhYeQpzgkssBmgmvZ9WnM", + "documentType": "joinRequest", + "lookup": { + "index": "bySubmittedCharter", + "keys": { + "submittedCharterId": "submittedCharterId", + "$ownerId": "." + } + } + } + } + }, + "required": [ + "submittedCharterId" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-own-not-unique.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-own-not-unique.json new file mode 100644 index 00000000000..0c32c02dd0d --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup-registration-own-not-unique.json @@ -0,0 +1,71 @@ +{ + "$formatVersion": "1", + "id": "HBpE9BP7v9czi2hmsozHXSJdtVGcmykvAhBuYfm2r4Xi", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "ballot": { + "type": "object", + "canBeDeleted": false, + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + } + }, + "indices": [ + { + "name": "byCharter", + "properties": [ + { + "submittedCharterId": "asc" + } + ] + } + ], + "required": [ + "submittedCharterId" + ], + "additionalProperties": false + }, + "vote": { + "type": "object", + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "voterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1, + "refersTo": { + "type": "permanentDocument", + "documentType": "ballot", + "lookup": { + "index": "byCharter", + "keys": { + "submittedCharterId": "." + } + } + } + } + }, + "required": [ + "submittedCharterId" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup.json new file mode 100644 index 00000000000..420dcf73039 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup.json @@ -0,0 +1,196 @@ +{ + "$formatVersion": "1", + "id": "4uAB6wAdt6FJ7djwjYrLnooVhYeQpzgkssBmgmvZ9WnM", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "joinRequest": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "message": { + "type": "string", + "position": 1, + "maxLength": 63 + } + }, + "indices": [ + { + "name": "bySubmittedCharter", + "properties": [ + { + "submittedCharterId": "asc" + }, + { + "$ownerId": "asc" + } + ], + "unique": true + } + ], + "required": [ + "submittedCharterId", + "message" + ], + "additionalProperties": false + }, + "charter": { + "type": "object", + "documentsMutable": true, + "properties": { + "submittedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "title": { + "type": "string", + "position": 1, + "maxLength": 63 + }, + "memberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 2, + "distinctFrom": "$ownerId", + "refersTo": { + "type": "permanentDocument", + "documentType": "joinRequest", + "lookup": { + "index": "bySubmittedCharter", + "keys": { + "submittedCharterId": "submittedCharterId", + "$ownerId": "." + } + } + } + }, + "agreedMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 3, + "refersTo": { + "type": "permanentDocument", + "documentType": "joinRequest", + "propertyAgreement": { + "title": "message" + }, + "lookup": { + "index": "bySubmittedCharter", + "keys": { + "submittedCharterId": "submittedCharterId", + "$ownerId": "." + } + } + } + }, + "requestedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 4, + "refersTo": { + "type": "permanentDocument", + "documentType": "joinRequest", + "lookup": { + "index": "bySubmittedCharter", + "keys": { + "submittedCharterId": ".", + "$ownerId": "$ownerId" + } + } + } + }, + "members": { + "type": "array", + "minItems": 0, + "maxItems": 15, + "uniqueItems": true, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "distinctFrom": "$ownerId", + "refersTo": { + "type": "permanentDocument", + "documentType": "joinRequest", + "lookup": { + "index": "bySubmittedCharter", + "keys": { + "submittedCharterId": "submittedCharterId", + "$ownerId": "." + } + } + } + }, + "position": 5 + }, + "meta": { + "type": "object", + "position": 6, + "properties": { + "charterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + } + }, + "required": [ + "charterId" + ], + "additionalProperties": false + }, + "metaMemberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 7, + "refersTo": { + "type": "permanentDocument", + "documentType": "joinRequest", + "lookup": { + "index": "bySubmittedCharter", + "keys": { + "submittedCharterId": "meta.charterId", + "$ownerId": "." + } + } + } + } + }, + "required": [ + "submittedCharterId", + "title", + "meta" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/lookup_reference_join_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/lookup_reference_join_tests.rs new file mode 100644 index 00000000000..3fe623a5d5e --- /dev/null +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/lookup_reference_join_tests.rs @@ -0,0 +1,144 @@ +//! Joins through a document reference resolved by a unique index +//! (`refersTo.lookup`, protocol version 14): such a reference's value is not +//! the referenced document's `$id`, so neither a chained query nor a composite +//! by-id join may take it as a join property. Both surfaces refuse it while +//! validating the shape, on the server and in the verifier alike. + +use crate::error::Error; +use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; +use dpp::platform_value::{platform_value, Identifier, Value}; +use dpp::prelude::DataContract; +use dpp::version::PlatformVersion; + +fn identifier(position: u32) -> Value { + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32u32, + "maxItems": 32u32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": position + }) +} + +/// A permanent `profile` type, unique by owner, and two types whose `authorId` +/// names the owner of a profile through that index: the indexOnly `like` a +/// chained query starts from and the plain `note` a composite page starts from. +fn lookup_join_contract() -> DataContract { + let mut author_id = identifier(0); + author_id + .insert( + "refersTo".to_string(), + platform_value!({ + "type": "permanentDocument", + "documentType": "profile", + "lookup": { "index": "byOwner", "keys": { "$ownerId": "." } } + }), + ) + .expect("refersTo inserts"); + let referring_type = |index_only: bool| { + let mut schema = platform_value!({ + "type": "object", + "properties": { "authorId": author_id.clone() }, + "indices": [{ "name": "byAuthor", "properties": [{ "authorId": "asc" }] }], + "required": ["authorId"], + "additionalProperties": false + }); + if index_only { + schema + .insert("indexOnly".to_string(), Value::Bool(true)) + .expect("indexOnly inserts"); + schema + .insert("documentsMutable".to_string(), Value::Bool(false)) + .expect("documentsMutable inserts"); + } + schema + }; + DataContract::from_value( + platform_value!({ + "$formatVersion": "1", + "id": Identifier::from([0x5A; 32]), + "ownerId": Identifier::from([0x5B; 32]), + "version": 1u32, + "documentSchemas": { + "profile": { + "type": "object", + "canBeDeleted": false, + "properties": { + "name": { "type": "string", "maxLength": 32u32, "position": 0u32 } + }, + "indices": [ + { "name": "byOwner", "properties": [{ "$ownerId": "asc" }], "unique": true } + ], + "required": ["name"], + "additionalProperties": false + }, + "like": referring_type(true), + "note": referring_type(false) + } + }), + true, + PlatformVersion::latest(), + ) + .expect("the lookup join contract parses") +} + +fn by_author<'a>(contract: &'a DataContract, type_name: &str) -> DriveDocumentQuery<'a> { + DriveDocumentQuery { + contract, + document_type: contract + .document_type_for_name(type_name) + .expect("the document type exists"), + internal_clauses: InternalClauses::extract_from_clauses( + vec![WhereClause { + field: "authorId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier([0x11; 32]), + }], + PlatformVersion::latest(), + ) + .expect("the clauses extract"), + offset: None, + limit: Some(10), + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + sub_queries: vec![], + } + .with_by_id_join( + "authorId", + contract + .document_type_for_name("profile") + .expect("the profile type exists"), + ) +} + +fn assert_refused_as_a_lookup(result: Result<(), Error>) { + match result { + Err(Error::Query(error)) => assert!( + error.to_string().contains("unique index \"byOwner\""), + "the refusal should name the lookup's index: {error}" + ), + other => panic!("expected the join to be refused, got {other:?}"), + } +} + +#[test] +fn should_refuse_a_chained_join_through_a_lookup_reference() { + let contract = lookup_join_contract(); + assert_refused_as_a_lookup( + by_author(&contract, "like").validate_chained(PlatformVersion::latest()), + ); +} + +#[test] +fn should_refuse_a_composite_by_id_join_through_a_lookup_reference() { + let contract = lookup_join_contract(); + assert_refused_as_a_lookup( + by_author(&contract, "note").validate_composite(PlatformVersion::latest()), + ); +} diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs index f3e0c989771..5273b0846ab 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs @@ -31,6 +31,7 @@ mod composite_query_e2e_tests; mod countable_e2e_tests; mod index_only_e2e_tests; mod index_only_scalar_terminal_e2e_tests; +mod lookup_reference_join_tests; mod noncounted_sibling_e2e_tests; mod preallocated_index_e2e_tests; mod prefix_ranked_index_e2e_tests; diff --git a/packages/rs-drive/src/query/chained_document_query/mod.rs b/packages/rs-drive/src/query/chained_document_query/mod.rs index 9aae4fb56e4..a421cc52287 100644 --- a/packages/rs-drive/src/query/chained_document_query/mod.rs +++ b/packages/rs-drive/src/query/chained_document_query/mod.rs @@ -60,7 +60,8 @@ use crate::query::{ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::{ - DocumentPropertyType, DocumentReferenceDeclaration, DocumentTypeRef, + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceDeclaration, + DocumentTypeRef, }; use dpp::data_contract::DataContract; use dpp::document::{Document, DocumentV0Getters}; @@ -230,6 +231,20 @@ impl<'a> DriveDocumentQuery<'a> { } _ => None, }; + // A lookup reference is a document reference whose value is not the + // outer document's id, so `as_document_reference` leaves it out; it + // is named here so the refusal says why + if let DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. }, + ) = &join_document_property.property_type + { + return Err(unsupported(format!( + "chained query join property \"{}\" refers to its document through the \ + unique index \"{}\", so its value is not the outer document's id: a join \ + needs a reference whose value is the referenced document's $id", + join_property, lookup.index, + ))); + } match document_reference { Some(DocumentReferenceDeclaration { contract_id, diff --git a/packages/rs-drive/src/query/composite_document_query/mod.rs b/packages/rs-drive/src/query/composite_document_query/mod.rs index ca15550f37f..88df71e80b9 100644 --- a/packages/rs-drive/src/query/composite_document_query/mod.rs +++ b/packages/rs-drive/src/query/composite_document_query/mod.rs @@ -99,7 +99,8 @@ use crate::query::{ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::{ - DocumentPropertyType, DocumentReferenceDeclaration, DocumentTypeRef, + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceDeclaration, + DocumentTypeRef, }; use dpp::data_contract::DataContract; use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; @@ -616,6 +617,20 @@ impl<'a> DriveDocumentQuery<'a> { // proof; a deletableDocument one does not, and a missing // document is then a proven absence (see // `assemble_documents`). + // A lookup reference's values are not document ids, so + // `document_reference_of` leaves it out; it is named here so + // the refusal says why + if let Some(DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. }, + )) = source_property_type + { + return Err(label(&format!( + "the source property's refersTo finds its document through the unique \ + index \"{}\", so its values are not document ids: a by-id join needs \ + a reference whose value is the referenced document's $id", + lookup.index, + ))); + } match source_property_type.and_then(document_reference_of) { Some(DocumentReferenceDeclaration { contract_id, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 26d0903bf78..8f9acd35690 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -721,6 +721,44 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// a target is deleted. A changed /// element `refersTo` is an incompatible schema change on update. /// +/// 32. **Document references resolved through a unique index**: a +/// `permanentDocument` `refersTo`, on an identifier property or on the +/// elements of a typed array (item 31), may carry a `lookup` +/// (meta-schema v3, `apply_property_reference` 0, parsed to the appended +/// `DocumentPropertyReferenceTarget::PermanentDocumentLookup`, so an id +/// reference keeps its variant and its encoding): the value is then +/// not the referenced document's id, and the referenced document is the +/// one the named unique index of the referenced document type finds for +/// a key assembled from the referring document. `keys` maps every index +/// property to a property path of the referring type, `$ownerId` or `.` +/// (the value, or the element, exactly once). A `deletableDocument` +/// reference takes none: a key into a deletable type could find a new +/// document once the one it found is deleted. Generation 3 of the parser +/// checks on every parse that each property a key reads is a stored, +/// required, single value of the referring type; +/// `create_document_types_from_document_schemas` 1, edited in place like +/// for item 29 (inert before this version, where no parsed reference +/// carries a lookup), checks a lookup into a document type of the same +/// contract under full validation (the index exists, is unique, carries +/// no `timeRange` and is not on an indexOnly type, the keys cover it +/// exactly, every source shares its index property's value kind, and the +/// key cannot move off the document it found: its schema properties are +/// immutable, and `$ownerId` is only a part on a type that is neither +/// transferable nor tradeable), and the contract reference validation +/// checks one into another contract, refusing it with +/// `ReferencedDocumentLookupInvalidError` (40137). The document +/// reference validation (generation 0, reached only from this version) +/// queries the index for each value's key, billed as a document fetch, +/// refuses a write with no match with `ReferencedEntityNotFoundError` +/// (40120, an element named by its list path), checks a +/// `propertyAgreement` against the document found, and on replace +/// re-validates when a property the key reads changed. A key may read +/// `$ownerId` only on a referring type that is neither transferable nor +/// tradeable, checked on every parse. A changed `lookup` is an +/// 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. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index dc4a35252a8..88453f8962f 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -160,6 +160,7 @@ use dpp::consensus::state::shielded::nullifier_already_spent_error::NullifierAlr use dpp::consensus::basic::state_transition::{StateTransitionNotActiveError, TransitionOverMaxInputsError, TransitionOverMaxOutputsError, InputWitnessCountMismatchError, TransitionNoInputsError, TransitionNoOutputsError, FeeStrategyEmptyError, FeeStrategyDuplicateError, FeeStrategyIndexOutOfBoundsError, FeeStrategyTooManyStepsError, InputBelowMinimumError, OutputBelowMinimumError, InputOutputBalanceMismatchError, OutputsNotGreaterThanInputsError, WithdrawalBalanceMismatchError, InsufficientFundingAmountError, InputsNotLessThanOutputsError, OutputAddressAlsoInputError, InvalidRemainderOutputCountError, WithdrawalBelowMinAmountError, ShieldedNoActionsError, ShieldedTooManyActionsError, ShieldedEmptyProofError, ShieldedZeroAnchorError, ShieldedInvalidValueBalanceError, ShieldedEncryptedNoteSizeMismatchError, ShieldedImplicitFeeCapExceededError, ShieldedInvalidDenominationError}; 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::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; @@ -691,6 +692,9 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::ReferencedIdentityKeyRequirementNotMetError(e) => { generic_consensus_error!(ReferencedIdentityKeyRequirementNotMetError, e).into() } + StateError::ReferencedDocumentLookupInvalidError(e) => { + generic_consensus_error!(ReferencedDocumentLookupInvalidError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index c749fbd1ac4..ba63519e79b 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -62,11 +62,17 @@ pub enum DocumentReferenceErrorCodeWasm { /// meet what the reference's `keyRequirements` require of it: its /// purpose, or a binding to a document type of the declaring contract. ReferencedIdentityKeyRequirementNotMet = 40136, + /// A `refersTo` lookup into a document type of another contract cannot + /// resolve there, reported at contract registration: the named index is + /// missing or not unique, the keys do not cover it exactly, or a source + /// 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, } impl DocumentReferenceErrorCodeWasm { /// The reference-validation error a code names, or `None` when the code - /// is not in the 40120-40125 range, 40131, 40135 or 40136. + /// is not in the 40120-40125 range, 40131, 40135, 40136 or 40137. fn from_code(code: u32) -> Option { match code { 40120 => Some(Self::ReferencedEntityNotFound), @@ -78,6 +84,7 @@ impl DocumentReferenceErrorCodeWasm { 40131 => Some(Self::ReferencedDocumentTypeNotDeletable), 40135 => Some(Self::ReferencedContractRequirementNotMet), 40136 => Some(Self::ReferencedIdentityKeyRequirementNotMet), + 40137 => Some(Self::ReferencedDocumentLookupInvalid), _ => None, } } @@ -245,6 +252,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_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; use dpp::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; @@ -402,6 +410,17 @@ mod tests { .into(), DocumentReferenceErrorCodeWasm::ReferencedIdentityKeyRequirementNotMet, ), + ( + StateError::ReferencedDocumentLookupInvalidError( + ReferencedDocumentLookupInvalidError::new( + "electedCharter.members".to_string(), + "bySubmittedCharter".to_string(), + "is not unique".to_string(), + ), + ) + .into(), + DocumentReferenceErrorCodeWasm::ReferencedDocumentLookupInvalid, + ), ( StateError::ReferencedDocumentTypeNotFoundError( ReferencedDocumentTypeNotFoundError::new( @@ -499,7 +518,7 @@ mod tests { #[test] fn codes_outside_the_reference_range_are_not_claimed() { - for code in [40119, 40126, 40137, 0, 40200] { + for code in [40119, 40126, 40138, 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 587f22b9d8b..685ff077807 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -109,6 +109,12 @@ export type DocumentPropertyReferenceTarget = * `{}`-valued — when the declaration carries none. */ propertyAgreement?: Record; + /** + * How the referenced document is found when the property's value is + * not its id. See {@link DocumentReferenceLookup}. Absent when the + * value is the referenced document's `$id`. + */ + lookup?: DocumentReferenceLookup; } | { type: 'identityPublicKey'; @@ -187,6 +193,26 @@ export type DocumentPropertyReferenceTarget = propertyAgreement?: Record; }; +/** + * The `lookup` of a document reference: the referenced document is the one + * the unique index `index` of the referenced document type finds for a key + * assembled from the referring document, and the reference holds if that + * document exists (code 40120 when it does not). + * + * `keys` maps every property of the index, by its name on the referenced + * side (`$ownerId` among the system ones), 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 that + * carries the reference (exactly once). To resolve a reference yourself, + * query the index with those values: at most one document matches. Only a + * `permanentDocument` reference carries a lookup, and its key cannot move + * off the document it found, so it keeps resolving. + */ +export type DocumentReferenceLookup = { + index: string; + keys: Record; +}; + /** * A single `refersTo` declaration on a document type. */ @@ -325,7 +351,8 @@ fn set_reference_target_fields( DocumentPropertyReferenceTarget::Identity => "identity", DocumentPropertyReferenceTarget::Contract { .. } => "contract", DocumentPropertyReferenceTarget::Token => "token", - DocumentPropertyReferenceTarget::PermanentDocument { .. } => "permanentDocument", + DocumentPropertyReferenceTarget::PermanentDocument { .. } + | DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } => "permanentDocument", DocumentPropertyReferenceTarget::IdentityPublicKey { .. } => "identityPublicKey", DocumentPropertyReferenceTarget::DeletableDocument { .. } => "deletableDocument", }; @@ -384,6 +411,12 @@ fn set_reference_target_fields( document_type_name, property_agreement, } + | DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id, + document_type_name, + property_agreement, + .. + } | DocumentPropertyReferenceTarget::DeletableDocument { contract_id, document_type_name, @@ -414,6 +447,30 @@ fn set_reference_target_fields( } set_field(object, "propertyAgreement", &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. + if let DocumentPropertyReferenceTarget::PermanentDocumentLookup { lookup, .. } = target + { + let lookup_object = Object::new(); + set_field( + &lookup_object, + "index", + &JsValue::from_str(&lookup.index), + path, + )?; + let keys = Object::new(); + for (index_property, source) in &lookup.keys { + set_field( + &keys, + index_property, + &JsValue::from_str(source.as_str()), + path, + )?; + } + set_field(&lookup_object, "keys", &keys, path)?; + set_field(object, "lookup", &lookup_object, path)?; + } } DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property, diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index c1e2cde8630..2ca3c2a75c8 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -147,6 +147,58 @@ type Reference = { keyRequirements?: { purpose?: string; boundTo?: string }; identityProperty?: string; propertyAgreement?: Record; + lookup?: { index: string; keys: Record }; +}; + +/** + * A `joinRequest` type unique on (`submittedCharterId`, `$ownerId`), and a + * `charter` whose `memberId` names the owner of a join request for the + * charter's own `submittedCharterId` rather than holding a request's id. + */ +const plainIdentifier = { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', + position: 0, +}; + +const lookupSchemas = { + joinRequest: { + type: 'object', + canBeDeleted: false, + // A permanentDocument lookup needs a key the join request keeps for good + documentsMutable: false, + properties: { + submittedCharterId: plainIdentifier, + }, + indices: [ + { + name: 'bySubmittedCharter', + properties: [{ submittedCharterId: 'asc' }, { $ownerId: 'asc' }], + unique: true, + }, + ], + required: ['submittedCharterId'], + additionalProperties: false, + }, + charter: { + type: 'object', + properties: { + submittedCharterId: plainIdentifier, + memberId: identifierProperty(1, { + type: 'permanentDocument', + documentType: 'joinRequest', + lookup: { + index: 'bySubmittedCharter', + keys: { submittedCharterId: 'submittedCharterId', $ownerId: '.' }, + }, + }), + }, + required: ['submittedCharterId'], + additionalProperties: false, + }, }; describe('DataContract — refersTo declarations (v14)', () => { @@ -317,6 +369,89 @@ describe('DataContract — refersTo declarations (v14)', () => { }); }); + describe('lookup', () => { + it('should carry the lookup of a reference resolved through a unique index', () => { + const contract = new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: lookupSchemas, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(14), + }); + const [member] = contract.documentTypeReferences('charter') as Reference[]; + + expect(member.path).to.equal('memberId'); + expect(member.type).to.equal('permanentDocument'); + expect(member.documentType).to.equal('joinRequest'); + expect(member.lookup).to.deep.equal({ + index: 'bySubmittedCharter', + keys: { $ownerId: '.', submittedCharterId: 'submittedCharterId' }, + }); + }); + + it('should carry the lookup the elements of a typed array declare', () => { + const withMembers = structuredClone(lookupSchemas); + (withMembers.charter.properties as Record).members = { + type: 'array', + minItems: 0, + maxItems: 15, + uniqueItems: true, + items: { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', + distinctFrom: '$ownerId', + refersTo: (withMembers.charter.properties.memberId as { refersTo: object }).refersTo, + }, + position: 2, + }; + const contract = new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: withMembers, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(14), + }); + const members = (contract.documentTypeReferences('charter') as Reference[]).find( + (reference) => reference.path === 'members[]', + )!; + + expect(members.type).to.equal('permanentDocument'); + expect(members.lookup).to.deep.equal({ + index: 'bySubmittedCharter', + keys: { $ownerId: '.', submittedCharterId: 'submittedCharterId' }, + }); + }); + + it('should omit lookup for a reference holding the referenced document id', () => { + const contract = buildContract(14); + const references = contract.documentTypeReferences('note') as Reference[]; + const other = references.find((reference) => reference.path === 'otherDoc')!; + + expect(other).to.not.have.property('lookup'); + }); + + it('should refuse a lookup into an index that is not unique', () => { + const notUnique = structuredClone(lookupSchemas); + delete (notUnique.joinRequest.indices[0] as { unique?: boolean }).unique; + + const build = () => new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: notUnique, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(14), + }); + + expect(build).to.throw(/is not unique/); + }); + }); + describe('documentReferences', () => { it('should key declarations by document type and omit types with none', () => { const contract = buildContract(14); @@ -358,6 +493,7 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentTypeNotDeletable).to.equal(40131); expect(wasm.DocumentReferenceErrorCode.ReferencedContractRequirementNotMet).to.equal(40135); expect(wasm.DocumentReferenceErrorCode.ReferencedIdentityKeyRequirementNotMet).to.equal(40136); + expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentLookupInvalid).to.equal(40137); }); it('should resolve a code back to its name', () => { diff --git a/packages/wasm-sdk/src/queries/chained_document.rs b/packages/wasm-sdk/src/queries/chained_document.rs index ce33811891d..63a2976c4e5 100644 --- a/packages/wasm-sdk/src/queries/chained_document.rs +++ b/packages/wasm-sdk/src/queries/chained_document.rs @@ -42,7 +42,9 @@ const CHAINED_DOCUMENTS_QUERY_TS: &'static str = r#" * an index carrying `joinProperty`, and `joinProperty` must declare a * same-contract `refersTo: permanentDocument` targeting * `outerDocumentType` ("posts I liked": inner `like` through `byLiker`, - * join `postId`, outer `post`). There are no outer-side clauses by + * join `postId`, outer `post`) whose `refersTo` carries no `lookup`: a + * reference resolved through a unique index holds no document ids, so it is + * refused as a join property. There are no outer-side clauses by * design — the verifier derives the outer query from the proven inner * results. */ diff --git a/packages/wasm-sdk/src/queries/composite_document.rs b/packages/wasm-sdk/src/queries/composite_document.rs index 7a25ff9731f..c063bb65231 100644 --- a/packages/wasm-sdk/src/queries/composite_document.rs +++ b/packages/wasm-sdk/src/queries/composite_document.rs @@ -65,8 +65,10 @@ export interface CompositeBind { * permanentDocument`, where a missing document is a verification * error, or `refersTo: deletableDocument`, where a document deleted * since is proven absent and left out, targeting the sub-query's - * document type); otherwise `$ownerId` or an indexed property (a - * LOOKUP, where absence is a proven fact). + * document type, and whose `refersTo` carries no `lookup`: a reference + * resolved through a unique index holds no document ids); otherwise + * `$ownerId` or an indexed property (a LOOKUP, where absence is a proven + * fact). */ field: string; }