From 901ebc65eaf773b235d0418a07b8a194da5511c6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 17 Sep 2026 01:02:26 +0700 Subject: [PATCH 1/3] feat(platform)!: allow contract-bound authentication keys Protocol 14 lets an identity register an AUTHENTICATION key whose existing contractBounds (singleContract or documentType) restrict what it may sign. No wire format changes: only which keys may carry bounds and what the bounds mean for signing. Consensus (protocol 14 tables only): - contract-bounds validation v2 admits non-MASTER authentication keys on any existing contract and document type; encryption and decryption keep v1. - identity-signature validation v1 refuses a bound authentication key on any non-Batch transition (ContractBoundedKeyNonBatchError, unpaid). - batch advanced-structure v1 requires every member to be inside the signing key's bounds (ContractBoundedKeyOutOfBoundsError, paid, nonce bumped). Token operations are contract-wide and never covered by a document-type bound. - identity create (asset lock, addresses, shielded pool) state v1 validates key bounds at creation; identity update state v1 retains the contract lookup fees in the caller's context. Drive (DRIVE_VERSION_V9 / identity methods V2): - contract-info indexing v1 stores bound authentication keys under a new AUTHENTICATION purpose subtree per bound group, with the current-key alias inside that subtree; refresh v1 maintains it on revocation (untrusted, so revoking an older key never repoints the alias at it). - apply_batch_low_level_drive_operations v1 coalesces alias writes per slot across a whole identity update: an insertion beats a refresh and the highest key id wins. - disable_identity_keys v1 estimates fees from the stored keys so a bound key's reference refreshes are priced. - all-keys listings of an AUTHENTICATION purpose subtree skip the alias. SDK signing helpers refuse to sign a transition the bounds do not cover. The consensus and Drive plumbing is carried over from #4613, without its scope type, permission mask and expiry. Co-Authored-By: pasta Co-Authored-By: Claude Fable 5.1 --- .../contract-bound-authentication-keys.md | 56 ++ packages/rs-dpp/src/errors/consensus/codes.rs | 2 + .../contract_bounded_key_non_batch_error.rs | 40 + ...ontract_bounded_key_out_of_bounds_error.rs | 40 + .../src/errors/consensus/signature/mod.rs | 6 + .../consensus/signature/signature_error.rs | 48 ++ .../contract_bounds/mod.rs | 127 +++ packages/rs-dpp/src/state_transition/mod.rs | 115 +++ .../mod.rs | 96 ++- .../v2/mod.rs | 87 ++ .../mod.rs | 13 +- .../v1/mod.rs | 65 ++ .../processor/traits/state.rs | 3 + .../batch/advanced_structure/mod.rs | 1 + .../batch/advanced_structure/v1/mod.rs | 286 +++++++ .../state_transitions/batch/mod.rs | 39 +- .../batch/tests/contract_bound_auth.rs | 428 ++++++++++ .../state_transitions/batch/tests/mod.rs | 2 + .../state_transitions/identity_create/mod.rs | 188 ++++- .../identity_create/state/mod.rs | 2 + .../identity_create/state/v1/mod.rs | 114 +++ .../identity_create_from_addresses/mod.rs | 13 +- .../state/mod.rs | 2 + .../state/v1/mod.rs | 116 +++ .../identity_create_from_shielded_pool/mod.rs | 13 +- .../state/mod.rs | 2 + .../state/v1/mod.rs | 148 ++++ .../tests.rs | 113 +++ .../state_transitions/identity_update/mod.rs | 779 +++++++++++++++++- .../identity_update/state/mod.rs | 2 + .../identity_update/state/v1/mod.rs | 162 ++++ .../mod.rs | 12 +- .../v1/mod.rs | 543 ++++++++++++ .../drive/identity/contract_info/keys/mod.rs | 85 +- .../mod.rs | 12 +- .../v1/mod.rs | 386 +++++++++ .../src/drive/identity/key/fetch/mod.rs | 16 +- .../methods/disable_identity_keys/mod.rs | 23 +- .../methods/disable_identity_keys/v1/mod.rs | 185 +++++ .../rs-drive/src/drive/identity/update/mod.rs | 41 +- .../mod.rs | 10 +- .../v1/mod.rs | 87 ++ .../drive_abci_validation_versions/v10.rs | 14 +- .../drive_identity_method_versions/v2.rs | 15 +- .../src/version/drive_versions/v9.rs | 2 +- .../rs-platform-version/src/version/v14.rs | 4 + .../src/errors/consensus/consensus_error.rs | 8 + .../contract_bounded_key_non_batch_error.rs | 29 + ...ontract_bounded_key_out_of_bounds_error.rs | 29 + .../src/errors/consensus/signature/mod.rs | 6 + 50 files changed, 4567 insertions(+), 48 deletions(-) create mode 100644 docs/protocol/contract-bound-authentication-keys.md create mode 100644 packages/rs-dpp/src/errors/consensus/signature/contract_bounded_key_non_batch_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/signature/contract_bounded_key_out_of_bounds_error.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v2/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v1/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/contract_bound_auth.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/v1/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/v1/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v1/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v1/mod.rs create mode 100644 packages/rs-drive/src/drive/identity/update/methods/disable_identity_keys/v1/mod.rs create mode 100644 packages/rs-drive/src/util/operations/apply_batch_low_level_drive_operations/v1/mod.rs create mode 100644 packages/wasm-dpp/src/errors/consensus/signature/contract_bounded_key_non_batch_error.rs create mode 100644 packages/wasm-dpp/src/errors/consensus/signature/contract_bounded_key_out_of_bounds_error.rs diff --git a/docs/protocol/contract-bound-authentication-keys.md b/docs/protocol/contract-bound-authentication-keys.md new file mode 100644 index 00000000000..c3105ae60f7 --- /dev/null +++ b/docs/protocol/contract-bound-authentication-keys.md @@ -0,0 +1,56 @@ +# Contract-bound authentication keys + +Protocol version 14 lets an identity register an AUTHENTICATION key whose +`contractBounds` name one contract, or one contract and one document type. Before +protocol 14 only ENCRYPTION and DECRYPTION keys could carry bounds. The bounds +variants and their encoding are unchanged; only which keys may carry them and what +they mean for signing changes. + +## Registering a bound key + +- The key has AUTHENTICATION purpose and a non-MASTER security level (HIGH is the + usual choice for document operations). +- `contractBounds` is `singleContract { id }` or `documentType { id, documentTypeName }`. +- The contract, and the document type when named, must exist when the key is + registered. Any contract may be bound; no contract opt-in is required, unlike + encryption bounds. +- Several bound authentication keys may cover the same contract. Drive keeps one + current-key pointer per contract (or contract and document type) that names the + newest registered key. + +Registration goes through the normal identity create or identity update flow, signed +by the identity's own keys. Below protocol 14 such a key is rejected as it is today. + +## What a bound key may sign + +A bound authentication key may sign only Batch transitions, and every member of the +batch must be inside the bounds: + +- `singleContract`: document operations of any type on that contract, and token + operations on tokens defined by that contract. +- `documentType`: document operations of that type on that contract. Token operations + are contract-wide and are never covered by a document-type bound. + +Anything else fails signature validation with `ContractBoundedKeyNonBatchError` +(identity updates, contract writes, credit transfers and withdrawals, votes). A batch +with a member outside the bounds fails as a paid validation failure with +`ContractBoundedKeyOutOfBoundsError`: fees are charged and the first member's +identity contract nonce advances, the same handling as other paid batch failures. + +Document security-level requirements, ownership rules and token rules still apply. The +bounds add a restriction; they never grant anything the key's purpose and security +level do not already allow. + +## What bounds do not do + +There are no per-operation permissions, no spending limits and no expiry. A bound key +can perform every document and token operation on its contract, including document +purchases (credits move to the seller) and token transfers, until the identity disables +it through a master-key identity update. Treat a bound key as full authority over the +bound contract, limited in scope but not in amount. + +## Compatibility + +No wire format changes. Clients and wallets that already handle the two bounds +variants decode bound authentication keys today. The SDK signing helpers refuse to sign +a transition the bounds do not cover before it reaches the network. diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 3bf047086e5..fa5bb7f3291 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -265,6 +265,8 @@ impl ErrorWithCode for SignatureError { Self::BasicBLSError(_) => 20010, Self::InvalidSignaturePublicKeyPurposeError(_) => 20011, Self::UncompressedPublicKeyNotAllowedError(_) => 20012, + Self::ContractBoundedKeyOutOfBoundsError(_) => 20014, + Self::ContractBoundedKeyNonBatchError(_) => 20013, } } } diff --git a/packages/rs-dpp/src/errors/consensus/signature/contract_bounded_key_non_batch_error.rs b/packages/rs-dpp/src/errors/consensus/signature/contract_bounded_key_non_batch_error.rs new file mode 100644 index 00000000000..9d64b487383 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/signature/contract_bounded_key_non_batch_error.rs @@ -0,0 +1,40 @@ +use crate::consensus::signature::SignatureError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Contract-bound authentication key {public_key_id} cannot sign a non-batch transition")] +#[platform_serialize(unversioned)] +pub struct ContractBoundedKeyNonBatchError { + public_key_id: u32, +} +impl ContractBoundedKeyNonBatchError { + pub fn new(public_key_id: u32) -> Self { + Self { public_key_id } + } + pub fn public_key_id(&self) -> &u32 { + &self.public_key_id + } +} +impl From for ConsensusError { + fn from(error: ContractBoundedKeyNonBatchError) -> Self { + Self::SignatureError(SignatureError::ContractBoundedKeyNonBatchError(error)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/signature/contract_bounded_key_out_of_bounds_error.rs b/packages/rs-dpp/src/errors/consensus/signature/contract_bounded_key_out_of_bounds_error.rs new file mode 100644 index 00000000000..127e14369d2 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/signature/contract_bounded_key_out_of_bounds_error.rs @@ -0,0 +1,40 @@ +use crate::consensus::signature::SignatureError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("Batch member is outside the contract bounds of key {public_key_id}")] +#[platform_serialize(unversioned)] +pub struct ContractBoundedKeyOutOfBoundsError { + public_key_id: u32, +} +impl ContractBoundedKeyOutOfBoundsError { + pub fn new(public_key_id: u32) -> Self { + Self { public_key_id } + } + pub fn public_key_id(&self) -> &u32 { + &self.public_key_id + } +} +impl From for ConsensusError { + fn from(error: ContractBoundedKeyOutOfBoundsError) -> Self { + Self::SignatureError(SignatureError::ContractBoundedKeyOutOfBoundsError(error)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/signature/mod.rs b/packages/rs-dpp/src/errors/consensus/signature/mod.rs index 91ff05114bd..30256b54b32 100644 --- a/packages/rs-dpp/src/errors/consensus/signature/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/signature/mod.rs @@ -27,3 +27,9 @@ pub use crate::consensus::signature::signature_error::SignatureError; pub use crate::consensus::signature::signature_should_not_be_present_error::SignatureShouldNotBePresentError; pub use crate::consensus::signature::uncompressed_public_key_not_allowed_error::UncompressedPublicKeyNotAllowedError; pub use crate::consensus::signature::wrong_public_key_purpose_error::WrongPublicKeyPurposeError; + +mod contract_bounded_key_non_batch_error; +pub use contract_bounded_key_non_batch_error::ContractBoundedKeyNonBatchError; + +mod contract_bounded_key_out_of_bounds_error; +pub use contract_bounded_key_out_of_bounds_error::ContractBoundedKeyOutOfBoundsError; diff --git a/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs b/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs index c149c3ef121..d10bce56d4f 100644 --- a/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs +++ b/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs @@ -1,3 +1,5 @@ +use crate::consensus::signature::ContractBoundedKeyNonBatchError; +use crate::consensus::signature::ContractBoundedKeyOutOfBoundsError; use crate::consensus::signature::{ BasicBLSError, BasicECDSAError, IdentityNotFoundError, InvalidIdentityPublicKeyTypeError, InvalidSignaturePublicKeySecurityLevelError, InvalidStateTransitionSignatureError, @@ -71,6 +73,11 @@ pub enum SignatureError { #[error(transparent)] UncompressedPublicKeyNotAllowedError(UncompressedPublicKeyNotAllowedError), + #[error(transparent)] + ContractBoundedKeyNonBatchError(ContractBoundedKeyNonBatchError), + + #[error(transparent)] + ContractBoundedKeyOutOfBoundsError(ContractBoundedKeyOutOfBoundsError), } impl From for ConsensusError { @@ -78,3 +85,44 @@ impl From for ConsensusError { Self::SignatureError(err) } } + +#[cfg(test)] +mod tests { + use super::*; + use platform_value::Identifier; + + /// `SignatureError` is encoded by variant position; appending is the only safe change. + fn discriminant_of(error: SignatureError) -> u8 { + let bytes = bincode::encode_to_vec(error, bincode::config::standard()) + .expect("expected to encode the signature error"); + bytes[0] + } + + #[test] + fn signature_error_discriminants_are_frozen() { + assert_eq!( + discriminant_of(SignatureError::IdentityNotFoundError( + IdentityNotFoundError::new(Identifier::from([1; 32])) + )), + 0 + ); + assert_eq!( + discriminant_of(SignatureError::UncompressedPublicKeyNotAllowedError( + UncompressedPublicKeyNotAllowedError::new(65) + )), + 12 + ); + assert_eq!( + discriminant_of(SignatureError::ContractBoundedKeyNonBatchError( + ContractBoundedKeyNonBatchError::new(1) + )), + 13 + ); + assert_eq!( + discriminant_of(SignatureError::ContractBoundedKeyOutOfBoundsError( + ContractBoundedKeyOutOfBoundsError::new(1) + )), + 14 + ); + } +} diff --git a/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/mod.rs b/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/mod.rs index 898dbb7310d..ca394d1b79b 100644 --- a/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/mod.rs +++ b/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/mod.rs @@ -6,6 +6,12 @@ use crate::identity::identity_public_key::contract_bounds::ContractBounds::{ use crate::serialization::JsonConvertible; #[cfg(feature = "value-conversion")] use crate::serialization::ValueConvertible; +#[cfg(feature = "state-transitions")] +use crate::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; +#[cfg(feature = "state-transitions")] +use crate::state_transition::batch_transition::batched_transition::token_transition::TokenTransitionV0Methods; +#[cfg(feature = "state-transitions")] +use crate::state_transition::batch_transition::batched_transition::BatchedTransitionRef; use crate::ProtocolError; use bincode::{Decode, DecodeUntrusted, Encode}; use serde::{Deserialize, Serialize}; @@ -122,6 +128,32 @@ impl ContractBounds { // MultipleContractsOfSameOwner { .. } => None, } } + + /// Whether a batch member is inside these bounds. Consensus uses this to authorize a batch + /// signed by a contract-bound AUTHENTICATION key. Token operations are contract-wide, so a + /// document-type bound never covers them. + #[cfg(feature = "state-transitions")] + pub fn allows_batched_transition(&self, transition: BatchedTransitionRef<'_>) -> bool { + match (self, transition) { + (SingleContract { id }, BatchedTransitionRef::Document(document)) => { + document.data_contract_id() == *id + } + (SingleContract { id }, BatchedTransitionRef::Token(token)) => { + token.data_contract_id() == *id + } + ( + SingleContractDocumentType { + id, + document_type_name, + }, + BatchedTransitionRef::Document(document), + ) => { + document.data_contract_id() == *id + && document.document_type_name() == document_type_name.as_str() + } + (SingleContractDocumentType { .. }, BatchedTransitionRef::Token(_)) => false, + } + } // // /// Gets the cbor value // pub fn to_cbor_value(&self) -> CborValue { @@ -343,3 +375,98 @@ mod tests { assert_eq!(bounds, restored); } } + +#[cfg(all(test, feature = "state-transitions"))] +mod batched_transition_tests { + use super::ContractBounds; + use crate::identifier::Identifier; + use crate::state_transition::batch_transition::batched_transition::{ + document_create_transition::DocumentCreateTransitionV0, + document_delete_transition::DocumentDeleteTransitionV0, + document_index_only_delete_transition::DocumentIndexOnlyDeleteTransitionV0, + document_purchase_transition::DocumentPurchaseTransitionV0, + document_replace_transition::DocumentReplaceTransitionV0, + document_transfer_transition::DocumentTransferTransitionV0, + document_update_price_transition::DocumentUpdatePriceTransitionV0, + token_transfer_transition::TokenTransferTransitionV0, BatchedTransitionRef, + DocumentTransition, TokenTransition, + }; + + // Default transitions target contract [0; 32] and the empty document type name. + fn documents() -> Vec { + vec![ + DocumentTransition::Create(DocumentCreateTransitionV0::default().into()), + DocumentTransition::Replace(DocumentReplaceTransitionV0::default().into()), + DocumentTransition::Delete(DocumentDeleteTransitionV0::default().into()), + DocumentTransition::IndexOnlyDelete( + DocumentIndexOnlyDeleteTransitionV0::default().into(), + ), + DocumentTransition::Transfer(DocumentTransferTransitionV0::default().into()), + DocumentTransition::UpdatePrice(DocumentUpdatePriceTransitionV0::default().into()), + DocumentTransition::Purchase(DocumentPurchaseTransitionV0::default().into()), + ] + } + + fn tokens() -> Vec { + vec![ + TokenTransition::Burn(Default::default()), + TokenTransition::Mint(Default::default()), + TokenTransition::Transfer(TokenTransferTransitionV0::default().into()), + TokenTransition::Freeze(Default::default()), + TokenTransition::Unfreeze(Default::default()), + TokenTransition::DestroyFrozenFunds(Default::default()), + TokenTransition::Claim(Default::default()), + TokenTransition::EmergencyAction(Default::default()), + TokenTransition::ConfigUpdate(Default::default()), + TokenTransition::DirectPurchase(Default::default()), + TokenTransition::SetPriceForDirectPurchase(Default::default()), + ] + } + + #[test] + fn single_contract_bounds_cover_every_operation_on_that_contract_only() { + let bounds = ContractBounds::SingleContract { + id: Identifier::from([0; 32]), + }; + let foreign = ContractBounds::SingleContract { + id: Identifier::from([1; 32]), + }; + for document in documents() { + let member = BatchedTransitionRef::Document(&document); + assert!(bounds.allows_batched_transition(member), "{document:?}"); + assert!(!foreign.allows_batched_transition(member), "{document:?}"); + } + for token in tokens() { + let member = BatchedTransitionRef::Token(&token); + assert!(bounds.allows_batched_transition(member), "{token:?}"); + assert!(!foreign.allows_batched_transition(member), "{token:?}"); + } + } + + #[test] + fn document_type_bounds_cover_that_type_only_and_never_tokens() { + let bounds = ContractBounds::SingleContractDocumentType { + id: Identifier::from([0; 32]), + document_type_name: String::new(), + }; + let other_type = ContractBounds::SingleContractDocumentType { + id: Identifier::from([0; 32]), + document_type_name: "other".to_string(), + }; + for document in documents() { + let member = BatchedTransitionRef::Document(&document); + assert!(bounds.allows_batched_transition(member), "{document:?}"); + assert!( + !other_type.allows_batched_transition(member), + "{document:?}" + ); + } + for token in tokens() { + let member = BatchedTransitionRef::Token(&token); + assert!( + !bounds.allows_batched_transition(member), + "token operations are contract-wide: {token:?}" + ); + } + } +} diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 8ffac4cf8bf..eaadc5cce14 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -48,6 +48,10 @@ mod traits; use crate::consensus::basic::UnsupportedFeatureError; #[cfg(feature = "state-transition-signing")] use crate::consensus::signature::InvalidSignaturePublicKeySecurityLevelError; +#[cfg(feature = "state-transition-signing")] +use crate::consensus::signature::{ + ContractBoundedKeyNonBatchError, ContractBoundedKeyOutOfBoundsError, +}; #[cfg(feature = "state-transition-validation")] use crate::consensus::signature::{ InvalidStateTransitionSignatureError, PublicKeyIsDisabledError, SignatureError, @@ -1378,6 +1382,7 @@ impl StateTransition { >, options: StateTransitionSigningOptions, ) -> Result<(), ProtocolError> { + self.verify_identity_key_bounds(identity_public_key)?; match self { StateTransition::DataContractCreate(st) => { st.verify_public_key_level_and_purpose(identity_public_key, options)?; @@ -1539,6 +1544,40 @@ impl StateTransition { Ok(()) } + /// A contract-bound AUTHENTICATION key may only sign a Batch whose members are all inside + /// its bounds. Consensus enforces the same rule from the stored key; checking here saves the + /// round trip when the signing API is handed the key metadata. + #[cfg(feature = "state-transition-signing")] + fn verify_identity_key_bounds( + &self, + identity_public_key: &IdentityPublicKey, + ) -> Result<(), ProtocolError> { + if identity_public_key.purpose() != Purpose::AUTHENTICATION { + return Ok(()); + } + let Some(bounds) = identity_public_key.contract_bounds() else { + return Ok(()); + }; + match self { + StateTransition::Batch(batch) => { + use crate::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; + if batch + .transitions_iter() + .all(|member| bounds.allows_batched_transition(member)) + { + Ok(()) + } else { + Err(ProtocolError::ConsensusError(Box::new( + ContractBoundedKeyOutOfBoundsError::new(identity_public_key.id()).into(), + ))) + } + } + _ => Err(ProtocolError::ConsensusError(Box::new( + ContractBoundedKeyNonBatchError::new(identity_public_key.id()).into(), + ))), + } + } + #[cfg(feature = "state-transition-signing")] pub fn sign( &mut self, @@ -1562,6 +1601,7 @@ impl StateTransition { bls: &impl BlsModule, options: StateTransitionSigningOptions, ) -> Result<(), ProtocolError> { + self.verify_identity_key_bounds(identity_public_key)?; call_errorable_method_identity_signed!( self, verify_public_key_level_and_purpose, @@ -2541,6 +2581,81 @@ mod tests { )) } + #[cfg(all(feature = "state-transition-signing", feature = "bls-signatures"))] + #[test] + fn should_enforce_contract_bounds_before_private_key_signing() { + use crate::consensus::signature::{ + ContractBoundedKeyNonBatchError, ContractBoundedKeyOutOfBoundsError, + }; + use crate::identity::contract_bounds::ContractBounds; + use crate::identity::identity_public_key::v0::IdentityPublicKeyV0; + + let private_key = [1; 32]; + let bls = crate::bls::native_bls::NativeBlsModule; + // `sample_batch_st_with_delete` deletes a "preorder" document of contract [2; 32]. + let mut key = IdentityPublicKeyV0 { + id: 7, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + key_type: KeyType::ECDSA_SECP256K1, + data: get_compressed_public_ec_key(&private_key) + .unwrap() + .to_vec() + .into(), + contract_bounds: Some(ContractBounds::SingleContract { + id: Identifier::from([2; 32]), + }), + ..Default::default() + }; + sample_batch_st_with_delete() + .sign(&key.clone().into(), &private_key, &bls) + .expect("a batch inside the bounds must sign"); + key.contract_bounds = Some(ContractBounds::SingleContractDocumentType { + id: Identifier::from([2; 32]), + document_type_name: "preorder".to_string(), + }); + sample_batch_st_with_delete() + .sign(&key.clone().into(), &private_key, &bls) + .expect("a batch of the bound document type must sign"); + + let err = sample_transfer_st() + .sign(&key.clone().into(), &private_key, &bls) + .unwrap_err(); + assert!(matches!(err, ProtocolError::ConsensusError(error) + if *error == ContractBoundedKeyNonBatchError::new(key.id).into())); + + for denied in [ + ContractBounds::SingleContract { + id: Identifier::from([3; 32]), + }, + ContractBounds::SingleContractDocumentType { + id: Identifier::from([2; 32]), + document_type_name: "other".to_string(), + }, + ] { + key.contract_bounds = Some(denied.clone()); + let mut transition = sample_batch_st_with_delete(); + let original = transition.clone(); + let err = transition + .sign(&key.clone().into(), &private_key, &bls) + .unwrap_err(); + assert!( + matches!(err, ProtocolError::ConsensusError(error) + if *error == ContractBoundedKeyOutOfBoundsError::new(key.id).into()), + "{denied:?}" + ); + assert_eq!( + transition, original, + "rejection must preserve the transition" + ); + } + + key.contract_bounds = None; + sample_batch_st_with_delete() + .sign(&key.into(), &private_key, &bls) + .expect("unbounded keys must still sign"); + } + fn sample_batch_st_with_delete() -> StateTransition { let base = DocumentBaseTransition::V0(DocumentBaseTransitionV0 { id: Identifier::from([1u8; 32]), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/mod.rs index 62575b8cfb4..9896e3a3083 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/mod.rs @@ -13,12 +13,14 @@ use drive::grovedb::TransactionArg; pub mod v0; pub mod v1; +pub mod v2; /// Validates the contract bounds attached to each public key in `identity_public_keys_with_witness`. /// /// `epoch` is used by v1+ to bill the underlying grovedb reads to `execution_context`; v0 /// ignores it (v0 didn't bill these reads — pre-PROTOCOL_VERSION_12 behavior is preserved /// verbatim for chain replay). +#[allow(clippy::too_many_arguments)] // Keep explicit versioned validation inputs. pub(crate) fn validate_identity_public_keys_contract_bounds( identity_id: Identifier, identity_public_keys_with_witness: &[IdentityPublicKeyInCreation], @@ -55,9 +57,18 @@ pub(crate) fn validate_identity_public_keys_contract_bounds( execution_context, platform_version, ), + 2 => v2::validate_identity_public_keys_contract_bounds_v2( + identity_id, + identity_public_keys_with_witness, + drive, + epoch, + transaction, + execution_context, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "validate_identity_public_keys_contract_bounds".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } @@ -438,14 +449,14 @@ mod tests { } /// Covers the integration this PR is wiring up — that the public dispatcher actually - /// routes to v1 under `PlatformVersion::latest()` (which sets the bounds-validator - /// version field to 1) and that the `epoch` parameter is forwarded through. If the + /// routes legacy bounds through v1 under `PlatformVersion::latest()` (which sets the bounds-validator + /// version field to 2) and that the `epoch` parameter is forwarded through. If the /// dispatcher were accidentally routing to v0 — which has the DECRYPTION-branch bug — /// the assertion below would flip from `is_valid` to invalid. #[test] - fn dispatcher_routes_to_v1_at_latest_platform_version() { + fn dispatcher_preserves_v1_encryption_rules_at_latest_platform_version() { let platform_version = PlatformVersion::latest(); - // Sanity: `latest` should select v1 of the bounds validator. + // Sanity: `latest` should select v2 of the bounds validator. assert_eq!( platform_version .drive_abci @@ -453,8 +464,8 @@ mod tests { .state_transitions .common_validation_methods .validate_identity_public_key_contract_bounds, - 1, - "test premise: latest platform version is expected to select v1; \ + 2, + "test premise: latest platform version is expected to select v2; \ update this test if the version field moves" ); @@ -511,4 +522,75 @@ mod tests { billed_count ); } + #[test] + fn should_validate_bound_authentication_keys_against_contracts() { + use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Setters; + let version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + // The contract declares no authentication opt-in: any contract may bind an + // authentication key, unlike encryption and decryption bounds. + let contract = build_contract_with_decryption_only_bounds(version); + platform + .drive + .apply_contract(&contract, BlockInfo::default(), true, None, None, version) + .unwrap(); + for case in [ + "valid_type", + "valid_contract", + "missing_type", + "missing_contract", + "wrong_purpose", + "master", + ] { + let id = if case == "missing_contract" { + Identifier::from([42; 32]) + } else { + contract.id() + }; + let bounds = match case { + "valid_contract" => ContractBounds::SingleContract { id }, + "missing_type" => ContractBounds::SingleContractDocumentType { + id, + document_type_name: "absent".into(), + }, + _ => ContractBounds::SingleContractDocumentType { + id, + document_type_name: "note".into(), + }, + }; + let mut key = make_decryption_key_bound_to_doc_type(contract.id(), "note".into()); + key.set_contract_bounds(Some(bounds)); + key.set_purpose(if case == "wrong_purpose" { + Purpose::TRANSFER + } else { + Purpose::AUTHENTICATION + }); + key.set_security_level(if case == "master" { + SecurityLevel::MASTER + } else { + SecurityLevel::HIGH + }); + let mut context = + StateTransitionExecutionContext::default_for_platform_version(version).unwrap(); + let result = validate_identity_public_keys_contract_bounds( + Identifier::from([1; 32]), + &[key], + &platform.drive, + &Epoch::new(0).unwrap(), + None, + &mut context, + version, + ) + .unwrap(); + assert_eq!( + result.is_valid(), + case.starts_with("valid"), + "{case}: {:?}", + result.errors + ); + } + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v2/mod.rs new file mode 100644 index 00000000000..db543e1cc64 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v2/mod.rs @@ -0,0 +1,87 @@ +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use dpp::block::epoch::Epoch; +use dpp::consensus::basic::document::{DataContractNotPresentError, InvalidDocumentTypeError}; +use dpp::consensus::basic::identity::InvalidIdentityPublicKeySecurityLevelError; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::identifier::Identifier; +use dpp::identity::{Purpose, SecurityLevel}; +use dpp::state_transition::public_key_in_creation::{ + accessors::IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreation, +}; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::{drive::Drive, grovedb::TransactionArg}; + +/// v2 admits contract bounds on AUTHENTICATION keys: the bound contract (and document type) +/// must exist and the key must not be a MASTER key. Any contract may be bound; there is no +/// contract opt-in or uniqueness rule, unlike encryption and decryption keys, which keep the v1 +/// rules unchanged. +#[allow(clippy::too_many_arguments)] // Keep explicit versioned validation inputs. +pub(super) fn validate_identity_public_keys_contract_bounds_v2( + identity_id: Identifier, + keys: &[IdentityPublicKeyInCreation], + drive: &Drive, + epoch: &Epoch, + transaction: TransactionArg, + context: &mut StateTransitionExecutionContext, + version: &PlatformVersion, +) -> Result { + let mut result = SimpleConsensusValidationResult::default(); + for key in keys { + let Some(bounds) = key.contract_bounds() else { + continue; + }; + if key.purpose() != Purpose::AUTHENTICATION { + result.add_errors( + super::v1::validate_identity_public_keys_contract_bounds_v1( + identity_id, + std::slice::from_ref(key), + drive, + epoch, + transaction, + context, + version, + )? + .errors, + ); + continue; + } + if key.security_level() == SecurityLevel::MASTER { + result.add_error(InvalidIdentityPublicKeySecurityLevelError::new( + key.id(), + key.purpose(), + key.security_level(), + Some(vec![ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ]), + )); + continue; + } + let contract_id = *bounds.identifier(); + let outcome = drive.get_system_or_user_contract_with_fee( + contract_id.to_buffer(), + epoch, + transaction, + version, + )?; + if let Some(fee) = outcome.fee() { + context.add_operation(ValidationOperation::PrecalculatedOperation(fee.clone())); + } + let Some(contract) = outcome.contract() else { + result.add_error(DataContractNotPresentError::new(contract_id)); + continue; + }; + if let Some(name) = bounds.document_type() { + if contract.document_type_optional_for_name(name).is_none() { + result.add_error(InvalidDocumentTypeError::new(name.clone(), contract_id)); + } + } + } + Ok(result) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/mod.rs index 6e35b4361d9..77f317fa4d2 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/mod.rs @@ -10,8 +10,11 @@ use crate::execution::types::state_transition_execution_context::StateTransition use crate::execution::validation::state_transition::common::validate_state_transition_identity_signed::v0::ValidateStateTransitionIdentitySignatureV0; pub mod v0; +mod v1; +use v1::ValidateStateTransitionIdentitySignatureV1; pub trait ValidateStateTransitionIdentitySignature { + #[allow(clippy::too_many_arguments)] // Keep explicit versioned validation inputs. fn validate_state_transition_identity_signed( &self, drive: &Drive, @@ -48,9 +51,17 @@ impl ValidateStateTransitionIdentitySignature for StateTransition { execution_context, platform_version, ), + 1 => self.validate_state_transition_identity_signed_v1( + drive, + request_balance, + request_revision, + transaction, + execution_context, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "StateTransition::validate_state_transition_identity_signature".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v1/mod.rs new file mode 100644 index 00000000000..9f7e8ae22f1 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v1/mod.rs @@ -0,0 +1,65 @@ +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::common::validate_state_transition_identity_signed::v0::ValidateStateTransitionIdentitySignatureV0; +use dpp::consensus::signature::ContractBoundedKeyNonBatchError; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{PartialIdentity, Purpose}; +use dpp::state_transition::StateTransition; +use dpp::validation::ConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; + +pub(super) trait ValidateStateTransitionIdentitySignatureV1 { + fn validate_state_transition_identity_signed_v1( + &self, + drive: &Drive, + request_balance: bool, + request_revision: bool, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl ValidateStateTransitionIdentitySignatureV1 for StateTransition { + /// v1 runs the v0 signature checks, then refuses a contract-bound AUTHENTICATION key on any + /// transition other than a Batch: such a key may only act inside its contract, and batch + /// members are checked against the bounds in batch advanced-structure validation. + fn validate_state_transition_identity_signed_v1( + &self, + drive: &Drive, + request_balance: bool, + request_revision: bool, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let result = self.validate_state_transition_identity_signed_v0( + drive, + request_balance, + request_revision, + transaction, + execution_context, + platform_version, + )?; + if let Some(identity) = result.data.as_ref().filter(|_| result.is_valid()) { + // v0 loads exactly the signing key, but look it up by id so another loaded key can + // never veto a transition it did not sign. + let signing_key = self + .signature_public_key_id() + .and_then(|key_id| identity.loaded_public_keys.get(&key_id)); + if let Some(key) = signing_key { + if key.purpose() == Purpose::AUTHENTICATION + && key.contract_bounds().is_some() + && !matches!(self, StateTransition::Batch(_)) + { + return Ok(ConsensusValidationResult::new_with_error( + ContractBoundedKeyNonBatchError::new(key.id()).into(), + )); + } + } + } + Ok(result) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs index a2590685711..f0aedbfc323 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs @@ -97,6 +97,7 @@ impl StateTransitionStateValidation for StateTransition { st.validate_state_for_identity_create_transition( action, platform, + block_info, execution_context, tx, ) @@ -168,6 +169,7 @@ impl StateTransitionStateValidation for StateTransition { st.validate_state_for_identity_create_from_addresses_transition( action, platform, + block_info, execution_context, tx, ) @@ -257,6 +259,7 @@ impl StateTransitionStateValidation for StateTransition { st.validate_state_for_identity_create_from_shielded_pool_transition( action, platform, + block_info, execution_context, tx, ) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/mod.rs index 9a1925de7fc..008be12cc67 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/mod.rs @@ -1 +1,2 @@ pub(crate) mod v0; +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs new file mode 100644 index 00000000000..a4e98a9ddd0 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs @@ -0,0 +1,286 @@ +use crate::error::Error; +use dpp::consensus::signature::ContractBoundedKeyOutOfBoundsError; +use dpp::identity::Purpose; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::basic::document::InvalidDocumentTransitionIdError; +use dpp::consensus::signature::{InvalidSignaturePublicKeySecurityLevelError, SignatureError}; +use dpp::dashcore::Network; +use dpp::document::Document; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::PartialIdentity; +use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; +use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; +use dpp::state_transition::batch_transition::BatchTransition; +use dpp::state_transition::{StateTransitionHasUserFeeIncrease, StateTransitionIdentitySigned, StateTransitionOwned}; +use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; +use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; +use dpp::state_transition::batch_transition::document_base_transition::document_base_transition_trait::DocumentBaseTransitionAccessors; +use dpp::validation::ConsensusValidationResult; + +use dpp::version::PlatformVersion; + +use drive::state_transition_action::batch::BatchTransitionAction; +use crate::execution::validation::state_transition::state_transitions::batch::action_validation::document::document_replace_transition_action::DocumentReplaceTransitionActionValidation; +use crate::execution::validation::state_transition::state_transitions::batch::action_validation::document::document_delete_transition_action::DocumentDeleteTransitionActionValidation; +use crate::execution::validation::state_transition::state_transitions::batch::action_validation::document::document_index_only_delete_transition_action::DocumentIndexOnlyDeleteTransitionActionValidation; +use crate::execution::validation::state_transition::state_transitions::batch::action_validation::document::document_create_transition_action::DocumentCreateTransitionActionValidation; +use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; +use drive::state_transition_action::batch::batched_transition::BatchedTransitionAction; +use drive::state_transition_action::batch::batched_transition::document_transition::document_delete_transition_action::v0::DocumentDeleteTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_index_only_delete_transition_action::v0::DocumentIndexOnlyDeleteTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_purchase_transition_action::DocumentPurchaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::DocumentReplaceTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_transfer_transition_action::DocumentTransferTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_update_price_transition_action::DocumentUpdatePriceTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; +use drive::state_transition_action::StateTransitionAction; +use drive::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; +use crate::error::execution::ExecutionError; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0}; +use crate::execution::validation::state_transition::batch::action_validation::document::document_purchase_transition_action::DocumentPurchaseTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::document::document_transfer_transition_action::DocumentTransferTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::document::document_update_price_transition_action::DocumentUpdatePriceTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::token::token_base_transition_action::TokenBaseTransitionActionValidation; + +pub(in crate::execution::validation::state_transition::state_transitions::batch) trait DocumentsBatchStateTransitionStructureValidationV1 +{ + fn validate_advanced_structure_from_state_v1( + &self, + block_info: &BlockInfo, + network: Network, + action: &BatchTransitionAction, + identity: &PartialIdentity, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl DocumentsBatchStateTransitionStructureValidationV1 for BatchTransition { + fn validate_advanced_structure_from_state_v1( + &self, + block_info: &BlockInfo, + network: Network, + action: &BatchTransitionAction, + identity: &PartialIdentity, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let security_levels = action.combined_security_level_requirement()?; + + let signing_key = identity.loaded_public_keys.get(&self.signature_public_key_id()).ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution("the key must exist for advanced structure validation as we already fetched it during signature validation")))?; + + if !security_levels.contains(&signing_key.security_level()) { + // We only need to bump the first identity data contract nonce as that will make a replay + // attack not possible + + let first_transition = self.first_transition().ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution("There must be at least one state transition as this is already verified in basic validation")))?; + + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_batched_transition_ref( + first_transition, + self.owner_id(), + self.user_fee_increase(), + ), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + vec![SignatureError::InvalidSignaturePublicKeySecurityLevelError( + InvalidSignaturePublicKeySecurityLevelError::new( + signing_key.security_level(), + security_levels, + ), + ) + .into()], + )); + } + + // A contract-bound AUTHENTICATION key may only act inside its contract (and document + // type). The signer is authenticated, so an out-of-bounds member is a paid failure. + if signing_key.purpose() == Purpose::AUTHENTICATION { + if let Some(bounds) = signing_key.contract_bounds() { + if self + .transitions_iter() + .any(|member| !bounds.allows_batched_transition(member)) + { + let first = self.first_transition().ok_or(Error::Execution( + ExecutionError::CorruptedCodeExecution("empty validated batch"), + ))?; + let bump = BumpIdentityDataContractNonceAction::from_batched_transition_ref( + first, + self.owner_id(), + self.user_fee_increase(), + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + StateTransitionAction::BumpIdentityDataContractNonceAction(bump), + vec![ContractBoundedKeyOutOfBoundsError::new(signing_key.id()).into()], + )); + } + } + } + + // We should validate that all newly created documents have valid ids + for transition in self.transitions_iter() { + if let BatchedTransitionRef::Document(DocumentTransition::Create(create_transition)) = + transition + { + // Validate the ID + let generated_document_id = Document::generate_document_id_v0( + create_transition.base().data_contract_id_ref(), + &self.owner_id(), + create_transition.base().document_type_name(), + &create_transition.entropy(), + ); + + // This hash will take 2 blocks (128 bytes) + execution_context.add_operation(ValidationOperation::DoubleSha256(2)); + + let id = create_transition.base().id(); + if generated_document_id != id { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + create_transition.base(), + self.owner_id(), + self.user_fee_increase(), + ), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + vec![ + InvalidDocumentTransitionIdError::new(generated_document_id, id).into(), + ], + )); + } + } + } + + // Next we need to validate the structure of all actions (this means with the data contract) + for transition in action.transitions() { + match transition { + BatchedTransitionAction::DocumentAction(document_action) => match document_action { + DocumentTransitionAction::CreateAction(create_action) => { + let result = create_action.validate_structure( + identity.id, + block_info, + network, + platform_version, + )?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(document_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::ReplaceAction(replace_action) => { + let result = replace_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(replace_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::DeleteAction(delete_action) => { + let result = delete_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(delete_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::TransferAction(transfer_action) => { + let result = transfer_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(transfer_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::UpdatePriceAction(update_price_action) => { + let result = update_price_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(update_price_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::PurchaseAction(purchase_action) => { + let result = purchase_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(purchase_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::IndexOnlyDeleteAction(index_only_delete_action) => { + let result = + index_only_delete_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(index_only_delete_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + }, + BatchedTransitionAction::TokenAction(token_transition_action) => { + // token actions only need to do advanced structure validation on the base action + let result = token_transition_action + .base() + .validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_token_base_transition_action(token_transition_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + BatchedTransitionAction::BumpIdentityDataContractNonce(_) => { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "we should not have a bump identity contract nonce at this stage", + ))); + } + } + } + Ok(ConsensusValidationResult::new()) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs index c1fef559bd1..527ce9b6115 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs @@ -1,3 +1,4 @@ +use advanced_structure::v1::DocumentsBatchStateTransitionStructureValidationV1; mod action_validation; mod advanced_structure; mod data_triggers; @@ -175,7 +176,7 @@ impl StateTransitionStructureKnownInStateValidationV0 for BatchTransition { .batch_state_transition .advanced_structure { - 0 => { + 0 | 1 => { let identity = identity.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( "The identity must be known on advanced structure validation", @@ -186,18 +187,36 @@ impl StateTransitionStructureKnownInStateValidationV0 for BatchTransition { "action must be a documents batch transition action", ))); }; - self.validate_advanced_structure_from_state_v0( - block_info, - network, - documents_batch_transition_action, - identity, - execution_context, - platform_version, - ) + if platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .advanced_structure + == 1 + { + self.validate_advanced_structure_from_state_v1( + block_info, + network, + documents_batch_transition_action, + identity, + execution_context, + platform_version, + ) + } else { + self.validate_advanced_structure_from_state_v0( + block_info, + network, + documents_batch_transition_action, + identity, + execution_context, + platform_version, + ) + } } version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "documents batch transition: advanced structure from state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/contract_bound_auth.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/contract_bound_auth.rs new file mode 100644 index 00000000000..c63a85c306e --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/contract_bound_auth.rs @@ -0,0 +1,428 @@ +use super::*; +use crate::execution::validation::state_transition::tests::setup_identity_without_adding_it; +use dpp::consensus::codes::ErrorWithCode; +use dpp::identity::contract_bounds::ContractBounds; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{accessors::IdentitySettersV0, IdentityPublicKey}; +use dpp::state_transition::batch_transition::methods::v1::DocumentsBatchTransitionMethodsV1; + +/// Sign with the original unbounded key metadata to deliberately bypass SDK preflight; +/// validators must enforce the bounds of the key stored in Drive. +#[tokio::test] +async fn should_enforce_contract_bounds_in_execution_and_preserve_paid_failure_nonces() { + for case in [ + "allowed", + "type_match", + "wrong_contract", + "wrong_type", + "disabled", + "mixed", + ] { + let version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (mut identity, signer, signing_key) = + setup_identity_without_adding_it(958, dash_to_credits!(0.1)); + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(version) + .unwrap(); + let dpns = platform + .drive + .cache + .system_data_contracts + .load_dpns(version) + .unwrap(); + let bounds = match case { + "wrong_contract" => ContractBounds::SingleContract { id: dpns.id() }, + "type_match" => ContractBounds::SingleContractDocumentType { + id: dashpay.id(), + document_type_name: "profile".into(), + }, + "wrong_type" => ContractBounds::SingleContractDocumentType { + id: dashpay.id(), + document_type_name: "contactRequest".into(), + }, + _ => ContractBounds::SingleContract { id: dashpay.id() }, + }; + let mut stored_key = signing_key.clone(); + let IdentityPublicKey::V0(ref mut key) = stored_key; + key.contract_bounds = Some(bounds); + if case == "disabled" { + key.disabled_at = Some(99); + } + identity.add_public_key(stored_key); + platform + .drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + version, + ) + .unwrap(); + let state = platform.state.load(); + let profile = dashpay.document_type_for_name("profile").unwrap(); + let mut rng = StdRng::seed_from_u64(433); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = profile + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + version, + ) + .unwrap(); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); + let mut batch = BatchTransition::new_document_creation_transition_from_document( + document, + profile, + entropy.0, + &signing_key, + 2, + 0, + None, + &signer, + version, + None, + ) + .await + .unwrap(); + if case == "mixed" { + use dpp::state_transition::batch_transition::batched_transition::{ + document_transition::DocumentTransitionV0Methods, BatchedTransition, + }; + use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; + use dpp::state_transition::StateTransition; + let StateTransition::Batch(BatchTransition::V1(ref mut inner)) = batch else { + panic!("expected v1 batch") + }; + let mut second = inner.transitions[0].clone(); + let BatchedTransition::Document(ref mut doc) = second else { + unreachable!() + }; + doc.base_mut() + .set_document_type_name("contactRequest".into()); + doc.base_mut() + .set_id(dpp::prelude::Identifier::from([8; 32])); + inner.transitions.push(second); + batch + .sign_external( + &signing_key, + &signer, + Some(|_, _| Ok(dpp::identity::SecurityLevel::HIGH)), + ) + .await + .unwrap(); + } + let bytes = batch.serialize_to_bytes().unwrap(); + let tx = platform.drive.grove.start_transaction(); + let block_info = BlockInfo { + time_ms: 100, + ..Default::default() + }; + let result = platform + .platform + .process_raw_state_transitions( + &vec![bytes.clone()], + &state, + &block_info, + &tx, + version, + false, + None, + ) + .unwrap(); + let execution = &result.execution_results()[0]; + match case { + // Protocol 14 still limits batches to one member; mixed batches must + // fail at basic validation before any bounds checks or fees. + "mixed" => { + assert!( + matches!(execution, StateTransitionExecutionResult::UnpaidConsensusError(error) if error.code() == 10412), + "{execution:?}" + ); + assert_eq!( + platform + .drive + .fetch_identity_contract_nonce( + identity.id().to_buffer(), + dashpay.id().to_buffer(), + true, + Some(&tx), + version + ) + .unwrap(), + None + ); + assert_eq!( + platform + .drive + .fetch_identity_balance(identity.id().to_buffer(), Some(&tx), version) + .unwrap(), + Some(identity.balance()) + ); + } + "allowed" | "type_match" => assert!( + matches!( + execution, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ), + "{case}: {execution:?}" + ), + "disabled" => assert!( + matches!( + execution, + StateTransitionExecutionResult::UnpaidConsensusError(_) + ), + "{execution:?}" + ), + _ => { + assert!( + matches!(execution, StateTransitionExecutionResult::PaidConsensusError { error, .. } if error.code() == 20014), + "{case}: {execution:?}" + ); + assert_eq!( + platform + .drive + .fetch_identity_contract_nonce( + identity.id().to_buffer(), + dashpay.id().to_buffer(), + true, + Some(&tx), + version + ) + .unwrap(), + Some((1u64 << 40) | 2) + ); + let balance = platform + .drive + .fetch_identity_balance(identity.id().to_buffer(), Some(&tx), version) + .unwrap() + .unwrap(); + assert!( + balance < identity.balance(), + "invalid batch must pay validation fees" + ); + let replay = platform + .platform + .process_raw_state_transitions( + &vec![bytes], + &state, + &block_info, + &tx, + version, + false, + None, + ) + .unwrap(); + assert!( + matches!( + &replay.execution_results()[0], + StateTransitionExecutionResult::UnpaidConsensusError(_) + ), + "replay must not charge twice" + ); + } + } + } +} + +#[tokio::test] +async fn should_reject_non_batch_use_of_a_bound_authentication_key() { + use dpp::data_contract::accessors::v0::DataContractV0Setters; + use dpp::state_transition::data_contract_create_transition::{ + methods::DataContractCreateTransitionMethodsV0, DataContractCreateTransition, + }; + let version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (mut identity, signer, signing_key) = + setup_identity_without_adding_it(958, dash_to_credits!(0.1)); + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(version) + .unwrap(); + let mut stored_key = signing_key.clone(); + let IdentityPublicKey::V0(ref mut key) = stored_key; + key.contract_bounds = Some(ContractBounds::SingleContract { id: dashpay.id() }); + identity.add_public_key(stored_key); + platform + .drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + version, + ) + .unwrap(); + let mut contract = dashpay.as_ref().clone(); + contract.set_owner_id(identity.id()); + let mut signer_identity = identity.clone(); + signer_identity.add_public_key(signing_key.clone()); + let transition = DataContractCreateTransition::new_from_data_contract( + contract, + 1, + &signer_identity.into_partial_identity_info(), + signing_key.id(), + &signer, + version, + None, + ) + .await + .unwrap(); + let tx = platform.drive.grove.start_transaction(); + let state = platform.state.load(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![transition.serialize_to_bytes().unwrap()], + &state, + &BlockInfo::default(), + &tx, + version, + false, + None, + ) + .unwrap(); + assert!( + matches!(&result.execution_results()[0], StateTransitionExecutionResult::UnpaidConsensusError(error) if error.code() == 20013), + "{:?}", + result.execution_results() + ); + assert_eq!( + platform + .drive + .fetch_identity_balance(identity.id().to_buffer(), Some(&tx), version) + .unwrap(), + Some(identity.balance()) + ); +} + +#[tokio::test] +async fn should_bound_token_operations_to_the_bound_contract() { + use crate::execution::validation::state_transition::tests::create_token_contract_with_owner_identity; + use dpp::data_contract::TokenConfiguration; + for bound_to_token_contract in [true, false] { + let version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (owner, _, _) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + owner.id(), + None::, + None, + None, + None, + version, + ); + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(version) + .unwrap(); + let (mut identity, signer, signing_key) = + setup_identity_without_adding_it(234, dash_to_credits!(0.1)); + let mut stored_key = signing_key.clone(); + let IdentityPublicKey::V0(ref mut key) = stored_key; + key.contract_bounds = Some(ContractBounds::SingleContract { + id: if bound_to_token_contract { + contract.id() + } else { + dashpay.id() + }, + }); + identity.add_public_key(stored_key); + platform + .drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + version, + ) + .unwrap(); + add_tokens_to_identity(&platform, token_id.into(), identity.id(), 15); + let batch = BatchTransition::new_token_transfer_transition( + token_id, + identity.id(), + contract.id(), + 0, + 5, + owner.id(), + None, + None, + None, + &signing_key, + 2, + 0, + &signer, + version, + None, + ) + .await + .unwrap(); + let tx = platform.drive.grove.start_transaction(); + let state = platform.state.load(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![batch.serialize_to_bytes().unwrap()], + &state, + &BlockInfo::default(), + &tx, + version, + false, + None, + ) + .unwrap(); + let execution = &result.execution_results()[0]; + if bound_to_token_contract { + assert!( + matches!( + execution, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ), + "{execution:?}" + ); + } else { + assert!( + matches!(execution, StateTransitionExecutionResult::PaidConsensusError { error, .. } if error.code() == 20014), + "{execution:?}" + ); + } + assert_eq!( + platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + Some(&tx), + version + ) + .unwrap(), + Some(if bound_to_token_contract { 10 } else { 15 }) + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/mod.rs index 41ddaad8029..c8dca99e08b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/mod.rs @@ -64,3 +64,5 @@ use drive::util::storage_flags::StorageFlags; use rand::prelude::StdRng; use rand::Rng; use rand::SeedableRng; + +mod contract_bound_auth; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs index a15e1695c74..3cd0fbfb2e7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs @@ -10,6 +10,7 @@ use crate::error::execution::ExecutionError; use crate::execution::validation::state_transition::identity_create::basic_structure::v0::IdentityCreateStateTransitionBasicStructureValidationV0; use crate::execution::validation::state_transition::identity_create::state::v0::IdentityCreateStateTransitionStateValidationV0; +use crate::execution::validation::state_transition::identity_create::state::v1::IdentityCreateStateTransitionStateValidationV1; use crate::platform_types::platform::PlatformRef; use crate::rpc::core::CoreRPCLike; @@ -163,6 +164,7 @@ pub trait StateTransitionStateValidationForIdentityCreateTransitionV0 { &self, action: IdentityCreateTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error>; @@ -173,6 +175,7 @@ impl StateTransitionStateValidationForIdentityCreateTransitionV0 for IdentityCre &self, action: IdentityCreateTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { @@ -185,9 +188,17 @@ impl StateTransitionStateValidationForIdentityCreateTransitionV0 for IdentityCre .state { 0 => self.validate_state_v0(platform, action, execution_context, tx, platform_version), + 1 => self.validate_state_v1( + platform, + block_info, + action, + execution_context, + tx, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "identity create transition: validate_state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -198,6 +209,7 @@ impl StateTransitionStateValidationForIdentityCreateTransitionV0 for IdentityCre mod tests { use crate::config::{PlatformConfig, PlatformTestConfig}; use crate::test::helpers::setup::TestPlatformBuilder; + use assert_matches::assert_matches; use dpp::block::block_info::BlockInfo; use dpp::dashcore::{Network, PrivateKey}; use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; @@ -446,6 +458,180 @@ mod tests { assert_eq!(identity_balance, 99913867460); } + #[tokio::test] + async fn should_create_identity_with_bound_authentication_key() { + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + for (protocol, document_type_bound) in [(13, false), (13, true), (14, false), (14, true)] { + let platform_version = PlatformVersion::get(protocol).unwrap(); + let platform_config = PlatformConfig { + testing_configs: PlatformTestConfig { + disable_instant_lock_signature_verification: true, + ..Default::default() + }, + ..Default::default() + }; + + let platform = TestPlatformBuilder::new() + .with_config(platform_config) + .with_initial_protocol_version(protocol) + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_state = platform.state.load(); + + let mut signer = SimpleSigner::default(); + + let mut rng = StdRng::seed_from_u64(567); + + let (master_key, master_private_key) = + IdentityPublicKey::random_ecdsa_master_authentication_key( + 0, + Some(58), + platform_version, + ) + .expect("expected to get key pair"); + + signer.add_identity_public_key(master_key.clone(), master_private_key); + + let (mut key, private_key) = + IdentityPublicKey::random_ecdsa_critical_level_authentication_key( + 1, + Some(999), + platform_version, + ) + .expect("expected to get key pair"); + + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::identity::contract_bounds::ContractBounds; + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(platform_version) + .unwrap(); + let bounds = if document_type_bound { + ContractBounds::SingleContractDocumentType { + id: dashpay.id(), + document_type_name: "profile".into(), + } + } else { + ContractBounds::SingleContract { id: dashpay.id() } + }; + let IdentityPublicKey::V0(ref mut key_v0) = key; + key_v0.contract_bounds = Some(bounds.clone()); + signer.add_identity_public_key(key.clone(), private_key); + + let (_, pk) = ECDSA_SECP256K1 + .random_public_and_private_key_data(&mut rng, platform_version) + .unwrap(); + + let asset_lock_proof = instant_asset_lock_proof_fixture( + Some(PrivateKey::from_byte_array(&pk, Network::Testnet).unwrap()), + None, + ); + + let identifier = asset_lock_proof + .create_identifier() + .expect("expected an identifier"); + + let identity: Identity = IdentityV0 { + id: identifier, + public_keys: BTreeMap::from([(0, master_key.clone()), (1, key.clone())]), + balance: 1000000000, + revision: 0, + } + .into(); + + let identity_create_transition: StateTransition = + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( + &identity, + asset_lock_proof, + pk.as_slice(), + &signer, + &NativeBlsModule, + 0, + platform_version, + ) + .await + .expect("expected an identity create transition"); + + let identity_create_serialized_transition = identity_create_transition + .serialize_to_bytes() + .expect("serialized state transition"); + + let before = platform + .drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .unwrap(); + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![identity_create_serialized_transition.clone()], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + if protocol == 13 { + // Protocol 13 keeps the historical Drive rejection of bound authentication keys. + use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; + assert_matches!(processing_result.execution_results().as_slice(), [StateTransitionExecutionResult::InternalError(error)] if error.contains("identity key bounds error: purpose not available for key bounds")); + assert_eq!( + platform + .drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .unwrap(), + before + ); + continue; + } + assert_eq!(processing_result.valid_count(), 1); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + let identity_balance = platform + .drive + .fetch_identity_balance(identity.id().into_buffer(), None, platform_version) + .expect("expected to get identity balance") + .expect("expected there to be an identity balance for this identity"); + + assert!(identity_balance > 0); + use drive::drive::identity::key::fetch::IdentityKeysRequest; + let fetched = platform + .drive + .fetch_identity_keys_as_partial_identity( + IdentityKeysRequest::new_specific_key_query(&identity.id().to_buffer(), 1), + None, + platform_version, + ) + .unwrap() + .unwrap(); + assert_eq!( + fetched + .loaded_public_keys + .get(&1) + .unwrap() + .contract_bounds(), + Some(&bounds) + ); + } + } + #[tokio::test] async fn test_identity_create_asset_lock_reuse_after_issue_first_protocol_version() { let platform_version = PlatformVersion::first(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/mod.rs index 9a1925de7fc..420eb16447d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/mod.rs @@ -1 +1,3 @@ pub(crate) mod v0; + +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/v1/mod.rs new file mode 100644 index 00000000000..e1312d5fe9b --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/v1/mod.rs @@ -0,0 +1,114 @@ +use crate::execution::validation::state_transition::common::validate_identity_public_key_contract_bounds::validate_identity_public_keys_contract_bounds; +use dpp::block::block_info::BlockInfo; +use crate::error::Error; +use crate::platform_types::platform::PlatformRef; +use crate::rpc::core::CoreRPCLike; + +use dpp::consensus::state::identity::IdentityAlreadyExistsError; + +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::identity_create_transition::accessors::IdentityCreateTransitionAccessorsV0; +use dpp::ProtocolError; + +use dpp::state_transition::identity_create_transition::IdentityCreateTransition; +use dpp::version::PlatformVersion; +use drive::state_transition_action::identity::identity_create::IdentityCreateTransitionAction; +use drive::state_transition_action::StateTransitionAction; + +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::system::partially_use_asset_lock_action::PartiallyUseAssetLockAction; + +use crate::execution::validation::state_transition::common::validate_unique_identity_public_key_hashes_in_state::validate_unique_identity_public_key_hashes_not_in_state; + +pub(in crate::execution::validation::state_transition::state_transitions::identity_create) trait IdentityCreateStateTransitionStateValidationV1 +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl IdentityCreateStateTransitionStateValidationV1 for IdentityCreateTransition { + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let drive = platform.drive; + + let identity_id = self.identity_id(); + let balance = + drive.fetch_identity_balance(identity_id.to_buffer(), transaction, platform_version)?; + + // Balance is here to check if the identity does already exist + if balance.is_some() { + // Since the id comes from the state transition this should never be reachable + return Ok(ConsensusValidationResult::new_with_error( + IdentityAlreadyExistsError::new(identity_id.to_owned()).into(), + )); + } + + // Now we should check the state of added keys to make sure there aren't any that already exist + let mut key_state_validation_result = + validate_unique_identity_public_key_hashes_not_in_state( + self.public_keys(), + drive, + execution_context, + transaction, + platform_version, + )?; + + key_state_validation_result.add_errors( + validate_identity_public_keys_contract_bounds( + identity_id, + self.public_keys(), + drive, + &block_info.epoch, + transaction, + execution_context, + platform_version, + )? + .errors, + ); + + if key_state_validation_result.is_valid() { + // We just pass the action that was given to us + Ok(ConsensusValidationResult::new_with_data( + StateTransitionAction::IdentityCreateAction(action), + )) + } else { + // It's not valid, we need to give back the action that partially uses the asset lock + + let penalty = platform_version + .drive_abci + .validation_and_processing + .penalties + .unique_key_already_present; + + let used_credits = penalty + .checked_add(execution_context.fee_cost(platform_version)?.processing_fee) + .ok_or(ProtocolError::Overflow("processing fee overflow error"))?; + + let bump_action = PartiallyUseAssetLockAction::from_identity_create_transition_action( + action, + used_credits, + ); + Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action.into(), + key_state_validation_result.errors, + )) + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/mod.rs index 90d7f68499d..48b4c87f645 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/mod.rs @@ -14,6 +14,7 @@ use std::collections::BTreeMap; use crate::execution::validation::state_transition::identity_create_from_addresses::basic_structure::v0::IdentityCreateFromAddressesStateTransitionBasicStructureValidationV0; use crate::execution::validation::state_transition::identity_create_from_addresses::state::v0::IdentityCreateFromAddressesStateTransitionStateValidationV0; +use crate::execution::validation::state_transition::identity_create_from_addresses::state::v1::IdentityCreateFromAddressesStateTransitionStateValidationV1; use crate::execution::validation::state_transition::processor::basic_structure::StateTransitionBasicStructureValidationV0; use crate::platform_types::platform::PlatformRef; @@ -155,6 +156,7 @@ pub trait StateTransitionStateValidationForIdentityCreateFromAddressesTransition &self, action: IdentityCreateFromAddressesTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error>; @@ -167,6 +169,7 @@ impl StateTransitionStateValidationForIdentityCreateFromAddressesTransitionV0 &self, action: IdentityCreateFromAddressesTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { @@ -179,9 +182,17 @@ impl StateTransitionStateValidationForIdentityCreateFromAddressesTransitionV0 .state { 0 => self.validate_state_v0(platform, action, execution_context, tx, platform_version), + 1 => self.validate_state_v1( + platform, + block_info, + action, + execution_context, + tx, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "identity create from addresses transition: validate_state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/mod.rs index 9a1925de7fc..420eb16447d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/mod.rs @@ -1 +1,3 @@ pub(crate) mod v0; + +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/v1/mod.rs new file mode 100644 index 00000000000..17cd8a58073 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/v1/mod.rs @@ -0,0 +1,116 @@ +use crate::execution::validation::state_transition::common::validate_identity_public_key_contract_bounds::validate_identity_public_keys_contract_bounds; +use dpp::block::block_info::BlockInfo; +use crate::error::Error; +use crate::platform_types::platform::PlatformRef; + +use dpp::consensus::state::identity::IdentityAlreadyExistsError; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::identity_create_from_addresses_transition::accessors::IdentityCreateFromAddressesTransitionAccessorsV0; +use dpp::ProtocolError; + +use dpp::state_transition::identity_create_from_addresses_transition::IdentityCreateFromAddressesTransition; +use dpp::state_transition::StateTransitionIdentityIdFromInputs; +use dpp::version::PlatformVersion; +use drive::state_transition_action::identity::identity_create_from_addresses::IdentityCreateFromAddressesTransitionAction; +use drive::state_transition_action::StateTransitionAction; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::system::bump_address_input_nonces_action::BumpAddressInputNoncesAction; +use crate::execution::validation::state_transition::common::validate_unique_identity_public_key_hashes_in_state::validate_unique_identity_public_key_hashes_not_in_state; + +pub(in crate::execution::validation::state_transition::state_transitions::identity_create_from_addresses) trait IdentityCreateFromAddressesStateTransitionStateValidationV1 +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateFromAddressesTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; + + +} + +impl IdentityCreateFromAddressesStateTransitionStateValidationV1 + for IdentityCreateFromAddressesTransition +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateFromAddressesTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let drive = platform.drive; + + let identity_id = self.identity_id_from_inputs()?; + let balance = + drive.fetch_identity_balance(identity_id.to_buffer(), transaction, platform_version)?; + + // Balance is here to check if the identity does already exist + if balance.is_some() { + // Since the id comes from the state transition this should never be reachable + return Ok(ConsensusValidationResult::new_with_error( + IdentityAlreadyExistsError::new(identity_id.to_owned()).into(), + )); + } + + // Now we should check the state of added keys to make sure there aren't any that already exist + let mut key_state_validation_result = + validate_unique_identity_public_key_hashes_not_in_state( + self.public_keys(), + drive, + execution_context, + transaction, + platform_version, + )?; + + key_state_validation_result.add_errors( + validate_identity_public_keys_contract_bounds( + identity_id, + self.public_keys(), + drive, + &block_info.epoch, + transaction, + execution_context, + platform_version, + )? + .errors, + ); + + if key_state_validation_result.is_valid() { + // We just pass the action that was given to us + Ok(ConsensusValidationResult::new_with_data( + StateTransitionAction::IdentityCreateFromAddressesAction(action), + )) + } else { + // It's not valid, we need to give back the action that partially uses the asset lock + + let penalty = platform_version + .drive_abci + .validation_and_processing + .penalties + .unique_key_already_present; + + let used_credits = penalty + .checked_add(execution_context.fee_cost(platform_version)?.processing_fee) + .ok_or(ProtocolError::Overflow("processing fee overflow error"))?; + + let bump_action = + BumpAddressInputNoncesAction::from_identity_create_from_addresses_transition_action( + action, + used_credits, + ); + Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action.into(), + key_state_validation_result.errors, + )) + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs index c295362bbdc..bb37a0084e0 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs @@ -14,6 +14,7 @@ use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::identity_create_from_shielded_pool::state::v0::IdentityCreateFromShieldedPoolStateTransitionStateValidationV0; +use crate::execution::validation::state_transition::identity_create_from_shielded_pool::state::v1::IdentityCreateFromShieldedPoolStateTransitionStateValidationV1; use crate::execution::validation::state_transition::identity_create_from_shielded_pool::transform_into_action::v0::IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV0; use crate::platform_types::platform::PlatformRef; use crate::platform_types::platform_state::PlatformStateV0Methods; @@ -83,6 +84,7 @@ pub trait StateTransitionStateValidationForIdentityCreateFromShieldedPoolTransit &self, action: IdentityCreateFromShieldedPoolTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error>; @@ -95,6 +97,7 @@ impl StateTransitionStateValidationForIdentityCreateFromShieldedPoolTransitionV0 &self, action: IdentityCreateFromShieldedPoolTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { @@ -107,9 +110,17 @@ impl StateTransitionStateValidationForIdentityCreateFromShieldedPoolTransitionV0 .state { 0 => self.validate_state_v0(platform, action, execution_context, tx, platform_version), + 1 => self.validate_state_v1( + platform, + block_info, + action, + execution_context, + tx, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "identity create from shielded pool transition: validate_state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/mod.rs index 9a1925de7fc..420eb16447d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/mod.rs @@ -1 +1,3 @@ pub(crate) mod v0; + +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v1/mod.rs new file mode 100644 index 00000000000..43bb533997b --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v1/mod.rs @@ -0,0 +1,148 @@ +use crate::execution::validation::state_transition::common::validate_identity_public_key_contract_bounds::validate_identity_public_keys_contract_bounds; +use dpp::block::block_info::BlockInfo; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::common::validate_unique_identity_public_key_hashes_in_state::validate_unique_identity_public_key_hashes_not_in_state; +use crate::platform_types::platform::PlatformRef; +use dpp::consensus::state::identity::IdentityAlreadyExistsError; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::shielded::identity_create_from_shielded_pool::IdentityCreateFromShieldedPoolTransitionAction; +use drive::state_transition_action::shielded::unshield::v0::UnshieldTransitionActionV0; +use drive::state_transition_action::shielded::unshield::UnshieldTransitionAction; +use drive::state_transition_action::StateTransitionAction; + +pub(in crate::execution::validation::state_transition::state_transitions::identity_create_from_shielded_pool) trait IdentityCreateFromShieldedPoolStateTransitionStateValidationV1 +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateFromShieldedPoolTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl IdentityCreateFromShieldedPoolStateTransitionStateValidationV1 + for IdentityCreateFromShieldedPoolTransition +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateFromShieldedPoolTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let drive = platform.drive; + + // 1. The new identity must not already exist. The id is `double_sha256(sorted nullifiers)` — + // collision-resistant and derived from single-use spend tags — so this is practically + // unreachable, but check explicitly to return a clean consensus rejection. There is no + // chargeable fallback for this case (it cannot be triggered by a relayer choosing a + // colliding id), so a failure is a plain free rejection, mirroring the identity-exists + // check in `IdentityCreateFromAddresses`'s `validate_state`. + let identity_id = derive_identity_id_from_actions(self.actions()); + if drive + .fetch_identity_balance(identity_id.to_buffer(), transaction, platform_version)? + .is_some() + { + // Since the id comes entirely from the spend nullifiers this should never be reachable. + return Ok(ConsensusValidationResult::new_with_error( + IdentityAlreadyExistsError::new(identity_id).into(), + )); + } + + // 2. None of the new identity's public-key hashes may already be registered to another + // identity (platform enforces globally-unique key hashes for unique key types). Unlike the + // identity-exists check above, this CAN be triggered by an attacker re-using a victim's + // public-key hash, so it gets a chargeable fallback instead of a free rejection: on + // failure the spend is still final and the value is credited to + // `send_to_address_on_creation_failure` minus a penalty. This is topologically identical + // to an `Unshield` (pool -> address minus fee), so we reuse `UnshieldTransitionAction` + // wholesale (its converter, `PaidFromShieldedPool` execution event, and conservation). + let mut key_state_validation_result = + validate_unique_identity_public_key_hashes_not_in_state( + self.public_keys(), + drive, + execution_context, + transaction, + platform_version, + )?; + + key_state_validation_result.add_errors( + validate_identity_public_keys_contract_bounds( + identity_id, + self.public_keys(), + drive, + &block_info.epoch, + transaction, + execution_context, + platform_version, + )? + .errors, + ); + + if key_state_validation_result.is_valid() { + // We just pass the success action that was built by `transform_into_action`. + Ok(ConsensusValidationResult::new_with_data( + StateTransitionAction::IdentityCreateFromShieldedPoolAction(action), + )) + } else { + // A key-state validation failure: finalize the spend and credit the fallback address minus a + // penalty. The penalty is the flat `unique_key_already_present` amount plus the metered + // processing fee accumulated so far (like `IdentityCreateFromAddresses`'s + // `BumpAddressInputNonces` penalty) PLUS the flat shielded compute fee + // (`compute_shielded_verification_fee`): the proposer ran the same Halo 2 verification on + // the failure path that the success path charges via `additional_fixed_fee_cost`, so the + // penalty floor must cover it too (fee parity with the success / other shielded paths). We + // then CAP it at the denomination so the Unshield converter's `amount.checked_sub(fee)` + // cannot underflow (a net-zero credit is the worst case: the whole spend is consumed by + // the penalty and flows to the fee pools). + let denomination = action.denomination(); + let compute_fee = dpp::shielded::compute_shielded_verification_fee( + action.notes().len(), + platform_version, + )?; + let penalty = platform_version + .drive_abci + .validation_and_processing + .penalties + .unique_key_already_present + .checked_add(execution_context.fee_cost(platform_version)?.processing_fee) + .and_then(|v| v.checked_add(compute_fee)) + .ok_or(ProtocolError::Overflow( + "identity create from shielded pool failure penalty overflow", + ))? + .min(denomination); + + let failure_action = UnshieldTransitionAction::V0(UnshieldTransitionActionV0 { + output_address: *self.send_to_address_on_creation_failure(), + amount: denomination, + notes: action.notes().to_vec(), + anchor: *action.anchor(), + fee_amount: penalty, + current_total_balance: action.current_total_balance(), + // This is the chargeable failure of an identity create: the `PaidFromShieldedPool` + // execution event reads this flag to apply its ops despite the attached validation + // errors (so the apply-despite-errors path is type-enforced, not comment-enforced). + chargeable_failure: true, + }); + + Ok(ConsensusValidationResult::new_with_data_and_errors( + StateTransitionAction::UnshieldAction(failure_action), + key_state_validation_result.errors, + )) + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs index 8938fb57d54..17989b160dd 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs @@ -119,6 +119,119 @@ fn build_success_action( } } +#[test] +fn should_validate_bound_authentication_keys_through_shielded_creation_dispatch() { + use super::StateTransitionStateValidationForIdentityCreateFromShieldedPoolTransitionV0; + use dpp::consensus::codes::ErrorWithCode; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::identifier::Identifier; + use dpp::identity::contract_bounds::ContractBounds; + + let version = PlatformVersion::latest(); + let platform = setup_platform(); + set_pool_total_balance(&platform, DENOMINATION * 10); + insert_anchor_into_state(&platform, &ANCHOR); + insert_dummy_encrypted_notes( + &platform, + version + .drive_abci + .validation_and_processing + .event_constants + .minimum_pool_notes_for_outgoing + .max(1), + ); + let contract = platform + .drive + .cache + .system_data_contracts + .load_dashpay(version) + .unwrap(); + let (valid_master, _) = + IdentityPublicKey::random_ecdsa_master_authentication_key(0, Some(31), version).unwrap(); + let state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + + for (case, id, document_type, error_code) in [ + ("valid", contract.id(), "contactRequest", None), + ( + "unknown contract", + Identifier::from([0x71; 32]), + "contactRequest", + Some(10400), + ), + ("unknown document", contract.id(), "missing", Some(10406)), + ] { + let key = IdentityPublicKeyInCreationV0 { + id: 1, + key_type: KeyType::ECDSA_HASH160, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name: document_type.into(), + }), + data: vec![0x72; 20].into(), + read_only: false, + signature: Default::default(), + }; + let st = transition( + vec![valid_master.clone().into(), key.into()], + vec![action(30), action(31)], + ); + let mut context = + StateTransitionExecutionContext::default_for_platform_version(version).unwrap(); + let success = build_success_action(&platform, &st, &mut context, version); + let expected_notes = success.notes().to_vec(); + let block_info = BlockInfo { + time_ms: 99, + ..Default::default() + }; + // Exercise the public version dispatcher, not a hard-coded v0/v1 implementation. + let result = st + .validate_state_for_identity_create_from_shielded_pool_transition( + success, + &platform_ref, + &block_info, + &mut context, + None, + ) + .unwrap(); + if let Some(error_code) = error_code { + assert_eq!(result.errors.len(), 1, "{case}: {:?}", result.errors); + assert_eq!(result.errors[0].code(), error_code); + let StateTransitionAction::UnshieldAction(fallback) = result.into_data().unwrap() + else { + panic!( + "{case}: invalid bounds must finalize the spend through the charged fallback" + ); + }; + assert!(fallback.chargeable_failure(), "{case}"); + assert_eq!(fallback.output_address(), &FALLBACK_ADDRESS); + assert_eq!(fallback.amount(), DENOMINATION); + assert_eq!(fallback.notes().len(), expected_notes.len()); + for (actual, expected) in fallback.notes().iter().zip(&expected_notes) { + assert_eq!(actual.nullifier, expected.nullifier); + assert_eq!(actual.cmx, expected.cmx); + assert_eq!(actual.cv_net, expected.cv_net); + assert_eq!(actual.encrypted_note, expected.encrypted_note); + } + assert_eq!(fallback.anchor(), &ANCHOR); + assert!(fallback.fee_amount() > 0 && fallback.fee_amount() < DENOMINATION); + } else { + assert!(result.is_valid(), "{:?}", result.errors); + assert_matches!( + result.into_data().unwrap(), + StateTransitionAction::IdentityCreateFromShieldedPoolAction(_) + ); + } + } +} + #[test] fn validate_state_rejects_when_identity_already_exists_at_derived_id() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs index b07b24b12a8..57b471336fe 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs @@ -25,6 +25,7 @@ use crate::rpc::core::CoreRPCLike; use crate::execution::validation::state_transition::identity_update::basic_structure::v0::IdentityUpdateStateTransitionStructureValidationV0; use crate::execution::validation::state_transition::identity_update::state::v0::IdentityUpdateStateTransitionStateValidationV0; +use crate::execution::validation::state_transition::identity_update::state::v1::IdentityUpdateStateTransitionStateValidationV1; use crate::execution::validation::state_transition::processor::basic_structure::StateTransitionBasicStructureValidationV0; use crate::execution::validation::state_transition::processor::state::StateTransitionStateValidation; use crate::execution::validation::state_transition::transformer::StateTransitionActionTransformer; @@ -95,8 +96,8 @@ impl StateTransitionStateValidation for IdentityUpdateTransition { _action: Option, platform: &PlatformRef, _validation_mode: ValidationMode, - _block_info: &BlockInfo, - _execution_context: &mut StateTransitionExecutionContext, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { let platform_version = platform.state.current_platform_version()?; @@ -108,9 +109,16 @@ impl StateTransitionStateValidation for IdentityUpdateTransition { .state { 0 => self.validate_state_v0(platform, tx, platform_version), + 1 => self.validate_state_v1( + platform, + block_info, + execution_context, + tx, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "identity update transition: validate_state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -376,6 +384,771 @@ mod tests { }; } + #[test] + fn should_retain_contract_lookup_fees_only_after_activation() { + use super::*; + use crate::execution::types::execution_operation::ValidationOperation; + use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContextMethodsV0; + use dpp::data_contract::factory::DataContractFactory; + use dpp::platform_value::platform_value; + use dpp::version::DefaultForPlatformVersion; + + for protocol in [13, 14] { + let version = PlatformVersion::get(protocol).unwrap(); + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol) + .build_with_mock_rpc() + .set_genesis_state(); + let (identity, _, _, master) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + let factory = DataContractFactory::new(protocol).unwrap(); + let contract = factory + .create_with_value_config( + identity.id(), + 1, + platform_value!({ + "note": { "type": "object", "requiresIdentityDecryptionBoundedKey": 0_u64, + "properties": {"text": {"type": "string", "maxLength": 64, "position": 0}}, "additionalProperties": false } + }), + None, + None, + ) + .unwrap() + .data_contract_owned(); + platform + .drive + .apply_contract(&contract, BlockInfo::default(), true, None, None, version) + .unwrap(); + let state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + for missing in [false, true] { + let id = if missing { + Identifier::from([0x73; 32]) + } else { + contract.id() + }; + platform.drive.cache.data_contracts.clear(); + let expected = platform + .drive + .get_system_or_user_contract_with_fee( + id.to_buffer(), + &BlockInfo::default().epoch, + None, + version, + ) + .unwrap(); + let expected_fee = expected.fee().unwrap().clone(); + assert!(expected_fee.processing_fee > 0); + platform.drive.cache.data_contracts.clear(); + let update: IdentityUpdateTransition = IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 1, + nonce: 1, + add_public_keys: vec![IdentityPublicKeyInCreationV0 { + id: 2, + purpose: Purpose::DECRYPTION, + security_level: SecurityLevel::HIGH, + key_type: KeyType::ECDSA_HASH160, + data: vec![0x74; 20].into(), + read_only: false, + signature: Default::default(), + contract_bounds: Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name: "note".into(), + }), + } + .into()], + disable_public_keys: vec![], + user_fee_increase: 0, + signature_public_key_id: master.id(), + signature: Default::default(), + } + .into(); + let mut context = + StateTransitionExecutionContext::default_for_platform_version(version).unwrap(); + let result = update + .validate_state( + None, + &platform_ref, + ValidationMode::Validator, + &BlockInfo::default(), + &mut context, + None, + ) + .unwrap(); + assert_eq!( + result.is_valid(), + !missing, + "protocol {protocol}: {:?}", + result.errors + ); + if missing { + assert_matches!( + result.into_data().unwrap(), + StateTransitionAction::BumpIdentityNonceAction(_) + ); + } + if protocol == 13 { + assert!( + context.operations_slice().is_empty(), + "historical v0 discards its local validation costs" + ); + } else { + assert!(context.operations_slice().iter().any(|operation| matches!(operation, + ValidationOperation::PrecalculatedOperation(fee) if fee == &expected_fee + )), "contract lookup costs must reach the caller even on paid failure"); + } + } + } + } + + #[tokio::test] + async fn should_register_bound_authentication_key_and_preserve_proof_metadata() { + use drive::drive::identity::key::fetch::{ + IdentityKeysRequest, KeyKindRequestType, KeyRequestType, + }; + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (identity, signer, _, key) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(platform_version) + .unwrap(); + let bounds = ContractBounds::SingleContractDocumentType { + id: dashpay.id(), + document_type_name: "profile".into(), + }; + let platform_state = platform.state.load(); + let secp = Secp256k1::new(); + let mut rng = StdRng::seed_from_u64(292); + let new_key_pair = Keypair::new(&secp, &mut rng); + let mut new_key = IdentityPublicKeyInCreationV0 { + id: 2, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + key_type: ECDSA_SECP256K1, + read_only: false, + data: new_key_pair.public_key().serialize().to_vec().into(), + signature: Default::default(), + contract_bounds: Some(bounds.clone()), + }; + let build = |new_key: IdentityPublicKeyInCreationV0| -> StateTransition { + IdentityUpdateTransition::from(IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 1, + nonce: 1, + add_public_keys: vec![IdentityPublicKeyInCreation::V0(new_key)], + disable_public_keys: vec![], + user_fee_increase: 0, + signature_public_key_id: key.id(), + signature: Default::default(), + }) + .into() + }; + let signable_bytes = build(new_key.clone()).signable_bytes().unwrap(); + new_key.signature = + signer::sign(&signable_bytes, &new_key_pair.secret_key().secret_bytes()) + .unwrap() + .to_vec() + .into(); + let mut update_transition = build(new_key); + update_transition + .set_signature(signer.sign(&key, signable_bytes.as_slice()).await.unwrap()); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![update_transition.serialize_to_bytes().unwrap()], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + true, + None, + ) + .unwrap(); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .unwrap(); + + // The state transition proof carries the bounds of the registered key. + let proof_result = platform + .platform + .drive + .prove_state_transition(&update_transition, None, platform_version) + .map_err(|e| e.to_string()) + .expect("expected to create proof"); + if let Some(proof_error) = proof_result.first_error() { + panic!("proof_result is not valid with error {}", proof_error); + } + let proof_data = proof_result + .into_data() + .map_err(|e| e.to_string()) + .expect("expected to get proof data"); + let (_, verification_result) = Drive::verify_state_transition_was_executed_with_proof( + &update_transition, + &BlockInfo::default(), + &proof_data, + &|_id: &Identifier| Ok(None), + platform_version, + ) + .map(|(root_hash, outcome)| (root_hash, outcome.into_result())) + .map_err(|e| e.to_string()) + .expect("expected to verify state transition"); + let StateTransitionProofResult::VerifiedPartialIdentity(proven) = verification_result + else { + panic!("expected a partial identity, got {verification_result:?}"); + }; + assert_eq!( + proven.loaded_public_keys.get(&2).unwrap().contract_bounds(), + Some(&bounds) + ); + // The key is indexed as the current authentication key of the bound document type. + let indexed = platform + .drive + .fetch_identity_keys_as_partial_identity( + IdentityKeysRequest { + identity_id: identity.id().to_buffer(), + request_type: KeyRequestType::ContractDocumentTypeBoundKey( + dashpay.id().to_buffer(), + "profile".into(), + Purpose::AUTHENTICATION, + KeyKindRequestType::CurrentKeyOfKindRequest, + ), + limit: None, + offset: None, + }, + None, + platform_version, + ) + .unwrap() + .unwrap(); + assert_eq!( + indexed + .loaded_public_keys + .get(&2) + .unwrap() + .contract_bounds(), + Some(&bounds) + ); + + // Revocation through the master key keeps the bounds on the disabled key. + let mut revoke: StateTransition = + IdentityUpdateTransition::from(IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 2, + nonce: 2, + add_public_keys: vec![], + disable_public_keys: vec![2], + user_fee_increase: 0, + signature_public_key_id: key.id(), + signature: Default::default(), + }) + .into(); + revoke.set_signature( + signer + .sign(&key, &revoke.signable_bytes().unwrap()) + .await + .unwrap(), + ); + let block = BlockInfo { + time_ms: 50, + ..Default::default() + }; + let tx = platform.drive.grove.start_transaction(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![revoke.serialize_to_bytes().unwrap()], + &platform_state, + &block, + &tx, + platform_version, + true, + None, + ) + .unwrap(); + assert_eq!(result.valid_count(), 1); + platform + .drive + .grove + .commit_transaction(tx) + .unwrap() + .unwrap(); + let fetched = platform + .drive + .fetch_identity_keys_as_partial_identity( + IdentityKeysRequest::new_specific_key_query(&identity.id().to_buffer(), 2), + None, + platform_version, + ) + .unwrap() + .unwrap(); + let revoked = fetched.loaded_public_keys.get(&2).unwrap(); + assert_eq!(revoked.disabled_at(), Some(50)); + assert_eq!(revoked.contract_bounds(), Some(&bounds)); + assert!(platform + .drive + .grove + .visualize_verify_grovedb(None, true, false, &platform_version.drive.grove_version) + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn should_refresh_every_bound_key_reference_after_revocation() { + use drive::drive::identity::key::fetch::{ + IdentityKeysRequest, KeyKindRequestType, KeyRequestType, + }; + let version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (mut identity, mut signer, _, master) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(version) + .unwrap(); + let dpns = platform + .drive + .cache + .system_data_contracts + .load_dpns(version) + .unwrap(); + let contract_bound = ContractBounds::SingleContract { id: dashpay.id() }; + let type_bound = ContractBounds::SingleContractDocumentType { + id: dpns.id(), + document_type_name: "preorder".into(), + }; + let contract_key = setup_add_key_to_identity( + &mut platform, + &mut identity, + &mut signer, + 4, + 2, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + KeyType::ECDSA_SECP256K1, + Some(contract_bound.clone()), + ); + let type_key = setup_add_key_to_identity( + &mut platform, + &mut identity, + &mut signer, + 5, + 3, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + KeyType::ECDSA_SECP256K1, + Some(type_bound.clone()), + ); + let mut update: StateTransition = + IdentityUpdateTransition::from(IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 1, + nonce: 1, + add_public_keys: vec![], + disable_public_keys: vec![contract_key.id(), type_key.id()], + user_fee_increase: 0, + signature_public_key_id: master.id(), + signature: Default::default(), + }) + .into(); + update.set_signature( + signer + .sign(&master, &update.signable_bytes().unwrap()) + .await + .unwrap(), + ); + let block = BlockInfo { + time_ms: 1001, + ..Default::default() + }; + let transaction = platform.drive.grove.start_transaction(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![update.serialize_to_bytes().unwrap()], + &platform.state.load(), + &block, + &transaction, + version, + true, + None, + ) + .unwrap(); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .unwrap(); + + // Both current-key references must resolve to the updated keys, not their old hashes. + for (key, bounds, request_type) in [ + ( + &contract_key, + &contract_bound, + KeyRequestType::ContractBoundKey( + dashpay.id().to_buffer(), + Purpose::AUTHENTICATION, + KeyKindRequestType::CurrentKeyOfKindRequest, + ), + ), + ( + &type_key, + &type_bound, + KeyRequestType::ContractDocumentTypeBoundKey( + dpns.id().to_buffer(), + "preorder".into(), + Purpose::AUTHENTICATION, + KeyKindRequestType::CurrentKeyOfKindRequest, + ), + ), + ] { + let fetched = platform + .drive + .fetch_identity_keys_as_partial_identity( + IdentityKeysRequest { + identity_id: identity.id().to_buffer(), + request_type, + limit: None, + offset: None, + }, + None, + version, + ) + .unwrap() + .unwrap(); + let refreshed = fetched + .loaded_public_keys + .get(&key.id()) + .expect("bound key reference"); + assert_eq!(refreshed.disabled_at(), Some(block.time_ms)); + assert_eq!(refreshed.contract_bounds(), Some(bounds)); + } + assert!( + platform + .drive + .grove + .visualize_verify_grovedb(None, true, false, &version.drive.grove_version) + .unwrap() + .is_empty(), + "revocation must leave no stale GroveDB references" + ); + } + + #[tokio::test] + async fn should_keep_the_newest_bound_key_current_across_registration_and_revocation() { + use drive::config::DriveConfig; + use drive::drive::identity::key::fetch::{ + IdentityKeysRequest, KeyIDVec, KeyKindRequestType, KeyRequestType, + }; + use std::collections::BTreeMap; + let version = PlatformVersion::latest(); + // Consistency verification makes GroveDB reject two pending operations on one slot, + // which is what bound keys covering one contract queue for its current-key alias. + let mut platform = TestPlatformBuilder::new() + .with_config(PlatformConfig { + drive: DriveConfig { + batching_consistency_verification: true, + ..DriveConfig::default_testnet() + }, + ..Default::default() + }) + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (identity, signer, _, master) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(version) + .unwrap(); + let bounds = ContractBounds::SingleContract { id: dashpay.id() }; + let secp = Secp256k1::new(); + let mut rng = StdRng::seed_from_u64(292); + let pairs: BTreeMap = [2u32, 3, 4] + .into_iter() + .map(|id| (id, Keypair::new(&secp, &mut rng))) + .collect(); + let bound_key = |id: u32| IdentityPublicKeyInCreationV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + key_type: ECDSA_SECP256K1, + read_only: false, + data: pairs[&id].public_key().serialize().to_vec().into(), + signature: Default::default(), + contract_bounds: Some(bounds.clone()), + }; + let unsigned = |revision: u64, + add: Vec, + disable: Vec| + -> StateTransition { + IdentityUpdateTransition::from(IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision, + nonce: revision, + add_public_keys: add + .into_iter() + .map(IdentityPublicKeyInCreation::V0) + .collect(), + disable_public_keys: disable, + user_fee_increase: 0, + signature_public_key_id: master.id(), + signature: Default::default(), + }) + .into() + }; + let apply = |transition: &StateTransition, time_ms: u64| { + let transaction = platform.drive.grove.start_transaction(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![transition.serialize_to_bytes().unwrap()], + &platform.state.load(), + &BlockInfo { + time_ms, + ..Default::default() + }, + &transaction, + version, + true, + None, + ) + .unwrap(); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }], + "update at {time_ms} must apply as one consistent batch" + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .unwrap(); + }; + let key_ids = |kind: KeyKindRequestType| -> Vec { + platform + .drive + .fetch_identity_keys::( + IdentityKeysRequest { + identity_id: identity.id().to_buffer(), + request_type: KeyRequestType::ContractBoundKey( + dashpay.id().to_buffer(), + Purpose::AUTHENTICATION, + kind, + ), + limit: Some(16), + offset: None, + }, + None, + version, + ) + .unwrap() + }; + let disabled_at = |key_id: u32| -> Option { + platform + .drive + .fetch_identity_keys_as_partial_identity( + IdentityKeysRequest { + identity_id: identity.id().to_buffer(), + request_type: KeyRequestType::SpecificKeys(vec![key_id]), + limit: Some(1), + offset: None, + }, + None, + version, + ) + .unwrap() + .unwrap() + .loaded_public_keys[&key_id] + .disabled_at() + }; + let assert_slot = |current: u32, all: &[u32]| { + assert_eq!( + key_ids(KeyKindRequestType::CurrentKeyOfKindRequest), + vec![current], + "current key" + ); + assert_eq!( + key_ids(KeyKindRequestType::AllKeysOfKindRequest), + all, + "listing must not repeat the current key alias" + ); + }; + + // 1. Two bound keys in one update, listed newest first: the highest key id must be + // current regardless of input order. + let mut adds = vec![bound_key(3), bound_key(2)]; + let signable = unsigned(1, adds.clone(), vec![]).signable_bytes().unwrap(); + for key in &mut adds { + key.signature = signer::sign(&signable, &pairs[&key.id].secret_key().secret_bytes()) + .unwrap() + .to_vec() + .into(); + } + let mut registration = unsigned(1, adds, vec![]); + registration.set_signature(signer.sign(&master, &signable).await.unwrap()); + apply(®istration, 1000); + assert_slot(3, &[2, 3]); + + // 2. Register a replacement and revoke the current key in the same transition: the + // replacement must become current, not the revoked key or a batch conflict. + let mut adds = vec![bound_key(4)]; + let signable = unsigned(2, adds.clone(), vec![3]).signable_bytes().unwrap(); + for key in &mut adds { + key.signature = signer::sign(&signable, &pairs[&key.id].secret_key().secret_bytes()) + .unwrap() + .to_vec() + .into(); + } + let mut replacement = unsigned(2, adds, vec![3]); + replacement.set_signature(signer.sign(&master, &signable).await.unwrap()); + apply(&replacement, 2000); + assert_slot(4, &[2, 3, 4]); + assert_eq!(disabled_at(3), Some(2000)); + assert_eq!(disabled_at(4), None); + + // 3. Revoking an older key on its own must not repoint the alias at it. + let mut revocation = unsigned(3, vec![], vec![2]); + revocation.set_signature( + signer + .sign(&master, &revocation.signable_bytes().unwrap()) + .await + .unwrap(), + ); + apply(&revocation, 3000); + assert_slot(4, &[2, 3, 4]); + assert_eq!(disabled_at(2), Some(3000)); + assert!( + platform + .drive + .grove + .visualize_verify_grovedb(None, true, false, &version.drive.grove_version) + .unwrap() + .is_empty(), + "registration and revocation must leave no stale GroveDB references" + ); + } + + #[tokio::test] + async fn should_estimate_bound_key_revocation_at_least_at_its_execution_cost() { + let version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (mut identity, mut signer, _, _) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(version) + .unwrap(); + let bound = setup_add_key_to_identity( + &mut platform, + &mut identity, + &mut signer, + 4, + 2, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + KeyType::ECDSA_SECP256K1, + Some(ContractBounds::SingleContract { id: dashpay.id() }), + ); + let plain = setup_add_key_to_identity( + &mut platform, + &mut identity, + &mut signer, + 5, + 3, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + KeyType::ECDSA_SECP256K1, + None, + ); + let block = BlockInfo { + time_ms: 1001, + ..Default::default() + }; + let estimate = |key_id: u32| { + platform + .drive + .disable_identity_keys( + identity.id().to_buffer(), + vec![key_id], + block.time_ms, + &block, + false, + None, + version, + ) + .unwrap() + }; + let estimated_bound = estimate(bound.id()); + let estimated_plain = estimate(plain.id()); + // The bound key has contract-info references to refresh; v0 estimated with an unbounded + // stand-in key and priced none of them. + assert!( + estimated_bound.processing_fee > estimated_plain.processing_fee, + "bound revocation estimate {} must exceed the unbounded one {}", + estimated_bound.processing_fee, + estimated_plain.processing_fee + ); + let actual = platform + .drive + .disable_identity_keys( + identity.id().to_buffer(), + vec![bound.id()], + block.time_ms, + &block, + true, + None, + version, + ) + .unwrap(); + assert!( + estimated_bound.processing_fee >= actual.processing_fee, + "estimate {} must cover execution {}", + estimated_bound.processing_fee, + actual.processing_fee + ); + assert!(estimated_bound.storage_fee >= actual.storage_fee); + } + #[tokio::test] async fn test_identity_update_that_disables_an_encryption_key() { let platform_config = PlatformConfig { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/mod.rs index 9a1925de7fc..420eb16447d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/mod.rs @@ -1 +1,3 @@ pub(crate) mod v0; + +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v1/mod.rs new file mode 100644 index 00000000000..efcb0cdfbfc --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v1/mod.rs @@ -0,0 +1,162 @@ +use super::v0::IdentityUpdateStateTransitionStateValidationV0; +use crate::error::Error; +use dpp::block::block_info::BlockInfo; + +use crate::platform_types::platform::PlatformRef; +use crate::rpc::core::CoreRPCLike; + +use dpp::prelude::ConsensusValidationResult; + +use dpp::state_transition::identity_update_transition::accessors::IdentityUpdateTransitionAccessorsV0; +use dpp::state_transition::identity_update_transition::IdentityUpdateTransition; +use dpp::version::PlatformVersion; +use drive::state_transition_action::StateTransitionAction; + +use drive::grovedb::TransactionArg; +use drive::state_transition_action::system::bump_identity_nonce_action::BumpIdentityNonceAction; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::common::validate_identity_public_key_contract_bounds::validate_identity_public_keys_contract_bounds; +use crate::execution::validation::state_transition::common::validate_identity_public_key_ids_dont_exist_in_state::validate_identity_public_key_ids_dont_exist_in_state; +use crate::execution::validation::state_transition::common::validate_identity_public_key_ids_exist_in_state::validate_identity_public_key_ids_exist_in_state; +use crate::execution::validation::state_transition::common::validate_not_disabling_last_master_key::validate_master_key_uniqueness; +use crate::execution::validation::state_transition::common::validate_unique_identity_public_key_hashes_in_state::validate_unique_identity_public_key_hashes_not_in_state; + +pub(in crate::execution::validation::state_transition::state_transitions::identity_update) trait IdentityUpdateStateTransitionStateValidationV1 +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + state_transition_execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl IdentityUpdateStateTransitionStateValidationV1 for IdentityUpdateTransition { + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + state_transition_execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let drive = platform.drive; + let mut validation_result = ConsensusValidationResult::::default(); + + // Now we should check the state of added keys to make sure there aren't any that already exist + validation_result.add_errors( + validate_unique_identity_public_key_hashes_not_in_state( + self.public_keys_to_add(), + drive, + state_transition_execution_context, + tx, + platform_version, + )? + .errors, + ); + + if !validation_result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_identity_update_transition(self), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + validation_result.errors, + )); + } + + validation_result.add_errors( + validate_identity_public_key_ids_dont_exist_in_state( + self.identity_id(), + self.public_keys_to_add(), + drive, + tx, + state_transition_execution_context, + platform_version, + )? + .errors, + ); + + if !validation_result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_identity_update_transition(self), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + validation_result.errors, + )); + } + + // Now we should check to make sure any keys that are added are valid for the contract + // bounds they refer to + validation_result.add_errors( + validate_identity_public_keys_contract_bounds( + self.identity_id(), + self.public_keys_to_add(), + drive, + &block_info.epoch, + tx, + state_transition_execution_context, + platform_version, + )? + .errors, + ); + + if !validation_result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_identity_update_transition(self), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + validation_result.errors, + )); + } + + if !self.public_key_ids_to_disable().is_empty() { + let validation_result_and_keys_to_disable = + validate_identity_public_key_ids_exist_in_state( + self.identity_id(), + self.public_key_ids_to_disable(), + drive, + state_transition_execution_context, + tx, + platform_version, + )?; + // We need to validate that all keys removed existed + if !validation_result_and_keys_to_disable.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_identity_update_transition(self), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + validation_result_and_keys_to_disable.errors, + )); + } + + let keys_to_disable = validation_result_and_keys_to_disable.into_data()?; + + let validation_result = validate_master_key_uniqueness( + self.public_keys_to_add(), + keys_to_disable.as_slice(), + platform_version, + )?; + if !validation_result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_identity_update_transition(self), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + validation_result.errors, + )); + } + } + self.transform_into_action_v0() + } +} diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/mod.rs index 721ba794bcb..c79321769f0 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/mod.rs @@ -10,6 +10,7 @@ use platform_version::version::PlatformVersion; use std::collections::HashMap; mod v0; +mod v1; impl Drive { /// Adds potential contract information for a contract-bounded key. @@ -62,9 +63,18 @@ impl Drive { drive_operations, platform_version, ), + 1 => self.add_potential_contract_info_for_contract_bounded_key_v1( + identity_id, + identity_key, + epoch, + estimated_costs_only_with_layer_info, + transaction, + drive_operations, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_potential_contract_info_for_contract_bounded_key".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v1/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v1/mod.rs new file mode 100644 index 00000000000..a0b0060276a --- /dev/null +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v1/mod.rs @@ -0,0 +1,543 @@ +use crate::drive::identity::contract_info::keys::IdentityDataContractKeyApplyInfo; +use crate::drive::identity::contract_info::ContractInfoStructure::ContractInfoKeysKey; +use crate::drive::identity::IdentityRootStructure::IdentityContractInfo; +use crate::drive::identity::{ + identity_contract_info_group_keys_path_vec, identity_contract_info_group_path_key_purpose_vec, + identity_contract_info_group_path_vec, identity_contract_info_root_path_vec, + identity_key_location_within_identity_vec, identity_path_vec, +}; +use crate::drive::Drive; +use crate::error::contract::DataContractError; +use crate::error::identity::IdentityError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::QueryTarget::QueryTargetValue; +use crate::util::grove_operations::{BatchInsertApplyType, BatchInsertTreeApplyType}; +use crate::util::object_size_info::{PathKeyElementInfo, PathKeyInfo}; +use dpp::block::epoch::Epoch; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::config::v0::DataContractConfigGettersV0; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::storage_requirements::keys_for_document_type::StorageKeyRequirements; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{IdentityPublicKey, Purpose}; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::reference_path::ReferencePathType::{SiblingReference, UpstreamRootHeightReference}; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; +use grovedb_costs::OperationCost; +use integer_encoding::VarInt; +use std::collections::HashMap; + +impl Drive { + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(in crate::drive::identity::contract_info) fn add_potential_contract_info_for_contract_bounded_key_v1( + &self, + identity_id: [u8; 32], + identity_key: &IdentityPublicKey, + epoch: &Epoch, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + if let Some(contract_bounds) = &identity_key.contract_bounds() { + // We need to get the contract + let contract_apply_info = IdentityDataContractKeyApplyInfo::new_from_single_key( + identity_key.id(), + identity_key.purpose(), + contract_bounds, + self, + epoch, + transaction, + drive_operations, + platform_version, + )?; + self.add_contract_info_operations_v1( + identity_id, + epoch, + vec![contract_apply_info], + estimated_costs_only_with_layer_info, + transaction, + drive_operations, + platform_version, + )?; + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + /// Adds the contract info operations + fn add_contract_info_operations_v1( + &self, + identity_id: [u8; 32], + epoch: &Epoch, + contract_infos: Vec, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let identity_path = identity_path_vec(identity_id.as_slice()); + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_contract_info( + &identity_id, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + let apply_type = if estimated_costs_only_with_layer_info.is_none() { + BatchInsertTreeApplyType::StatefulBatchInsertTree + } else { + BatchInsertTreeApplyType::StatelessBatchInsertTree { + in_tree_type: TreeType::NormalTree, + tree_type: TreeType::NormalTree, + flags_len: 0, + } + }; + + // we insert the contract root tree if it doesn't exist already + self.batch_insert_empty_tree_if_not_exists_check_existing_operations( + PathKeyInfo::<0>::PathKey((identity_path, vec![IdentityContractInfo as u8])), + false, + None, + apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + + for contract_info in contract_infos.into_iter() { + let root_id = contract_info.root_id(); + + let contract = if estimated_costs_only_with_layer_info.is_none() { + // we should start by fetching the contract + let (fee, contract) = self.get_contract_with_fetch_info_and_fee( + root_id, + Some(epoch), + true, + transaction, + platform_version, + )?; + + let fee = fee.ok_or(Error::Identity( + IdentityError::IdentityKeyDataContractNotFound, + ))?; + let contract = contract.ok_or(Error::Identity( + IdentityError::IdentityKeyDataContractNotFound, + ))?; + drive_operations.push(LowLevelDriveOperation::PreCalculatedFeeResult(fee)); + Some(contract) + } else { + drive_operations.push(LowLevelDriveOperation::CalculatedCostOperation( + OperationCost { + seek_count: 1, + storage_cost: Default::default(), + storage_loaded_bytes: 100, + hash_node_calls: 0, + sinsemilla_hash_calls: 0, + }, + )); + None + }; + + let (document_keys, contract_or_family_keys) = contract_info.keys(); + + if !contract_or_family_keys.is_empty() { + // we only need to do this once + if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + Self::add_estimation_costs_for_contract_info_group( + &identity_id, + &root_id, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + + Self::add_estimation_costs_for_contract_info_group_keys( + &identity_id, + &root_id, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + self.batch_insert_empty_tree_if_not_exists_check_existing_operations( + PathKeyInfo::<0>::PathKey(( + identity_contract_info_root_path_vec(&identity_id), + root_id.to_vec(), + )), + false, + None, + apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + + // We need to insert the keys parent tree + self.batch_insert_empty_tree_if_not_exists_check_existing_operations( + PathKeyInfo::<0>::PathKey(( + identity_contract_info_group_path_vec(&identity_id, &root_id), + vec![ContractInfoKeysKey as u8], + )), + false, + None, + apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + } + + for (key_id, purpose) in contract_or_family_keys { + if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + Self::add_estimation_costs_for_contract_info_group_key_purpose( + &identity_id, + &root_id, + purpose, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + // We need to insert the key type + self.batch_insert_empty_tree_if_not_exists_check_existing_operations( + PathKeyInfo::<0>::PathKey(( + identity_contract_info_group_keys_path_vec(&identity_id, &root_id), + vec![purpose as u8], + )), + false, + None, + apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + + // we need to add a reference to the key + let key_id_bytes = key_id.encode_var_vec(); + let key_reference = + identity_key_location_within_identity_vec(key_id_bytes.as_slice()); + + let reference_type_path = UpstreamRootHeightReference(2, key_reference); + + let ref_apply_type = if estimated_costs_only_with_layer_info.is_none() { + BatchInsertApplyType::StatefulBatchInsert + } else { + BatchInsertApplyType::StatelessBatchInsert { + in_tree_type: TreeType::NormalTree, + target: QueryTargetValue(reference_type_path.serialized_size() as u32), + } + }; + + // at this point we want to know if the contract is single key or multiple key + let storage_key_requirements = contract + .as_ref() + .map(|contract| match purpose { + Purpose::AUTHENTICATION => { + Ok(StorageKeyRequirements::MultipleReferenceToLatest) + } + Purpose::ENCRYPTION => { + let encryption_storage_key_requirements = contract + .contract + .config() + .requires_identity_encryption_bounded_key() + .ok_or(Error::DataContract( + DataContractError::KeyBoundsExpectedButNotPresent( + "expected encryption key bounds for encryption", + ), + ))?; + Ok(encryption_storage_key_requirements) + } + Purpose::DECRYPTION => { + let decryption_storage_key_requirements = contract + .contract + .config() + .requires_identity_decryption_bounded_key() + .ok_or(Error::DataContract( + DataContractError::KeyBoundsExpectedButNotPresent( + "expected encryption key bounds for decryption", + ), + ))?; + Ok(decryption_storage_key_requirements) + } + _ => Err(Error::Identity(IdentityError::IdentityKeyBoundsError( + "purpose not available for key bounds", + ))), + }) + .transpose()? + .unwrap_or(StorageKeyRequirements::MultipleReferenceToLatest); + + // if we are multiple we insert the key under the key bytes, otherwise it is under 0 + + if storage_key_requirements == StorageKeyRequirements::Unique { + self.batch_insert_if_not_exists( + PathKeyElementInfo::<0>::PathKeyElement(( + identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &root_id, + purpose, + ), + vec![], + Element::Reference(reference_type_path, Some(1), None), + )), + ref_apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + } else { + self.batch_insert_if_not_exists( + PathKeyElementInfo::<0>::PathKeyRefElement(( + identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &root_id, + purpose, + ), + key_id_bytes.as_slice(), + Element::Reference(reference_type_path, Some(1), None), + )), + ref_apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + }; + + if storage_key_requirements == StorageKeyRequirements::MultipleReferenceToLatest { + // we also insert a sibling reference so we can query the current key + + let sibling_ref_type_path = SiblingReference(key_id_bytes); + let sibling_path = if purpose == Purpose::AUTHENTICATION { + // A bound authentication key's current-key reference belongs beside + // its key IDs, under the purpose subtree. Keep legacy paths frozen. + identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &root_id, + purpose, + ) + } else { + identity_contract_info_group_keys_path_vec(&identity_id, &root_id) + }; + + self.batch_insert( + PathKeyElementInfo::<0>::PathKeyElement(( + sibling_path, + vec![], + Element::Reference(sibling_ref_type_path, Some(2), None), + )), + drive_operations, + &platform_version.drive, + )?; + } + } + + for (document_type_name, document_key_ids) in document_keys { + // The path is the concatenation of the contract_id and the document type name + let mut contract_id_bytes_with_document_type_name = root_id.to_vec(); + contract_id_bytes_with_document_type_name.extend(document_type_name.as_bytes()); + + if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + Self::add_estimation_costs_for_contract_info_group( + &identity_id, + &contract_id_bytes_with_document_type_name, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + + Self::add_estimation_costs_for_contract_info_group_keys( + &identity_id, + &contract_id_bytes_with_document_type_name, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + self.batch_insert_empty_tree_if_not_exists_check_existing_operations( + PathKeyInfo::<0>::PathKey(( + identity_contract_info_root_path_vec(&identity_id), + contract_id_bytes_with_document_type_name.to_vec(), + )), + false, + None, + apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + + self.batch_insert_empty_tree_if_not_exists_check_existing_operations( + PathKeyInfo::<0>::PathKey(( + identity_contract_info_group_path_vec( + &identity_id, + &contract_id_bytes_with_document_type_name, + ), + vec![ContractInfoKeysKey as u8], + )), + false, + None, + apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + + for (key_id, purpose) in document_key_ids { + if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + Self::add_estimation_costs_for_contract_info_group_key_purpose( + &identity_id, + &contract_id_bytes_with_document_type_name, + purpose, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + // We need to insert the key type + self.batch_insert_empty_tree_if_not_exists_check_existing_operations( + PathKeyInfo::<0>::PathKey(( + identity_contract_info_group_keys_path_vec( + &identity_id, + &contract_id_bytes_with_document_type_name, + ), + vec![purpose as u8], + )), + false, + None, + apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + + // we need to add a reference to the key + let key_id_bytes = key_id.encode_var_vec(); + let key_reference = + identity_key_location_within_identity_vec(key_id_bytes.as_slice()); + + let reference = UpstreamRootHeightReference(2, key_reference); + + let ref_apply_type = if estimated_costs_only_with_layer_info.is_none() { + BatchInsertApplyType::StatefulBatchInsert + } else { + BatchInsertApplyType::StatelessBatchInsert { + in_tree_type: TreeType::NormalTree, + target: QueryTargetValue(reference.serialized_size() as u32), + } + }; + + // at this point we want to know if the contract is single key or multiple key + let storage_key_requirements = contract + .as_ref() + .map(|contract| match purpose { + Purpose::AUTHENTICATION => { + Ok(StorageKeyRequirements::MultipleReferenceToLatest) + } + Purpose::ENCRYPTION => { + let document_type = contract + .contract + .document_type_for_name(document_type_name.as_str())?; + let encryption_storage_key_requirements = document_type + .requires_identity_encryption_bounded_key() + .ok_or(Error::DataContract( + DataContractError::KeyBoundsExpectedButNotPresent( + "expected encryption key bounds in document type", + ), + ))?; + Ok(encryption_storage_key_requirements) + } + Purpose::DECRYPTION => { + let document_type = contract + .contract + .document_type_for_name(document_type_name.as_str())?; + let decryption_storage_key_requirements = document_type + .requires_identity_decryption_bounded_key() + .ok_or(Error::DataContract( + DataContractError::KeyBoundsExpectedButNotPresent( + "expected encryption key bounds in document type", + ), + ))?; + Ok(decryption_storage_key_requirements) + } + _ => Err(Error::Identity(IdentityError::IdentityKeyBoundsError( + "purpose not available for key bounds", + ))), + }) + .transpose()? + .unwrap_or(StorageKeyRequirements::MultipleReferenceToLatest); + + if storage_key_requirements == StorageKeyRequirements::Unique { + self.batch_insert( + PathKeyElementInfo::<0>::PathKeyElement(( + identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &contract_id_bytes_with_document_type_name, + purpose, + ), + vec![], + Element::Reference(reference, Some(1), None), + )), + drive_operations, + &platform_version.drive, + )?; + } else { + self.batch_insert_if_not_exists( + PathKeyElementInfo::<0>::PathKeyElement(( + identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &contract_id_bytes_with_document_type_name, + purpose, + ), + key_id_bytes.clone(), + Element::Reference(reference, Some(1), None), + )), + ref_apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + }; + + if storage_key_requirements == StorageKeyRequirements::MultipleReferenceToLatest + { + // we also insert a sibling reference so we can query the current key + + let sibling_ref_type_path = SiblingReference(key_id_bytes); + let sibling_path = identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &contract_id_bytes_with_document_type_name, + purpose, + ); + + self.batch_insert( + PathKeyElementInfo::<0>::PathKeyElement(( + sibling_path, + vec![], + Element::Reference(sibling_ref_type_path, Some(2), None), + )), + drive_operations, + &platform_version.drive, + )?; + } + } + } + } + + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/mod.rs index 27df5d4a0da..143d85cb15c 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/mod.rs @@ -9,13 +9,94 @@ use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::identifier::Identifier; use dpp::identity::contract_bounds::ContractBounds; use dpp::identity::{KeyID, Purpose}; -use grovedb::TransactionArg; +use grovedb::batch::key_info::KeyInfo; +use grovedb::batch::{GroveOp, KeyInfoPath, QualifiedGroveDbOp}; +use grovedb::reference_path::ReferencePathType; +use grovedb::{Element, TransactionArg}; +use integer_encoding::VarInt; use platform_version::version::PlatformVersion; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap, HashSet}; mod add_potential_contract_info_for_contract_bounded_key; mod refresh_potential_contract_info_key_references; +/// Coalesces the current-key alias writes of contract-info purpose subtrees in one batch. +/// +/// Every contract-bound authentication key covering a contract (or a contract document type) +/// writes the alias at the empty key of the AUTHENTICATION purpose subtree, and disabling such a +/// key refreshes it. An identity update can therefore queue several operations for one slot: two +/// registered keys, or a registration and a revocation built by separate operation builders. +/// GroveDB rejects two operations on one slot under batching consistency verification, and would +/// otherwise apply whichever came last. Keep exactly one per slot: an insertion beats a refresh +/// (the insertion rewrites the element and its hash), the insertion naming the highest key id wins +/// so the newest key is current regardless of input order, and duplicate refreshes collapse. +pub(crate) fn coalesce_current_key_alias_operations(operations: &mut Vec) { + let mut winners: HashMap<&KeyInfoPath, (usize, Option)> = HashMap::new(); + let mut alias_indices = Vec::new(); + for (index, operation) in operations.iter().enumerate() { + let Some((path, key_id)) = current_key_alias_write(operation) else { + continue; + }; + alias_indices.push(index); + let replaces = match winners.get(path) { + None => true, + Some((_, current)) => match (current, key_id) { + (None, Some(_)) => true, + (Some(current_id), Some(new_id)) => new_id > *current_id, + (Some(_), None) | (None, None) => false, + }, + }; + if replaces { + winners.insert(path, (index, key_id)); + } + } + if alias_indices.len() == winners.len() { + return; + } + let kept: HashSet = winners.into_values().map(|(index, _)| index).collect(); + let dropped: HashSet = alias_indices + .into_iter() + .filter(|index| !kept.contains(index)) + .collect(); + let mut index = 0; + operations.retain(|_| { + let keep = !dropped.contains(&index); + index += 1; + keep + }); +} + +/// Recognizes a current-key alias write: a sibling reference inserted at, or refreshed at, the +/// empty key. Returns the subtree path and, for an insertion, the key id the alias names. +fn current_key_alias_write( + operation: &LowLevelDriveOperation, +) -> Option<(&KeyInfoPath, Option)> { + let LowLevelDriveOperation::GroveOperation(QualifiedGroveDbOp { + path, + key: Some(KeyInfo::KnownKey(key)), + op, + }) = operation + else { + return None; + }; + if !key.is_empty() { + return None; + } + match op { + GroveOp::InsertOrReplace { + element: Element::Reference(ReferencePathType::SiblingReference(sibling), _, _), + } + | GroveOp::InsertOrReplaceDontCheckForBackwardsReferences { + element: Element::Reference(ReferencePathType::SiblingReference(sibling), _, _), + } => KeyID::decode_var(sibling).map(|(key_id, _)| (path, Some(key_id))), + GroveOp::RefreshReference { + reference_path_type: ReferencePathType::SiblingReference(_), + .. + } => Some((path, None)), + _ => None, + } +} + pub enum IdentityDataContractKeyApplyInfo { /// The root_id is either a contract id or an owner id /// It is a contract id for in the case of contract bound keys or contract diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/mod.rs index ce9ef8928ec..51755954bd1 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/mod.rs @@ -10,6 +10,7 @@ use platform_version::version::PlatformVersion; use std::collections::HashMap; mod v0; +mod v1; impl Drive { /// Adds potential contract information for a contract-bounded key. @@ -62,9 +63,18 @@ impl Drive { drive_operations, platform_version, ), + 1 => self.refresh_potential_contract_info_key_references_v1( + identity_id, + identity_key, + epoch, + estimated_costs_only_with_layer_info, + transaction, + drive_operations, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "refresh_potential_contract_info_key_references".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v1/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v1/mod.rs new file mode 100644 index 00000000000..3119cf19f43 --- /dev/null +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v1/mod.rs @@ -0,0 +1,386 @@ +use crate::drive::identity::contract_info::keys::IdentityDataContractKeyApplyInfo; +use crate::drive::identity::{ + identity_contract_info_group_keys_path_vec, identity_contract_info_group_path_key_purpose_vec, + identity_key_location_within_identity_vec, +}; +use crate::drive::Drive; +use crate::error::contract::DataContractError; +use crate::error::identity::IdentityError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::block::epoch::Epoch; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::config::v0::DataContractConfigGettersV0; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::storage_requirements::keys_for_document_type::StorageKeyRequirements; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{IdentityPublicKey, Purpose}; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::reference_path::ReferencePathType::{SiblingReference, UpstreamRootHeightReference}; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; +use grovedb_costs::OperationCost; +use integer_encoding::VarInt; +use std::collections::HashMap; + +impl Drive { + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(in crate::drive::identity::contract_info) fn refresh_potential_contract_info_key_references_v1( + &self, + identity_id: [u8; 32], + identity_key: &IdentityPublicKey, + epoch: &Epoch, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + if let Some(contract_bounds) = &identity_key.contract_bounds() { + // We need to get the contract + let contract_apply_info = IdentityDataContractKeyApplyInfo::new_from_single_key( + identity_key.id(), + identity_key.purpose(), + contract_bounds, + self, + epoch, + transaction, + drive_operations, + platform_version, + )?; + self.refresh_contract_info_operations_v1( + identity_id, + epoch, + vec![contract_apply_info], + estimated_costs_only_with_layer_info, + transaction, + drive_operations, + platform_version, + )?; + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + /// Refreshes keys for the contract info + fn refresh_contract_info_operations_v1( + &self, + identity_id: [u8; 32], + epoch: &Epoch, + contract_infos: Vec, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_contract_info( + &identity_id, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + for contract_info in contract_infos.into_iter() { + let root_id = contract_info.root_id(); + + let contract = if estimated_costs_only_with_layer_info.is_none() { + // we should start by fetching the contract + let (fee, contract) = self.get_contract_with_fetch_info_and_fee( + root_id, + Some(epoch), + true, + transaction, + platform_version, + )?; + + let fee = fee.ok_or(Error::Identity( + IdentityError::IdentityKeyDataContractNotFound, + ))?; + let contract = contract.ok_or(Error::Identity( + IdentityError::IdentityKeyDataContractNotFound, + ))?; + drive_operations.push(LowLevelDriveOperation::PreCalculatedFeeResult(fee)); + Some(contract) + } else { + drive_operations.push(LowLevelDriveOperation::CalculatedCostOperation( + OperationCost { + seek_count: 1, + storage_cost: Default::default(), + storage_loaded_bytes: 100, + hash_node_calls: 0, + sinsemilla_hash_calls: 0, + }, + )); + None + }; + + let (document_keys, contract_or_family_keys) = contract_info.keys(); + + // v0 never registered the contract-level group layers here; unreachable there, + // because v0 key disabling estimated with a boundless stand-in key. v1 estimates + // with the stored key, so a refresh under `/keys/` needs them. + if !contract_or_family_keys.is_empty() { + if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + Self::add_estimation_costs_for_contract_info_group( + &identity_id, + &root_id, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + + Self::add_estimation_costs_for_contract_info_group_keys( + &identity_id, + &root_id, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + } + + for (key_id, purpose) in contract_or_family_keys { + if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + Self::add_estimation_costs_for_contract_info_group_key_purpose( + &identity_id, + &root_id, + purpose, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + // we need to add a reference to the key + let key_id_bytes = key_id.encode_var_vec(); + let key_reference = + identity_key_location_within_identity_vec(key_id_bytes.as_slice()); + + let reference_type_path = UpstreamRootHeightReference(2, key_reference); + + // at this point we want to know if the contract is single key or multiple key + let storage_key_requirements = contract + .as_ref() + .map(|contract| match purpose { + Purpose::AUTHENTICATION => { + Ok(StorageKeyRequirements::MultipleReferenceToLatest) + } + Purpose::ENCRYPTION => { + let encryption_storage_key_requirements = contract + .contract + .config() + .requires_identity_encryption_bounded_key() + .ok_or(Error::DataContract( + DataContractError::KeyBoundsExpectedButNotPresent( + "expected encryption key bounds for encryption", + ), + ))?; + Ok(encryption_storage_key_requirements) + } + Purpose::DECRYPTION => { + let decryption_storage_key_requirements = contract + .contract + .config() + .requires_identity_decryption_bounded_key() + .ok_or(Error::DataContract( + DataContractError::KeyBoundsExpectedButNotPresent( + "expected encryption key bounds for decryption", + ), + ))?; + Ok(decryption_storage_key_requirements) + } + _ => Err(Error::Identity(IdentityError::IdentityKeyBoundsError( + "purpose not available for key bounds", + ))), + }) + .transpose()? + .unwrap_or(StorageKeyRequirements::MultipleReferenceToLatest); + + // if we are multiple we refresh the key under the key bytes, otherwise it is under 0 + + let key = if storage_key_requirements == StorageKeyRequirements::Unique { + vec![] + } else { + key_id_bytes.clone() + }; + + self.batch_refresh_reference( + identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &root_id, + purpose, + ), + key, + Element::Reference(reference_type_path, Some(1), None), + true, + drive_operations, + &platform_version.drive, + )?; + + if storage_key_requirements == StorageKeyRequirements::MultipleReferenceToLatest { + // we also refresh the sibling reference, so we can query the current key + + let sibling_ref_type_path = SiblingReference(key_id_bytes); + let sibling_path = if purpose == Purpose::AUTHENTICATION { + // A bound authentication key's current-key reference belongs beside + // its key IDs, under the purpose subtree. Legacy purposes keep + // their historical path (see v0). + identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &root_id, + purpose, + ) + } else { + identity_contract_info_group_keys_path_vec(&identity_id, &root_id) + }; + + // Untrusted refresh: the slot may point at a newer key covering the same + // contract, so only the stored value hash is rebuilt; a trusted refresh + // would rewrite the pointer to the key being disabled. + self.batch_refresh_reference( + sibling_path, + vec![], + Element::Reference(sibling_ref_type_path, Some(2), None), + false, + drive_operations, + &platform_version.drive, + )?; + } + } + + for (document_type_name, document_key_ids) in document_keys { + // The path is the concatenation of the contract_id and the document type name + let mut contract_id_bytes_with_document_type_name = root_id.to_vec(); + contract_id_bytes_with_document_type_name.extend(document_type_name.as_bytes()); + + if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + Self::add_estimation_costs_for_contract_info_group( + &identity_id, + &contract_id_bytes_with_document_type_name, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + + Self::add_estimation_costs_for_contract_info_group_keys( + &identity_id, + &contract_id_bytes_with_document_type_name, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + for (key_id, purpose) in document_key_ids { + if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + Self::add_estimation_costs_for_contract_info_group_key_purpose( + &identity_id, + &contract_id_bytes_with_document_type_name, + purpose, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + // we need to add a reference to the key + let key_id_bytes = key_id.encode_var_vec(); + let key_reference = + identity_key_location_within_identity_vec(key_id_bytes.as_slice()); + + let reference = UpstreamRootHeightReference(2, key_reference); + + // at this point we want to know if the contract is single key or multiple key + let storage_key_requirements = contract + .as_ref() + .map(|contract| match purpose { + Purpose::AUTHENTICATION => { + Ok(StorageKeyRequirements::MultipleReferenceToLatest) + } + Purpose::ENCRYPTION => { + let document_type = contract + .contract + .document_type_for_name(document_type_name.as_str())?; + let encryption_storage_key_requirements = document_type + .requires_identity_encryption_bounded_key() + .ok_or(Error::DataContract( + DataContractError::KeyBoundsExpectedButNotPresent( + "expected encryption key bounds in document type", + ), + ))?; + Ok(encryption_storage_key_requirements) + } + Purpose::DECRYPTION => { + let document_type = contract + .contract + .document_type_for_name(document_type_name.as_str())?; + let decryption_storage_key_requirements = document_type + .requires_identity_decryption_bounded_key() + .ok_or(Error::DataContract( + DataContractError::KeyBoundsExpectedButNotPresent( + "expected encryption key bounds in document type", + ), + ))?; + Ok(decryption_storage_key_requirements) + } + _ => Err(Error::Identity(IdentityError::IdentityKeyBoundsError( + "purpose not available for key bounds", + ))), + }) + .transpose()? + .unwrap_or(StorageKeyRequirements::MultipleReferenceToLatest); + + let key = if storage_key_requirements == StorageKeyRequirements::Unique { + vec![] + } else { + key_id_bytes.clone() + }; + + self.batch_refresh_reference( + identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &contract_id_bytes_with_document_type_name, + purpose, + ), + key, + Element::Reference(reference, Some(1), None), + true, + drive_operations, + &platform_version.drive, + )?; + + if storage_key_requirements == StorageKeyRequirements::MultipleReferenceToLatest + { + // we also need to refresh the sibling reference, so we can query the current key + + let sibling_ref_type_path = SiblingReference(key_id_bytes); + let sibling_path = identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &contract_id_bytes_with_document_type_name, + purpose, + ); + + // Untrusted for the same reason as the contract-level slot above. + self.batch_refresh_reference( + sibling_path, + vec![], + Element::Reference(sibling_ref_type_path, Some(2), None), + false, + drive_operations, + &platform_version.drive, + )?; + } + } + } + } + + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/identity/key/fetch/mod.rs b/packages/rs-drive/src/drive/identity/key/fetch/mod.rs index 73c8ef819f1..c53283618e0 100644 --- a/packages/rs-drive/src/drive/identity/key/fetch/mod.rs +++ b/packages/rs-drive/src/drive/identity/key/fetch/mod.rs @@ -926,7 +926,13 @@ impl IdentityKeysRequest { Query::new_single_key(vec![]) } AllKeysOfKindRequest => { - Query::new_single_query_item(QueryItem::RangeFull(RangeFull)) + if purpose == Purpose::AUTHENTICATION { + // Bound authentication keys keep their current-key alias at the + // empty key of the purpose subtree; listing must not repeat it. + Query::new_single_query_item(QueryItem::RangeAfter(vec![]..)) + } else { + Query::new_single_query_item(QueryItem::RangeFull(RangeFull)) + } } }; PathQuery { @@ -957,7 +963,13 @@ impl IdentityKeysRequest { Query::new_single_key(vec![]) } AllKeysOfKindRequest => { - Query::new_single_query_item(QueryItem::RangeFull(RangeFull)) + if purpose == Purpose::AUTHENTICATION { + // Bound authentication keys keep their current-key alias at the + // empty key of the purpose subtree; listing must not repeat it. + Query::new_single_query_item(QueryItem::RangeAfter(vec![]..)) + } else { + Query::new_single_query_item(QueryItem::RangeFull(RangeFull)) + } } }; PathQuery { diff --git a/packages/rs-drive/src/drive/identity/update/methods/disable_identity_keys/mod.rs b/packages/rs-drive/src/drive/identity/update/methods/disable_identity_keys/mod.rs index e7f35b00e81..9a71641fc52 100644 --- a/packages/rs-drive/src/drive/identity/update/methods/disable_identity_keys/mod.rs +++ b/packages/rs-drive/src/drive/identity/update/methods/disable_identity_keys/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -59,9 +60,18 @@ impl Drive { transaction, platform_version, ), + 1 => self.disable_identity_keys_v1( + identity_id, + keys_ids, + disable_at, + block_info, + apply, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "disable_identity_keys".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -110,9 +120,18 @@ impl Drive { transaction, platform_version, ), + 1 => self.disable_identity_keys_operations_v1( + identity_id, + key_ids, + disable_at, + epoch, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "disable_identity_keys_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/identity/update/methods/disable_identity_keys/v1/mod.rs b/packages/rs-drive/src/drive/identity/update/methods/disable_identity_keys/v1/mod.rs new file mode 100644 index 00000000000..78790260f8e --- /dev/null +++ b/packages/rs-drive/src/drive/identity/update/methods/disable_identity_keys/v1/mod.rs @@ -0,0 +1,185 @@ +use dpp::block::block_info::BlockInfo; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use grovedb::batch::KeyInfoPath; + +use crate::drive::identity::key::fetch::{ + IdentityKeysRequest, KeyIDIdentityPublicKeyPairVec, KeyRequestType, +}; +use dpp::fee::fee_result::FeeResult; +use dpp::identity::identity_public_key::accessors::v0::{ + IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, +}; +use dpp::identity::KeyID; +use dpp::prelude::TimestampMillis; + +use dpp::block::epoch::Epoch; +use dpp::version::PlatformVersion; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use integer_encoding::VarInt; +use std::collections::HashMap; + +impl Drive { + /// Disable identity keys + #[allow(clippy::too_many_arguments)] + pub(super) fn disable_identity_keys_v1( + &self, + identity_id: [u8; 32], + keys_ids: Vec, + disable_at: TimestampMillis, + block_info: &BlockInfo, + apply: bool, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let mut estimated_costs_only_with_layer_info = if apply { + None::> + } else { + Some(HashMap::new()) + }; + + let batch_operations = self.disable_identity_keys_operations_v1( + identity_id, + keys_ids, + disable_at, + &block_info.epoch, + &mut estimated_costs_only_with_layer_info, + transaction, + platform_version, + )?; + + let mut drive_operations: Vec = vec![]; + + self.apply_batch_low_level_drive_operations( + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + &mut drive_operations, + &platform_version.drive, + )?; + let fees = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + self.config.epochs_per_era, + platform_version, + None, // TODO: Does disable mean delete? Check if previous_fee_versions are required in this case + )?; + + Ok(fees) + } + + /// Disables a set of identity keys for a given identity in version 1. + /// + /// This method performs operations to disable specific identity keys for the identity + /// identified by `identity_id`. The disabling is done by marking the keys as disabled at + /// a specified timestamp (`disable_at`). + /// + /// # Parameters + /// + /// * `identity_id`: A unique identifier for the identity. It's a 32-byte array. + /// * `key_ids`: A vector of `KeyID` that represents the keys to be disabled. + /// * `disable_at`: A timestamp (in milliseconds) indicating when the keys should be marked as disabled. + /// * `estimated_costs_only_with_layer_info`: An optional mutable reference to a map that, + /// if provided, will be populated with estimated layer information about the operation, + /// rather than performing the actual disabling of keys. If `None`, the actual operations + /// are executed. + /// * `transaction`: A transaction argument used for the disabling process. + /// * `platform_version`: Represents the platform version to ensure compatibility. + /// + /// # Returns + /// + /// A `Result` containing a vector of `LowLevelDriveOperation` which represents the operations + /// performed during the disabling process, or an `Error` if the operation fails. + /// + #[allow(clippy::too_many_arguments)] + #[inline(always)] + pub(super) fn disable_identity_keys_operations_v1( + &self, + identity_id: [u8; 32], + key_ids: Vec, + disable_at: TimestampMillis, + epoch: &Epoch, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut drive_operations = vec![]; + + let drive_version = &platform_version.drive; + + let key_ids_len = key_ids.len(); + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_keys_for_identity_id( + identity_id, + estimated_costs_only_with_layer_info, + drive_version, + )?; + Self::add_estimation_costs_for_root_key_reference_tree( + identity_id, + estimated_costs_only_with_layer_info, + drive_version, + )?; + } + + // v1 reads the stored keys in estimation mode too. v0 substituted a maximal stand-in + // key without contract bounds, so a bound key's contract-info reference refreshes + // (up to one per bounded contract and document type) were missing from the fee + // estimate that admits the transition. + let key_request = IdentityKeysRequest { + identity_id, + request_type: KeyRequestType::SpecificKeys(key_ids), + limit: Some(key_ids_len as u16), + offset: None, + }; + + let keys: KeyIDIdentityPublicKeyPairVec = self.fetch_identity_keys_operations( + key_request, + transaction, + &mut drive_operations, + platform_version, + )?; + + if keys.len() != key_ids_len { + // TODO Choose / add an appropriate error + return Err(Error::Drive(DriveError::UpdatingDocumentThatDoesNotExist( + "key to disable with specified ID is not found", + ))); + } + + const DISABLE_KEY_TIME_BYTE_COST: i32 = 9; + + for (_, mut key) in keys { + key.set_disabled_at(disable_at); + + let key_id_bytes = key.id().encode_var_vec(); + + self.replace_key_in_storage_operations( + identity_id.as_slice(), + &key, + &key_id_bytes, + DISABLE_KEY_TIME_BYTE_COST, + &mut drive_operations, + drive_version, + )?; + + self.refresh_identity_key_reference_operations( + identity_id, + &key, + epoch, + estimated_costs_only_with_layer_info, + transaction, + &mut drive_operations, + platform_version, + )? + } + + Ok(drive_operations) + } +} diff --git a/packages/rs-drive/src/drive/identity/update/mod.rs b/packages/rs-drive/src/drive/identity/update/mod.rs index 0d209c88a66..fb53ee88325 100644 --- a/packages/rs-drive/src/drive/identity/update/mod.rs +++ b/packages/rs-drive/src/drive/identity/update/mod.rs @@ -443,8 +443,9 @@ mod tests { } #[test] - fn should_disable_a_few_keys_latest_version_estimated() { - let platform_version = PlatformVersion::latest(); + fn should_disable_a_few_keys_protocol_version_13_estimated() { + // Protocol 13 estimates with a maximal stand-in key (disable_identity_keys v0). + let platform_version = PlatformVersion::get(13).expect("protocol 13"); let expected_fee_result = FeeResult { storage_fee: 486000, processing_fee: 3216860, @@ -453,6 +454,19 @@ mod tests { do_should_disable_a_few_keys(false, platform_version, expected_fee_result); } + #[test] + fn should_disable_a_few_keys_latest_version_estimated() { + // Protocol 14 estimates from the stored keys (disable_identity_keys v1), so the + // storage estimate matches the applied cost exactly. + let platform_version = PlatformVersion::latest(); + let expected_fee_result = FeeResult { + storage_fee: 513000, + processing_fee: 3195760, + ..Default::default() + }; + do_should_disable_a_few_keys(false, platform_version, expected_fee_result); + } + fn do_should_disable_a_few_keys( apply: bool, platform_version: &PlatformVersion, @@ -547,12 +561,33 @@ mod tests { ); } + #[test] + fn estimated_costs_should_have_same_storage_cost_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("protocol 13"); + let expected_estimated_fee_result = FeeResult { + storage_fee: 486000, + processing_fee: 3216860, + ..Default::default() + }; + let expected_fee_result = FeeResult { + storage_fee: 486000, + processing_fee: 794720, + ..Default::default() + }; + estimated_costs_should_have_same_storage_cost( + platform_version, + expected_estimated_fee_result, + expected_fee_result, + ); + } + #[test] fn estimated_costs_should_have_same_storage_cost_latest_version() { let platform_version = PlatformVersion::latest(); + // disable_identity_keys v1 also reads the stored keys during estimation. let expected_estimated_fee_result = FeeResult { storage_fee: 486000, - processing_fee: 3216860, + processing_fee: 3251060, ..Default::default() }; let expected_fee_result = FeeResult { diff --git a/packages/rs-drive/src/util/operations/apply_batch_low_level_drive_operations/mod.rs b/packages/rs-drive/src/util/operations/apply_batch_low_level_drive_operations/mod.rs index 547b156d965..f01a58b4ce5 100644 --- a/packages/rs-drive/src/util/operations/apply_batch_low_level_drive_operations/mod.rs +++ b/packages/rs-drive/src/util/operations/apply_batch_low_level_drive_operations/mod.rs @@ -1,5 +1,6 @@ #![allow(clippy::result_large_err)] // Operation application returns drive::Error with rich causes mod v0; +mod v1; use crate::drive::Drive; use crate::error::{drive::DriveError, Error}; @@ -50,9 +51,16 @@ impl Drive { drive_operations, drive_version, ), + 1 => self.apply_batch_low_level_drive_operations_v1( + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + drive_operations, + drive_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "apply_batch_low_level_drive_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/util/operations/apply_batch_low_level_drive_operations/v1/mod.rs b/packages/rs-drive/src/util/operations/apply_batch_low_level_drive_operations/v1/mod.rs new file mode 100644 index 00000000000..fc2d568163b --- /dev/null +++ b/packages/rs-drive/src/util/operations/apply_batch_low_level_drive_operations/v1/mod.rs @@ -0,0 +1,87 @@ +#![allow(clippy::result_large_err)] // Operation application returns drive::Error with rich causes +use crate::drive::identity::contract_info::keys::coalesce_current_key_alias_operations; +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::batch::grovedb_op_batch::GroveDbOpBatchV0Methods; +use dpp::version::drive_versions::DriveVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Applies a batch of Drive operations to groveDB. + /// + /// v1 first coalesces the current-key alias writes of bound authentication keys, so an + /// identity update that registers and revokes keys covering one contract queues a single + /// operation per alias slot (see [`coalesce_current_key_alias_operations`]). + pub(crate) fn apply_batch_low_level_drive_operations_v1( + &self, + estimated_costs_only_with_layer_info: Option< + HashMap, + >, + transaction: TransactionArg, + mut batch_operations: Vec, + drive_operations: &mut Vec, + drive_version: &DriveVersion, + ) -> Result<(), Error> { + coalesce_current_key_alias_operations(&mut batch_operations); + let (grove_db_operations, ephemeral_grove_db_operations, mut other_operations) = + LowLevelDriveOperation::grovedb_operations_batch_consume_split_ephemeral( + batch_operations, + ); + // The ephemeral (TTL'd-subtree) operations apply as their own batch + // so their cost is known separately and can be consumed at the + // ephemeral price — added bytes to processing instead of storage. + // Cloning the layer info keeps the estimation path symmetric: the + // dry run prices the ephemeral batch through the same worst-case + // machinery, under the same pricing rule, so estimated stays an + // upper bound of actual per fee class. + let ephemeral_layer_info = if ephemeral_grove_db_operations.is_empty() { + None + } else { + estimated_costs_only_with_layer_info.clone() + }; + // Two batches must still commit as one. GroveDB opens and commits + // an owned transaction per batch when none is supplied, which would + // leave the standing batch committed if the ephemeral one failed — + // a document row and its permanent index entries without their + // TTL'd entries. Span both with one owned transaction instead and + // commit only after both applied. + let owned_transaction = (transaction.is_none() + && estimated_costs_only_with_layer_info.is_none() + && !grove_db_operations.is_empty() + && !ephemeral_grove_db_operations.is_empty()) + .then(|| self.grove.start_transaction()); + let transaction = owned_transaction.as_ref().or(transaction); + if !grove_db_operations.is_empty() { + self.apply_batch_grovedb_operations( + estimated_costs_only_with_layer_info, + transaction, + grove_db_operations, + drive_operations, + drive_version, + )?; + } + if !ephemeral_grove_db_operations.is_empty() { + let mut ephemeral_cost_operations: Vec = vec![]; + self.apply_batch_grovedb_operations( + ephemeral_layer_info, + transaction, + ephemeral_grove_db_operations, + &mut ephemeral_cost_operations, + drive_version, + )?; + drive_operations.extend( + ephemeral_cost_operations + .into_iter() + .map(LowLevelDriveOperation::retag_ephemeral), + ); + } + drive_operations.append(&mut other_operations); + if let Some(owned_transaction) = owned_transaction { + self.commit_transaction(owned_transaction, drive_version)?; + } + Ok(()) + } +} diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 166ffcf527f..5a7b1019ce2 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -26,10 +26,10 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = fetch_asset_lock_transaction_output_sync: 0, verify_asset_lock_is_not_spent_and_has_enough_balance: 0, }, - validate_identity_public_key_contract_bounds: 1, + validate_identity_public_key_contract_bounds: 2, validate_identity_public_key_ids_dont_exist_in_state: 0, validate_identity_public_key_ids_exist_in_state: 0, - validate_state_transition_identity_signed: 0, + validate_state_transition_identity_signed: 1, validate_unique_identity_public_key_hashes_in_state: 1, validate_master_key_uniqueness: 0, validate_non_masternode_identity_exists: 0, @@ -41,7 +41,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = advanced_structure: Some(0), identity_signatures: Some(0), nonce: None, - state: 0, + state: 1, transform_into_action: 0, }, identity_update_state_transition: DriveAbciStateTransitionValidationVersion { @@ -49,7 +49,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = advanced_structure: Some(0), identity_signatures: Some(0), nonce: Some(0), - state: 0, + state: 1, transform_into_action: 0, }, identity_top_up_state_transition: DriveAbciStateTransitionValidationVersion { @@ -116,7 +116,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = data_contract_reference_validation: 0, batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { basic_structure: 0, - advanced_structure: 0, + advanced_structure: 1, state: 0, revision: 0, // PROTOCOL_VERSION_12 (v3.1 hard fork): batch state transition @@ -233,7 +233,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = advanced_structure: Some(0), identity_signatures: Some(0), nonce: Some(0), - state: 0, + state: 1, transform_into_action: 0, }, identity_top_up_from_addresses_state_transition: @@ -315,7 +315,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = advanced_structure: None, identity_signatures: None, nonce: None, - state: 0, + state: 1, transform_into_action: 0, }, shield_from_identity_state_transition: DriveAbciStateTransitionValidationVersion { diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs index 482c0bc7252..42ef505beb9 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs @@ -14,8 +14,15 @@ use crate::version::drive_versions::drive_identity_method_versions::{ }; /// V2 is protocol version 14's identity-method table. It differs from V1 in -/// its withdrawal methods: +/// its contract-bound key indexing and withdrawal methods: /// +/// * `contract_info.add_potential_contract_info_for_contract_bounded_key` 0 -> 1 and +/// `contract_info.refresh_potential_contract_info_key_references` 0 -> 1: +/// write and refresh contract-bound authentication-key references. Both v0s preserve the +/// historical rejection of authentication keys with contract bounds before v14. +/// * `update.disable_identity_keys` 0 -> 1: fee estimation reads the stored keys so a +/// bound key's reference refreshes are priced; v0 estimated with an unbounded +/// stand-in key. /// * `withdrawals.document.find_withdrawal_documents_by_status_and_transaction_indices` /// 0 -> 1, selecting the v1 withdrawal-by-transaction-index query builder /// that carries the transaction-index `In` clause in @@ -124,7 +131,7 @@ pub const DRIVE_IDENTITY_METHOD_VERSIONS_V2: DriveIdentityMethodVersions = merge_identity_nonce: 0, update_identity_negative_credit_operation: 0, initialize_identity_revision: 0, - disable_identity_keys: 0, + disable_identity_keys: 1, re_enable_identity_keys: 0, add_new_non_unique_keys_to_identity: 0, add_new_unique_keys_to_identity: 0, @@ -141,8 +148,8 @@ pub const DRIVE_IDENTITY_METHOD_VERSIONS_V2: DriveIdentityMethodVersions = add_new_identity: 0, }, contract_info: DriveIdentityContractInfoMethodVersions { - add_potential_contract_info_for_contract_bounded_key: 0, - refresh_potential_contract_info_key_references: 0, + add_potential_contract_info_for_contract_bounded_key: 1, + refresh_potential_contract_info_key_references: 1, merge_identity_contract_nonce: 0, }, cost_estimation: DriveIdentityCostEstimationMethodVersions { diff --git a/packages/rs-platform-version/src/version/drive_versions/v9.rs b/packages/rs-platform-version/src/version/drive_versions/v9.rs index 0a83bcc68a0..a8b2b805020 100644 --- a/packages/rs-platform-version/src/version/drive_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_versions/v9.rs @@ -101,7 +101,7 @@ pub const DRIVE_VERSION_V9: DriveVersion = DriveVersion { commit_transaction: 0, apply_partial_batch_low_level_drive_operations: 0, apply_partial_batch_grovedb_operations: 0, - apply_batch_low_level_drive_operations: 0, + apply_batch_low_level_drive_operations: 1, // changed: coalesces bound current-key alias writes per batch apply_batch_grovedb_operations: 0, }, state_transitions: DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V4, // changed: document_from_action generation 1 stamps built documents with the contract version (create assigns, replace re-assigns; paired with document serialization format 3) diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 92771cac98a..3b393eb4988 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -281,6 +281,10 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// where-clause operator enum gains `IN_TIME_RANGE = 11`, which pre-v14 /// servers reject as an unknown operator rather than misread (the v0 wire /// has no time-range operator at all). +/// Contract-bound authentication keys activate through contract-bounds validation v2, +/// identity-signature validation v1 and batch advanced-structure v1. Identity creation +/// validates key bounds (state v1) and identity-update state v1 retains the contract +/// lookup fees; Drive identity methods v2 index and refresh the bound keys. pub const PLATFORM_V14: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_14, drive: DRIVE_VERSION_V9, // changed: drive document method versions v4 — v2 index walkers (shared-prefix aggregate indexes become insertable) + the detect_ranked_mode slot diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 0ea04af550e..19b6c7c2d65 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -1,3 +1,5 @@ +use super::signature::ContractBoundedKeyNonBatchErrorWasm; +use super::signature::ContractBoundedKeyOutOfBoundsErrorWasm; use crate::errors::consensus::basic::{ IncompatibleProtocolVersionErrorWasm, InvalidIdentifierErrorWasm, InvalidSignaturePublicKeyPurposeErrorWasm, JsonSchemaErrorWasm, @@ -1052,6 +1054,12 @@ fn from_signature_error(signature_error: &SignatureError) -> JsValue { SignatureError::InvalidSignaturePublicKeyPurposeError(err) => { InvalidSignaturePublicKeyPurposeErrorWasm::from(err).into() } + SignatureError::ContractBoundedKeyNonBatchError(err) => { + ContractBoundedKeyNonBatchErrorWasm::from(err).into() + } + SignatureError::ContractBoundedKeyOutOfBoundsError(err) => { + ContractBoundedKeyOutOfBoundsErrorWasm::from(err).into() + } SignatureError::UncompressedPublicKeyNotAllowedError(err) => { UncompressedPublicKeyNotAllowedErrorWasm::from(err).into() } diff --git a/packages/wasm-dpp/src/errors/consensus/signature/contract_bounded_key_non_batch_error.rs b/packages/wasm-dpp/src/errors/consensus/signature/contract_bounded_key_non_batch_error.rs new file mode 100644 index 00000000000..81e1a976a2b --- /dev/null +++ b/packages/wasm-dpp/src/errors/consensus/signature/contract_bounded_key_non_batch_error.rs @@ -0,0 +1,29 @@ +use dpp::consensus::codes::ErrorWithCode; +use dpp::consensus::signature::ContractBoundedKeyNonBatchError; +use dpp::consensus::ConsensusError; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name=ContractBoundedKeyNonBatchError)] +pub struct ContractBoundedKeyNonBatchErrorWasm { + inner: ContractBoundedKeyNonBatchError, +} + +impl From<&ContractBoundedKeyNonBatchError> for ContractBoundedKeyNonBatchErrorWasm { + fn from(e: &ContractBoundedKeyNonBatchError) -> Self { + Self { inner: e.clone() } + } +} + +#[wasm_bindgen(js_class=ContractBoundedKeyNonBatchError)] +impl ContractBoundedKeyNonBatchErrorWasm { + #[wasm_bindgen(js_name=getCode)] + pub fn get_code(&self) -> u32 { + ConsensusError::from(self.inner.clone()).code() + } + + #[wasm_bindgen(getter)] + pub fn message(&self) -> String { + self.inner.to_string() + } +} diff --git a/packages/wasm-dpp/src/errors/consensus/signature/contract_bounded_key_out_of_bounds_error.rs b/packages/wasm-dpp/src/errors/consensus/signature/contract_bounded_key_out_of_bounds_error.rs new file mode 100644 index 00000000000..5a011baf1bd --- /dev/null +++ b/packages/wasm-dpp/src/errors/consensus/signature/contract_bounded_key_out_of_bounds_error.rs @@ -0,0 +1,29 @@ +use dpp::consensus::codes::ErrorWithCode; +use dpp::consensus::signature::ContractBoundedKeyOutOfBoundsError; +use dpp::consensus::ConsensusError; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name=ContractBoundedKeyOutOfBoundsError)] +pub struct ContractBoundedKeyOutOfBoundsErrorWasm { + inner: ContractBoundedKeyOutOfBoundsError, +} + +impl From<&ContractBoundedKeyOutOfBoundsError> for ContractBoundedKeyOutOfBoundsErrorWasm { + fn from(e: &ContractBoundedKeyOutOfBoundsError) -> Self { + Self { inner: e.clone() } + } +} + +#[wasm_bindgen(js_class=ContractBoundedKeyOutOfBoundsError)] +impl ContractBoundedKeyOutOfBoundsErrorWasm { + #[wasm_bindgen(js_name=getCode)] + pub fn get_code(&self) -> u32 { + ConsensusError::from(self.inner.clone()).code() + } + + #[wasm_bindgen(getter)] + pub fn message(&self) -> String { + self.inner.to_string() + } +} diff --git a/packages/wasm-dpp/src/errors/consensus/signature/mod.rs b/packages/wasm-dpp/src/errors/consensus/signature/mod.rs index 8b5b27b3f10..e216d393175 100644 --- a/packages/wasm-dpp/src/errors/consensus/signature/mod.rs +++ b/packages/wasm-dpp/src/errors/consensus/signature/mod.rs @@ -9,3 +9,9 @@ pub use basic_ecdsa_error::*; pub use identity_not_found_error::*; pub use signature_should_not_be_present_error::*; pub use uncompressed_public_key_not_allowed_error::*; + +mod contract_bounded_key_non_batch_error; +pub use contract_bounded_key_non_batch_error::ContractBoundedKeyNonBatchErrorWasm; + +mod contract_bounded_key_out_of_bounds_error; +pub use contract_bounded_key_out_of_bounds_error::ContractBoundedKeyOutOfBoundsErrorWasm; From 7407c39750576be32c73f8ef1dfce4edbb801892 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 17 Sep 2026 02:16:59 +0700 Subject: [PATCH 2/3] fix(drive): price bound key contract lookups from the real fetch in estimation Indexing v1 and refresh v1 estimated each bound contract lookup with a fixed 100-byte stand-in while the apply path billed the real fetch, so a cold user contract could execute above its estimate. Both v1 paths now fetch the contract in estimation mode as well and bill the same PreCalculatedFeeResult the apply path bills; v0 keeps the stand-in. The regression compares the contract lookup fees of the estimated and applied revocation operations for two cold user contracts, bound at contract and at document-type level, and checks registration and revocation estimates cover execution. Co-Authored-By: Claude Fable 5.1 --- .../state_transitions/identity_update/mod.rs | 159 ++++++++++++++++++ .../v1/mod.rs | 48 ++---- .../v1/mod.rs | 48 ++---- 3 files changed, 195 insertions(+), 60 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs index 57b471336fe..e26bf7a22e2 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs @@ -1149,6 +1149,165 @@ mod tests { assert!(estimated_bound.storage_fee >= actual.storage_fee); } + #[tokio::test] + async fn should_estimate_bound_key_fees_from_the_real_contract_lookup() { + use dpp::data_contract::factory::DataContractFactory; + use dpp::identity::IdentityPublicKey; + use dpp::platform_value::{platform_value, Value}; + let version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (identity, _, _, _) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + // Two user contracts, one bound at contract level and one at document-type level. + // Neither is served from the system-contract cache. + let factory = DataContractFactory::new(version.protocol_version).unwrap(); + let document_type = |n: usize| { + ( + Value::Text(format!("t{n:02}")), + platform_value!({"type": "object", + "properties": {"text": {"type": "string", "maxLength": 64, "position": 0}, + "note": {"type": "string", "maxLength": 128, "position": 1}}, + "additionalProperties": false}), + ) + }; + let mut contracts = Vec::new(); + for (nonce, document_types) in [(1u64, 1usize), (2, 16)] { + let contract = factory + .create_with_value_config( + identity.id(), + nonce, + Value::Map((0..document_types).map(document_type).collect()), + None, + None, + ) + .unwrap() + .data_contract_owned(); + platform + .drive + .apply_contract(&contract, BlockInfo::default(), true, None, None, version) + .unwrap(); + contracts.push(contract); + } + let mut rng = StdRng::seed_from_u64(77); + let keys: Vec = contracts + .iter() + .enumerate() + .map(|(index, contract)| { + IdentityPublicKey::random_key_with_known_attributes( + 2 + index as u32, + &mut rng, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + KeyType::ECDSA_SECP256K1, + Some(ContractBounds::SingleContractDocumentType { + id: contract.id(), + document_type_name: "t00".into(), + }), + version, + ) + .unwrap() + .0 + }) + .collect(); + let block = BlockInfo { + time_ms: 1001, + ..Default::default() + }; + let register = |key: &IdentityPublicKey, apply: bool| { + platform.drive.cache.data_contracts.clear(); + platform + .drive + .add_new_unique_keys_to_identity( + identity.id().to_buffer(), + vec![key.clone()], + &block, + apply, + None, + version, + ) + .unwrap() + }; + let revoke = |key: &IdentityPublicKey, apply: bool| { + platform.drive.cache.data_contracts.clear(); + platform + .drive + .disable_identity_keys( + identity.id().to_buffer(), + vec![key.id()], + block.time_ms, + &block, + apply, + None, + version, + ) + .unwrap() + }; + + let estimated_registration: Vec<_> = keys.iter().map(|key| register(key, false)).collect(); + let actual_registration: Vec<_> = keys.iter().map(|key| register(key, true)).collect(); + + // The estimated operations must bill the same contract lookups as the applied + // operations: one real fee per bound group, not a fixed stand-in. + use dpp::block::epoch::Epoch; + use drive::fees::op::LowLevelDriveOperation; + use std::collections::HashMap; + let lookup_fees = |operations: &[LowLevelDriveOperation]| -> Vec { + operations + .iter() + .filter_map(|operation| match operation { + LowLevelDriveOperation::PreCalculatedFeeResult(fee) => Some(fee.processing_fee), + _ => None, + }) + .collect() + }; + let key_ids: Vec<_> = keys.iter().map(|key| key.id()).collect(); + let revocation_operations = |estimate: bool| { + platform.drive.cache.data_contracts.clear(); + let mut layer_info = estimate.then(HashMap::new); + platform + .drive + .disable_identity_keys_operations( + identity.id().to_buffer(), + key_ids.clone(), + block.time_ms, + &Epoch::new(0).unwrap(), + &mut layer_info, + None, + version, + ) + .unwrap() + }; + let estimated_lookups = lookup_fees(&revocation_operations(true)); + let applied_lookups = lookup_fees(&revocation_operations(false)); + assert!( + estimated_lookups.len() >= keys.len(), + "at least one contract lookup fee per bound key: {estimated_lookups:?}" + ); + assert_eq!( + estimated_lookups, applied_lookups, + "the estimate must price the same contract lookups the apply path bills" + ); + + let estimated_revocation: Vec<_> = keys.iter().map(|key| revoke(key, false)).collect(); + let actual_revocation: Vec<_> = keys.iter().map(|key| revoke(key, true)).collect(); + for (estimated, actual) in estimated_registration + .iter() + .zip(&actual_registration) + .chain(estimated_revocation.iter().zip(&actual_revocation)) + { + assert!( + estimated.processing_fee >= actual.processing_fee, + "estimate {} must cover execution {}", + estimated.processing_fee, + actual.processing_fee + ); + assert!(estimated.storage_fee >= actual.storage_fee); + } + } + #[tokio::test] async fn test_identity_update_that_disables_an_encryption_key() { let platform_config = PlatformConfig { diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v1/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v1/mod.rs index a0b0060276a..b0332fe4055 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v1/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v1/mod.rs @@ -25,7 +25,6 @@ use dpp::version::PlatformVersion; use grovedb::batch::KeyInfoPath; use grovedb::reference_path::ReferencePathType::{SiblingReference, UpstreamRootHeightReference}; use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; -use grovedb_costs::OperationCost; use integer_encoding::VarInt; use std::collections::HashMap; @@ -117,36 +116,25 @@ impl Drive { for contract_info in contract_infos.into_iter() { let root_id = contract_info.root_id(); - let contract = if estimated_costs_only_with_layer_info.is_none() { - // we should start by fetching the contract - let (fee, contract) = self.get_contract_with_fetch_info_and_fee( - root_id, - Some(epoch), - true, - transaction, - platform_version, - )?; + // v1 fetches the contract in estimation mode as well. v0 priced the lookup with a + // fixed 100-byte stand-in, which under-estimates a cold user contract; the apply + // path bills the real fetch, so the estimate must too. + let (fee, contract) = self.get_contract_with_fetch_info_and_fee( + root_id, + Some(epoch), + true, + transaction, + platform_version, + )?; - let fee = fee.ok_or(Error::Identity( - IdentityError::IdentityKeyDataContractNotFound, - ))?; - let contract = contract.ok_or(Error::Identity( - IdentityError::IdentityKeyDataContractNotFound, - ))?; - drive_operations.push(LowLevelDriveOperation::PreCalculatedFeeResult(fee)); - Some(contract) - } else { - drive_operations.push(LowLevelDriveOperation::CalculatedCostOperation( - OperationCost { - seek_count: 1, - storage_cost: Default::default(), - storage_loaded_bytes: 100, - hash_node_calls: 0, - sinsemilla_hash_calls: 0, - }, - )); - None - }; + let fee = fee.ok_or(Error::Identity( + IdentityError::IdentityKeyDataContractNotFound, + ))?; + let contract = contract.ok_or(Error::Identity( + IdentityError::IdentityKeyDataContractNotFound, + ))?; + drive_operations.push(LowLevelDriveOperation::PreCalculatedFeeResult(fee)); + let contract = Some(contract); let (document_keys, contract_or_family_keys) = contract_info.keys(); diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v1/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v1/mod.rs index 3119cf19f43..9e775656da6 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v1/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v1/mod.rs @@ -19,7 +19,6 @@ use dpp::version::PlatformVersion; use grovedb::batch::KeyInfoPath; use grovedb::reference_path::ReferencePathType::{SiblingReference, UpstreamRootHeightReference}; use grovedb::{Element, EstimatedLayerInformation, TransactionArg}; -use grovedb_costs::OperationCost; use integer_encoding::VarInt; use std::collections::HashMap; @@ -88,36 +87,25 @@ impl Drive { for contract_info in contract_infos.into_iter() { let root_id = contract_info.root_id(); - let contract = if estimated_costs_only_with_layer_info.is_none() { - // we should start by fetching the contract - let (fee, contract) = self.get_contract_with_fetch_info_and_fee( - root_id, - Some(epoch), - true, - transaction, - platform_version, - )?; + // v1 fetches the contract in estimation mode as well. v0 priced the lookup with a + // fixed 100-byte stand-in, which under-estimates a cold user contract; the apply + // path bills the real fetch, so the estimate must too. + let (fee, contract) = self.get_contract_with_fetch_info_and_fee( + root_id, + Some(epoch), + true, + transaction, + platform_version, + )?; - let fee = fee.ok_or(Error::Identity( - IdentityError::IdentityKeyDataContractNotFound, - ))?; - let contract = contract.ok_or(Error::Identity( - IdentityError::IdentityKeyDataContractNotFound, - ))?; - drive_operations.push(LowLevelDriveOperation::PreCalculatedFeeResult(fee)); - Some(contract) - } else { - drive_operations.push(LowLevelDriveOperation::CalculatedCostOperation( - OperationCost { - seek_count: 1, - storage_cost: Default::default(), - storage_loaded_bytes: 100, - hash_node_calls: 0, - sinsemilla_hash_calls: 0, - }, - )); - None - }; + let fee = fee.ok_or(Error::Identity( + IdentityError::IdentityKeyDataContractNotFound, + ))?; + let contract = contract.ok_or(Error::Identity( + IdentityError::IdentityKeyDataContractNotFound, + ))?; + drive_operations.push(LowLevelDriveOperation::PreCalculatedFeeResult(fee)); + let contract = Some(contract); let (document_keys, contract_or_family_keys) = contract_info.keys(); From db18db1f5fde3f2311941aa83bcaab1ff634a5c5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 17 Sep 2026 03:06:16 +0700 Subject: [PATCH 3/3] fix(drive): coalesce only bound authentication key alias writes The batch-level alias coalescer recognized any empty-key sibling reference write. Encryption and decryption bounds keep their frozen v0 layout, with the current-key alias one level up at the keys level, and that layout must not pick up new batch semantics from the authentication change. Restrict the coalescer to the AUTHENTICATION contract-info purpose subtree (Identities / identity / IdentityContractInfo / group / ContractInfoKeysKey / AUTHENTICATION) and pin the boundary with unit tests: authentication slots coalesce per group to the insertion naming the highest key id, keys-level and other-purpose aliases pass through untouched, duplicate refreshes collapse, key-id entries are left alone. Also drop the extra blank lines in the identity-create-from-addresses state v1 trait body. Co-Authored-By: Claude Fable 5.1 --- .../state/v1/mod.rs | 2 - .../drive/identity/contract_info/keys/mod.rs | 167 +++++++++++++++++- 2 files changed, 163 insertions(+), 6 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/v1/mod.rs index 17cd8a58073..a7e8bc64a3d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/v1/mod.rs @@ -31,8 +31,6 @@ pub(in crate::execution::validation::state_transition::state_transitions::identi transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result, Error>; - - } impl IdentityCreateFromAddressesStateTransitionStateValidationV1 diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/mod.rs index 143d85cb15c..4360796225b 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/mod.rs @@ -1,5 +1,7 @@ use crate::drive::identity::contract_info::keys::IdentityDataContractKeyApplyInfo::ContractBased; -use crate::drive::Drive; +use crate::drive::identity::contract_info::ContractInfoStructure; +use crate::drive::identity::IdentityRootStructure; +use crate::drive::{Drive, RootTree}; use crate::error::identity::IdentityError; use crate::error::Error; use crate::fees::op::LowLevelDriveOperation; @@ -66,8 +68,12 @@ pub(crate) fn coalesce_current_key_alias_operations(operations: &mut Vec Option<(&KeyInfoPath, Option)> { @@ -79,7 +85,7 @@ fn current_key_alias_write( else { return None; }; - if !key.is_empty() { + if !key.is_empty() || !is_bound_authentication_keys_path(path) { return None; } match op { @@ -97,6 +103,19 @@ fn current_key_alias_write( } } +/// Whether `path` is the AUTHENTICATION purpose subtree of an identity's contract-info group: +/// `Identities / / IdentityContractInfo / / ContractInfoKeysKey / AUTHENTICATION`. +fn is_bound_authentication_keys_path(path: &KeyInfoPath) -> bool { + let segments = path.to_path_refs(); + segments.len() == 6 + && segments[0] == [RootTree::Identities as u8] + && segments[1].len() == 32 + && segments[2] == [IdentityRootStructure::IdentityContractInfo as u8] + && segments[4] == [ContractInfoStructure::ContractInfoKeysKey as u8] + && segments[5] == [Purpose::AUTHENTICATION as u8] +} + pub enum IdentityDataContractKeyApplyInfo { /// The root_id is either a contract id or an owner id /// It is a contract id for in the case of contract bound keys or contract @@ -192,3 +211,143 @@ impl IdentityDataContractKeyApplyInfo { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::drive::identity::{ + identity_contract_info_group_keys_path_vec, + identity_contract_info_group_path_key_purpose_vec, + }; + use grovedb::reference_path::ReferencePathType::SiblingReference; + + fn alias_insert(path: Vec>, key_id: KeyID) -> LowLevelDriveOperation { + LowLevelDriveOperation::insert_for_known_path_key_element( + path, + vec![], + Element::Reference(SiblingReference(key_id.encode_var_vec()), Some(2), None), + ) + } + + fn alias_refresh(path: Vec>, key_id: KeyID) -> LowLevelDriveOperation { + LowLevelDriveOperation::refresh_reference_for_known_path_key_reference_info( + path, + vec![], + SiblingReference(key_id.encode_var_vec()), + Some(2), + None, + false, + ) + } + + fn key_id_entry(path: Vec>, key_id: KeyID) -> LowLevelDriveOperation { + LowLevelDriveOperation::insert_for_known_path_key_element( + path, + key_id.encode_var_vec(), + Element::Reference(SiblingReference(key_id.encode_var_vec()), Some(2), None), + ) + } + + #[test] + fn should_coalesce_only_bound_authentication_alias_writes() { + let identity_id = [1u8; 32]; + let contract_id = [2u8; 32]; + let document_type_group = [contract_id.as_slice(), b"preorder"].concat(); + + let contract_auth_path = identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &contract_id, + Purpose::AUTHENTICATION, + ); + let document_type_auth_path = identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &document_type_group, + Purpose::AUTHENTICATION, + ); + // Legacy encryption and decryption bounds alias the current key at the keys level. + let legacy_alias_path = + identity_contract_info_group_keys_path_vec(&identity_id, &contract_id); + // A purpose-level alias for another purpose must not be recognized either. + let encryption_purpose_path = identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &contract_id, + Purpose::ENCRYPTION, + ); + + let mut operations = vec![ + alias_refresh(contract_auth_path.clone(), 1), + alias_insert(contract_auth_path.clone(), 2), + alias_insert(legacy_alias_path.clone(), 5), + alias_insert(document_type_auth_path.clone(), 9), + alias_insert(contract_auth_path.clone(), 3), + alias_insert(legacy_alias_path.clone(), 6), + alias_refresh(encryption_purpose_path.clone(), 7), + alias_insert(encryption_purpose_path.clone(), 8), + alias_insert(document_type_auth_path.clone(), 10), + alias_refresh(contract_auth_path.clone(), 4), + ]; + + coalesce_current_key_alias_operations(&mut operations); + + assert_eq!( + operations, + vec![ + alias_insert(legacy_alias_path.clone(), 5), + alias_insert(contract_auth_path, 3), + alias_insert(legacy_alias_path, 6), + alias_refresh(encryption_purpose_path.clone(), 7), + alias_insert(encryption_purpose_path, 8), + alias_insert(document_type_auth_path, 10), + ], + "each AUTHENTICATION slot keeps the insertion naming its highest key id, in place; \ + legacy and other-purpose aliases are untouched" + ); + } + + #[test] + fn should_collapse_duplicate_authentication_alias_refreshes_to_one() { + let identity_id = [1u8; 32]; + let contract_id = [2u8; 32]; + let contract_auth_path = identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &contract_id, + Purpose::AUTHENTICATION, + ); + + let mut operations = vec![ + alias_refresh(contract_auth_path.clone(), 3), + alias_refresh(contract_auth_path.clone(), 3), + ]; + + coalesce_current_key_alias_operations(&mut operations); + + assert_eq!(operations, vec![alias_refresh(contract_auth_path, 3)]); + } + + #[test] + fn should_leave_key_id_entries_in_the_authentication_subtree_alone() { + let identity_id = [1u8; 32]; + let contract_id = [2u8; 32]; + let contract_auth_path = identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &contract_id, + Purpose::AUTHENTICATION, + ); + + let mut operations = vec![ + key_id_entry(contract_auth_path.clone(), 2), + key_id_entry(contract_auth_path.clone(), 3), + ]; + + coalesce_current_key_alias_operations(&mut operations); + + assert_eq!( + operations, + vec![ + key_id_entry(contract_auth_path.clone(), 2), + key_id_entry(contract_auth_path, 3), + ], + "only the empty-key alias slot coalesces" + ); + } +}