From ff5c1ca427a4ef137de57ffc35fbb47b9eef1483 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 19:12:56 +0700 Subject: [PATCH 1/4] feat(platform)!: ownerRefersTo, a reference on the document's writer (PV14) A document type may declare one refersTo declaration of its own under the doctype-level ownerRefersTo keyword, whose value is the document's $ownerId, the writer, instead of a property's value. It takes every target an identifier property's refersTo takes but contract and identityPublicKey, propertyAgreement and lookup included (in a lookup "." is the writer). - meta-schema v3 and parser generation 3 read it through the same apply_property_reference 0, onto DocumentTypeV2::owner_reference (DocumentTypeV2Getters::owner_reference); its lookup is checked on both sides like a property's, and it counts one against max_references_per_document - contract registration checks it as a property's declaration, named .$ownerId - document create and every replace check the writer against the target, refusing with the target's own error (40120 and the rest) at $ownerId; transfers and purchases are not checked; an identity target reads nothing - adding, removing or changing it is an incompatible schema change on update (validate_schema_compatibility 1) - wasm-dpp2 lists it first in documentTypeReferences / documentReferences Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/documents.md | 31 +- .../document/v3/document-meta.json | 16 +- .../document_type/accessors/mod.rs | 28 +- .../document_type/accessors/v2/mod.rs | 8 + .../v1/mod.rs | 43 +- .../class_methods/try_from_schema/mod.rs | 87 ++- .../class_methods/try_from_schema/v3/mod.rs | 25 +- .../v3/owner_reference_tests.rs | 525 ++++++++++++++++++ .../src/data_contract/document_type/mod.rs | 5 + .../property/reference_lookup.rs | 29 +- .../validate_schema_compatibility/v1/mod.rs | 20 +- .../document_type/v2/accessors.rs | 8 +- .../src/data_contract/document_type/v2/mod.rs | 11 + .../document_reference_validation/mod.rs | 6 +- .../document_reference_validation/v0/mod.rs | 32 ++ .../batch/tests/document/mod.rs | 1 + .../batch/tests/document/owner_reference.rs | 492 ++++++++++++++++ .../v0/mod.rs | 47 +- .../data_contract_create/mod.rs | 35 ++ ...r-refers-to-registration-unknown-type.json | 24 + ...e-validation-contract-owner-refers-to.json | 146 +++++ .../src/version/system_limits/mod.rs | 5 +- .../src/version/system_limits/v4.rs | 5 +- .../rs-platform-version/src/version/v14.rs | 35 ++ .../data_contract/document_type_reference.rs | 21 +- packages/wasm-dpp2/src/data_contract/model.rs | 5 +- .../unit/DocumentPropertyReference.spec.ts | 97 ++++ 27 files changed, 1734 insertions(+), 53 deletions(-) create mode 100644 packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-unknown-type.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 29a42bec5a3..f73b9fb80fc 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -335,6 +335,35 @@ When the referring document is created or replaced, the document reference valid 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. +### On the writer (`ownerRefersTo`) + +A property's reference constrains a value the writer chose. Some rules constrain the writer instead: in the moderation charters, a `resignationRequest` may only come from a moderator of the team it resigns from. A document type states that with the doctype-level `ownerRefersTo` keyword, one `refersTo` declaration whose value is the document's `$ownerId`, the writer, rather than a property's value: + +```json +"resignationRequest": { + "type": "object", + "ownerRefersTo": { + "type": "permanentDocument", + "documentType": "addedModerator", + "lookup": { + "index": "byElectedCharterMember", + "keys": { "electedCharterId": "electedCharterId", "memberId": "." } + } + }, + "properties": { "electedCharterId": { "...": "..." } } +} +``` + +reads: the writer must be the `memberId` of an `addedModerator` for this document's `electedCharterId`. + +- The declaration is the one an identifier property carries, read by the same code (`apply_property_reference` 0), with every target and key it takes except two: `contract`, since the writer is an identity and never a contract, and `identityPublicKey`, which pairs the value with a key id the writer does not carry. Meta-schema v3 reuses the property declaration by `$ref` and refuses those two types; the parser (generation 3, `parse_owner_reference`) refuses them on the stored path too. The parsed declaration is `DocumentTypeV2::owner_reference`, read through `DocumentTypeV2Getters::owner_reference`, and the property types are unchanged. +- In a `lookup`, `"."` is the writer, and a `"$ownerId"` key part is the writer as well. The rule that a key part reading `"$ownerId"` needs a type whose documents can be neither transferred nor traded does not apply here: the declaration is checked on every write, the writer included, and governs writing rather than holding, exactly as a `propertyAgreement` pair keyed by `$ownerId`. The other referring-side rules hold unchanged (`DocumentReferenceLookup::owner_reference_referring_side_error`), and so does every referenced-side rule, for a type of the same contract at contract level and for one of another contract at registration. +- A `propertyAgreement` works as on a property reference; its referring side may name `$ownerId`, which is then the same writer as the reference's value. +- It counts one against `SystemLimits::max_references_per_document`. +- When a document is created, and on every replace whatever the replace changes, the document reference validation checks the writer against the target exactly as a property's value is checked, before the properties' references. The writer is transition metadata that never appears among the changed fields and may not be the one who wrote the document before, so an unchanged replace is checked too. A transfer or a purchase is not checked: on a transferable type the new owner holds the document, and the first replace they write is refused if they do not meet the target. A failure is the error the target reports for a property (`ReferencedEntityNotFoundError`, 40120, for a lookup that finds no document; `ReferencedDocumentPropertyMismatchError`, 40127, for an agreement; and the rest), with `$ownerId` as its path. An `identity` target reads nothing: the transition has already proved that the writer exists. At registration the contract reference validation checks the declaration as a property's, naming it `.$ownerId`. +- `permanentDocument` and `deletableDocument` references by id, and `token`, are admitted as for a property, but their value would have to be the writer's identity id, which no document id or token id equals, so a document type declaring one could never be written. +- Adding, removing or changing it is an incompatible schema change on update (`validate_schema_compatibility` 1 freezes the keyword as the shared rule set freezes `refersTo`). + ## 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. @@ -414,7 +443,7 @@ An identifier element may carry a `refersTo` declaration, which every element of - The declaration takes every target and key a single identifier property's `refersTo` takes (`identity`, `contract` with `contractRequirements`, `token`, and `permanentDocument` and `deletableDocument` with `contractId`, `documentType` and `propertyAgreement`), with the same checks at contract registration, except `identityPublicKey` in either form: its `keyIdProperty` names one sibling key id, and the `identityProperty` form sits on the key id itself, neither of which can pair with many elements, so an element may not declare it. The declaration belongs on the `items`; on the array itself it is refused. - When a document is created or replaced each element is checked as a single reference is, in list order: the target must exist, a referenced contract must meet the `contractRequirements`, a referenced document's type must be deletable or not as declared, and each `propertyAgreement` pair must hold. The referring side of a pair is still a property of the referring document or its `$ownerId`, the same for every element; the referenced side is a property of that element's referenced document. The first element that fails refuses the write with the error a single reference gives (`ReferencedEntityNotFoundError` 40120, `ReferencedDocumentPropertyMismatchError` 40127, `ReferencedContractRequirementNotMetError` 40135 and so on), whose `path` names the element by its list path: `reasons[2]` for the third. An empty or absent list checks nothing. Registration errors name the declaration `submittedCharter.reasons[]`. - A replace follows the rules of a single reference, element by element. A changed list re-validates the elements the stored list did not hold (the replace action carries the stored value of each changed property, `stored_changed_values`); the ones it held are unchanged references and are left alone, as an unchanged single reference is. Every element is re-validated when a property bound by a `propertyAgreement` changed, and on every replace when an agreement is keyed by `$ownerId` or the elements are `deletableDocument` references. An element repeating an earlier one of the same list is not fetched again. -- Every read is billed as a single reference's is. A foreign contract holding the referenced document type is fetched once per list, not once per element. Contract registration caps the references one document of a type can carry at `SystemLimits::max_references_per_document` (256), counting one per property with `refersTo` (an identifier, or a key id carrying a key reference) and `maxItems` per typed array of referencing elements: `maxItems` alone would let a type declare many lists of up to 1024 references each, and each one is a read when a document is written. +- Every read is billed as a single reference's is. A foreign contract holding the referenced document type is fetched once per list, not once per element. Contract registration caps the references one document of a type can carry at `SystemLimits::max_references_per_document` (256), counting one per property with `refersTo` (an identifier, or a key id carrying a key reference), one for the type's `ownerRefersTo` and `maxItems` per typed array of referencing elements: `maxItems` alone would let a type declare many lists of up to 1024 references each, and each one is a read when a document is written. - An `immutable` property may not hold a `deletableDocument` reference a replace could not clear: a typed array of them, at the top level or inside an immutable object, or a single one inside an immutable object. Every replace re-validates them, so once a target is deleted the property would have to change, which an immutable property cannot. A single `deletableDocument` reference that is itself the immutable top-level property has a way out, a replace may remove it once its target is gone, and that exception reads the one identifier the removed top-level property held. - A changed element `refersTo` is an incompatible schema change on contract update, as a changed `refersTo` on a scalar is. 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 adc1000459d..63a8e5f72f3 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), the requiredSince property keyword (the contract version a property is required from), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property) and its ownerRefersTo form on the document type, whose value is the writer, the requiredSince property keyword (the contract version a property is required from), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "documentProperties": { @@ -1688,6 +1688,20 @@ "uniqueItems": true, "description": "The subset of the immutable properties a replace may still set while the stored document has no value for them: a first-time set is accepted, after which the property is frozen like the rest of the immutable list (it can neither change nor be removed). Every entry must also appear in immutable; it is only meaningful for optional properties, since a required one always has a value from creation. On contract update an entry may be dropped (tightening) at any time, but may only be added for a property that becomes immutable in the same update: an already-immutable property cannot start allowing a set. Available from protocol version 14." }, + "ownerRefersTo": { + "description": "A refersTo declaration whose value is the document's $ownerId, the writer, instead of a property's value: the declaration of an identifier property, with the same keys and the same checks, except that contract (the writer is an identity, never a contract) and identityPublicKey (it pairs the value with a key id, which the writer does not carry) are refused. In a lookup, \".\" is the writer, and \"$ownerId\" names the writer as well. A propertyAgreement's referring side is still a property of the document or its $ownerId, the same writer. When a document is created, and on every replace whatever it changes (the writer is not part of the document data, and may not be the one who wrote it before), consensus checks the writer against the target exactly as a property's value is checked, and refuses the write with the error that target reports for a property (ReferencedEntityNotFoundError, 40120, and the rest), naming $ownerId as the path. A transfer or a purchase is not checked: the declaration governs writing, not holding. It counts as one reference against the references a document may carry. Fixed when the document type is created: adding, removing or changing it is an incompatible schema change on update. Available from protocol version 14.", + "$ref": "#/$defs/documentSchema/properties/refersTo", + "properties": { + "type": { + "not": { + "enum": [ + "contract", + "identityPublicKey" + ] + } + } + } + }, "additionalProperties": { "type": "boolean", "const": false diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs index 978fc4af0dd..f0828998d1d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs @@ -5,7 +5,9 @@ mod v2; use crate::data_contract::document_type::action_fees::DocumentActionFees; use crate::data_contract::document_type::index::Index; use crate::data_contract::document_type::index_level::IndexLevel; -use crate::data_contract::document_type::property::DocumentProperty; +use crate::data_contract::document_type::property::{ + DocumentProperty, DocumentPropertyReferenceTarget, +}; use crate::data_contract::document_type::{DocumentType, DocumentTypeMutRef, DocumentTypeRef}; use platform_value::{Identifier, Value}; @@ -1044,6 +1046,14 @@ impl DocumentTypeV2Getters for DocumentType { DocumentType::V2(v2) => v2.action_fees(), } } + + fn owner_reference(&self) -> Option<&DocumentPropertyReferenceTarget> { + match self { + DocumentType::V0(_) => None, + DocumentType::V1(_) => None, + DocumentType::V2(v2) => v2.owner_reference(), + } + } } impl DocumentTypeV2Setters for DocumentType { @@ -1177,6 +1187,14 @@ impl DocumentTypeV2Getters for DocumentTypeRef<'_> { DocumentTypeRef::V2(v2) => v2.action_fees(), } } + + fn owner_reference(&self) -> Option<&DocumentPropertyReferenceTarget> { + match self { + DocumentTypeRef::V0(_) => None, + DocumentTypeRef::V1(_) => None, + DocumentTypeRef::V2(v2) => v2.owner_reference(), + } + } } impl DocumentTypeV2Getters for DocumentTypeMutRef<'_> { @@ -1276,6 +1294,14 @@ impl DocumentTypeV2Getters for DocumentTypeMutRef<'_> { DocumentTypeMutRef::V2(v2) => v2.action_fees(), } } + + fn owner_reference(&self) -> Option<&DocumentPropertyReferenceTarget> { + match self { + DocumentTypeMutRef::V0(_) => None, + DocumentTypeMutRef::V1(_) => None, + DocumentTypeMutRef::V2(v2) => v2.owner_reference(), + } + } } #[cfg(test)] diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs index 37308e76a8d..6945f21be81 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs @@ -1,4 +1,5 @@ use crate::data_contract::document_type::action_fees::DocumentActionFees; +use crate::data_contract::document_type::property::DocumentPropertyReferenceTarget; use std::collections::BTreeSet; /// Trait providing getters for DocumentTypeV2-specific fields. @@ -76,6 +77,13 @@ pub trait DocumentTypeV2Getters { /// (the `actionFees` keyword, protocol version 14). `None` on document types that /// declare none and on those that predate the keyword. fn action_fees(&self) -> Option<&DocumentActionFees>; + + /// The `refersTo` declaration whose value is the document's `$ownerId`, the + /// writer (the `ownerRefersTo` keyword, protocol version 14): consensus + /// checks it with the writer's id as the value when a document is created + /// and on every replace. `None` on document types that declare none and on + /// those that predate the keyword. + fn owner_reference(&self) -> Option<&DocumentPropertyReferenceTarget>; } /// Trait providing setters for DocumentTypeV2-specific fields. 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 26a9ca3799a..ac3480973f6 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 @@ -156,19 +156,32 @@ impl DocumentType { // registration, and a reference naming a document type this contract does not have // is left to that validation too, which reports it. // + // The type's `ownerRefersTo` declaration, whose lookup key takes the writer for + // `"."`, is checked the same way. + // // 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. + // `apply_property_reference: Some(_)`, so the loop below finds none there, and only + // parser generation 3, selected from protocol version 14, reads `ownerRefersTo`. 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 declaring = document_type.as_ref(); + // On the writer (`None`), on an identifier property or on the elements of a + // typed array + let declarations = + declaring + .owner_reference() + .map(|target| (None, target)) + .into_iter() + .chain(declaring.flattened_properties().iter().filter_map( + |(path, property)| { + property + .property_type + .reference() + .and_then(|reference| reference.target()) + .map(|target| (Some(path), target)) + }, + )); + for (path, target) in declarations { let Some(DocumentReferenceDeclaration { contract_id, document_type_name, @@ -196,12 +209,14 @@ impl DocumentType { { continue; } - if let Some(reason) = - lookup.referenced_side_error(document_type.as_ref(), referenced) - { + if let Some(reason) = lookup.referenced_side_error(declaring, referenced) { + let declared_on = match path { + Some(path) => format!("property \"{path}\" refersTo"), + None => "ownerRefersTo".to_string(), + }; return Err(consensus_or_protocol_data_contract_error( DataContractError::InvalidContractStructure(format!( - "document type \"{name}\" property \"{path}\" refersTo lookup: {reason}" + "document type \"{name}\" {declared_on} lookup: {reason}" )), )); } 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 74a0f58d4f5..21d347bc8ce 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,5 +1,7 @@ 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::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::{ @@ -25,7 +27,7 @@ use crate::validation::operations::ProtocolValidationOperation; use crate::ProtocolError; use indexmap::IndexMap; use platform_value::btreemap_extensions::BTreeValueMapHelper; -use platform_value::{Identifier, Value}; +use platform_value::{Identifier, Value, ValueMapHelper}; use platform_version::version::PlatformVersion; use std::collections::{BTreeMap, BTreeSet}; @@ -923,6 +925,71 @@ fn apply_element_reference_v0( apply_property_reference_v0(items, element_type) } +/// Reads a document type's `ownerRefersTo` keyword: one `refersTo` declaration +/// whose value is the document's `$ownerId`, the writer, instead of a +/// property's value. The declaration goes through [`apply_property_reference`] +/// as one declared on an identifier property does, so it takes every target +/// and key that declaration takes, `propertyAgreement` and `lookup` included +/// (where `"."` is the writer), except two refused up front: `contract`, since +/// the writer is an identity and never a contract, and `identityPublicKey`, +/// which pairs the value with a key id the writer does not carry, in either +/// form. +/// +/// Only parser generation 3 calls it, on every parse, validating or not, like +/// its other doctype-level keywords. Versioned through +/// `apply_property_reference`, the gate of the declaration it reads: `None` +/// leaves the keyword unread, as it leaves `refersTo` on a property. +pub(super) fn parse_owner_reference( + schema: &Value, + platform_version: &PlatformVersion, +) -> Result, DataContractError> { + // A schema that is not an object carries no keyword: the core parser + // refuses it, and a value error here must not replace that refusal + let Ok(schema_map) = schema.to_map() else { + return Ok(None); + }; + let Some(declaration) = schema_map.get_optional_key(property_names::OWNER_REFERS_TO) else { + return Ok(None); + }; + + let reference_type = declaration + .to_btree_ref_string_map()? + .get(property_names::TYPE) + .and_then(|reference_type| reference_type.as_text()) + .map(str::to_string); + match reference_type.as_deref() { + Some("contract") => { + return Err(DataContractError::InvalidContractStructure( + "ownerRefersTo does not take a contract reference: its value is the writer, an \ + identity, which is never a contract" + .to_string(), + )) + } + Some("identityPublicKey") => { + return Err(DataContractError::InvalidContractStructure( + "ownerRefersTo does not take an identityPublicKey reference: it pairs the value \ + with a key id, which the writer does not carry" + .to_string(), + )) + } + _ => {} + } + + let inner_properties = BTreeMap::from([(property_names::REFERS_TO.to_string(), declaration)]); + match apply_property_reference( + &inner_properties, + DocumentPropertyType::Identifier, + platform_version, + )? { + DocumentPropertyType::IdentifierWithReference(target) => Ok(Some(target)), + // The declaration is not read where the keyword is not active + DocumentPropertyType::Identifier => Ok(None), + _ => Err(DataContractError::InvalidContractStructure( + "ownerRefersTo must declare what the writer refers to".to_string(), + )), + } +} + /// 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 `"."` @@ -1015,7 +1082,9 @@ fn parse_document_reference_lookup( /// 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`]. +/// [`DocumentReferenceLookup::referring_side_error`], and +/// [`DocumentReferenceLookup::owner_reference_referring_side_error`] for the +/// lookup of the type's `ownerRefersTo`. /// /// 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 @@ -1044,6 +1113,18 @@ pub(super) fn validate_reference_lookup_sources( ))); } } + // The writer's own reference, whose `"."` is the writer + if let Some(lookup) = document_type + .owner_reference() + .and_then(|target| target.as_any_document_reference()) + .and_then(|declaration| declaration.lookup) + { + if let Some(reason) = lookup.owner_reference_referring_side_error(document_type) { + return Err(DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" ownerRefersTo lookup: {reason}" + ))); + } + } Ok(()) } 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 9369456d887..9182f6e7329 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 @@ -2,7 +2,8 @@ //! //! Generation 3 is generation 2 plus the ranked index keywords //! (`rankedCountable` / `rankedSummable` / `rankedAverageable`), the -//! indexOnly grammar, and the doctype-level `immutable` property list. +//! indexOnly grammar, the doctype-level `immutable` property list and the +//! doctype-level `ownerRefersTo` reference on the writer. //! //! It exists as its own generation — rather than as a version gate inside the //! shipped ones — because that is what keeps a historical block from ever @@ -48,7 +49,9 @@ use crate::consensus::basic::data_contract::InvalidIndexedPropertyConstraintErro use crate::consensus::ConsensusError; use super::common; -use super::{validate_encrypted_for_declarations, validate_reference_lookup_sources}; +use super::{ + parse_owner_reference, validate_encrypted_for_declarations, validate_reference_lookup_sources, +}; mod ranked_prefix_overlap; use ranked_prefix_overlap::validate_no_ranked_prefix_overlap; @@ -328,6 +331,8 @@ fn try_from_schema_generation_3( name, property_names::IMMUTABLE_ALLOW_SETTING, )?; + let owner_reference = parse_owner_reference(&schema, platform_version) + .map_err(consensus_or_protocol_data_contract_error)?; let v1 = common::parse_document_type_core( data_contract_id, @@ -397,6 +402,7 @@ fn try_from_schema_generation_3( let mut v2: DocumentTypeV2 = v1.into(); v2.action_fees = action_fees; v2.entry_payload = entry_payload; + v2.owner_reference = owner_reference; common::apply_doctype_aggregates(&mut v2, aggregates, name)?; // After the aggregates: `apply_index_only` rejects the doctype-level // aggregate flags (they describe the primary-key tree, which an @@ -417,7 +423,8 @@ 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. + // The same for the properties a `refersTo` lookup reads to assemble its key, + // the lookup of the `ownerRefersTo` declaration included. validate_reference_lookup_sources(DocumentTypeRef::V2(&v2), name) .map_err(consensus_or_protocol_data_contract_error)?; @@ -494,8 +501,8 @@ fn validate_typed_array_max_items( /// The references one document of the type can carry, one for each /// property declaring `refersTo` (an identifier, or a key id with a key -/// reference) and `maxItems` for each typed array whose elements declare it, -/// are at most +/// reference), `maxItems` for each typed array whose elements declare it and +/// one for the type's `ownerRefersTo`, are at most /// `SystemLimits::max_references_per_document`. Every reference is a billed /// state read when the document is created or replaced, so the sum bounds /// the reads one write can cause; `max_typed_array_items` alone would let a @@ -510,18 +517,20 @@ fn validate_reference_count( platform_version: &PlatformVersion, ) -> Result<(), ProtocolError> { let limit = platform_version.system_limits.max_references_per_document; - let references: u32 = document_type + let property_references: u32 = document_type .flattened_properties() .values() .filter_map(|property| property.property_type.reference()) .map(|reference| reference.max_references()) .sum(); + let references = + property_references.saturating_add(u32::from(document_type.owner_reference.is_some())); if references > u32::from(limit) { return Err(consensus_or_protocol_data_contract_error( DataContractError::InvalidContractStructure(format!( "document type \"{name}\" declares references for up to {references} values per \ document (one per property with refersTo, maxItems per typed array of \ - referencing elements), above the maximum of {limit}", + referencing elements, one for ownerRefersTo), above the maximum of {limit}", )), )); } @@ -626,6 +635,8 @@ mod moderators_delete_tests; #[cfg(all(test, feature = "validation"))] mod name_rules_tests; #[cfg(all(test, feature = "validation"))] +mod owner_reference_tests; +#[cfg(all(test, feature = "validation"))] mod reference_lookup_tests; #[cfg(all(test, feature = "validation"))] mod typed_array_reference_tests; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs new file mode 100644 index 00000000000..d7ec3bd6ce7 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs @@ -0,0 +1,525 @@ +//! `ownerRefersTo` (protocol version 14): a document type's own `refersTo` +//! declaration, whose value is the document's `$ownerId`, the writer. The +//! parse of every target it takes and the two it refuses, the checks of its +//! lookup on both sides, its count against the references a document may +//! carry, the protocol version gate, the platform serialization round trip and +//! the contract update rule. + +use crate::block::block_info::BlockInfo; +use crate::consensus::basic::basic_error::BasicError; +use crate::consensus::ConsensusError; +use crate::data_contract::accessors::v0::DataContractV0Getters; +use crate::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; +use crate::data_contract::document_type::accessors::DocumentTypeV2Getters; +use crate::data_contract::document_type::{ + DocumentPropertyReferenceTarget, DocumentReferenceLookup, LookupKeySource, +}; +use crate::data_contract::methods::validate_update::DataContractUpdateValidationMethodsV0; +use crate::data_contract::schema::DataContractSchemaMethodsV0; +use crate::data_contract::DataContract; +use crate::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, + PlatformSerializableWithPlatformVersion, +}; +use crate::ProtocolError; +use platform_value::string_encoding::Encoding; +use platform_value::Identifier; +use platform_version::version::PlatformVersion; +use serde_json::json; +use std::collections::BTreeMap; + +const CONTRACT_ID: [u8; 32] = [7; 32]; + +fn identifier(position: u32) -> serde_json::Value { + json!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": position + }) +} + +/// The moderation charters' rule: the writer must be the `memberId` of an +/// `addedModerator` for the document's `electedCharterId`. +fn added_moderator_lookup() -> serde_json::Value { + json!({ + "index": "byElectedCharterMember", + "keys": { "electedCharterId": "electedCharterId", "memberId": "." } + }) +} + +fn permanent_added_moderator(lookup: serde_json::Value) -> serde_json::Value { + json!({ "type": "permanentDocument", "documentType": "addedModerator", "lookup": lookup }) +} + +/// A contract with a permanent, immutable `addedModerator` type (unique on +/// (`electedCharterId`, `memberId`) and on (`$ownerId`, `memberId`), with a +/// non-unique `byMember` index), a deletable `post` type, and a transferable +/// `resignationRequest` type declaring `owner_refers_to` (when it is not +/// null) next to a required `electedCharterId`, an optional `note` and +/// `extra`, one more property schema. +fn charter_contract_with( + owner_refers_to: serde_json::Value, + extra: Option, + version: u32, +) -> serde_json::Value { + let mut resignation_request = json!({ + "type": "object", + "transferable": 1, + "properties": { + "electedCharterId": identifier(0), + "note": { "type": "string", "maxLength": 63, "position": 1 } + }, + "required": ["electedCharterId"], + "additionalProperties": false + }); + if !owner_refers_to.is_null() { + resignation_request["ownerRefersTo"] = owner_refers_to; + } + if let Some(extra) = extra { + resignation_request["properties"]["extra"] = extra; + } + json!({ + "$formatVersion": "1", + "id": Identifier::from(CONTRACT_ID).to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": version, + "documentSchemas": { + "addedModerator": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "electedCharterId": identifier(0), + "memberId": identifier(1) + }, + "indices": [ + { + "name": "byElectedCharterMember", + "properties": [{ "electedCharterId": "asc" }, { "memberId": "asc" }], + "unique": true + }, + { + "name": "byOwnerMember", + "properties": [{ "$ownerId": "asc" }, { "memberId": "asc" }], + "unique": true + }, + { "name": "byMember", "properties": [{ "memberId": "asc" }] } + ], + "required": ["electedCharterId", "memberId"], + "additionalProperties": false + }, + "post": { + "type": "object", + "properties": { + "text": { "type": "string", "maxLength": 63, "position": 0 } + }, + "additionalProperties": false + }, + "resignationRequest": resignation_request + } + }) +} + +fn charter_contract(owner_refers_to: serde_json::Value) -> serde_json::Value { + charter_contract_with(owner_refers_to, None, 1) +} + +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 owner_reference(contract: &DataContract) -> Option { + contract + .document_type_for_name("resignationRequest") + .expect("the resignationRequest document type") + .owner_reference() + .cloned() +} + +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_an_owner_reference_to_every_target_an_identifier_property_takes_but_two() { + let lookup = DocumentReferenceLookup { + index: "byElectedCharterMember".to_string(), + keys: BTreeMap::from([ + ( + "electedCharterId".to_string(), + LookupKeySource::Property("electedCharterId".to_string()), + ), + ("memberId".to_string(), LookupKeySource::ReferenceValue), + ]), + }; + let agreement = BTreeMap::from([("$ownerId".to_string(), "memberId".to_string())]); + + for (owner_refers_to, expected) in [ + ( + json!({ "type": "identity" }), + DocumentPropertyReferenceTarget::Identity, + ), + ( + json!({ "type": "token" }), + DocumentPropertyReferenceTarget::Token, + ), + ( + json!({ "type": "permanentDocument", "documentType": "addedModerator" }), + DocumentPropertyReferenceTarget::PermanentDocument { + contract_id: None, + document_type_name: "addedModerator".to_string(), + property_agreement: BTreeMap::new(), + }, + ), + ( + permanent_added_moderator(added_moderator_lookup()), + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "addedModerator".to_string(), + property_agreement: BTreeMap::new(), + lookup: lookup.clone(), + }, + ), + // The referring side of an agreement may be the writer: the same + // identity as the reference's value + ( + json!({ + "type": "permanentDocument", + "documentType": "addedModerator", + "propertyAgreement": { "$ownerId": "memberId" }, + "lookup": added_moderator_lookup() + }), + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "addedModerator".to_string(), + property_agreement: agreement.clone(), + lookup, + }, + ), + ( + json!({ "type": "deletableDocument", "documentType": "post" }), + DocumentPropertyReferenceTarget::DeletableDocument { + contract_id: None, + document_type_name: "post".to_string(), + property_agreement: BTreeMap::new(), + }, + ), + ] { + // Read on both paths, as every doctype-level keyword of generation 3 + for full_validation in [true, false] { + let parsed = contract_on( + charter_contract(owner_refers_to.clone()), + full_validation, + PlatformVersion::latest(), + ) + .unwrap_or_else(|e| panic!("{owner_refers_to} should parse: {e}")); + assert_eq!( + owner_reference(&parsed).as_ref(), + Some(&expected), + "{owner_refers_to}" + ); + } + } + + // A type that declares none has none, and no other type gains one + let without = contract(charter_contract(serde_json::Value::Null)).expect("parses"); + assert_eq!(owner_reference(&without), None); + assert_eq!( + without + .document_type_for_name("addedModerator") + .expect("the addedModerator document type") + .owner_reference(), + None + ); +} + +#[test] +fn should_refuse_a_contract_or_identity_public_key_owner_reference() { + for (owner_refers_to, fragment) in [ + ( + json!({ "type": "contract" }), + "ownerRefersTo does not take a contract reference", + ), + ( + json!({ "type": "contract", "contractRequirements": { "owner": "self" } }), + "ownerRefersTo does not take a contract reference", + ), + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "note" }), + "ownerRefersTo does not take an identityPublicKey reference", + ), + ( + json!({ "type": "identityPublicKey", "identityProperty": "$ownerId" }), + "ownerRefersTo does not take an identityPublicKey reference", + ), + ] { + let schema = charter_contract(owner_refers_to.clone()); + // The parser refuses it on the stored path, where no meta-schema runs + assert_refused( + contract_on(schema.clone(), false, PlatformVersion::latest()), + fragment, + ); + // and the meta-schema before it at registration + contract(schema).expect_err("the meta-schema should refuse it"); + } +} + +#[test] +fn should_refuse_an_owner_lookup_without_the_reference_value() { + // `"$ownerId"` names the writer too, but a lookup still fills exactly one + // key part from `"."` + for keys in [ + json!({ "electedCharterId": "electedCharterId", "memberId": "$ownerId" }), + json!({ "electedCharterId": ".", "memberId": "." }), + ] { + let schema = charter_contract(permanent_added_moderator(json!({ + "index": "byElectedCharterMember", + "keys": keys + }))); + for full_validation in [true, false] { + assert_refused( + contract_on(schema.clone(), full_validation, PlatformVersion::latest()), + "must fill exactly one index property from \".\"", + ); + } + } +} + +#[test] +fn should_check_the_referring_side_of_an_owner_lookup_on_every_parse() { + // A key part read from an optional property could be missing + let optional_source = charter_contract(permanent_added_moderator(json!({ + "index": "byElectedCharterMember", + "keys": { "electedCharterId": "note", "memberId": "." } + }))); + for full_validation in [true, false] { + assert_refused( + contract_on( + optional_source.clone(), + full_validation, + PlatformVersion::latest(), + ), + "document type \"resignationRequest\" ownerRefersTo lookup: key \ + \"electedCharterId\" reads \"note\", which is not required", + ); + } + + // `"$ownerId"` is the writer, like `"."`: an owner reference governs + // writing, so it reads the writer even on a type whose documents can be + // transferred, as `resignationRequest`'s can + let writer_source = contract(charter_contract(permanent_added_moderator(json!({ + "index": "byOwnerMember", + "keys": { "$ownerId": "$ownerId", "memberId": "." } + })))) + .expect("an owner lookup may read the writer on a transferable type"); + assert!(matches!( + owner_reference(&writer_source), + Some(DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. }) + )); + + // whereas a property's lookup may not, there + let mut member_id = identifier(2); + member_id["refersTo"] = permanent_added_moderator(json!({ + "index": "byOwnerMember", + "keys": { "$ownerId": "$ownerId", "memberId": "." } + })); + assert_refused( + contract(charter_contract_with( + serde_json::Value::Null, + Some(member_id), + 1, + )), + "a lookup may read the writer only on a document type that cannot be transferred", + ); +} + +#[test] +fn should_check_an_owner_lookup_into_a_type_of_the_same_contract() { + for (index, fragment) in [ + ( + "byNothing", + "document type \"resignationRequest\" ownerRefersTo lookup: the referenced document \ + type \"addedModerator\" has no index named \"byNothing\"", + ), + ( + "byMember", + "document type \"resignationRequest\" ownerRefersTo lookup: index \"byMember\" of \ + \"addedModerator\" is not unique", + ), + ] { + let keys = if index == "byMember" { + json!({ "memberId": "." }) + } else { + json!({ "anything": "." }) + }; + let schema = charter_contract(permanent_added_moderator( + json!({ "index": index, "keys": keys }), + )); + assert_refused(contract(schema.clone()), fragment); + // A contract read back from state passed the check when it was + // registered + contract_on(schema, false, PlatformVersion::latest()) + .expect("the stored path does not re-check the referenced side"); + } +} + +#[test] +fn should_count_the_owner_reference_against_the_references_a_document_may_carry() { + let limit = PlatformVersion::latest() + .system_limits + .max_references_per_document; + // A typed array of as many identity references as a document may carry + let references = json!({ + "type": "array", + "minItems": 0, + "maxItems": limit, + "items": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { "type": "identity" } + }, + "position": 2 + }); + + contract(charter_contract_with( + serde_json::Value::Null, + Some(references.clone()), + 1, + )) + .expect("the array alone is at the limit"); + + assert_refused( + contract(charter_contract_with( + json!({ "type": "identity" }), + Some(references), + 1, + )), + &format!( + "declares references for up to {} values per document", + u32::from(limit) + 1 + ), + ); +} + +#[test] +fn should_refuse_owner_refers_to_before_protocol_version_14_and_read_it_at_14() { + let schema = charter_contract(permanent_added_moderator(added_moderator_lookup())); + let platform_version_13 = PlatformVersion::get(13).expect("platform version 13 should exist"); + + // Meta-schema v2 closes the document type level, so a registering parse + // refuses the unknown keyword + contract_on(schema.clone(), true, platform_version_13) + .expect_err("protocol version 13 should refuse the keyword"); + // A parser predating it does not read it + let ignored = contract_on(schema.clone(), false, platform_version_13) + .expect("protocol version 13 should parse the rest of the contract"); + assert_eq!(owner_reference(&ignored), None); + + let accepted = contract_on(schema, true, PlatformVersion::latest()).expect("parses"); + assert!(matches!( + owner_reference(&accepted), + Some(DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. }) + )); +} + +/// The declaration rides on the schema, which is what the contract +/// serializes: a contract with one comes back with it, and one without +/// serializes as it did before the keyword existed, its schema carrying no +/// trace of it. +#[test] +fn should_round_trip_a_contract_through_platform_serialization_with_and_without_an_owner_reference() +{ + let platform_version = PlatformVersion::latest(); + + for owner_refers_to in [ + serde_json::Value::Null, + json!({ "type": "identity" }), + permanent_added_moderator(added_moderator_lookup()), + ] { + let original = contract(charter_contract(owner_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, "ownerRefersTo {owner_refers_to}"); + assert_eq!(owner_reference(&original), owner_reference(&recovered)); + assert_eq!( + recovered + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the contract should serialize again"), + bytes + ); + let stored_schema = recovered + .document_schemas() + .get("resignationRequest") + .copied() + .expect("the resignationRequest schema"); + assert_eq!( + stored_schema + .get_optional_value("ownerRefersTo") + .expect("the schema is a map") + .is_some(), + !owner_refers_to.is_null() + ); + } +} + +#[test] +fn should_refuse_adding_removing_or_changing_an_owner_reference_on_update() { + let platform_version = PlatformVersion::latest(); + let identity = json!({ "type": "identity" }); + let lookup = permanent_added_moderator(added_moderator_lookup()); + + for (before, after, operation) in [ + (serde_json::Value::Null, identity.clone(), "add"), + (lookup.clone(), serde_json::Value::Null, "remove"), + (identity.clone(), json!({ "type": "token" }), "replace"), + ] { + let old = contract(charter_contract_with(before.clone(), None, 1)).expect("parses"); + let new = contract(charter_contract_with(after.clone(), None, 2)).expect("parses"); + let result = old + .validate_update(&new, &BlockInfo::default(), platform_version) + .expect("the update should be judged"); + assert!( + matches!( + result.errors.as_slice(), + [ConsensusError::BasicError(BasicError::IncompatibleDocumentTypeSchemaError(e))] + if e.document_type_name() == "resignationRequest" + && e.operation() == operation + && e.property_path().starts_with("/ownerRefersTo") + ), + "{before} -> {after}: {:?}", + result.errors + ); + } + + // An update leaving it as it was is judged on the rest alone + let old = contract(charter_contract_with(lookup.clone(), None, 1)).expect("parses"); + let new = contract(charter_contract_with(lookup, None, 2)).expect("parses"); + let result = old + .validate_update(&new, &BlockInfo::default(), platform_version) + .expect("the update should be judged"); + assert!(result.is_valid(), "{:?}", result.errors); +} 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 ed17c917031..f57634dea03 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -105,6 +105,11 @@ pub(crate) mod property_names { pub const ENCRYPTION_KEY_REQUIREMENTS: &str = "encryptionKeyReqs"; pub const DECRYPTION_KEY_REQUIREMENTS: &str = "decryptionKeyReqs"; pub const REFERS_TO: &str = "refersTo"; + /// Doctype-level `refersTo` declaration whose value is the document's + /// `$ownerId`, the writer, rather than a property's value. Meta-schema + /// v3+ (protocol version 14). See `parse_owner_reference` in + /// `try_from_schema`. + pub const OWNER_REFERS_TO: &str = "ownerRefersTo"; pub const DISTINCT_FROM: &str = "distinctFrom"; pub const CONTRACT_ID: &str = "contractId"; pub const DOCUMENT_TYPE: &str = "documentType"; diff --git a/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs b/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs index 5813a1b4be6..be4d405301d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs @@ -152,12 +152,37 @@ impl DocumentReferenceLookup { &self, declaring: DocumentTypeRef, reference_path: &str, + ) -> Option { + self.referring_sources_error(declaring, Some(reference_path)) + } + + /// [`Self::referring_side_error`] for the lookup of an `ownerRefersTo` + /// declaration of `declaring`, whose own value, `"."`, is the writer: every + /// property source follows the same rules. A `"$ownerId"` source is the + /// writer as well, and is admitted on any type: an owner reference is + /// checked on every create and every replace, whoever the writer is, and + /// like the `$ownerId` side of a `propertyAgreement` it governs writing, + /// not holding, so a transfer or a purchase moving the owner leaves + /// nothing it promised behind. + pub fn owner_reference_referring_side_error( + &self, + declaring: DocumentTypeRef, + ) -> Option { + self.referring_sources_error(declaring, None) + } + + /// The referring-side rules, for a lookup declared on the property at + /// `reference_path`, or, when it is `None`, on the owner reference. + fn referring_sources_error( + &self, + declaring: DocumentTypeRef, + reference_path: Option<&str>, ) -> Option { for (index_property, source) in &self.keys { let path = match source { LookupKeySource::ReferenceValue => continue, LookupKeySource::OwnerId => { - if owner_can_change(declaring) { + if reference_path.is_some() && 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 \ @@ -169,7 +194,7 @@ impl DocumentReferenceLookup { } LookupKeySource::Property(path) => path, }; - if path == reference_path { + if Some(path.as_str()) == reference_path { return Some(format!( "key \"{index_property}\" names the reference property itself: write \".\" \ for the reference's own value" diff --git a/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs index c5091476767..410b34e5e51 100644 --- a/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs @@ -29,6 +29,10 @@ //! the differ has no rule for it, and `validate_update` v1's //! `validate_immutable_fields_update` judges it (the list may grow, never //! shrink). +//! +//! The top-level `ownerRefersTo` key (protocol version 14) gets the frozen +//! rule of the property `refersTo`, so any change to it is an incompatible +//! schema change. use crate::data_contract::document_type::schema::IncompatibleJsonSchemaOperation; use crate::data_contract::errors::{DataContractError, JsonSchemaError}; @@ -56,8 +60,22 @@ static OPTIONS: Lazy = Lazy::new(|| { .expect("required rule must have inner rules") .allow_removal = false; + // `ownerRefersTo` (protocol version 14) is the document type's own + // `refersTo`, whose value is the writer: frozen exactly as the property + // keyword is, so adding, removing or changing it is reported as an + // incompatible change. The rule lives here rather than in the shared rule + // set, which the generation-0 check also reads, because no earlier + // protocol version knows the keyword. + let owner_refers_to_rule = KEYWORD_COMPATIBILITY_RULES + .get("refersTo") + .expect("refersTo rule must be present") + .clone(); + Options { - override_rules: CompatibilityRulesCollection::from_iter([("required", required_rule)]), + override_rules: CompatibilityRulesCollection::from_iter([ + ("required", required_rule), + ("ownerRefersTo", owner_refers_to_rule), + ]), } }); diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs index 13b2ed0ac4c..b0990cedfe1 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs @@ -5,7 +5,9 @@ use crate::data_contract::document_type::accessors::{ use crate::data_contract::document_type::action_fees::DocumentActionFees; use crate::data_contract::document_type::index::Index; use crate::data_contract::document_type::index_level::IndexLevel; -use crate::data_contract::document_type::property::DocumentProperty; +use crate::data_contract::document_type::property::{ + DocumentProperty, DocumentPropertyReferenceTarget, +}; use platform_value::{Identifier, Value}; @@ -260,6 +262,10 @@ impl DocumentTypeV2Getters for DocumentTypeV2 { fn action_fees(&self) -> Option<&DocumentActionFees> { self.action_fees.as_ref() } + + fn owner_reference(&self) -> Option<&DocumentPropertyReferenceTarget> { + self.owner_reference.as_ref() + } } impl DocumentTypeV2Setters for DocumentTypeV2 { diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index 9421b62dc17..76d854963ef 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -10,6 +10,7 @@ use crate::data_contract::document_type::action_fees::DocumentActionFees; use crate::data_contract::document_type::methods::{ DocumentTypeBasicMethods, DocumentTypeV0Methods, }; +use crate::data_contract::document_type::property::DocumentPropertyReferenceTarget; use crate::data_contract::document_type::restricted_creation::CreationRestrictionMode; use crate::data_contract::document_type::token_costs::accessors::TokenCostSettersV0; use crate::data_contract::document_type::token_costs::TokenCosts; @@ -148,6 +149,14 @@ pub struct DocumentTypeV2 { /// clock: `$updatedAt`, or `$createdAt` when its documents never change /// (`apply_can_be_deleted_by_moderators_for`). pub(in crate::data_contract) documents_can_be_deleted_by_moderators_for: Option, + /// The `refersTo` declaration whose value is the document's `$ownerId`, + /// the writer (`ownerRefersTo` keyword, protocol version 14), `None` when + /// the type declares none. Checked with the writer's id as the value on + /// every create and every replace, as a property reference is checked + /// with the property's value. Never a `contract` or `identityPublicKey` + /// target, which the parser (`parse_owner_reference`) refuses: the writer + /// is an identity and carries no key id. + pub(in crate::data_contract) owner_reference: Option, } impl DocumentTypeBasicMethods for DocumentTypeV2 {} @@ -236,6 +245,7 @@ impl From for DocumentTypeV2 { action_fees: None, documents_can_be_deleted_by_moderators: false, documents_can_be_deleted_by_moderators_for: None, + owner_reference: None, } } } @@ -284,6 +294,7 @@ impl From for DocumentTypeV2 { action_fees: None, documents_can_be_deleted_by_moderators: false, documents_can_be_deleted_by_moderators_for: None, + owner_reference: None, } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs index d350462403b..c95803fd5f3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs @@ -53,9 +53,11 @@ pub(crate) trait DocumentReferenceValidation { /// property changed, a writer gate applies or its target is deletable. /// /// `owner_id` is the writer, the transition's owner: a `propertyAgreement` - /// whose referring side is `$ownerId` compares it, and an `identityPublicKey` + /// whose referring side is `$ownerId` compares it, an `identityPublicKey` /// reference on a key id property with `identityProperty: $ownerId` names - /// its key, since it lives on the transition rather than in `document_data`. + /// its key, and the document type's `ownerRefersTo` declaration is checked + /// with it as the value, on every create and every replace, since it lives + /// on the transition rather than in `document_data`. /// `creator_id` is the document's creator for the `$creatorId` form: the /// writer on a create, the stored creator on a replace, `None` when the /// document type records none (registration then admits no such form). 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 c3954b5fb8a..b638a71e5a5 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 @@ -226,6 +226,38 @@ fn validate_document_type_references_v0( execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, ) -> Result { + // The writer's own reference (`ownerRefersTo`, protocol version 14): its + // value is the writer, `owner_id`, checked as a property's value is, and + // named `$ownerId` in the errors; in a lookup the writer fills `"."`. It + // is checked on every create and on EVERY replace, touched or not, as a + // writer gate is: the writer is transition metadata that never appears + // among the changed fields, and may not be the one who wrote the + // document before (a transfer or a purchase moves the owner). An + // `identity` target has nothing to fetch: the transition already proved + // the writer exists. + if let Some(owner_reference) = document_type.owner_reference() { + if !matches!(owner_reference, DocumentPropertyReferenceTarget::Identity) { + let result = validate_reference_v0( + contract, + document_type, + document_data, + owner_id, + owner_reference, + owner_id.to_buffer(), + OWNER_ID, + &mut BTreeMap::new(), + platform, + block_info, + transaction, + execution_context, + platform_version, + )?; + if !result.is_valid() { + return Ok(result); + } + } + } + for (path, property) in document_type.flattened_properties() { // A reference is an identifier property's value, or each element of // a typed array of identifiers whose `items` declare it (protocol 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 23e8c230af1..2b5ce24fa7e 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 @@ -13,6 +13,7 @@ mod keep_history; mod lookup_reference; mod nft; mod owner_balance_proof; +mod owner_reference; mod ranked_group_drain; mod reference_test_setup; mod replacement; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs new file mode 100644 index 00000000000..345d991355e --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs @@ -0,0 +1,492 @@ +//! `ownerRefersTo` (protocol version 14) through the full ABCI pipeline: a +//! document type's own `refersTo` declaration, whose value is the writer. The +//! fixture's `addedModerator` is unique on (`electedCharterId`, `memberId`), +//! and a `resignationRequest` may only be written by the `memberId` of an +//! `addedModerator` for its own `electedCharterId`, the moderation charters' +//! rule: the lookup takes the writer for `"."`. `resignationRequest` can be +//! transferred, so its owner can change without a write. `roleResignation` +//! adds a `propertyAgreement` checked against the moderator the lookup finds, +//! and `note` declares an identity target, which every writer meets. +//! +//! A writer the target does not accept is refused, paid, with the error the +//! target reports for a property, `ReferencedEntityNotFoundError` (40120) for +//! a lookup that finds nothing, naming `$ownerId`. + +use super::*; + +mod owner_reference_tests { + use super::*; + use dpp::data_contract::document_type::{DocumentPropertyReferenceTarget, DocumentTypeRef}; + use dpp::document::Document; + use dpp::identifier::Identifier; + use dpp::identity::{Identity, IdentityPublicKey}; + use dpp::prelude::{DataContract, IdentityNonce}; + use dpp::state_transition::StateTransition; + use simple_signer::signer::SimpleSigner; + use std::collections::BTreeMap; + + const CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json"; + + /// The identities of the fixture: the `Founder` seats moderators, the + /// `Member` is seated for charter 1, and the `Stranger` for nothing. + #[derive(Clone, Copy)] + enum Who { + Founder, + Member, + Stranger, + } + + struct Writer { + identity: Identity, + signer: SimpleSigner, + key: IdentityPublicKey, + /// The identity contract nonce the next transition uses; every + /// processed transition consumes one, a refused one included. + next_nonce: IdentityNonce, + } + + impl Writer { + fn next_nonce(&mut self) -> IdentityNonce { + let this_one = self.next_nonce; + self.next_nonce += 1; + this_one + } + } + + struct OwnerReferenceFixture { + platform: TempPlatform, + contract: DataContract, + rng: StdRng, + founder: Writer, + member: Writer, + stranger: Writer, + } + + fn id_value(id: Identifier) -> Value { + Value::Identifier(id.to_buffer()) + } + + fn charter_id(byte: u8) -> Identifier { + Identifier::from([byte; 32]) + } + + impl OwnerReferenceFixture { + 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_nonce: 1, + } + }; + let founder = writer(1958); + let member = writer(1959); + let stranger = writer(1960); + + // Parsed with full validation, so the contract-level lookup checks + // run on the fixture too + let contract = json_document_to_contract(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"); + + Self { + platform, + contract, + rng: StdRng::seed_from_u64(5533), + founder, + member, + stranger, + } + } + + fn id(&self, who: Who) -> Identifier { + match who { + Who::Founder => self.founder.identity.id(), + Who::Member => self.member.identity.id(), + Who::Stranger => self.stranger.identity.id(), + } + } + + /// The document type named `type_name`, `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<'_>, &mut Writer, &mut StdRng) { + let document_type = self + .contract + .document_type_for_name(type_name) + .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, 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 written 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, 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(); + 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, writer, _) = self.parts(who, type_name); + let nonce = writer.next_nonce(); + 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) + } + + /// Transfers `document`, as last accepted, from `from`, its owner, + /// to `to`. + async fn transfer( + &mut self, + from: Who, + to: Who, + type_name: &str, + document: &Document, + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let recipient = self.id(to); + let mut transferred = document.clone(); + transferred + .increment_revision() + .expect("the revision increments"); + let (document_type, writer, _) = self.parts(from, type_name); + let nonce = writer.next_nonce(); + let transition = BatchTransition::new_document_transfer_transition_from_document( + transferred, + document_type, + recipient, + &writer.key, + nonce, + 0, + None, + &writer.signer, + platform_version, + None, + ) + .await + .expect("expected the transfer transition"); + self.process(&transition) + } + + /// Seats `who` as a moderator of the elected charter `charter` in + /// `role`, an `addedModerator` document written by the founder. + async fn seat(&mut self, who: Who, charter: Identifier, role: &str) { + let member_id = self.id(who); + let (_, result) = self + .create( + Who::Founder, + "addedModerator", + &[ + ("electedCharterId", id_value(charter)), + ("memberId", id_value(member_id)), + ("role", role.into()), + ], + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + /// A resignation request by `who` from the elected charter `charter`. + async fn request_resignation( + &mut self, + who: Who, + charter: Identifier, + ) -> (Document, StateTransitionExecutionResult) { + self.create( + who, + "resignationRequest", + &[ + ("electedCharterId", id_value(charter)), + ("reason", "stepping down".into()), + ], + ) + .await + } + } + + /// The refusal of a writer the owner reference's lookup found no + /// moderator for, naming `$ownerId` and the writer. + fn assert_writer_not_found(result: StateTransitionExecutionResult, writer: Identifier) { + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(e)), + .. + } if e.path() == "$ownerId" + && *e.entity_id() == writer + && matches!( + e.entity_type(), + DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } + ), + "expected 40120 at $ownerId" + ); + } + + #[tokio::test] + async fn should_create_a_document_whose_writer_meets_the_owner_reference() { + let mut fixture = OwnerReferenceFixture::new(); + fixture.seat(Who::Member, charter_id(1), "chair").await; + + let (_, result) = fixture + .request_resignation(Who::Member, charter_id(1)) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_refuse_a_writer_who_does_not_meet_the_owner_reference() { + let mut fixture = OwnerReferenceFixture::new(); + fixture.seat(Who::Member, charter_id(1), "chair").await; + let stranger = fixture.id(Who::Stranger); + let member = fixture.id(Who::Member); + + // Nobody seated the stranger + let (_, result) = fixture + .request_resignation(Who::Stranger, charter_id(1)) + .await; + assert_writer_not_found(result, stranger); + + // and the member is seated for charter 1, not charter 2: the key's + // other part is the document's own `electedCharterId` + let (_, result) = fixture + .request_resignation(Who::Member, charter_id(2)) + .await; + assert_writer_not_found(result, member); + } + + #[tokio::test] + async fn should_refuse_a_replace_by_a_writer_who_no_longer_meets_the_owner_reference_even_when_no_field_changed( + ) { + let mut fixture = OwnerReferenceFixture::new(); + fixture.seat(Who::Member, charter_id(1), "chair").await; + let stranger = fixture.id(Who::Stranger); + + let (request, result) = fixture + .request_resignation(Who::Member, charter_id(1)) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + // The member still meets it: a replace changing nothing passes + let result = fixture + .replace(Who::Member, "resignationRequest", &request, |_| {}) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let mut request = request; + request + .increment_revision() + .expect("the revision increments"); + + // A transfer is not checked: the reference governs writing + let result = fixture + .transfer(Who::Member, Who::Stranger, "resignationRequest", &request) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + request + .increment_revision() + .expect("the revision increments"); + request.set_owner_id(stranger); + + // The new owner writes no field, and is still refused: the writer is + // checked on every replace + let result = fixture + .replace(Who::Stranger, "resignationRequest", &request, |_| {}) + .await; + assert_writer_not_found(result, stranger); + } + + #[tokio::test] + async fn should_check_a_property_agreement_of_the_owner_reference_against_the_document_found() { + let mut fixture = OwnerReferenceFixture::new(); + fixture.seat(Who::Member, charter_id(1), "chair").await; + + // `role` must be the seated moderator's own, and the writer its member + let (_, agreeing) = fixture + .create( + Who::Member, + "roleResignation", + &[ + ("electedCharterId", id_value(charter_id(1))), + ("role", "chair".into()), + ], + ) + .await; + assert_matches!( + agreeing, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let (_, disagreeing) = fixture + .create( + Who::Member, + "roleResignation", + &[ + ("electedCharterId", id_value(charter_id(1))), + ("role", "scribe".into()), + ], + ) + .await; + assert_matches!( + disagreeing, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyMismatchError(e) + ), + .. + } if e.path() == "$ownerId" + && e.referring_property() == "role" + && e.referenced_property() == "role" + ); + } + + #[tokio::test] + async fn should_refuse_nothing_extra_for_an_identity_owner_reference() { + let mut fixture = OwnerReferenceFixture::new(); + + // The writer always exists: whoever writes, the note is accepted + for who in [Who::Founder, Who::Member, Who::Stranger] { + let (_, result) = fixture + .create(who, "note", &[("text", "hello".into())]) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + } +} 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 1a7ac58c775..a9e01828e7d 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 @@ -7,7 +7,7 @@ use dpp::data_contract::document_type::{ DocumentReferenceDeclaration, KeyReferenceIdentityProperty, PropertyReference, }; use dpp::data_contract::DataContract; -use dpp::document::property_names::CREATOR_ID; +use dpp::document::property_names::{CREATOR_ID, OWNER_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; @@ -63,9 +63,14 @@ fn same_value_kind(a: &DocumentPropertyType, b: &DocumentPropertyType) -> bool { /// referenced document type. `identityPublicKey` never reaches here on /// elements: the parser refuses it there. /// +/// A document type's `ownerRefersTo` declaration, whose value is the writer, +/// is checked as a single identifier reference's is, first; it is never an +/// `identityPublicKey` or a `contract` one, which the parser refuses. +/// /// The error paths name the failing declaration as -/// `documentTypeName.propertyPath`, and an element declaration by its list -/// path, `documentTypeName.propertyPath[]`. Validation stops at the first invalid +/// `documentTypeName.propertyPath`, an element declaration by its list +/// path, `documentTypeName.propertyPath[]`, and the owner reference as +/// `documentTypeName.$ownerId`. Validation stops at the first invalid /// declaration: this bounds the billed work an invalid contract can cause and /// matches document write-time reference validation. Foreign contract /// resolutions are memoized per contract id, so a contract declaring many @@ -84,14 +89,34 @@ pub(super) fn validate_data_contract_references_v0( BTreeMap::new(); for (declaring_type_name, document_type) in contract.document_types() { - for (path, property) in document_type.as_ref().flattened_properties() { + let declaring = document_type.as_ref(); + // The writer's reference first (`ownerRefersTo`, whose value is the + // document's `$ownerId` and which is named by that path), then the + // properties' own. The owner reference is never a key reference: the + // parser refuses `identityPublicKey` there, and `contract` too + let references = declaring + .owner_reference() + .map(|target| (OWNER_ID, true, PropertyReference::Value(target))) + .into_iter() + .chain( + declaring + .flattened_properties() + .iter() + .filter_map(|(path, property)| { + property + .property_type + .reference() + .map(|reference| (path.as_str(), false, reference)) + }), + ); + for (path, is_owner_reference, reference) in references { let declaration_path = format!("{declaring_type_name}.{path}"); - let (reference_target, declaration_path) = match property.property_type.reference() { + let (reference_target, declaration_path) = match reference { // A key reference on the key id property: what `identityProperty` // names must fit the document type; nothing else about the // declaration is state-dependent - Some(PropertyReference::KeyId(reference)) => { + PropertyReference::KeyId(reference) => { let invalid = |message: &str| { SimpleConsensusValidationResult::new_with_error( ReferencedKeyIdPropertyInvalidError::new( @@ -166,14 +191,13 @@ pub(super) fn validate_data_contract_references_v0( } continue; } - Some(PropertyReference::Value(target)) => (target, declaration_path), + PropertyReference::Value(target) => (target, declaration_path), // A typed array only parses from protocol version 14, whose // contract create and update state validation are the only // callers, so this arm is never reached before it - Some(PropertyReference::Elements { target, .. }) => { + PropertyReference::Elements { target, .. } => { (target, format!("{declaring_type_name}.{path}[]")) } - None => continue, }; // The key id property must exist in the same document type and be @@ -382,7 +406,10 @@ pub(super) fn validate_data_contract_references_v0( .into(), ) }; - if referring_property == path { + // The writer's own reference may name the writer on the + // referring side: `$ownerId` there is the same identity as its + // value, which a pair can bind to a referenced property + if !is_owner_reference && referring_property == path { return Ok(invalid( "the referring property cannot be the reference property itself", )); 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 d9dc53b4d1d..a49b85aace0 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 @@ -5702,6 +5702,41 @@ mod tests { ); } + #[tokio::test] + async fn should_register_contract_with_owner_references() { + // `ownerRefersTo` on three types: a lookup into a permanent type of + // the same contract, the same with a propertyAgreement whose + // referring side is the writer (`$ownerId`, the reference's own + // value), and an identity target + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_reject_an_owner_reference_to_an_unknown_document_type_at_its_owner_path() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-unknown-type.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentTypeNotFoundError(e) + ), + .. + } if e.path() == "note.$ownerId" && e.document_type_name() == "ghost" + ); + } + #[tokio::test] async fn should_register_contract_with_deletable_document_references() { // A deletableDocument reference targets a document type that diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-unknown-type.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-unknown-type.json new file mode 100644 index 00000000000..abb6cf4fadc --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-unknown-type.json @@ -0,0 +1,24 @@ +{ + "$formatVersion": "1", + "id": "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "note": { + "type": "object", + "ownerRefersTo": { + "type": "permanentDocument", + "documentType": "ghost" + }, + "properties": { + "content": { + "type": "string", + "position": 0, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json new file mode 100644 index 00000000000..ff1fa3e1657 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json @@ -0,0 +1,146 @@ +{ + "$formatVersion": "1", + "id": "95zHGjLfHmQ2TMYgcKtVUWjMa4NPcnNZ16pk68C1xk5g", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "addedModerator": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "memberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1 + }, + "role": { + "type": "string", + "position": 2, + "maxLength": 63 + } + }, + "indices": [ + { + "name": "byElectedCharterMember", + "properties": [ + { + "electedCharterId": "asc" + }, + { + "memberId": "asc" + } + ], + "unique": true + } + ], + "required": [ + "electedCharterId", + "memberId", + "role" + ], + "additionalProperties": false + }, + "resignationRequest": { + "type": "object", + "transferable": 1, + "ownerRefersTo": { + "type": "permanentDocument", + "documentType": "addedModerator", + "lookup": { + "index": "byElectedCharterMember", + "keys": { + "electedCharterId": "electedCharterId", + "memberId": "." + } + } + }, + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "reason": { + "type": "string", + "position": 1, + "maxLength": 63 + } + }, + "required": [ + "electedCharterId", + "reason" + ], + "additionalProperties": false + }, + "roleResignation": { + "type": "object", + "ownerRefersTo": { + "type": "permanentDocument", + "documentType": "addedModerator", + "propertyAgreement": { + "role": "role", + "$ownerId": "memberId" + }, + "lookup": { + "index": "byElectedCharterMember", + "keys": { + "electedCharterId": "electedCharterId", + "memberId": "." + } + } + }, + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "role": { + "type": "string", + "position": 1, + "maxLength": 63 + } + }, + "required": [ + "electedCharterId", + "role" + ], + "additionalProperties": false + }, + "note": { + "type": "object", + "ownerRefersTo": { + "type": "identity" + }, + "properties": { + "text": { + "type": "string", + "position": 0, + "maxLength": 63 + } + }, + "required": [ + "text" + ], + "additionalProperties": false + } + } +} diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 3bd698ed411..40476f433f7 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -20,8 +20,9 @@ pub struct SystemLimits { pub max_typed_array_items: u16, /// Maximum number of references one document of a document type may carry, counted at /// contract registration or update from the type's `refersTo` declarations: one for each - /// property that declares one (an identifier, or a key id carrying a key reference), and - /// `maxItems` for each typed array whose identifier elements declare one. Every reference is checked against state when the + /// property that declares one (an identifier, or a key id carrying a key reference), one + /// for the type's `ownerRefersTo`, and `maxItems` for each typed array whose identifier + /// elements declare one. Every reference is checked against state when the /// document is created or replaced, each check a billed read, so this bounds the reads one /// document write can cause; without it a type could declare many typed arrays of /// `max_typed_array_items` references each. Refused under full validation only, like diff --git a/packages/rs-platform-version/src/version/system_limits/v4.rs b/packages/rs-platform-version/src/version/system_limits/v4.rs index d9269889647..4e637dc352d 100644 --- a/packages/rs-platform-version/src/version/system_limits/v4.rs +++ b/packages/rs-platform-version/src/version/system_limits/v4.rs @@ -54,8 +54,9 @@ use crate::version::system_limits::SystemLimits; /// `maxItems`, at most 1024 elements (`max_typed_array_items`, backfilled into the /// earlier tables, whose parsers never read it). /// * References (protocol version 14): one document of a document type carries at most 256 -/// references, counted at registration as one per property with `refersTo` and -/// `maxItems` per typed array whose elements declare one (`max_references_per_document`, +/// references, counted at registration as one per property with `refersTo`, one for the +/// type's `ownerRefersTo` and `maxItems` per typed array whose elements declare one +/// (`max_references_per_document`, /// backfilled into the earlier tables, whose parsers never read it). Each reference is a /// billed state read when the document is written. pub const SYSTEM_LIMITS_V4: SystemLimits = SystemLimits { diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 8f9acd35690..2d649be0dac 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -759,6 +759,41 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// by-id joins refuse a lookup reference as a join property, and /// preallocated indexes are never bound through one. /// +/// 33. **References on the document's writer (`ownerRefersTo`)**: a document +/// type may declare one `refersTo` declaration of its own, under the +/// doctype-level `ownerRefersTo` keyword (meta-schema v3, which reuses the +/// property declaration by `$ref` and refuses `contract` and +/// `identityPublicKey`), whose value is the document's `$ownerId`, the +/// writer, instead of a property's. Parser generation 3 reads it on every +/// parse through the same `apply_property_reference` 0 an identifier +/// property's goes through, onto `DocumentTypeV2::owner_reference`, so it +/// takes every target an identifier property takes but those two (the +/// writer is an identity, never a contract, and carries no key id), +/// `propertyAgreement` and `lookup` included: in a lookup `.` is the +/// writer, and a `$ownerId` key part, the writer too, is admitted on a +/// transferable or tradeable type, since the declaration governs writing +/// rather than holding. Its lookup's referring side is checked on every +/// parse, a lookup into a type of the same contract by +/// `create_document_types_from_document_schemas` 1 (edited in place like +/// for item 29, inert before this version, whose parsers never set an +/// owner reference), and the whole declaration at registration by the +/// contract reference validation (`data_contract_reference_validation` 0, +/// extended in place, only reached from this version), which names it +/// `.$ownerId` and lets its `propertyAgreement` name the +/// writer on the referring side. It counts one against +/// `max_references_per_document`. Document create state validation 2 and +/// replace state validation 1 (`document_reference_validation` 0, extended +/// in place, both only reached from this version) check the writer against +/// the target exactly as a property's value is checked, on every create and +/// on every replace whatever it changes (the writer is transition metadata +/// that never appears among the changed fields), never on a transfer or a +/// purchase, and refuse the write with the error the target reports for a +/// property (40120 and the rest) at the path `$ownerId`; an `identity` +/// target fetches nothing, the transition having proved the writer exists. +/// Adding, removing or changing it is an incompatible schema change on +/// update (`validate_schema_compatibility` 1 freezes it as the shared rule +/// set freezes `refersTo`). +/// /// 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-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index 685ff077807..db9bce69f87 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -13,11 +13,12 @@ use crate::error::{WasmDppError, WasmDppResult}; use crate::identifier::IdentifierWasm; -use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::{ DocumentPropertyReferenceTarget, DocumentTypeRef, IdentityKeyReferenceRequirements, KeyIdReference, PropertyReference, }; +use dpp::document::property_names::OWNER_ID; use dpp::prelude::Identifier; use js_sys::{Array, Object, Reflect}; use wasm_bindgen::JsValue; @@ -225,6 +226,12 @@ export type DocumentPropertyReference = { * example `"reasons[]"`; its `type` is never `identityPublicKey`, which * an element cannot declare. * + * The document type's `ownerRefersTo` declaration, whose value is the + * document's `$ownerId`, the writer, is listed first with the path + * `"$ownerId"`. Consensus checks it with the writer's id on every create + * and every replace; in its `lookup`, `'.'` is the writer. Its `type` is + * never `contract` or `identityPublicKey`, which it cannot declare. + * * This is the same string consensus reports in the `path` field of the * document-write reference errors (codes 40120-40125, 40131, 40135 and * 40136), except that a write error names the failing element by its @@ -489,9 +496,11 @@ fn set_reference_target_fields( Ok(()) } -/// Collect every reference declaration of one document type, in schema -/// property order: an identifier property's own, and the one the elements -/// of a typed array of identifiers carry, listed at `path[]`. +/// Collect every reference declaration of one document type: its +/// `ownerRefersTo` first, listed at `$ownerId`, the path consensus names it +/// by, then in schema property order an identifier property's own, and the +/// one the elements of a typed array of identifiers carry, listed at +/// `path[]`. /// /// Walks `flattened_properties` rather than `properties` because that is /// what both consensus validators walk, and because their error `path` is @@ -503,6 +512,10 @@ pub(crate) fn references_for_document_type( ) -> WasmDppResult { let references = Array::new(); + if let Some(target) = document_type.owner_reference() { + references.push(&reference_to_js(OWNER_ID, target, declaring_contract_id)?); + } + for (path, property) in document_type.flattened_properties() { match property.property_type.reference() { Some(PropertyReference::KeyId(reference)) => { diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index b84fbeb5938..d5c7a77252f 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -698,8 +698,9 @@ impl DataContractWasm { Ok(DataContract::generate_data_contract_id_v0(owner_id.to_buffer(), identity_nonce).into()) } - /// All `refersTo` declarations of one document type, in schema property - /// order. + /// All `refersTo` declarations of one document type: its `ownerRefersTo` + /// first, at the path `$ownerId`, then the properties' own in schema + /// property order. /// /// Returns an empty array when the document type declares none. Throws /// when the contract has no document type by that name — an empty array diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index 2ca3c2a75c8..b254d166928 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -452,6 +452,103 @@ describe('DataContract — refersTo declarations (v14)', () => { }); }); + describe('ownerRefersTo', () => { + /** + * A `resignation` may only be written by the owner of a join request for + * its own `submittedCharterId`: the document type's own reference, whose + * value is the writer rather than a property's value. + */ + const ownerSchemas = { + joinRequest: lookupSchemas.joinRequest, + resignation: { + type: 'object', + ownerRefersTo: { + type: 'permanentDocument', + documentType: 'joinRequest', + lookup: { + index: 'bySubmittedCharter', + keys: { submittedCharterId: 'submittedCharterId', $ownerId: '.' }, + }, + }, + properties: { + submittedCharterId: plainIdentifier, + author: identifierProperty(1, { type: 'identity' }), + }, + required: ['submittedCharterId'], + additionalProperties: false, + }, + }; + + function buildOwnerContract( + resignation: object, + platformVersion = 14, + fullValidation = true, + ) { + return new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: { joinRequest: ownerSchemas.joinRequest, resignation }, + definitions: null, + fullValidation, + platformVersion: new PlatformVersion(platformVersion), + }); + } + + it('should list the owner reference first, at the path $ownerId', () => { + const contract = buildOwnerContract(ownerSchemas.resignation); + const references = contract.documentTypeReferences('resignation') as Reference[]; + + expect(references.map((reference) => reference.path)).to.deep.equal([ + '$ownerId', + 'author', + ]); + const [writer] = references; + expect(writer.type).to.equal('permanentDocument'); + expect(writer.documentType).to.equal('joinRequest'); + expect(writer.contractId!.toBase58()).to.equal(contract.id.toBase58()); + expect(writer.lookup).to.deep.equal({ + index: 'bySubmittedCharter', + keys: { $ownerId: '.', submittedCharterId: 'submittedCharterId' }, + }); + expect( + (contract.documentReferences as Map).get('resignation')! + .map((reference) => reference.path), + ).to.deep.equal(['$ownerId', 'author']); + }); + + it('should report a document type declaring only an owner reference', () => { + const onlyOwner = structuredClone(ownerSchemas.resignation) as { + ownerRefersTo: object; + properties: Record; + }; + delete onlyOwner.properties.author; + onlyOwner.ownerRefersTo = { type: 'identity' }; + const contract = buildOwnerContract(onlyOwner); + + expect(contract.documentTypeReferences('resignation')).to.deep.equal([ + { path: '$ownerId', type: 'identity' }, + ]); + }); + + it('should refuse a contract or identityPublicKey owner reference', () => { + for (const ownerRefersTo of [ + { type: 'contract' }, + { type: 'identityPublicKey', identityProperty: '$ownerId' }, + ]) { + const refused = { ...ownerSchemas.resignation, ownerRefersTo }; + expect(() => buildOwnerContract(refused)).to.throw(); + // The stored path refuses it too, where no meta-schema runs + expect(() => buildOwnerContract(refused, 14, false)).to.throw(/ownerRefersTo does not take/); + } + }); + + it('should report no owner reference on a pre-v14 contract', () => { + const contract = buildOwnerContract(ownerSchemas.resignation, 13, false); + + expect(contract.documentTypeReferences('resignation')).to.deep.equal([]); + }); + }); + describe('documentReferences', () => { it('should key declarations by document type and omit types with none', () => { const contract = buildContract(14); From fddf6ecaa842397fb0ba0d841852e9b1a7b2d915 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 19:59:28 +0700 Subject: [PATCH 2/4] fix(platform)!: ownerRefersTo review fixes: writer-only targets, no transfers, one enumeration - only identity or a permanentDocument lookup: contract, token, a document by id (never the writer's identity id) and identityPublicKey are refused, by meta-schema v3 and on the stored path - refused on a document type whose documents can be transferred or traded, so the writer stays the owner; owner lookups then use the property lookup rules unchanged (the $ownerId exemption is gone) - parsed from the stored schema after the core parse, so the meta-schema reports a malformed declaration first; the gate is checked first - DocumentTypeRef::reference_declarations (with ReferenceHolder) is the one enumeration of a type's references: reference count, lookup checks, both drive-abci validators and wasm-dpp2 - a replace re-validates the owner reference under its target's rules (a bound property changed, or a $ownerId agreement pair), not on every replace - the ownerRefersTo compatibility rule is looked up without expect - inert-before-14 comments on the in-place loops; js-evo-sdk README notes the $ownerId path; tests for no reads and the registration error paths Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/documents.md | 9 +- packages/js-evo-sdk/README.md | 9 + .../document/v3/document-meta.json | 29 ++- .../document_type/accessors/v2/mod.rs | 7 +- .../v1/mod.rs | 35 +--- .../class_methods/try_from_schema/mod.rs | 132 ++++++------ .../class_methods/try_from_schema/v3/mod.rs | 30 ++- .../v3/owner_reference_tests.rs | 131 ++++++------ .../document_type/property/mod.rs | 61 +++++- .../property/reference_lookup.rs | 31 +-- .../validate_schema_compatibility/v1/mod.rs | 17 +- .../src/data_contract/document_type/v2/mod.rs | 10 +- .../document_reference_validation/mod.rs | 4 +- .../document_reference_validation/v0/mod.rs | 90 ++++----- .../batch/tests/document/owner_reference.rs | 189 +++++++++++++----- .../v0/mod.rs | 33 +-- .../data_contract_create/mod.rs | 60 ++++++ ...ers-to-registration-agreement-invalid.json | 63 ++++++ ...fers-to-registration-deletable-target.json | 59 ++++++ ...o-registration-foreign-lookup-invalid.json | 31 +++ ...r-refers-to-registration-unknown-type.json | 8 +- ...e-validation-contract-owner-refers-to.json | 1 - .../rs-platform-version/src/version/v14.rs | 35 ++-- .../data_contract/document_type_reference.rs | 28 ++- .../unit/DocumentPropertyReference.spec.ts | 3 +- 25 files changed, 738 insertions(+), 367 deletions(-) create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-agreement-invalid.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-deletable-target.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-foreign-lookup-invalid.json diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index f73b9fb80fc..dc5ae3f8766 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -356,13 +356,14 @@ A property's reference constrains a value the writer chose. Some rules constrain reads: the writer must be the `memberId` of an `addedModerator` for this document's `electedCharterId`. -- The declaration is the one an identifier property carries, read by the same code (`apply_property_reference` 0), with every target and key it takes except two: `contract`, since the writer is an identity and never a contract, and `identityPublicKey`, which pairs the value with a key id the writer does not carry. Meta-schema v3 reuses the property declaration by `$ref` and refuses those two types; the parser (generation 3, `parse_owner_reference`) refuses them on the stored path too. The parsed declaration is `DocumentTypeV2::owner_reference`, read through `DocumentTypeV2Getters::owner_reference`, and the property types are unchanged. -- In a `lookup`, `"."` is the writer, and a `"$ownerId"` key part is the writer as well. The rule that a key part reading `"$ownerId"` needs a type whose documents can be neither transferred nor traded does not apply here: the declaration is checked on every write, the writer included, and governs writing rather than holding, exactly as a `propertyAgreement` pair keyed by `$ownerId`. The other referring-side rules hold unchanged (`DocumentReferenceLookup::owner_reference_referring_side_error`), and so does every referenced-side rule, for a type of the same contract at contract level and for one of another contract at registration. +- The declaration is the one an identifier property carries, read by the same code (`apply_property_reference` 0), but only two targets can hold a writer: `identity`, and a `permanentDocument` found through a `lookup`. The rest are refused. `contract`, `token` and a document by id would need the writer's identity id to be a contract, token or document id, which it never is, so a document type declaring one could never be written; `identityPublicKey` pairs the value with a key id the writer does not carry. Meta-schema v3 reuses the property declaration by `$ref` and admits only those two forms; the parser (generation 3, `parse_owner_reference`, which reads the stored schema once the core parse has run the meta-schema) refuses the others on the stored path too. The parsed declaration is `DocumentTypeV2::owner_reference`, read through `DocumentTypeV2Getters::owner_reference`, and the property types are unchanged. +- Only a document type whose documents can be neither transferred nor traded may declare it, checked on every parse. A transfer or a purchase is not a write, so it would hand the document to an owner the declaration never checked; with neither possible, the owner of every document is the writer that was checked. +- In a `lookup`, `"."` is the writer, and a `"$ownerId"` key part is the writer as well. Every referring-side rule of a property's lookup applies unchanged (its `"$ownerId"` rule holds by the point above), and so does every referenced-side rule, for a type of the same contract at contract level and for one of another contract at registration. - A `propertyAgreement` works as on a property reference; its referring side may name `$ownerId`, which is then the same writer as the reference's value. - It counts one against `SystemLimits::max_references_per_document`. -- When a document is created, and on every replace whatever the replace changes, the document reference validation checks the writer against the target exactly as a property's value is checked, before the properties' references. The writer is transition metadata that never appears among the changed fields and may not be the one who wrote the document before, so an unchanged replace is checked too. A transfer or a purchase is not checked: on a transferable type the new owner holds the document, and the first replace they write is refused if they do not meet the target. A failure is the error the target reports for a property (`ReferencedEntityNotFoundError`, 40120, for a lookup that finds no document; `ReferencedDocumentPropertyMismatchError`, 40127, for an agreement; and the rest), with `$ownerId` as its path. An `identity` target reads nothing: the transition has already proved that the writer exists. At registration the contract reference validation checks the declaration as a property's, naming it `.$ownerId`. -- `permanentDocument` and `deletableDocument` references by id, and `token`, are admitted as for a property, but their value would have to be the writer's identity id, which no document id or token id equals, so a document type declaring one could never be written. +- When a document is created, the document reference validation checks the writer against the target exactly as a property's value is checked, before the properties' references. A replace re-validates it under the rules of its target, as a property's: when a property its lookup or a `propertyAgreement` reads changed, and on every replace for a pair keyed by `$ownerId`, a writer gate. Nothing else can change the outcome: the writer is the owner, the target can never be deleted and its key is fixed. A failure is the error the target reports for a property (`ReferencedEntityNotFoundError`, 40120, for a lookup that finds no document; `ReferencedDocumentPropertyMismatchError`, 40127, for an agreement; and the rest), with `$ownerId` as its path. An `identity` target reads nothing: the transition has already proved that the writer exists. At registration the contract reference validation checks the declaration as a property's, naming it `.$ownerId`. - Adding, removing or changing it is an incompatible schema change on update (`validate_schema_compatibility` 1 freezes the keyword as the shared rule set freezes `refersTo`). +- Every validator, the reference bound and the client bindings enumerate a type's references through `DocumentTypeRef::reference_declarations`, which yields the owner reference first, as `ReferenceHolder::Owner`, then each property's, so none can skip it. ## Immutable Properties on Mutable Document Types diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 4d9d3a4040c..12e1c0aa96f 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -209,6 +209,15 @@ contract.documentReferences; A typed array of identifiers may declare `refersTo` on its `items`, which every element then carries. Such a declaration is listed at the list path of its elements, `path: 'reasons[]'`, which is not a property path: read the list at `reasons` and treat each element as a reference. The same declaration is on the typed array's item, `contract.documentTypeTypedArrays('charter')[0].items.refersTo`. Consensus checks every element when the document is written, and a rejection names the failing element by its index, as in `reasons[2]` for the third. +A document type may also declare `ownerRefersTo`, a reference whose value is the document's owner, the writer, instead of a property's value. It is listed first, at `path: '$ownerId'`, which is not a property path: the value it constrains is the document's `ownerId`. Its `type` is `identity` or a `permanentDocument` with a `lookup`, in which `'.'` is the writer: + +```ts +// { path: '$ownerId', type: 'permanentDocument', contractId, documentType: 'addedModerator', +// lookup: { index: 'byElectedCharterMember', keys: { electedCharterId: 'electedCharterId', memberId: '.' } } } +``` + +reads: the writer must be the `memberId` of an `addedModerator` for the document's `electedCharterId`. Consensus checks it when a document is created and when a replace changes a property the lookup or a `propertyAgreement` reads, and a rejection names it `$ownerId`. Only a document type whose documents can be neither transferred nor traded may declare it, so the owner is always the writer that was checked. + A document reference comes in two strengths. `permanentDocument` requires the referenced document type to declare `canBeDeleted: false`, so a reference that was accepted keeps resolving. `deletableDocument` takes the same declaration (`contractId`, `documentType`, `propertyAgreement`) and is its disjoint counterpart: the referenced type must allow deletion (`ReferencedDocumentTypeNotDeletable`, 40131, otherwise). The referenced document must exist, and the agreement must hold, when the referring document is written, but it may be deleted afterwards. Nothing blocks that deletion and nothing cleans up after it, so a reader must expect such a reference to resolve to nothing. It can never start resolving to different content: a document id commits to the nonce of its create transition, so a deleted id can not be created again. A writer may not leave it that way: every replace of the referring document re-validates the reference, touched or not, so once the target is gone the replace has to repoint it at a document that exists or clear it (`ReferencedEntityNotFound` otherwise). A writer gate is then checked against the new target, never against a missing one. On an `immutable` property clearing is the only move, and the immutable check lets that one change through. The referring document can always be deleted. A property cannot switch between the two on a contract update, and `preallocated` indexes are only available through `permanentDocument`. Declarations are only parsed from protocol version 14 onward; a contract deserialized against an earlier version reports none even when its raw schema carries the keyword. 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 63a8e5f72f3..d3b5b9c006f 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property) and its ownerRefersTo form on the document type, whose value is the writer, the requiredSince property keyword (the contract version a property is required from), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property) and its ownerRefersTo form on the document type, whose value is the writer (an identity or a permanentDocument lookup), the requiredSince property keyword (the contract version a property is required from), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "documentProperties": { @@ -1689,17 +1689,30 @@ "description": "The subset of the immutable properties a replace may still set while the stored document has no value for them: a first-time set is accepted, after which the property is frozen like the rest of the immutable list (it can neither change nor be removed). Every entry must also appear in immutable; it is only meaningful for optional properties, since a required one always has a value from creation. On contract update an entry may be dropped (tightening) at any time, but may only be added for a property that becomes immutable in the same update: an already-immutable property cannot start allowing a set. Available from protocol version 14." }, "ownerRefersTo": { - "description": "A refersTo declaration whose value is the document's $ownerId, the writer, instead of a property's value: the declaration of an identifier property, with the same keys and the same checks, except that contract (the writer is an identity, never a contract) and identityPublicKey (it pairs the value with a key id, which the writer does not carry) are refused. In a lookup, \".\" is the writer, and \"$ownerId\" names the writer as well. A propertyAgreement's referring side is still a property of the document or its $ownerId, the same writer. When a document is created, and on every replace whatever it changes (the writer is not part of the document data, and may not be the one who wrote it before), consensus checks the writer against the target exactly as a property's value is checked, and refuses the write with the error that target reports for a property (ReferencedEntityNotFoundError, 40120, and the rest), naming $ownerId as the path. A transfer or a purchase is not checked: the declaration governs writing, not holding. It counts as one reference against the references a document may carry. Fixed when the document type is created: adding, removing or changing it is an incompatible schema change on update. Available from protocol version 14.", + "description": "A refersTo declaration whose value is the document's $ownerId, the writer, instead of a property's value: the declaration of an identifier property, with the same keys and the same checks, for the two targets a writer can be: identity, or a permanentDocument found through a lookup, where \".\" is the writer and \"$ownerId\" names the writer as well. contract, token and a document by id are refused, since the writer's identity id is never one of those ids, and identityPublicKey pairs the value with a key id, which the writer does not carry. A propertyAgreement's referring side is still a property of the document or its $ownerId, the same writer. Only on a document type whose documents can be neither transferred nor traded, so the writer stays the owner. When a document is created, and when a replace changes a property the lookup or a propertyAgreement reads (every replace for a $ownerId agreement pair), consensus checks the writer against the target exactly as a property's value is checked, and refuses the write with the error that target reports for a property (ReferencedEntityNotFoundError, 40120, and the rest), naming $ownerId as the path. It counts as one reference against the references a document may carry. Fixed when the document type is created: adding, removing or changing it is an incompatible schema change on update. Available from protocol version 14.", "$ref": "#/$defs/documentSchema/properties/refersTo", "properties": { "type": { - "not": { - "enum": [ - "contract", - "identityPublicKey" - ] - } + "enum": [ + "identity", + "permanentDocument" + ] } + }, + "if": { + "properties": { + "type": { + "const": "permanentDocument" + } + }, + "required": [ + "type" + ] + }, + "then": { + "required": [ + "lookup" + ] } }, "additionalProperties": { diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs index 6945f21be81..51cfc04cb4c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs @@ -80,9 +80,10 @@ pub trait DocumentTypeV2Getters { /// The `refersTo` declaration whose value is the document's `$ownerId`, the /// writer (the `ownerRefersTo` keyword, protocol version 14): consensus - /// checks it with the writer's id as the value when a document is created - /// and on every replace. `None` on document types that declare none and on - /// those that predate the keyword. + /// checks it with the writer's id as the value when a document is created, + /// and on a replace under the rules of its target. `None` on document types + /// that declare none and on those that predate the keyword. Enumerated with + /// the property references by `DocumentTypeRef::reference_declarations`. fn owner_reference(&self) -> Option<&DocumentPropertyReferenceTarget>; } 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 ac3480973f6..e67e26ecf75 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 @@ -161,27 +161,17 @@ impl DocumentType { // // 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, and only - // parser generation 3, selected from protocol version 14, reads `ownerRefersTo`. + // `apply_property_reference: Some(_)`, so the loop below finds none there. Only + // parser generation 3, selected from protocol version 14, sets an owner reference, + // so before it `reference_declarations` yields the properties' references alone, in + // the order the loop walked them, and names them as it did. for (name, document_type) in &contract_document_types { let declaring = document_type.as_ref(); - // On the writer (`None`), on an identifier property or on the elements of a - // typed array - let declarations = - declaring - .owner_reference() - .map(|target| (None, target)) - .into_iter() - .chain(declaring.flattened_properties().iter().filter_map( - |(path, property)| { - property - .property_type - .reference() - .and_then(|reference| reference.target()) - .map(|target| (Some(path), target)) - }, - )); - for (path, target) in declarations { + // On the writer, on an identifier property or on the elements of a typed array + for (holder, reference) in declaring.reference_declarations() { + let Some(target) = reference.target() else { + continue; + }; let Some(DocumentReferenceDeclaration { contract_id, document_type_name, @@ -210,13 +200,10 @@ impl DocumentType { continue; } if let Some(reason) = lookup.referenced_side_error(declaring, referenced) { - let declared_on = match path { - Some(path) => format!("property \"{path}\" refersTo"), - None => "ownerRefersTo".to_string(), - }; return Err(consensus_or_protocol_data_contract_error( DataContractError::InvalidContractStructure(format!( - "document type \"{name}\" {declared_on} lookup: {reason}" + "document type \"{name}\" {} lookup: {reason}", + holder.describe() )), )); } 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 21d347bc8ce..22ddec75083 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,7 +1,4 @@ use crate::data_contract::config::DataContractConfig; -use crate::data_contract::document_type::accessors::{ - DocumentTypeV0Getters, DocumentTypeV2Getters, -}; use crate::data_contract::document_type::class_methods::apply_required_since::apply_required_since; use crate::data_contract::document_type::class_methods::parse_typed_array::parse_typed_array; use crate::data_contract::document_type::reference_lookup::{ @@ -928,21 +925,33 @@ fn apply_element_reference_v0( /// Reads a document type's `ownerRefersTo` keyword: one `refersTo` declaration /// whose value is the document's `$ownerId`, the writer, instead of a /// property's value. The declaration goes through [`apply_property_reference`] -/// as one declared on an identifier property does, so it takes every target -/// and key that declaration takes, `propertyAgreement` and `lookup` included -/// (where `"."` is the writer), except two refused up front: `contract`, since -/// the writer is an identity and never a contract, and `identityPublicKey`, -/// which pairs the value with a key id the writer does not carry, in either -/// form. +/// as one declared on an identifier property does, `propertyAgreement` and +/// `lookup` included (where `"."` is the writer), but only two targets can +/// hold a writer: `identity`, and a `permanentDocument` found through a +/// `lookup`. The others are refused: `contract`, `token` and a document by id, +/// since the writer's identity id is never a contract, token or document id, +/// so a type declaring one could never be written, and `identityPublicKey`, +/// which pairs the value with a key id the writer does not carry. /// -/// Only parser generation 3 calls it, on every parse, validating or not, like -/// its other doctype-level keywords. Versioned through -/// `apply_property_reference`, the gate of the declaration it reads: `None` -/// leaves the keyword unread, as it leaves `refersTo` on a property. +/// Only parser generation 3 calls it, once the core parse has run the +/// meta-schema, on every parse, validating or not, like its other +/// doctype-level keywords. Versioned through `apply_property_reference`, the +/// gate of the declaration it reads: `None` leaves the keyword unread, as it +/// leaves `refersTo` on a property, refusals included. pub(super) fn parse_owner_reference( schema: &Value, platform_version: &PlatformVersion, ) -> Result, DataContractError> { + if platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .apply_property_reference + .is_none() + { + return Ok(None); + } // A schema that is not an object carries no keyword: the core parser // refuses it, and a value error here must not replace that refusal let Ok(schema_map) = schema.to_map() else { @@ -957,22 +966,30 @@ pub(super) fn parse_owner_reference( .get(property_names::TYPE) .and_then(|reference_type| reference_type.as_text()) .map(str::to_string); - match reference_type.as_deref() { - Some("contract") => { - return Err(DataContractError::InvalidContractStructure( - "ownerRefersTo does not take a contract reference: its value is the writer, an \ - identity, which is never a contract" - .to_string(), - )) - } - Some("identityPublicKey") => { - return Err(DataContractError::InvalidContractStructure( - "ownerRefersTo does not take an identityPublicKey reference: it pairs the value \ - with a key id, which the writer does not carry" - .to_string(), - )) - } - _ => {} + let refusal = match reference_type.as_deref() { + Some("contract") => Some( + "ownerRefersTo does not take a contract reference: its value is the writer, an \ + identity, which is never a contract", + ), + Some("identityPublicKey") => Some( + "ownerRefersTo does not take an identityPublicKey reference: it pairs the value with \ + a key id, which the writer does not carry", + ), + Some("token") => Some( + "ownerRefersTo does not take a token reference: its value, the writer's identity id, \ + is never a token id", + ), + Some("deletableDocument") => Some( + "ownerRefersTo does not take a deletableDocument reference: its value, the writer's \ + identity id, is never a document id, and only a permanentDocument reference takes \ + the lookup that could find one", + ), + _ => None, + }; + if let Some(refusal) = refusal { + return Err(DataContractError::InvalidContractStructure( + refusal.to_string(), + )); } let inner_properties = BTreeMap::from([(property_names::REFERS_TO.to_string(), declaration)]); @@ -981,11 +998,21 @@ pub(super) fn parse_owner_reference( DocumentPropertyType::Identifier, platform_version, )? { - DocumentPropertyType::IdentifierWithReference(target) => Ok(Some(target)), - // The declaration is not read where the keyword is not active - DocumentPropertyType::Identifier => Ok(None), + DocumentPropertyType::IdentifierWithReference( + target @ (DocumentPropertyReferenceTarget::Identity + | DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. }), + ) => Ok(Some(target)), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocument { .. }, + ) => Err(DataContractError::InvalidContractStructure( + "ownerRefersTo takes a permanentDocument reference only with a lookup: its value, the \ + writer's identity id, is never a document id" + .to_string(), + )), _ => Err(DataContractError::InvalidContractStructure( - "ownerRefersTo must declare what the writer refers to".to_string(), + "ownerRefersTo takes an identity reference or a permanentDocument reference with a \ + lookup" + .to_string(), )), } } @@ -1082,9 +1109,10 @@ fn parse_document_reference_lookup( /// 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`], and -/// [`DocumentReferenceLookup::owner_reference_referring_side_error`] for the -/// lookup of the type's `ownerRefersTo`. +/// [`DocumentReferenceLookup::referring_side_error`]. The lookup of the type's +/// `ownerRefersTo` follows the same rules: its `"$ownerId"` sources pass the +/// owner rule, since the type cannot change owner (generation 3 refuses the +/// keyword otherwise). /// /// 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 @@ -1094,34 +1122,22 @@ 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()) + // On the writer, on an identifier property or on the elements of a typed + // array: the key's other parts are the same for every element, and the + // owner reference's `"."` is the writer, named by the path `$ownerId`, + // which no property source can be + for (holder, reference) in document_type.reference_declarations() { + let Some(lookup) = 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}" - ))); - } - } - // The writer's own reference, whose `"."` is the writer - if let Some(lookup) = document_type - .owner_reference() - .and_then(|target| target.as_any_document_reference()) - .and_then(|declaration| declaration.lookup) - { - if let Some(reason) = lookup.owner_reference_referring_side_error(document_type) { + if let Some(reason) = lookup.referring_side_error(document_type, holder.path()) { return Err(DataContractError::InvalidContractStructure(format!( - "document type \"{document_type_name}\" ownerRefersTo lookup: {reason}" + "document type \"{document_type_name}\" {} lookup: {reason}", + holder.describe() ))); } } 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 9182f6e7329..666ab1daf98 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,6 +31,7 @@ use crate::data_contract::document_type::property::{ DocumentPropertyReferenceTarget, PropertyReference, }; use crate::data_contract::document_type::property_names; +use crate::data_contract::document_type::reference_lookup::owner_can_change; use crate::data_contract::document_type::v2::DocumentTypeV2; use crate::data_contract::document_type::{DocumentType, DocumentTypeRef}; use crate::data_contract::errors::DataContractError; @@ -331,8 +332,6 @@ fn try_from_schema_generation_3( name, property_names::IMMUTABLE_ALLOW_SETTING, )?; - let owner_reference = parse_owner_reference(&schema, platform_version) - .map_err(consensus_or_protocol_data_contract_error)?; let v1 = common::parse_document_type_core( data_contract_id, @@ -402,6 +401,23 @@ fn try_from_schema_generation_3( let mut v2: DocumentTypeV2 = v1.into(); v2.action_fees = action_fees; v2.entry_payload = entry_payload; + // Read from the stored schema once the core parse has run the meta-schema, + // so under full validation a malformed declaration is the meta-schema's to + // report, as a malformed `refersTo` on a property is + let owner_reference = parse_owner_reference(&v2.schema, platform_version) + .map_err(consensus_or_protocol_data_contract_error)?; + // A document that can change owner, by a transfer or a purchase, would end + // up held by an owner the declaration never checked, since neither is a + // write: the declaration is only admitted where the writer stays the owner + if owner_reference.is_some() && owner_can_change(DocumentTypeRef::V2(&v2)) { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "document type \"{name}\" declares ownerRefersTo, but its documents can be \ + transferred or traded: a transfer or a purchase would hand a document to an \ + owner the declaration never checked", + )), + )); + } v2.owner_reference = owner_reference; common::apply_doctype_aggregates(&mut v2, aggregates, name)?; // After the aggregates: `apply_index_only` rejects the doctype-level @@ -517,14 +533,10 @@ fn validate_reference_count( platform_version: &PlatformVersion, ) -> Result<(), ProtocolError> { let limit = platform_version.system_limits.max_references_per_document; - let property_references: u32 = document_type - .flattened_properties() - .values() - .filter_map(|property| property.property_type.reference()) - .map(|reference| reference.max_references()) + let references: u32 = DocumentTypeRef::V2(document_type) + .reference_declarations() + .map(|(_, reference)| reference.max_references()) .sum(); - let references = - property_references.saturating_add(u32::from(document_type.owner_reference.is_some())); if references > u32::from(limit) { return Err(consensus_or_protocol_data_contract_error( DataContractError::InvalidContractStructure(format!( diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs index d7ec3bd6ce7..f28e336040c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs @@ -1,9 +1,9 @@ //! `ownerRefersTo` (protocol version 14): a document type's own `refersTo` //! declaration, whose value is the document's `$ownerId`, the writer. The -//! parse of every target it takes and the two it refuses, the checks of its -//! lookup on both sides, its count against the references a document may -//! carry, the protocol version gate, the platform serialization round trip and -//! the contract update rule. +//! parse of the two targets it takes and the ones it refuses, the owner rule, +//! the checks of its lookup on both sides, its count against the references a +//! document may carry, the protocol version gate, the platform serialization +//! round trip and the contract update rule. use crate::block::block_info::BlockInfo; use crate::consensus::basic::basic_error::BasicError; @@ -56,7 +56,7 @@ fn permanent_added_moderator(lookup: serde_json::Value) -> serde_json::Value { /// A contract with a permanent, immutable `addedModerator` type (unique on /// (`electedCharterId`, `memberId`) and on (`$ownerId`, `memberId`), with a -/// non-unique `byMember` index), a deletable `post` type, and a transferable +/// non-unique `byMember` index), a deletable `post` type, and a /// `resignationRequest` type declaring `owner_refers_to` (when it is not /// null) next to a required `electedCharterId`, an optional `note` and /// `extra`, one more property schema. @@ -67,7 +67,6 @@ fn charter_contract_with( ) -> serde_json::Value { let mut resignation_request = json!({ "type": "object", - "transferable": 1, "properties": { "electedCharterId": identifier(0), "note": { "type": "string", "maxLength": 63, "position": 1 } @@ -148,6 +147,14 @@ fn owner_reference(contract: &DataContract) -> Option bool { + matches!( + error, + ProtocolError::ConsensusError(boxed) + if matches!(**boxed, ConsensusError::BasicError(BasicError::JsonSchemaError(_))) + ) +} + fn assert_refused(result: Result, fragment: &str) { let error = result.expect_err("the contract should be refused"); assert!( @@ -157,7 +164,7 @@ fn assert_refused(result: Result, fragment: &str) { } #[test] -fn should_parse_an_owner_reference_to_every_target_an_identifier_property_takes_but_two() { +fn should_parse_an_identity_or_a_permanent_document_lookup_owner_reference() { let lookup = DocumentReferenceLookup { index: "byElectedCharterMember".to_string(), keys: BTreeMap::from([ @@ -175,18 +182,6 @@ fn should_parse_an_owner_reference_to_every_target_an_identifier_property_takes_ json!({ "type": "identity" }), DocumentPropertyReferenceTarget::Identity, ), - ( - json!({ "type": "token" }), - DocumentPropertyReferenceTarget::Token, - ), - ( - json!({ "type": "permanentDocument", "documentType": "addedModerator" }), - DocumentPropertyReferenceTarget::PermanentDocument { - contract_id: None, - document_type_name: "addedModerator".to_string(), - property_agreement: BTreeMap::new(), - }, - ), ( permanent_added_moderator(added_moderator_lookup()), DocumentPropertyReferenceTarget::PermanentDocumentLookup { @@ -208,18 +203,10 @@ fn should_parse_an_owner_reference_to_every_target_an_identifier_property_takes_ DocumentPropertyReferenceTarget::PermanentDocumentLookup { contract_id: None, document_type_name: "addedModerator".to_string(), - property_agreement: agreement.clone(), + property_agreement: agreement, lookup, }, ), - ( - json!({ "type": "deletableDocument", "documentType": "post" }), - DocumentPropertyReferenceTarget::DeletableDocument { - contract_id: None, - document_type_name: "post".to_string(), - property_agreement: BTreeMap::new(), - }, - ), ] { // Read on both paths, as every doctype-level keyword of generation 3 for full_validation in [true, false] { @@ -249,8 +236,11 @@ fn should_parse_an_owner_reference_to_every_target_an_identifier_property_takes_ ); } +/// The targets the writer's identity id can never be, and `identityPublicKey`, +/// which needs a key id the writer does not carry: a type declaring one could +/// never be written. #[test] -fn should_refuse_a_contract_or_identity_public_key_owner_reference() { +fn should_refuse_an_owner_reference_to_a_target_the_writer_can_never_be() { for (owner_refers_to, fragment) in [ ( json!({ "type": "contract" }), @@ -268,6 +258,18 @@ fn should_refuse_a_contract_or_identity_public_key_owner_reference() { json!({ "type": "identityPublicKey", "identityProperty": "$ownerId" }), "ownerRefersTo does not take an identityPublicKey reference", ), + ( + json!({ "type": "token" }), + "ownerRefersTo does not take a token reference", + ), + ( + json!({ "type": "permanentDocument", "documentType": "addedModerator" }), + "ownerRefersTo takes a permanentDocument reference only with a lookup", + ), + ( + json!({ "type": "deletableDocument", "documentType": "post" }), + "ownerRefersTo does not take a deletableDocument reference", + ), ] { let schema = charter_contract(owner_refers_to.clone()); // The parser refuses it on the stored path, where no meta-schema runs @@ -275,8 +277,30 @@ fn should_refuse_a_contract_or_identity_public_key_owner_reference() { contract_on(schema.clone(), false, PlatformVersion::latest()), fragment, ); - // and the meta-schema before it at registration - contract(schema).expect_err("the meta-schema should refuse it"); + // and the meta-schema reports it at registration, before the parser + // reads the keyword, as it reports a malformed `refersTo` on a property + let error = contract(schema).expect_err("the meta-schema should refuse it"); + assert!( + is_json_schema_error(&error), + "{owner_refers_to}: expected a meta-schema error, got {error}" + ); + } +} + +/// A transfer or a purchase would hand a document to an owner the declaration +/// never checked, so the type must keep its writer as its owner. +#[test] +fn should_refuse_an_owner_reference_on_a_type_whose_documents_can_change_owner() { + for (keyword, value) in [("transferable", 1), ("tradeMode", 1)] { + let mut schema = charter_contract(json!({ "type": "identity" })); + schema["documentSchemas"]["resignationRequest"][keyword] = json!(value); + for full_validation in [true, false] { + assert_refused( + contract_on(schema.clone(), full_validation, PlatformVersion::latest()), + "document type \"resignationRequest\" declares ownerRefersTo, but its documents \ + can be transferred or traded", + ); + } } } @@ -320,33 +344,17 @@ fn should_check_the_referring_side_of_an_owner_lookup_on_every_parse() { ); } - // `"$ownerId"` is the writer, like `"."`: an owner reference governs - // writing, so it reads the writer even on a type whose documents can be - // transferred, as `resignationRequest`'s can + // `"$ownerId"` is the writer, like `"."`, and passes the owner rule of a + // lookup's referring side: the type cannot change owner let writer_source = contract(charter_contract(permanent_added_moderator(json!({ "index": "byOwnerMember", "keys": { "$ownerId": "$ownerId", "memberId": "." } })))) - .expect("an owner lookup may read the writer on a transferable type"); + .expect("an owner lookup may read the writer"); assert!(matches!( owner_reference(&writer_source), Some(DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. }) )); - - // whereas a property's lookup may not, there - let mut member_id = identifier(2); - member_id["refersTo"] = permanent_added_moderator(json!({ - "index": "byOwnerMember", - "keys": { "$ownerId": "$ownerId", "memberId": "." } - })); - assert_refused( - contract(charter_contract_with( - serde_json::Value::Null, - Some(member_id), - 1, - )), - "a lookup may read the writer only on a document type that cannot be transferred", - ); } #[test] @@ -495,21 +503,28 @@ fn should_refuse_adding_removing_or_changing_an_owner_reference_on_update() { for (before, after, operation) in [ (serde_json::Value::Null, identity.clone(), "add"), (lookup.clone(), serde_json::Value::Null, "remove"), - (identity.clone(), json!({ "type": "token" }), "replace"), + (identity.clone(), lookup.clone(), "replace"), ] { let old = contract(charter_contract_with(before.clone(), None, 1)).expect("parses"); let new = contract(charter_contract_with(after.clone(), None, 2)).expect("parses"); let result = old .validate_update(&new, &BlockInfo::default(), platform_version) .expect("the update should be judged"); - assert!( - matches!( - result.errors.as_slice(), - [ConsensusError::BasicError(BasicError::IncompatibleDocumentTypeSchemaError(e))] + let incompatible: Vec<_> = result + .errors + .iter() + .map(|error| match error { + ConsensusError::BasicError(BasicError::IncompatibleDocumentTypeSchemaError(e)) if e.document_type_name() == "resignationRequest" - && e.operation() == operation - && e.property_path().starts_with("/ownerRefersTo") - ), + && e.property_path().starts_with("/ownerRefersTo") => + { + e.operation() + } + other => panic!("{before} -> {after}: unexpected {other:?}"), + }) + .collect(); + assert!( + incompatible.contains(&operation), "{before} -> {after}: {:?}", result.errors ); 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 3477d4cfe66..244e92a867b 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 @@ -17,7 +17,7 @@ use crate::data_contract::config::v0::DataContractConfigGettersV0; use crate::data_contract::config::v1::DataContractConfigGettersV1; use crate::data_contract::config::v2::DataContractConfigGettersV2; use crate::data_contract::config::DataContractConfig; -use crate::data_contract::document_type::property_names; +use crate::data_contract::document_type::{property_names, DocumentTypeRef}; use crate::data_contract::DataContract; use crate::document::property_names::{CREATOR_ID, OWNER_ID}; use crate::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -976,6 +976,65 @@ impl<'a> PropertyReference<'a> { } } +/// Where a reference declaration of a document type sits, and so where the +/// value it checks comes from. Paired with each declaration by +/// [`DocumentTypeRef::reference_declarations`]. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum ReferenceHolder<'a> { + /// The document type's `ownerRefersTo`: the value is the document's + /// `$ownerId`, the writer. + Owner, + /// A property, by its flattened path: the value is the property's, or each + /// element's for a typed array. + Property(&'a str), +} + +impl<'a> ReferenceHolder<'a> { + /// The path the reference errors name the declaration by: the property's, + /// or `$ownerId` for the owner reference. + pub fn path(&self) -> &'a str { + match self { + ReferenceHolder::Owner => OWNER_ID, + ReferenceHolder::Property(path) => path, + } + } + + /// How contract structure errors name the declaration. + pub fn describe(&self) -> String { + match self { + ReferenceHolder::Owner => property_names::OWNER_REFERS_TO.to_string(), + ReferenceHolder::Property(path) => format!("property \"{path}\" refersTo"), + } + } +} + +impl<'a> DocumentTypeRef<'a> { + /// Every reference declaration of the document type with its holder: the + /// type's `ownerRefersTo` first, then each property's own + /// ([`DocumentPropertyType::reference`]) in schema order. This is how the + /// registration and write-time validators, the per-document reference + /// bound and the client bindings enumerate a type's references, so none of + /// them can skip a holder. + pub fn reference_declarations( + self, + ) -> impl Iterator, PropertyReference<'a>)> { + let (owner_reference, flattened_properties) = match self { + DocumentTypeRef::V0(v0) => (None, &v0.flattened_properties), + DocumentTypeRef::V1(v1) => (None, &v1.flattened_properties), + DocumentTypeRef::V2(v2) => (v2.owner_reference.as_ref(), &v2.flattened_properties), + }; + owner_reference + .map(|target| (ReferenceHolder::Owner, PropertyReference::Value(target))) + .into_iter() + .chain(flattened_properties.iter().filter_map(|(path, property)| { + property + .property_type + .reference() + .map(|reference| (ReferenceHolder::Property(path.as_str()), reference)) + })) + } +} + /// The system properties of a referenced document that the referenced side /// of a `propertyAgreement` pair may name, next to the referenced document /// type's schema properties: `$ownerId`, the current owner (which follows diff --git a/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs b/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs index be4d405301d..df00227ff51 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/reference_lookup.rs @@ -152,37 +152,12 @@ impl DocumentReferenceLookup { &self, declaring: DocumentTypeRef, reference_path: &str, - ) -> Option { - self.referring_sources_error(declaring, Some(reference_path)) - } - - /// [`Self::referring_side_error`] for the lookup of an `ownerRefersTo` - /// declaration of `declaring`, whose own value, `"."`, is the writer: every - /// property source follows the same rules. A `"$ownerId"` source is the - /// writer as well, and is admitted on any type: an owner reference is - /// checked on every create and every replace, whoever the writer is, and - /// like the `$ownerId` side of a `propertyAgreement` it governs writing, - /// not holding, so a transfer or a purchase moving the owner leaves - /// nothing it promised behind. - pub fn owner_reference_referring_side_error( - &self, - declaring: DocumentTypeRef, - ) -> Option { - self.referring_sources_error(declaring, None) - } - - /// The referring-side rules, for a lookup declared on the property at - /// `reference_path`, or, when it is `None`, on the owner reference. - fn referring_sources_error( - &self, - declaring: DocumentTypeRef, - reference_path: Option<&str>, ) -> Option { for (index_property, source) in &self.keys { let path = match source { LookupKeySource::ReferenceValue => continue, LookupKeySource::OwnerId => { - if reference_path.is_some() && owner_can_change(declaring) { + 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 \ @@ -194,7 +169,7 @@ impl DocumentReferenceLookup { } LookupKeySource::Property(path) => path, }; - if Some(path.as_str()) == reference_path { + if path == reference_path { return Some(format!( "key \"{index_property}\" names the reference property itself: write \".\" \ for the reference's own value" @@ -431,7 +406,7 @@ impl DocumentReferenceLookup { /// 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 { +pub(crate) fn owner_can_change(document_type: DocumentTypeRef) -> bool { document_type.documents_transferable().is_transferable() || document_type.trade_mode() != TradeMode::None } diff --git a/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs index 410b34e5e51..4ab2cd609be 100644 --- a/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs @@ -65,17 +65,20 @@ static OPTIONS: Lazy = Lazy::new(|| { // keyword is, so adding, removing or changing it is reported as an // incompatible change. The rule lives here rather than in the shared rule // set, which the generation-0 check also reads, because no earlier - // protocol version knows the keyword. + // protocol version knows the keyword. Without a `refersTo` rule to copy, a + // diff under `ownerRefersTo` fails as an unsupported keyword, an error + // rather than a panic. let owner_refers_to_rule = KEYWORD_COMPATIBILITY_RULES .get("refersTo") - .expect("refersTo rule must be present") - .clone(); + .cloned() + .map(|rule| ("ownerRefersTo", rule)); Options { - override_rules: CompatibilityRulesCollection::from_iter([ - ("required", required_rule), - ("ownerRefersTo", owner_refers_to_rule), - ]), + override_rules: CompatibilityRulesCollection::from_iter( + [("required", required_rule)] + .into_iter() + .chain(owner_refers_to_rule), + ), } }); diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index 76d854963ef..00054a9169e 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -151,11 +151,11 @@ pub struct DocumentTypeV2 { pub(in crate::data_contract) documents_can_be_deleted_by_moderators_for: Option, /// The `refersTo` declaration whose value is the document's `$ownerId`, /// the writer (`ownerRefersTo` keyword, protocol version 14), `None` when - /// the type declares none. Checked with the writer's id as the value on - /// every create and every replace, as a property reference is checked - /// with the property's value. Never a `contract` or `identityPublicKey` - /// target, which the parser (`parse_owner_reference`) refuses: the writer - /// is an identity and carries no key id. + /// the type declares none. Checked with the writer's id as the value, as a + /// property reference is checked with the property's value. Only an + /// `identity` or a `permanentDocument` lookup target, and only on a type + /// whose documents can be neither transferred nor traded, which the parser + /// (`parse_owner_reference` and generation 3) enforces. pub(in crate::data_contract) owner_reference: Option, } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs index c95803fd5f3..b05e630b406 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs @@ -56,8 +56,8 @@ pub(crate) trait DocumentReferenceValidation { /// whose referring side is `$ownerId` compares it, an `identityPublicKey` /// reference on a key id property with `identityProperty: $ownerId` names /// its key, and the document type's `ownerRefersTo` declaration is checked - /// with it as the value, on every create and every replace, since it lives - /// on the transition rather than in `document_data`. + /// with it as the value (under the replace rules of its target), since it + /// lives on the transition rather than in `document_data`. /// `creator_id` is the document's creator for the `$creatorId` form: the /// writer on a create, the stored creator on a replace, `None` when the /// document type records none (registration then admits no such form). 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 b638a71e5a5..78b9945ae1b 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 @@ -14,7 +14,7 @@ use dpp::data_contract::document_type::{ is_referring_system_agreement_property, DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceLookup, DocumentTypeRef, IdentityKeyReferenceRequirements, KeyReferenceIdentityProperty, PropertyReference, - ReferringWrite, + ReferenceHolder, ReferringWrite, }; use dpp::data_contract::DataContract; use dpp::document::property_names::{CREATOR_ID, OWNER_ID}; @@ -226,47 +226,21 @@ fn validate_document_type_references_v0( execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, ) -> Result { - // The writer's own reference (`ownerRefersTo`, protocol version 14): its - // value is the writer, `owner_id`, checked as a property's value is, and - // named `$ownerId` in the errors; in a lookup the writer fills `"."`. It - // is checked on every create and on EVERY replace, touched or not, as a - // writer gate is: the writer is transition metadata that never appears - // among the changed fields, and may not be the one who wrote the - // document before (a transfer or a purchase moves the owner). An - // `identity` target has nothing to fetch: the transition already proved - // the writer exists. - if let Some(owner_reference) = document_type.owner_reference() { - if !matches!(owner_reference, DocumentPropertyReferenceTarget::Identity) { - let result = validate_reference_v0( - contract, - document_type, - document_data, - owner_id, - owner_reference, - owner_id.to_buffer(), - OWNER_ID, - &mut BTreeMap::new(), - platform, - block_info, - transaction, - execution_context, - platform_version, - )?; - if !result.is_valid() { - return Ok(result); - } - } - } - - for (path, property) in document_type.flattened_properties() { - // A reference is an identifier property's value, or each element of - // a typed array of identifiers whose `items` declare it (protocol - // version 14, the version whose document create and replace state - // validation call this; no document type of an earlier version can - // hold a typed array, so the element arm is never reached there) - let Some(reference) = property.property_type.reference() else { - continue; - }; + // A reference is the writer's (`ownerRefersTo`, whose value is the + // document's `$ownerId` and which the errors name by that path), an + // identifier property's value, or each element of a typed array of + // identifiers whose `items` declare it. All three are protocol version 14 + // declarations, the version whose document create and replace state + // validation call this: no document type of an earlier version has an + // owner reference or a typed array, so neither arm is reached there. The + // owner reference follows the replace rules of the target it declares, + // as a property's does: its holder never changes (the writer is the owner + // on a type that can be neither transferred nor traded, which generation 3 + // requires of it), so a replace re-validates it when a property its + // lookup or a `propertyAgreement` reads changed, or always for a writer + // gate. + for (holder, reference) in document_type.reference_declarations() { + let path = holder.path(); let (reference_target, holds_elements) = match reference { // A key reference on the key id property itself: the value is the // key id and the declaration names whose key it is. A transfer @@ -389,15 +363,29 @@ fn validate_document_type_references_v0( let mut referenced_contracts = BTreeMap::new(); if !holds_elements { - let referenced_id = match document_data.get_optional_identifier_at_path(path) { - Ok(Some(referenced_id)) => referenced_id, - // A reference property that is not set is not validated; whether it may be - // absent at all is enforced by the document type's required fields - Ok(None) => continue, - Err(err) => { - return Ok(SimpleConsensusValidationResult::new_with_error( - InvalidIdentifierError::new(path.to_string(), err.to_string()).into(), - )) + let referenced_id = match holder { + // The writer: an identity target has nothing to fetch, the + // transition already proved the writer exists + ReferenceHolder::Owner => { + if matches!(reference_target, DocumentPropertyReferenceTarget::Identity) { + continue; + } + owner_id.to_buffer() + } + ReferenceHolder::Property(path) => { + match document_data.get_optional_identifier_at_path(path) { + Ok(Some(referenced_id)) => referenced_id, + // A reference property that is not set is not validated; whether it + // may be absent at all is enforced by the document type's required + // fields + Ok(None) => continue, + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidIdentifierError::new(path.to_string(), err.to_string()) + .into(), + )) + } + } } }; let result = validate_reference_v0( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs index 345d991355e..918d6e1dfca 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs @@ -3,8 +3,8 @@ //! fixture's `addedModerator` is unique on (`electedCharterId`, `memberId`), //! and a `resignationRequest` may only be written by the `memberId` of an //! `addedModerator` for its own `electedCharterId`, the moderation charters' -//! rule: the lookup takes the writer for `"."`. `resignationRequest` can be -//! transferred, so its owner can change without a write. `roleResignation` +//! rule: the lookup takes the writer for `"."`. Neither type can be +//! transferred or traded, so the writer stays the owner. `roleResignation` //! adds a `propertyAgreement` checked against the moderator the lookup finds, //! and `note` declares an identity target, which every writer meets. //! @@ -16,14 +16,24 @@ use super::*; mod owner_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 dpp::data_contract::document_type::{DocumentPropertyReferenceTarget, DocumentTypeRef}; use dpp::document::Document; use dpp::identifier::Identifier; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::prelude::{DataContract, IdentityNonce}; use dpp::state_transition::StateTransition; + use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; + use dpp::validation::SimpleConsensusValidationResult; + 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; + use std::collections::{BTreeMap, BTreeSet}; const CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json"; @@ -254,38 +264,67 @@ mod owner_reference_tests { self.process(&transition) } - /// Transfers `document`, as last accepted, from `from`, its owner, - /// to `to`. - async fn transfer( - &mut self, - from: Who, - to: Who, + /// Runs the document reference validation directly on `data`, a + /// `type_name` document written by `who`, as a create, or as a replace + /// changing `changed_fields`, and returns the result with the execution + /// context, whose operations are the reads it billed. + fn validate_directly( + &self, + who: Who, type_name: &str, - document: &Document, - ) -> StateTransitionExecutionResult { + data: BTreeMap, + changed_fields: Option>, + ) -> ( + SimpleConsensusValidationResult, + StateTransitionExecutionContext, + ) { let platform_version = PlatformVersion::latest(); - let recipient = self.id(to); - let mut transferred = document.clone(); - transferred - .increment_revision() - .expect("the revision increments"); - let (document_type, writer, _) = self.parts(from, type_name); - let nonce = writer.next_nonce(); - let transition = BatchTransition::new_document_transfer_transition_from_document( - transferred, - document_type, - recipient, - &writer.key, - nonce, - 0, - None, - &writer.signer, - platform_version, - None, - ) - .await - .expect("expected the transfer transition"); - self.process(&transition) + let (_, contract_fetch_info) = self + .platform + .drive + .get_contract_with_fetch_info_and_fee( + self.contract.id().to_buffer(), + None, + false, + None, + platform_version, + ) + .expect("expected to fetch the contract"); + let base = DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { + id: Identifier::from([0xAB; 32]), + identity_contract_nonce: 1, + document_type_name: type_name.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 = self.platform.state.load(); + let platform_ref = PlatformStateRef { + drive: &self.platform.drive, + state: &platform_state, + config: &self.platform.config, + }; + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("expected an execution context"); + let result = base + .validate_document_references( + &data, + self.id(who), + // The fixture's types record no creator ids + None, + changed_fields.as_ref(), + None, + &platform_ref, + &BlockInfo::default(), + None, + &mut execution_context, + platform_version, + ) + .expect("expected the references to be validated"); + (result, execution_context) } /// Seats `who` as a moderator of the elected charter `charter` in @@ -382,11 +421,10 @@ mod owner_reference_tests { } #[tokio::test] - async fn should_refuse_a_replace_by_a_writer_who_no_longer_meets_the_owner_reference_even_when_no_field_changed( - ) { + async fn should_refuse_a_replace_that_moves_the_writer_off_the_owner_reference() { let mut fixture = OwnerReferenceFixture::new(); fixture.seat(Who::Member, charter_id(1), "chair").await; - let stranger = fixture.id(Who::Stranger); + let member = fixture.id(Who::Member); let (request, result) = fixture .request_resignation(Who::Member, charter_id(1)) @@ -396,9 +434,11 @@ mod owner_reference_tests { StateTransitionExecutionResult::SuccessfulExecution { .. } ); - // The member still meets it: a replace changing nothing passes + // Touching nothing the lookup reads leaves the reference alone let result = fixture - .replace(Who::Member, "resignationRequest", &request, |_| {}) + .replace(Who::Member, "resignationRequest", &request, |request| { + request.set("reason", "changed my mind".into()); + }) .await; assert_matches!( result, @@ -408,26 +448,57 @@ mod owner_reference_tests { request .increment_revision() .expect("the revision increments"); + request.set("reason", "changed my mind".into()); - // A transfer is not checked: the reference governs writing + // Moving the key part re-validates it: the member is not seated for + // charter 2 let result = fixture - .transfer(Who::Member, Who::Stranger, "resignationRequest", &request) + .replace(Who::Member, "resignationRequest", &request, |request| { + request.set("electedCharterId", id_value(charter_id(2))); + }) .await; + assert_writer_not_found(result, member); + } + + /// A replace that changes nothing the lookup reads cannot change its + /// outcome (the writer stays the owner, the target can never be deleted + /// and its key is fixed), so it reads nothing; one that moves a key part + /// is billed the lookup. + #[tokio::test] + async fn should_read_nothing_on_a_replace_leaving_the_owner_lookup_keys_alone() { + let mut fixture = OwnerReferenceFixture::new(); + fixture.seat(Who::Member, charter_id(1), "chair").await; + let request = |charter: Identifier| { + BTreeMap::from([ + ("electedCharterId".to_string(), id_value(charter)), + ( + "reason".to_string(), + Value::Text("stepping down".to_string()), + ), + ]) + }; + + let (result, execution_context) = fixture.validate_directly( + Who::Member, + "resignationRequest", + request(charter_id(1)), + Some(BTreeSet::from(["reason".to_string()])), + ); + assert!(result.is_valid(), "{:?}", result.errors); + assert!(execution_context.operations_slice().is_empty()); + + let (result, execution_context) = fixture.validate_directly( + Who::Member, + "resignationRequest", + request(charter_id(1)), + Some(BTreeSet::from(["electedCharterId".to_string()])), + ); + assert!(result.is_valid(), "{:?}", result.errors); assert_matches!( - result, - StateTransitionExecutionResult::SuccessfulExecution { .. } + execution_context.operations_slice(), + [ValidationOperation::PrecalculatedOperation(fee)] if fee.processing_fee > 0, + "the lookup query is the one billed operation" ); - request - .increment_revision() - .expect("the revision increments"); - request.set_owner_id(stranger); - - // The new owner writes no field, and is still refused: the writer is - // checked on every replace - let result = fixture - .replace(Who::Stranger, "resignationRequest", &request, |_| {}) - .await; - assert_writer_not_found(result, stranger); } #[tokio::test] @@ -488,5 +559,17 @@ mod owner_reference_tests { StateTransitionExecutionResult::SuccessfulExecution { .. } ); } + + // and nothing is read to find that out, on a create or a replace + for changed_fields in [None, Some(BTreeSet::from(["text".to_string()]))] { + let (result, execution_context) = fixture.validate_directly( + Who::Stranger, + "note", + BTreeMap::from([("text".to_string(), Value::Text("hello".to_string()))]), + changed_fields, + ); + assert!(result.is_valid(), "{:?}", result.errors); + assert!(execution_context.operations_slice().is_empty()); + } } } 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 a9e01828e7d..d9abae3392c 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 @@ -4,10 +4,10 @@ use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, Docume use dpp::data_contract::document_type::{ is_referenced_system_agreement_property, is_referring_system_agreement_property, DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, - DocumentReferenceDeclaration, KeyReferenceIdentityProperty, PropertyReference, + DocumentReferenceDeclaration, KeyReferenceIdentityProperty, PropertyReference, ReferenceHolder, }; use dpp::data_contract::DataContract; -use dpp::document::property_names::{CREATOR_ID, OWNER_ID}; +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; @@ -64,8 +64,8 @@ fn same_value_kind(a: &DocumentPropertyType, b: &DocumentPropertyType) -> bool { /// elements: the parser refuses it there. /// /// A document type's `ownerRefersTo` declaration, whose value is the writer, -/// is checked as a single identifier reference's is, first; it is never an -/// `identityPublicKey` or a `contract` one, which the parser refuses. +/// is checked as a single identifier reference's is, first; the parser only +/// admits an `identity` or a `permanentDocument` lookup one. /// /// The error paths name the failing declaration as /// `documentTypeName.propertyPath`, an element declaration by its list @@ -89,27 +89,16 @@ pub(super) fn validate_data_contract_references_v0( BTreeMap::new(); for (declaring_type_name, document_type) in contract.document_types() { - let declaring = document_type.as_ref(); // The writer's reference first (`ownerRefersTo`, whose value is the // document's `$ownerId` and which is named by that path), then the // properties' own. The owner reference is never a key reference: the - // parser refuses `identityPublicKey` there, and `contract` too - let references = declaring - .owner_reference() - .map(|target| (OWNER_ID, true, PropertyReference::Value(target))) - .into_iter() - .chain( - declaring - .flattened_properties() - .iter() - .filter_map(|(path, property)| { - property - .property_type - .reference() - .map(|reference| (path.as_str(), false, reference)) - }), - ); - for (path, is_owner_reference, reference) in references { + // parser only admits an identity or a permanentDocument lookup there. + // Inert before protocol version 14: this module is only called from + // contract create and update state validation 1, selected from it, and + // no parse before it sets an owner reference. + for (holder, reference) in document_type.as_ref().reference_declarations() { + let path = holder.path(); + let is_owner_reference = matches!(holder, ReferenceHolder::Owner); let declaration_path = format!("{declaring_type_name}.{path}"); let (reference_target, declaration_path) = match reference { 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 a49b85aace0..f7661c4187d 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 @@ -5737,6 +5737,46 @@ mod tests { ); } + #[tokio::test] + async fn should_reject_an_owner_reference_to_a_deletable_type_at_its_owner_path() { + // A permanentDocument lookup into a type of the same contract that + // allows deletion: the contract parse leaves it to registration + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-deletable-target.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentTypeDeletableError(e) + ), + .. + } if e.path() == "note.$ownerId" && e.document_type_name() == "moderator" + ); + } + + #[tokio::test] + async fn should_reject_an_owner_reference_property_agreement_at_its_owner_path() { + // The referring side names a property the declaring type does not + // have; `$ownerId` there would have been admitted + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-agreement-invalid.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentPropertyAgreementInvalidError(e) + ), + .. + } if e.path() == "note.$ownerId" && e.referring_property() == "missing" + ); + } + #[tokio::test] async fn should_register_contract_with_deletable_document_references() { // A deletableDocument reference targets a document type that @@ -6230,6 +6270,26 @@ mod tests { const LOOKUP_CONTRACT_PATH: &str = "tests/supporting_files/contract/reference-validation/reference-validation-contract-lookup.json"; + #[tokio::test] + async fn should_reject_an_owner_lookup_into_another_contract_at_its_owner_path() { + // `joinRequest` of the lookup contract has no `byMessage` index + let result = run_contract_create_with_foreign( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-foreign-lookup-invalid.json", + LOOKUP_CONTRACT_PATH, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentLookupInvalidError(e) + ), + .. + } if e.path() == "note.$ownerId" && e.index() == "byMessage" + ); + } + #[tokio::test] async fn should_register_a_lookup_into_a_unique_index_of_another_contract() { let result = run_contract_create_with_foreign( diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-agreement-invalid.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-agreement-invalid.json new file mode 100644 index 00000000000..8bb78fd74c3 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-agreement-invalid.json @@ -0,0 +1,63 @@ +{ + "$formatVersion": "1", + "id": "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "moderator": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "memberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + } + }, + "indices": [ + { + "name": "byMember", + "properties": [ + { + "memberId": "asc" + } + ], + "unique": true + } + ], + "required": [ + "memberId" + ], + "additionalProperties": false + }, + "note": { + "type": "object", + "ownerRefersTo": { + "type": "permanentDocument", + "documentType": "moderator", + "propertyAgreement": { + "missing": "memberId" + }, + "lookup": { + "index": "byMember", + "keys": { + "memberId": "." + } + } + }, + "properties": { + "content": { + "type": "string", + "position": 0, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-deletable-target.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-deletable-target.json new file mode 100644 index 00000000000..e9cfde6777a --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-deletable-target.json @@ -0,0 +1,59 @@ +{ + "$formatVersion": "1", + "id": "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "moderator": { + "type": "object", + "documentsMutable": false, + "properties": { + "memberId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + } + }, + "indices": [ + { + "name": "byMember", + "properties": [ + { + "memberId": "asc" + } + ], + "unique": true + } + ], + "required": [ + "memberId" + ], + "additionalProperties": false + }, + "note": { + "type": "object", + "ownerRefersTo": { + "type": "permanentDocument", + "documentType": "moderator", + "lookup": { + "index": "byMember", + "keys": { + "memberId": "." + } + } + }, + "properties": { + "content": { + "type": "string", + "position": 0, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-foreign-lookup-invalid.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-foreign-lookup-invalid.json new file mode 100644 index 00000000000..0897868b915 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-foreign-lookup-invalid.json @@ -0,0 +1,31 @@ +{ + "$formatVersion": "1", + "id": "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "note": { + "type": "object", + "ownerRefersTo": { + "type": "permanentDocument", + "contractId": "4uAB6wAdt6FJ7djwjYrLnooVhYeQpzgkssBmgmvZ9WnM", + "documentType": "joinRequest", + "lookup": { + "index": "byMessage", + "keys": { + "message": "." + } + } + }, + "properties": { + "content": { + "type": "string", + "position": 0, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-unknown-type.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-unknown-type.json index abb6cf4fadc..91d33d03306 100644 --- a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-unknown-type.json +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to-registration-unknown-type.json @@ -8,7 +8,13 @@ "type": "object", "ownerRefersTo": { "type": "permanentDocument", - "documentType": "ghost" + "documentType": "ghost", + "lookup": { + "index": "byMember", + "keys": { + "memberId": "." + } + } }, "properties": { "content": { diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json index ff1fa3e1657..dae3c0a28b7 100644 --- a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json @@ -54,7 +54,6 @@ }, "resignationRequest": { "type": "object", - "transferable": 1, "ownerRefersTo": { "type": "permanentDocument", "documentType": "addedModerator", diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 2d649be0dac..994b28dab0f 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -762,18 +762,21 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// 33. **References on the document's writer (`ownerRefersTo`)**: a document /// type may declare one `refersTo` declaration of its own, under the /// doctype-level `ownerRefersTo` keyword (meta-schema v3, which reuses the -/// property declaration by `$ref` and refuses `contract` and -/// `identityPublicKey`), whose value is the document's `$ownerId`, the -/// writer, instead of a property's. Parser generation 3 reads it on every -/// parse through the same `apply_property_reference` 0 an identifier -/// property's goes through, onto `DocumentTypeV2::owner_reference`, so it -/// takes every target an identifier property takes but those two (the -/// writer is an identity, never a contract, and carries no key id), -/// `propertyAgreement` and `lookup` included: in a lookup `.` is the -/// writer, and a `$ownerId` key part, the writer too, is admitted on a -/// transferable or tradeable type, since the declaration governs writing -/// rather than holding. Its lookup's referring side is checked on every -/// parse, a lookup into a type of the same contract by +/// property declaration by `$ref`), whose value is the document's +/// `$ownerId`, the writer, instead of a property's. Only two targets can +/// hold a writer: `identity`, and a `permanentDocument` found through a +/// `lookup`, where `.` is the writer; `contract`, `token` and a document +/// by id (which the writer's identity id never is) and `identityPublicKey` +/// (which needs a key id) are refused. Parser generation 3 reads it from +/// the stored schema once the core parse has run the meta-schema, on +/// every parse, through the same `apply_property_reference` 0 an +/// identifier property's goes through, onto +/// `DocumentTypeV2::owner_reference`, and refuses it on a type whose +/// documents can be transferred or traded, since neither is a write. Every +/// enumeration of a type's references goes through +/// `DocumentTypeRef::reference_declarations`, which yields it first: its +/// lookup's referring side is checked on every parse, a lookup into a +/// type of the same contract by /// `create_document_types_from_document_schemas` 1 (edited in place like /// for item 29, inert before this version, whose parsers never set an /// owner reference), and the whole declaration at registration by the @@ -784,10 +787,10 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// `max_references_per_document`. Document create state validation 2 and /// replace state validation 1 (`document_reference_validation` 0, extended /// in place, both only reached from this version) check the writer against -/// the target exactly as a property's value is checked, on every create and -/// on every replace whatever it changes (the writer is transition metadata -/// that never appears among the changed fields), never on a transfer or a -/// purchase, and refuse the write with the error the target reports for a +/// the target exactly as a property's value is checked: on every create, +/// and on a replace under the rules of its target (a changed property its +/// lookup or a `propertyAgreement` reads, every replace for a `$ownerId` +/// pair), and refuse the write with the error the target reports for a /// property (40120 and the rest) at the path `$ownerId`; an `identity` /// target fetches nothing, the transition having proved the writer exists. /// Adding, removing or changing it is an incompatible schema change on 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 db9bce69f87..a4fb4772af8 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -13,12 +13,10 @@ use crate::error::{WasmDppError, WasmDppResult}; use crate::identifier::IdentifierWasm; -use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::{ DocumentPropertyReferenceTarget, DocumentTypeRef, IdentityKeyReferenceRequirements, KeyIdReference, PropertyReference, }; -use dpp::document::property_names::OWNER_ID; use dpp::prelude::Identifier; use js_sys::{Array, Object, Reflect}; use wasm_bindgen::JsValue; @@ -228,9 +226,13 @@ export type DocumentPropertyReference = { * * The document type's `ownerRefersTo` declaration, whose value is the * document's `$ownerId`, the writer, is listed first with the path - * `"$ownerId"`. Consensus checks it with the writer's id on every create - * and every replace; in its `lookup`, `'.'` is the writer. Its `type` is - * never `contract` or `identityPublicKey`, which it cannot declare. + * `"$ownerId"`, which is not a property path: the value it checks is the + * document's owner. Consensus checks it with the writer's id when a + * document is created, and when a replace changes a property its `lookup` + * or `propertyAgreement` reads; in its `lookup`, `'.'` is the writer. Its + * `type` is `identity` or a `permanentDocument` with a `lookup`, the only + * targets a writer can be, on a document type whose documents can be + * neither transferred nor traded. * * This is the same string consensus reports in the `path` field of the * document-write reference errors (codes 40120-40125, 40131, 40135 and @@ -512,19 +514,16 @@ pub(crate) fn references_for_document_type( ) -> WasmDppResult { let references = Array::new(); - if let Some(target) = document_type.owner_reference() { - references.push(&reference_to_js(OWNER_ID, target, declaring_contract_id)?); - } - - for (path, property) in document_type.flattened_properties() { - match property.property_type.reference() { - Some(PropertyReference::KeyId(reference)) => { + for (holder, reference) in document_type.reference_declarations() { + let path = holder.path(); + match reference { + PropertyReference::KeyId(reference) => { references.push(&key_id_reference_to_js(path, reference)?); } - Some(PropertyReference::Value(target)) => { + PropertyReference::Value(target) => { references.push(&reference_to_js(path, target, declaring_contract_id)?); } - Some(PropertyReference::Elements { target, .. }) => { + PropertyReference::Elements { target, .. } => { let element_path = format!("{path}[]"); references.push(&reference_to_js( &element_path, @@ -532,7 +531,6 @@ pub(crate) fn references_for_document_type( declaring_contract_id, )?); } - None => {} } } diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index b254d166928..f5d53edddaf 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -530,9 +530,10 @@ describe('DataContract — refersTo declarations (v14)', () => { ]); }); - it('should refuse a contract or identityPublicKey owner reference', () => { + it('should refuse an owner reference to a target the writer can never be', () => { for (const ownerRefersTo of [ { type: 'contract' }, + { type: 'token' }, { type: 'identityPublicKey', identityProperty: '$ownerId' }, ]) { const refused = { ...ownerSchemas.resignation, ownerRefersTo }; From e765b362ae306a80b88f8af11e53146f23b172c9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 20:25:18 +0700 Subject: [PATCH 3/4] feat(platform)!: creatorRefersTo, a reference on the document's creator (PV14) The counterpart of ownerRefersTo for document types whose documents can be transferred or traded: one refersTo declaration whose value is the document's $creatorId, which a transfer or a purchase never changes. - only on a type that records creator ids (should_use_creator_id: a transferable or tradeable type of a format-1 contract), exactly where ownerRefersTo is refused, so a type declares at most one of the two - the same meta-schema shape and parser (parse_doctype_reference, now shared by both keywords), the same two targets (identity, or a permanentDocument lookup with "." the creator); stored as DocumentTypeV2::creator_reference and yielded by reference_declarations as ReferenceHolder::Creator - checked against the writer on a create and the stored creator on a replace, under the target's replace rules, never on a transfer or a purchase; errors name $creatorId (.$creatorId at registration); an identity target reads nothing - counts one against max_references_per_document and is frozen on update Co-Authored-By: Claude Opus 5.5 --- book/src/data-model/documents.md | 25 +- packages/js-evo-sdk/README.md | 2 +- .../document/v3/document-meta.json | 31 +- .../document_type/accessors/mod.rs | 24 ++ .../document_type/accessors/v2/mod.rs | 7 + .../class_methods/try_from_schema/mod.rs | 97 +++--- .../class_methods/try_from_schema/v3/mod.rs | 50 ++- .../v3/owner_reference_tests.rs | 317 ++++++++++++++---- .../src/data_contract/document_type/mod.rs | 11 +- .../document_type/property/mod.rs | 26 +- .../validate_schema_compatibility/v1/mod.rs | 32 +- .../document_type/v2/accessors.rs | 4 + .../src/data_contract/document_type/v2/mod.rs | 10 + .../document_reference_validation/mod.rs | 7 +- .../document_reference_validation/v0/mod.rs | 40 ++- .../batch/tests/document/owner_reference.rs | 184 +++++++++- .../v0/mod.rs | 26 +- .../data_contract_create/mod.rs | 22 +- ...r-refers-to-registration-unknown-type.json | 31 ++ ...e-validation-contract-owner-refers-to.json | 53 +++ .../rs-platform-version/src/version/v14.rs | 24 +- .../data_contract/document_type_reference.rs | 10 +- packages/wasm-dpp2/src/data_contract/model.rs | 4 +- .../unit/DocumentPropertyReference.spec.ts | 26 +- 24 files changed, 872 insertions(+), 191 deletions(-) create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-refers-to-registration-unknown-type.json diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index dc5ae3f8766..de08251b0cb 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -335,7 +335,7 @@ When the referring document is created or replaced, the document reference valid 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. -### On the writer (`ownerRefersTo`) +### On the writer or the creator (`ownerRefersTo`, `creatorRefersTo`) A property's reference constrains a value the writer chose. Some rules constrain the writer instead: in the moderation charters, a `resignationRequest` may only come from a moderator of the team it resigns from. A document type states that with the doctype-level `ownerRefersTo` keyword, one `refersTo` declaration whose value is the document's `$ownerId`, the writer, rather than a property's value: @@ -363,7 +363,28 @@ reads: the writer must be the `memberId` of an `addedModerator` for this documen - It counts one against `SystemLimits::max_references_per_document`. - When a document is created, the document reference validation checks the writer against the target exactly as a property's value is checked, before the properties' references. A replace re-validates it under the rules of its target, as a property's: when a property its lookup or a `propertyAgreement` reads changed, and on every replace for a pair keyed by `$ownerId`, a writer gate. Nothing else can change the outcome: the writer is the owner, the target can never be deleted and its key is fixed. A failure is the error the target reports for a property (`ReferencedEntityNotFoundError`, 40120, for a lookup that finds no document; `ReferencedDocumentPropertyMismatchError`, 40127, for an agreement; and the rest), with `$ownerId` as its path. An `identity` target reads nothing: the transition has already proved that the writer exists. At registration the contract reference validation checks the declaration as a property's, naming it `.$ownerId`. - Adding, removing or changing it is an incompatible schema change on update (`validate_schema_compatibility` 1 freezes the keyword as the shared rule set freezes `refersTo`). -- Every validator, the reference bound and the client bindings enumerate a type's references through `DocumentTypeRef::reference_declarations`, which yields the owner reference first, as `ReferenceHolder::Owner`, then each property's, so none can skip it. +- Every validator, the reference bound and the client bindings enumerate a type's references through `DocumentTypeRef::reference_declarations`, which yields the owner or creator reference first, as `ReferenceHolder::Owner` or `ReferenceHolder::Creator`, then each property's, so none can skip it. + +A document type whose documents can be transferred or traded declares `creatorRefersTo` instead: the same declaration, whose value is the document's `$creatorId`, its creator, which a transfer or a purchase never changes. A marketplace item that only a seated moderator may mint, and anyone may then own, reads: + +```json +"moderatorBadge": { + "type": "object", + "transferable": 1, + "creatorRefersTo": { + "type": "permanentDocument", + "documentType": "addedModerator", + "lookup": { + "index": "byElectedCharterMember", + "keys": { "electedCharterId": "electedCharterId", "memberId": "." } + } + }, + "properties": { "electedCharterId": { "...": "..." } } +} +``` + +- It takes the same two targets, with `"."` the creator in a lookup, and is refused where `ownerRefersTo` is admitted: only a document type that records creator ids may declare it, a transferable or tradeable type of a format-1 contract (`should_use_creator_id`), checked on every parse. A type therefore declares at most one of the two. A `"$ownerId"` key part in its lookup is refused, as in a property's lookup on such a type, since the owner moves. +- When a document is created its creator is the writer; on a replace, the value is the stored creator, whoever writes, and the replace rules are those of its target, as for the owner reference. A transfer or a purchase needs no check. A failure is the target's error at the path `$creatorId`, and registration names the declaration `.$creatorId`. An `identity` target reads nothing: the creator existed when it wrote the document, and an identity is never removed. It counts one against `max_references_per_document`, and a change to it is an incompatible schema change on update. ## Immutable Properties on Mutable Document Types diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 12e1c0aa96f..e99ffc3a9ce 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -216,7 +216,7 @@ A document type may also declare `ownerRefersTo`, a reference whose value is the // lookup: { index: 'byElectedCharterMember', keys: { electedCharterId: 'electedCharterId', memberId: '.' } } } ``` -reads: the writer must be the `memberId` of an `addedModerator` for the document's `electedCharterId`. Consensus checks it when a document is created and when a replace changes a property the lookup or a `propertyAgreement` reads, and a rejection names it `$ownerId`. Only a document type whose documents can be neither transferred nor traded may declare it, so the owner is always the writer that was checked. +reads: the writer must be the `memberId` of an `addedModerator` for the document's `electedCharterId`. Consensus checks it when a document is created and when a replace changes a property the lookup or a `propertyAgreement` reads, and a rejection names it `$ownerId`. Only a document type whose documents can be neither transferred nor traded may declare it, so the owner is always the writer that was checked. A transferable or tradeable type declares `creatorRefersTo` instead, listed first at `path: '$creatorId'`: the same declaration, whose value is the document's creator, which never changes, so a transfer or a purchase leaves it true. A document reference comes in two strengths. `permanentDocument` requires the referenced document type to declare `canBeDeleted: false`, so a reference that was accepted keeps resolving. `deletableDocument` takes the same declaration (`contractId`, `documentType`, `propertyAgreement`) and is its disjoint counterpart: the referenced type must allow deletion (`ReferencedDocumentTypeNotDeletable`, 40131, otherwise). The referenced document must exist, and the agreement must hold, when the referring document is written, but it may be deleted afterwards. Nothing blocks that deletion and nothing cleans up after it, so a reader must expect such a reference to resolve to nothing. It can never start resolving to different content: a document id commits to the nonce of its create transition, so a deleted id can not be created again. A writer may not leave it that way: every replace of the referring document re-validates the reference, touched or not, so once the target is gone the replace has to repoint it at a document that exists or clear it (`ReferencedEntityNotFound` otherwise). A writer gate is then checked against the new target, never against a missing one. On an `immutable` property clearing is the only move, and the immutable check lets that one change through. The referring document can always be deleted. A property cannot switch between the two on a contract update, and `preallocated` indexes are only available through `permanentDocument`. 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 d3b5b9c006f..a7b3508f38c 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property) and its ownerRefersTo form on the document type, whose value is the writer (an identity or a permanentDocument lookup), the requiredSince property keyword (the contract version a property is required from), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property) and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity or a permanentDocument lookup), the requiredSince property keyword (the contract version a property is required from), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "documentProperties": { @@ -1689,7 +1689,34 @@ "description": "The subset of the immutable properties a replace may still set while the stored document has no value for them: a first-time set is accepted, after which the property is frozen like the rest of the immutable list (it can neither change nor be removed). Every entry must also appear in immutable; it is only meaningful for optional properties, since a required one always has a value from creation. On contract update an entry may be dropped (tightening) at any time, but may only be added for a property that becomes immutable in the same update: an already-immutable property cannot start allowing a set. Available from protocol version 14." }, "ownerRefersTo": { - "description": "A refersTo declaration whose value is the document's $ownerId, the writer, instead of a property's value: the declaration of an identifier property, with the same keys and the same checks, for the two targets a writer can be: identity, or a permanentDocument found through a lookup, where \".\" is the writer and \"$ownerId\" names the writer as well. contract, token and a document by id are refused, since the writer's identity id is never one of those ids, and identityPublicKey pairs the value with a key id, which the writer does not carry. A propertyAgreement's referring side is still a property of the document or its $ownerId, the same writer. Only on a document type whose documents can be neither transferred nor traded, so the writer stays the owner. When a document is created, and when a replace changes a property the lookup or a propertyAgreement reads (every replace for a $ownerId agreement pair), consensus checks the writer against the target exactly as a property's value is checked, and refuses the write with the error that target reports for a property (ReferencedEntityNotFoundError, 40120, and the rest), naming $ownerId as the path. It counts as one reference against the references a document may carry. Fixed when the document type is created: adding, removing or changing it is an incompatible schema change on update. Available from protocol version 14.", + "description": "A refersTo declaration whose value is the document's $ownerId, the writer, instead of a property's value: the declaration of an identifier property, with the same keys and the same checks, for the two targets a writer can be: identity, or a permanentDocument found through a lookup, where \".\" is the writer and \"$ownerId\" names the writer as well. contract, token and a document by id are refused, since the writer's identity id is never one of those ids, and identityPublicKey pairs the value with a key id, which the writer does not carry. A propertyAgreement's referring side is still a property of the document or its $ownerId, the same writer. Only on a document type whose documents can be neither transferred nor traded, so the writer stays the owner; on a type whose documents can, creatorRefersTo checks the creator instead. When a document is created, and when a replace changes a property the lookup or a propertyAgreement reads (every replace for a $ownerId agreement pair), consensus checks the writer against the target exactly as a property's value is checked, and refuses the write with the error that target reports for a property (ReferencedEntityNotFoundError, 40120, and the rest), naming $ownerId as the path. It counts as one reference against the references a document may carry. Fixed when the document type is created: adding, removing or changing it is an incompatible schema change on update. Available from protocol version 14.", + "$ref": "#/$defs/documentSchema/properties/refersTo", + "properties": { + "type": { + "enum": [ + "identity", + "permanentDocument" + ] + } + }, + "if": { + "properties": { + "type": { + "const": "permanentDocument" + } + }, + "required": [ + "type" + ] + }, + "then": { + "required": [ + "lookup" + ] + } + }, + "creatorRefersTo": { + "description": "A refersTo declaration whose value is the document's $creatorId, its creator, instead of a property's value: the counterpart of ownerRefersTo for a document type whose documents can be transferred or traded, since the creator never changes. The declaration of an identifier property, with the same keys and the same checks, for the two targets a creator can be: identity, or a permanentDocument found through a lookup, where \".\" is the creator. contract, token and a document by id are refused, since the creator's identity id is never one of those ids, and identityPublicKey pairs the value with a key id, which the creator does not carry. A propertyAgreement's referring side is still a property of the document or its $ownerId, the writer. Only on a document type that records creator ids: a transferable or tradeable document type of a format-1 contract; a type declares at most one of ownerRefersTo and creatorRefersTo. When a document is created (by its creator) and when a replace changes a property the lookup or a propertyAgreement reads (every replace for a $ownerId agreement pair), consensus checks the creator against the target exactly as a property's value is checked, and refuses the write with the error that target reports for a property (ReferencedEntityNotFoundError, 40120, and the rest), naming $creatorId as the path. A transfer or a purchase needs no check: it does not change the creator. It counts as one reference against the references a document may carry. Fixed when the document type is created: adding, removing or changing it is an incompatible schema change on update. Available from protocol version 14.", "$ref": "#/$defs/documentSchema/properties/refersTo", "properties": { "type": { diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs index f0828998d1d..ab5b2c0990f 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/mod.rs @@ -1054,6 +1054,14 @@ impl DocumentTypeV2Getters for DocumentType { DocumentType::V2(v2) => v2.owner_reference(), } } + + fn creator_reference(&self) -> Option<&DocumentPropertyReferenceTarget> { + match self { + DocumentType::V0(_) => None, + DocumentType::V1(_) => None, + DocumentType::V2(v2) => v2.creator_reference(), + } + } } impl DocumentTypeV2Setters for DocumentType { @@ -1195,6 +1203,14 @@ impl DocumentTypeV2Getters for DocumentTypeRef<'_> { DocumentTypeRef::V2(v2) => v2.owner_reference(), } } + + fn creator_reference(&self) -> Option<&DocumentPropertyReferenceTarget> { + match self { + DocumentTypeRef::V0(_) => None, + DocumentTypeRef::V1(_) => None, + DocumentTypeRef::V2(v2) => v2.creator_reference(), + } + } } impl DocumentTypeV2Getters for DocumentTypeMutRef<'_> { @@ -1302,6 +1318,14 @@ impl DocumentTypeV2Getters for DocumentTypeMutRef<'_> { DocumentTypeMutRef::V2(v2) => v2.owner_reference(), } } + + fn creator_reference(&self) -> Option<&DocumentPropertyReferenceTarget> { + match self { + DocumentTypeMutRef::V0(_) => None, + DocumentTypeMutRef::V1(_) => None, + DocumentTypeMutRef::V2(v2) => v2.creator_reference(), + } + } } #[cfg(test)] diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs index 51cfc04cb4c..39fe1b9cc62 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/v2/mod.rs @@ -85,6 +85,13 @@ pub trait DocumentTypeV2Getters { /// that declare none and on those that predate the keyword. Enumerated with /// the property references by `DocumentTypeRef::reference_declarations`. fn owner_reference(&self) -> Option<&DocumentPropertyReferenceTarget>; + + /// The `refersTo` declaration whose value is the document's `$creatorId`, + /// its creator (the `creatorRefersTo` keyword, protocol version 14), + /// checked with the creator's id as the value when a document is created, + /// and on a replace under the rules of its target. `None` on document types + /// that declare none and on those that predate the keyword. + fn creator_reference(&self) -> Option<&DocumentPropertyReferenceTarget>; } /// Trait providing setters for DocumentTypeV2-specific fields. 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 22ddec75083..9ae8ad53cc6 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 @@ -922,24 +922,28 @@ fn apply_element_reference_v0( apply_property_reference_v0(items, element_type) } -/// Reads a document type's `ownerRefersTo` keyword: one `refersTo` declaration -/// whose value is the document's `$ownerId`, the writer, instead of a -/// property's value. The declaration goes through [`apply_property_reference`] -/// as one declared on an identifier property does, `propertyAgreement` and -/// `lookup` included (where `"."` is the writer), but only two targets can -/// hold a writer: `identity`, and a `permanentDocument` found through a -/// `lookup`. The others are refused: `contract`, `token` and a document by id, -/// since the writer's identity id is never a contract, token or document id, -/// so a type declaring one could never be written, and `identityPublicKey`, -/// which pairs the value with a key id the writer does not carry. +/// Reads one of a document type's own `refersTo` keywords, `keyword`: one +/// declaration whose value is an identity of the document rather than a +/// property's value, `ownerRefersTo` the document's `$ownerId` (the writer) +/// and `creatorRefersTo` its `$creatorId` (the creator), named `value` in the +/// errors. The declaration goes through [`apply_property_reference`] as one +/// declared on an identifier property does, `propertyAgreement` and `lookup` +/// included (where `"."` is that identity), but only two targets can hold an +/// identity: `identity`, and a `permanentDocument` found through a `lookup`. +/// The others are refused: `contract`, `token` and a document by id, since an +/// identity id is never a contract, token or document id, so a type declaring +/// one could never be written, and `identityPublicKey`, which pairs the value +/// with a key id the document does not carry. /// /// Only parser generation 3 calls it, once the core parse has run the /// meta-schema, on every parse, validating or not, like its other /// doctype-level keywords. Versioned through `apply_property_reference`, the /// gate of the declaration it reads: `None` leaves the keyword unread, as it /// leaves `refersTo` on a property, refusals included. -pub(super) fn parse_owner_reference( +pub(super) fn parse_doctype_reference( schema: &Value, + keyword: &str, + value: &str, platform_version: &PlatformVersion, ) -> Result, DataContractError> { if platform_version @@ -957,7 +961,7 @@ pub(super) fn parse_owner_reference( let Ok(schema_map) = schema.to_map() else { return Ok(None); }; - let Some(declaration) = schema_map.get_optional_key(property_names::OWNER_REFERS_TO) else { + let Some(declaration) = schema_map.get_optional_key(keyword) else { return Ok(None); }; @@ -967,29 +971,27 @@ pub(super) fn parse_owner_reference( .and_then(|reference_type| reference_type.as_text()) .map(str::to_string); let refusal = match reference_type.as_deref() { - Some("contract") => Some( - "ownerRefersTo does not take a contract reference: its value is the writer, an \ - identity, which is never a contract", - ), - Some("identityPublicKey") => Some( - "ownerRefersTo does not take an identityPublicKey reference: it pairs the value with \ - a key id, which the writer does not carry", - ), - Some("token") => Some( - "ownerRefersTo does not take a token reference: its value, the writer's identity id, \ - is never a token id", - ), - Some("deletableDocument") => Some( - "ownerRefersTo does not take a deletableDocument reference: its value, the writer's \ + Some("contract") => Some(format!( + "{keyword} does not take a contract reference: its value is {value}, an identity, \ + which is never a contract" + )), + Some("identityPublicKey") => Some(format!( + "{keyword} does not take an identityPublicKey reference: it pairs the value with a \ + key id, which {value} does not carry" + )), + Some("token") => Some(format!( + "{keyword} does not take a token reference: its value, {value}'s identity id, is \ + never a token id" + )), + Some("deletableDocument") => Some(format!( + "{keyword} does not take a deletableDocument reference: its value, {value}'s \ identity id, is never a document id, and only a permanentDocument reference takes \ - the lookup that could find one", - ), + the lookup that could find one" + )), _ => None, }; if let Some(refusal) = refusal { - return Err(DataContractError::InvalidContractStructure( - refusal.to_string(), - )); + return Err(DataContractError::InvalidContractStructure(refusal)); } let inner_properties = BTreeMap::from([(property_names::REFERS_TO.to_string(), declaration)]); @@ -1004,16 +1006,13 @@ pub(super) fn parse_owner_reference( ) => Ok(Some(target)), DocumentPropertyType::IdentifierWithReference( DocumentPropertyReferenceTarget::PermanentDocument { .. }, - ) => Err(DataContractError::InvalidContractStructure( - "ownerRefersTo takes a permanentDocument reference only with a lookup: its value, the \ - writer's identity id, is never a document id" - .to_string(), - )), - _ => Err(DataContractError::InvalidContractStructure( - "ownerRefersTo takes an identity reference or a permanentDocument reference with a \ - lookup" - .to_string(), - )), + ) => Err(DataContractError::InvalidContractStructure(format!( + "{keyword} takes a permanentDocument reference only with a lookup: its value, \ + {value}'s identity id, is never a document id" + ))), + _ => Err(DataContractError::InvalidContractStructure(format!( + "{keyword} takes an identity reference or a permanentDocument reference with a lookup" + ))), } } @@ -1110,9 +1109,10 @@ fn parse_document_reference_lookup( /// be a stored, required, single value (with every object around it /// required), and not be the reference property itself. See /// [`DocumentReferenceLookup::referring_side_error`]. The lookup of the type's -/// `ownerRefersTo` follows the same rules: its `"$ownerId"` sources pass the -/// owner rule, since the type cannot change owner (generation 3 refuses the -/// keyword otherwise). +/// `ownerRefersTo` or `creatorRefersTo` follows the same rules: the owner's +/// `"$ownerId"` sources pass the owner rule, since that type cannot change +/// owner (generation 3 refuses the keyword otherwise), and the creator's are +/// refused by it, since that type can. /// /// 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 @@ -1122,10 +1122,11 @@ pub(super) fn validate_reference_lookup_sources( document_type: DocumentTypeRef, document_type_name: &str, ) -> Result<(), DataContractError> { - // On the writer, on an identifier property or on the elements of a typed - // array: the key's other parts are the same for every element, and the - // owner reference's `"."` is the writer, named by the path `$ownerId`, - // which no property source can be + // On the writer or the creator, on an identifier property or on the + // elements of a typed array: the key's other parts are the same for every + // element, and the owner or creator reference's `"."` is that identity, + // named by the path `$ownerId` or `$creatorId`, which no property source + // can be for (holder, reference) in document_type.reference_declarations() { let Some(lookup) = reference .target() 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 666ab1daf98..f95e9287fca 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 @@ -3,7 +3,8 @@ //! Generation 3 is generation 2 plus the ranked index keywords //! (`rankedCountable` / `rankedSummable` / `rankedAverageable`), the //! indexOnly grammar, the doctype-level `immutable` property list and the -//! doctype-level `ownerRefersTo` reference on the writer. +//! doctype-level `ownerRefersTo` and `creatorRefersTo` references on the +//! writer and the creator. //! //! It exists as its own generation — rather than as a version gate inside the //! shipped ones — because that is what keeps a historical block from ever @@ -51,7 +52,7 @@ use crate::consensus::ConsensusError; use super::common; use super::{ - parse_owner_reference, validate_encrypted_for_declarations, validate_reference_lookup_sources, + parse_doctype_reference, validate_encrypted_for_declarations, validate_reference_lookup_sources, }; mod ranked_prefix_overlap; @@ -404,21 +405,53 @@ fn try_from_schema_generation_3( // Read from the stored schema once the core parse has run the meta-schema, // so under full validation a malformed declaration is the meta-schema's to // report, as a malformed `refersTo` on a property is - let owner_reference = parse_owner_reference(&v2.schema, platform_version) - .map_err(consensus_or_protocol_data_contract_error)?; + let owner_reference = parse_doctype_reference( + &v2.schema, + property_names::OWNER_REFERS_TO, + "the writer", + platform_version, + ) + .map_err(consensus_or_protocol_data_contract_error)?; + let creator_reference = parse_doctype_reference( + &v2.schema, + property_names::CREATOR_REFERS_TO, + "the creator", + platform_version, + ) + .map_err(consensus_or_protocol_data_contract_error)?; // A document that can change owner, by a transfer or a purchase, would end // up held by an owner the declaration never checked, since neither is a - // write: the declaration is only admitted where the writer stays the owner + // write: the owner's declaration is only admitted where the writer stays + // the owner. The creator's is only admitted where the creator is recorded, + // on a type that can change owner, since elsewhere the creator is the + // owner and `ownerRefersTo` says it. So a type takes at most one of them if owner_reference.is_some() && owner_can_change(DocumentTypeRef::V2(&v2)) { return Err(consensus_or_protocol_data_contract_error( DataContractError::InvalidContractStructure(format!( "document type \"{name}\" declares ownerRefersTo, but its documents can be \ transferred or traded: a transfer or a purchase would hand a document to an \ - owner the declaration never checked", + owner the declaration never checked; creatorRefersTo checks the creator, who \ + never changes", + )), + )); + } + if creator_reference.is_some() + && !DocumentTypeRef::V2(&v2).should_use_creator_id( + data_contract_system_version, + contract_config_version, + platform_version, + )? + { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "document type \"{name}\" declares creatorRefersTo, but it records no creator \ + ids: only a transferable or tradeable document type of a format-1 contract \ + does; ownerRefersTo checks the writer of a type whose documents stay with it", )), )); } v2.owner_reference = owner_reference; + v2.creator_reference = creator_reference; common::apply_doctype_aggregates(&mut v2, aggregates, name)?; // After the aggregates: `apply_index_only` rejects the doctype-level // aggregate flags (they describe the primary-key tree, which an @@ -518,7 +551,7 @@ fn validate_typed_array_max_items( /// The references one document of the type can carry, one for each /// property declaring `refersTo` (an identifier, or a key id with a key /// reference), `maxItems` for each typed array whose elements declare it and -/// one for the type's `ownerRefersTo`, are at most +/// one for the type's `ownerRefersTo` or `creatorRefersTo`, are at most /// `SystemLimits::max_references_per_document`. Every reference is a billed /// state read when the document is created or replaced, so the sum bounds /// the reads one write can cause; `max_typed_array_items` alone would let a @@ -542,7 +575,8 @@ fn validate_reference_count( DataContractError::InvalidContractStructure(format!( "document type \"{name}\" declares references for up to {references} values per \ document (one per property with refersTo, maxItems per typed array of \ - referencing elements, one for ownerRefersTo), above the maximum of {limit}", + referencing elements, one for ownerRefersTo or creatorRefersTo), above the \ + maximum of {limit}", )), )); } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs index f28e336040c..2ec63d2d24f 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs @@ -1,9 +1,10 @@ -//! `ownerRefersTo` (protocol version 14): a document type's own `refersTo` -//! declaration, whose value is the document's `$ownerId`, the writer. The -//! parse of the two targets it takes and the ones it refuses, the owner rule, -//! the checks of its lookup on both sides, its count against the references a -//! document may carry, the protocol version gate, the platform serialization -//! round trip and the contract update rule. +//! `ownerRefersTo` and `creatorRefersTo` (protocol version 14): a document +//! type's own `refersTo` declaration, whose value is the document's +//! `$ownerId`, the writer, or its `$creatorId`, the creator. The parse of the +//! two targets they take and the ones they refuse, the owner and creator +//! rules, the checks of a lookup on both sides, the count against the +//! references a document may carry, the protocol version gate, the platform +//! serialization round trip and the contract update rule. use crate::block::block_info::BlockInfo; use crate::consensus::basic::basic_error::BasicError; @@ -147,6 +148,39 @@ fn owner_reference(contract: &DataContract) -> Option, + version: u32, +) -> serde_json::Value { + let mut schema = charter_contract_with(serde_json::Value::Null, None, version); + let resignation_request = &mut schema["documentSchemas"]["resignationRequest"]; + resignation_request["creatorRefersTo"] = creator_refers_to; + if let Some((keyword, value)) = keyword { + resignation_request[keyword] = json!(value); + } + schema +} + +/// [`creator_contract_with`] on a transferable type. +fn creator_contract(creator_refers_to: serde_json::Value) -> serde_json::Value { + creator_contract_with(creator_refers_to, Some(("transferable", 1)), 1) +} + +/// A contract builder declaring the given reference on `resignationRequest`. +type ContractDeclaring = fn(serde_json::Value) -> serde_json::Value; + +fn creator_reference(contract: &DataContract) -> Option { + contract + .document_type_for_name("resignationRequest") + .expect("the resignationRequest document type") + .creator_reference() + .cloned() +} + fn is_json_schema_error(error: &ProtocolError) -> bool { matches!( error, @@ -240,50 +274,58 @@ fn should_parse_an_identity_or_a_permanent_document_lookup_owner_reference() { /// which needs a key id the writer does not carry: a type declaring one could /// never be written. #[test] -fn should_refuse_an_owner_reference_to_a_target_the_writer_can_never_be() { - for (owner_refers_to, fragment) in [ - ( - json!({ "type": "contract" }), - "ownerRefersTo does not take a contract reference", - ), - ( - json!({ "type": "contract", "contractRequirements": { "owner": "self" } }), - "ownerRefersTo does not take a contract reference", - ), - ( - json!({ "type": "identityPublicKey", "keyIdProperty": "note" }), - "ownerRefersTo does not take an identityPublicKey reference", - ), - ( - json!({ "type": "identityPublicKey", "identityProperty": "$ownerId" }), - "ownerRefersTo does not take an identityPublicKey reference", - ), - ( - json!({ "type": "token" }), - "ownerRefersTo does not take a token reference", - ), - ( - json!({ "type": "permanentDocument", "documentType": "addedModerator" }), - "ownerRefersTo takes a permanentDocument reference only with a lookup", - ), - ( - json!({ "type": "deletableDocument", "documentType": "post" }), - "ownerRefersTo does not take a deletableDocument reference", - ), - ] { - let schema = charter_contract(owner_refers_to.clone()); - // The parser refuses it on the stored path, where no meta-schema runs - assert_refused( - contract_on(schema.clone(), false, PlatformVersion::latest()), - fragment, - ); - // and the meta-schema reports it at registration, before the parser - // reads the keyword, as it reports a malformed `refersTo` on a property - let error = contract(schema).expect_err("the meta-schema should refuse it"); - assert!( - is_json_schema_error(&error), - "{owner_refers_to}: expected a meta-schema error, got {error}" - ); +fn should_refuse_an_owner_or_creator_reference_to_a_target_its_identity_can_never_be() { + let declare: [(&str, ContractDeclaring); 2] = [ + ("ownerRefersTo", charter_contract), + ("creatorRefersTo", creator_contract), + ]; + for (keyword, contract_declaring) in declare { + for (declaration, refusal) in [ + ( + json!({ "type": "contract" }), + "does not take a contract reference", + ), + ( + json!({ "type": "contract", "contractRequirements": { "owner": "self" } }), + "does not take a contract reference", + ), + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "note" }), + "does not take an identityPublicKey reference", + ), + ( + json!({ "type": "identityPublicKey", "identityProperty": "$ownerId" }), + "does not take an identityPublicKey reference", + ), + ( + json!({ "type": "token" }), + "does not take a token reference", + ), + ( + json!({ "type": "permanentDocument", "documentType": "addedModerator" }), + "takes a permanentDocument reference only with a lookup", + ), + ( + json!({ "type": "deletableDocument", "documentType": "post" }), + "does not take a deletableDocument reference", + ), + ] { + let schema = contract_declaring(declaration.clone()); + // The parser refuses it on the stored path, where no meta-schema + // runs + assert_refused( + contract_on(schema.clone(), false, PlatformVersion::latest()), + &format!("{keyword} {refusal}"), + ); + // and the meta-schema reports it at registration, before the + // parser reads the keyword, as it reports a malformed `refersTo` on + // a property + let error = contract(schema).expect_err("the meta-schema should refuse it"); + assert!( + is_json_schema_error(&error), + "{keyword} {declaration}: expected a meta-schema error, got {error}" + ); + } } } @@ -388,7 +430,7 @@ fn should_check_an_owner_lookup_into_a_type_of_the_same_contract() { } #[test] -fn should_count_the_owner_reference_against_the_references_a_document_may_carry() { +fn should_count_the_owner_or_creator_reference_against_the_references_a_document_may_carry() { let limit = PlatformVersion::latest() .system_limits .max_references_per_document; @@ -415,17 +457,21 @@ fn should_count_the_owner_reference_against_the_references_a_document_may_carry( )) .expect("the array alone is at the limit"); + let over_the_limit = format!( + "declares references for up to {} values per document", + u32::from(limit) + 1 + ); assert_refused( contract(charter_contract_with( json!({ "type": "identity" }), - Some(references), + Some(references.clone()), 1, )), - &format!( - "declares references for up to {} values per document", - u32::from(limit) + 1 - ), + &over_the_limit, ); + let mut creator = creator_contract(json!({ "type": "identity" })); + creator["documentSchemas"]["resignationRequest"]["properties"]["extra"] = references; + assert_refused(contract(creator), &over_the_limit); } #[test] @@ -538,3 +584,162 @@ fn should_refuse_adding_removing_or_changing_an_owner_reference_on_update() { .expect("the update should be judged"); assert!(result.is_valid(), "{:?}", result.errors); } + +#[test] +fn should_parse_a_creator_reference_on_a_type_that_records_creator_ids() { + let lookup = DocumentReferenceLookup { + index: "byElectedCharterMember".to_string(), + keys: BTreeMap::from([ + ( + "electedCharterId".to_string(), + LookupKeySource::Property("electedCharterId".to_string()), + ), + ("memberId".to_string(), LookupKeySource::ReferenceValue), + ]), + }; + for keyword in [("transferable", 1), ("tradeMode", 1)] { + for (creator_refers_to, expected) in [ + ( + json!({ "type": "identity" }), + DocumentPropertyReferenceTarget::Identity, + ), + ( + permanent_added_moderator(added_moderator_lookup()), + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + contract_id: None, + document_type_name: "addedModerator".to_string(), + property_agreement: BTreeMap::new(), + lookup: lookup.clone(), + }, + ), + ] { + for full_validation in [true, false] { + let parsed = contract_on( + creator_contract_with(creator_refers_to.clone(), Some(keyword), 1), + full_validation, + PlatformVersion::latest(), + ) + .unwrap_or_else(|e| panic!("{creator_refers_to} should parse: {e}")); + assert_eq!(creator_reference(&parsed).as_ref(), Some(&expected)); + assert_eq!(owner_reference(&parsed), None); + } + } + } +} + +/// The creator is only recorded on a type whose documents can change owner; +/// elsewhere it is the owner, which `ownerRefersTo` checks. So a type takes +/// at most one of the two keywords. +#[test] +fn should_refuse_a_creator_reference_on_a_type_that_records_no_creator_ids() { + let identity = json!({ "type": "identity" }); + let mut both = creator_contract_with(identity.clone(), None, 1); + both["documentSchemas"]["resignationRequest"]["ownerRefersTo"] = identity.clone(); + let mut both_transferable = creator_contract(identity.clone()); + both_transferable["documentSchemas"]["resignationRequest"]["ownerRefersTo"] = identity.clone(); + + for (schema, fragment) in [ + ( + creator_contract_with(identity, None, 1), + "document type \"resignationRequest\" declares creatorRefersTo, but it records no \ + creator ids", + ), + ( + both, + "document type \"resignationRequest\" declares creatorRefersTo, but it records no \ + creator ids", + ), + ( + both_transferable, + "document type \"resignationRequest\" declares ownerRefersTo, but its documents \ + can be transferred or traded", + ), + ] { + for full_validation in [true, false] { + assert_refused( + contract_on(schema.clone(), full_validation, PlatformVersion::latest()), + fragment, + ); + } + } +} + +/// The owner of a transferable document moves, so the creator's lookup may not +/// read it, as a property's lookup on such a type may not. +#[test] +fn should_refuse_a_creator_lookup_reading_the_owner() { + assert_refused( + contract(creator_contract(permanent_added_moderator(json!({ + "index": "byOwnerMember", + "keys": { "$ownerId": "$ownerId", "memberId": "." } + })))), + "document type \"resignationRequest\" creatorRefersTo lookup: key \"$ownerId\" reads \ + \"$ownerId\"", + ); +} + +#[test] +fn should_refuse_creator_refers_to_before_protocol_version_14_and_read_it_at_14() { + let schema = creator_contract(permanent_added_moderator(added_moderator_lookup())); + let platform_version_13 = PlatformVersion::get(13).expect("platform version 13 should exist"); + + contract_on(schema.clone(), true, platform_version_13) + .expect_err("protocol version 13 should refuse the keyword"); + let ignored = contract_on(schema.clone(), false, platform_version_13) + .expect("protocol version 13 should parse the rest of the contract"); + assert_eq!(creator_reference(&ignored), None); + + let accepted = contract_on(schema, true, PlatformVersion::latest()).expect("parses"); + assert!(matches!( + creator_reference(&accepted), + Some(DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. }) + )); +} + +#[test] +fn should_round_trip_a_creator_reference_and_refuse_changing_it_on_update() { + let platform_version = PlatformVersion::latest(); + let lookup = permanent_added_moderator(added_moderator_lookup()); + + let original = contract(creator_contract(lookup.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); + assert_eq!(creator_reference(&original), creator_reference(&recovered)); + + let transferable = Some(("transferable", 1)); + let without = charter_contract_with(serde_json::Value::Null, None, 1); + let mut without_transferable = without.clone(); + without_transferable["documentSchemas"]["resignationRequest"]["transferable"] = json!(1); + for (before, after, operation) in [ + ( + without_transferable, + creator_contract_with(lookup.clone(), transferable, 2), + "add", + ), + ( + creator_contract_with(lookup.clone(), transferable, 1), + creator_contract_with(json!({ "type": "identity" }), transferable, 2), + "replace", + ), + ] { + let old = contract(before).expect("parses"); + let new = contract(after).expect("parses"); + let result = old + .validate_update(&new, &BlockInfo::default(), platform_version) + .expect("the update should be judged"); + assert!( + result.errors.iter().any(|error| matches!( + error, + ConsensusError::BasicError(BasicError::IncompatibleDocumentTypeSchemaError(e)) + if e.operation() == operation + && e.property_path().starts_with("/creatorRefersTo") + )), + "{operation}: {:?}", + result.errors + ); + } +} 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 f57634dea03..ab7541b61e0 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -106,10 +106,15 @@ pub(crate) mod property_names { pub const DECRYPTION_KEY_REQUIREMENTS: &str = "decryptionKeyReqs"; pub const REFERS_TO: &str = "refersTo"; /// Doctype-level `refersTo` declaration whose value is the document's - /// `$ownerId`, the writer, rather than a property's value. Meta-schema - /// v3+ (protocol version 14). See `parse_owner_reference` in - /// `try_from_schema`. + /// `$ownerId`, the writer, rather than a property's value, only on a type + /// whose documents cannot change owner. Meta-schema v3+ (protocol version + /// 14). See `parse_doctype_reference` in `try_from_schema`. pub const OWNER_REFERS_TO: &str = "ownerRefersTo"; + /// Doctype-level `refersTo` declaration whose value is the document's + /// `$creatorId`, its creator, only on a type that records creator ids (a + /// transferable or tradeable one). Meta-schema v3+ (protocol version 14). + /// See `parse_doctype_reference` in `try_from_schema`. + pub const CREATOR_REFERS_TO: &str = "creatorRefersTo"; pub const DISTINCT_FROM: &str = "distinctFrom"; pub const CONTRACT_ID: &str = "contractId"; pub const DOCUMENT_TYPE: &str = "documentType"; 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 244e92a867b..f7503b08dc9 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 @@ -984,6 +984,9 @@ pub enum ReferenceHolder<'a> { /// The document type's `ownerRefersTo`: the value is the document's /// `$ownerId`, the writer. Owner, + /// The document type's `creatorRefersTo`: the value is the document's + /// `$creatorId`, its creator, which never changes. + Creator, /// A property, by its flattened path: the value is the property's, or each /// element's for a typed array. Property(&'a str), @@ -991,10 +994,11 @@ pub enum ReferenceHolder<'a> { impl<'a> ReferenceHolder<'a> { /// The path the reference errors name the declaration by: the property's, - /// or `$ownerId` for the owner reference. + /// `$ownerId` for the owner reference or `$creatorId` for the creator one. pub fn path(&self) -> &'a str { match self { ReferenceHolder::Owner => OWNER_ID, + ReferenceHolder::Creator => CREATOR_ID, ReferenceHolder::Property(path) => path, } } @@ -1003,6 +1007,7 @@ impl<'a> ReferenceHolder<'a> { pub fn describe(&self) -> String { match self { ReferenceHolder::Owner => property_names::OWNER_REFERS_TO.to_string(), + ReferenceHolder::Creator => property_names::CREATOR_REFERS_TO.to_string(), ReferenceHolder::Property(path) => format!("property \"{path}\" refersTo"), } } @@ -1010,7 +1015,8 @@ impl<'a> ReferenceHolder<'a> { impl<'a> DocumentTypeRef<'a> { /// Every reference declaration of the document type with its holder: the - /// type's `ownerRefersTo` first, then each property's own + /// type's `ownerRefersTo` and `creatorRefersTo` first (a type declares at + /// most one of them), then each property's own /// ([`DocumentPropertyType::reference`]) in schema order. This is how the /// registration and write-time validators, the per-document reference /// bound and the client bindings enumerate a type's references, so none of @@ -1018,14 +1024,22 @@ impl<'a> DocumentTypeRef<'a> { pub fn reference_declarations( self, ) -> impl Iterator, PropertyReference<'a>)> { - let (owner_reference, flattened_properties) = match self { - DocumentTypeRef::V0(v0) => (None, &v0.flattened_properties), - DocumentTypeRef::V1(v1) => (None, &v1.flattened_properties), - DocumentTypeRef::V2(v2) => (v2.owner_reference.as_ref(), &v2.flattened_properties), + let (owner_reference, creator_reference, flattened_properties) = match self { + DocumentTypeRef::V0(v0) => (None, None, &v0.flattened_properties), + DocumentTypeRef::V1(v1) => (None, None, &v1.flattened_properties), + DocumentTypeRef::V2(v2) => ( + v2.owner_reference.as_ref(), + v2.creator_reference.as_ref(), + &v2.flattened_properties, + ), }; owner_reference .map(|target| (ReferenceHolder::Owner, PropertyReference::Value(target))) .into_iter() + .chain( + creator_reference + .map(|target| (ReferenceHolder::Creator, PropertyReference::Value(target))), + ) .chain(flattened_properties.iter().filter_map(|(path, property)| { property .property_type diff --git a/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs index 4ab2cd609be..da34f2b3daa 100644 --- a/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/schema/validate_schema_compatibility/v1/mod.rs @@ -30,9 +30,9 @@ //! `validate_immutable_fields_update` judges it (the list may grow, never //! shrink). //! -//! The top-level `ownerRefersTo` key (protocol version 14) gets the frozen -//! rule of the property `refersTo`, so any change to it is an incompatible -//! schema change. +//! The top-level `ownerRefersTo` and `creatorRefersTo` keys (protocol version +//! 14) get the frozen rule of the property `refersTo`, so any change to them +//! is an incompatible schema change. use crate::data_contract::document_type::schema::IncompatibleJsonSchemaOperation; use crate::data_contract::errors::{DataContractError, JsonSchemaError}; @@ -60,24 +60,24 @@ static OPTIONS: Lazy = Lazy::new(|| { .expect("required rule must have inner rules") .allow_removal = false; - // `ownerRefersTo` (protocol version 14) is the document type's own - // `refersTo`, whose value is the writer: frozen exactly as the property - // keyword is, so adding, removing or changing it is reported as an - // incompatible change. The rule lives here rather than in the shared rule - // set, which the generation-0 check also reads, because no earlier - // protocol version knows the keyword. Without a `refersTo` rule to copy, a - // diff under `ownerRefersTo` fails as an unsupported keyword, an error - // rather than a panic. - let owner_refers_to_rule = KEYWORD_COMPATIBILITY_RULES - .get("refersTo") - .cloned() - .map(|rule| ("ownerRefersTo", rule)); + // `ownerRefersTo` and `creatorRefersTo` (protocol version 14) are the + // document type's own `refersTo`, whose value is the writer or the + // creator: frozen exactly as the property keyword is, so adding, removing + // or changing one is reported as an incompatible change. The rules live + // here rather than in the shared rule set, which the generation-0 check + // also reads, because no earlier protocol version knows the keywords. + // Without a `refersTo` rule to copy, a diff under either fails as an + // unsupported keyword, an error rather than a panic. + let refers_to_rule = KEYWORD_COMPATIBILITY_RULES.get("refersTo"); + let doctype_refers_to_rules = ["ownerRefersTo", "creatorRefersTo"] + .into_iter() + .filter_map(|keyword| refers_to_rule.map(|rule| (keyword, rule.clone()))); Options { override_rules: CompatibilityRulesCollection::from_iter( [("required", required_rule)] .into_iter() - .chain(owner_refers_to_rule), + .chain(doctype_refers_to_rules), ), } }); diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs index b0990cedfe1..1775feb310b 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/accessors.rs @@ -266,6 +266,10 @@ impl DocumentTypeV2Getters for DocumentTypeV2 { fn owner_reference(&self) -> Option<&DocumentPropertyReferenceTarget> { self.owner_reference.as_ref() } + + fn creator_reference(&self) -> Option<&DocumentPropertyReferenceTarget> { + self.creator_reference.as_ref() + } } impl DocumentTypeV2Setters for DocumentTypeV2 { diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index 00054a9169e..d66019448d0 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -157,6 +157,14 @@ pub struct DocumentTypeV2 { /// whose documents can be neither transferred nor traded, which the parser /// (`parse_owner_reference` and generation 3) enforces. pub(in crate::data_contract) owner_reference: Option, + /// The `refersTo` declaration whose value is the document's `$creatorId`, + /// its creator (`creatorRefersTo` keyword, protocol version 14), `None` + /// when the type declares none. The counterpart of + /// [`Self::owner_reference`] for a type whose documents can change owner: + /// the same two targets, only on a type that records creator ids (a + /// transferable or tradeable type of a format-1 contract), where the + /// creator never changes. + pub(in crate::data_contract) creator_reference: Option, } impl DocumentTypeBasicMethods for DocumentTypeV2 {} @@ -246,6 +254,7 @@ impl From for DocumentTypeV2 { documents_can_be_deleted_by_moderators: false, documents_can_be_deleted_by_moderators_for: None, owner_reference: None, + creator_reference: None, } } } @@ -295,6 +304,7 @@ impl From for DocumentTypeV2 { documents_can_be_deleted_by_moderators: false, documents_can_be_deleted_by_moderators_for: None, owner_reference: None, + creator_reference: None, } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs index b05e630b406..50c1a67c377 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/mod.rs @@ -58,9 +58,10 @@ pub(crate) trait DocumentReferenceValidation { /// its key, and the document type's `ownerRefersTo` declaration is checked /// with it as the value (under the replace rules of its target), since it /// lives on the transition rather than in `document_data`. - /// `creator_id` is the document's creator for the `$creatorId` form: the - /// writer on a create, the stored creator on a replace, `None` when the - /// document type records none (registration then admits no such form). + /// `creator_id` is the document's creator for the `$creatorId` form and + /// the value of the document type's `creatorRefersTo`: the writer on a + /// create, the stored creator on a replace, `None` when the document type + /// records none (registration then admits neither). #[allow(clippy::too_many_arguments)] fn validate_document_references( &self, 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 78b9945ae1b..2ca37bdddb4 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 @@ -227,18 +227,20 @@ fn validate_document_type_references_v0( platform_version: &PlatformVersion, ) -> Result { // A reference is the writer's (`ownerRefersTo`, whose value is the - // document's `$ownerId` and which the errors name by that path), an - // identifier property's value, or each element of a typed array of - // identifiers whose `items` declare it. All three are protocol version 14 + // document's `$ownerId` and which the errors name by that path), the + // creator's (`creatorRefersTo`, `$creatorId`), an identifier property's + // value, or each element of a typed array of identifiers whose `items` + // declare it. The first two and the last are protocol version 14 // declarations, the version whose document create and replace state // validation call this: no document type of an earlier version has an - // owner reference or a typed array, so neither arm is reached there. The - // owner reference follows the replace rules of the target it declares, - // as a property's does: its holder never changes (the writer is the owner - // on a type that can be neither transferred nor traded, which generation 3 - // requires of it), so a replace re-validates it when a property its - // lookup or a `propertyAgreement` reads changed, or always for a writer - // gate. + // owner or creator reference or a typed array, so none of those arms is + // reached there. The owner and creator references follow the replace + // rules of the target they declare, as a property's does: their value + // never changes (the writer is the owner on a type that can be neither + // transferred nor traded, which generation 3 requires of `ownerRefersTo`, + // and the creator is set once), so a replace re-validates one when a + // property its lookup or a `propertyAgreement` reads changed, or always + // for a writer gate. for (holder, reference) in document_type.reference_declarations() { let path = holder.path(); let (reference_target, holds_elements) = match reference { @@ -372,6 +374,24 @@ fn validate_document_type_references_v0( } owner_id.to_buffer() } + // The creator: the writer on a create, which the transition + // proved exists, and the stored creator on a replace, which + // existed when it wrote the document (an identity is never + // removed), so an identity target has nothing to fetch either + ReferenceHolder::Creator => { + if matches!(reference_target, DocumentPropertyReferenceTarget::Identity) { + continue; + } + // Generation 3 admits `creatorRefersTo` only on a document + // type that records creator ids, so a document of such a + // type has one + creator_id + .ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "a creatorRefersTo declaration needs a document type that records \ + creator ids", + )))? + .to_buffer() + } ReferenceHolder::Property(path) => { match document_data.get_optional_identifier_at_path(path) { Ok(Some(referenced_id)) => referenced_id, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs index 918d6e1dfca..438143fa33e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs @@ -1,5 +1,6 @@ -//! `ownerRefersTo` (protocol version 14) through the full ABCI pipeline: a -//! document type's own `refersTo` declaration, whose value is the writer. The +//! `ownerRefersTo` and `creatorRefersTo` (protocol version 14) through the +//! full ABCI pipeline: a document type's own `refersTo` declaration, whose +//! value is the writer or the creator. The //! fixture's `addedModerator` is unique on (`electedCharterId`, `memberId`), //! and a `resignationRequest` may only be written by the `memberId` of an //! `addedModerator` for its own `electedCharterId`, the moderation charters' @@ -7,10 +8,14 @@ //! transferred or traded, so the writer stays the owner. `roleResignation` //! adds a `propertyAgreement` checked against the moderator the lookup finds, //! and `note` declares an identity target, which every writer meets. +//! `moderatorBadge` can be transferred, so it declares `creatorRefersTo` +//! instead: only a seated moderator may mint one, and whoever holds it later, +//! the check is against that creator. `creatorNote` declares an identity +//! target on the creator. //! -//! A writer the target does not accept is refused, paid, with the error the -//! target reports for a property, `ReferencedEntityNotFoundError` (40120) for -//! a lookup that finds nothing, naming `$ownerId`. +//! A writer or creator the target does not accept is refused, paid, with the +//! error the target reports for a property, `ReferencedEntityNotFoundError` +//! (40120) for a lookup that finds nothing, naming `$ownerId` or `$creatorId`. use super::*; @@ -264,13 +269,49 @@ mod owner_reference_tests { self.process(&transition) } + /// Transfers `document`, as last accepted, from `from`, its owner, + /// to `to`. + async fn transfer( + &mut self, + from: Who, + to: Who, + type_name: &str, + document: &Document, + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let recipient = self.id(to); + let mut transferred = document.clone(); + transferred + .increment_revision() + .expect("the revision increments"); + let (document_type, writer, _) = self.parts(from, type_name); + let nonce = writer.next_nonce(); + let transition = BatchTransition::new_document_transfer_transition_from_document( + transferred, + document_type, + recipient, + &writer.key, + nonce, + 0, + None, + &writer.signer, + platform_version, + None, + ) + .await + .expect("expected the transfer transition"); + self.process(&transition) + } + /// Runs the document reference validation directly on `data`, a - /// `type_name` document written by `who`, as a create, or as a replace + /// `type_name` document written by `who` and created by `creator` (on + /// a type that records creators), as a create, or as a replace /// changing `changed_fields`, and returns the result with the execution /// context, whose operations are the reads it billed. fn validate_directly( &self, who: Who, + creator: Option, type_name: &str, data: BTreeMap, changed_fields: Option>, @@ -313,8 +354,7 @@ mod owner_reference_tests { .validate_document_references( &data, self.id(who), - // The fixture's types record no creator ids - None, + creator.map(|creator| self.id(creator)), changed_fields.as_ref(), None, &platform_ref, @@ -369,18 +409,28 @@ mod owner_reference_tests { /// The refusal of a writer the owner reference's lookup found no /// moderator for, naming `$ownerId` and the writer. fn assert_writer_not_found(result: StateTransitionExecutionResult, writer: Identifier) { + assert_lookup_not_found(result, "$ownerId", writer); + } + + /// The refusal of an identity, at `path`, the reference's lookup found no + /// moderator for. + fn assert_lookup_not_found( + result: StateTransitionExecutionResult, + path: &str, + identity: Identifier, + ) { assert_matches!( result, PaidConsensusError { error: ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(e)), .. - } if e.path() == "$ownerId" - && *e.entity_id() == writer + } if e.path() == path + && *e.entity_id() == identity && matches!( e.entity_type(), DocumentPropertyReferenceTarget::PermanentDocumentLookup { .. } ), - "expected 40120 at $ownerId" + "expected 40120 at {path}" ); } @@ -480,6 +530,7 @@ mod owner_reference_tests { let (result, execution_context) = fixture.validate_directly( Who::Member, + None, "resignationRequest", request(charter_id(1)), Some(BTreeSet::from(["reason".to_string()])), @@ -489,6 +540,7 @@ mod owner_reference_tests { let (result, execution_context) = fixture.validate_directly( Who::Member, + None, "resignationRequest", request(charter_id(1)), Some(BTreeSet::from(["electedCharterId".to_string()])), @@ -564,6 +616,7 @@ mod owner_reference_tests { for changed_fields in [None, Some(BTreeSet::from(["text".to_string()]))] { let (result, execution_context) = fixture.validate_directly( Who::Stranger, + None, "note", BTreeMap::from([("text".to_string(), Value::Text("hello".to_string()))]), changed_fields, @@ -572,4 +625,113 @@ mod owner_reference_tests { assert!(execution_context.operations_slice().is_empty()); } } + + /// A badge by `who` for the elected charter `charter`. + async fn mint_badge( + fixture: &mut OwnerReferenceFixture, + who: Who, + charter: Identifier, + ) -> (Document, StateTransitionExecutionResult) { + fixture + .create( + who, + "moderatorBadge", + &[ + ("electedCharterId", id_value(charter)), + ("label", "seated".into()), + ], + ) + .await + } + + #[tokio::test] + async fn should_create_a_document_whose_creator_meets_the_creator_reference() { + let mut fixture = OwnerReferenceFixture::new(); + fixture.seat(Who::Member, charter_id(1), "chair").await; + let stranger = fixture.id(Who::Stranger); + + let (_, result) = mint_badge(&mut fixture, Who::Member, charter_id(1)).await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let (_, result) = mint_badge(&mut fixture, Who::Stranger, charter_id(1)).await; + assert_lookup_not_found(result, "$creatorId", stranger); + } + + /// After a transfer the new owner writes, but the value checked is still + /// the creator: a replace moving a key part is judged against the member + /// who minted the badge, not the stranger who holds it. + #[tokio::test] + async fn should_check_the_creator_not_the_owner_after_a_transfer() { + let mut fixture = OwnerReferenceFixture::new(); + fixture.seat(Who::Member, charter_id(1), "chair").await; + fixture.seat(Who::Stranger, charter_id(2), "chair").await; + let member = fixture.id(Who::Member); + let stranger = fixture.id(Who::Stranger); + + let (badge, result) = mint_badge(&mut fixture, Who::Member, charter_id(1)).await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + // A transfer does not change the creator, so it needs no check + let result = fixture + .transfer(Who::Member, Who::Stranger, "moderatorBadge", &badge) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let mut badge = badge; + badge.increment_revision().expect("the revision increments"); + badge.set_owner_id(stranger); + + // The new owner relabels it: nothing the lookup reads changed + let result = fixture + .replace(Who::Stranger, "moderatorBadge", &badge, |badge| { + badge.set("label", "passed on".into()); + }) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + badge.increment_revision().expect("the revision increments"); + badge.set("label", "passed on".into()); + + // Moving it to charter 2, where the stranger is seated but the creator + // is not, is refused for the creator + let result = fixture + .replace(Who::Stranger, "moderatorBadge", &badge, |badge| { + badge.set("electedCharterId", id_value(charter_id(2))); + }) + .await; + assert_lookup_not_found(result, "$creatorId", member); + } + + #[tokio::test] + async fn should_read_nothing_for_an_identity_creator_reference() { + let fixture = OwnerReferenceFixture::new(); + + // The creator existed when it wrote the document, and an identity is + // never removed: nothing is read on a create or on a replace by + // another owner + for (writer, changed_fields) in [ + (Who::Stranger, None), + (Who::Member, Some(BTreeSet::from(["text".to_string()]))), + ] { + let (result, execution_context) = fixture.validate_directly( + writer, + Some(Who::Stranger), + "creatorNote", + BTreeMap::from([("text".to_string(), Value::Text("hello".to_string()))]), + changed_fields, + ); + assert!(result.is_valid(), "{:?}", result.errors); + assert!(execution_context.operations_slice().is_empty()); + } + } } 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 d9abae3392c..e2aa233c6ee 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 @@ -63,14 +63,15 @@ fn same_value_kind(a: &DocumentPropertyType, b: &DocumentPropertyType) -> bool { /// referenced document type. `identityPublicKey` never reaches here on /// elements: the parser refuses it there. /// -/// A document type's `ownerRefersTo` declaration, whose value is the writer, -/// is checked as a single identifier reference's is, first; the parser only -/// admits an `identity` or a `permanentDocument` lookup one. +/// A document type's `ownerRefersTo` or `creatorRefersTo` declaration, whose +/// value is the writer or the creator, is checked as a single identifier +/// reference's is, first; the parser only admits an `identity` or a +/// `permanentDocument` lookup one. /// /// The error paths name the failing declaration as /// `documentTypeName.propertyPath`, an element declaration by its list -/// path, `documentTypeName.propertyPath[]`, and the owner reference as -/// `documentTypeName.$ownerId`. Validation stops at the first invalid +/// path, `documentTypeName.propertyPath[]`, and the owner and creator +/// references as `documentTypeName.$ownerId` and `documentTypeName.$creatorId`. Validation stops at the first invalid /// declaration: this bounds the billed work an invalid contract can cause and /// matches document write-time reference validation. Foreign contract /// resolutions are memoized per contract id, so a contract declaring many @@ -89,13 +90,14 @@ pub(super) fn validate_data_contract_references_v0( BTreeMap::new(); for (declaring_type_name, document_type) in contract.document_types() { - // The writer's reference first (`ownerRefersTo`, whose value is the - // document's `$ownerId` and which is named by that path), then the - // properties' own. The owner reference is never a key reference: the - // parser only admits an identity or a permanentDocument lookup there. - // Inert before protocol version 14: this module is only called from - // contract create and update state validation 1, selected from it, and - // no parse before it sets an owner reference. + // The writer's or the creator's reference first (`ownerRefersTo` or + // `creatorRefersTo`, whose value is the document's `$ownerId` or + // `$creatorId` and which is named by that path), then the properties' + // own. Neither is ever a key reference: the parser only admits an + // identity or a permanentDocument lookup there. Inert before protocol + // version 14: this module is only called from contract create and + // update state validation 1, selected from it, and no parse before it + // sets an owner or creator reference. for (holder, reference) in document_type.as_ref().reference_declarations() { let path = holder.path(); let is_owner_reference = matches!(holder, ReferenceHolder::Owner); 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 f7661c4187d..059d995742d 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 @@ -5707,7 +5707,8 @@ mod tests { // `ownerRefersTo` on three types: a lookup into a permanent type of // the same contract, the same with a propertyAgreement whose // referring side is the writer (`$ownerId`, the reference's own - // value), and an identity target + // value), and an identity target; `creatorRefersTo` on two + // transferable types, a lookup and an identity target let result = run_contract_create( "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json", ) @@ -5737,6 +5738,25 @@ mod tests { ); } + #[tokio::test] + async fn should_reject_a_creator_reference_to_an_unknown_document_type_at_its_creator_path() + { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-refers-to-registration-unknown-type.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedDocumentTypeNotFoundError(e) + ), + .. + } if e.path() == "note.$creatorId" && e.document_type_name() == "ghost" + ); + } + #[tokio::test] async fn should_reject_an_owner_reference_to_a_deletable_type_at_its_owner_path() { // A permanentDocument lookup into a type of the same contract that diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-refers-to-registration-unknown-type.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-refers-to-registration-unknown-type.json new file mode 100644 index 00000000000..eab2680a23b --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-refers-to-registration-unknown-type.json @@ -0,0 +1,31 @@ +{ + "$formatVersion": "1", + "id": "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "note": { + "type": "object", + "transferable": 1, + "creatorRefersTo": { + "type": "permanentDocument", + "documentType": "ghost", + "lookup": { + "index": "byMember", + "keys": { + "memberId": "." + } + } + }, + "properties": { + "content": { + "type": "string", + "position": 0, + "maxLength": 100 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json index dae3c0a28b7..7085e164806 100644 --- a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json @@ -140,6 +140,59 @@ "text" ], "additionalProperties": false + }, + "moderatorBadge": { + "type": "object", + "transferable": 1, + "creatorRefersTo": { + "type": "permanentDocument", + "documentType": "addedModerator", + "lookup": { + "index": "byElectedCharterMember", + "keys": { + "electedCharterId": "electedCharterId", + "memberId": "." + } + } + }, + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "label": { + "type": "string", + "position": 1, + "maxLength": 63 + } + }, + "required": [ + "electedCharterId", + "label" + ], + "additionalProperties": false + }, + "creatorNote": { + "type": "object", + "transferable": 1, + "creatorRefersTo": { + "type": "identity" + }, + "properties": { + "text": { + "type": "string", + "position": 0, + "maxLength": 63 + } + }, + "required": [ + "text" + ], + "additionalProperties": false } } } diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 994b28dab0f..7dc7332f66e 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -759,11 +759,12 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// by-id joins refuse a lookup reference as a join property, and /// preallocated indexes are never bound through one. /// -/// 33. **References on the document's writer (`ownerRefersTo`)**: a document -/// type may declare one `refersTo` declaration of its own, under the -/// doctype-level `ownerRefersTo` keyword (meta-schema v3, which reuses the -/// property declaration by `$ref`), whose value is the document's -/// `$ownerId`, the writer, instead of a property's. Only two targets can +/// 33. **References on the document's writer or creator (`ownerRefersTo`, +/// `creatorRefersTo`)**: a document type may declare one `refersTo` +/// declaration of its own, under the doctype-level `ownerRefersTo` +/// keyword (meta-schema v3, which reuses the property declaration by +/// `$ref`), whose value is the document's `$ownerId`, the writer, instead +/// of a property's. Only two targets can /// hold a writer: `identity`, and a `permanentDocument` found through a /// `lookup`, where `.` is the writer; `contract`, `token` and a document /// by id (which the writer's identity id never is) and `identityPublicKey` @@ -795,7 +796,18 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// target fetches nothing, the transition having proved the writer exists. /// Adding, removing or changing it is an incompatible schema change on /// update (`validate_schema_compatibility` 1 freezes it as the shared rule -/// set freezes `refersTo`). +/// set freezes `refersTo`). Its counterpart for a type whose documents can +/// be transferred or traded is `creatorRefersTo`, whose value is the +/// document's `$creatorId`, the creator, which never changes: the same +/// two targets (`.` the creator), only on a type that records creator ids +/// (`should_use_creator_id`: a transferable or tradeable type of a +/// format-1 contract), so a type declares at most one of the two; stored +/// as `DocumentTypeV2::creator_reference`, enumerated second by +/// `reference_declarations`, named `$creatorId` (and +/// `.$creatorId` at registration), checked against the +/// writer on a create and the stored creator on a replace under the same +/// rules, never on a transfer or a purchase, and frozen on update the same +/// way. /// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by diff --git a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index a4fb4772af8..1eec6306718 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -232,7 +232,10 @@ export type DocumentPropertyReference = { * or `propertyAgreement` reads; in its `lookup`, `'.'` is the writer. Its * `type` is `identity` or a `permanentDocument` with a `lookup`, the only * targets a writer can be, on a document type whose documents can be - * neither transferred nor traded. + * neither transferred nor traded. On a type whose documents can, a + * `creatorRefersTo` declaration takes its place, listed first with the + * path `"$creatorId"`: the same, with the document's creator, who never + * changes, as the value. * * This is the same string consensus reports in the `path` field of the * document-write reference errors (codes 40120-40125, 40131, 40135 and @@ -499,8 +502,9 @@ fn set_reference_target_fields( } /// Collect every reference declaration of one document type: its -/// `ownerRefersTo` first, listed at `$ownerId`, the path consensus names it -/// by, then in schema property order an identifier property's own, and the +/// `ownerRefersTo` or `creatorRefersTo` first, listed at `$ownerId` or +/// `$creatorId`, the path consensus names it by, then in schema property +/// order an identifier property's own, and the /// one the elements of a typed array of identifiers carry, listed at /// `path[]`. /// diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index d5c7a77252f..cf38c4d533b 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -699,8 +699,8 @@ impl DataContractWasm { } /// All `refersTo` declarations of one document type: its `ownerRefersTo` - /// first, at the path `$ownerId`, then the properties' own in schema - /// property order. + /// or `creatorRefersTo` first, at the path `$ownerId` or `$creatorId`, + /// then the properties' own in schema property order. /// /// Returns an empty array when the document type declares none. Throws /// when the contract has no document type by that name — an empty array diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index f5d53edddaf..b587e50aaa1 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -452,7 +452,7 @@ describe('DataContract — refersTo declarations (v14)', () => { }); }); - describe('ownerRefersTo', () => { + describe('ownerRefersTo and creatorRefersTo', () => { /** * A `resignation` may only be written by the owner of a join request for * its own `submittedCharterId`: the document type's own reference, whose @@ -543,6 +543,30 @@ describe('DataContract — refersTo declarations (v14)', () => { } }); + it('should list a creator reference first, at the path $creatorId', () => { + const { ownerRefersTo, ...rest } = ownerSchemas.resignation; + const badge = { ...rest, transferable: 1, creatorRefersTo: ownerRefersTo }; + const contract = buildOwnerContract(badge); + const references = contract.documentTypeReferences('resignation') as Reference[]; + + expect(references.map((reference) => reference.path)).to.deep.equal([ + '$creatorId', + 'author', + ]); + expect(references[0].type).to.equal('permanentDocument'); + expect(references[0].lookup).to.deep.equal({ + index: 'bySubmittedCharter', + keys: { $ownerId: '.', submittedCharterId: 'submittedCharterId' }, + }); + }); + + it('should refuse a creator reference on a type that records no creator ids', () => { + const { ownerRefersTo, ...rest } = ownerSchemas.resignation; + const notTransferable = { ...rest, creatorRefersTo: ownerRefersTo }; + + expect(() => buildOwnerContract(notTransferable)).to.throw(/records no creator ids/); + }); + it('should report no owner reference on a pre-v14 contract', () => { const contract = buildOwnerContract(ownerSchemas.resignation, 13, false); From d2120f2bdf01551be3dee34e67d49c55789e4c27 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 23 Sep 2026 20:57:13 +0700 Subject: [PATCH 4/4] test(platform): ownerRefersTo and creatorRefersTo composed with anyOf and allOf An anyOf of two lookups on the owner and an allOf on the creator parse, and a leaf by id is refused at its expression path; through the full pipeline a stepDownNotice writer is admitted through either operand of an anyOf (added moderator or founder seat) and anyone else is refused with the last operand's error at $ownerId. Co-Authored-By: Claude Opus 5.5 --- .../v3/owner_reference_tests.rs | 53 ++++++++++++ .../batch/tests/document/owner_reference.rs | 67 +++++++++++++++ ...e-validation-contract-owner-refers-to.json | 85 +++++++++++++++++++ 3 files changed, 205 insertions(+) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs index 2ec63d2d24f..99855c7ae4c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/owner_reference_tests.rs @@ -743,3 +743,56 @@ fn should_round_trip_a_creator_reference_and_refuse_changing_it_on_update() { ); } } + +/// With `anyOf` / `allOf` the writer or the creator may meet one of several +/// targets: an expression whose every leaf can hold that identity is admitted, +/// and a leaf by id is refused as it is alone, named by where it sits. +#[test] +fn should_parse_an_owner_or_creator_reference_expression_of_identity_capable_leaves() { + let owner_lookup = permanent_added_moderator(json!({ + "index": "byOwnerMember", + "keys": { "$ownerId": "$ownerId", "memberId": "." } + })); + let expression = json!({ + "anyOf": [permanent_added_moderator(added_moderator_lookup()), owner_lookup] + }); + for full_validation in [true, false] { + let parsed = contract_on( + charter_contract(expression.clone()), + full_validation, + PlatformVersion::latest(), + ) + .expect("an expression of lookups should parse"); + let target = owner_reference(&parsed).expect("the owner reference"); + assert!(matches!(target, DocumentPropertyReferenceTarget::AnyOf(_))); + assert_eq!(target.leaves().len(), 2); + } + let creator_expression = json!({ + "allOf": [ + { "type": "identity" }, + permanent_added_moderator(added_moderator_lookup()) + ] + }); + let parsed = contract(creator_contract(creator_expression)).expect("parses"); + assert!(matches!( + creator_reference(&parsed), + Some(DocumentPropertyReferenceTarget::AllOf(_)) + )); + + let with_leaf_by_id = json!({ + "anyOf": [ + permanent_added_moderator(added_moderator_lookup()), + { "type": "permanentDocument", "documentType": "addedModerator" } + ] + }); + for full_validation in [true, false] { + assert_refused( + contract_on( + charter_contract(with_leaf_by_id.clone()), + full_validation, + PlatformVersion::latest(), + ), + "ownerRefersTo anyOf[1] takes a permanentDocument reference only with a lookup", + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs index 438143fa33e..a09a0378232 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/owner_reference.rs @@ -8,6 +8,8 @@ //! transferred or traded, so the writer stays the owner. `roleResignation` //! adds a `propertyAgreement` checked against the moderator the lookup finds, //! and `note` declares an identity target, which every writer meets. +//! `stepDownNotice` composes with `anyOf`: its writer is an added moderator +//! or the charter's founder (`founderSeat`). //! `moderatorBadge` can be transferred, so it declares `creatorRefersTo` //! instead: only a seated moderator may mint one, and whoever holds it later, //! the check is against that creator. `creatorNote` declares an identity @@ -734,4 +736,69 @@ mod owner_reference_tests { assert!(execution_context.operations_slice().is_empty()); } } + + /// `anyOf` on the writer: either operand admits the writer, checked in + /// declared order, and a writer neither admits is refused with the last + /// operand's error, at `$ownerId`. + #[tokio::test] + async fn should_admit_a_writer_meeting_any_operand_of_an_owner_reference_expression() { + let mut fixture = OwnerReferenceFixture::new(); + fixture.seat(Who::Member, charter_id(1), "chair").await; + let stranger = fixture.id(Who::Stranger); + let founder = fixture.id(Who::Founder); + let (_, result) = fixture + .create( + Who::Founder, + "founderSeat", + &[ + ("electedCharterId", id_value(charter_id(1))), + ("founderId", id_value(stranger)), + ], + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + // Through the first operand, an added moderator, and through the + // second, the founder's seat + for who in [Who::Member, Who::Stranger] { + let (_, result) = fixture + .create( + who, + "stepDownNotice", + &[("electedCharterId", id_value(charter_id(1)))], + ) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + // The founder of the contract holds neither seat + let (_, result) = fixture + .create( + Who::Founder, + "stepDownNotice", + &[("electedCharterId", id_value(charter_id(1)))], + ) + .await; + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedEntityNotFoundError(e)), + .. + } if e.path() == "$ownerId" + && *e.entity_id() == founder + && matches!( + e.entity_type(), + DocumentPropertyReferenceTarget::PermanentDocumentLookup { + document_type_name, .. + } if document_type_name == "founderSeat" + ), + "expected the last operand's 40120 at $ownerId" + ); + } } diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json index 7085e164806..40bfc01f867 100644 --- a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-refers-to.json @@ -193,6 +193,91 @@ "text" ], "additionalProperties": false + }, + "founderSeat": { + "type": "object", + "canBeDeleted": false, + "documentsMutable": false, + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "founderId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 1 + } + }, + "indices": [ + { + "name": "byElectedCharterFounder", + "properties": [ + { + "electedCharterId": "asc" + }, + { + "founderId": "asc" + } + ], + "unique": true + } + ], + "required": [ + "electedCharterId", + "founderId" + ], + "additionalProperties": false + }, + "stepDownNotice": { + "type": "object", + "ownerRefersTo": { + "anyOf": [ + { + "type": "permanentDocument", + "documentType": "addedModerator", + "lookup": { + "index": "byElectedCharterMember", + "keys": { + "electedCharterId": "electedCharterId", + "memberId": "." + } + } + }, + { + "type": "permanentDocument", + "documentType": "founderSeat", + "lookup": { + "index": "byElectedCharterFounder", + "keys": { + "electedCharterId": "electedCharterId", + "founderId": "." + } + } + } + ] + }, + "properties": { + "electedCharterId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + } + }, + "required": [ + "electedCharterId" + ], + "additionalProperties": false } } }