From 3ca176a6bfa443eda94323168dda50e6a4a7600f Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 10 Sep 2026 14:37:00 -0500 Subject: [PATCH 01/13] feat(platform): add contract-scoped authentication keys --- .../contract-scoped-authentication.md | 106 +++ .../src/errors/consensus/basic/basic_error.rs | 3 + .../invalid_authentication_scope_error.rs | 28 + .../errors/consensus/basic/identity/mod.rs | 3 + packages/rs-dpp/src/errors/consensus/codes.rs | 4 + .../src/errors/consensus/signature/mod.rs | 9 + .../signature/scoped_key_expired_error.rs | 28 + .../signature/scoped_key_non_batch_error.rs | 28 + .../scoped_key_out_of_scope_error.rs | 28 + .../consensus/signature/signature_error.rs | 11 + packages/rs-dpp/src/identity/identity.rs | 82 ++- .../contract_bounds/authentication_scope.rs | 551 ++++++++++++++++ .../contract_bounds/mod.rs | 46 +- .../src/identity/identity_public_key/mod.rs | 6 +- packages/rs-dpp/src/shielded/mod.rs | 3 +- packages/rs-dpp/src/shielded/sighash.rs | 110 +++- packages/rs-dpp/src/state_transition/mod.rs | 220 +++++++ .../mod.rs | 8 +- .../v0/mod.rs | 25 +- .../v1/mod.rs | 32 + .../check_tx_verification/v0/mod.rs | 1 + .../mod.rs | 102 ++- .../v0/mod.rs | 6 + .../v1/mod.rs | 9 + .../v2/mod.rs | 85 +++ .../mod.rs | 16 +- .../v1/mod.rs | 63 ++ .../traits/identity_based_signature.rs | 6 + .../processor/traits/state.rs | 3 + .../state_transition/processor/v0/mod.rs | 1 + .../batch/advanced_structure/mod.rs | 1 + .../batch/advanced_structure/v1/mod.rs | 292 +++++++++ .../state_transitions/batch/mod.rs | 39 +- .../state_transitions/batch/tests/mod.rs | 2 + .../batch/tests/scoped_auth.rs | 611 ++++++++++++++++++ .../state_transitions/identity_create/mod.rs | 213 +++++- .../identity_create/state/mod.rs | 2 + .../identity_create/state/v1/mod.rs | 115 ++++ .../identity_create_from_addresses/mod.rs | 13 +- .../state/mod.rs | 2 + .../state/v1/mod.rs | 117 ++++ .../identity_create_from_shielded_pool/mod.rs | 13 +- .../state/mod.rs | 2 + .../state/v1/mod.rs | 149 +++++ .../tests.rs | 129 ++++ .../state_transitions/identity_update/mod.rs | 567 +++++++++++++++- .../identity_update/state/mod.rs | 2 + .../identity_update/state/v0/mod.rs | 1 + .../identity_update/state/v1/mod.rs | 163 +++++ .../mod.rs | 12 +- .../v0/mod.rs | 2 +- .../v1/mod.rs | 541 ++++++++++++++++ .../drive/identity/contract_info/keys/mod.rs | 51 +- .../v0/mod.rs | 20 +- .../dpp_versions/dpp_method_versions/v3.rs | 5 +- .../mod.rs | 1 + .../v2.rs | 15 + .../drive_abci_validation_versions/v10.rs | 14 +- .../drive_identity_method_versions/v2.rs | 7 +- .../rs-platform-version/src/version/v14.rs | 8 +- .../src/identity_persistence.rs | 134 +++- .../src/identity_update.rs | 4 + .../rs-platform-wallet-ffi/src/persistence.rs | 58 +- 63 files changed, 4803 insertions(+), 125 deletions(-) create mode 100644 docs/protocol/contract-scoped-authentication.md create mode 100644 packages/rs-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs create mode 100644 packages/rs-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs create mode 100644 packages/rs-dpp/src/identity/identity_public_key/contract_bounds/authentication_scope.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.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/scoped_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-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/v2.rs diff --git a/docs/protocol/contract-scoped-authentication.md b/docs/protocol/contract-scoped-authentication.md new file mode 100644 index 00000000000..873a92cf359 --- /dev/null +++ b/docs/protocol/contract-scoped-authentication.md @@ -0,0 +1,106 @@ +# Contract-scoped authentication keys + +Protocol version 14 adds application authentication scopes. A wallet can register +a separate key for an application while retaining the identity's master key. +Validators enforce the registered scope on every batch member. Existing identity +ownership, key purpose, security level and document rules still apply. + +## Registering an application key + +The key must have AUTHENTICATION purpose and a non-MASTER security level. A HIGH +key is suitable for normal document operations. Its `contractBounds` is a new +`scoped` variant containing a versioned authentication scope: + +- `contracts`: explicit contract IDs with optional document-type restrictions. +- `permissions`: an action bitmask shared by every listed contract. +- `expiresAt`: an optional expiry in milliseconds, checked against block time. + +A missing/null document-type restriction authorizes all types in that contract, +including types added by later contract updates. An empty array is invalid. +Contract IDs and document-type names must be sorted and unique on the wire. There +are at most 16 contracts and 16 types per contract, and the encoded scope must not +exceed 2048 bytes. The WASM constructor sorts entries and rejects duplicates. + +For an application that creates, updates and deletes documents and pays their +configured token fees, construct the bounds with the WASM SDK: + +```javascript +const P = wasm.AuthenticationPermission; +const bounds = wasm.ContractBounds.Scoped( + [ + { id: socialContractId, documentTypes: ['like', 'post'] }, + { id: profileContractId, documentTypes: ['profile'] }, + ], + P.DocumentCreate | P.DocumentReplace | P.DocumentDelete | P.DocumentTokenPayment, + BigInt(Date.now() + 24 * 60 * 60 * 1000), +); + +const keyToAdd = new wasm.IdentityPublicKeyInCreation({ + keyId: nextKeyId, + purpose: 'authentication', + securityLevel: 'high', + keyType: 'ecdsa_hash160', + isReadOnly: false, + data: applicationPublicKeyHash160, + signature: new Uint8Array(), + contractBounds: bounds, +}); +``` + +Use the normal wallet-authorized identity-update procedure to register the key. +The registration signature binds the scope as well as the public-key material. +The browser only needs the application key's private material. Registering a +scope requires its referenced contracts/types to exist and its expiry, if any, +to be in the future. No contract encryption-key opt-in or unique-key setting is +required for scoped authentication. + +## Permissions and token fees + +Document create, replace, delete, ownership transfer, price updates and purchases +have separate bits. Index-only deletion uses the delete bit. Standalone token +transition kinds also have separate bits. New/unknown bits are rejected. + +`DocumentTokenPayment` permits the actual contract-defined token cost of an +otherwise-authorized document action. It also covers fees using a token issued +by another contract. That does not authorize document writes or standalone token +operations on the issuing contract. Without the bit, a document action with a +positive token cost is rejected, even if its create/replace/delete bit is set. + +A document-token payment permission does not implicitly authorize token transfer, +burn, mint, purchase or administration transitions. Explicitly granting one of +those bits still cannot override its normal purpose/security/ownership rules. +Contract updates can change document token fees; v0 scopes do not pin the fee +amount or currency. + +## Expiry, revocation and failures + +A key is expired when executing block time is greater than or equal to its expiry. +Mempool checks use the last committed block information; a transaction may expire +between admission and execution. Disable the key through the normal identity +update to revoke it. Extending expiry or expanding permissions requires a +wallet-authorized replacement. Expired keys are not automatically deleted. + +Scoped keys cannot execute non-batch transitions, including identity-key updates, +contract creation/updates, credit transfers/withdrawals or masternode votes. +Expired keys and non-batch use fail in identity-signature authorization. + +Batch scope violations follow normal paid validation-failure handling. Requested +document/token operations do not execute, but Platform credit validation fees +can be charged and the first batch member's identity-contract nonce can advance, +even if that member is outside the scope. Replays follow the usual nonce rules. + +There are no per-key budgets in scope version 0. A stolen key can exhaust credit +balances through fees and permitted token balances through allowed operations. +Expiry limits the time window, not total financial loss. + +## Compatibility + +The scoped variant is appended to the existing bounds enum; old key encodings +remain unchanged. Older protocols reject scoped registration, and older clients +cannot be assumed to decode scoped keys. SDK signing performs local structural +checks, but validator checks against current state remain authoritative. + +The native key ABI carries an encoded scope pointer/length. Native libraries, +generated headers and Swift/Kotlin consumers must be updated together. Key query, +persistence, restore and refresh paths must preserve scope metadata. It must +never be dropped or reconstructed as an unrestricted key. diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index d1bed746659..eeb97402b56 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -1,3 +1,4 @@ +use crate::consensus::basic::identity::InvalidAuthenticationScopeError; use crate::errors::ProtocolError; use bincode::{Decode, Encode}; use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; @@ -699,6 +700,8 @@ pub enum BasicError { #[error(transparent)] DataContractInvalidRequiredFieldsUpdateError(DataContractInvalidRequiredFieldsUpdateError), + #[error(transparent)] + InvalidAuthenticationScopeError(InvalidAuthenticationScopeError), } impl From for ConsensusError { diff --git a/packages/rs-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs b/packages/rs-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs new file mode 100644 index 00000000000..283ee13b0e0 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs @@ -0,0 +1,28 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Invalid authentication scope: {reason}")] +#[platform_serialize(unversioned)] +pub struct InvalidAuthenticationScopeError { + reason: String, +} +impl InvalidAuthenticationScopeError { + pub fn new(reason: String) -> Self { + Self { reason } + } + pub fn reason(&self) -> &String { + &self.reason + } +} +impl From for ConsensusError { + fn from(error: InvalidAuthenticationScopeError) -> Self { + Self::BasicError(BasicError::InvalidAuthenticationScopeError(error)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/identity/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/identity/mod.rs index 9ab0839536d..dd2459ca9c4 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/identity/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/identity/mod.rs @@ -68,3 +68,6 @@ mod missing_master_public_key_error; mod not_implemented_credit_withdrawal_transition_pooling_error; mod too_many_master_public_key_error; mod withdrawal_output_script_not_allowed_when_signing_with_owner_key; + +mod invalid_authentication_scope_error; +pub use invalid_authentication_scope_error::InvalidAuthenticationScopeError; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index c7a00352876..410efe61ae6 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -205,6 +205,7 @@ impl ErrorWithCode for BasicError { Self::WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError(_) => 10532, Self::InvalidKeyPurposeForContractBoundsError(_) => 10533, Self::IdentityAssetLockTransactionTooManyInputsError(_) => 10534, + Self::InvalidAuthenticationScopeError(_) => 10535, // State Transition Errors: 10600-10699 Self::InvalidStateTransitionTypeError { .. } => 10600, @@ -264,6 +265,9 @@ impl ErrorWithCode for SignatureError { Self::BasicBLSError(_) => 20010, Self::InvalidSignaturePublicKeyPurposeError(_) => 20011, Self::UncompressedPublicKeyNotAllowedError(_) => 20012, + Self::ScopedKeyOutOfScopeError(_) => 20015, + Self::ScopedKeyExpiredError(_) => 20014, + Self::ScopedKeyNonBatchError(_) => 20013, } } } diff --git a/packages/rs-dpp/src/errors/consensus/signature/mod.rs b/packages/rs-dpp/src/errors/consensus/signature/mod.rs index 91ff05114bd..6426c1ce61c 100644 --- a/packages/rs-dpp/src/errors/consensus/signature/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/signature/mod.rs @@ -27,3 +27,12 @@ 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 scoped_key_non_batch_error; +pub use scoped_key_non_batch_error::ScopedKeyNonBatchError; + +mod scoped_key_expired_error; +pub use scoped_key_expired_error::ScopedKeyExpiredError; + +mod scoped_key_out_of_scope_error; +pub use scoped_key_out_of_scope_error::ScopedKeyOutOfScopeError; diff --git a/packages/rs-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs new file mode 100644 index 00000000000..159036496e5 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs @@ -0,0 +1,28 @@ +use crate::consensus::signature::SignatureError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Scoped key {public_key_id} has expired")] +#[platform_serialize(unversioned)] +pub struct ScopedKeyExpiredError { + public_key_id: u32, +} +impl ScopedKeyExpiredError { + 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: ScopedKeyExpiredError) -> Self { + Self::SignatureError(SignatureError::ScopedKeyExpiredError(error)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs new file mode 100644 index 00000000000..8773ddeb0d7 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs @@ -0,0 +1,28 @@ +use crate::consensus::signature::SignatureError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Scoped key {public_key_id} cannot sign a non-batch transition")] +#[platform_serialize(unversioned)] +pub struct ScopedKeyNonBatchError { + public_key_id: u32, +} +impl ScopedKeyNonBatchError { + 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: ScopedKeyNonBatchError) -> Self { + Self::SignatureError(SignatureError::ScopedKeyNonBatchError(error)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs new file mode 100644 index 00000000000..1492707d00e --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs @@ -0,0 +1,28 @@ +use crate::consensus::signature::SignatureError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Batch member is outside key {public_key_id} scope")] +#[platform_serialize(unversioned)] +pub struct ScopedKeyOutOfScopeError { + public_key_id: u32, +} +impl ScopedKeyOutOfScopeError { + 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: ScopedKeyOutOfScopeError) -> Self { + Self::SignatureError(SignatureError::ScopedKeyOutOfScopeError(error)) + } +} 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 72b0e7afb1a..fefe04e76dd 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,6 @@ +use crate::consensus::signature::ScopedKeyExpiredError; +use crate::consensus::signature::ScopedKeyNonBatchError; +use crate::consensus::signature::ScopedKeyOutOfScopeError; use crate::consensus::signature::{ BasicBLSError, BasicECDSAError, IdentityNotFoundError, InvalidIdentityPublicKeyTypeError, InvalidSignaturePublicKeySecurityLevelError, InvalidStateTransitionSignatureError, @@ -60,6 +63,14 @@ pub enum SignatureError { #[error(transparent)] UncompressedPublicKeyNotAllowedError(UncompressedPublicKeyNotAllowedError), + #[error(transparent)] + ScopedKeyNonBatchError(ScopedKeyNonBatchError), + + #[error(transparent)] + ScopedKeyExpiredError(ScopedKeyExpiredError), + + #[error(transparent)] + ScopedKeyOutOfScopeError(ScopedKeyOutOfScopeError), } impl From for ConsensusError { diff --git a/packages/rs-dpp/src/identity/identity.rs b/packages/rs-dpp/src/identity/identity.rs index 86aee2c2d14..da7c0737207 100644 --- a/packages/rs-dpp/src/identity/identity.rs +++ b/packages/rs-dpp/src/identity/identity.rs @@ -29,6 +29,11 @@ use std::collections::{BTreeMap, BTreeSet}; /// The identity is not stored inside of drive, because of this, the serialization is mainly for /// transport, the serialization of the identity will include the version, so no passthrough or /// untagged is needed here +/// +/// The 256 MiB decoding budget accommodates `IDENTITY_MAX_KEYS` (15,000) at +/// 16 KiB per scoped key, plus the key map and identity fields. Bincode counts +/// container allocations as well as wire bytes. Individual authentication scopes +/// still have a separate 2 KiB wire limit enforced by scope validation. #[derive(Debug, Clone, PartialEq, From)] #[cfg_attr( feature = "serde-conversion", @@ -39,7 +44,7 @@ use std::collections::{BTreeMap, BTreeSet}; #[cfg_attr( feature = "identity-serialization", derive(Encode, Decode, PlatformDeserialize, PlatformSerialize), - platform_serialize(limit = 15000, unversioned) + platform_serialize(limit = 268435456, unversioned) )] #[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] pub enum Identity { @@ -335,6 +340,81 @@ mod tests { }) } + #[cfg(feature = "identity-serialization")] + #[test] + fn should_decode_full_identities_with_large_scoped_key_sets() { + use crate::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractBounds, ContractScope, + }; + use crate::identity::fields::IDENTITY_MAX_KEYS; + use crate::serialization::{PlatformDeserializable, PlatformSerializable}; + + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: (1..=16) + .map(|id| ContractScope { + id: Identifier::from([id; 32]), + document_types: Some((0..16).map(|n| format!("t{n:03}")).collect()), + }) + .collect(), + permissions: permissions::ALL, + expires_at: Some(u64::MAX), + }); + scope.validate().unwrap(); + + for count in [8, u32::from(IDENTITY_MAX_KEYS)] { + let mut public_keys = BTreeMap::from([(0, sample_key(0))]); + for id in 1..count { + let mut key = IdentityPublicKeyV0 { + id, + key_type: KeyType::BLS12_381, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: Some(ContractBounds::Scoped(scope.clone())), + data: vec![0x42; 48].into(), + read_only: false, + disabled_at: None, + }; + // Full identities retain disabled keys too. + if id % 2 == 0 { + key.disabled_at = Some(u64::MAX); + } + public_keys.insert(id, key.into()); + } + let identity: Identity = IdentityV0 { + id: Identifier::from([0x42; 32]), + public_keys, + balance: u64::MAX, + revision: u64::MAX, + } + .into(); + let bytes = identity.serialize_to_bytes().unwrap(); + assert_eq!( + Identity::deserialize_from_bytes(&bytes).unwrap(), + identity, + "full identity with {count} keys must round-trip" + ); + } + } + + #[cfg(feature = "identity-serialization")] + #[test] + fn should_reject_full_identity_with_excessive_declared_key_allocation() { + use crate::serialization::PlatformDeserializable; + + // Valid V0 tag and identity ID, followed by a forged public-key map length. + // The decoder must enforce its allocation budget before reading any keys. + let bytes = bincode::encode_to_vec( + (0_u8, Identifier::from([0x42; 32]), u64::MAX), + bincode::config::standard().with_big_endian(), + ) + .unwrap(); + assert!(matches!( + Identity::deserialize_from_bytes(&bytes), + Err(ProtocolError::MaxEncodedBytesReachedError { .. }) + )); + } + #[test] fn default_versioned_returns_default_v0() { let identity = diff --git a/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/authentication_scope.rs b/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/authentication_scope.rs new file mode 100644 index 00000000000..91221d7bde7 --- /dev/null +++ b/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/authentication_scope.rs @@ -0,0 +1,551 @@ +//! Immutable application delegation. Budgets are deliberately not part of V0. +use crate::identifier::Identifier; +use crate::identity::TimestampMillis; +#[cfg(feature = "value-conversion")] +use crate::serialization::ValueConvertible; +#[cfg(feature = "json-conversion")] +use crate::serialization::{json_safe_fields, JsonConvertible}; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use serde::{Deserialize, Serialize}; + +pub const MAX_SCOPE_BYTES: usize = 2048; +pub const MAX_SCOPE_CONTRACTS: usize = 16; +pub const MAX_SCOPE_DOCUMENT_TYPES: usize = 16; + +/// Stable wire bits. Unknown bits are rejected, never ignored. +pub mod permissions { + pub const DOCUMENT_CREATE: u32 = 1 << 0; + pub const DOCUMENT_REPLACE: u32 = 1 << 1; + pub const DOCUMENT_DELETE: u32 = 1 << 2; + pub const DOCUMENT_TRANSFER: u32 = 1 << 3; + pub const DOCUMENT_UPDATE_PRICE: u32 = 1 << 4; + pub const DOCUMENT_PURCHASE: u32 = 1 << 5; + pub const DOCUMENT_TOKEN_PAYMENT: u32 = 1 << 6; + pub const TOKEN_BURN: u32 = 1 << 7; + pub const TOKEN_MINT: u32 = 1 << 8; + pub const TOKEN_TRANSFER: u32 = 1 << 9; + pub const TOKEN_FREEZE: u32 = 1 << 10; + pub const TOKEN_UNFREEZE: u32 = 1 << 11; + pub const TOKEN_DESTROY_FROZEN_FUNDS: u32 = 1 << 12; + pub const TOKEN_CLAIM: u32 = 1 << 13; + pub const TOKEN_EMERGENCY_ACTION: u32 = 1 << 14; + pub const TOKEN_CONFIG_UPDATE: u32 = 1 << 15; + pub const TOKEN_DIRECT_PURCHASE: u32 = 1 << 16; + pub const TOKEN_SET_PRICE: u32 = 1 << 17; + pub const ALL: u32 = (1 << 18) - 1; +} + +#[cfg_attr(feature = "json-conversion", json_safe_fields)] +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode, Serialize, Deserialize, +)] +#[serde(rename_all = "camelCase")] +pub struct ContractScope { + pub id: Identifier, + /// None grants all document types; Some(empty) is invalid. + pub document_types: Option>, +} + +#[cfg_attr(feature = "json-conversion", json_safe_fields)] +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode, Serialize, Deserialize, +)] +#[serde(rename_all = "camelCase")] +pub struct AuthenticationScopeV0 { + pub contracts: Vec, + pub permissions: u32, + pub expires_at: Option, +} + +#[cfg_attr(feature = "json-conversion", derive(JsonConvertible))] +#[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode, Serialize, Deserialize, +)] +#[serde(tag = "$formatVersion")] +pub enum AuthenticationScope { + #[serde(rename = "0")] + V0(AuthenticationScopeV0), +} + +impl AuthenticationScope { + pub fn v0(&self) -> &AuthenticationScopeV0 { + match self { + Self::V0(scope) => scope, + } + } + + pub fn contracts(&self) -> &[ContractScope] { + &self.v0().contracts + } + pub fn expires_at(&self) -> Option { + self.v0().expires_at + } + pub fn allows(&self, permission: u32) -> bool { + self.v0().permissions & permission == permission + } + pub fn is_expired(&self, time_ms: TimestampMillis) -> bool { + self.expires_at().is_some_and(|expiry| time_ms >= expiry) + } + pub fn allows_contract(&self, id: &Identifier) -> bool { + self.contracts().iter().any(|scope| scope.id == id) + } + pub fn allows_document(&self, id: &Identifier, name: &str) -> bool { + self.contracts().iter().any(|scope| { + scope.id == id + && scope + .document_types + .as_ref() + .is_none_or(|names| names.iter().any(|n| n == name)) + }) + } + + /// Validate before fetching any referenced contracts. + pub fn validate(&self) -> Result<(), ProtocolError> { + let invalid = + |reason: &str| ProtocolError::InvalidKeyContractBoundsError(reason.to_owned()); + let scope = self.v0(); + if scope.contracts.is_empty() || scope.contracts.len() > MAX_SCOPE_CONTRACTS { + return Err(invalid("scope must contain between 1 and 16 contracts")); + } + if scope.permissions == 0 || scope.permissions & !permissions::ALL != 0 { + return Err(invalid("scope must have a nonempty, known permission mask")); + } + if !scope + .contracts + .windows(2) + .all(|pair| pair[0].id < pair[1].id) + { + return Err(invalid("scope contract IDs must be sorted and unique")); + } + for contract in &scope.contracts { + if let Some(names) = &contract.document_types { + if names.is_empty() + || names.len() > MAX_SCOPE_DOCUMENT_TYPES + || names + .iter() + .any(|name| name.is_empty() || name.len() > MAX_SCOPE_BYTES) + || !names.windows(2).all(|pair| pair[0] < pair[1]) + { + return Err(invalid("scope document types must be nonempty, sorted, unique and at most 16 per contract")); + } + } + } + let bytes = bincode::encode_to_vec(self, bincode::config::standard()) + .map_err(|e| ProtocolError::EncodingError(e.to_string()))?; + if bytes.len() > MAX_SCOPE_BYTES { + return Err(invalid("encoded authentication scope exceeds 2048 bytes")); + } + Ok(()) + } + + /// Canonical native persistence / shielded preimage representation. + pub fn to_bytes(&self) -> Result, ProtocolError> { + self.validate()?; + bincode::encode_to_vec(self, bincode::config::standard()) + .map_err(|e| ProtocolError::EncodingError(e.to_string())) + } + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() > MAX_SCOPE_BYTES { + return Err(ProtocolError::DecodingError( + "scope exceeds 2048 bytes".into(), + )); + } + let (scope, consumed): (Self, usize) = bincode::decode_from_slice( + bytes, + bincode::config::standard().with_limit::<{ MAX_SCOPE_BYTES * 8 }>(), + ) + .map_err(|e| ProtocolError::DecodingError(e.to_string()))?; + if consumed != bytes.len() { + return Err(ProtocolError::DecodingError("trailing scope bytes".into())); + } + scope.validate()?; + Ok(scope) + } + + #[cfg(feature = "state-transitions")] + pub fn allows_transition( + &self, + transition: crate::state_transition::batch_transition::batched_transition::BatchedTransitionRef<'_>, + ) -> bool { + use crate::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; + use crate::state_transition::batch_transition::batched_transition::token_transition::TokenTransitionV0Methods; + use crate::state_transition::batch_transition::batched_transition::{ + BatchedTransitionRef, DocumentTransition, TokenTransition, + }; + use permissions::*; + let permission = match transition { + BatchedTransitionRef::Document(doc) => { + if !self.allows_document(&doc.data_contract_id(), doc.document_type_name()) { + return false; + } + match doc { + DocumentTransition::Create(_) => DOCUMENT_CREATE, + DocumentTransition::Replace(_) => DOCUMENT_REPLACE, + DocumentTransition::Delete(_) | DocumentTransition::IndexOnlyDelete(_) => { + DOCUMENT_DELETE + } + DocumentTransition::Transfer(_) => DOCUMENT_TRANSFER, + DocumentTransition::UpdatePrice(_) => DOCUMENT_UPDATE_PRICE, + DocumentTransition::Purchase(_) => DOCUMENT_PURCHASE, + } + } + BatchedTransitionRef::Token(token) => { + if !self.allows_contract(&token.data_contract_id()) { + return false; + } + match token { + TokenTransition::Burn(_) => TOKEN_BURN, + TokenTransition::Mint(_) => TOKEN_MINT, + TokenTransition::Transfer(_) => TOKEN_TRANSFER, + TokenTransition::Freeze(_) => TOKEN_FREEZE, + TokenTransition::Unfreeze(_) => TOKEN_UNFREEZE, + TokenTransition::DestroyFrozenFunds(_) => TOKEN_DESTROY_FROZEN_FUNDS, + TokenTransition::Claim(_) => TOKEN_CLAIM, + TokenTransition::EmergencyAction(_) => TOKEN_EMERGENCY_ACTION, + TokenTransition::ConfigUpdate(_) => TOKEN_CONFIG_UPDATE, + TokenTransition::DirectPurchase(_) => TOKEN_DIRECT_PURCHASE, + TokenTransition::SetPriceForDirectPurchase(_) => TOKEN_SET_PRICE, + } + } + }; + self.allows(permission) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> AuthenticationScope { + AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([1; 32]), + document_types: Some(vec!["post".into()]), + }], + permissions: permissions::DOCUMENT_CREATE | permissions::DOCUMENT_TOKEN_PAYMENT, + expires_at: Some(100), + }) + } + #[test] + fn should_decode_stored_keys_with_large_scopes() { + use crate::identity::contract_bounds::ContractBounds; + use crate::identity::identity_public_key::v0::IdentityPublicKeyV0; + use crate::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use crate::serialization::{PlatformDeserializable, PlatformSerializable}; + + for contract_count in [8, 16] { + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: (0..contract_count) + .map(|id| ContractScope { + id: Identifier::from([id; 32]), + document_types: Some((0..16).map(|n| format!("t{n:02}")).collect()), + }) + .collect(), + permissions: permissions::ALL, + expires_at: Some(u64::MAX), + }); + scope.validate().unwrap(); + let key: IdentityPublicKey = IdentityPublicKeyV0 { + id: u32::MAX, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + data: vec![2; 33].into(), + read_only: false, + disabled_at: Some(u64::MAX), + contract_bounds: Some(ContractBounds::Scoped(scope)), + } + .into(); + let bytes = key.serialize_to_bytes().unwrap(); + assert_eq!( + IdentityPublicKey::deserialize_from_bytes(&bytes).unwrap(), + key + ); + } + } + + #[test] + fn should_preserve_scope_and_reject_trailing_bytes() { + let scope = fixture(); + let mut bytes = scope.to_bytes().unwrap(); + assert_eq!(AuthenticationScope::from_bytes(&bytes).unwrap(), scope); + bytes.push(0); + assert!(AuthenticationScope::from_bytes(&bytes).is_err()); + } + #[test] + fn should_restrict_contracts_types_actions_and_expiry() { + let scope = fixture(); + assert!(scope.allows_document(&Identifier::from([1; 32]), "post")); + assert!(!scope.allows_document(&Identifier::from([2; 32]), "post")); + assert!(!scope.allows_document(&Identifier::from([1; 32]), "profile")); + assert!(!scope.allows(permissions::TOKEN_TRANSFER)); + assert!(!scope.is_expired(99)); + assert!(scope.is_expired(100)); + } + #[test] + fn should_reject_empty_types_unknown_bits_and_duplicate_contracts() { + let AuthenticationScope::V0(original) = fixture(); + let mut scope = original.clone(); + scope.contracts[0].document_types = Some(vec![]); + assert!(AuthenticationScope::V0(scope).validate().is_err()); + let mut scope = original.clone(); + scope.permissions |= 1 << 31; + assert!(AuthenticationScope::V0(scope).validate().is_err()); + let mut scope = original; + scope.contracts.push(scope.contracts[0].clone()); + assert!(AuthenticationScope::V0(scope).validate().is_err()); + } + #[test] + fn should_bound_scope_size_and_distinguish_unrestricted_types() { + let AuthenticationScope::V0(mut scope) = fixture(); + scope.contracts[0].document_types = None; + let unrestricted = AuthenticationScope::V0(scope.clone()); + assert!(unrestricted.validate().is_ok()); + assert!(unrestricted.allows_document(&scope.contracts[0].id, "anything")); + scope.contracts[0].document_types = Some(vec!["a".repeat(MAX_SCOPE_BYTES)]); + assert!(AuthenticationScope::V0(scope).validate().is_err()); + assert!(AuthenticationScope::from_bytes(&vec![0; MAX_SCOPE_BYTES + 1]).is_err()); + + let AuthenticationScope::V0(mut scope) = fixture(); + scope.contracts = (0..16) + .map(|id| ContractScope { + id: Identifier::from([id; 32]), + document_types: None, + }) + .collect(); + assert!(AuthenticationScope::V0(scope.clone()).validate().is_ok()); + scope.contracts.push(ContractScope { + id: Identifier::from([16; 32]), + document_types: None, + }); + assert!(AuthenticationScope::V0(scope.clone()).validate().is_err()); + scope.contracts.clear(); + assert!(AuthenticationScope::V0(scope).validate().is_err()); + + let AuthenticationScope::V0(mut scope) = fixture(); + let names = (0..16).map(|n| format!("type{n:02}")).collect::>(); + scope.contracts[0].document_types = Some(names.clone()); + assert!(AuthenticationScope::V0(scope.clone()).validate().is_ok()); + scope.contracts[0] + .document_types + .as_mut() + .unwrap() + .push("type16".into()); + assert!(AuthenticationScope::V0(scope.clone()).validate().is_err()); + scope.contracts[0].document_types = Some(names); + scope.permissions = 0; + assert!(AuthenticationScope::V0(scope).validate().is_err()); + } + + #[cfg(feature = "state-transitions")] + #[test] + fn should_require_each_token_permission_independently_and_reject_foreign_contracts() { + use crate::state_transition::batch_transition::batched_transition::{ + token_transfer_transition::TokenTransferTransitionV0, BatchedTransitionRef, + TokenTransition, + }; + use permissions::*; + + let cases = [ + (TokenTransition::Burn(Default::default()), TOKEN_BURN), + (TokenTransition::Mint(Default::default()), TOKEN_MINT), + ( + TokenTransition::Transfer(TokenTransferTransitionV0::default().into()), + TOKEN_TRANSFER, + ), + (TokenTransition::Freeze(Default::default()), TOKEN_FREEZE), + ( + TokenTransition::Unfreeze(Default::default()), + TOKEN_UNFREEZE, + ), + ( + TokenTransition::DestroyFrozenFunds(Default::default()), + TOKEN_DESTROY_FROZEN_FUNDS, + ), + (TokenTransition::Claim(Default::default()), TOKEN_CLAIM), + ( + TokenTransition::EmergencyAction(Default::default()), + TOKEN_EMERGENCY_ACTION, + ), + ( + TokenTransition::ConfigUpdate(Default::default()), + TOKEN_CONFIG_UPDATE, + ), + ( + TokenTransition::DirectPurchase(Default::default()), + TOKEN_DIRECT_PURCHASE, + ), + ( + TokenTransition::SetPriceForDirectPurchase(Default::default()), + TOKEN_SET_PRICE, + ), + ]; + // Only the operation and contract are relevant to the authorization policy; + // amounts, recipients and other action fields are validated separately. + for (transition, required) in cases { + let member = BatchedTransitionRef::Token(&transition); + let mut scope = AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([0; 32]), + document_types: None, + }], + permissions: required, + expires_at: None, + }; + assert!(AuthenticationScope::V0(scope.clone()).allows_transition(member)); + scope.permissions = ALL & !required; + assert!(!AuthenticationScope::V0(scope.clone()).allows_transition(member), + "all other permissions, including document token fees, must not authorize {transition:?}"); + scope.permissions = ALL; + scope.contracts[0].id = Identifier::from([1; 32]); + assert!( + !AuthenticationScope::V0(scope).allows_transition(member), + "no permission may escape its contract" + ); + } + } + + #[cfg(all(feature = "json-conversion", feature = "value-conversion"))] + #[test] + fn should_round_trip_scoped_bounds_in_json_and_platform_value() { + use super::super::ContractBounds; + let bounds = ContractBounds::Scoped(fixture()); + let json = bounds.to_json().unwrap(); + assert_eq!(ContractBounds::from_json(json).unwrap(), bounds); + let value = bounds.to_object().unwrap(); + assert_eq!(ContractBounds::from_object(value).unwrap(), bounds); + } + + #[cfg(feature = "state-transitions")] + #[test] + fn should_reject_scoped_keys_before_activation_and_scoped_master_keys() { + use super::super::ContractBounds; + use crate::identity::{KeyType, Purpose, SecurityLevel}; + use crate::state_transition::public_key_in_creation::{ + v0::IdentityPublicKeyInCreationV0, IdentityPublicKeyInCreation, + }; + let mut key = IdentityPublicKeyInCreationV0 { + id: 2, + key_type: KeyType::ECDSA_HASH160, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + data: vec![1; 20].into(), + read_only: false, + signature: Default::default(), + contract_bounds: Some(ContractBounds::Scoped(fixture())), + }; + let old = crate::version::PlatformVersion::get(13).unwrap(); + let new = crate::version::PlatformVersion::latest(); + assert!( + !IdentityPublicKeyInCreation::validate_identity_public_keys_structure( + &[key.clone().into()], + false, + old + ) + .unwrap() + .is_valid() + ); + assert!( + IdentityPublicKeyInCreation::validate_identity_public_keys_structure( + &[key.clone().into()], + false, + new + ) + .unwrap() + .is_valid() + ); + key.security_level = SecurityLevel::MASTER; + assert!( + !IdentityPublicKeyInCreation::validate_identity_public_keys_structure( + &[key.into()], + false, + new + ) + .unwrap() + .is_valid() + ); + } + + #[cfg(feature = "state-transitions")] + #[test] + fn should_bind_every_scope_field_in_versioned_shielded_preimages() { + use super::super::ContractBounds; + use crate::address_funds::PlatformAddress; + use crate::identity::{KeyType, Purpose, SecurityLevel}; + use crate::shielded::{ + identity_create_from_shielded_extra_sighash_data as versioned, + identity_create_from_shielded_extra_sighash_data_v0 as old, + identity_create_from_shielded_extra_sighash_data_v1 as new, + }; + use crate::state_transition::public_key_in_creation::{ + v0::IdentityPublicKeyInCreationV0, IdentityPublicKeyInCreation, + }; + let mut key = IdentityPublicKeyInCreationV0 { + id: 2, + key_type: KeyType::ECDSA_HASH160, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + data: vec![1; 20].into(), + read_only: false, + signature: Default::default(), + contract_bounds: None, + }; + let fallback = PlatformAddress::P2pkh([2; 20]); + for address in [fallback, PlatformAddress::P2sh([3; 20])] { + for bounds in [ + None, + Some(ContractBounds::SingleContract { + id: Identifier::from([4; 32]), + }), + Some(ContractBounds::SingleContractDocumentType { + id: Identifier::from([4; 32]), + document_type_name: "legacy".into(), + }), + ] { + key.contract_bounds = bounds; + let legacy: IdentityPublicKeyInCreation = key.clone().into(); + let keys = std::slice::from_ref(&legacy); + let frozen = old(&[1; 32], 1, &address, keys).unwrap(); + assert_eq!(frozen, new(&[1; 32], 1, &address, keys).unwrap()); + for protocol in [13, 14] { + assert_eq!( + frozen, + versioned( + &[1; 32], + 1, + &address, + keys, + crate::version::PlatformVersion::get(protocol).unwrap() + ) + .unwrap(), + "legacy key preimage must remain stable under protocol {protocol}" + ); + } + } + } + key.contract_bounds = Some(ContractBounds::Scoped(fixture())); + assert!(old(&[1; 32], 1, &fallback, &[key.clone().into()]).is_err()); + let original = new(&[1; 32], 1, &fallback, &[key.clone().into()]).unwrap(); + for field in ["contract", "types", "permissions", "expiry"] { + let mut changed = key.clone(); + let Some(ContractBounds::Scoped(AuthenticationScope::V0(ref mut scope))) = + changed.contract_bounds + else { + unreachable!() + }; + match field { + "contract" => scope.contracts[0].id = Identifier::from([2; 32]), + "types" => scope.contracts[0].document_types = None, + "permissions" => scope.permissions |= permissions::DOCUMENT_DELETE, + "expiry" => scope.expires_at = Some(101), + _ => unreachable!(), + } + assert_ne!( + original, + new(&[1; 32], 1, &fallback, &[changed.into()]).unwrap(), + "must bind {field}" + ); + } + } +} 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 7052fea0813..29d8b974498 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 @@ -1,3 +1,4 @@ +pub mod authentication_scope; use crate::identifier::Identifier; use crate::identity::identity_public_key::contract_bounds::ContractBounds::{ SingleContract, SingleContractDocumentType, @@ -7,6 +8,7 @@ use crate::serialization::JsonConvertible; #[cfg(feature = "value-conversion")] use crate::serialization::ValueConvertible; use crate::ProtocolError; +pub use authentication_scope::{AuthenticationScope, AuthenticationScopeV0, ContractScope}; use bincode::{Decode, Encode}; use serde::{Deserialize, Serialize}; @@ -35,6 +37,8 @@ pub enum ContractBounds { id: Identifier, document_type_name: String, } = 1, + /// Application authentication permissions. Existing encryption variants retain their wire tags. + Scoped(AuthenticationScope) = 2, // /// this key can only be used within contracts owned by a specified owner // #[serde(rename = "multipleContractsOfSameOwner")] // MultipleContractsOfSameOwner { owner_id: Identifier } = 2, @@ -69,7 +73,7 @@ impl ContractBounds { match self { SingleContract { .. } => 0, SingleContractDocumentType { .. } => 1, - // MultipleContractsOfSameOwner { .. } => 2, + Self::Scoped(_) => 2, } } @@ -77,6 +81,7 @@ impl ContractBounds { match str { "singleContract" => Ok(0), "documentType" => Ok(1), + "scoped" => Ok(2), _ => Err(ProtocolError::DecodingError(String::from( "Expected type to be one of none, singleContract or singleContractDocumentType", ))), @@ -87,16 +92,39 @@ impl ContractBounds { match self { SingleContract { .. } => "singleContract", SingleContractDocumentType { .. } => "documentType", - // MultipleContractsOfSameOwner { .. } => "multipleContractsOfSameOwner", + Self::Scoped(_) => "scoped", } } + /// Every bounded contract and its optional document-type restriction. + /// Unlike the legacy singular accessors, this retains the entire delegation. + pub fn contracts(&self) -> impl Iterator)> { + let single = match self { + Self::SingleContract { id } => Some((id, None)), + Self::SingleContractDocumentType { + id, + document_type_name, + } => Some((id, Some(std::slice::from_ref(document_type_name)))), + Self::Scoped(_) => None, + }; + let scoped = match self { + Self::Scoped(scope) => Some(scope.contracts()), + _ => None, + }; + single.into_iter().chain( + scoped + .into_iter() + .flatten() + .map(|entry| (&entry.id, entry.document_types.as_deref())), + ) + } + /// Gets the identifier - pub fn identifier(&self) -> &Identifier { + pub fn identifier(&self) -> Option<&Identifier> { match self { - SingleContract { id } => id, - SingleContractDocumentType { id, .. } => id, - // MultipleContractsOfSameOwner { owner_id } => owner_id, + SingleContract { id } => Some(id), + SingleContractDocumentType { id, .. } => Some(id), + Self::Scoped(_) => None, } } @@ -108,7 +136,7 @@ impl ContractBounds { document_type_name: document_type, .. } => Some(document_type), - // MultipleContractsOfSameOwner { .. } => None, + Self::Scoped(_) => None, } } // @@ -176,7 +204,7 @@ mod core_tests { assert!(matches!(bounds, ContractBounds::SingleContract { .. })); assert_eq!(bounds.contract_bounds_type(), 0); assert_eq!(bounds.contract_bounds_type_string(), "singleContract"); - assert_eq!(bounds.identifier().as_bytes(), id_bytes.as_slice()); + assert_eq!(bounds.identifier().unwrap().as_bytes(), id_bytes.as_slice()); // document_type is None for SingleContract regardless of what we passed in. assert!(bounds.document_type().is_none()); } @@ -192,7 +220,7 @@ mod core_tests { )); assert_eq!(bounds.contract_bounds_type(), 1); assert_eq!(bounds.contract_bounds_type_string(), "documentType"); - assert_eq!(bounds.identifier().as_bytes(), id_bytes.as_slice()); + assert_eq!(bounds.identifier().unwrap().as_bytes(), id_bytes.as_slice()); assert_eq!(bounds.document_type().map(String::as_str), Some("myDoc")); } diff --git a/packages/rs-dpp/src/identity/identity_public_key/mod.rs b/packages/rs-dpp/src/identity/identity_public_key/mod.rs index c76091ccddc..5989a50f896 100644 --- a/packages/rs-dpp/src/identity/identity_public_key/mod.rs +++ b/packages/rs-dpp/src/identity/identity_public_key/mod.rs @@ -51,7 +51,11 @@ pub type TimestampMillis = u64; Ord, PartialOrd, )] -#[platform_serialize(limit = 2000, unversioned)] //This is not platform versioned automatically +// Bincode's decoding budget includes container allocations, not just wire bytes. +// A valid scope can allocate 16 ContractScopes and 256 Strings in addition to +// its 2 KiB encoding. 16 KiB covers those allocations and the remaining key fields. +// Scope validation retains its separate 2 KiB wire limit. +#[platform_serialize(limit = 16384, unversioned)] // Not automatically platform versioned. #[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] #[serde(tag = "$formatVersion")] pub enum IdentityPublicKey { diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index edf4a0a7de2..6fb496496d1 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -24,7 +24,8 @@ pub use compute_minimum_shielded_fee::{ // re-exported (callers use the wrappers; byte-layout tests use the `_v0` impls). pub use sighash::{ compute_platform_sighash, identity_create_from_shielded_extra_sighash_data, - identity_create_from_shielded_extra_sighash_data_v0, shielded_withdrawal_extra_sighash_data, + identity_create_from_shielded_extra_sighash_data_v0, + identity_create_from_shielded_extra_sighash_data_v1, shielded_withdrawal_extra_sighash_data, shielded_withdrawal_extra_sighash_data_v0, unshield_extra_sighash_data, unshield_extra_sighash_data_v0, }; diff --git a/packages/rs-dpp/src/shielded/sighash.rs b/packages/rs-dpp/src/shielded/sighash.rs index 5856a9e5b33..05aeaa36a59 100644 --- a/packages/rs-dpp/src/shielded/sighash.rs +++ b/packages/rs-dpp/src/shielded/sighash.rs @@ -74,7 +74,7 @@ pub fn shielded_withdrawal_extra_sighash_data( platform_version: &PlatformVersion, ) -> Result, ProtocolError> { match platform_version.dpp.methods.shielded_extra_sighash_data { - 0 => Ok(shielded_withdrawal_extra_sighash_data_v0( + 0 | 1 => Ok(shielded_withdrawal_extra_sighash_data_v0( output_script, unshielding_amount, core_fee_per_byte, @@ -82,7 +82,7 @@ pub fn shielded_withdrawal_extra_sighash_data( )), version => Err(ProtocolError::UnknownVersionMismatch { method: "shielded_withdrawal_extra_sighash_data".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } @@ -117,13 +117,13 @@ pub fn unshield_extra_sighash_data( platform_version: &PlatformVersion, ) -> Result, ProtocolError> { match platform_version.dpp.methods.shielded_extra_sighash_data { - 0 => Ok(unshield_extra_sighash_data_v0( + 0 | 1 => Ok(unshield_extra_sighash_data_v0( output_address, unshielding_amount, )), version => Err(ProtocolError::UnknownVersionMismatch { method: "unshield_extra_sighash_data".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } @@ -171,15 +171,21 @@ pub fn identity_create_from_shielded_extra_sighash_data( platform_version: &PlatformVersion, ) -> Result, ProtocolError> { match platform_version.dpp.methods.shielded_extra_sighash_data { - 0 => Ok(identity_create_from_shielded_extra_sighash_data_v0( + 0 => identity_create_from_shielded_extra_sighash_data_v0( identity_id, denomination, send_to_address_on_creation_failure, public_keys, - )), + ), + 1 => identity_create_from_shielded_extra_sighash_data_v1( + identity_id, + denomination, + send_to_address_on_creation_failure, + public_keys, + ), version => Err(ProtocolError::UnknownVersionMismatch { method: "identity_create_from_shielded_extra_sighash_data".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } @@ -193,7 +199,7 @@ pub fn identity_create_from_shielded_extra_sighash_data_v0( denomination: u64, send_to_address_on_creation_failure: &PlatformAddress, public_keys: &[IdentityPublicKeyInCreation], -) -> Vec { +) -> Result, ProtocolError> { let mut data = Vec::with_capacity(32 + 8 + 21 + 2 + public_keys.len() * 44); data.extend_from_slice(identity_id); data.extend_from_slice(&denomination.to_le_bytes()); @@ -225,6 +231,13 @@ pub fn identity_create_from_shielded_extra_sighash_data_v0( // cannot flip `read_only` or alter `contract_bounds` on an observed transition. data.push(key.read_only() as u8); match key.contract_bounds() { + // This variant was not representable under v0. Reject it without + // changing a single byte of any historical preimage. + Some(ContractBounds::Scoped(_)) => { + return Err(ProtocolError::InvalidKeyContractBoundsError( + "scoped keys require shielded sighash v1".into(), + )) + } None => data.push(0u8), Some(ContractBounds::SingleContract { id }) => { data.push(1u8); @@ -242,7 +255,71 @@ pub fn identity_create_from_shielded_extra_sighash_data_v0( } } } - data + Ok(data) +} + +/// v1 adds a length-prefixed scoped delegation; legacy keys retain their preimages. +pub fn identity_create_from_shielded_extra_sighash_data_v1( + identity_id: &[u8; 32], + denomination: u64, + send_to_address_on_creation_failure: &PlatformAddress, + public_keys: &[IdentityPublicKeyInCreation], +) -> Result, ProtocolError> { + let mut data = Vec::with_capacity(32 + 8 + 21 + 2 + public_keys.len() * 44); + data.extend_from_slice(identity_id); + data.extend_from_slice(&denomination.to_le_bytes()); + // Bind the fallback address (type tag || 20-byte hash) so a relayer cannot redirect the + // failure credit. Mirrors the way `unshield`/`withdrawal` bind their output address. + match send_to_address_on_creation_failure { + PlatformAddress::P2pkh(hash) => { + data.push(0u8); + data.extend_from_slice(hash); + } + PlatformAddress::P2sh(hash) => { + data.push(1u8); + data.extend_from_slice(hash); + } + } + data.extend_from_slice(&(public_keys.len() as u16).to_le_bytes()); + for key in public_keys { + data.extend_from_slice(&key.id().to_le_bytes()); + data.push(key.purpose() as u8); + data.push(key.security_level() as u8); + data.push(key.key_type() as u8); + let key_data = key.data().as_slice(); + data.extend_from_slice(&(key_data.len() as u16).to_le_bytes()); + data.extend_from_slice(key_data); + // Also bind `read_only` and `contract_bounds`. These are state-determining key fields that + // ARE in the transition's signable_bytes, but the per-key proof-of-possession does NOT bind + // them for hash-based key types (which accept an empty signature). Committing them into the + // Orchard binding sighash makes them un-malleable for EVERY key type, so a relayer/proposer + // cannot flip `read_only` or alter `contract_bounds` on an observed transition. + data.push(key.read_only() as u8); + match key.contract_bounds() { + Some(ContractBounds::Scoped(scope)) => { + let bytes = scope.to_bytes()?; + data.push(3u8); + data.extend_from_slice(&(bytes.len() as u16).to_le_bytes()); + data.extend_from_slice(&bytes); + } + None => data.push(0u8), + Some(ContractBounds::SingleContract { id }) => { + data.push(1u8); + data.extend_from_slice(id.as_bytes()); + } + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name, + }) => { + data.push(2u8); + data.extend_from_slice(id.as_bytes()); + let name = document_type_name.as_bytes(); + data.extend_from_slice(&(name.len() as u16).to_le_bytes()); + data.extend_from_slice(name); + } + } + } + Ok(data) } #[cfg(test)] @@ -306,7 +383,20 @@ mod tests { use super::*; // Pin the v0 preimage directly (see the note in the parent test module). use crate::identity::{KeyType, Purpose, SecurityLevel}; - use crate::shielded::identity_create_from_shielded_extra_sighash_data_v0 as identity_create_from_shielded_extra_sighash_data; + fn identity_create_from_shielded_extra_sighash_data( + id: &[u8; 32], + denomination: u64, + fallback: &PlatformAddress, + keys: &[IdentityPublicKeyInCreation], + ) -> Vec { + super::super::identity_create_from_shielded_extra_sighash_data_v0( + id, + denomination, + fallback, + keys, + ) + .unwrap() + } use crate::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use platform_value::BinaryData; diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 5c048ae84ef..eb116981383 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -805,6 +805,32 @@ impl StateTransition { platform_value::with_value_decode_depth_limit(max_value_depth, || { StateTransition::deserialize_from_bytes(bytes) })?; + // Before activation, old binaries cannot decode the new bounds variant. + // Preserve that unpaid failure before any asset lock or nonce can be consumed. + if platform_version.protocol_version < 14 { + use crate::identity::contract_bounds::ContractBounds; + use crate::state_transition::identity_create_from_addresses_transition::accessors::IdentityCreateFromAddressesTransitionAccessorsV0; + use crate::state_transition::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; + use crate::state_transition::identity_create_transition::accessors::IdentityCreateTransitionAccessorsV0; + use crate::state_transition::identity_update_transition::accessors::IdentityUpdateTransitionAccessorsV0; + use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; + + let keys = match &state_transition { + Self::IdentityCreate(st) => st.public_keys(), + Self::IdentityCreateFromAddresses(st) => st.public_keys(), + Self::IdentityCreateFromShieldedPool(st) => st.public_keys(), + Self::IdentityUpdate(st) => st.public_keys_to_add(), + _ => &[], + }; + if keys + .iter() + .any(|key| matches!(key.contract_bounds(), Some(ContractBounds::Scoped(_)))) + { + return Err(ProtocolError::PlatformDeserializationError( + "scoped authentication keys are not activated".into(), + )); + } + } #[cfg(all(feature = "state-transitions", feature = "validation"))] { let active_version_range = state_transition.active_version_range(); @@ -1279,6 +1305,43 @@ impl StateTransition { call_method_identity_signed!(self, set_signature_public_key_id, public_key_id) } + /// Check the scope when the signing API receives the identity key metadata. + /// Raw signing primitives cannot check bounds without that metadata. + #[cfg(feature = "state-transition-signing")] + fn verify_identity_key_scope( + &self, + identity_public_key: &IdentityPublicKey, + ) -> Result<(), ProtocolError> { + if let Some(crate::identity::contract_bounds::ContractBounds::Scoped(scope)) = + identity_public_key.contract_bounds() + { + use crate::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; + match self { + StateTransition::Batch(batch) + if batch + .transitions_iter() + .all(|transition| scope.allows_transition(transition)) => {} + StateTransition::Batch(_) => { + return Err(ProtocolError::ConsensusError(Box::new( + crate::consensus::signature::ScopedKeyOutOfScopeError::new( + identity_public_key.id(), + ) + .into(), + ))) + } + _ => { + return Err(ProtocolError::ConsensusError(Box::new( + crate::consensus::signature::ScopedKeyNonBatchError::new( + identity_public_key.id(), + ) + .into(), + ))) + } + } + } + Ok(()) + } + #[cfg(feature = "state-transition-signing")] pub async fn sign_external>( &mut self, @@ -1307,6 +1370,7 @@ impl StateTransition { >, options: StateTransitionSigningOptions, ) -> Result<(), ProtocolError> { + self.verify_identity_key_scope(identity_public_key)?; match self { StateTransition::DataContractCreate(st) => { st.verify_public_key_level_and_purpose(identity_public_key, options)?; @@ -1482,6 +1546,7 @@ impl StateTransition { bls: &impl BlsModule, options: StateTransitionSigningOptions, ) -> Result<(), ProtocolError> { + self.verify_identity_key_scope(identity_public_key)?; call_errorable_method_identity_signed!( self, verify_public_key_level_and_purpose, @@ -1973,6 +2038,81 @@ mod tests { // StateTransitionSigningOptions tests // ----------------------------------------------------------------------- + #[cfg(all(feature = "state-transition-signing", feature = "bls-signatures"))] + #[test] + fn should_enforce_scope_before_private_key_signing() { + use crate::consensus::signature::{ScopedKeyNonBatchError, ScopedKeyOutOfScopeError}; + use crate::identity::contract_bounds::authentication_scope::{ + permissions, AuthenticationScope, AuthenticationScopeV0, ContractScope, + }; + 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; + let scope = AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([2; 32]), + document_types: Some(vec!["preorder".to_string()]), + }], + permissions: permissions::DOCUMENT_DELETE, + expires_at: None, + }; + 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::Scoped(AuthenticationScope::V0( + scope.clone(), + ))), + ..Default::default() + }; + sample_batch_st_with_delete() + .sign(&key.clone().into(), &private_key, &bls) + .expect("allowed document delete must sign"); + + let err = sample_transfer_st() + .sign(&key.clone().into(), &private_key, &bls) + .unwrap_err(); + assert!(matches!(err, ProtocolError::ConsensusError(error) + if *error == ScopedKeyNonBatchError::new(key.id).into())); + + for mismatch in ["contract", "document type", "operation"] { + let mut denied = scope.clone(); + match mismatch { + "contract" => denied.contracts[0].id = Identifier::from([3; 32]), + "document type" => denied.contracts[0].document_types = Some(vec!["other".into()]), + "operation" => denied.permissions = permissions::DOCUMENT_CREATE, + _ => unreachable!(), + } + key.contract_bounds = Some(ContractBounds::Scoped(AuthenticationScope::V0(denied))); + 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 == ScopedKeyOutOfScopeError::new(key.id).into()), + "{mismatch}" + ); + 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("unscoped keys must still sign"); + } + #[test] fn test_signing_options_default() { let opts = StateTransitionSigningOptions::default(); @@ -2324,6 +2464,86 @@ mod tests { assert_eq!(original, restored); } + #[test] + fn should_reject_scoped_registration_during_decoding_before_activation() { + use crate::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractBounds, ContractScope, + }; + use crate::serialization::PlatformSerializable; + use crate::state_transition::identity_create_from_addresses_transition::v0::IdentityCreateFromAddressesTransitionV0; + use crate::state_transition::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; + use crate::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; + + let key = IdentityPublicKeyInCreationV0 { + contract_bounds: Some(ContractBounds::Scoped(AuthenticationScope::V0( + AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([1; 32]), + document_types: None, + }], + permissions: permissions::DOCUMENT_CREATE, + expires_at: None, + }, + ))), + ..Default::default() + }; + let transitions = [ + StateTransition::IdentityCreate(IdentityCreateTransition::V0( + IdentityCreateTransitionV0 { + public_keys: vec![key.clone().into()], + ..Default::default() + }, + )), + StateTransition::IdentityCreateFromAddresses( + IdentityCreateFromAddressesTransition::V0( + IdentityCreateFromAddressesTransitionV0 { + public_keys: vec![key.clone().into()], + ..Default::default() + }, + ), + ), + StateTransition::IdentityCreateFromShieldedPool( + IdentityCreateFromShieldedPoolTransition::V0( + IdentityCreateFromShieldedPoolTransitionV0 { + public_keys: vec![key.clone().into()], + denomination: 0, + actions: vec![], + anchor: [0; 32], + proof: vec![], + binding_signature: [0; 64], + send_to_address_on_creation_failure: Default::default(), + identity_id: Identifier::from([0; 32]), + }, + ), + ), + StateTransition::IdentityUpdate(IdentityUpdateTransition::V0( + IdentityUpdateTransitionV0 { + add_public_keys: vec![key.into()], + ..Default::default() + }, + )), + ]; + for transition in transitions { + let bytes = transition.serialize_to_bytes().unwrap(); + assert!(matches!( + StateTransition::deserialize_from_bytes_in_version( + &bytes, + PlatformVersion::get(13).unwrap() + ), + Err(ProtocolError::PlatformDeserializationError(_)) + )); + assert_eq!( + StateTransition::deserialize_from_bytes_in_version( + &bytes, + PlatformVersion::get(14).unwrap() + ) + .unwrap(), + transition + ); + } + } + #[test] fn test_transaction_id_is_deterministic() { let st = sample_transfer_st(); diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/mod.rs index e002fffb270..34b5cb00d57 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/mod.rs @@ -4,6 +4,7 @@ use crate::ProtocolError; use platform_version::version::PlatformVersion; pub mod v0; +pub mod v1; impl IdentityPublicKeyInCreation { pub fn validate_identity_public_keys_structure( @@ -22,10 +23,15 @@ impl IdentityPublicKeyInCreation { in_create_identity, platform_version, ), + 1 => Self::validate_identity_public_keys_structure_v1( + identity_public_keys_with_witness, + in_create_identity, + platform_version, + ), version => Err(ProtocolError::UnknownVersionMismatch { method: "IdentityPublicKeyInCreation::validate_identity_public_keys_structure" .to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v0/mod.rs index 6b02b2e6454..fd2f3fdd29f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v0/mod.rs @@ -37,11 +37,34 @@ lazy_static! { }; } impl IdentityPublicKeyInCreation { + /// New binaries can decode Scoped even during historical replay. Reject only + /// that newly representable input; all historical validation remains unchanged. + pub(super) fn validate_identity_public_keys_structure_v0( + keys: &[IdentityPublicKeyInCreation], + in_create_identity: bool, + version: &PlatformVersion, + ) -> Result { + if keys.iter().any(|key| { + matches!( + key.contract_bounds(), + Some(crate::identity::contract_bounds::ContractBounds::Scoped(_)) + ) + }) { + return Ok(SimpleConsensusValidationResult::new_with_error( + crate::consensus::basic::identity::InvalidAuthenticationScopeError::new( + "scoped authentication keys are not activated".into(), + ) + .into(), + )); + } + Self::validate_identity_public_keys_structure_common(keys, in_create_identity, version) + } + /// This validation will validate the count of new keys, that there are no duplicates either by /// id or by data. This is done before signature and state validation to remove potential /// attack vectors. #[inline(always)] - pub(super) fn validate_identity_public_keys_structure_v0( + pub(super) fn validate_identity_public_keys_structure_common( identity_public_keys_with_witness: &[IdentityPublicKeyInCreation], in_create_identity: bool, platform_version: &PlatformVersion, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs new file mode 100644 index 00000000000..192fe3ea569 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs @@ -0,0 +1,32 @@ +use crate::consensus::basic::identity::InvalidAuthenticationScopeError; +use crate::identity::{contract_bounds::ContractBounds, Purpose, SecurityLevel}; +use crate::state_transition::public_key_in_creation::{ + accessors::IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreation, +}; +use crate::{validation::SimpleConsensusValidationResult, version::PlatformVersion, ProtocolError}; + +impl IdentityPublicKeyInCreation { + pub(super) fn validate_identity_public_keys_structure_v1( + keys: &[Self], + in_create_identity: bool, + version: &PlatformVersion, + ) -> Result { + for key in keys { + if let Some(ContractBounds::Scoped(scope)) = key.contract_bounds() { + let reason = if key.purpose() != Purpose::AUTHENTICATION + || key.security_level() == SecurityLevel::MASTER + { + Some("scoped keys must be non-MASTER authentication keys".to_owned()) + } else { + scope.validate().err().map(|error| error.to_string()) + }; + if let Some(reason) = reason { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidAuthenticationScopeError::new(reason).into(), + )); + } + } + } + Self::validate_identity_public_keys_structure_common(keys, in_create_identity, version) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs index 2711d97287c..df8da81456a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs @@ -194,6 +194,7 @@ pub(super) fn state_transition_to_execution_event_for_check_tx_v0<'a, C: CoreRPC let result = if state_transition.validates_signature_based_on_identity_info() { state_transition.validate_identity_signed_state_transition( platform.drive, + platform.state.last_block_info().time_ms, None, &mut state_transition_execution_context, platform_version, 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..2daf5d145d1 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,17 +13,20 @@ 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], drive: &Drive, epoch: &Epoch, + time_ms: u64, transaction: TransactionArg, execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, @@ -55,9 +58,19 @@ 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, + time_ms, + 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 +451,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 +466,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" ); @@ -487,6 +500,7 @@ mod tests { &[key], &platform.drive, &epoch, + 0, None, &mut execution_context, platform_version, @@ -511,4 +525,78 @@ mod tests { billed_count ); } + #[test] + fn should_validate_scoped_registration_against_contracts_and_executing_time() { + use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractScope, + }; + 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(); + 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", + "expired", + "missing_type", + "missing_contract", + "wrong_purpose", + "master", + ] { + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: if case == "missing_contract" { + Identifier::from([42; 32]) + } else { + contract.id() + }, + document_types: Some(vec![if case == "missing_type" { + "absent".into() + } else { + "note".into() + }]), + }], + permissions: permissions::DOCUMENT_CREATE | permissions::DOCUMENT_TOKEN_PAYMENT, + expires_at: Some(if case == "expired" { 100 } else { 101 }), + }); + let mut key = make_decryption_key_bound_to_doc_type(contract.id(), "note".into()); + key.set_contract_bounds(Some(ContractBounds::Scoped(scope))); + 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(), + 100, + None, + &mut context, + version, + ) + .unwrap(); + assert_eq!( + result.is_valid(), + case == "valid", + "{case}: {:?}", + result.errors + ); + } + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v0/mod.rs index 03407cf94d0..d9206f54644 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v0/mod.rs @@ -62,6 +62,12 @@ fn validate_identity_public_key_contract_bounds_v0( let purpose = identity_public_key_in_creation.purpose(); if let Some(contract_bounds) = identity_public_key_in_creation.contract_bounds() { match contract_bounds { + ContractBounds::Scoped(_) => Ok(SimpleConsensusValidationResult::new_with_error( + dpp::consensus::basic::identity::InvalidAuthenticationScopeError::new( + "scope is not activated".into(), + ) + .into(), + )), ContractBounds::SingleContract { id: contract_id } => { // we should fetch the contract let contract = drive.get_contract_with_fetch_info( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs index 57a8c428d9d..b6306b927e3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs @@ -91,6 +91,14 @@ fn validate_identity_public_key_contract_bounds_v1( let contract_id = match contract_bounds { ContractBounds::SingleContract { id } => *id, ContractBounds::SingleContractDocumentType { id, .. } => *id, + ContractBounds::Scoped(_) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + dpp::consensus::basic::identity::InvalidAuthenticationScopeError::new( + "scope is not activated".into(), + ) + .into(), + )) + } }; let outcome = drive.get_system_or_user_contract_with_fee( contract_id.to_buffer(), @@ -110,6 +118,7 @@ fn validate_identity_public_key_contract_bounds_v1( }; match contract_bounds { + ContractBounds::Scoped(_) => unreachable!("rejected above"), ContractBounds::SingleContract { .. } => { let requirements_for_purpose = match purpose { ENCRYPTION => contract.config().requires_identity_encryption_bounded_key(), 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..a5a2375742d --- /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,85 @@ +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}, + identity::InvalidAuthenticationScopeError, +}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::identifier::Identifier; +use dpp::identity::{contract_bounds::ContractBounds, 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 adds authentication delegations; encryption/decryption use unchanged v1 rules. +#[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, + time_ms: u64, + transaction: TransactionArg, + context: &mut StateTransitionExecutionContext, + version: &PlatformVersion, +) -> Result { + let mut result = SimpleConsensusValidationResult::default(); + for key in keys { + let Some(ContractBounds::Scoped(scope)) = key.contract_bounds() else { + 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.purpose() != Purpose::AUTHENTICATION + || key.security_level() == SecurityLevel::MASTER + || scope.validate().is_err() + || scope.is_expired(time_ms) + { + result.add_error(InvalidAuthenticationScopeError::new( + "scope must be valid, unexpired and attached to a non-MASTER authentication key" + .into(), + )); + continue; + } + for entry in scope.contracts() { + let outcome = drive.get_system_or_user_contract_with_fee( + entry.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(entry.id)); + continue; + }; + if let Some(names) = &entry.document_types { + for name in names { + if contract.document_type_optional_for_name(name).is_none() { + result.add_error(InvalidDocumentTypeError::new(name.clone(), entry.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..7fe2ef877d9 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,11 +10,15 @@ 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, + time_ms: u64, request_balance: bool, request_revision: bool, transaction: TransactionArg, @@ -27,6 +31,7 @@ impl ValidateStateTransitionIdentitySignature for StateTransition { fn validate_state_transition_identity_signed( &self, drive: &Drive, + time_ms: u64, request_balance: bool, request_revision: bool, transaction: TransactionArg, @@ -48,9 +53,18 @@ impl ValidateStateTransitionIdentitySignature for StateTransition { execution_context, platform_version, ), + 1 => self.validate_state_transition_identity_signed_v1( + drive, + time_ms, + 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..c1ccfb9678e --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v1/mod.rs @@ -0,0 +1,63 @@ +use dpp::identity::PartialIdentity; +use dpp::state_transition::StateTransition; +use dpp::validation::ConsensusValidationResult; +use dpp::version::{PlatformVersion}; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +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; + +pub(super) trait ValidateStateTransitionIdentitySignatureV1 { + #[allow(clippy::too_many_arguments)] // Keep explicit versioned validation inputs. + fn validate_state_transition_identity_signed_v1( + &self, + drive: &Drive, + time_ms: u64, + request_balance: bool, + request_revision: bool, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} +impl ValidateStateTransitionIdentitySignatureV1 for StateTransition { + fn validate_state_transition_identity_signed_v1( + &self, + drive: &Drive, + time_ms: u64, + request_balance: bool, + request_revision: bool, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + use dpp::identity::contract_bounds::ContractBounds; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + 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()) { + for key in identity.loaded_public_keys.values() { + if let Some(ContractBounds::Scoped(scope)) = key.contract_bounds() { + if scope.is_expired(time_ms) { + return Ok(ConsensusValidationResult::new_with_error( + dpp::consensus::signature::ScopedKeyExpiredError::new(key.id()).into(), + )); + } + if !matches!(self, StateTransition::Batch(_)) { + return Ok(ConsensusValidationResult::new_with_error( + dpp::consensus::signature::ScopedKeyNonBatchError::new(key.id()).into(), + )); + } + } + } + } + Ok(result) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs index bdf5da140d3..00cf466ebda 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs @@ -30,6 +30,7 @@ pub(crate) trait StateTransitionIdentityBasedSignatureValidationV0 { fn validate_identity_signed_state_transition( &self, drive: &Drive, + time_ms: u64, tx: TransactionArg, execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, @@ -55,6 +56,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { fn validate_identity_signed_state_transition( &self, drive: &Drive, + time_ms: u64, tx: TransactionArg, execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, @@ -68,6 +70,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { //Basic signature verification Ok(self.validate_state_transition_identity_signed( drive, + time_ms, true, false, tx, @@ -79,6 +82,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { let mut consensus_validation_result = self .validate_state_transition_identity_signed( drive, + time_ms, true, false, tx, @@ -102,6 +106,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { //Basic signature verification Ok(self.validate_state_transition_identity_signed( drive, + time_ms, true, true, tx, @@ -117,6 +122,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { Ok(self.validate_state_transition_identity_signed( drive, + time_ms, false, false, tx, 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 6d881a6d248..c8d9a62df47 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, ) @@ -163,6 +164,7 @@ impl StateTransitionStateValidation for StateTransition { st.validate_state_for_identity_create_from_addresses_transition( action, platform, + block_info, execution_context, tx, ) @@ -247,6 +249,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/processor/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs index eb5ca95d0d0..02c9ec024cd 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs @@ -59,6 +59,7 @@ pub(super) fn process_state_transition_v0<'a, C: CoreRPCLike>( let result = if state_transition.validates_signature_based_on_identity_info() { state_transition.validate_identity_signed_state_transition( platform.drive, + block_info.time_ms, transaction, &mut state_transition_execution_context, platform_version, 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..63f19d80971 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs @@ -0,0 +1,292 @@ +use crate::error::Error; +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()], + )); + } + + if let Some(dpp::identity::contract_bounds::ContractBounds::Scoped(scope)) = + signing_key.contract_bounds() + { + use dpp::identity::contract_bounds::authentication_scope::permissions; + use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; + let unauthorized = self + .transitions_iter() + .any(|member| !scope.allows_transition(member)); + let unauthorized_payment = !scope.allows(permissions::DOCUMENT_TOKEN_PAYMENT) + && action.transitions().iter().any(|member| { + matches!(member, BatchedTransitionAction::DocumentAction(doc) + if doc.base().token_cost().is_some_and(|(_, _, amount)| amount > 0)) + }); + if unauthorized || unauthorized_payment { + 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![dpp::consensus::signature::ScopedKeyOutOfScopeError::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/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/mod.rs index 41ddaad8029..812742bc8e3 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 scoped_auth; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/scoped_auth.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/scoped_auth.rs new file mode 100644 index 00000000000..406334e1fda --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/scoped_auth.rs @@ -0,0 +1,611 @@ +use super::*; +use crate::execution::validation::state_transition::tests::setup_identity_without_adding_it; +use dpp::consensus::codes::ErrorWithCode; +use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, ContractBounds, + ContractScope, +}; +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 scoped key stored in Drive. +#[tokio::test] +async fn should_enforce_scoped_auth_in_execution_and_preserve_paid_failure_nonces() { + for case in [ + "allowed", + "wrong_contract", + "wrong_action", + "expired", + "disabled", + "wrong_type", + "multi_contract", + "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 mut contracts = vec![ContractScope { + id: if case == "wrong_contract" { + dpns.id() + } else { + dashpay.id() + }, + document_types: if case == "wrong_type" { + Some(vec!["contactRequest".into()]) + } else if case == "mixed" { + Some(vec!["profile".into()]) + } else { + None + }, + }]; + if case == "multi_contract" { + contracts.push(ContractScope { + id: dpns.id(), + document_types: None, + }); + contracts.sort_by_key(|c| c.id); + } + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + contracts, + permissions: if case == "wrong_action" { + permissions::DOCUMENT_DELETE + } else { + permissions::DOCUMENT_CREATE + }, + expires_at: Some(if case == "expired" { 100 } else { 101 }), + }); + let mut stored_key = signing_key.clone(); + let IdentityPublicKey::V0(ref mut key) = stored_key; + key.contract_bounds = Some(ContractBounds::Scoped(scope)); + 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 scope 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" | "multi_contract" => assert!( + matches!( + execution, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ), + "{case}: {execution:?}" + ), + "disabled" => assert!( + matches!( + execution, + StateTransitionExecutionResult::UnpaidConsensusError(_) + ), + "{execution:?}" + ), + "expired" => assert!( + matches!(execution, StateTransitionExecutionResult::UnpaidConsensusError(error) if error.code() == 20014), + "{execution:?}" + ), + _ => { + assert!( + matches!(execution, StateTransitionExecutionResult::PaidConsensusError { error, .. } if error.code() == 20015), + "{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_require_token_payment_permission_including_external_fee_tokens() { + use crate::execution::validation::state_transition::tests::{ + create_card_game_external_token_contract_with_owner_identity, + create_token_contract_with_owner_identity, + }; + use dpp::data_contract::TokenConfiguration; + use dpp::tokens::{ + gas_fees_paid_by::GasFeesPaidBy, + token_payment_info::{v0::TokenPaymentInfoV0, TokenPaymentInfo}, + }; + for allow_payment in [false, true] { + 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 (token_contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + owner.id(), + None::, + None, + None, + None, + version, + ); + let contract = create_card_game_external_token_contract_with_owner_identity( + &mut platform, + token_contract.id(), + 0, + 5, + GasFeesPaidBy::DocumentOwner, + owner.id(), + version, + ); + let (mut identity, signer, signing_key) = + setup_identity_without_adding_it(234, dash_to_credits!(0.1)); + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + // Intentionally does not include the external token's issuing contract. + contracts: vec![ContractScope { + id: contract.id(), + document_types: Some(vec!["card".into()]), + }], + permissions: permissions::DOCUMENT_CREATE + | if allow_payment { + permissions::DOCUMENT_TOKEN_PAYMENT + } else { + 0 + }, + expires_at: None, + }); + let mut stored_key = signing_key.clone(); + let IdentityPublicKey::V0(ref mut key) = stored_key; + key.contract_bounds = Some(ContractBounds::Scoped(scope)); + 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 card = contract.document_type_for_name("card").unwrap(); + let mut rng = StdRng::seed_from_u64(433); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = card + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + version, + ) + .unwrap(); + document.set("attack", 4.into()); + document.set("defense", 7.into()); + let batch = BatchTransition::new_document_creation_transition_from_document( + document, + card, + entropy.0, + &signing_key, + 2, + 0, + Some(TokenPaymentInfo::V0(TokenPaymentInfoV0 { + payment_token_contract_id: Some(token_contract.id()), + token_contract_position: 0, + minimum_token_cost: None, + maximum_token_cost: Some(5), + gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, + })), + &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 allow_payment { + assert!( + matches!( + execution, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ), + "{execution:?}" + ); + } else { + assert!( + matches!(execution, StateTransitionExecutionResult::PaidConsensusError { error, .. } if error.code() == 20015), + "{execution:?}" + ); + } + let remaining = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + Some(&tx), + version, + ) + .unwrap(); + assert_eq!(remaining, Some(if allow_payment { 10 } else { 15 })); + } +} + +#[tokio::test] +async fn should_reject_non_batch_use_even_with_all_scope_permissions() { + 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::Scoped(AuthenticationScope::V0( + AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: dashpay.id(), + document_types: None, + }], + permissions: permissions::ALL, + expires_at: None, + }, + ))); + 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_distinguish_scoped_document_token_fees_from_standalone_token_transfers() { + use crate::execution::validation::state_transition::tests::create_token_contract_with_owner_identity; + use dpp::data_contract::TokenConfiguration; + for allow_transfer in [false, true] { + 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 (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::Scoped(AuthenticationScope::V0( + AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: contract.id(), + document_types: None, + }], + permissions: permissions::DOCUMENT_TOKEN_PAYMENT + | if allow_transfer { + permissions::TOKEN_TRANSFER + } else { + 0 + }, + expires_at: None, + }, + ))); + 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 allow_transfer { + assert!( + matches!( + execution, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ), + "{execution:?}" + ); + } else { + assert!( + matches!(execution, StateTransitionExecutionResult::PaidConsensusError { error, .. } if error.code() == 20015), + "{execution:?}" + ); + } + assert_eq!( + platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + Some(&tx), + version + ) + .unwrap(), + Some(if allow_transfer { 10 } else { 15 }) + ); + } +} 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..b5baac3b743 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,205 @@ mod tests { assert_eq!(identity_balance, 99913867460); } + #[tokio::test] + async fn should_create_identity_with_scoped_authentication_key() { + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + for (protocol, bounds_kind) in [(13, 0), (13, 1), (13, 2), (14, 0)] { + 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::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractBounds, ContractScope, + }; + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(platform_version) + .unwrap(); + let scoped_bounds = + ContractBounds::Scoped(AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: dashpay.id(), + document_types: Some(vec!["profile".into()]), + }], + permissions: permissions::DOCUMENT_CREATE, + expires_at: Some(100), + })); + let bounds = match bounds_kind { + 0 => scoped_bounds, + 1 => ContractBounds::SingleContract { id: dashpay.id() }, + _ => ContractBounds::SingleContractDocumentType { + id: dashpay.id(), + document_type_name: "profile".into(), + }, + }; + 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 { + use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; + if bounds_kind == 0 { + assert_eq!(processing_result.invalid_unpaid_count(), 1); + assert_eq!(processing_result.invalid_paid_count(), 0); + assert_eq!( + platform + .drive + .grove + .root_hash(Some(&transaction), &platform_version.drive.grove_version) + .unwrap() + .unwrap(), + before + ); + } else { + 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..5eba2932946 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/v1/mod.rs @@ -0,0 +1,115 @@ +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, + block_info.time_ms, + 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..649dfd7ab8c --- /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,117 @@ +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, + block_info.time_ms, + 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..c05fdf2ad53 --- /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,149 @@ +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, + block_info.time_ms, + 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..3c6e08fa081 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,135 @@ fn build_success_action( } } +#[test] +fn should_validate_scoped_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::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractBounds, ContractScope, + }; + + 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, time_ms, error_code) in [ + ("valid", contract.id(), "contactRequest", 99, None), + ("expired", contract.id(), "contactRequest", 100, Some(10535)), + ( + "unknown contract", + Identifier::from([0x71; 32]), + "contactRequest", + 99, + Some(10400), + ), + ( + "unknown document", + contract.id(), + "missing", + 99, + Some(10406), + ), + ] { + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id, + document_types: Some(vec![document_type.into()]), + }], + permissions: permissions::DOCUMENT_CREATE, + expires_at: Some(100), + }); + let key = IdentityPublicKeyInCreationV0 { + id: 1, + key_type: KeyType::ECDSA_HASH160, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: Some(ContractBounds::Scoped(scope)), + 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, + ..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..070ebd28c9c 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,559 @@ 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_scoped_authentication_key_and_preserve_proof_metadata() { + let platform_version = PlatformVersion::latest(); + + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + + let (identity, signer, _, key) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + + use dpp::data_contract::accessors::v0::DataContractV0Setters; + use dpp::data_contract::factory::DataContractFactory; + use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractScope, + }; + use dpp::platform_value::{platform_value, Value}; + let schemas = Value::Map((0..16).map(|n| ( + Value::Text(format!("t{n:02}")), + platform_value!({"type": "object", "properties": {"text": {"type": "string", "maxLength": 64, "position": 0}}, "additionalProperties": false}), + )).collect()); + let factory = DataContractFactory::new(platform_version.protocol_version).unwrap(); + let template = factory + .create_with_value_config(identity.id(), 1, schemas, None, None) + .unwrap() + .data_contract_owned(); + let mut contracts = Vec::new(); + for id in 1..=16 { + let mut contract = template.clone(); + contract.set_id(Identifier::from([id; 32])); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + None, + None, + platform_version, + ) + .unwrap(); + contracts.push(ContractScope { + id: contract.id(), + document_types: Some((0..16).map(|n| format!("t{n:02}")).collect()), + }); + } + let first_contract = contracts[0].id; + let bounds = ContractBounds::Scoped(AuthenticationScope::V0(AuthenticationScopeV0 { + contracts, + permissions: permissions::DOCUMENT_CREATE | permissions::DOCUMENT_TOKEN_PAYMENT, + expires_at: Some(100), + })); + 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 update_transition: IdentityUpdateTransition = IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 1, + nonce: 1, + add_public_keys: vec![IdentityPublicKeyInCreation::V0(new_key.clone())], + disable_public_keys: vec![], + user_fee_increase: 0, + signature_public_key_id: key.id(), + signature: Default::default(), + } + .into(); + + let update_transition: StateTransition = update_transition.into(); + + let signable_bytes = update_transition + .signable_bytes() + .expect("expected signable bytes"); + + let secret = new_key_pair.secret_key(); + let signature = + signer::sign(&signable_bytes, &secret.secret_bytes()).expect("expected to sign"); + + new_key.signature = signature.to_vec().into(); + + let update_transition: IdentityUpdateTransition = 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 mut update_transition: StateTransition = update_transition.into(); + + update_transition.set_signature( + signer + .sign(&key, signable_bytes.as_slice()) + .await + .expect("expected to sign"), + ); + + let update_transition_bytes = update_transition + .serialize_to_bytes() + .expect("expected to serialize"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![update_transition_bytes.clone()], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + true, + None, + ) + .expect("expected to process state transition"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let 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(document) = verification_result + else { + panic!( + "verification_result expected partial identity, but got: {:?}", + verification_result + ); + }; + assert_eq!( + document + .loaded_public_keys + .get(&2) + .unwrap() + .contract_bounds(), + Some(&bounds) + ); + use drive::drive::identity::key::fetch::{ + IdentityKeysRequest, KeyKindRequestType, KeyRequestType, + }; + let indexed = platform + .drive + .fetch_identity_keys_as_partial_identity( + IdentityKeysRequest { + identity_id: identity.id().to_buffer(), + request_type: KeyRequestType::ContractDocumentTypeBoundKey( + first_contract.to_buffer(), + "t00".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) + ); + 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_scoped_key_reference_after_revocation() { + use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractScope, + }; + 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 mut contracts = vec![ + ContractScope { + id: dashpay.id(), + document_types: None, + }, + ContractScope { + id: dpns.id(), + document_types: Some(vec!["preorder".into()]), + }, + ]; + contracts.sort_by_key(|entry| entry.id); + let bounds = ContractBounds::Scoped(AuthenticationScope::V0(AuthenticationScopeV0 { + contracts, + permissions: permissions::DOCUMENT_CREATE, + expires_at: None, + })); + let key = setup_add_key_to_identity( + &mut platform, + &mut identity, + &mut signer, + 4, + 2, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + KeyType::ECDSA_SECP256K1, + Some(bounds.clone()), + ); + let mut update: StateTransition = + IdentityUpdateTransition::from(IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 1, + nonce: 1, + add_public_keys: vec![], + disable_public_keys: vec![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 state = platform.state.load(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![update.serialize_to_bytes().unwrap()], + &state, + &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 key, not its old hash. + for request_type in [ + KeyRequestType::ContractBoundKey( + dashpay.id().to_buffer(), + Purpose::AUTHENTICATION, + KeyKindRequestType::CurrentKeyOfKindRequest, + ), + 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("scoped 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 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/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v0/mod.rs index 92f7c7216d3..fbd5029d131 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v0/mod.rs @@ -102,6 +102,7 @@ impl IdentityUpdateStateTransitionStateValidationV0 for IdentityUpdateTransition self.public_keys_to_add(), drive, platform.state.last_committed_block_epoch_ref(), + 0, tx, &mut state_transition_execution_context, platform_version, 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..ed05f65c20f --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v1/mod.rs @@ -0,0 +1,163 @@ +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, + block_info.time_ms, + 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/v0/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v0/mod.rs index a8b2a949f83..399403079b4 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v0/mod.rs @@ -59,7 +59,7 @@ impl Drive { self.add_contract_info_operations_v0( identity_id, epoch, - vec![contract_apply_info], + contract_apply_info, estimated_costs_only_with_layer_info, transaction, drive_operations, 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..d79976be56b --- /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,541 @@ +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, + 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); + + self.batch_insert( + PathKeyElementInfo::<0>::PathKeyElement(( + if purpose == Purpose::AUTHENTICATION { + // Scoped authentication'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) + }, + 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); + + 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(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..bcab1161423 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 @@ -68,8 +68,41 @@ impl IdentityDataContractKeyApplyInfo { transaction: TransactionArg, drive_operations: &mut Vec, platform_version: &PlatformVersion, - ) -> Result { - let contract_id = contract_bounds.identifier().to_buffer(); + ) -> Result, Error> { + if let ContractBounds::Scoped(scope) = contract_bounds { + return Ok(scope + .contracts() + .iter() + .map(|entry| { + let contract_id = entry.id; + let document_type_keys = entry + .document_types + .as_ref() + .map(|names| { + names + .iter() + .map(|name| (name.clone(), vec![(key_id, purpose)])) + .collect() + }) + .unwrap_or_default(); + ContractBased { + contract_id, + document_type_keys, + contract_keys: if entry.document_types.is_none() { + vec![(key_id, purpose)] + } else { + vec![] + }, + } + }) + .collect()); + } + let contract_id = contract_bounds + .identifier() + .ok_or(Error::Identity(IdentityError::IdentityKeyBoundsError( + "expected single contract bounds", + )))? + .to_buffer(); // we are getting with fetch info to add the cost to the drive operations let maybe_contract_fetch_info = drive.get_contract_with_fetch_info_and_add_to_operations( contract_id, @@ -86,28 +119,26 @@ impl IdentityDataContractKeyApplyInfo { }; let contract = &contract_fetch_info.contract; match contract_bounds { - ContractBounds::SingleContract { .. } => Ok(ContractBased { + ContractBounds::SingleContract { .. } => Ok(vec![ContractBased { contract_id: contract.id(), document_type_keys: Default::default(), contract_keys: vec![(key_id, purpose)], - }), + }]), ContractBounds::SingleContractDocumentType { document_type_name: document_type, .. } => { let document_type = contract.document_type_for_name(document_type)?; - Ok(ContractBased { + Ok(vec![ContractBased { contract_id: contract.id(), document_type_keys: BTreeMap::from([( document_type.name().clone(), vec![(key_id, purpose)], )]), contract_keys: vec![], - }) - } // ContractBounds::MultipleContractsOfSameOwner { .. } => Ok(ContractFamilyBased { - // contracts_owner_id: contract.owner_id(), - // family_keys: vec![key_id], - // }), + }]) + } + ContractBounds::Scoped(_) => unreachable!("handled above"), } } } diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v0/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v0/mod.rs index eddd0c9f029..c23fcbb62a0 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v0/mod.rs @@ -53,7 +53,7 @@ impl Drive { self.refresh_contract_info_operations_v0( identity_id, epoch, - vec![contract_apply_info], + contract_apply_info, estimated_costs_only_with_layer_info, transaction, drive_operations, @@ -145,6 +145,9 @@ impl Drive { 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 @@ -203,7 +206,17 @@ impl Drive { let sibling_ref_type_path = SiblingReference(key_id_bytes); self.batch_refresh_reference( - identity_contract_info_group_keys_path_vec(&identity_id, &root_id), + if purpose == Purpose::AUTHENTICATION { + // Scoped authentication'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) + }, vec![], Element::Reference(sibling_ref_type_path, Some(2), None), true, @@ -260,6 +273,9 @@ impl Drive { let storage_key_requirements = contract .as_ref() .map(|contract| match purpose { + Purpose::AUTHENTICATION => { + Ok(StorageKeyRequirements::MultipleReferenceToLatest) + } Purpose::ENCRYPTION => { let document_type = contract .contract diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v3.rs index e2cc9994d40..42ebdb98760 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v3.rs @@ -2,11 +2,12 @@ use crate::version::dpp_versions::dpp_method_versions::DPPMethodVersions; /// DPP method versions 3. Introduced in protocol v14: `daily_withdrawal_limit` 1 → 2 replaces the /// flat daily withdrawal limit with a percentage of the total credits Platform held a day ago -/// (`SystemLimits::daily_withdrawal_limit_percent`). Everything else matches V2. +/// (`SystemLimits::daily_withdrawal_limit_percent`). `shielded_extra_sighash_data` 0 → 1 +/// binds contract-scoped authentication keys in shielded identity creation. Everything else matches V2. pub const DPP_METHOD_VERSIONS_V3: DPPMethodVersions = DPPMethodVersions { epoch_core_reward_credits_for_distribution: 0, daily_withdrawal_limit: 2, deduct_fee_from_outputs_or_remaining_balance_of_inputs: 0, compute_minimum_shielded_fee: 0, - shielded_extra_sighash_data: 0, + shielded_extra_sighash_data: 1, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/mod.rs index ad2a16e431e..fe703b81fd6 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/mod.rs @@ -1,6 +1,7 @@ use versioned_feature_core::FeatureVersion; pub mod v1; +pub mod v2; #[derive(Clone, Debug, Default)] pub struct DPPStateTransitionMethodVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/v2.rs new file mode 100644 index 00000000000..2f53cf9bfd6 --- /dev/null +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/v2.rs @@ -0,0 +1,15 @@ +use crate::version::dpp_versions::dpp_state_transition_method_versions::{ + DPPStateTransitionMethodVersions, PublicKeyInCreationMethodVersions, +}; + +pub const STATE_TRANSITION_METHOD_VERSIONS_V2: DPPStateTransitionMethodVersions = + DPPStateTransitionMethodVersions { + public_key_in_creation_methods: PublicKeyInCreationMethodVersions { + from_public_key_signed_with_private_key: 0, + from_public_key_signed_external: 0, + hash: 0, + duplicated_key_ids_witness: 0, + duplicated_keys_witness: 0, + validate_identity_public_keys_structure: 1, + }, + }; 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 96aef2ddb79..728ecbf8b43 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 @@ -22,10 +22,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, @@ -37,7 +37,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 { @@ -45,7 +45,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 { @@ -113,7 +113,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 @@ -230,7 +230,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: @@ -312,7 +312,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, }, }, 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..95b05a0102c 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,11 @@ 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: +/// supports scoped authentication-key references. V0 preserves the historical +/// rejection of authentication keys with legacy contract bounds before v14. /// * `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 @@ -141,7 +144,7 @@ pub const DRIVE_IDENTITY_METHOD_VERSIONS_V2: DriveIdentityMethodVersions = add_new_identity: 0, }, contract_info: DriveIdentityContractInfoMethodVersions { - add_potential_contract_info_for_contract_bounded_key: 0, + add_potential_contract_info_for_contract_bounded_key: 1, refresh_potential_contract_info_key_references: 0, merge_identity_contract_nonce: 0, }, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 7589c485738..bd4885043dd 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -7,7 +7,7 @@ use crate::version::dpp_versions::dpp_factory_versions::v1::DPP_FACTORY_VERSIONS use crate::version::dpp_versions::dpp_identity_versions::v1::IDENTITY_VERSIONS_V1; use crate::version::dpp_versions::dpp_method_versions::v3::DPP_METHOD_VERSIONS_V3; use crate::version::dpp_versions::dpp_state_transition_conversion_versions::v2::STATE_TRANSITION_CONVERSION_VERSIONS_V2; -use crate::version::dpp_versions::dpp_state_transition_method_versions::v1::STATE_TRANSITION_METHOD_VERSIONS_V1; +use crate::version::dpp_versions::dpp_state_transition_method_versions::v2::STATE_TRANSITION_METHOD_VERSIONS_V2; use crate::version::dpp_versions::dpp_state_transition_serialization_versions::v3::STATE_TRANSITION_SERIALIZATION_VERSIONS_V3; use crate::version::dpp_versions::dpp_state_transition_versions::v3::STATE_TRANSITION_VERSIONS_V3; use crate::version::dpp_versions::dpp_token_versions::v2::TOKEN_VERSIONS_V2; @@ -194,6 +194,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-scoped authentication keys activate through key-structure v1, bounds v2, +/// signature authorization v1 and batch advanced-structure v1. Identity creation +/// now validates key bounds, and identity-update state v1 retains validation fees. +/// Shielded identity creation binds scope metadata using extra-sighash-data v1. 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 @@ -210,7 +214,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { validation: DPP_VALIDATION_VERSIONS_V5, state_transition_serialization_versions: STATE_TRANSITION_SERIALIZATION_VERSIONS_V3, // changed: the indexOnly delete-by-values kind (documentIndexOnlyDelete) joins the wire state_transition_conversion_versions: STATE_TRANSITION_CONVERSION_VERSIONS_V2, - state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V1, + state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V2, state_transitions: STATE_TRANSITION_VERSIONS_V3, contract_versions: CONTRACT_VERSIONS_V6, // changed: v3 document meta-schema hosts the ranked, refersTo, requiredSince and timeRange keywords document_versions: DOCUMENT_VERSIONS_V4, // changed: document serialization format 3 — the contract version stamp that enables `requiredSince` properties diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index 2d0c526e034..fbc89290aa7 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -652,11 +652,41 @@ impl IdentityKeyEntryFFI { /// caller owns the heap-allocated `public_key_data_ptr` byte /// buffer and (when present) the /// `contract_bounds_document_type` C-string; release both via - /// [`free_identity_key_entry_ffi`]. - pub fn from_entry(entry: &IdentityKeyEntry) -> Self { + /// [`free_identity_key_entry_ffi`]. Scoped keys are rejected before any + /// allocation because this ABI cannot preserve their authorization bounds. + pub fn from_entry(entry: &IdentityKeyEntry) -> Result { use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::identity_public_key::contract_bounds::ContractBounds; + // Project the DPP `ContractBounds` enum into the kind / + // id / doc-type-cstring trio so the Swift side can switch + // on a single discriminant. Strings containing interior + // NULs (impossible in practice — DPP rejects them) keep + // the discriminant + payload self-consistent by falling + // back to `SingleContract { id }` (kind=1 + null doc-type + // pointer); emitting kind=2 with a null doc-type pointer + // would silently strip the bound on the Swift side, so + // demoting to `SingleContract` is the closest faithful + // representation — the document-type qualifier is the + // only thing lost, the contract id is preserved. + let (contract_bounds_kind, contract_bounds_id, contract_bounds_document_type) = match entry + .public_key + .contract_bounds() + { + Some(ContractBounds::SingleContract { id }) => (1u8, id.to_buffer(), ptr::null()), + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name, + }) => match CString::new(document_type_name.as_str()) { + Ok(c) => (2u8, id.to_buffer(), c.into_raw() as *const c_char), + Err(_) => (1u8, id.to_buffer(), ptr::null()), + }, + Some(ContractBounds::Scoped(_)) => { + return Err("scoped authentication keys require a newer native persistence ABI"); + } + None => (0u8, [0u8; 32], ptr::null()), + }; + let pk_bytes = entry.public_key.data().as_slice().to_vec(); let pk_len = pk_bytes.len(); let pk_boxed = pk_bytes.into_boxed_slice(); @@ -678,31 +708,7 @@ impl IdentityKeyEntryFFI { None => (false, 0, 0), }; - // Project the DPP `ContractBounds` enum into the kind / - // id / doc-type-cstring trio so the Swift side can switch - // on a single discriminant. Strings containing interior - // NULs (impossible in practice — DPP rejects them) keep - // the discriminant + payload self-consistent by falling - // back to `SingleContract { id }` (kind=1 + null doc-type - // pointer); emitting kind=2 with a null doc-type pointer - // would silently strip the bound on the Swift side, so - // demoting to `SingleContract` is the closest faithful - // representation — the document-type qualifier is the - // only thing lost, the contract id is preserved. - let (contract_bounds_kind, contract_bounds_id, contract_bounds_document_type) = - match entry.public_key.contract_bounds() { - Some(ContractBounds::SingleContract { id }) => (1u8, id.to_buffer(), ptr::null()), - Some(ContractBounds::SingleContractDocumentType { - id, - document_type_name, - }) => match CString::new(document_type_name.as_str()) { - Ok(c) => (2u8, id.to_buffer(), c.into_raw() as *const c_char), - Err(_) => (1u8, id.to_buffer(), ptr::null()), - }, - None => (0u8, [0u8; 32], ptr::null()), - }; - - Self { + Ok(Self { identity_id: entry.identity_id.to_buffer(), key_id: entry.key_id, purpose: entry.public_key.purpose() as u8, @@ -722,7 +728,7 @@ impl IdentityKeyEntryFFI { contract_bounds_kind, contract_bounds_id, contract_bounds_document_type, - } + }) } } @@ -1187,7 +1193,7 @@ mod tests { key_index: 5, }), }; - let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); + let mut ffi = IdentityKeyEntryFFI::from_entry(&entry).unwrap(); assert_eq!(ffi.identity_id, [2u8; 32]); assert_eq!(ffi.key_id, 5); assert_eq!(ffi.purpose, Purpose::AUTHENTICATION as u8); @@ -1229,7 +1235,7 @@ mod tests { wallet_id: None, derivation_indices: None, }; - let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); + let mut ffi = IdentityKeyEntryFFI::from_entry(&entry).unwrap(); assert!(!ffi.wallet_id_is_some); assert!(!ffi.derivation_indices_is_some); assert!(ffi.read_only); @@ -1240,6 +1246,70 @@ mod tests { unsafe { free_identity_key_entry_ffi(&mut ffi) }; } + #[test] + fn scoped_keys_are_rejected_before_native_persistence_callbacks() { + use crate::persistence::{FFIPersister, PersistenceCallbacks}; + use dpp::identity::contract_bounds::{ + AuthenticationScope, AuthenticationScopeV0, ContractBounds, ContractScope, + }; + use platform_wallet::changeset::PlatformWalletPersistence; + use platform_wallet::{IdentityKeysChangeSet, PlatformWalletChangeSet}; + use std::sync::atomic::{AtomicBool, Ordering}; + + unsafe extern "C" fn begin(context: *mut std::ffi::c_void, _: *const u8) -> i32 { + (*(context as *const AtomicBool)).store(true, Ordering::SeqCst); + 0 + } + + let entry = IdentityKeyEntry { + identity_id: Identifier::from([1; 32]), + key_id: 1, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: Some(ContractBounds::Scoped(AuthenticationScope::V0( + AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([2; 32]), + document_types: None, + }], + permissions: 1, + expires_at: None, + }, + ))), + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![2; 33]), + disabled_at: None, + }), + public_key_hash: [0; 20], + wallet_id: None, + derivation_indices: None, + }; + assert!(IdentityKeyEntryFFI::from_entry(&entry).is_err()); + + let began = AtomicBool::new(false); + let persister = FFIPersister::new(PersistenceCallbacks { + context: &began as *const AtomicBool as *mut std::ffi::c_void, + on_changeset_begin_fn: Some(begin), + ..Default::default() + }); + let changeset = PlatformWalletChangeSet { + identity_keys: Some(IdentityKeysChangeSet { + upserts: [((entry.identity_id, entry.key_id), entry)].into(), + ..Default::default() + }), + ..Default::default() + }; + assert!(persister.store([3; 32], changeset).is_err()); + assert!(!began.load(Ordering::SeqCst)); + assert!(persister + .store([3; 32], PlatformWalletChangeSet::default()) + .is_ok()); + assert!(began.load(Ordering::SeqCst)); + } + #[test] fn test_identity_key_entry_ffi_contract_bounds_single_contract() { use dpp::identity::identity_public_key::contract_bounds::ContractBounds; @@ -1262,7 +1332,7 @@ mod tests { wallet_id: None, derivation_indices: None, }; - let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); + let mut ffi = IdentityKeyEntryFFI::from_entry(&entry).unwrap(); assert_eq!(ffi.contract_bounds_kind, 1); assert_eq!(ffi.contract_bounds_id, [0xAB; 32]); assert!(ffi.contract_bounds_document_type.is_null()); @@ -1294,7 +1364,7 @@ mod tests { wallet_id: None, derivation_indices: None, }; - let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); + let mut ffi = IdentityKeyEntryFFI::from_entry(&entry).unwrap(); assert_eq!(ffi.contract_bounds_kind, 2); assert_eq!(ffi.contract_bounds_id, [0xCD; 32]); assert!(!ffi.contract_bounds_document_type.is_null()); diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index 4f3d9cae544..670d9630e6b 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -119,6 +119,10 @@ fn encode_contract_bounds( ), )), }, + Some(ContractBounds::Scoped(_)) => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "scoped authentication keys require a newer native inspection ABI", + )), None => Ok((0u8, [0u8; 32], ptr::null_mut())), } } diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 52148306238..4dae5e6fe4c 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1694,6 +1694,23 @@ impl PlatformWalletPersistence for FFIPersister { wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { + // The legacy native ABI cannot represent Scoped. Reject the whole + // round before any callback; never persist an unrestricted projection. + if let Some(keys) = &changeset.identity_keys { + use dpp::identity::contract_bounds::ContractBounds; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + if keys.upserts.values().any(|entry| { + matches!( + entry.public_key.contract_bounds(), + Some(ContractBounds::Scoped(_)) + ) + }) { + return Err(PersistenceError::backend( + "scoped authentication keys require a newer native persistence ABI", + )); + } + } + // Serialize the ENTIRE begin→per-kind→end round against every // other round producer (see `round_lock`'s field doc and // dashpay/platform#4069). The lock is a synchronous @@ -2131,11 +2148,10 @@ impl PlatformWalletPersistence for FFIPersister { // `PersistentPublicKey` rows. if let Some(ref keys_cs) = changeset.identity_keys { if let Some(cb) = self.callbacks.on_persist_identity_keys_fn { - let mut upserts: Vec = keys_cs - .upserts - .values() - .map(IdentityKeyEntryFFI::from_entry) - .collect(); + let mut upserts = Vec::with_capacity(keys_cs.upserts.len()); + let projection = keys_cs.upserts.values().try_for_each(|entry| { + IdentityKeyEntryFFI::from_entry(entry).map(|entry| upserts.push(entry)) + }); let removed: Vec = keys_cs .removed .iter() @@ -2144,19 +2160,25 @@ impl PlatformWalletPersistence for FFIPersister { key_id: *key_id, }) .collect(); - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - upserts.as_ptr(), - upserts.len(), - if removed.is_empty() { - std::ptr::null() - } else { - removed.as_ptr() - }, - removed.len(), - ) + let result = if projection.is_ok() { + unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + upserts.as_ptr(), + upserts.len(), + if removed.is_empty() { + std::ptr::null() + } else { + removed.as_ptr() + }, + removed.len(), + ) + } + } else { + // Preserve rollback and free every successful projection + // on any projection failure added in the future. + -1 }; for entry in upserts.iter_mut() { unsafe { free_identity_key_entry_ffi(entry) }; From cf8b57b84d45bcfb5f0d4649464dbbd41a977319 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 10 Sep 2026 14:54:27 -0500 Subject: [PATCH 02/13] fix(wasm-dpp): map scoped authentication errors --- .../wasm-dpp/src/errors/consensus/consensus_error.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 5c76989dc95..80ec2415ec7 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -1,3 +1,7 @@ +use super::basic::identity::InvalidAuthenticationScopeErrorWasm; +use super::signature::ScopedKeyExpiredErrorWasm; +use super::signature::ScopedKeyNonBatchErrorWasm; +use super::signature::ScopedKeyOutOfScopeErrorWasm; use crate::errors::consensus::basic::{ IncompatibleProtocolVersionErrorWasm, InvalidIdentifierErrorWasm, InvalidSignaturePublicKeyPurposeErrorWasm, JsonSchemaErrorWasm, @@ -656,6 +660,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { InvalidIdentityAssetLockTransactionError(e) => { InvalidIdentityAssetLockTransactionErrorWasm::from(e).into() } + dpp::consensus::basic::BasicError::InvalidAuthenticationScopeError(e) => { + InvalidAuthenticationScopeErrorWasm::from(e).into() + } IdentityAssetLockTransactionTooManyInputsError(e) => { IdentityAssetLockTransactionTooManyInputsErrorWasm::from(e).into() } @@ -1049,6 +1056,11 @@ fn from_signature_error(signature_error: &SignatureError) -> JsValue { SignatureError::InvalidSignaturePublicKeyPurposeError(err) => { InvalidSignaturePublicKeyPurposeErrorWasm::from(err).into() } + SignatureError::ScopedKeyNonBatchError(err) => ScopedKeyNonBatchErrorWasm::from(err).into(), + SignatureError::ScopedKeyExpiredError(err) => ScopedKeyExpiredErrorWasm::from(err).into(), + SignatureError::ScopedKeyOutOfScopeError(err) => { + ScopedKeyOutOfScopeErrorWasm::from(err).into() + } SignatureError::UncompressedPublicKeyNotAllowedError(err) => { UncompressedPublicKeyNotAllowedErrorWasm::from(err).into() } From 62f9648c62f36c192e85f9befb9bb6f427a00cb8 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 10 Sep 2026 14:57:24 -0500 Subject: [PATCH 03/13] fix(wasm): keep scoped bounds bindings exhaustive --- .../invalid_authentication_scope_error.rs | 29 +++++++++++++++++++ .../errors/consensus/basic/identity/mod.rs | 3 ++ .../src/errors/consensus/signature/mod.rs | 9 ++++++ .../signature/scoped_key_expired_error.rs | 29 +++++++++++++++++++ .../signature/scoped_key_non_batch_error.rs | 29 +++++++++++++++++++ .../scoped_key_out_of_scope_error.rs | 29 +++++++++++++++++++ .../src/data_contract/contract_bounds.rs | 20 ++++++++++--- 7 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 packages/wasm-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs create mode 100644 packages/wasm-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs create mode 100644 packages/wasm-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs create mode 100644 packages/wasm-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs diff --git a/packages/wasm-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs b/packages/wasm-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs new file mode 100644 index 00000000000..75656eb1f44 --- /dev/null +++ b/packages/wasm-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs @@ -0,0 +1,29 @@ +use dpp::consensus::basic::identity::InvalidAuthenticationScopeError; +use dpp::consensus::codes::ErrorWithCode; +use dpp::consensus::ConsensusError; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name=InvalidAuthenticationScopeError)] +pub struct InvalidAuthenticationScopeErrorWasm { + inner: InvalidAuthenticationScopeError, +} + +impl From<&InvalidAuthenticationScopeError> for InvalidAuthenticationScopeErrorWasm { + fn from(e: &InvalidAuthenticationScopeError) -> Self { + Self { inner: e.clone() } + } +} + +#[wasm_bindgen(js_class=InvalidAuthenticationScopeError)] +impl InvalidAuthenticationScopeErrorWasm { + #[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/basic/identity/mod.rs b/packages/wasm-dpp/src/errors/consensus/basic/identity/mod.rs index 95db5d69870..1e0eb0b2f82 100644 --- a/packages/wasm-dpp/src/errors/consensus/basic/identity/mod.rs +++ b/packages/wasm-dpp/src/errors/consensus/basic/identity/mod.rs @@ -57,3 +57,6 @@ pub use invalid_instant_asset_lock_proof_signature_error::*; pub use missing_master_public_key_error::*; pub use missing_public_key_error::*; pub use not_implemented_credit_withdrawal_transition_pooling_error::*; + +mod invalid_authentication_scope_error; +pub use invalid_authentication_scope_error::InvalidAuthenticationScopeErrorWasm; diff --git a/packages/wasm-dpp/src/errors/consensus/signature/mod.rs b/packages/wasm-dpp/src/errors/consensus/signature/mod.rs index 8b5b27b3f10..b1ad818a97e 100644 --- a/packages/wasm-dpp/src/errors/consensus/signature/mod.rs +++ b/packages/wasm-dpp/src/errors/consensus/signature/mod.rs @@ -9,3 +9,12 @@ 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 scoped_key_non_batch_error; +pub use scoped_key_non_batch_error::ScopedKeyNonBatchErrorWasm; + +mod scoped_key_expired_error; +pub use scoped_key_expired_error::ScopedKeyExpiredErrorWasm; + +mod scoped_key_out_of_scope_error; +pub use scoped_key_out_of_scope_error::ScopedKeyOutOfScopeErrorWasm; diff --git a/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs new file mode 100644 index 00000000000..2ad0c9aa498 --- /dev/null +++ b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs @@ -0,0 +1,29 @@ +use dpp::consensus::codes::ErrorWithCode; +use dpp::consensus::signature::ScopedKeyExpiredError; +use dpp::consensus::ConsensusError; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name=ScopedKeyExpiredError)] +pub struct ScopedKeyExpiredErrorWasm { + inner: ScopedKeyExpiredError, +} + +impl From<&ScopedKeyExpiredError> for ScopedKeyExpiredErrorWasm { + fn from(e: &ScopedKeyExpiredError) -> Self { + Self { inner: e.clone() } + } +} + +#[wasm_bindgen(js_class=ScopedKeyExpiredError)] +impl ScopedKeyExpiredErrorWasm { + #[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/scoped_key_non_batch_error.rs b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs new file mode 100644 index 00000000000..d19206280a5 --- /dev/null +++ b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs @@ -0,0 +1,29 @@ +use dpp::consensus::codes::ErrorWithCode; +use dpp::consensus::signature::ScopedKeyNonBatchError; +use dpp::consensus::ConsensusError; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name=ScopedKeyNonBatchError)] +pub struct ScopedKeyNonBatchErrorWasm { + inner: ScopedKeyNonBatchError, +} + +impl From<&ScopedKeyNonBatchError> for ScopedKeyNonBatchErrorWasm { + fn from(e: &ScopedKeyNonBatchError) -> Self { + Self { inner: e.clone() } + } +} + +#[wasm_bindgen(js_class=ScopedKeyNonBatchError)] +impl ScopedKeyNonBatchErrorWasm { + #[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/scoped_key_out_of_scope_error.rs b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs new file mode 100644 index 00000000000..0ac94a61331 --- /dev/null +++ b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs @@ -0,0 +1,29 @@ +use dpp::consensus::codes::ErrorWithCode; +use dpp::consensus::signature::ScopedKeyOutOfScopeError; +use dpp::consensus::ConsensusError; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name=ScopedKeyOutOfScopeError)] +pub struct ScopedKeyOutOfScopeErrorWasm { + inner: ScopedKeyOutOfScopeError, +} + +impl From<&ScopedKeyOutOfScopeError> for ScopedKeyOutOfScopeErrorWasm { + fn from(e: &ScopedKeyOutOfScopeError) -> Self { + Self { inner: e.clone() } + } +} + +#[wasm_bindgen(js_class=ScopedKeyOutOfScopeError)] +impl ScopedKeyOutOfScopeErrorWasm { + #[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-dpp2/src/data_contract/contract_bounds.rs b/packages/wasm-dpp2/src/data_contract/contract_bounds.rs index f8081c78d43..ac720b34fed 100644 --- a/packages/wasm-dpp2/src/data_contract/contract_bounds.rs +++ b/packages/wasm-dpp2/src/data_contract/contract_bounds.rs @@ -1,3 +1,4 @@ +use crate::error::WasmDppError; use crate::error::WasmDppResult; use crate::identifier::{IdentifierLikeJs, IdentifierWasm}; use crate::impl_try_from_js_value; @@ -99,8 +100,8 @@ impl ContractBoundsWasm { } #[wasm_bindgen(getter = "identifier")] - pub fn id(&self) -> IdentifierWasm { - (*self.0.identifier()).into() + pub fn id(&self) -> Option { + self.0.identifier().copied().map(Into::into) } #[wasm_bindgen(getter = "documentTypeName")] @@ -126,6 +127,11 @@ impl ContractBoundsWasm { let contract_id: Identifier = contract_id.try_into()?; self.0 = match self.clone().0 { + ContractBounds::Scoped(_) => { + return Err(WasmDppError::invalid_argument( + "replace the complete scope to change scoped bounds", + )); + } ContractBounds::SingleContract { .. } => { ContractBounds::SingleContract { id: contract_id } } @@ -144,8 +150,13 @@ impl ContractBoundsWasm { pub fn set_document_type_name( &mut self, #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: String, - ) { + ) -> WasmDppResult<()> { self.0 = match self.clone().0 { + ContractBounds::Scoped(_) => { + return Err(WasmDppError::invalid_argument( + "replace the complete scope to change scoped bounds", + )); + } ContractBounds::SingleContract { .. } => self.clone().0, ContractBounds::SingleContractDocumentType { id, .. } => { ContractBounds::SingleContractDocumentType { @@ -153,7 +164,8 @@ impl ContractBoundsWasm { document_type_name, } } - } + }; + Ok(()) } } From 07bf81fbd6e086e9b125df1eb6149ff85b95a6b8 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 10 Sep 2026 15:51:32 -0500 Subject: [PATCH 04/13] fix(wasm-drive-verify): serialize scoped contract bounds --- .../src/identity/verify_identity_keys_by_identity_id.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/wasm-drive-verify/src/identity/verify_identity_keys_by_identity_id.rs b/packages/wasm-drive-verify/src/identity/verify_identity_keys_by_identity_id.rs index c8d3e38fbdc..89c3da84eb2 100644 --- a/packages/wasm-drive-verify/src/identity/verify_identity_keys_by_identity_id.rs +++ b/packages/wasm-drive-verify/src/identity/verify_identity_keys_by_identity_id.rs @@ -204,6 +204,11 @@ fn serialize_identity_public_key(key: &IdentityPublicKey) -> Result { let bounds_obj = Object::new(); match bounds { + dpp::identity::contract_bounds::ContractBounds::Scoped(scope) => { + Reflect::set(&bounds_obj, &JsValue::from_str("type"), &JsValue::from_str("Scoped"))?; + let value = serde_wasm_bindgen::to_value(scope).map_err(|e| JsValue::from_str(&e.to_string()))?; + Reflect::set(&bounds_obj, &JsValue::from_str("scope"), &value)?; + } dpp::identity::identity_public_key::contract_bounds::ContractBounds::SingleContract { id } => { Reflect::set(&bounds_obj, &JsValue::from_str("type"), &JsValue::from_str("SingleContract")) .map_err(|_| JsValue::from_str("Failed to set bounds type"))?; From ccf5b29381dae2e9235c0a3d92097175a7162580 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 16 Sep 2026 02:47:02 +0700 Subject: [PATCH 05/13] fix(wasm-drive-verify): build scoped contract bounds explicitly Scoped bounds in verified identity keys were serialized straight through serde, exposing contract ids as base58 strings and a nested `scope` object, unlike the legacy branches that emit Uint8Array ids. Build the scoped object field by field: `contracts[].id` as Uint8Array, `documentTypes` as string array or null, `permissions` as number, `expiresAt` as decimal string or null (same convention as `disabledAt`). Co-Authored-By: Claude Fable 5.1 --- .../verify_identity_keys_by_identity_id.rs | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/wasm-drive-verify/src/identity/verify_identity_keys_by_identity_id.rs b/packages/wasm-drive-verify/src/identity/verify_identity_keys_by_identity_id.rs index cabb00890c1..690e9e45501 100644 --- a/packages/wasm-drive-verify/src/identity/verify_identity_keys_by_identity_id.rs +++ b/packages/wasm-drive-verify/src/identity/verify_identity_keys_by_identity_id.rs @@ -205,10 +205,44 @@ fn serialize_identity_public_key(key: &IdentityPublicKey) -> Result { let bounds_obj = Object::new(); match bounds { - dpp::identity::contract_bounds::ContractBounds::Scoped(scope) => { - Reflect::set(&bounds_obj, &JsValue::from_str("type"), &JsValue::from_str("Scoped"))?; - let value = serde_wasm_bindgen::to_value(scope).map_err(|e| JsValue::from_str(&e.to_string()))?; - Reflect::set(&bounds_obj, &JsValue::from_str("scope"), &value)?; + dpp::identity::identity_public_key::contract_bounds::ContractBounds::Scoped(scope) => { + // Same shape conventions as the legacy branches below: contract ids as + // Uint8Array, optional fields as null, timestamps as decimal strings + // (like `disabledAt`). + Reflect::set(&bounds_obj, &JsValue::from_str("type"), &JsValue::from_str("Scoped")) + .map_err(|_| JsValue::from_str("Failed to set bounds type"))?; + let contracts = Array::new(); + for contract in scope.contracts() { + let contract_obj = Object::new(); + let id_array = Uint8Array::from(contract.id.as_slice()); + Reflect::set(&contract_obj, &JsValue::from_str("id"), &id_array) + .map_err(|_| JsValue::from_str("Failed to set scoped contract id"))?; + let document_types = match &contract.document_types { + Some(names) => names + .iter() + .map(|name| JsValue::from_str(name)) + .collect::() + .into(), + None => JsValue::NULL, + }; + Reflect::set(&contract_obj, &JsValue::from_str("documentTypes"), &document_types) + .map_err(|_| JsValue::from_str("Failed to set scoped document types"))?; + contracts.push(&contract_obj); + } + Reflect::set(&bounds_obj, &JsValue::from_str("contracts"), &contracts) + .map_err(|_| JsValue::from_str("Failed to set scoped contracts"))?; + Reflect::set( + &bounds_obj, + &JsValue::from_str("permissions"), + &JsValue::from(scope.v0().permissions), + ) + .map_err(|_| JsValue::from_str("Failed to set scoped permissions"))?; + let expires_at = match scope.expires_at() { + Some(timestamp) => JsValue::from_str(×tamp.to_string()), + None => JsValue::NULL, + }; + Reflect::set(&bounds_obj, &JsValue::from_str("expiresAt"), &expires_at) + .map_err(|_| JsValue::from_str("Failed to set scoped expiry"))?; } dpp::identity::identity_public_key::contract_bounds::ContractBounds::SingleContract { id } => { Reflect::set(&bounds_obj, &JsValue::from_str("type"), &JsValue::from_str("SingleContract")) From 2df278c2d03b9930473a4f9f126995f05d838780 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 16 Sep 2026 03:29:07 +0700 Subject: [PATCH 06/13] fix(dpp): accept sighash data version 1 for shielded identity top-ups Protocol 14 selects shielded_extra_sighash_data 1 so identity creation can bind scoped keys, but the IdentityTopUpFromShieldedPool dispatcher only knew version 0. The transition is protocol-14-only, so it could neither be built nor executed: consensus returned an internal error and any proposal carrying one was rejected. The top-up layout is unchanged, so both versions share the frozen v0 bytes, matching the withdrawal and unshield dispatchers. Adds a dispatcher regression asserting every shielded sighash helper resolves at protocols 13, 14 and latest and reproduces its v0 bytes. Co-Authored-By: Claude Fable 5.1 --- packages/rs-dpp/src/shielded/sighash.rs | 56 ++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/rs-dpp/src/shielded/sighash.rs b/packages/rs-dpp/src/shielded/sighash.rs index 87e8e4bc6c3..4d413cdd495 100644 --- a/packages/rs-dpp/src/shielded/sighash.rs +++ b/packages/rs-dpp/src/shielded/sighash.rs @@ -152,13 +152,15 @@ pub fn identity_top_up_from_shielded_extra_sighash_data( platform_version: &PlatformVersion, ) -> Result, ProtocolError> { match platform_version.dpp.methods.shielded_extra_sighash_data { - 0 => Ok(identity_top_up_from_shielded_extra_sighash_data_v0( + // Version 1 only changes the identity-create preimage (scoped key binding); the top-up + // layout is unchanged, so both versions share the frozen v0 bytes. + 0 | 1 => Ok(identity_top_up_from_shielded_extra_sighash_data_v0( identity_id, top_up_amount, )), version => Err(ProtocolError::UnknownVersionMismatch { method: "identity_top_up_from_shielded_extra_sighash_data".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } @@ -360,6 +362,56 @@ pub fn identity_create_from_shielded_extra_sighash_data_v1( Ok(data) } +#[cfg(test)] +mod dispatcher_tests { + use crate::shielded::{ + identity_top_up_from_shielded_extra_sighash_data, + identity_top_up_from_shielded_extra_sighash_data_v0, + shielded_withdrawal_extra_sighash_data, shielded_withdrawal_extra_sighash_data_v0, + unshield_extra_sighash_data, unshield_extra_sighash_data_v0, + }; + use crate::withdrawal::Pooling; + use platform_version::version::PlatformVersion; + + /// Every dispatcher keyed on `dpp.methods.shielded_extra_sighash_data` must resolve at every + /// protocol version that can carry its transition. Version 1 only changes the identity-create + /// preimage (scoped key binding), so the other transitions keep their frozen v0 bytes. + #[test] + fn every_shielded_sighash_dispatcher_resolves_at_protocol_13_14_and_latest() { + for platform_version in [ + PlatformVersion::get(13).expect("protocol 13"), + PlatformVersion::get(14).expect("protocol 14"), + PlatformVersion::latest(), + ] { + let version = platform_version.dpp.methods.shielded_extra_sighash_data; + let script = [0xAA; 25]; + assert_eq!( + shielded_withdrawal_extra_sighash_data( + &script, + 7, + 1, + Pooling::Never, + platform_version + ) + .unwrap_or_else(|e| panic!("withdrawal dispatcher at version {version}: {e}")), + shielded_withdrawal_extra_sighash_data_v0(&script, 7, 1, Pooling::Never) + ); + let address = [0xBB; 21]; + assert_eq!( + unshield_extra_sighash_data(&address, 7, platform_version) + .unwrap_or_else(|e| panic!("unshield dispatcher at version {version}: {e}")), + unshield_extra_sighash_data_v0(&address, 7) + ); + let identity_id = [1; 32]; + assert_eq!( + identity_top_up_from_shielded_extra_sighash_data(&identity_id, 7, platform_version) + .unwrap_or_else(|e| panic!("top-up dispatcher at version {version}: {e}")), + identity_top_up_from_shielded_extra_sighash_data_v0(&identity_id, 7) + ); + } + } +} + #[cfg(test)] mod tests { use super::*; From 68e10e596d853d1a3cd64633591890c26a2ce940 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 16 Sep 2026 03:29:07 +0700 Subject: [PATCH 07/13] fix(drive): version scoped key reference refresh and keep the newest key current - refresh_potential_contract_info_key_references gets a v1 selected by DRIVE_IDENTITY_METHOD_VERSIONS_V2; v0 is restored to its pre-PR body so protocol 13 replay keeps the historical rejection of bounded authentication keys. - The current-key sibling pointer of an authentication purpose subtree is refreshed untrusted. A trusted refresh writes the payload verbatim, so revoking an older scoped key repointed the slot at the revoked key and hid the still-active one from current-key fetches and getIdentitiesContractKeys. - Two scoped keys covering the same contract in one transition queued two writes for that slot; GroveDB rejects that under batching consistency verification, so such a node produced an internal error while others applied the block. The earlier pending write is now dropped so the last registered key wins. - The Scoped arm of the shared key-apply constructor returns an error instead of unreachable!(). Adds a drive-abci regression that registers two scoped keys on one contract in a single update with consistency verification on, revokes the older one, and asserts the current key stays the newer one. Co-Authored-By: Claude Fable 5.1 --- .../state_transitions/identity_update/mod.rs | 206 ++++++++++ .../v1/mod.rs | 43 +- .../drive/identity/contract_info/keys/mod.rs | 35 +- .../mod.rs | 12 +- .../v0/mod.rs | 18 +- .../v1/mod.rs | 367 ++++++++++++++++++ .../drive_identity_method_versions/v2.rs | 9 +- 7 files changed, 650 insertions(+), 40 deletions(-) create mode 100644 packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v1/mod.rs 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 070ebd28c9c..5f0ec23039b 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 @@ -937,6 +937,212 @@ mod tests { ); } + #[tokio::test] + async fn should_keep_the_newest_scoped_key_current_after_revoking_an_older_one() { + use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractScope, + }; + use drive::config::DriveConfig; + use drive::drive::identity::key::fetch::{ + IdentityKeysRequest, KeyKindRequestType, KeyRequestType, + }; + let version = PlatformVersion::latest(); + // Consistency verification makes GroveDB reject two pending operations on one slot, + // which is exactly what two scoped keys covering one contract write for the + // current-key pointer of that contract. + 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::Scoped(AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: dashpay.id(), + document_types: None, + }], + permissions: permissions::DOCUMENT_CREATE, + expires_at: None, + })); + + // Register two scoped keys covering the same contract in one transition. + let secp = Secp256k1::new(); + let mut rng = StdRng::seed_from_u64(292); + let pairs: Vec<(u32, Keypair)> = [2u32, 3] + .into_iter() + .map(|id| (id, Keypair::new(&secp, &mut rng))) + .collect(); + let mut new_keys: Vec = pairs + .iter() + .map(|(id, pair)| IdentityPublicKeyInCreationV0 { + id: *id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + key_type: ECDSA_SECP256K1, + read_only: false, + data: pair.public_key().serialize().to_vec().into(), + signature: Default::default(), + contract_bounds: Some(bounds.clone()), + }) + .collect(); + let registration = |keys: Vec| -> StateTransition { + IdentityUpdateTransition::from(IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 1, + nonce: 1, + add_public_keys: keys + .into_iter() + .map(IdentityPublicKeyInCreation::V0) + .collect(), + disable_public_keys: vec![], + user_fee_increase: 0, + signature_public_key_id: master.id(), + signature: Default::default(), + }) + .into() + }; + let signable_bytes = registration(new_keys.clone()).signable_bytes().unwrap(); + for (key, (_, pair)) in new_keys.iter_mut().zip(&pairs) { + key.signature = signer::sign(&signable_bytes, &pair.secret_key().secret_bytes()) + .unwrap() + .to_vec() + .into(); + } + let mut registration = registration(new_keys); + registration.set_signature(signer.sign(&master, &signable_bytes).await.unwrap()); + + let transaction = platform.drive.grove.start_transaction(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![registration.serialize_to_bytes().unwrap()], + &platform.state.load(), + &BlockInfo::default(), + &transaction, + version, + true, + None, + ) + .unwrap(); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }], + "two scoped keys on one contract must register in a single batch" + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .unwrap(); + + // Revoke the older key; the current-key pointer must keep naming the newer one. + let mut revocation: 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: master.id(), + signature: Default::default(), + }) + .into(); + revocation.set_signature( + signer + .sign(&master, &revocation.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![revocation.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(); + + let fetch = |request_type: KeyRequestType| { + 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() + .loaded_public_keys + }; + let current = fetch(KeyRequestType::ContractBoundKey( + dashpay.id().to_buffer(), + Purpose::AUTHENTICATION, + KeyKindRequestType::CurrentKeyOfKindRequest, + )); + assert_eq!( + current.keys().copied().collect::>(), + vec![3], + "revoking an older scoped key must not repoint the current key at it" + ); + assert_eq!(current[&3].disabled_at(), None); + let all = fetch(KeyRequestType::ContractBoundKey( + dashpay.id().to_buffer(), + Purpose::AUTHENTICATION, + KeyKindRequestType::AllKeysOfKindRequest, + )); + assert_eq!(all[&2].disabled_at(), Some(block.time_ms)); + assert_eq!(all[&3].disabled_at(), None); + 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 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 d79976be56b..c82a7f5dac9 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 @@ -1,4 +1,6 @@ -use crate::drive::identity::contract_info::keys::IdentityDataContractKeyApplyInfo; +use crate::drive::identity::contract_info::keys::{ + drop_pending_operation_at, IdentityDataContractKeyApplyInfo, +}; use crate::drive::identity::contract_info::ContractInfoStructure::ContractInfoKeysKey; use crate::drive::identity::IdentityRootStructure::IdentityContractInfo; use crate::drive::identity::{ @@ -319,20 +321,24 @@ impl Drive { // 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 { + // Scoped authentication'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) + }; + // Two scoped keys covering this contract in one transition both write the + // current-key slot; keep only the last one in the batch. + drop_pending_operation_at(drive_operations, &sibling_path, &[]); self.batch_insert( PathKeyElementInfo::<0>::PathKeyElement(( - if purpose == Purpose::AUTHENTICATION { - // Scoped authentication'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) - }, + sibling_path, vec![], Element::Reference(sibling_ref_type_path, Some(2), None), )), @@ -517,14 +523,17 @@ impl Drive { // 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, + ); + // Same de-duplication as the contract-level current-key slot above. + drop_pending_operation_at(drive_operations, &sibling_path, &[]); self.batch_insert( PathKeyElementInfo::<0>::PathKeyElement(( - identity_contract_info_group_path_key_purpose_vec( - &identity_id, - &contract_id_bytes_with_document_type_name, - purpose, - ), + sibling_path, vec![], Element::Reference(sibling_ref_type_path, Some(2), None), )), 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 bcab1161423..4af9e04bc8b 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,6 +9,8 @@ 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::batch::key_info::KeyInfo; +use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; use grovedb::TransactionArg; use platform_version::version::PlatformVersion; use std::collections::BTreeMap; @@ -16,6 +18,32 @@ use std::collections::BTreeMap; mod add_potential_contract_info_for_contract_bounded_key; mod refresh_potential_contract_info_key_references; +/// Drops any pending grove operation already queued for `path`/`key`. +/// +/// The "current key" sibling pointer of a contract-info purpose subtree lives at the empty +/// key, and every scoped key covering that contract writes it. Two scoped keys registered in +/// one transition would therefore queue two operations for one slot, which GroveDB rejects +/// under batching consistency verification. The last registered key must win, so the earlier +/// pending write is removed before the new one is queued. +pub(super) fn drop_pending_operation_at( + drive_operations: &mut Vec, + path: &[Vec], + key: &[u8], +) { + let path = KeyInfoPath::from_known_owned_path(path.to_vec()); + let key = KeyInfo::KnownKey(key.to_vec()); + drive_operations.retain(|operation| { + !matches!( + operation, + LowLevelDriveOperation::GroveOperation(QualifiedGroveDbOp { + path: pending_path, + key: Some(pending_key), + .. + }) if *pending_path == path && *pending_key == key + ) + }); +} + 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 @@ -138,7 +166,12 @@ impl IdentityDataContractKeyApplyInfo { contract_keys: vec![], }]) } - ContractBounds::Scoped(_) => unreachable!("handled above"), + // Expanded per contract by the early return above; never panic in block execution. + ContractBounds::Scoped(_) => { + Err(Error::Identity(IdentityError::IdentityKeyBoundsError( + "scoped bounds are expanded per contract before this match", + ))) + } } } } 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/v0/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v0/mod.rs index c23fcbb62a0..9b5433db305 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v0/mod.rs @@ -145,9 +145,6 @@ impl Drive { 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 @@ -206,17 +203,7 @@ impl Drive { let sibling_ref_type_path = SiblingReference(key_id_bytes); self.batch_refresh_reference( - if purpose == Purpose::AUTHENTICATION { - // Scoped authentication'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) - }, + identity_contract_info_group_keys_path_vec(&identity_id, &root_id), vec![], Element::Reference(sibling_ref_type_path, Some(2), None), true, @@ -273,9 +260,6 @@ impl Drive { let storage_key_requirements = contract .as_ref() .map(|contract| match purpose { - Purpose::AUTHENTICATION => { - Ok(StorageKeyRequirements::MultipleReferenceToLatest) - } Purpose::ENCRYPTION => { let document_type = contract .contract 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..bacb90744d0 --- /dev/null +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v1/mod.rs @@ -0,0 +1,367 @@ +use crate::drive::identity::contract_info::keys::{ + drop_pending_operation_at, 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, + 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(); + + 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 { + // Scoped authentication'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) + }; + drop_pending_operation_at(drive_operations, &sibling_path, &[]); + + // 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, + ); + drop_pending_operation_at(drive_operations, &sibling_path, &[]); + + // 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-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 95b05a0102c..219f888d3db 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 @@ -16,9 +16,10 @@ use crate::version::drive_versions::drive_identity_method_versions::{ /// V2 is protocol version 14's identity-method table. It differs from V1 in /// its contract-bound key indexing and withdrawal methods: /// -/// * `contract_info.add_potential_contract_info_for_contract_bounded_key` 0 -> 1: -/// supports scoped authentication-key references. V0 preserves the historical -/// rejection of authentication keys with legacy contract bounds before v14. +/// * `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 scoped authentication-key references. Both v0s preserve the +/// historical rejection of authentication keys with contract bounds before v14. /// * `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 @@ -145,7 +146,7 @@ pub const DRIVE_IDENTITY_METHOD_VERSIONS_V2: DriveIdentityMethodVersions = }, contract_info: DriveIdentityContractInfoMethodVersions { add_potential_contract_info_for_contract_bounded_key: 1, - refresh_potential_contract_info_key_references: 0, + refresh_potential_contract_info_key_references: 1, merge_identity_contract_nonce: 0, }, cost_estimation: DriveIdentityCostEstimationMethodVersions { From 86828016e2774ed900637211ee823c5c40bff2b2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 16 Sep 2026 03:29:07 +0700 Subject: [PATCH 08/13] fix(drive-abci): pin the scope check to the signing key and fail closed on scoped bounds The signature-stage scope check iterated every loaded key; v0 loads only the signing key today, but look it up by signature_public_key_id so another key can never veto a transition it did not sign. The legacy bounds validator's Scoped arm returns a corrupted-code error instead of panicking in block execution. Documents that the DPP bounds type tag is not the wallet FFI kind. Co-Authored-By: Claude Fable 5.1 --- .../identity/identity_public_key/contract_bounds/mod.rs | 4 +++- .../v1/mod.rs | 8 +++++++- .../validate_state_transition_identity_signed/v1/mod.rs | 7 ++++++- 3 files changed, 16 insertions(+), 3 deletions(-) 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 57bcdd66b35..45a7741df60 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 @@ -79,7 +79,9 @@ impl ContractBounds { }) } - /// Gets the contract bounds type + /// Gets the contract bounds type: 0 single contract, 1 single contract document type, + /// 2 scoped. This is the DPP type tag, not the wallet FFI `contract_bounds_kind` + /// (where 0 means unbounded and 1/2 are the two legacy variants). pub fn contract_bounds_type(&self) -> ContractBoundsType { match self { SingleContract { .. } => 0, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs index b6306b927e3..a0949fb5071 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs @@ -12,6 +12,7 @@ //! is now billed to the passed-in execution context, instead of being //! discarded as v0 did (which had an explicit `//todo:` for this). +use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::execution_operation::ValidationOperation; use crate::execution::types::state_transition_execution_context::{ @@ -118,7 +119,12 @@ fn validate_identity_public_key_contract_bounds_v1( }; match contract_bounds { - ContractBounds::Scoped(_) => unreachable!("rejected above"), + // Rejected by the purpose check above; never panic in block execution. + ContractBounds::Scoped(_) => { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "scoped bounds must be rejected before legacy bounds validation", + ))) + } ContractBounds::SingleContract { .. } => { let requirements_for_purpose = match purpose { ENCRYPTION => contract.config().requires_identity_encryption_bounded_key(), 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 index c1ccfb9678e..1dfaadc754c 100644 --- 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 @@ -43,7 +43,12 @@ impl ValidateStateTransitionIdentitySignatureV1 for StateTransition { platform_version, )?; if let Some(identity) = result.data.as_ref().filter(|_| result.is_valid()) { - for key in identity.loaded_public_keys.values() { + // v0 loads exactly the signing key, but pin the scope check to that key 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 let Some(ContractBounds::Scoped(scope)) = key.contract_bounds() { if scope.is_expired(time_ms) { return Ok(ConsensusValidationResult::new_with_error( From 2bc996799dc33f2493693a4e1cc87e8056922a69 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 16 Sep 2026 03:29:07 +0700 Subject: [PATCH 09/13] test(dpp): freeze scoped error discriminants, permission mask and document permission table Frozen-discriminant guards for SignatureError and BasicError (the new scoped variants sit at the tails), a pinned scope version 0 permission mask, and a document-kind permission table mirroring the token one, including the document-type restriction and foreign-contract cases. Co-Authored-By: Claude Fable 5.1 --- .../src/errors/consensus/basic/basic_error.rs | 28 ++++++ .../consensus/signature/signature_error.rs | 41 ++++++++ .../contract_bounds/authentication_scope.rs | 93 +++++++++++++++++++ 3 files changed, 162 insertions(+) diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index 6d6aaec000f..832947d3fe6 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -726,3 +726,31 @@ impl From for ConsensusError { Self::BasicError(error) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// `BasicError` is encoded by variant position; appending is the only safe change. + fn discriminant_of(error: BasicError) -> u8 { + let bytes = bincode::encode_to_vec(error, bincode::config::standard()) + .expect("expected to encode the basic error"); + bytes[0] + } + + #[test] + fn basic_error_discriminants_are_frozen() { + assert_eq!( + discriminant_of(BasicError::ProtocolVersionParsingError( + ProtocolVersionParsingError::new("parse".to_string()) + )), + 0 + ); + assert_eq!( + discriminant_of(BasicError::InvalidAuthenticationScopeError( + InvalidAuthenticationScopeError::new("scope".to_string()) + )), + 175 + ); + } +} 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 547f5746651..01c7d08d272 100644 --- a/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs +++ b/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs @@ -89,3 +89,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::ScopedKeyNonBatchError( + ScopedKeyNonBatchError::new(1) + )), + 13 + ); + assert_eq!( + discriminant_of(SignatureError::ScopedKeyOutOfScopeError( + ScopedKeyOutOfScopeError::new(1) + )), + 15 + ); + } +} diff --git a/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/authentication_scope.rs b/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/authentication_scope.rs index 214e2990fe1..0995c318e20 100644 --- a/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/authentication_scope.rs +++ b/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/authentication_scope.rs @@ -439,6 +439,99 @@ mod tests { } } + #[cfg(feature = "state-transitions")] + #[test] + fn should_require_each_document_permission_independently_and_respect_type_restrictions() { + 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, + BatchedTransitionRef, DocumentTransition, + }; + use permissions::*; + + let cases = [ + ( + DocumentTransition::Create(DocumentCreateTransitionV0::default().into()), + DOCUMENT_CREATE, + ), + ( + DocumentTransition::Replace(DocumentReplaceTransitionV0::default().into()), + DOCUMENT_REPLACE, + ), + ( + DocumentTransition::Delete(DocumentDeleteTransitionV0::default().into()), + DOCUMENT_DELETE, + ), + ( + DocumentTransition::IndexOnlyDelete( + DocumentIndexOnlyDeleteTransitionV0::default().into(), + ), + DOCUMENT_DELETE, + ), + ( + DocumentTransition::Transfer(DocumentTransferTransitionV0::default().into()), + DOCUMENT_TRANSFER, + ), + ( + DocumentTransition::UpdatePrice(DocumentUpdatePriceTransitionV0::default().into()), + DOCUMENT_UPDATE_PRICE, + ), + ( + DocumentTransition::Purchase(DocumentPurchaseTransitionV0::default().into()), + DOCUMENT_PURCHASE, + ), + ]; + // Default transitions target contract [0; 32] and the empty document type name. + for (transition, required) in cases { + let member = BatchedTransitionRef::Document(&transition); + let mut scope = AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([0; 32]), + document_types: None, + }], + permissions: required, + expires_at: None, + }; + assert!(AuthenticationScope::V0(scope.clone()).allows_transition(member)); + scope.permissions = ALL & !required; + assert!( + !AuthenticationScope::V0(scope.clone()).allows_transition(member), + "all other permissions must not authorize {transition:?}" + ); + scope.permissions = ALL; + scope.contracts[0].document_types = Some(vec!["other".to_string()]); + assert!( + !AuthenticationScope::V0(scope.clone()).allows_transition(member), + "a type restriction must exclude unlisted types for {transition:?}" + ); + scope.contracts[0].document_types = Some(vec![String::new()]); + assert!( + AuthenticationScope::V0(scope.clone()).allows_transition(member), + "listing the member's own type must authorize {transition:?}" + ); + scope.contracts[0].document_types = None; + scope.contracts[0].id = Identifier::from([1; 32]); + assert!( + !AuthenticationScope::V0(scope).allows_transition(member), + "no permission may escape its contract" + ); + } + } + + /// Consensus rejects unknown bits against this mask at registration. Once protocol 14 is + /// released, widening it would accept on replay what the network rejected, so new bits + /// need a new scope version (or a versioned mask), never an edit here. + #[test] + fn scope_version_0_permission_mask_is_frozen() { + assert_eq!(permissions::ALL, 0x0003_FFFF); + assert_eq!(permissions::TOKEN_SET_PRICE, 1 << 17); + } + #[cfg(all(feature = "json-conversion", feature = "value-conversion"))] #[test] fn should_round_trip_scoped_bounds_in_json_and_platform_value() { From e31029258ae6f18355417b053cc19b8baeb32f76 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 16 Sep 2026 03:29:07 +0700 Subject: [PATCH 10/13] docs(protocol): state scoped key spending authority and the SDK follow-up DocumentTokenPayment delegates spending over every token balance the identity holds, DocumentPurchase amounts to credit-transfer authority towards the seller, token bits ignore document-type restrictions, and the JS constructor ships with the SDK follow-up (#4655). Co-Authored-By: Claude Fable 5.1 --- .../contract-scoped-authentication.md | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/protocol/contract-scoped-authentication.md b/docs/protocol/contract-scoped-authentication.md index 873a92cf359..b7c7304b3ee 100644 --- a/docs/protocol/contract-scoped-authentication.md +++ b/docs/protocol/contract-scoped-authentication.md @@ -19,10 +19,13 @@ A missing/null document-type restriction authorizes all types in that contract, including types added by later contract updates. An empty array is invalid. Contract IDs and document-type names must be sorted and unique on the wire. There are at most 16 contracts and 16 types per contract, and the encoded scope must not -exceed 2048 bytes. The WASM constructor sorts entries and rejects duplicates. +exceed 2048 bytes. The WASM constructor (SDK follow-up, PR #4655) sorts entries and +rejects duplicates. For an application that creates, updates and deletes documents and pays their -configured token fees, construct the bounds with the WASM SDK: +configured token fees, construct the bounds with the WASM SDK (`ContractBounds.Scoped` +and `AuthenticationPermission` ship with the SDK follow-up, PR #4655; this PR only +keeps the existing bindings compiling): ```javascript const P = wasm.AuthenticationPermission; @@ -58,7 +61,9 @@ required for scoped authentication. Document create, replace, delete, ownership transfer, price updates and purchases have separate bits. Index-only deletion uses the delete bit. Standalone token -transition kinds also have separate bits. New/unknown bits are rejected. +transition kinds also have separate bits. New/unknown bits are rejected. Token bits +apply to every token defined on a listed contract; a document-type restriction only +narrows document actions, never token operations. `DocumentTokenPayment` permits the actual contract-defined token cost of an otherwise-authorized document action. It also covers fees using a token issued @@ -89,9 +94,15 @@ document/token operations do not execute, but Platform credit validation fees can be charged and the first batch member's identity-contract nonce can advance, even if that member is outside the scope. Replays follow the usual nonce rules. -There are no per-key budgets in scope version 0. A stolen key can exhaust credit -balances through fees and permitted token balances through allowed operations. -Expiry limits the time window, not total financial loss. +There are no per-key budgets in scope version 0. Two bits carry more authority than +their names suggest. `DocumentTokenPayment` delegates spending over every token +balance the identity holds: the in-scope contract owner chooses the fee token and +amount, may point at a token issued by any other contract, and may change both by +contract update. `DocumentPurchase` moves credits to the seller at the listed price, +so it amounts to credit-transfer authority towards any seller the key holder controls, +using an authentication key rather than a TRANSFER key. A stolen key can exhaust +credit and token balances through allowed operations. Expiry limits the time window, +not total financial loss. ## Compatibility From b9d2670380f0a3ad425da24c2088a6e9ba34a24a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 16 Sep 2026 09:29:33 +0700 Subject: [PATCH 11/13] fix(drive-abci): drop the needless return in the scoped bounds arm Clippy under -D warnings rejects the explicit return in the ContractBounds::Scoped match arm of validate_identity_public_key_contract_bounds v1, which failed the Rust workspace tests job. The arm now yields the Err directly. Co-Authored-By: Claude Fable 5.1 --- .../v1/mod.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs index a0949fb5071..6b09f0467fe 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs @@ -120,11 +120,9 @@ fn validate_identity_public_key_contract_bounds_v1( match contract_bounds { // Rejected by the purpose check above; never panic in block execution. - ContractBounds::Scoped(_) => { - return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( - "scoped bounds must be rejected before legacy bounds validation", - ))) - } + ContractBounds::Scoped(_) => Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "scoped bounds must be rejected before legacy bounds validation", + ))), ContractBounds::SingleContract { .. } => { let requirements_for_purpose = match purpose { ENCRYPTION => contract.config().requires_identity_encryption_bounded_key(), From 3d1890f366d37b8fd6ddb8afe48bec16e92130f9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 17 Sep 2026 00:04:07 +0700 Subject: [PATCH 12/13] fix(drive): coalesce scoped current-key alias writes across an identity update The current-key alias of an authentication purpose subtree is written by every scoped key covering the contract and refreshed when one is disabled. An identity update builds its add and disable operations in separate vectors, so the per-vector de-duplication could not see a registration and a revocation landing on one slot: GroveDB rejected the batch under consistency verification, and otherwise the refresh applied last and left the revoked key current. It also let input order, not recency, pick the current key. apply_batch_low_level_drive_operations v1 (DRIVE_VERSION_V9, protocol 14) now coalesces alias writes per slot for the whole batch: an insertion beats a refresh, the insertion naming the highest key id wins, and duplicate refreshes collapse. The per-vector de-duplication is removed. All-keys listings for an AUTHENTICATION purpose subtree skip the alias key so the current key is not returned twice. refresh v1 registers the contract-level group layers in estimation mode; v0 never reached them. The regression registers two scoped keys newest-first, then adds a replacement and revokes the current key in one transition, then revokes the older key, with consistency verification on, asserting the current key and alias-free listings in both a contract-level and a document-type slot. Co-Authored-By: Claude Fable 5.1 --- .../state_transitions/identity_update/mod.rs | 401 ++++++++++++------ .../v1/mod.rs | 9 +- .../drive/identity/contract_info/keys/mod.rs | 103 +++-- .../v1/mod.rs | 29 +- .../src/drive/identity/key/fetch/mod.rs | 16 +- .../mod.rs | 10 +- .../v1/mod.rs | 87 ++++ .../src/version/drive_versions/v9.rs | 2 +- 8 files changed, 485 insertions(+), 172 deletions(-) create mode 100644 packages/rs-drive/src/util/operations/apply_batch_low_level_drive_operations/v1/mod.rs 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 5f0ec23039b..a1eb6caa751 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 @@ -938,19 +938,19 @@ mod tests { } #[tokio::test] - async fn should_keep_the_newest_scoped_key_current_after_revoking_an_older_one() { + async fn should_keep_the_newest_scoped_key_current_across_registration_and_revocation() { use dpp::identity::contract_bounds::{ authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, ContractScope, }; use drive::config::DriveConfig; use drive::drive::identity::key::fetch::{ - IdentityKeysRequest, KeyKindRequestType, KeyRequestType, + 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 exactly what two scoped keys covering one contract write for the - // current-key pointer of that contract. + // which is what scoped keys covering one contract queue for its current-key alias. let mut platform = TestPlatformBuilder::new() .with_config(PlatformConfig { drive: DriveConfig { @@ -970,168 +970,197 @@ mod tests { .system_data_contracts .load_dashpay(version) .unwrap(); - let bounds = ContractBounds::Scoped(AuthenticationScope::V0(AuthenticationScopeV0 { - contracts: vec![ContractScope { + let dpns = platform + .drive + .cache + .system_data_contracts + .load_dpns(version) + .unwrap(); + // One contract-level slot (dashpay) and one document-type slot (dpns preorder). + let mut contracts = vec![ + ContractScope { id: dashpay.id(), document_types: None, - }], + }, + ContractScope { + id: dpns.id(), + document_types: Some(vec!["preorder".into()]), + }, + ]; + contracts.sort_by_key(|entry| entry.id); + let bounds = ContractBounds::Scoped(AuthenticationScope::V0(AuthenticationScopeV0 { + contracts, permissions: permissions::DOCUMENT_CREATE, expires_at: None, })); - - // Register two scoped keys covering the same contract in one transition. let secp = Secp256k1::new(); let mut rng = StdRng::seed_from_u64(292); - let pairs: Vec<(u32, Keypair)> = [2u32, 3] + let pairs: BTreeMap = [2u32, 3, 4] .into_iter() .map(|id| (id, Keypair::new(&secp, &mut rng))) .collect(); - let mut new_keys: Vec = pairs - .iter() - .map(|(id, pair)| IdentityPublicKeyInCreationV0 { - id: *id, - purpose: Purpose::AUTHENTICATION, - security_level: SecurityLevel::HIGH, - key_type: ECDSA_SECP256K1, - read_only: false, - data: pair.public_key().serialize().to_vec().into(), - signature: Default::default(), - contract_bounds: Some(bounds.clone()), - }) - .collect(); - let registration = |keys: Vec| -> StateTransition { + let scoped_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: 1, - nonce: 1, - add_public_keys: keys + revision, + nonce: revision, + add_public_keys: add .into_iter() .map(IdentityPublicKeyInCreation::V0) .collect(), - disable_public_keys: vec![], + disable_public_keys: disable, user_fee_increase: 0, signature_public_key_id: master.id(), signature: Default::default(), }) .into() }; - let signable_bytes = registration(new_keys.clone()).signable_bytes().unwrap(); - for (key, (_, pair)) in new_keys.iter_mut().zip(&pairs) { - key.signature = signer::sign(&signable_bytes, &pair.secret_key().secret_bytes()) + 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() - .to_vec() - .into(); - } - let mut registration = registration(new_keys); - registration.set_signature(signer.sign(&master, &signable_bytes).await.unwrap()); - - let transaction = platform.drive.grove.start_transaction(); - let result = platform - .platform - .process_raw_state_transitions( - &vec![registration.serialize_to_bytes().unwrap()], - &platform.state.load(), - &BlockInfo::default(), - &transaction, - version, - true, - None, - ) - .unwrap(); - assert_matches!( - result.execution_results().as_slice(), - [StateTransitionExecutionResult::SuccessfulExecution { .. }], - "two scoped keys on one contract must register in a single batch" - ); - platform - .drive - .grove - .commit_transaction(transaction) - .unwrap() - .unwrap(); - - // Revoke the older key; the current-key pointer must keep naming the newer one. - let mut revocation: 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: master.id(), - signature: Default::default(), - }) - .into(); - revocation.set_signature( - signer - .sign(&master, &revocation.signable_bytes().unwrap()) - .await - .unwrap(), - ); - let block = BlockInfo { - time_ms: 1001, - ..Default::default() + .unwrap(); }; - let transaction = platform.drive.grove.start_transaction(); - let result = platform - .platform - .process_raw_state_transitions( - &vec![revocation.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(); - - let fetch = |request_type: KeyRequestType| { + let key_ids = |request_type: KeyRequestType| -> Vec { platform .drive - .fetch_identity_keys_as_partial_identity( + .fetch_identity_keys::( IdentityKeysRequest { identity_id: identity.id().to_buffer(), request_type, - limit: None, + 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() - .loaded_public_keys + .unwrap() + .loaded_public_keys[&key_id] + .disabled_at() }; - let current = fetch(KeyRequestType::ContractBoundKey( - dashpay.id().to_buffer(), - Purpose::AUTHENTICATION, - KeyKindRequestType::CurrentKeyOfKindRequest, - )); - assert_eq!( - current.keys().copied().collect::>(), - vec![3], - "revoking an older scoped key must not repoint the current key at it" + let slots = |kind: KeyKindRequestType| { + [ + KeyRequestType::ContractBoundKey( + dashpay.id().to_buffer(), + Purpose::AUTHENTICATION, + kind, + ), + KeyRequestType::ContractDocumentTypeBoundKey( + dpns.id().to_buffer(), + "preorder".into(), + Purpose::AUTHENTICATION, + kind, + ), + ] + }; + let assert_slots = |current: u32, all: &[u32]| { + for slot in slots(KeyKindRequestType::CurrentKeyOfKindRequest) { + assert_eq!(key_ids(slot), vec![current], "current key"); + } + for slot in slots(KeyKindRequestType::AllKeysOfKindRequest) { + assert_eq!( + key_ids(slot), + all, + "listing must not repeat the current key alias" + ); + } + }; + + // 1. Two scoped keys covering the same slots in one update, listed newest first: the + // highest key id must be current in both slots regardless of input order. + let mut adds = vec![scoped_key(3), scoped_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_slots(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![scoped_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_slots(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(), ); - assert_eq!(current[&3].disabled_at(), None); - let all = fetch(KeyRequestType::ContractBoundKey( - dashpay.id().to_buffer(), - Purpose::AUTHENTICATION, - KeyKindRequestType::AllKeysOfKindRequest, - )); - assert_eq!(all[&2].disabled_at(), Some(block.time_ms)); - assert_eq!(all[&3].disabled_at(), None); + apply(&revocation, 3000); + assert_slots(4, &[2, 3, 4]); + assert_eq!(disabled_at(2), Some(3000)); assert!( platform .drive @@ -1139,8 +1168,120 @@ mod tests { .visualize_verify_grovedb(None, true, false, &version.drive.grove_version) .unwrap() .is_empty(), - "revocation must leave no stale GroveDB references" + "registration and revocation must leave no stale GroveDB references" + ); + } + + #[tokio::test] + async fn should_estimate_scoped_key_revocation_at_least_at_its_execution_cost() { + use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractScope, + }; + 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 dpns = platform + .drive + .cache + .system_data_contracts + .load_dpns(version) + .unwrap(); + let mut contracts = vec![ + ContractScope { + id: dashpay.id(), + document_types: None, + }, + ContractScope { + id: dpns.id(), + document_types: Some(vec!["domain".into(), "preorder".into()]), + }, + ]; + contracts.sort_by_key(|entry| entry.id); + let bounds = ContractBounds::Scoped(AuthenticationScope::V0(AuthenticationScopeV0 { + contracts, + permissions: permissions::DOCUMENT_CREATE, + expires_at: None, + })); + let scoped = setup_add_key_to_identity( + &mut platform, + &mut identity, + &mut signer, + 4, + 2, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + KeyType::ECDSA_SECP256K1, + Some(bounds), + ); + 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_scoped = estimate(scoped.id()); + let estimated_plain = estimate(plain.id()); + // The scope adds three contract-info groups whose references must be refreshed; + // v0 estimated with an unbounded stand-in key and priced none of them. + assert!( + estimated_scoped.processing_fee > estimated_plain.processing_fee, + "scoped revocation estimate {} must exceed the unbounded one {}", + estimated_scoped.processing_fee, + estimated_plain.processing_fee + ); + let actual = platform + .drive + .disable_identity_keys( + identity.id().to_buffer(), + vec![scoped.id()], + block.time_ms, + &block, + true, + None, + version, + ) + .unwrap(); + assert!( + estimated_scoped.processing_fee >= actual.processing_fee, + "estimate {} must cover execution {}", + estimated_scoped.processing_fee, + actual.processing_fee ); + assert!(estimated_scoped.storage_fee >= actual.storage_fee); } #[tokio::test] 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 c82a7f5dac9..1ea3c469df5 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 @@ -1,6 +1,4 @@ -use crate::drive::identity::contract_info::keys::{ - drop_pending_operation_at, IdentityDataContractKeyApplyInfo, -}; +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::{ @@ -332,9 +330,6 @@ impl Drive { } else { identity_contract_info_group_keys_path_vec(&identity_id, &root_id) }; - // Two scoped keys covering this contract in one transition both write the - // current-key slot; keep only the last one in the batch. - drop_pending_operation_at(drive_operations, &sibling_path, &[]); self.batch_insert( PathKeyElementInfo::<0>::PathKeyElement(( @@ -528,8 +523,6 @@ impl Drive { &contract_id_bytes_with_document_type_name, purpose, ); - // Same de-duplication as the contract-level current-key slot above. - drop_pending_operation_at(drive_operations, &sibling_path, &[]); self.batch_insert( PathKeyElementInfo::<0>::PathKeyElement(( 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 4af9e04bc8b..2325d38a061 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 @@ -10,40 +10,93 @@ use dpp::identifier::Identifier; use dpp::identity::contract_bounds::ContractBounds; use dpp::identity::{KeyID, Purpose}; use grovedb::batch::key_info::KeyInfo; -use grovedb::batch::{KeyInfoPath, QualifiedGroveDbOp}; -use grovedb::TransactionArg; +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; -/// Drops any pending grove operation already queued for `path`/`key`. +/// Coalesces the current-key alias writes of contract-info purpose subtrees in one batch. /// -/// The "current key" sibling pointer of a contract-info purpose subtree lives at the empty -/// key, and every scoped key covering that contract writes it. Two scoped keys registered in -/// one transition would therefore queue two operations for one slot, which GroveDB rejects -/// under batching consistency verification. The last registered key must win, so the earlier -/// pending write is removed before the new one is queued. -pub(super) fn drop_pending_operation_at( - drive_operations: &mut Vec, - path: &[Vec], - key: &[u8], -) { - let path = KeyInfoPath::from_known_owned_path(path.to_vec()); - let key = KeyInfo::KnownKey(key.to_vec()); - drive_operations.retain(|operation| { - !matches!( - operation, - LowLevelDriveOperation::GroveOperation(QualifiedGroveDbOp { - path: pending_path, - key: Some(pending_key), - .. - }) if *pending_path == path && *pending_key == key - ) +/// Every scoped 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 to the first. +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/v1/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v1/mod.rs index bacb90744d0..d4678b0e19b 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 @@ -1,6 +1,4 @@ -use crate::drive::identity::contract_info::keys::{ - drop_pending_operation_at, IdentityDataContractKeyApplyInfo, -}; +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, @@ -123,6 +121,29 @@ impl Drive { 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 @@ -218,7 +239,6 @@ impl Drive { } else { identity_contract_info_group_keys_path_vec(&identity_id, &root_id) }; - drop_pending_operation_at(drive_operations, &sibling_path, &[]); // 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 @@ -346,7 +366,6 @@ impl Drive { &contract_id_bytes_with_document_type_name, purpose, ); - drop_pending_operation_at(drive_operations, &sibling_path, &[]); // Untrusted for the same reason as the contract-level slot above. self.batch_refresh_reference( 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..9e16f24f9ee 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 { + // Scoped 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 { + // Scoped 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/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..5c0d8292451 --- /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 scoped 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_versions/v9.rs b/packages/rs-platform-version/src/version/drive_versions/v9.rs index 0a83bcc68a0..023df968dcb 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 scoped 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) From 2a066f0909799282b9112f20768aa4b74ccf6481 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 17 Sep 2026 00:04:07 +0700 Subject: [PATCH 13/13] fix(drive): price scoped key revocation from the stored keys disable_identity_keys v0 estimated with a maximal stand-in key that carries no contract bounds, so a scoped key's contract-info reference refreshes (up to one per bounded contract and document type) were absent from the fee estimate that admits the transition. v1, selected by DRIVE_IDENTITY_METHOD_VERSIONS_V2, reads the stored keys in estimation mode as well, which also makes the storage estimate match the applied cost exactly. Regressions: a scoped key's revocation estimate exceeds an unbounded key's and covers its execution cost; the rs-drive key-disable baselines are re-pinned for protocol 14 with protocol 13 twins holding the v0 numbers. Co-Authored-By: Claude Fable 5.1 --- .../methods/disable_identity_keys/mod.rs | 23 ++- .../methods/disable_identity_keys/v1/mod.rs | 185 ++++++++++++++++++ .../rs-drive/src/drive/identity/update/mod.rs | 41 +++- .../drive_identity_method_versions/v2.rs | 5 +- 4 files changed, 248 insertions(+), 6 deletions(-) create mode 100644 packages/rs-drive/src/drive/identity/update/methods/disable_identity_keys/v1/mod.rs 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..f052656b26f --- /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 scoped 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-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 219f888d3db..d3bf73c656a 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 @@ -20,6 +20,9 @@ use crate::version::drive_versions::drive_identity_method_versions::{ /// `contract_info.refresh_potential_contract_info_key_references` 0 -> 1: /// write and refresh scoped 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 +/// scoped 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 @@ -128,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,