diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 2ab6ecbf524..bed07b9ae01 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -268,6 +268,25 @@ pub const INITIAL_REVISION: u64 = 1; Revision 0 is never used for active documents. This allows `0` to serve as a sentinel value meaning "no revision" in some contexts. +## Document References (`refersTo`) + +From protocol version 14 a property of a document type can declare what it points at, and consensus refuses a create or replace whose target does not exist when the document is written (the reference is a write-time constraint only; nothing resolves it for a reader). The keyword is `refersTo` on the property, its `type` one of `identity`, `contract` (optionally with `contractRequirements`, see [Contract Moderation](contract-moderation.md)), `token`, `permanentDocument`, `deletableDocument` and `identityPublicKey`. Every form sits on an identifier property, with one exception below. The parsed shape is `DocumentPropertyType::IdentifierWithReference(target)`, and any change to a declaration on contract update is an incompatible schema change. + +An `identityPublicKey` reference names one key of one identity, and comes in two forms that differ in which property carries what: + +- **On the identity property.** The identifier property carries the identity id and `keyIdProperty` names the sibling integer property carrying the key id: `"refersTo": { "type": "identityPublicKey", "keyIdProperty": "toKeyIndex" }`. Consensus fetches the named key of that identity. +- **On the key id property.** The integer property carries the key id and `identityProperty` names whose key it is: `"refersTo": { "type": "identityPublicKey", "identityProperty": "$ownerId" }`. The property must declare exactly the range of a `KeyID` (`"type": "integer", "minimum": 0, "maximum": 4294967295`) and the declaration takes no `keyIdProperty`. `identityProperty` is `$ownerId` (the writer), `$creatorId` (the document's creator, only on a document type that records creator ids: a transferable or tradeable type of a format-1 contract) or the path of an identifier property of the same document type (which must exist, be an identifier and not carry an `identityPublicKey` reference of its own); the last two are checked at contract registration. `keyRequirements` sit on this form exactly as on the identifier form. The parsed shape is `DocumentPropertyType::KeyIdWithReference(KeyIdReference)`, the identity source plus the requirements, sized, encoded and queried exactly as a plain `u32`. + +```json +"senderKeyId": { + "type": "integer", "minimum": 0, "maximum": 4294967295, + "refersTo": { "type": "identityPublicKey", "identityProperty": "$ownerId" }, + "position": 3 +} +``` + +Both forms share the state check (`validate_referenced_identity_key_v0` in the document reference validation): the key must exist and not be disabled, else the write is refused, paid, with `ReferencedIdentityKeyNotFoundError` (40123) or `ReferencedIdentityKeyDisabledError` (40124). Identity keys can be disabled but never removed, so a validated reference never dangles. The owner form's identity is the transition's signer, which the transition already proved exists, so the key fetch is its only read; a key id of some other identity's key is meaningless by construction, there is no property to name another identity. On replace the identity form is re-validated when either the identity property or its key id property changed. For the key id form it depends on where the identity comes from. `$ownerId` is the writer, transition metadata that never appears among the changed fields, and the document may have changed hands since the key id was written, so the reference is re-validated on every replace, touched or not, as the `$ownerId` writer gate is: after a transfer the new owner has to repoint the key id at one of its own keys. `$creatorId` never changes, so it is re-validated when the key id changed. A property path is re-validated when the key id or that property changed, and a key id set while the property is not is refused (`ReferencedKeyIdPropertyInvalidError`, 40125). A transfer itself is not checked in any form, so the reference governs writing, not holding. The declaring property must carry exactly the key id range in its schema, whatever the contract's integer sizing setting, and a `keyIdProperty` of the identity form may not name a property that carries this form, nor may a path name an identifier carrying an `identityPublicKey` reference: one pair is declared once (40125 at registration). The charter contract's `joinRequest.senderKeyId`, the owner's encryption key a shared secret is derived from, is the first user. + ## Immutable Properties on Mutable Document Types A document type either allows replaces (`documentsMutable: true`, the default) or freezes its documents entirely. Protocol version 14 adds a middle ground: the doctype-level `immutable` keyword lists top-level properties that are frozen at creation while the rest of the document stays replaceable. diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 23634be975c..caa195b5938 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, 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), 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), 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), 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": { @@ -209,12 +209,29 @@ "additionalProperties": false }, "keyIdProperty": { - "description": "The property of the same document type whose value carries the referenced key id; the reference property's value carries the identity id", + "description": "identityPublicKey references on an identifier property: the property of the same document type whose value carries the referenced key id; the reference property's value carries the identity id. Takes the place of identityProperty", "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" }, + "identityProperty": { + "description": "identityPublicKey references on the key id property itself, the inverse form: the declaring property is an integer from 0 to 4294967295 (a key id) whose value must be the id of a key of the named identity that exists and is not disabled when the document is written (ReferencedIdentityKeyNotFoundError, 40123; ReferencedIdentityKeyDisabledError, 40124). \"$ownerId\" names the writer, the document's owner, and \"$creatorId\" its creator (only on a document type that records creator ids, checked at contract registration), whose existence is already proven, so only the key is fetched; any other value is the path of an identifier property of the same document type (it must exist, be an identifier and not carry an identityPublicKey reference of its own, checked at registration), whose value is the identity, and a key id set while it is not is refused (ReferencedKeyIdPropertyInvalidError, 40125). Takes the place of keyIdProperty", + "anyOf": [ + { + "enum": [ + "$ownerId", + "$creatorId" + ] + }, + { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" + } + ] + }, "keyRequirements": { "description": "identityPublicKey references only: what the referenced key must be beyond existing and not being disabled, checked when the referring document is written against the key already fetched for the existence check, so a requirement costs no further read. Each key names an aspect of the referenced key and its value the requirement: purpose requires the key's purpose to be the named one (any but system); boundTo names a document type of the declaring contract and requires the key's contract bounds to be exactly the declaring contract and that document type (a whole-contract or contract group bound never meets it). At registration boundTo must name a document type the contract has, and one a key of the required purpose can be bound to: only authentication, encryption and decryption keys carry a document type bound, and an encryption or decryption key only where the type declares requiresIdentityEncryptionBoundedKey or requiresIdentityDecryptionBoundedKey. An unmet requirement refuses the write (ReferencedIdentityKeyRequirementNotMetError, 40136)", "type": "object", @@ -300,11 +317,26 @@ "required": ["type"] }, "then": { - "required": ["type", "keyIdProperty"] + "oneOf": [ + { + "required": ["type", "keyIdProperty"], + "properties": { + "identityProperty": false + } + }, + { + "required": ["type", "identityProperty"], + "properties": { + "keyIdProperty": false, + "propertyAgreement": false + } + } + ] }, "else": { "properties": { "keyIdProperty": false, + "identityProperty": false, "keyRequirements": false } } @@ -477,31 +509,58 @@ ] }, "refersTo": { - "description": "refersTo is only allowed on identifier properties", - "properties": { - "type": { - "const": "array" - }, - "byteArray": { - "const": true - }, - "contentMediaType": { - "const": "application/x.dash.dpp.identifier" - }, - "minItems": { - "const": 32 - }, - "maxItems": { - "const": 32 + "description": "refersTo is only allowed on identifier properties, except an identityPublicKey reference with identityProperty, which sits on the key id property: an integer with minimum 0 and maximum 4294967295, the range of a key id", + "if": { + "properties": { + "refersTo": { + "required": ["identityProperty"] + } } }, - "required": [ - "type", - "byteArray", - "contentMediaType", - "minItems", - "maxItems" - ] + "then": { + "properties": { + "type": { + "const": "integer" + }, + "minimum": { + "const": 0 + }, + "maximum": { + "const": 4294967295 + } + }, + "required": [ + "type", + "minimum", + "maximum" + ] + }, + "else": { + "properties": { + "type": { + "const": "array" + }, + "byteArray": { + "const": true + }, + "contentMediaType": { + "const": "application/x.dash.dpp.identifier" + }, + "minItems": { + "const": 32 + }, + "maxItems": { + "const": 32 + } + }, + "required": [ + "type", + "byteArray", + "contentMediaType", + "minItems", + "maxItems" + ] + } }, "encryptedFor": { "description": "encryptedFor is only allowed on byte array properties that are not identifiers", 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 28c3a6762d2..d475b4ddf1f 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 @@ -90,13 +90,17 @@ impl DocumentType { for (name, document_type) in &contract_document_types { for (path, property) in document_type.as_ref().flattened_properties() { - let DocumentPropertyType::IdentifierWithReference( - DocumentPropertyReferenceTarget::IdentityPublicKey { - key_requirements, .. - }, - ) = &property.property_type - else { - continue; + // Both forms of the key reference carry the same requirements + let key_requirements = match &property.property_type { + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_requirements, .. + }, + ) => key_requirements, + DocumentPropertyType::KeyIdWithReference(reference) => { + &reference.key_requirements + } + _ => continue, }; let Some(bound_to) = &key_requirements.bound_to else { continue; @@ -148,7 +152,9 @@ mod tests { 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::IdentityKeyReferenceRequirements; + use crate::data_contract::document_type::{ + IdentityKeyReferenceRequirements, KeyIdReference, KeyReferenceIdentityProperty, + }; use crate::data_contract::DataContract; use crate::identity::Purpose; use crate::serialization::{ @@ -303,6 +309,69 @@ mod tests { ); } + /// [`contract_value`] with the reference moved onto the key id property: `recipientKeyId` + /// carries `refers_to` (an `identityProperty` form) and `recipientId` is a plain identifier. + fn contract_value_with_key_id_reference(refers_to: Value) -> Value { + let mut value = contract_value(platform_value!({ "type": "identity" })); + let recipient_id = value + .get_mut_value_at_path("documentSchemas.joinRequest.properties.recipientId") + .expect("the recipientId schema"); + recipient_id + .remove("refersTo") + .expect("the identity reference removes"); + let recipient_key_id = value + .get_mut_value_at_path("documentSchemas.joinRequest.properties.recipientKeyId") + .expect("the recipientKeyId schema"); + recipient_key_id + .insert("maximum".to_string(), Value::U64(u64::from(u32::MAX))) + .expect("the maximum inserts"); + recipient_key_id + .insert("refersTo".to_string(), refers_to) + .expect("the reference inserts"); + value + } + + /// The bound check reads the key id form's requirements too. + #[test] + fn should_check_bound_to_on_the_key_id_form() { + let platform_version = PlatformVersion::latest(); + + let contract = DataContract::from_value( + contract_value_with_key_id_reference(platform_value!({ + "type": "identityPublicKey", + "identityProperty": "$ownerId", + "keyRequirements": { "purpose": "decryption", "boundTo": "submittedCharter" } + })), + true, + platform_version, + ) + .expect("the contract should parse"); + assert_eq!( + contract + .document_type_for_name("joinRequest") + .expect("the joinRequest document type") + .flattened_properties() + .get("recipientKeyId") + .map(|p| p.property_type.clone()), + Some(DocumentPropertyType::KeyIdWithReference(KeyIdReference { + identity_property: KeyReferenceIdentityProperty::OwnerId, + key_requirements: IdentityKeyReferenceRequirements { + purpose: Some(Purpose::DECRYPTION), + bound_to: Some("submittedCharter".to_string()), + }, + })) + ); + + assert_eq!( + refusal_message(contract_value_with_key_id_reference(platform_value!({ + "type": "identityPublicKey", + "identityProperty": "$ownerId", + "keyRequirements": { "purpose": "decryption", "boundTo": "electedCharter" } + }))), + "joinRequest.recipientKeyId refersTo keyRequirements boundTo \"electedCharter\" names no document type of this contract" + ); + } + #[test] fn should_reject_a_bound_to_no_key_could_ever_carry() { // Only authentication, encryption and decryption keys carry a document type bound 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 d4020d5aca3..b393086a8c0 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -9,7 +9,8 @@ use crate::data_contract::document_type::{ property_names, ContractReferenceModeration, ContractReferenceOwner, ContractReferenceRequirements, DistinctFrom, DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentPropertyTypeParsingOptions, DocumentType, EncryptedFor, - EncryptedForRecipient, EncryptionScheme, IdentityKeyReferenceRequirements, + EncryptedForRecipient, EncryptionScheme, IdentityKeyReferenceRequirements, KeyIdReference, + KeyReferenceIdentityProperty, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; @@ -526,8 +527,10 @@ fn validate_distinct_from_targets_v0( } /// Folds a `refersTo` declaration into the property type: an identifier property -/// with `refersTo` becomes `IdentifierWithReference(target)`. Non-identifier -/// properties cannot carry `refersTo`. +/// with `refersTo` becomes `IdentifierWithReference(target)`, and a `u32` key id +/// property with an `identityPublicKey` declaration naming `identityProperty` +/// becomes `KeyIdWithReference(identity property)`. No other property can carry +/// `refersTo`. /// /// Versioned on `apply_property_reference` in the platform version's document /// type schema versions. `None` selects the behavior of the versions that @@ -561,15 +564,6 @@ fn apply_property_reference_v0( return Ok(property_type); }; - if !matches!( - property_type, - DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) - ) { - return Err(DataContractError::InvalidContractStructure( - "refersTo is only allowed on identifier properties".to_string(), - )); - } - let refers_to_map = refers_to_value.to_btree_ref_string_map()?; let reference_type = refers_to_map @@ -596,6 +590,42 @@ fn apply_property_reference_v0( ))); } + // `propertyAgreement` compares against a referenced DOCUMENT's values; + // no other target kind has a document body to agree with + if refers_to_map.contains_key(property_names::PROPERTY_AGREEMENT) + && !matches!(reference_type, "permanentDocument" | "deletableDocument") + { + return Err(DataContractError::InvalidContractStructure( + "propertyAgreement is only allowed on permanentDocument and deletableDocument \ + references" + .to_string(), + )); + } + + // A key reference declared on the key id property itself names whose key + // it is through `identityProperty`; it is the one form that sits on a + // non-identifier property + if let Some(identity_property_value) = refers_to_map.get(property_names::IDENTITY_PROPERTY) { + return apply_key_id_reference_v0( + inner_properties, + &refers_to_map, + reference_type, + identity_property_value, + property_type, + ); + } + + if !matches!( + property_type, + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) + ) { + return Err(DataContractError::InvalidContractStructure( + "refersTo is only allowed on identifier properties, except an identityPublicKey \ + reference with identityProperty, which sits on the key id property" + .to_string(), + )); + } + let target = match reference_type { "identity" => DocumentPropertyReferenceTarget::Identity, "contract" => DocumentPropertyReferenceTarget::Contract { @@ -727,19 +757,63 @@ fn apply_property_reference_v0( } }; - // `propertyAgreement` compares against a referenced DOCUMENT's values — - // no other target kind has a document body to agree with. - if refers_to_map.contains_key(property_names::PROPERTY_AGREEMENT) - && target.as_document_reference().is_none() - { + Ok(DocumentPropertyType::IdentifierWithReference(target)) +} + +/// An `identityPublicKey` declaration on the KEY ID property: `identityProperty` names +/// whose key the value is (`$ownerId`, `$creatorId` or an identifier property of the +/// same document type), so the declaration takes no `keyIdProperty` and sits on an +/// integer property declaring exactly the range of a key id (`minimum` 0, `maximum` +/// 4294967295), the bounds the meta-schema pins, read from the schema rather than the +/// inferred type so that the rule does not depend on the contract's sized integer +/// types setting. Whether a named property or `$creatorId` fits the document type is +/// checked at contract registration. +fn apply_key_id_reference_v0( + inner_properties: &BTreeMap, + refers_to_map: &BTreeMap, + reference_type: &str, + identity_property_value: &Value, + property_type: DocumentPropertyType, +) -> Result { + if reference_type != "identityPublicKey" { + return Err(DataContractError::InvalidContractStructure(format!( + "{reference_type} refersTo does not take identityProperty" + ))); + } + if refers_to_map.contains_key(property_names::KEY_ID_PROPERTY) { return Err(DataContractError::InvalidContractStructure( - "propertyAgreement is only allowed on permanentDocument and deletableDocument \ - references" + "identityPublicKey refersTo takes either keyIdProperty, on the identity \ + property, or identityProperty, on the key id property, not both" .to_string(), )); } - - Ok(DocumentPropertyType::IdentifierWithReference(target)) + let identity_property_name = identity_property_value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "identityPublicKey refersTo identityProperty must be a string".to_string(), + ) + })?; + let identity_property = KeyReferenceIdentityProperty::from_wire_name(identity_property_name) + .ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "identityPublicKey refersTo identityProperty {identity_property_name:?} is \ + invalid, expected one of {:?} or a property path of 1 to 256 characters", + KeyReferenceIdentityProperty::SYSTEM_WIRE_NAMES + )) + })?; + let minimum = inner_properties.get_optional_integer::(property_names::MINIMUM)?; + let maximum = inner_properties.get_optional_integer::(property_names::MAXIMUM)?; + let is_key_id_range = minimum == Some(0) && maximum == Some(i64::from(u32::MAX)); + if !property_type.is_integer() || !is_key_id_range { + return Err(DataContractError::InvalidContractStructure( + "identityPublicKey refersTo with identityProperty is only allowed on a key id \ + property: an integer with minimum 0 and maximum 4294967295" + .to_string(), + )); + } + Ok(DocumentPropertyType::KeyIdWithReference(KeyIdReference { + identity_property, + key_requirements: parse_identity_key_reference_requirements(refers_to_map)?, + })) } /// Reads a property's `encryptedFor` declaration: how the bytes of a byte @@ -2458,6 +2532,346 @@ mod tests { .expect_err("should fail"); } + // ================================================================ + // identityPublicKey on the key id property (identityProperty) + // ================================================================ + + /// A `message` document type whose `senderKeyId` carries `refers_to`, with + /// the property's own keywords under `key_id_schema` (the `u32` key id + /// range by default). + fn key_id_reference_schema( + key_id_schema: serde_json::Value, + refers_to: serde_json::Value, + ) -> serde_json::Value { + let mut sender_key_id = key_id_schema; + sender_key_id["position"] = json!(0); + sender_key_id["refersTo"] = refers_to; + json!({ + "type": "object", + "properties": { + "senderKeyId": sender_key_id, + "note": { "type": "string", "maxLength": 64, "position": 1 } + }, + "required": [], + "additionalProperties": false + }) + } + + fn u32_key_id_schema() -> serde_json::Value { + json!({ "type": "integer", "minimum": 0, "maximum": 4294967295u64 }) + } + + fn owner_key_refers_to() -> serde_json::Value { + json!({ "type": "identityPublicKey", "identityProperty": "$ownerId" }) + } + + fn sender_key_id_type(document_type: &DocumentType) -> DocumentPropertyType { + document_type + .as_ref() + .flattened_properties() + .get("senderKeyId") + .map(|p| p.property_type.clone()) + .expect("property should be present") + } + + #[test] + fn should_parse_identity_public_key_refers_to_on_the_key_id_property() { + // With and without the meta-schema: the v3 meta-schema admits the form + for document_type in [ + try_document_type_from_schema(key_id_reference_schema( + u32_key_id_schema(), + owner_key_refers_to(), + )) + .expect("should parse"), + try_document_type_from_schema_full_validation(key_id_reference_schema( + u32_key_id_schema(), + owner_key_refers_to(), + )) + .expect("should parse under the meta-schema"), + ] { + assert_eq!( + sender_key_id_type(&document_type), + DocumentPropertyType::KeyIdWithReference(KeyIdReference::new( + KeyReferenceIdentityProperty::OwnerId + )) + ); + // A key id property is still an integer to everything that asks + assert!(sender_key_id_type(&document_type).is_integer()); + } + } + + /// The form sits on a nested property too, under the meta-schema and in + /// the flattened properties consensus walks, at its dotted path. + #[test] + fn should_parse_identity_public_key_refers_to_on_a_nested_key_id_property() { + let mut sender_key_id = u32_key_id_schema(); + sender_key_id["position"] = json!(0); + sender_key_id["refersTo"] = owner_key_refers_to(); + let schema = json!({ + "type": "object", + "properties": { + "meta": { + "type": "object", + "position": 0, + "properties": { "senderKeyId": sender_key_id }, + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + }); + + let document_type = try_document_type_from_schema_full_validation(schema) + .expect("should parse under the meta-schema"); + + assert_eq!( + document_type + .as_ref() + .flattened_properties() + .get("meta.senderKeyId") + .map(|p| p.property_type.clone()), + Some(DocumentPropertyType::KeyIdWithReference( + KeyIdReference::new(KeyReferenceIdentityProperty::OwnerId) + )) + ); + } + + /// `keyRequirements` sit on the key id form exactly as on the identifier + /// form. + #[test] + fn should_parse_key_requirements_on_the_key_id_form() { + let schema = key_id_reference_schema( + u32_key_id_schema(), + json!({ + "type": "identityPublicKey", + "identityProperty": "$ownerId", + "keyRequirements": { "purpose": "encryption" } + }), + ); + for document_type in [ + try_document_type_from_schema(schema.clone()).expect("should parse"), + try_document_type_from_schema_full_validation(schema) + .expect("should parse under the meta-schema"), + ] { + assert_eq!( + sender_key_id_type(&document_type), + DocumentPropertyType::KeyIdWithReference(KeyIdReference { + identity_property: KeyReferenceIdentityProperty::OwnerId, + key_requirements: IdentityKeyReferenceRequirements { + purpose: Some(Purpose::ENCRYPTION), + bound_to: None, + }, + }) + ); + } + } + + #[test] + fn should_reject_identity_property_on_an_identifier_property() { + let schema = key_id_reference_schema( + json!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier" + }), + owner_key_refers_to(), + ); + let err = try_document_type_from_schema(schema.clone()).expect_err("should fail"); + assert!( + err.to_string() + .contains("only allowed on a key id property"), + "unexpected error: {err}" + ); + try_document_type_from_schema_full_validation(schema) + .expect_err("the meta-schema should refuse the form on an identifier"); + } + + #[test] + fn should_reject_identity_property_on_an_integer_without_the_u32_range() { + for key_id_schema in [ + // Narrower than a key id + json!({ "type": "integer", "minimum": 0, "maximum": 255 }), + // Wider than a key id + json!({ "type": "integer", "minimum": 0 }), + json!({ "type": "integer", "minimum": -1, "maximum": 4294967295u64 }), + // Narrower at the bottom: the rule is the exact range, as the + // meta-schema pins it, not the inferred u32 + json!({ "type": "integer", "minimum": 1, "maximum": 4294967295u64 }), + // Not an integer at all + json!({ "type": "string", "maxLength": 10 }), + ] { + let schema = key_id_reference_schema(key_id_schema.clone(), owner_key_refers_to()); + let err = try_document_type_from_schema(schema.clone()).expect_err("should fail"); + assert!( + err.to_string() + .contains("only allowed on a key id property"), + "{key_id_schema}: unexpected error: {err}" + ); + try_document_type_from_schema_full_validation(schema) + .expect_err("the meta-schema should refuse a property outside the u32 range"); + } + } + + #[test] + fn should_reject_identity_property_together_with_key_id_property() { + let schema = key_id_reference_schema( + u32_key_id_schema(), + json!({ + "type": "identityPublicKey", + "identityProperty": "$ownerId", + "keyIdProperty": "senderKeyId" + }), + ); + let err = try_document_type_from_schema(schema.clone()).expect_err("should fail"); + assert!( + err.to_string().contains("not both"), + "unexpected error: {err}" + ); + try_document_type_from_schema_full_validation(schema) + .expect_err("the meta-schema should refuse both keys on one declaration"); + } + + /// `identityProperty` admits `$creatorId` and a property path beside + /// `$ownerId`; whether the path or the creator fits the document type is + /// a registration check, not a parse + #[test] + fn should_parse_identity_public_key_refers_to_naming_the_creator_or_a_property() { + for (identity_property, expected) in [ + ("$creatorId", KeyReferenceIdentityProperty::CreatorId), + ( + "note", + KeyReferenceIdentityProperty::Property("note".to_string()), + ), + ( + "meta.toUserId", + KeyReferenceIdentityProperty::Property("meta.toUserId".to_string()), + ), + ] { + let schema = key_id_reference_schema( + u32_key_id_schema(), + json!({ "type": "identityPublicKey", "identityProperty": identity_property }), + ); + for document_type in [ + try_document_type_from_schema(schema.clone()).expect("should parse"), + try_document_type_from_schema_full_validation(schema) + .expect("should parse under the meta-schema"), + ] { + assert_eq!( + sender_key_id_type(&document_type), + DocumentPropertyType::KeyIdWithReference(KeyIdReference::new(expected.clone())), + "{identity_property}" + ); + } + } + } + + #[test] + fn should_reject_identity_property_values_that_are_neither_a_system_name_nor_a_path() { + for identity_property in [ + json!("$id"), + json!("$creatorId2"), + json!(""), + json!("a".repeat(257)), + json!("with-dash"), + json!(5), + ] { + let schema = key_id_reference_schema( + u32_key_id_schema(), + json!({ "type": "identityPublicKey", "identityProperty": identity_property }), + ); + // A hyphenated path is the meta-schema's refusal alone: the parser + // reads stored contracts as they are + if identity_property != json!("with-dash") { + let err = try_document_type_from_schema(schema.clone()).expect_err("should fail"); + assert!( + err.to_string().contains("identityProperty"), + "{identity_property}: unexpected error: {err}" + ); + } + try_document_type_from_schema_full_validation(schema) + .expect_err("the meta-schema should refuse the value"); + } + } + + #[test] + fn should_reject_identity_property_on_other_reference_types() { + for reference_type in ["identity", "contract", "token", "deletableDocument"] { + let schema = key_id_reference_schema( + u32_key_id_schema(), + json!({ + "type": reference_type, + "documentType": "note", + "identityProperty": "$ownerId" + }), + ); + let err = try_document_type_from_schema(schema.clone()).expect_err("should fail"); + assert!( + err.to_string().contains(&format!( + "{reference_type} refersTo does not take identityProperty" + )), + "{reference_type}: unexpected error: {err}" + ); + try_document_type_from_schema_full_validation(schema) + .expect_err("the meta-schema should refuse identityProperty on other types"); + } + } + + #[test] + fn should_reject_property_agreement_on_the_key_id_form() { + let schema = key_id_reference_schema( + u32_key_id_schema(), + json!({ + "type": "identityPublicKey", + "identityProperty": "$ownerId", + "propertyAgreement": { "note": "note" } + }), + ); + let err = try_document_type_from_schema(schema.clone()).expect_err("should fail"); + assert!( + err.to_string() + .contains("propertyAgreement is only allowed"), + "unexpected error: {err}" + ); + try_document_type_from_schema_full_validation(schema) + .expect_err("the meta-schema should refuse propertyAgreement here"); + } + + #[test] + fn should_ignore_the_key_id_form_on_platform_versions_predating_it() { + // Protocol version 13 predates `refersTo` entirely: parsed without the + // meta-schema the keyword is ignored and the property stays the plain + // u32 it always was; its meta-schema (v2) refuses the keyword outright + let platform_version = PlatformVersion::get(13).expect("platform version 13 should exist"); + let schema = key_id_reference_schema(u32_key_id_schema(), owner_key_refers_to()); + + let document_type = + try_document_type_from_schema_on_version(schema.clone(), platform_version) + .expect("should parse"); + assert_eq!( + sender_key_id_type(&document_type), + DocumentPropertyType::U32 + ); + + let config = + DataContractConfig::default_for_version(platform_version).expect("config should build"); + DocumentType::try_from_schema( + Identifier::random(), + 0, + config.version(), + "msg", + platform_value::to_value(schema).expect("schema should convert"), + None, + &BTreeMap::new(), + &config, + true, + &mut vec![], + platform_version, + ) + .expect_err("the v2 meta-schema should refuse refersTo"); + } + #[test] fn should_ignore_refers_to_on_platform_versions_predating_it() { // Platform versions whose tables carry `apply_property_reference: None` diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs index 835241b9e46..132d9a0764b 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs @@ -1723,6 +1723,7 @@ mod tests { mod validate_schema { use super::*; use crate::consensus::basic::BasicError; + use crate::data_contract::document_type::accessors::DocumentTypeV0MutGetters; use std::collections::BTreeMap; #[test] @@ -2210,6 +2211,134 @@ mod tests { assert!(result.is_valid(), "{:?}", result.errors); } + + /// A `message` type whose `senderKeyId` is a `u32` key id, carrying + /// `refers_to` when given. + fn key_id_document_type( + refers_to: Option, + platform_version: &PlatformVersion, + ) -> DocumentType { + let mut sender_key_id = platform_value!({ + "type": "integer", + "minimum": 0, + "maximum": 4294967295u64, + "position": 0 + }); + + if let Some(refers_to) = refers_to { + sender_key_id + .insert("refersTo".to_string(), refers_to) + .expect("should insert refersTo"); + } + + let schema = platform_value!({ + "type": "object", + "properties": { + "senderKeyId": sender_key_id + }, + "signatureSecurityLevelRequirement": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + "message", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + /// The key id form of `identityPublicKey` is a `refersTo` like any + /// other: adding it, removing it or changing what it names is an + /// incompatible change, so a contract can neither start nor stop + /// checking a stored key id against the owner's keys. + #[test] + fn should_return_invalid_result_when_a_key_id_reference_is_added_removed_or_changed() { + let platform_version = PlatformVersion::latest(); + let owner_key = platform_value!({ + "type": "identityPublicKey", + "identityProperty": "$ownerId" + }); + + for (old_refers_to, new_refers_to, operation, changed_path) in [ + ( + None, + Some(owner_key.clone()), + "add", + "/properties/senderKeyId/refersTo", + ), + ( + Some(owner_key.clone()), + None, + "remove", + "/properties/senderKeyId/refersTo", + ), + // `identityProperty` admits one value today; what a change + // would look like is the reference losing it for the other + // spelling of the same reference type, which is refused + // before the parser is even asked whether it can be parsed + ( + Some(owner_key.clone()), + Some(platform_value!({ + "type": "identityPublicKey", + "identityProperty": "$creatorId" + })), + "replace", + "/properties/senderKeyId/refersTo/identityProperty", + ), + ] { + let old_document_type = key_id_document_type(old_refers_to, platform_version); + let new_document_type = if new_refers_to + .as_ref() + .is_some_and(|value| value.to_string().contains("creatorId")) + { + // Not parseable at all: compare the schemas directly + // through a type built without the reference, then swap + // the schema in + let mut document_type = key_id_document_type(None, platform_version); + document_type + .schema_mut() + .get_mut("properties") + .expect("the schema should be a map") + .expect("properties should exist") + .get_mut("senderKeyId") + .expect("properties should be a map") + .expect("senderKeyId should exist") + .insert( + "refersTo".to_string(), + new_refers_to.clone().expect("a refersTo value"), + ) + .expect("should insert refersTo"); + document_type + } else { + key_id_document_type(new_refers_to, platform_version) + }; + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.operation() == operation && e.property_path() == changed_path, + "{operation} at {changed_path}: {:?}", result.errors + ); + } + } } mod encrypted_for { 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 c78f429ae8f..8b2c384f8bf 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -109,6 +109,10 @@ pub(crate) mod property_names { pub const CONTRACT_ID: &str = "contractId"; pub const DOCUMENT_TYPE: &str = "documentType"; pub const KEY_ID_PROPERTY: &str = "keyIdProperty"; + /// `identityPublicKey` reference on the key id property itself: whose key + /// the value names (`"$ownerId"`, the writer). Takes the place of + /// [`KEY_ID_PROPERTY`]; a declaration carries one or the other. + pub const IDENTITY_PROPERTY: &str = "identityProperty"; pub const PROPERTY_AGREEMENT: &str = "propertyAgreement"; pub const CONTRACT_REQUIREMENTS: &str = "contractRequirements"; pub const MODERATION: &str = "moderation"; 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 2013f7de1b1..3ff74331974 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 @@ -980,6 +980,98 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { } } +/// Whose key a `refersTo: identityPublicKey` declaration on a KEY ID property +/// names: its `identityProperty`. The declaring property carries the key id +/// (a `u32`, so an integer property with `minimum` 0 and `maximum` +/// 4294967295) and this names the identity the key belongs to: the document's +/// owner, its creator, or an identifier property of the same document type. +/// It is the inverse of [`DocumentPropertyReferenceTarget::IdentityPublicKey`], +/// where the declaring property carries the identity id and `keyIdProperty` +/// names the sibling carrying the key id; a declaration is one form or the +/// other, never both. +// @append_only +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +pub enum KeyReferenceIdentityProperty { + /// `"$ownerId"`: the writer's own identity. The document's owner signs + /// the transition, which already proved the identity exists, so the + /// reference costs the key fetch alone. The owner can change through a + /// transfer or a purchase, so every replace re-validates the reference. + #[serde(rename = "$ownerId")] + OwnerId, + /// `"$creatorId"`: the identity that created the document, the writer + /// of its create and the stored creator id after that. Only a document + /// type that records creator ids (a transferable or tradeable type of a + /// format-1 contract) may declare it, checked at contract registration. + /// The creator never changes, so a replace re-validates the reference + /// when the key id changed. + #[serde(rename = "$creatorId")] + CreatorId, + /// An identifier property of the same document type (a dotted path when + /// nested) whose value is the identity; it must exist, be an identifier + /// and not carry an `identityPublicKey` reference of its own, checked at + /// contract registration. The identity is read from the document, so a + /// replace re-validates the reference when the key id or that property + /// changed, and a key id set while the property is not is refused. + Property(String), +} + +impl KeyReferenceIdentityProperty { + /// The system `identityProperty` values the schema admits, as spelled + /// there; every other admitted value is a property path. + pub const SYSTEM_WIRE_NAMES: [&'static str; 2] = [OWNER_ID, CREATOR_ID]; + + /// The value for its schema spelling: a system name, or a property path + /// of 1 to 256 characters that does not start with `$`. `None` for a + /// spelling the schema does not admit. + pub fn from_wire_name(name: &str) -> Option { + match name { + OWNER_ID => Some(KeyReferenceIdentityProperty::OwnerId), + CREATOR_ID => Some(KeyReferenceIdentityProperty::CreatorId), + path if path.starts_with('$') || path.is_empty() || path.len() > 256 => None, + path => Some(KeyReferenceIdentityProperty::Property(path.to_string())), + } + } + + /// The schema spelling. + pub fn as_str(&self) -> &str { + match self { + KeyReferenceIdentityProperty::OwnerId => OWNER_ID, + KeyReferenceIdentityProperty::CreatorId => CREATOR_ID, + KeyReferenceIdentityProperty::Property(path) => path, + } + } +} + +impl std::fmt::Display for KeyReferenceIdentityProperty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A `refersTo: identityPublicKey` declaration on a KEY ID property: whose +/// key the value is, and what that key must be beyond existing and not +/// being disabled, the same [`IdentityKeyReferenceRequirements`] the +/// identifier form takes, checked the same way. +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +pub struct KeyIdReference { + pub identity_property: KeyReferenceIdentityProperty, + #[serde( + default, + skip_serializing_if = "IdentityKeyReferenceRequirements::is_empty" + )] + pub key_requirements: IdentityKeyReferenceRequirements, +} + +impl KeyIdReference { + /// A declaration requiring nothing of the key beyond existing. + pub fn new(identity_property: KeyReferenceIdentityProperty) -> Self { + KeyIdReference { + identity_property, + key_requirements: IdentityKeyReferenceRequirements::default(), + } + } +} + // @append_only #[derive(Debug, PartialEq, Clone, Serialize)] pub enum DocumentPropertyType { @@ -1012,6 +1104,12 @@ pub enum DocumentPropertyType { /// inline like [`DocumentPropertyType::Array`], a varint element count /// followed by the elements. TypedArray(TypedArrayProperty), + /// A `u32` key id carrying a `refersTo: identityPublicKey` declaration + /// with `identityProperty`: the value is the id of a key of the named + /// identity, which must exist, not be disabled and meet the declared + /// requirements when the document is written. Sized, encoded and queried + /// exactly as [`Self::U32`]. + KeyIdWithReference(KeyIdReference), } impl DocumentPropertyType { @@ -1058,7 +1156,9 @@ impl DocumentPropertyType { DocumentPropertyType::I128 => "i128".to_string(), DocumentPropertyType::U64 => "u64".to_string(), DocumentPropertyType::I64 => "i64".to_string(), - DocumentPropertyType::U32 => "u32".to_string(), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { + "u32".to_string() + } DocumentPropertyType::I32 => "i32".to_string(), DocumentPropertyType::U16 => "u16".to_string(), DocumentPropertyType::I16 => "i16".to_string(), @@ -1104,7 +1204,7 @@ impl DocumentPropertyType { DocumentPropertyType::I128 => Some(16), DocumentPropertyType::U64 => Some(8), DocumentPropertyType::I64 => Some(8), - DocumentPropertyType::U32 => Some(4), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => Some(4), DocumentPropertyType::I32 => Some(4), DocumentPropertyType::U16 => Some(2), DocumentPropertyType::I16 => Some(2), @@ -1142,7 +1242,7 @@ impl DocumentPropertyType { DocumentPropertyType::I128 => Ok(Some(16)), DocumentPropertyType::U64 => Ok(Some(8)), DocumentPropertyType::I64 => Ok(Some(8)), - DocumentPropertyType::U32 => Ok(Some(4)), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => Ok(Some(4)), DocumentPropertyType::I32 => Ok(Some(4)), DocumentPropertyType::U16 => Ok(Some(2)), DocumentPropertyType::I16 => Ok(Some(2)), @@ -1192,7 +1292,7 @@ impl DocumentPropertyType { DocumentPropertyType::I128 => Ok(Some(16)), DocumentPropertyType::U64 => Ok(Some(8)), DocumentPropertyType::I64 => Ok(Some(8)), - DocumentPropertyType::U32 => Ok(Some(4)), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => Ok(Some(4)), DocumentPropertyType::I32 => Ok(Some(4)), DocumentPropertyType::U16 => Ok(Some(2)), DocumentPropertyType::I16 => Ok(Some(2)), @@ -1239,7 +1339,7 @@ impl DocumentPropertyType { DocumentPropertyType::I128 => Some(16), DocumentPropertyType::U64 => Some(8), DocumentPropertyType::I64 => Some(8), - DocumentPropertyType::U32 => Some(4), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => Some(4), DocumentPropertyType::I32 => Some(4), DocumentPropertyType::U16 => Some(2), DocumentPropertyType::I16 => Some(2), @@ -1282,7 +1382,9 @@ impl DocumentPropertyType { | DocumentPropertyType::I64 | DocumentPropertyType::F64 | DocumentPropertyType::Date => Some(8), - DocumentPropertyType::U32 | DocumentPropertyType::I32 => Some(4), + DocumentPropertyType::U32 + | DocumentPropertyType::KeyIdWithReference(_) + | DocumentPropertyType::I32 => Some(4), DocumentPropertyType::U16 | DocumentPropertyType::I16 => Some(2), DocumentPropertyType::U8 | DocumentPropertyType::I8 | DocumentPropertyType::Boolean => { Some(1) @@ -1374,7 +1476,9 @@ impl DocumentPropertyType { DocumentPropertyType::I128 => Value::I128(rng.gen::()), DocumentPropertyType::U64 => Value::U64(rng.gen::()), DocumentPropertyType::I64 => Value::I64(rng.gen::()), - DocumentPropertyType::U32 => Value::U32(rng.gen::()), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { + Value::U32(rng.gen::()) + } DocumentPropertyType::I32 => Value::I32(rng.gen::()), DocumentPropertyType::U16 => Value::U16(rng.gen::()), DocumentPropertyType::I16 => Value::I16(rng.gen::()), @@ -1445,7 +1549,9 @@ impl DocumentPropertyType { DocumentPropertyType::I128 => Value::I128(rng.gen::()), DocumentPropertyType::U64 => Value::U64(rng.gen::()), DocumentPropertyType::I64 => Value::I64(rng.gen::()), - DocumentPropertyType::U32 => Value::U32(rng.gen::()), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { + Value::U32(rng.gen::()) + } DocumentPropertyType::I32 => Value::I32(rng.gen::()), DocumentPropertyType::U16 => Value::U16(rng.gen::()), DocumentPropertyType::I16 => Value::I16(rng.gen::()), @@ -1499,7 +1605,9 @@ impl DocumentPropertyType { DocumentPropertyType::I128 => Value::I128(rng.gen::()), DocumentPropertyType::U64 => Value::U64(rng.gen::()), DocumentPropertyType::I64 => Value::I64(rng.gen::()), - DocumentPropertyType::U32 => Value::U32(rng.gen::()), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { + Value::U32(rng.gen::()) + } DocumentPropertyType::I32 => Value::I32(rng.gen::()), DocumentPropertyType::U16 => Value::U16(rng.gen::()), DocumentPropertyType::I16 => Value::I16(rng.gen::()), @@ -1630,7 +1738,7 @@ impl DocumentPropertyType { })?; Ok((Some(Value::I64(value)), false)) } - DocumentPropertyType::U32 => { + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { let value = buf.read_u32::().map_err(|_| { DataContractError::CorruptedSerialization( "error reading u32 from serialized document".to_string(), @@ -1897,7 +2005,7 @@ impl DocumentPropertyType { Ok(r_vec) } } - DocumentPropertyType::U32 => { + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { let value_as_u32: u32 = value.into_integer().map_err(ProtocolError::ValueError)?; let mut value_bytes = value_as_u32.to_be_bytes().to_vec(); if required { @@ -2091,7 +2199,7 @@ impl DocumentPropertyType { let value_as_i64: i64 = value.to_integer().map_err(ProtocolError::ValueError)?; Ok(value_as_i64.to_be_bytes().to_vec()) } - DocumentPropertyType::U32 => { + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { let value_as_u32: u32 = value.to_integer().map_err(ProtocolError::ValueError)?; Ok(value_as_u32.to_be_bytes().to_vec()) } @@ -2237,7 +2345,7 @@ impl DocumentPropertyType { let value_as_i64 = value.to_integer().map_err(ProtocolError::ValueError)?; Ok(DocumentPropertyType::encode_i64(value_as_i64)) } - DocumentPropertyType::U32 => { + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { let value_as_u32 = value.to_integer().map_err(ProtocolError::ValueError)?; Ok(DocumentPropertyType::encode_u32(value_as_u32)) } @@ -2348,7 +2456,7 @@ impl DocumentPropertyType { )?; Ok(Value::I64(integer)) } - DocumentPropertyType::U32 => { + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { let integer = DocumentPropertyType::decode_u32(value).ok_or( ProtocolError::DecodingError("could not decode u32".to_string()), )?; @@ -2461,11 +2569,13 @@ impl DocumentPropertyType { "value is not an i64 integer from string".to_string(), ) }), - DocumentPropertyType::U32 => str.parse::().map(Value::U32).map_err(|_| { - DataContractError::ValueWrongType( - "value is not a u32 integer from string".to_string(), - ) - }), + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { + str.parse::().map(Value::U32).map_err(|_| { + DataContractError::ValueWrongType( + "value is not a u32 integer from string".to_string(), + ) + }) + } DocumentPropertyType::I32 => str.parse::().map(Value::I32).map_err(|_| { DataContractError::ValueWrongType( "value is not an i32 integer from string".to_string(), @@ -3096,6 +3206,7 @@ impl DocumentPropertyType { | DocumentPropertyType::U8 | DocumentPropertyType::U16 | DocumentPropertyType::U32 + | DocumentPropertyType::KeyIdWithReference(_) | DocumentPropertyType::U64 ) } @@ -3246,17 +3357,32 @@ impl DocumentPropertyType { *value = Value::U16(n as u16); } - (DocumentPropertyType::U32, Value::U32(_)) => {} // Already correct - (DocumentPropertyType::U32, Value::U8(n)) => { + ( + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_), + Value::U32(_), + ) => {} // Already correct + ( + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_), + Value::U8(n), + ) => { *value = Value::U32(n as u32); } - (DocumentPropertyType::U32, Value::U16(n)) => { + ( + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_), + Value::U16(n), + ) => { *value = Value::U32(n as u32); } - (DocumentPropertyType::U32, Value::U64(n)) if n <= u32::MAX as u64 => { + ( + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_), + Value::U64(n), + ) if n <= u32::MAX as u64 => { *value = Value::U32(n as u32); } - (DocumentPropertyType::U32, Value::U128(n)) if n <= u32::MAX as u128 => { + ( + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_), + Value::U128(n), + ) if n <= u32::MAX as u128 => { *value = Value::U32(n as u32); } @@ -8645,6 +8771,131 @@ mod tests { ); } + #[test] + fn should_serialize_key_id_reference_metadata() { + let property = DocumentProperty { + property_type: DocumentPropertyType::KeyIdWithReference(KeyIdReference::new( + KeyReferenceIdentityProperty::OwnerId, + )), + required: false, + transient: false, + required_since: None, + distinct_from: None, + encrypted_for: None, + }; + + let value = serde_json::to_value(&property).expect("serialization should succeed"); + + assert_eq!( + value.get("property_type"), + Some(&serde_json::json!({ + "KeyIdWithReference": { "identity_property": "$ownerId" } + })) + ); + } + + /// A key id with a reference is a `u32` to everything that sizes, encodes, + /// decodes or names a property: the declaration changes what consensus + /// checks, not the bytes. + #[test] + fn should_treat_a_key_id_with_reference_exactly_as_a_u32() { + let platform_version = PlatformVersion::latest(); + let key_id = DocumentPropertyType::KeyIdWithReference(KeyIdReference::new( + KeyReferenceIdentityProperty::OwnerId, + )); + let u32_type = DocumentPropertyType::U32; + + assert_eq!(key_id.name(), u32_type.name()); + assert!(key_id.is_integer()); + assert_eq!(key_id.min_size(), u32_type.min_size()); + assert_eq!(key_id.max_size(), u32_type.max_size()); + assert_eq!( + key_id.middle_size(platform_version), + u32_type.middle_size(platform_version) + ); + assert_eq!( + key_id.min_byte_size(platform_version).unwrap(), + u32_type.min_byte_size(platform_version).unwrap() + ); + assert_eq!( + key_id.max_byte_size(platform_version).unwrap(), + u32_type.max_byte_size(platform_version).unwrap() + ); + + let value = Value::U32(7); + assert_eq!( + key_id.encode_value_for_tree_keys(&value).unwrap(), + u32_type.encode_value_for_tree_keys(&value).unwrap() + ); + assert_eq!( + key_id.encode_value_with_size(value.clone(), true).unwrap(), + u32_type + .encode_value_with_size(value.clone(), true) + .unwrap() + ); + assert_eq!( + key_id.encode_value_ref_with_size(&value, false).unwrap(), + u32_type.encode_value_ref_with_size(&value, false).unwrap() + ); + let encoded = key_id.encode_value_for_tree_keys(&value).unwrap(); + assert_eq!( + key_id.decode_value_for_tree_keys(&encoded).unwrap(), + u32_type.decode_value_for_tree_keys(&encoded).unwrap() + ); + assert_eq!( + key_id.value_from_string("7").unwrap(), + u32_type.value_from_string("7").unwrap() + ); + + let mut widened = Value::U64(7); + key_id.sanitize_value_mut(&mut widened); + assert_eq!(widened, Value::U32(7)); + } + + #[test] + fn should_spell_the_key_reference_identity_property_as_the_schema_does() { + assert_eq!(KeyReferenceIdentityProperty::OwnerId.as_str(), "$ownerId"); + assert_eq!( + KeyReferenceIdentityProperty::OwnerId.to_string(), + "$ownerId" + ); + assert_eq!( + KeyReferenceIdentityProperty::CreatorId.as_str(), + "$creatorId" + ); + assert_eq!( + KeyReferenceIdentityProperty::Property("meta.toUserId".to_string()).as_str(), + "meta.toUserId" + ); + assert_eq!( + KeyReferenceIdentityProperty::from_wire_name("$ownerId"), + Some(KeyReferenceIdentityProperty::OwnerId) + ); + assert_eq!( + KeyReferenceIdentityProperty::from_wire_name("$creatorId"), + Some(KeyReferenceIdentityProperty::CreatorId) + ); + assert_eq!( + KeyReferenceIdentityProperty::from_wire_name("toUserId"), + Some(KeyReferenceIdentityProperty::Property( + "toUserId".to_string() + )) + ); + // Other system names, the empty path and an overlong path are not admitted + assert_eq!(KeyReferenceIdentityProperty::from_wire_name("$id"), None); + assert_eq!(KeyReferenceIdentityProperty::from_wire_name(""), None); + assert_eq!( + KeyReferenceIdentityProperty::from_wire_name(&"a".repeat(257)), + None + ); + for name in KeyReferenceIdentityProperty::SYSTEM_WIRE_NAMES { + assert_eq!( + KeyReferenceIdentityProperty::from_wire_name(name).map(|p| p.as_str().to_string()), + Some(name.to_string()) + ); + } + } + #[test] fn should_meet_a_minimum_age_from_the_recorded_creation_time_at_the_block_time() { let created_at: TimestampMillis = 1_700_000_000_000; diff --git a/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs b/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs index 29d7eff1a77..5143ef5c0e1 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs @@ -284,7 +284,7 @@ impl DocumentTypeV0 { } serde_json::Value::Object(schema) }, - DocumentPropertyType::U128 | DocumentPropertyType::U64 | DocumentPropertyType::U32 | DocumentPropertyType::U16 | DocumentPropertyType::U8 | + DocumentPropertyType::U128 | DocumentPropertyType::U64 | DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) | DocumentPropertyType::U16 | DocumentPropertyType::U8 | DocumentPropertyType::I128 | DocumentPropertyType::I64 | DocumentPropertyType::I32 | DocumentPropertyType::I16 | DocumentPropertyType::I8 => { let mut schema = serde_json::Map::new(); schema.insert("type".to_string(), serde_json::Value::String("integer".to_owned())); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs index 2ff22312056..e2140c5d065 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/state_v2/mod.rs @@ -57,9 +57,11 @@ impl DocumentCreateTransitionActionStateValidationV2 for DocumentCreateTransitio return Ok(validation_result); } + // The creator of a document being created is its writer let reference_result = self.base().validate_document_references( self.data(), owner_id, + Some(owner_id), None, platform, block_info, 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 530d3df845c..7f462b08cbd 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 @@ -46,13 +46,18 @@ pub(crate) trait DocumentReferenceValidation { /// keyed by `$ownerId`, is validated on every replace regardless. /// /// `owner_id` is the writer, the transition's owner: a `propertyAgreement` - /// whose referring side is `$ownerId` compares it, since it lives on the - /// transition rather than in `document_data`. + /// whose referring side is `$ownerId` compares it, and 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`. + /// `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). #[allow(clippy::too_many_arguments)] fn validate_document_references( &self, document_data: &BTreeMap, owner_id: Identifier, + creator_id: Option, changed_fields: Option<&BTreeSet>, platform: &PlatformStateRef, block_info: &BlockInfo, @@ -102,6 +107,7 @@ impl DocumentReferenceValidation for DocumentBaseTransitionAction { &self, document_data: &BTreeMap, owner_id: Identifier, + creator_id: Option, changed_fields: Option<&BTreeSet>, platform: &PlatformStateRef, block_info: &BlockInfo, @@ -119,6 +125,7 @@ impl DocumentReferenceValidation for DocumentBaseTransitionAction { 0 => self.validate_document_references_v0( document_data, owner_id, + creator_id, changed_fields, platform, block_info, 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 9fdff64d40a..c5705e76d33 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs @@ -12,7 +12,8 @@ use dpp::data_contract::document_type::accessors::{ use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; use dpp::data_contract::document_type::{ is_referring_system_agreement_property, DocumentPropertyReferenceTarget, - DocumentPropertyType, DocumentTypeRef, ReferringWrite, + DocumentPropertyType, DocumentTypeRef, IdentityKeyReferenceRequirements, + KeyReferenceIdentityProperty, ReferringWrite, }; use dpp::data_contract::DataContract; use dpp::document::property_names::{CREATOR_ID, OWNER_ID}; @@ -75,6 +76,7 @@ pub(crate) trait DocumentReferenceValidationV0 { &self, document_data: &BTreeMap, owner_id: Identifier, + creator_id: Option, changed_fields: Option<&BTreeSet>, platform: &PlatformStateRef, block_info: &BlockInfo, @@ -163,6 +165,7 @@ impl DocumentReferenceValidationV0 for DocumentBaseTransitionAction { &self, document_data: &BTreeMap, owner_id: Identifier, + creator_id: Option, changed_fields: Option<&BTreeSet>, platform: &PlatformStateRef, block_info: &BlockInfo, @@ -186,6 +189,7 @@ impl DocumentReferenceValidationV0 for DocumentBaseTransitionAction { document_type, document_data, owner_id, + creator_id, changed_fields, platform, block_info, @@ -202,6 +206,7 @@ fn validate_document_type_references_v0( document_type: DocumentTypeRef<'_>, document_data: &BTreeMap, owner_id: Identifier, + creator_id: Option, changed_fields: Option<&BTreeSet>, platform: &PlatformStateRef, block_info: &BlockInfo, @@ -210,10 +215,55 @@ fn validate_document_type_references_v0( platform_version: &PlatformVersion, ) -> Result { for (path, property) in document_type.flattened_properties() { - let DocumentPropertyType::IdentifierWithReference(reference_target) = - &property.property_type - else { - continue; + let reference_target = match &property.property_type { + DocumentPropertyType::IdentifierWithReference(reference_target) => reference_target, + // 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 + // itself is not checked, so the reference governs writing, not + // holding; which replaces re-validate it depends on where the + // identity comes from. + DocumentPropertyType::KeyIdWithReference(reference) => { + let identity_property = &reference.identity_property; + if let Some(changed) = changed_fields { + let must_revalidate = match identity_property { + // The owner is transition metadata, never among the + // changed fields, and may have changed since the key + // id was written (a transfer or a purchase), so every + // replace re-validates, as a writer gate is + KeyReferenceIdentityProperty::OwnerId => true, + // The creator never changes + KeyReferenceIdentityProperty::CreatorId => is_changed_field(changed, path), + // The identity is in the document: either side of the + // (identity, key id) pair changing re-validates it + KeyReferenceIdentityProperty::Property(identity_path) => { + is_changed_field(changed, path) + || is_changed_field(changed, identity_path) + } + }; + if !must_revalidate { + continue; + } + } + let result = validate_key_id_reference_v0( + path, + identity_property, + &reference.key_requirements, + document_type.name(), + contract.id(), + document_data, + owner_id, + creator_id, + platform, + transaction, + execution_context, + platform_version, + )?; + if !result.is_valid() { + return Ok(result); + } + continue; + } + _ => continue, }; if let Some(changed) = changed_fields { @@ -599,61 +649,20 @@ fn validate_document_type_references_v0( } }; - execution_context.add_operation(ValidationOperation::RetrieveIdentity( - RetrieveIdentityInfo::one_key(), - )); - - // A missing identity and a missing key resolve to the same - // failure: the referenced key could not be found - let Some(key) = platform - .drive - .fetch_identity_keys::( - IdentityKeysRequest::new_specific_key_query(&referenced_id, key_id), - transaction, - platform_version, - )? - else { - return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedIdentityKeyNotFoundError::new( - Identifier::from(referenced_id), - key_id, - path.to_string(), - ) - .into(), - )); - }; - - // Keys can never be removed, so an existing reference can not - // dangle; a disabled key is still rejected for fresh writes - if key.is_disabled() { - return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedIdentityKeyDisabledError::new( - Identifier::from(referenced_id), - key_id, - path.to_string(), - ) - .into(), - )); - } - - // The declaration's requirements are checked against the key just - // fetched, so they cost no further read; the first unmet one refuses - // the write. A bound names the declaring contract and one of its own - // document types (checked when the contract was registered), so the - // check needs nothing beyond the key and the contract in hand - if let Some(requirement) = key_requirements.first_unmet_by(&key, contract.id()) { - return Ok(SimpleConsensusValidationResult::new_with_error( - ReferencedIdentityKeyRequirementNotMetError::new( - document_type.name().to_string(), - path.to_string(), - Identifier::from(referenced_id), - key_id, - requirement.field().to_string(), - requirement.required(), - requirement.actual_of(&key), - ) - .into(), - )); + let result = validate_referenced_identity_key_v0( + Identifier::from(referenced_id), + key_id, + path, + key_requirements, + document_type.name(), + contract.id(), + platform, + transaction, + execution_context, + platform_version, + )?; + if !result.is_valid() { + return Ok(result); } true @@ -679,6 +688,156 @@ fn validate_document_type_references_v0( Ok(SimpleConsensusValidationResult::new()) } +/// A key reference declared on the key id property at `path` +/// (`DocumentPropertyType::KeyIdWithReference`): the value is the key id, and +/// `identity_property` names whose key it is. For `$ownerId` that is the +/// writer, `owner_id`, whose existence the transition already proved, and for +/// `$creatorId` the document's creator, `creator_id` (the writer on a create, +/// the stored creator on a replace), so the key fetch is the only read; for a +/// property path the identity is read from the document, and a key id set +/// while that property is not is refused. An unset key id is not validated; +/// whether it may be absent is the document type's required list. +#[allow(clippy::too_many_arguments)] +fn validate_key_id_reference_v0( + path: &str, + identity_property: &KeyReferenceIdentityProperty, + key_requirements: &IdentityKeyReferenceRequirements, + document_type_name: &str, + declaring_contract_id: Identifier, + document_data: &BTreeMap, + owner_id: Identifier, + creator_id: Option, + platform: &PlatformStateRef, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, +) -> Result { + let key_id: KeyID = match document_data.get_optional_integer_at_path(path) { + Ok(Some(key_id)) => key_id, + Ok(None) => return Ok(SimpleConsensusValidationResult::new()), + // The declaring property is the key id property itself + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedKeyIdPropertyInvalidError::new( + path.to_string(), + path.to_string(), + err.to_string(), + ) + .into(), + )) + } + }; + + let identity_id = match identity_property { + KeyReferenceIdentityProperty::OwnerId => owner_id, + // Contract registration admits `$creatorId` only on a document type + // that records creator ids, so a document of such a type has one + KeyReferenceIdentityProperty::CreatorId => { + creator_id.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( + "a $creatorId key reference needs a document type that records creator ids", + )))? + } + KeyReferenceIdentityProperty::Property(identity_path) => { + match document_data.get_optional_identifier_at_path(identity_path) { + Ok(Some(identity_id)) => Identifier::from(identity_id), + Ok(None) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedKeyIdPropertyInvalidError::new( + path.to_string(), + path.to_string(), + format!("the identity property {identity_path} is not set"), + ) + .into(), + )) + } + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidIdentifierError::new(identity_path.clone(), err.to_string()).into(), + )) + } + } + } + }; + + validate_referenced_identity_key_v0( + identity_id, + key_id, + path, + key_requirements, + document_type_name, + declaring_contract_id, + platform, + transaction, + execution_context, + platform_version, + ) +} + +/// The state check both `identityPublicKey` forms share: key `key_id` of +/// `identity_id`, referenced from `path`, must exist, not be disabled and +/// meet the declaration's `key_requirements`. A missing identity and a +/// missing key resolve to the same failure, the referenced key could not be +/// found. Keys can never be removed, so an existing reference can not +/// dangle; a disabled key is still rejected for fresh writes. The +/// requirements are checked against the key just fetched, so they cost no +/// further read, and the first unmet one refuses the write; a bound names +/// the declaring contract and one of its own document types (checked when +/// the contract was registered), so the check needs nothing beyond the key +/// and the contract in hand. +#[allow(clippy::too_many_arguments)] +fn validate_referenced_identity_key_v0( + identity_id: Identifier, + key_id: KeyID, + path: &str, + key_requirements: &IdentityKeyReferenceRequirements, + document_type_name: &str, + declaring_contract_id: Identifier, + platform: &PlatformStateRef, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, +) -> Result { + execution_context.add_operation(ValidationOperation::RetrieveIdentity( + RetrieveIdentityInfo::one_key(), + )); + + let Some(key) = platform + .drive + .fetch_identity_keys::( + IdentityKeysRequest::new_specific_key_query(&identity_id.to_buffer(), key_id), + transaction, + platform_version, + )? + else { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedIdentityKeyNotFoundError::new(identity_id, key_id, path.to_string()).into(), + )); + }; + + if key.is_disabled() { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedIdentityKeyDisabledError::new(identity_id, key_id, path.to_string()).into(), + )); + } + + if let Some(requirement) = key_requirements.first_unmet_by(&key, declaring_contract_id) { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedIdentityKeyRequirementNotMetError::new( + document_type_name.to_string(), + path.to_string(), + identity_id, + key_id, + requirement.field().to_string(), + requirement.required(), + requirement.actual_of(&key), + ) + .into(), + )); + } + + Ok(SimpleConsensusValidationResult::new()) +} + /// A flattened property path counts as changed when the replace transition changed /// the path itself or any of its ancestors: `changed_data_fields` holds top-level /// document keys, so a changed object key replaces its entire subtree, including diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/state_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/state_v1/mod.rs index 3c17a32ec27..ea54c765f13 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/state_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/state_v1/mod.rs @@ -113,6 +113,7 @@ impl DocumentReplaceTransitionActionStateValidationV1 for DocumentReplaceTransit let reference_result = self.base().validate_document_references( self.data(), owner_id, + self.creator_id(), Some(self.changed_data_fields()), platform, block_info, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index 7847416a475..b733147c676 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -7237,4 +7237,545 @@ mod creation_tests { } ); } + + const REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key.json"; + + /// Committed state the key id reference tests can point at: the writer + /// (its enabled critical key, its master key disabled in state) and a + /// second identity in the same state, for the form naming an identity + /// property. + struct KeyIdReferenceTargets { + writer_id: Identifier, + enabled_key_id: KeyID, + disabled_key_id: KeyID, + other_id: Identifier, + other_enabled_key_id: KeyID, + other_disabled_key_id: KeyID, + } + + /// Registers the key id reference fixture at `contract_path` (a `message` + /// type whose key id property carries `refersTo: identityPublicKey` with + /// an `identityProperty`), disables the master key of the writer and of + /// a second identity in state, then creates a `message` document mutated + /// by the test and returns the execution result. + async fn run_key_id_reference_creation( + contract_path: &str, + mutator: F, + ) -> StateTransitionExecutionResult + where + F: FnOnce(&mut Document, &KeyIdReferenceTargets), + { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(434); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 959, dash_to_credits!(0.1)); + let (other, _, other_key) = setup_identity(&mut platform, 452, dash_to_credits!(0.1)); + + // Key 0 is the master key; documents are signed with the critical key, + // so disabling it leaves the transition below valid + for identity_id in [identity.id(), other.id()] { + platform + .drive + .disable_identity_keys( + identity_id.to_buffer(), + vec![0], + 1, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to disable the master key"); + } + + let targets = KeyIdReferenceTargets { + writer_id: identity.id(), + enabled_key_id: key.id(), + disabled_key_id: 0, + other_id: other.id(), + other_enabled_key_id: other_key.id(), + other_disabled_key_id: 0, + }; + + let contract = setup_contract( + &platform.drive, + contract_path, + None, + None, + None::, + None, + None, + ); + + let message = contract + .document_type_for_name("message") + .expect("expected a message document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut document = message + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + // The key id property is optional; each test sets what it + // exercises + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random message document"); + document + .set_id_for_creation(message, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + + mutator(&mut document, &targets); + + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document, + message, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_create_serialized_transition = documents_batch_create_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[documents_batch_create_serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + processing_result + .execution_results() + .first() + .expect("expected one execution result") + .clone() + } + + #[tokio::test] + async fn should_document_creation_succeed_when_owner_key_reference_names_an_existing_key() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, targets| { + document.set("senderKeyId", (targets.enabled_key_id as i64).into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + /// The reference names a key of the writer's own identity, so a key id + /// the owner does not have is a missing key, whatever other identity may + /// hold a key under that id: there is no property naming another + /// identity, so "another identity's key" is not expressible and needs no + /// test of its own. Refused paid, the identity exists. + #[tokio::test] + async fn should_document_creation_fail_when_owner_key_reference_names_a_missing_key() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, _| { + document.set("senderKeyId", 99i64.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + e + )), + .. + } if e.key_id() == 99 && e.path() == "senderKeyId" + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_owner_key_reference_names_a_disabled_key() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, targets| { + document.set("senderKeyId", (targets.disabled_key_id as i64).into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyDisabledError( + _ + )), + .. + } + ); + } + + /// The form on a nested property is validated at its dotted path, the + /// same path the flattened properties and the error report. + #[tokio::test] + async fn should_document_creation_fail_when_nested_owner_key_reference_names_a_missing_key() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, _| { + document.set( + "meta", + dpp::platform_value::platform_value!({ "senderKeyId": 99u32 }), + ); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + e + )), + .. + } if e.key_id() == 99 && e.path() == "meta.senderKeyId" + ); + } + + /// An unset key id is not a reference to validate; whether the property + /// may be absent is the document type's required list (it is optional + /// in the fixture). + const REFERENCE_VALIDATION_OWNER_KEY_REQUIREMENTS_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key-requirements.json"; + + /// Registers the owner-key fixture whose `senderKeyId` requires a + /// decryption key bound to the contract's `inbox` type, adds the keys of + /// [`IdentityKeyRequirementTargets`] to the writer, then creates a + /// `message` document mutated by the test and returns the execution + /// result. + async fn run_owner_key_requirement_creation(mutator: F) -> StateTransitionExecutionResult + where + F: FnOnce(&mut Document, &IdentityKeyRequirementTargets), + { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(436); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 960, dash_to_credits!(0.1)); + + let contract = setup_contract( + &platform.drive, + REFERENCE_VALIDATION_OWNER_KEY_REQUIREMENTS_CONTRACT_PATH, + None, + None, + None::, + None, + None, + ); + + let targets = add_identity_key_requirement_targets( + &mut platform, + &identity, + key.id(), + contract.id(), + platform_version, + ); + + let message = contract + .document_type_for_name("message") + .expect("expected a message document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut document = message + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random message document"); + document + .set_id_for_creation(message, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + + mutator(&mut document, &targets); + + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document, + message, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[documents_batch_create_transition + .serialize_to_bytes() + .expect("expected a serialized create transition")], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + processing_result + .execution_results() + .first() + .expect("expected one execution result") + .clone() + } + + /// `keyRequirements` apply to the key id form exactly as to the + /// identifier form, through the shared key check. + #[tokio::test] + async fn should_document_creation_succeed_when_owner_key_meets_its_requirements() { + let result = run_owner_key_requirement_creation(|document, targets| { + document.set( + "senderKeyId", + (targets.decryption_key_bound_to_inbox_id as i64).into(), + ); + }) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_owner_key_does_not_meet_its_requirements() { + let result = run_owner_key_requirement_creation(|document, targets| { + document.set("senderKeyId", (targets.authentication_key_id as i64).into()); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedIdentityKeyRequirementNotMetError(ref e) + ), + .. + } if e.path() == "senderKeyId" && e.field() == "purpose" + ); + } + + const REFERENCE_VALIDATION_CREATOR_KEY_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key.json"; + const REFERENCE_VALIDATION_IDENTITY_PROPERTY_KEY_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key.json"; + + /// `$creatorId` on a create names the writer, the document's creator. + #[tokio::test] + async fn should_document_creation_succeed_when_creator_key_reference_names_an_existing_key() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_CREATOR_KEY_CONTRACT_PATH, + |document, targets| { + document.set("senderKeyId", (targets.enabled_key_id as i64).into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_creator_key_reference_names_a_missing_key() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_CREATOR_KEY_CONTRACT_PATH, + |document, _| { + document.set("senderKeyId", 99i64.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + e + )), + .. + } if e.key_id() == 99 && e.path() == "senderKeyId" + ); + } + + /// A property path: `toUserId` carries the identity, `recipientKeyId` the + /// id of one of its keys. + #[tokio::test] + async fn should_document_creation_succeed_when_key_reference_names_an_identity_property() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_IDENTITY_PROPERTY_KEY_CONTRACT_PATH, + |document, targets| { + document.set("toUserId", targets.other_id.into()); + document.set( + "recipientKeyId", + (targets.other_enabled_key_id as i64).into(), + ); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + /// The key is looked up on the named identity, not the writer: the + /// writer's critical key id is enabled on the writer but names the other + /// identity's disabled master key when the ids coincide, and a missing + /// one is missing on the other identity. + #[tokio::test] + async fn should_document_creation_fail_when_named_identity_lacks_or_disabled_the_key() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_IDENTITY_PROPERTY_KEY_CONTRACT_PATH, + |document, targets| { + document.set("toUserId", targets.other_id.into()); + document.set( + "recipientKeyId", + (targets.other_disabled_key_id as i64).into(), + ); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyDisabledError( + e + )), + .. + } if e.path() == "recipientKeyId" + ); + + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_IDENTITY_PROPERTY_KEY_CONTRACT_PATH, + |document, targets| { + document.set("toUserId", targets.other_id.into()); + document.set("recipientKeyId", 99i64.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + e + )), + .. + } if e.key_id() == 99 + ); + } + + /// A key id without the identity it belongs to is refused: the pair is + /// the reference. + #[tokio::test] + async fn should_document_creation_fail_when_key_reference_identity_property_is_not_set() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_IDENTITY_PROPERTY_KEY_CONTRACT_PATH, + |document, targets| { + document.set("recipientKeyId", (targets.enabled_key_id as i64).into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedKeyIdPropertyInvalidError( + e + )), + .. + } if e.path() == "recipientKeyId" + ); + } + + #[tokio::test] + async fn should_document_creation_succeed_when_owner_key_reference_is_not_set() { + let result = run_key_id_reference_creation( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, _| { + document.set("note", "no key named".into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index fc5a0c79a7b..13c73623208 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -3712,4 +3712,698 @@ mod replacement_tests { StateTransitionExecutionResult::SuccessfulExecution { .. } ); } + + const REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key.json"; + + /// Committed state the key id reference replace tests can point at: the + /// writer and a second identity, each with an enabled critical key and a + /// master key (key 0) that the helper may disable between the create and + /// the replace. + struct KeyIdReferenceTargets { + writer_id: Identifier, + enabled_key_id: KeyID, + master_key_id: KeyID, + other_id: Identifier, + other_enabled_key_id: KeyID, + } + + /// Whose master key the helper disables between the create and the + /// replace, so that a replace refetching the key is observable. + enum DisableMasterKeyBetween { + Nobody, + Writer, + Other, + } + + /// Registers the key id reference fixture at `contract_path` (a `message` + /// type whose key id property carries `refersTo: identityPublicKey` with + /// an `identityProperty`), creates a `message` document shaped by + /// `create_mutator` (asserting success), disables the chosen master key + /// in state, replaces the document shaped by `replace_mutator` and + /// returns the replace execution result. Master keys are enabled at + /// create time, so a create may reference one. + async fn run_key_id_reference_create_then_replace( + contract_path: &str, + create_mutator: C, + disable_between: DisableMasterKeyBetween, + replace_mutator: R, + ) -> StateTransitionExecutionResult + where + C: FnOnce(&mut Document, &KeyIdReferenceTargets), + R: FnOnce(&mut Document, &KeyIdReferenceTargets), + { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(434); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 959, dash_to_credits!(0.1)); + let (other, _, other_key) = setup_identity(&mut platform, 452, dash_to_credits!(0.1)); + + let targets = KeyIdReferenceTargets { + writer_id: identity.id(), + enabled_key_id: key.id(), + master_key_id: 0, + other_id: other.id(), + other_enabled_key_id: other_key.id(), + }; + + let contract = setup_contract( + &platform.drive, + contract_path, + None, + None, + None::, + None, + None, + ); + + let message = contract + .document_type_for_name("message") + .expect("expected a message document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut document = message + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + // The key id property is optional; each test sets what it + // exercises + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random message document"); + document + .set_id_for_creation(message, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + + create_mutator(&mut document, &targets); + + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document.clone(), + message, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_create_serialized_transition = documents_batch_create_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[documents_batch_create_serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let disabled_identity = match disable_between { + DisableMasterKeyBetween::Nobody => None, + DisableMasterKeyBetween::Writer => Some(identity.id()), + DisableMasterKeyBetween::Other => Some(other.id()), + }; + if let Some(identity_id) = disabled_identity { + // Documents are signed with the critical key, so the replace + // below stays valid + platform + .drive + .disable_identity_keys( + identity_id.to_buffer(), + vec![0], + 1, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to disable the master key"); + } + + document.increment_revision().unwrap(); + replace_mutator(&mut document, &targets); + + let documents_batch_replace_transition = + BatchTransition::new_document_replacement_transition_from_document( + document, + message, + &key, + 3, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_replace_serialized_transition = documents_batch_replace_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[documents_batch_replace_serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + processing_result + .execution_results() + .first() + .expect("expected one execution result") + .clone() + } + + /// The key id form on replace: a changed key id is re-validated against + /// the owner's keys, the identity being the writer by construction. + #[tokio::test] + async fn should_document_replace_fail_when_owner_key_id_changed_to_a_missing_key() { + let result = run_key_id_reference_create_then_replace( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, targets| { + document.set("senderKeyId", (targets.enabled_key_id as i64).into()); + }, + DisableMasterKeyBetween::Nobody, + |document, _| { + document.set("senderKeyId", 99i64.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + e + )), + .. + } if e.key_id() == 99 && e.path() == "senderKeyId" + ); + } + + #[tokio::test] + async fn should_document_replace_fail_when_owner_key_id_changed_to_a_disabled_key() { + let result = run_key_id_reference_create_then_replace( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, targets| { + document.set("senderKeyId", (targets.enabled_key_id as i64).into()); + }, + DisableMasterKeyBetween::Writer, + |document, targets| { + document.set("senderKeyId", (targets.master_key_id as i64).into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyDisabledError( + _ + )), + .. + } + ); + } + + /// An untouched key id is re-validated on every replace, as the writer + /// gate is: the identity is the writer, which is transition metadata and + /// never among the changed fields. The document was created naming the + /// master key while it was enabled, the key is disabled before the + /// replace, and a replace of another property is refused. + #[tokio::test] + async fn should_document_replace_fail_when_untouched_owner_key_id_names_a_now_disabled_key() { + let result = run_key_id_reference_create_then_replace( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, targets| { + document.set("senderKeyId", (targets.master_key_id as i64).into()); + }, + DisableMasterKeyBetween::Writer, + |document, _| { + document.set("note", "changed".into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyDisabledError( + _ + )), + .. + } + ); + } + + /// The same untouched replace passes while the key stays enabled: the + /// refetch is a check, not a change. + #[tokio::test] + async fn should_document_replace_succeed_when_untouched_owner_key_id_still_names_an_enabled_key( + ) { + let result = run_key_id_reference_create_then_replace( + REFERENCE_VALIDATION_OWNER_KEY_CONTRACT_PATH, + |document, targets| { + document.set("senderKeyId", (targets.enabled_key_id as i64).into()); + }, + DisableMasterKeyBetween::Writer, + |document, _| { + document.set("note", "changed".into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + const REFERENCE_VALIDATION_OWNER_KEY_TRANSFERABLE_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key-transferable.json"; + + /// The identities of a transfer: the writer (creator), whose master key + /// stays enabled, and the receiver, whose master key is disabled. + struct TransferTargets { + writer_id: Identifier, + receiver_id: Identifier, + receiver_key_id: KeyID, + } + + /// A transferable key id reference fixture at `contract_path`: the writer + /// creates a `message` naming its own master key (key 0, enabled) as + /// `senderKeyId`, transfers it to a receiver whose master key was + /// disabled in state beforehand, and the receiver replaces it shaped by + /// `replace_mutator`. Returns the replace execution result and the + /// identities. + async fn run_key_id_reference_create_transfer_then_replace( + contract_path: &str, + replace_mutator: R, + ) -> (StateTransitionExecutionResult, TransferTargets) + where + R: FnOnce(&mut Document, &TransferTargets), + { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(435); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 959, dash_to_credits!(0.1)); + let (receiver, receiver_signer, receiver_key) = + setup_identity(&mut platform, 451, dash_to_credits!(0.1)); + + // The receiver's master key is disabled, so a key id of 0 names an + // enabled key of the writer and a disabled key of the receiver + platform + .drive + .disable_identity_keys( + receiver.id().to_buffer(), + vec![0], + 1, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to disable the receiver's master key"); + + let contract = setup_contract( + &platform.drive, + contract_path, + None, + None, + None::, + None, + None, + ); + + let targets = TransferTargets { + writer_id: identity.id(), + receiver_id: receiver.id(), + receiver_key_id: receiver_key.id(), + }; + + let message = contract + .document_type_for_name("message") + .expect("expected a message document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut document = message + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random message document"); + document + .set_id_for_creation(message, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + document.set("senderKeyId", 0i64.into()); + + let create_transition = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + message, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[create_transition + .serialize_to_bytes() + .expect("expected a serialized create transition")], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // The transfer is not a reference check: the document changes hands + // with its key id as written + document.set_revision(Some(2)); + let transfer_transition = BatchTransition::new_document_transfer_transition_from_document( + document.clone(), + message, + receiver.id(), + &key, + 3, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition for transfer"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[transfer_transition + .serialize_to_bytes() + .expect("expected a serialized transfer transition")], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + document.set_owner_id(receiver.id()); + document.set_revision(Some(3)); + replace_mutator(&mut document, &targets); + + let replace_transition = + BatchTransition::new_document_replacement_transition_from_document( + document, + message, + &receiver_key, + 1, + 0, + None, + &receiver_signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &[replace_transition + .serialize_to_bytes() + .expect("expected a serialized replace transition")], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + ( + processing_result + .execution_results() + .first() + .expect("expected one execution result") + .clone(), + targets, + ) + } + + /// After a transfer the writer is the receiver, and the replace + /// re-validates the reference against it whether or not the key id + /// changed: the stored key id names the receiver's disabled master key, + /// and a replace of another property is refused. + #[tokio::test] + async fn should_document_replace_fail_after_transfer_when_untouched_owner_key_id_is_not_the_new_owners_key( + ) { + let (result, targets) = run_key_id_reference_create_transfer_then_replace( + REFERENCE_VALIDATION_OWNER_KEY_TRANSFERABLE_CONTRACT_PATH, + |document, _| { + document.set("note", "changed by the receiver".into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyDisabledError( + e + )), + .. + } if *e.identity_id() == targets.receiver_id + ); + } + + #[tokio::test] + async fn should_document_replace_succeed_after_transfer_when_owner_key_id_is_repointed_at_the_new_owners_key( + ) { + let (result, _) = run_key_id_reference_create_transfer_then_replace( + REFERENCE_VALIDATION_OWNER_KEY_TRANSFERABLE_CONTRACT_PATH, + |document, targets| { + document.set("senderKeyId", (targets.receiver_key_id as i64).into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + const REFERENCE_VALIDATION_CREATOR_KEY_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key.json"; + const REFERENCE_VALIDATION_IDENTITY_PROPERTY_KEY_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key.json"; + + /// `$creatorId` follows the creator through a transfer: the receiver may + /// repoint the key id at the creator's master key (enabled on the + /// creator, disabled on the receiver), which the owner form would refuse. + #[tokio::test] + async fn should_document_replace_succeed_after_transfer_when_creator_key_id_names_the_creators_key( + ) { + let (result, _) = run_key_id_reference_create_transfer_then_replace( + REFERENCE_VALIDATION_CREATOR_KEY_CONTRACT_PATH, + |document, _| { + document.set("senderKeyId", 0i64.into()); + document.set("note", "changed by the receiver".into()); + }, + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_replace_fail_after_transfer_when_creator_key_id_names_a_key_the_creator_lacks( + ) { + let (result, targets) = run_key_id_reference_create_transfer_then_replace( + REFERENCE_VALIDATION_CREATOR_KEY_CONTRACT_PATH, + |document, _| { + document.set("senderKeyId", 99i64.into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + e + )), + .. + } if *e.identity_id() == targets.writer_id && e.key_id() == 99 + ); + } + + /// A property path binds both sides of the pair: changing only the + /// identity property re-validates the key id against the new identity. + #[tokio::test] + async fn should_document_replace_fail_when_only_the_identity_property_changed_to_an_identity_without_the_key( + ) { + let result = run_key_id_reference_create_then_replace( + REFERENCE_VALIDATION_IDENTITY_PROPERTY_KEY_CONTRACT_PATH, + |document, targets| { + document.set("toUserId", targets.other_id.into()); + document.set( + "recipientKeyId", + (targets.other_enabled_key_id as i64).into(), + ); + }, + DisableMasterKeyBetween::Nobody, + |document, _| { + document.set("toUserId", Identifier::random().into()); + }, + ) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + _ + )), + .. + } + ); + } + + /// An untouched pair is not refetched: the named identity's key is + /// disabled between the create and a replace of another property, and + /// the replace still passes. + #[tokio::test] + async fn should_document_replace_succeed_without_refetching_an_untouched_identity_property_pair( + ) { + let result = run_key_id_reference_create_then_replace( + REFERENCE_VALIDATION_IDENTITY_PROPERTY_KEY_CONTRACT_PATH, + |document, targets| { + document.set("toUserId", targets.other_id.into()); + document.set("recipientKeyId", (targets.master_key_id as i64).into()); + }, + DisableMasterKeyBetween::Other, + |document, _| { + document.set("note", "changed".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 1790ae164dd..7663242bc71 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 @@ -3,7 +3,8 @@ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::{ is_referenced_system_agreement_property, is_referring_system_agreement_property, - DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentReferenceDeclaration, + DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, + DocumentReferenceDeclaration, KeyReferenceIdentityProperty, }; use dpp::data_contract::DataContract; use dpp::document::property_names::CREATOR_ID; @@ -30,13 +31,16 @@ use crate::execution::types::state_transition_execution_context::{ /// Whether two property types hold the same KIND of value for agreement /// purposes: sizes and other constraints may differ (both sides validated -/// their own documents already), and an identifier is one kind whether or -/// not it carries its own reference annotation. +/// their own documents already), and an identifier, or a `u32` key id, is one +/// kind whether or not it carries its own reference annotation. fn same_value_kind(a: &DocumentPropertyType, b: &DocumentPropertyType) -> bool { let normalized_kind = |property_type: &DocumentPropertyType| match property_type { DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { std::mem::discriminant(&DocumentPropertyType::Identifier) } + DocumentPropertyType::U32 | DocumentPropertyType::KeyIdWithReference(_) => { + std::mem::discriminant(&DocumentPropertyType::U32) + } other => std::mem::discriminant(other), }; normalized_kind(a) == normalized_kind(b) @@ -79,14 +83,91 @@ pub(super) fn validate_data_contract_references_v0( for (declaring_type_name, document_type) in contract.document_types() { for (path, property) in document_type.as_ref().flattened_properties() { - let DocumentPropertyType::IdentifierWithReference(reference_target) = - &property.property_type - else { - continue; - }; - let declaration_path = format!("{declaring_type_name}.{path}"); + let reference_target = match &property.property_type { + DocumentPropertyType::IdentifierWithReference(reference_target) => reference_target, + // A key reference on the key id property: what `identityProperty` + // names must fit the document type; nothing else about the + // declaration is state-dependent + DocumentPropertyType::KeyIdWithReference(reference) => { + let invalid = |message: &str| { + SimpleConsensusValidationResult::new_with_error( + ReferencedKeyIdPropertyInvalidError::new( + path.to_string(), + declaration_path.clone(), + message.to_string(), + ) + .into(), + ) + }; + match &reference.identity_property { + KeyReferenceIdentityProperty::OwnerId => {} + KeyReferenceIdentityProperty::CreatorId => { + if !document_type + .as_ref() + .should_use_creator_id( + contract.system_version_type(), + contract.config().version(), + platform_version, + ) + .map_err(Error::Protocol)? + { + return Ok(invalid( + "identityProperty $creatorId needs a document type that \ + records creator ids: only transferable or tradeable \ + document types of a format-1 contract do", + )); + } + } + KeyReferenceIdentityProperty::Property(identity_path) => { + match document_type + .as_ref() + .flattened_properties() + .get(identity_path) + { + None => { + return Ok(invalid(&format!( + "the document type does not define the identity \ + property {identity_path}" + ))); + } + // The (identity, key id) pair is declared once + Some(DocumentProperty { + property_type: + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { + .. + }, + ), + .. + }) => { + return Ok(invalid(&format!( + "the identity property {identity_path} carries its own \ + identityPublicKey reference" + ))); + } + Some(identity_property) + if !matches!( + identity_property.property_type, + DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_) + ) => + { + return Ok(invalid(&format!( + "the identity property {identity_path} must be an \ + identifier" + ))); + } + Some(_) => {} + } + } + } + continue; + } + _ => continue, + }; + // The key id property must exist in the same document type and be // an integer; nothing else about the declaration is state-dependent if let DocumentPropertyReferenceTarget::IdentityPublicKey { @@ -118,6 +199,22 @@ pub(super) fn validate_data_contract_references_v0( .into(), )); } + // A key id that already names whose key it is (the writer's) + // can not also be a key of the referenced identity + Some(DocumentProperty { + property_type: DocumentPropertyType::KeyIdWithReference(_), + .. + }) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedKeyIdPropertyInvalidError::new( + key_id_property.clone(), + declaration_path, + "the property carries its own identityPublicKey reference" + .to_string(), + ) + .into(), + )); + } Some(_) => continue, } } 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 2b380472d7e..7948f04ed93 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 @@ -5957,6 +5957,149 @@ mod tests { ); } + /// The key id form: `senderKeyId` names the writer's own key. Nothing + /// is checked at registration beyond the parse and the meta-schema, + /// which a real create runs, unlike the fixture insertion the + /// document tests use. + #[tokio::test] + async fn should_register_contract_with_owner_key_reference() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + /// A key id that already names whose key it is (the writer's) can not + /// also be named as a key of a referenced identity: one value would + /// have to be a key of two identities. + #[tokio::test] + async fn should_reject_contract_whose_key_id_property_carries_its_own_key_reference() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-key-id-with-reference.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedKeyIdPropertyInvalidError(e) + ), + .. + } if e.key_id_property() == "senderKeyId" + ); + } + + /// `$creatorId` needs a document type that records creator ids: a + /// transferable type of a format-1 contract does, a plain one does not. + #[tokio::test] + async fn should_register_contract_with_creator_key_reference_on_a_type_recording_creators() + { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_reject_creator_key_reference_on_a_type_not_recording_creators() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key-registration-not-recorded.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedKeyIdPropertyInvalidError(e) + ), + .. + } if e.key_id_property() == "senderKeyId" && e.message().contains("$creatorId") + ); + } + + /// A property path names an identifier property of the same type that + /// carries the identity; it must exist, be an identifier, and not + /// declare the same (identity, key id) pair from its own side. + #[tokio::test] + async fn should_register_contract_with_key_reference_naming_an_identity_property() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_reject_key_reference_naming_an_undefined_identity_property() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-missing.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedKeyIdPropertyInvalidError(e) + ), + .. + } if e.message().contains("does not define") + ); + } + + #[tokio::test] + async fn should_reject_key_reference_naming_a_non_identifier_identity_property() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-non-identifier.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedKeyIdPropertyInvalidError(e) + ), + .. + } if e.message().contains("must be an identifier") + ); + } + + #[tokio::test] + async fn should_reject_key_reference_naming_an_identity_property_with_its_own_key_reference( + ) { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-double-reference.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedKeyIdPropertyInvalidError(_) + ), + .. + } + ); + } + #[tokio::test] async fn should_reject_contract_referencing_missing_contract() { let result = run_contract_create( diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key-registration-not-recorded.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key-registration-not-recorded.json new file mode 100644 index 00000000000..60dd9eb2c2f --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key-registration-not-recorded.json @@ -0,0 +1,31 @@ +{ + "$formatVersion": "1", + "id": "8KfsEEAD7i7Whp6vg8sAPz5KcmhH4VaQcznmhZCXebCo", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "senderKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$creatorId" + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key.json new file mode 100644 index 00000000000..f328f7c3d23 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-creator-key.json @@ -0,0 +1,32 @@ +{ + "$formatVersion": "1", + "id": "5qPsCGpmWxpaNubcjhSGTYGkyfrSGVTaxhjGAR3u7GiZ", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "transferable": 1, + "properties": { + "senderKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$creatorId" + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-key-id-with-reference.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-key-id-with-reference.json new file mode 100644 index 00000000000..88b96a2d7cc --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-key-id-with-reference.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "727KQg1FomW3hGE1jVSuerDrm3y9v3BFZ26fivL4AL9P", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "senderKeyId" + } + }, + "senderKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 1, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$ownerId" + } + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key.json new file mode 100644 index 00000000000..d182446c74a --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-key.json @@ -0,0 +1,39 @@ +{ + "$formatVersion": "1", + "id": "F2rQbPN8ub3UN19qTQh86Wx8LWsAkHFJe39j4QJyxYVG", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "recipientKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 1, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "toUserId" + } + }, + "note": { + "type": "string", + "position": 2, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-double-reference.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-double-reference.json new file mode 100644 index 00000000000..52e0e66412d --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-double-reference.json @@ -0,0 +1,43 @@ +{ + "$formatVersion": "1", + "id": "6umgknTHwDxJ1PTdr1sYVdn48xgaFFLhC9f28f8izEFu", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId" + } + }, + "recipientKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 1, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "toUserId" + } + }, + "note": { + "type": "string", + "position": 2, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-missing.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-missing.json new file mode 100644 index 00000000000..048b3b9fc34 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-missing.json @@ -0,0 +1,31 @@ +{ + "$formatVersion": "1", + "id": "E8etzQdQHwu9A8XHnBrxRLJaDiNLyZtdJ1fjZuYQngjN", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "recipientKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "toUserId" + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-non-identifier.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-non-identifier.json new file mode 100644 index 00000000000..e888743de8f --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-property-registration-non-identifier.json @@ -0,0 +1,31 @@ +{ + "$formatVersion": "1", + "id": "HSpYHaHzFx9cKw7aHhUxUgHkDVDna28XurQtdxQm6qca", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "recipientKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "note" + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key-requirements.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key-requirements.json new file mode 100644 index 00000000000..724b9475fa0 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key-requirements.json @@ -0,0 +1,51 @@ +{ + "$formatVersion": "1", + "id": "EzvJ8tVV8evCUDLxzPxAbGe8aCj6eXQ64S43qoD7EKzi", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "requiresIdentityEncryptionBoundedKey": 2, + "requiresIdentityDecryptionBoundedKey": 2, + "properties": { + "senderKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$ownerId", + "keyRequirements": { + "purpose": "decryption", + "boundTo": "inbox" + } + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + }, + "inbox": { + "type": "object", + "requiresIdentityEncryptionBoundedKey": 2, + "requiresIdentityDecryptionBoundedKey": 2, + "properties": { + "label": { + "type": "string", + "position": 0, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key-transferable.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key-transferable.json new file mode 100644 index 00000000000..31c5f9a9163 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key-transferable.json @@ -0,0 +1,32 @@ +{ + "$formatVersion": "1", + "id": "GAhXtycMfHYYoXxreywKkNXbsMA4u8UaawZGyc3pBoMX", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "transferable": 1, + "properties": { + "senderKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$ownerId" + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key.json new file mode 100644 index 00000000000..334df9943ce --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-owner-key.json @@ -0,0 +1,48 @@ +{ + "$formatVersion": "1", + "id": "A55q7wQJcgwqMXJkEWdUuwhg4pffsxcYJFyBk1GSMwzU", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "properties": { + "senderKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$ownerId" + } + }, + "note": { + "type": "string", + "position": 1, + "maxLength": 64 + }, + "meta": { + "type": "object", + "position": 2, + "properties": { + "senderKeyId": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "identityProperty": "$ownerId" + } + } + }, + "additionalProperties": false + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive/src/query/conditions.rs b/packages/rs-drive/src/query/conditions.rs index 6fa93ff4090..0d231e38e71 100644 --- a/packages/rs-drive/src/query/conditions.rs +++ b/packages/rs-drive/src/query/conditions.rs @@ -1302,10 +1302,16 @@ impl<'a> WhereClause { | Value::U8(_) | Value::I8(_) ), - T::U8 | T::U16 | T::U32 | T::U64 | T::U128 => matches!( - v, - Value::U8(_) | Value::U16(_) | Value::U32(_) | Value::U64(_) | Value::U128(_) - ), + T::U8 | T::U16 | T::U32 | T::KeyIdWithReference(_) | T::U64 | T::U128 => { + matches!( + v, + Value::U8(_) + | Value::U16(_) + | Value::U32(_) + | Value::U64(_) + | Value::U128(_) + ) + } T::I8 | T::I16 | T::I32 | T::I64 | T::I128 => matches!( v, Value::I8(_) | Value::I16(_) | Value::I32(_) | Value::I64(_) | Value::I128(_) @@ -1324,6 +1330,7 @@ impl<'a> WhereClause { T::U8 | T::U16 | T::U32 + | T::KeyIdWithReference(_) | T::U64 | T::U128 | T::I8 @@ -1426,6 +1433,7 @@ pub fn allowed_ops_for_type(property_type: &DocumentPropertyType) -> &'static [W | DocumentPropertyType::U16 | DocumentPropertyType::I16 | DocumentPropertyType::U32 + | DocumentPropertyType::KeyIdWithReference(_) | DocumentPropertyType::I32 | DocumentPropertyType::U64 | DocumentPropertyType::I64 diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 142b79c58f0..4e0fe186b24 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -654,6 +654,38 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// with `ReferencedIdentityKeyRequirementNotMetError` (40136). A changed /// `keyRequirements` is an incompatible schema change on update. /// +/// 30. **Key references on the key id property**: an `identityPublicKey` +/// `refersTo` declaration may sit on the key id property itself, an +/// integer with `minimum` 0 and `maximum` 4294967295 (a `KeyID` is a +/// `u32`), naming through `identityProperty` whose key the value is: +/// `"$ownerId"` (the writer), `"$creatorId"` (the document's creator, +/// only on a document type that records creator ids) or the path of an +/// identifier property of the same document type (which must exist, be +/// an identifier and not carry an `identityPublicKey` reference of its +/// own); the last two are checked at contract registration (40125). The +/// declaration takes no `keyIdProperty`; the identifier form is unchanged +/// and every other `refersTo` form stays identifier-only (meta-schema v3, +/// `apply_property_reference` 0, `DocumentPropertyType::KeyIdWithReference` +/// over `KeyReferenceIdentityProperty`). At document create and replace the +/// reference validation reads the key id from the property, resolves the +/// identity (the writer, the creator the action carries, or the named +/// property's value, a key id set while it is unset being refused with +/// 40125) and fetches that key, so the key fetch is the only read; a key +/// that does not exist refuses the write, paid, with +/// `ReferencedIdentityKeyNotFoundError` (40123) and a disabled one with +/// `ReferencedIdentityKeyDisabledError` (40124), as for the identifier +/// form. A replace re-validates `$ownerId` touched or not, as the +/// `$ownerId` writer gate is, since the writer may not be the one who +/// wrote the key id; `$creatorId` when the key id changed; a property +/// path when the key id or that property changed (a transfer itself is +/// never checked: the reference governs writing, not holding). A +/// `keyIdProperty` may not name a property carrying this form (40125 at +/// registration). `keyRequirements` (item 29) sit on this form exactly +/// as on the identifier form, checked by the same key check and by the +/// same `boundTo` registration rule. Adding, removing or changing it is +/// an incompatible schema change on update, like the rest of a +/// `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 2539291597d..316a5921e6f 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -1,10 +1,12 @@ //! `refersTo` declarations — the document-reference metadata a contract //! carries from protocol version 14 onward. //! -//! `refersTo` annotates an identifier property with what it points at, and -//! consensus enforces that the target exists whenever a document carrying -//! it is written. It is a **write-time constraint only**: nothing anywhere -//! in the stack resolves a reference for a reader. What this module adds is +//! `refersTo` annotates an identifier property with what it points at (or, +//! for an `identityPublicKey` reference with `identityProperty`, a key id +//! property with whose key it names), and consensus enforces that the target +//! exists whenever a document carrying it is written. It is a **write-time +//! constraint only**: nothing anywhere in the stack resolves a reference for +//! a reader. What this module adds is //! the ability to *discover* the declarations — "which properties of this //! document type are references, and to what?" — without hand-parsing the //! contract's raw JSON schema. @@ -14,6 +16,7 @@ use crate::identifier::IdentifierWasm; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::{ DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentTypeRef, + IdentityKeyReferenceRequirements, KeyIdReference, }; use dpp::prelude::Identifier; use js_sys::{Array, Object, Reflect}; @@ -30,6 +33,15 @@ const DOCUMENT_PROPERTY_REFERENCE_TS: &'static str = r#" * own, so what `contract.toJSON()` shows under `refersTo` and what these * accessors return line up key for key. */ +/** + * What an `identityPublicKey` reference requires of the key it points at, + * beyond its existence and its not being disabled. + */ +export type IdentityKeyReferenceRequirements = { + purpose?: 'authentication' | 'encryption' | 'decryption' | 'transfer' | 'voting' | 'owner'; + boundTo?: string; +}; + export type DocumentPropertyReferenceTarget = | { type: 'identity' } | { @@ -106,6 +118,7 @@ export type DocumentPropertyReferenceTarget = * identity id. A dotted path when the property is nested. */ keyIdProperty: string; + identityProperty?: never; /** * What the referenced key must be beyond existing and not being * disabled, checked by consensus when the referring document is @@ -117,16 +130,36 @@ export type DocumentPropertyReferenceTarget = * when either is unmet). Absent when the declaration carries no * requirement. */ - keyRequirements?: { - purpose?: - | 'authentication' - | 'encryption' - | 'decryption' - | 'transfer' - | 'voting' - | 'owner'; - boundTo?: string; - }; + keyRequirements?: IdentityKeyReferenceRequirements; + } + | { + /** + * The inverse form, declared on the key id property itself: the + * declaring property (an integer from 0 to 4294967295) carries the + * key id, and `identityProperty` names whose key it is: `'$ownerId'`, + * the document's owner, `'$creatorId'`, its creator (only on a + * document type that records creator ids), or the dotted path of an + * identifier property of the same document type. Consensus fetches + * only the key (codes 40123 when it does not exist, 40124 when it is + * disabled; 40125 when a key id is set while the named property is + * not), and checks `keyRequirements` against it exactly as for the + * identifier form. + */ + type: 'identityPublicKey'; + identityProperty: '$ownerId' | '$creatorId' | (string & {}); + keyIdProperty?: never; + /** + * What the referenced key must be beyond existing and not being + * disabled, checked by consensus when the referring document is + * written against the key fetched for the existence check: + * `purpose` requires the key's purpose to be the named one, and + * `boundTo` requires the key's contract bounds to be exactly the + * declaring contract and the named document type of it; a + * whole-contract or contract group bound never meets it (code 40136 + * when either is unmet). Absent when the declaration carries no + * requirement. + */ + keyRequirements?: IdentityKeyReferenceRequirements; } | { /** @@ -192,6 +225,59 @@ fn set_field(target: &Object, key: &str, value: &JsValue, path: &str) -> WasmDpp Ok(()) } +/// The flat, internally-tagged JS object for an `identityPublicKey` +/// declaration on the key id property: `identityProperty` names whose key +/// the property's value is. +fn key_id_reference_to_js(path: &str, reference: &KeyIdReference) -> WasmDppResult { + let object = Object::new(); + set_field(&object, "path", &JsValue::from_str(path), path)?; + set_field( + &object, + "type", + &JsValue::from_str("identityPublicKey"), + path, + )?; + set_field( + &object, + "identityProperty", + &JsValue::from_str(reference.identity_property.as_str()), + path, + )?; + set_key_requirements_field(&object, &reference.key_requirements, path)?; + Ok(object.into()) +} + +/// The `keyRequirements` field of either `identityPublicKey` form. Absent, +/// not `{}`-valued, when the declaration requires nothing, matching the +/// schema's own omission. +fn set_key_requirements_field( + object: &Object, + key_requirements: &IdentityKeyReferenceRequirements, + path: &str, +) -> WasmDppResult<()> { + if key_requirements.is_empty() { + return Ok(()); + } + let fields = Object::new(); + if let Some(purpose) = key_requirements.purpose { + set_field( + &fields, + "purpose", + &JsValue::from_str(purpose.wire_name()), + path, + )?; + } + if let Some(document_type_name) = &key_requirements.bound_to { + set_field( + &fields, + "boundTo", + &JsValue::from_str(document_type_name), + path, + )?; + } + set_field(object, "keyRequirements", &fields, path) +} + /// Build the flat, internally-tagged JS object for one declaration. /// /// `declaring_contract_id` resolves the document variants' @@ -310,28 +396,7 @@ fn reference_to_js( &JsValue::from_str(key_id_property), path, )?; - // Absent, not `{}`-valued, when the declaration requires nothing, - // matching the schema's own omission. - if !key_requirements.is_empty() { - let fields = Object::new(); - if let Some(purpose) = key_requirements.purpose { - set_field( - &fields, - "purpose", - &JsValue::from_str(purpose.wire_name()), - path, - )?; - } - if let Some(document_type_name) = &key_requirements.bound_to { - set_field( - &fields, - "boundTo", - &JsValue::from_str(document_type_name), - path, - )?; - } - set_field(&object, "keyRequirements", &fields, path)?; - } + set_key_requirements_field(&object, key_requirements, path)?; } } @@ -352,8 +417,14 @@ pub(crate) fn references_for_document_type( let references = Array::new(); for (path, property) in document_type.flattened_properties() { - if let DocumentPropertyType::IdentifierWithReference(target) = &property.property_type { - references.push(&reference_to_js(path, target, declaring_contract_id)?); + match &property.property_type { + DocumentPropertyType::IdentifierWithReference(target) => { + references.push(&reference_to_js(path, target, declaring_contract_id)?); + } + DocumentPropertyType::KeyIdWithReference(reference) => { + references.push(&key_id_reference_to_js(path, reference)?); + } + _ => {} } } diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index 5bdb32acdec..c1e2cde8630 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -24,7 +24,9 @@ const foreignContractId = '4fJLR2GYTPFdomuTVvNy3VRrvWgvkKPzqehEBpNf2nk6'; /** * `refersTo` is only allowed on properties with exactly this shape — a - * 32-byte identifier. The meta-schema rejects it anywhere else. + * 32-byte identifier. The one exception is the `identityPublicKey` form + * declared on the key id property itself (`senderKeyId` below). The + * meta-schema rejects it anywhere else. */ function identifierProperty(position: number, refersTo: object): object { return { @@ -89,6 +91,30 @@ const schemas = { keyRequirements: { purpose: 'decryption', boundTo: 'note' }, }), recipientKeyId: { type: 'integer', position: 9, minimum: 0 }, + // The inverse key reference: the property carries the key id and the + // declaration names whose key it is (the writer's), so it sits on a + // u32 integer rather than an identifier; `keyRequirements` apply to it + // exactly as to the identifier form. + senderKeyId: { + type: 'integer', + minimum: 0, + maximum: 4294967295, + position: 10, + refersTo: { + type: 'identityPublicKey', + identityProperty: '$ownerId', + keyRequirements: { purpose: 'encryption' }, + }, + }, + // The same form naming an identifier property of this type: the key + // is one of `author`'s. + authorKeyId: { + type: 'integer', + minimum: 0, + maximum: 4294967295, + position: 11, + refersTo: { type: 'identityPublicKey', identityProperty: 'author' }, + }, }, additionalProperties: false, }, @@ -119,6 +145,7 @@ type Reference = { documentType?: string; keyIdProperty?: string; keyRequirements?: { purpose?: string; boundTo?: string }; + identityProperty?: string; propertyAgreement?: Record; }; @@ -137,6 +164,8 @@ describe('DataContract — refersTo declarations (v14)', () => { 'signerKey', 'meta.ownerRef', 'recipientKey', + 'senderKeyId', + 'authorKeyId', ]); }); @@ -153,6 +182,8 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(byPath.get('signerKey')!.type).to.equal('identityPublicKey'); expect(byPath.get('meta.ownerRef')!.type).to.equal('identity'); expect(byPath.get('recipientKey')!.type).to.equal('identityPublicKey'); + expect(byPath.get('senderKeyId')!.type).to.equal('identityPublicKey'); + expect(byPath.get('authorKeyId')!.type).to.equal('identityPublicKey'); }); it('should carry no target fields for the bare kinds', () => { @@ -214,6 +245,25 @@ describe('DataContract — refersTo declarations (v14)', () => { const signerKey = references.find((reference) => reference.path === 'signerKey')!; expect(signerKey.keyIdProperty).to.equal('signerKeyId'); + expect(signerKey.identityProperty).to.equal(undefined); + }); + + it('should carry identityProperty for an identityPublicKey reference on the key id property', () => { + const contract = buildContract(14); + const references = contract.documentTypeReferences('note') as Reference[]; + const senderKeyId = references.find((reference) => reference.path === 'senderKeyId')!; + + expect(senderKeyId).to.deep.equal({ + path: 'senderKeyId', + type: 'identityPublicKey', + identityProperty: '$ownerId', + keyRequirements: { purpose: 'encryption' }, + }); + + const authorKeyId = references.find((reference) => reference.path === 'authorKeyId')!; + expect(authorKeyId.identityProperty).to.equal('author'); + expect(authorKeyId.keyIdProperty).to.equal(undefined); + expect(authorKeyId).to.not.have.property('keyRequirements'); }); it('should carry keyRequirements when declared and omit them otherwise', () => {