From f963dd8cdb6821400577f1d1566270db5e93d968 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 13 Aug 2026 15:49:52 +0700 Subject: [PATCH] feat(platform)!: identity public key references (refersTo identityPublicKey) Extends refersTo with an identityPublicKey target: the property value holds the referenced identity's id and the declaration names a sibling integer property of the same document type (keyIdProperty) whose value carries the key id. Identity keys can be disabled but never removed, so an existing reference can never dangle; at write time the key must exist and must not be disabled (a key disabled later does not invalidate old documents). The billed existence check goes through fetch_identity_keys with a specific-key request, billed as one key lookup, matching how signature validation fetches keys. Contract registration (the create/update reference validation from the permanentDocument work) checks that the declared keyIdProperty exists in the same document type and is an integer. New state errors: ReferencedIdentityKeyNotFoundError (40123, also covers a missing identity), ReferencedIdentityKeyDisabledError (40124) and ReferencedKeyIdPropertyInvalidError (40125), discriminants pinned. Co-Authored-By: Claude Fable 5 --- .../document/v3/document-meta.json | 52 +++- .../class_methods/try_from_schema/mod.rs | 83 +++++++ .../src/data_contract/document_type/mod.rs | 1 + .../document_type/property/mod.rs | 14 ++ packages/rs-dpp/src/errors/consensus/codes.rs | 3 + .../errors/consensus/state/document/mod.rs | 3 + .../referenced_identity_key_disabled_error.rs | 52 ++++ ...referenced_identity_key_not_found_error.rs | 52 ++++ ...eferenced_key_id_property_invalid_error.rs | 50 ++++ .../src/errors/consensus/state/state_error.rs | 42 ++++ .../src/validation/meta_validators/mod.rs | 64 +++++ .../document_reference_validation/v0/mod.rs | 74 ++++++ .../batch/tests/document/creation.rs | 223 ++++++++++++++++++ .../v0/mod.rs | 70 +++++- .../data_contract_create/mod.rs | 49 ++++ ...dentity-key-registration-missing-prop.json | 32 +++ ...identity-key-registration-non-integer.json | 32 +++ ...tract-identity-key-registration-valid.json | 32 +++ ...ence-validation-contract-identity-key.json | 38 +++ .../src/errors/consensus/consensus_error.rs | 12 + 20 files changed, 952 insertions(+), 26 deletions(-) create mode 100644 packages/rs-dpp/src/errors/consensus/state/document/referenced_identity_key_disabled_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/document/referenced_identity_key_not_found_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/state/document/referenced_key_id_property_invalid_error.rs create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-missing-prop.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-non-integer.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-valid.json create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key.json 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 473ff4a3f6..f5fb020a8b 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 @@ -99,7 +99,8 @@ "identity", "contract", "token", - "permanentDocument" + "permanentDocument", + "identityPublicKey" ] }, "contractId": { @@ -129,25 +130,50 @@ "minLength": 1, "maxLength": 64, "pattern": "^[a-zA-Z0-9-_]{1,64}$" + }, + "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", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-zA-Z0-9-_]{1,64}(\\.[a-zA-Z0-9-_]{1,64})*$" } }, "required": [ "type" ], "additionalProperties": false, - "if": { - "properties": { "type": { "const": "permanentDocument" } }, - "required": ["type"] - }, - "then": { - "required": ["type", "documentType"] - }, - "else": { - "properties": { - "contractId": false, - "documentType": false + "allOf": [ + { + "if": { + "properties": { "type": { "const": "permanentDocument" } }, + "required": ["type"] + }, + "then": { + "required": ["type", "documentType"] + }, + "else": { + "properties": { + "contractId": false, + "documentType": false + } + } + }, + { + "if": { + "properties": { "type": { "const": "identityPublicKey" } }, + "required": ["type"] + }, + "then": { + "required": ["type", "keyIdProperty"] + }, + "else": { + "properties": { + "keyIdProperty": false + } + } } - } + ] }, "contains": { "$ref": "https://json-schema.org/draft/2020-12/meta/applicator#/properties/contains" 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 2481cb9c77..db391c3456 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 @@ -382,6 +382,22 @@ fn apply_property_reference_v0( document_type_name: document_type_name.to_string(), } } + "identityPublicKey" => { + let key_id_property = refers_to_map + .get_str(property_names::KEY_ID_PROPERTY) + .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; + + if key_id_property.is_empty() || key_id_property.len() > 256 { + return Err(DataContractError::InvalidContractStructure( + "identityPublicKey refersTo keyIdProperty must be between 1 and 256 characters" + .to_string(), + )); + } + + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: key_id_property.to_string(), + } + } other => { return Err(DataContractError::InvalidContractStructure(format!( "invalid refersTo type {other}" @@ -658,6 +674,73 @@ mod tests { .expect_err("should fail"); } + #[test] + fn should_parse_identity_public_key_refers_to() { + let document_type = try_document_type_from_schema(json!({ + "type": "object", + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "toKeyIndex" + } + }, + "toKeyIndex": { + "type": "integer", + "position": 1 + } + }, + "required": [], + "additionalProperties": false + })) + .expect("should parse"); + + let property_type = document_type + .as_ref() + .flattened_properties() + .get("toUserId") + .map(|p| p.property_type.clone()) + .expect("property should be present"); + + assert_eq!( + property_type, + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: "toKeyIndex".to_string(), + } + ) + ); + } + + #[test] + fn should_reject_identity_public_key_refers_to_without_key_id_property() { + try_document_type_from_schema(json!({ + "type": "object", + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "identityPublicKey" + } + } + }, + "required": [], + "additionalProperties": false + })) + .expect_err("should fail"); + } + #[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/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 158995b556..3492b39957 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -82,6 +82,7 @@ pub(crate) mod property_names { pub const REFERS_TO: &str = "refersTo"; pub const CONTRACT_ID: &str = "contractId"; pub const DOCUMENT_TYPE: &str = "documentType"; + pub const KEY_ID_PROPERTY: &str = "keyIdProperty"; pub const DOCUMENTS_COUNTABLE: &str = "documentsCountable"; pub const RANGE_COUNTABLE: &str = "rangeCountable"; /// Doctype-level flag naming the property whose values are summed into 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 bfb3cb593f..7b1dc041af 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 @@ -75,6 +75,17 @@ pub enum DocumentPropertyReferenceTarget { contract_id: Option, document_type_name: String, }, + /// A specific public key of an identity: the property value holds the + /// identity id and the named sibling property of the same document type + /// holds the key id. Identity keys can be disabled but never removed, so + /// an existing reference can never dangle; at write time the key must + /// exist and must not be disabled. + #[serde(rename = "identityPublicKey")] + IdentityPublicKey { + /// The property of the same document type whose value carries the + /// referenced key id + key_id_property: String, + }, } impl std::fmt::Display for DocumentPropertyReferenceTarget { @@ -97,6 +108,9 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { f, "permanent document (own contract, document type {document_type_name})" ), + DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } => { + write!(f, "identity public key (key id property {key_id_property})") + } } } } diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 0d57e72afa..6fa1b64d6c 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -314,6 +314,9 @@ impl ErrorWithCode for StateError { Self::ReferencedEntityNotFoundError(_) => 40120, Self::ReferencedDocumentTypeNotFoundError(_) => 40121, Self::ReferencedDocumentTypeDeletableError(_) => 40122, + Self::ReferencedIdentityKeyNotFoundError(_) => 40123, + Self::ReferencedIdentityKeyDisabledError(_) => 40124, + Self::ReferencedKeyIdPropertyInvalidError(_) => 40125, // Identity Errors: 40200-40299 Self::IdentityAlreadyExistsError(_) => 40200, diff --git a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs index 3b7996c91e..e5702e70fc 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs @@ -18,3 +18,6 @@ pub mod invalid_document_revision_error; pub mod referenced_document_type_deletable_error; pub mod referenced_document_type_not_found_error; pub mod referenced_entity_not_found_error; +pub mod referenced_identity_key_disabled_error; +pub mod referenced_identity_key_not_found_error; +pub mod referenced_key_id_property_invalid_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_identity_key_disabled_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_identity_key_disabled_error.rs new file mode 100644 index 0000000000..e006c18f7a --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_identity_key_disabled_error.rs @@ -0,0 +1,52 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::identity::KeyID; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("referenced public key {key_id} of identity {identity_id} is disabled for path {path}")] +#[platform_serialize(unversioned)] +pub struct ReferencedIdentityKeyDisabledError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + identity_id: Identifier, + key_id: KeyID, + path: String, +} + +impl ReferencedIdentityKeyDisabledError { + pub fn new(identity_id: Identifier, key_id: KeyID, path: String) -> Self { + Self { + identity_id, + key_id, + path, + } + } + + pub fn identity_id(&self) -> &Identifier { + &self.identity_id + } + + pub fn key_id(&self) -> KeyID { + self.key_id + } + + pub fn path(&self) -> &str { + &self.path + } +} + +impl From for ConsensusError { + fn from(err: ReferencedIdentityKeyDisabledError) -> Self { + Self::StateError(StateError::ReferencedIdentityKeyDisabledError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_identity_key_not_found_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_identity_key_not_found_error.rs new file mode 100644 index 0000000000..95726dc449 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_identity_key_not_found_error.rs @@ -0,0 +1,52 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::identity::KeyID; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("referenced public key {key_id} of identity {identity_id} not found for path {path}")] +#[platform_serialize(unversioned)] +pub struct ReferencedIdentityKeyNotFoundError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + identity_id: Identifier, + key_id: KeyID, + path: String, +} + +impl ReferencedIdentityKeyNotFoundError { + pub fn new(identity_id: Identifier, key_id: KeyID, path: String) -> Self { + Self { + identity_id, + key_id, + path, + } + } + + pub fn identity_id(&self) -> &Identifier { + &self.identity_id + } + + pub fn key_id(&self) -> KeyID { + self.key_id + } + + pub fn path(&self) -> &str { + &self.path + } +} + +impl From for ConsensusError { + fn from(err: ReferencedIdentityKeyNotFoundError) -> Self { + Self::StateError(StateError::ReferencedIdentityKeyNotFoundError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_key_id_property_invalid_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_key_id_property_invalid_error.rs new file mode 100644 index 0000000000..3ea5000956 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_key_id_property_invalid_error.rs @@ -0,0 +1,50 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("keyIdProperty {key_id_property} referenced at path {path} is invalid: {message}")] +#[platform_serialize(unversioned)] +pub struct ReferencedKeyIdPropertyInvalidError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + key_id_property: String, + path: String, + message: String, +} + +impl ReferencedKeyIdPropertyInvalidError { + pub fn new(key_id_property: String, path: String, message: String) -> Self { + Self { + key_id_property, + path, + message, + } + } + + pub fn key_id_property(&self) -> &str { + &self.key_id_property + } + + pub fn path(&self) -> &str { + &self.path + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl From for ConsensusError { + fn from(err: ReferencedKeyIdPropertyInvalidError) -> Self { + Self::StateError(StateError::ReferencedKeyIdPropertyInvalidError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index cb54245057..aa63ff7779 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -44,6 +44,9 @@ use crate::consensus::state::document::document_contest_not_required_error::Docu use crate::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; use crate::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; use crate::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; +use crate::consensus::state::document::referenced_identity_key_disabled_error::ReferencedIdentityKeyDisabledError; +use crate::consensus::state::document::referenced_identity_key_not_found_error::ReferencedIdentityKeyNotFoundError; +use crate::consensus::state::document::referenced_key_id_property_invalid_error::ReferencedKeyIdPropertyInvalidError; use crate::consensus::state::document::document_incorrect_purchase_price_error::DocumentIncorrectPurchasePriceError; use crate::consensus::state::document::document_not_for_sale_error::DocumentNotForSaleError; use crate::consensus::state::group::{GroupActionAlreadyCompletedError, GroupActionAlreadySignedByIdentityError, GroupActionDoesNotExistError, IdentityMemberOfGroupNotFoundError, IdentityNotMemberOfGroupError, ModificationOfGroupActionMainParametersNotPermittedError}; @@ -374,6 +377,15 @@ pub enum StateError { #[error(transparent)] ReferencedDocumentTypeDeletableError(ReferencedDocumentTypeDeletableError), + + #[error(transparent)] + ReferencedIdentityKeyNotFoundError(ReferencedIdentityKeyNotFoundError), + + #[error(transparent)] + ReferencedIdentityKeyDisabledError(ReferencedIdentityKeyDisabledError), + + #[error(transparent)] + ReferencedKeyIdPropertyInvalidError(ReferencedKeyIdPropertyInvalidError), } impl From for ConsensusError { @@ -470,5 +482,35 @@ mod tests { )), 95 ); + assert_eq!( + discriminant_of(StateError::ReferencedIdentityKeyNotFoundError( + ReferencedIdentityKeyNotFoundError::new( + Identifier::from([1; 32]), + 2, + "toUserId".to_string(), + ) + )), + 96 + ); + assert_eq!( + discriminant_of(StateError::ReferencedIdentityKeyDisabledError( + ReferencedIdentityKeyDisabledError::new( + Identifier::from([1; 32]), + 2, + "toUserId".to_string(), + ) + )), + 97 + ); + assert_eq!( + discriminant_of(StateError::ReferencedKeyIdPropertyInvalidError( + ReferencedKeyIdPropertyInvalidError::new( + "recipientKeyIndex".to_string(), + "toUserId".to_string(), + "missing".to_string(), + ) + )), + 98 + ); } } diff --git a/packages/rs-dpp/src/validation/meta_validators/mod.rs b/packages/rs-dpp/src/validation/meta_validators/mod.rs index d05da98b1e..55d2bd4f05 100644 --- a/packages/rs-dpp/src/validation/meta_validators/mod.rs +++ b/packages/rs-dpp/src/validation/meta_validators/mod.rs @@ -425,6 +425,70 @@ mod tests { } } + #[test] + fn should_accept_identity_public_key_refers_to_in_v3_document_schema() { + let schema = document_schema_with_refers_to(json!({ + "type": "identityPublicKey", + "keyIdProperty": "toKeyIndex" + })); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_ok(), + "expected identityPublicKey refersTo to be valid" + ); + } + + #[test] + fn should_reject_identity_public_key_refers_to_without_key_id_property() { + let schema = document_schema_with_refers_to(json!({ + "type": "identityPublicKey" + })); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_err(), + "expected identityPublicKey refersTo without keyIdProperty to be invalid" + ); + } + + #[test] + fn should_reject_key_id_property_on_non_key_refers_to_targets() { + for target in ["identity", "contract", "token", "permanentDocument"] { + let mut refers_to = serde_json::json!({ + "type": target, + "keyIdProperty": "toKeyIndex" + }); + if target == "permanentDocument" { + refers_to["documentType"] = serde_json::json!("note"); + } + + let schema = document_schema_with_refers_to(refers_to); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_err(), + "expected keyIdProperty on a {target} target to be invalid" + ); + } + } + + #[test] + fn should_reject_identity_public_key_refers_to_with_invalid_key_id_property() { + for bad in [ + serde_json::json!(""), + serde_json::json!("bad name!"), + serde_json::json!(3), + ] { + let schema = document_schema_with_refers_to(json!({ + "type": "identityPublicKey", + "keyIdProperty": bad + })); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_err(), + "expected invalid keyIdProperty {bad} to be rejected" + ); + } + } + #[test] fn should_reject_refers_to_with_unknown_properties() { let schema = document_schema_with_refers_to(json!({ diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs index d444bd7cb0..c1e49f09e8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs @@ -14,11 +14,19 @@ use dpp::data_contract::DataContract; use dpp::errors::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; use dpp::errors::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; use dpp::errors::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; +use dpp::errors::consensus::state::document::referenced_identity_key_disabled_error::ReferencedIdentityKeyDisabledError; +use dpp::errors::consensus::state::document::referenced_identity_key_not_found_error::ReferencedIdentityKeyNotFoundError; +use dpp::errors::consensus::state::document::referenced_key_id_property_invalid_error::ReferencedKeyIdPropertyInvalidError; use dpp::identifier::Identifier; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::KeyID; use dpp::platform_value::btreemap_extensions::BTreeValueMapPathHelper; use dpp::platform_value::Value; use dpp::validation::SimpleConsensusValidationResult; use dpp::version::PlatformVersion; +use drive::drive::identity::key::fetch::{ + IdentityKeysRequest, OptionalSingleIdentityPublicKeyOutcome, +}; use drive::query::TransactionArg; use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionAction; use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; @@ -258,6 +266,72 @@ fn validate_document_type_references_v0( )? .is_some() } + DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } => { + // The referenced key id is carried by the named sibling property + let key_id: KeyID = + match document_data.get_optional_integer_at_path(key_id_property) { + Ok(Some(key_id)) => key_id, + Ok(None) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedKeyIdPropertyInvalidError::new( + key_id_property.clone(), + path.to_string(), + "the key id property is not set".to_string(), + ) + .into(), + )) + } + Err(err) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedKeyIdPropertyInvalidError::new( + key_id_property.clone(), + path.to_string(), + err.to_string(), + ) + .into(), + )) + } + }; + + 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(), + )); + } + + true + } }; if !exists { 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 70120bda45..bdc120d9b4 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 @@ -36,6 +36,8 @@ mod creation_tests { use dpp::dashcore::Network::Testnet; use dpp::data_contract::{DataContract, TokenConfiguration}; use dpp::document::transfer::Transferable; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::KeyID; use dpp::identity::SecurityLevel; use dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; use dpp::state_transition::batch_transition::document_create_transition::DocumentCreateTransitionV0; @@ -5309,4 +5311,225 @@ mod creation_tests { } ); } + + const REFERENCE_VALIDATION_IDENTITY_KEY_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key.json"; + + /// Committed state the identity-key reference tests can point at: the test + /// identity (which has an enabled critical authentication key and a master + /// key that the helper disables in state). + struct IdentityKeyReferenceTargets { + identity_id: Identifier, + enabled_key_id: KeyID, + disabled_key_id: KeyID, + } + + /// Registers the identity-key fixture contract, disables the test + /// identity's master key in state, then creates a `message` document + /// mutated by the test and returns the execution result. + async fn run_identity_key_reference_creation(mutator: F) -> StateTransitionExecutionResult + where + F: FnOnce(&mut Document, &IdentityKeyReferenceTargets), + { + 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(433); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, 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 + 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 = IdentityKeyReferenceTargets { + identity_id: identity.id(), + enabled_key_id: key.id(), + disabled_key_id: 0, + }; + + let contract = setup_contract( + &platform.drive, + REFERENCE_VALIDATION_IDENTITY_KEY_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 reference properties are optional; each test sets only + // what it exercises + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random message document"); + + 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_referenced_identity_key_exists() { + let result = run_identity_key_reference_creation(|document, targets| { + document.set("toUserId", targets.identity_id.into()); + document.set("toKeyIndex", (targets.enabled_key_id as i64).into()); + }) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_referenced_identity_key_missing() { + let result = run_identity_key_reference_creation(|document, targets| { + document.set("toUserId", targets.identity_id.into()); + document.set("toKeyIndex", 99i64.into()); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + _ + )), + .. + } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_key_referenced_identity_missing() { + let result = run_identity_key_reference_creation(|document, _| { + document.set("toUserId", Identifier::random().into()); + document.set("toKeyIndex", 0i64.into()); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + _ + )), + .. + } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_referenced_identity_key_is_disabled() { + let result = run_identity_key_reference_creation(|document, targets| { + document.set("toUserId", targets.identity_id.into()); + document.set("toKeyIndex", (targets.disabled_key_id as i64).into()); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyDisabledError( + _ + )), + .. + } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_key_id_property_not_set() { + let result = run_identity_key_reference_creation(|document, targets| { + document.set("toUserId", targets.identity_id.into()); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedKeyIdPropertyInvalidError( + _ + )), + .. + } + ); + } } 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 1d319d8531..01f387b1fb 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 @@ -5,6 +5,7 @@ use dpp::data_contract::document_type::{DocumentPropertyReferenceTarget, Documen use dpp::data_contract::DataContract; use dpp::errors::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; use dpp::errors::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; +use dpp::errors::consensus::state::document::referenced_key_id_property_invalid_error::ReferencedKeyIdPropertyInvalidError; use dpp::identifier::Identifier; use dpp::validation::SimpleConsensusValidationResult; use dpp::version::PlatformVersion; @@ -21,13 +22,18 @@ use crate::execution::types::state_transition_execution_context::{ StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, }; -/// Checks every `permanentDocument` reference declaration of the given -/// contract: the referenced contract must exist (the declaring contract -/// itself when no contract id is named, including when it names its own id), -/// the referenced document type must exist in it, and that type must forbid -/// deletion. Self references are checked against the in-flight contract, so a -/// contract may reference its own document types on creation; foreign contract -/// fetches are billed. +/// Checks every reference declaration of the given contract that carries +/// declaration content. +/// +/// `permanentDocument`: the referenced contract must exist (the declaring +/// contract itself when no contract id is named, including when it names its +/// own id), the referenced document type must exist in it, and that type must +/// forbid deletion. Self references are checked against the in-flight +/// contract, so a contract may reference its own document types on creation; +/// foreign contract fetches are billed. +/// +/// `identityPublicKey`: the declared key id property must exist in the same +/// document type and be an integer. /// /// The error paths name the failing declaration as /// `documentTypeName.propertyPath`. Validation stops at the first invalid @@ -50,18 +56,56 @@ 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( - DocumentPropertyReferenceTarget::PermanentDocument { - contract_id, - document_type_name, - }, - ) = &property.property_type + let DocumentPropertyType::IdentifierWithReference(reference_target) = + &property.property_type else { continue; }; let declaration_path = format!("{declaring_type_name}.{path}"); + // 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 { key_id_property } = + reference_target + { + match document_type + .as_ref() + .flattened_properties() + .get(key_id_property) + { + None => { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedKeyIdPropertyInvalidError::new( + key_id_property.clone(), + declaration_path, + "the document type does not define this property".to_string(), + ) + .into(), + )); + } + Some(key_property) if !key_property.property_type.is_integer() => { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedKeyIdPropertyInvalidError::new( + key_id_property.clone(), + declaration_path, + "the property must be an integer".to_string(), + ) + .into(), + )); + } + Some(_) => continue, + } + } + + let DocumentPropertyReferenceTarget::PermanentDocument { + contract_id, + document_type_name, + } = reference_target + else { + continue; + }; + let effective_contract_id = contract_id.unwrap_or(contract.id()); let referenced_contract_fetch_info; 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 20c4a9ba5f..8983c91b39 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 @@ -5370,6 +5370,55 @@ mod tests { ); } + #[tokio::test] + async fn should_register_contract_with_valid_identity_key_reference() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-valid.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_reject_contract_with_undefined_key_id_property() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-missing-prop.json", + ) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedKeyIdPropertyInvalidError(_) + ), + .. + } + ); + } + + #[tokio::test] + async fn should_reject_contract_with_non_integer_key_id_property() { + let result = run_contract_create( + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-non-integer.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-identity-key-registration-missing-prop.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-missing-prop.json new file mode 100644 index 0000000000..ae210c3bad --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-missing-prop.json @@ -0,0 +1,32 @@ +{ + "$formatVersion": "1", + "id": "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "ghostKeyIndex" + } + }, + "toKeyIndex": { + "type": "integer", + "position": 1, + "minimum": 0 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-non-integer.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-non-integer.json new file mode 100644 index 0000000000..128a4e3bd6 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-non-integer.json @@ -0,0 +1,32 @@ +{ + "$formatVersion": "1", + "id": "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "keyName" + } + }, + "keyName": { + "type": "string", + "position": 1, + "maxLength": 32 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-valid.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-valid.json new file mode 100644 index 0000000000..7afe6fe08c --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-registration-valid.json @@ -0,0 +1,32 @@ +{ + "$formatVersion": "1", + "id": "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "toKeyIndex" + } + }, + "toKeyIndex": { + "type": "integer", + "position": 1, + "minimum": 0 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key.json new file mode 100644 index 0000000000..ce980570bc --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key.json @@ -0,0 +1,38 @@ +{ + "$formatVersion": "1", + "id": "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", + "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": "toKeyIndex" + } + }, + "toKeyIndex": { + "type": "integer", + "position": 1, + "minimum": 0 + }, + "note": { + "type": "string", + "position": 2, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 91ff55de7e..7a1b2245af 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -93,6 +93,9 @@ use dpp::consensus::state::prefunded_specialized_balances::prefunded_specialized use dpp::consensus::state::token::{IdentityDoesNotHaveEnoughTokenBalanceError, IdentityTokenAccountNotFrozenError, IdentityTokenAccountFrozenError, TokenIsPausedError, IdentityTokenAccountAlreadyFrozenError, UnauthorizedTokenActionError, TokenSettingMaxSupplyToLessThanCurrentSupplyError, TokenMintPastMaxSupplyError, NewTokensDestinationIdentityDoesNotExistError, NewAuthorizedActionTakerIdentityDoesNotExistError, NewAuthorizedActionTakerGroupDoesNotExistError, NewAuthorizedActionTakerMainGroupNotSetError, InvalidGroupPositionError, TokenAlreadyPausedError, TokenNotPausedError, InvalidTokenClaimPropertyMismatch, InvalidTokenClaimNoCurrentRewards, InvalidTokenClaimWrongClaimant, TokenTransferRecipientIdentityNotExistError, PreProgrammedDistributionTimestampInPastError, IdentityHasNotAgreedToPayRequiredTokenAmountError, RequiredTokenPaymentInfoNotSetError, IdentityTryingToPayWithWrongTokenError, TokenDirectPurchaseUserPriceTooLow, TokenAmountUnderMinimumSaleAmount, TokenNotForDirectSale, InvalidTokenPositionStateError}; use dpp::consensus::state::address_funds::{AddressDoesNotExistError, AddressInvalidNonceError, AddressNotEnoughFundsError, AddressesNotEnoughFundsError}; use dpp::consensus::state::document::referenced_document_type_deletable_error::ReferencedDocumentTypeDeletableError; +use dpp::consensus::state::document::referenced_identity_key_disabled_error::ReferencedIdentityKeyDisabledError; +use dpp::consensus::state::document::referenced_identity_key_not_found_error::ReferencedIdentityKeyNotFoundError; +use dpp::consensus::state::document::referenced_key_id_property_invalid_error::ReferencedKeyIdPropertyInvalidError; use dpp::consensus::state::document::referenced_document_type_not_found_error::ReferencedDocumentTypeNotFoundError; use dpp::consensus::state::shielded::insufficient_pool_notes_error::InsufficientPoolNotesError; use dpp::consensus::state::shielded::insufficient_shielded_fee_error::InsufficientShieldedFeeError; @@ -483,6 +486,15 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::ReferencedDocumentTypeDeletableError(e) => { generic_consensus_error!(ReferencedDocumentTypeDeletableError, e).into() } + StateError::ReferencedIdentityKeyNotFoundError(e) => { + generic_consensus_error!(ReferencedIdentityKeyNotFoundError, e).into() + } + StateError::ReferencedIdentityKeyDisabledError(e) => { + generic_consensus_error!(ReferencedIdentityKeyDisabledError, e).into() + } + StateError::ReferencedKeyIdPropertyInvalidError(e) => { + generic_consensus_error!(ReferencedKeyIdPropertyInvalidError, e).into() + } } }